Skip to content

refactor: support markviews #1437

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Feb 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions packages/core/src/editor/BlockNoteExtensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ import { Text } from "@tiptap/extension-text";
import { Plugin } from "prosemirror-state";
import * as Y from "yjs";

import type { BlockNoteEditor, BlockNoteExtension } from "./BlockNoteEditor.js";
import { createDropFileExtension } from "../api/clipboard/fromClipboard/fileDropExtension.js";
import { createPasteFromClipboardExtension } from "../api/clipboard/fromClipboard/pasteExtension.js";
import { createCopyToClipboardExtension } from "../api/clipboard/toClipboard/copyExtension.js";
import { BackgroundColorExtension } from "../extensions/BackgroundColor/BackgroundColorExtension.js";
import { createCollaborationExtensions } from "../extensions/Collaboration/createCollaborationExtensions.js";
import { FilePanelProsemirrorPlugin } from "../extensions/FilePanel/FilePanelPlugin.js";
import { FormattingToolbarProsemirrorPlugin } from "../extensions/FormattingToolbar/FormattingToolbarPlugin.js";
import { KeyboardShortcutsExtension } from "../extensions/KeyboardShortcuts/KeyboardShortcutsExtension.js";
Expand Down Expand Up @@ -40,7 +40,7 @@ import {
StyleSchema,
StyleSpecs,
} from "../schema/index.js";
import { createCollaborationExtensions } from "../extensions/Collaboration/createCollaborationExtensions.js";
import type { BlockNoteEditor, BlockNoteExtension } from "./BlockNoteEditor.js";

type ExtensionOptions<
BSchema extends BlockSchema,
Expand Down Expand Up @@ -171,7 +171,9 @@ const getTipTapExtensions = <
protocols: VALID_LINK_PROTOCOLS,
}),
...Object.values(opts.styleSpecs).map((styleSpec) => {
return styleSpec.implementation.mark;
return styleSpec.implementation.mark.configure({
editor: opts.editor as any,
});
}),

TextColorExtension,
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/editor/BlockNoteTipTapEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,13 +152,23 @@ export class BlockNoteTipTapEditor extends TiptapEditor {
private createViewAlternative(contentComponent?: any) {
(this as any).contentComponent = contentComponent;

const markViews: any = {};
this.extensionManager.extensions.forEach((extension) => {
if (extension.type === "mark" && extension.config.addMarkView) {
// Note: migrate to using `addMarkView` from tiptap as soon as this lands
// (currently tiptap doesn't support markviews)
markViews[extension.name] = extension.config.addMarkView;
}
});

this.view = new EditorView(
{ mount: this.options.element as any }, // use mount option so that we reuse the existing element instead of creating a new one
{
...this.options.editorProps,
// @ts-ignore
dispatchTransaction: this.dispatchTransaction.bind(this),
state: this.state,
markViews,
}
);

Expand Down
15 changes: 10 additions & 5 deletions packages/react/src/schema/@util/ReactRenderUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,17 @@ export function renderToDOMSpec(
div
);
} else {
// If no editor is provided, use a temporary root. This is currently only used for Styles (see ReactStyleSpec).
// In this case, react context etc. won't be available inside `fc`

// We also use this if _tiptapEditor or _tiptapEditor.contentComponent is undefined, use a temporary root.
// This is actually a fallback / temporary fix, as normally this shouldn't happen (see #755). TODO: find cause
// If no editor is provided, use a temporary root.
// This is currently only used for when we use ServerBlockNoteEditor (@blocknote/server-util)
// and without using `withReactContext`

if (!editor?.headless) {
throw new Error(
"elementRenderer not available, expected headless editor"
);
}
root = createRoot(div);

flushSync(() => {
root!.render(fc((el) => (contentDOM = el || undefined)));
});
Expand Down
24 changes: 23 additions & 1 deletion packages/react/src/schema/ReactStyleSpec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { Mark } from "@tiptap/react";
import { FC } from "react";
import { renderToDOMSpec } from "./@util/ReactRenderUtil.js";
import { ReactMarkView } from "./markviews/ReactMarkViewRenderer.js";

// this file is mostly analogoues to `customBlocks.ts`, but for React blocks

Expand Down Expand Up @@ -43,9 +44,10 @@ export function createReactStyleSpec<T extends StyleConfig>(
}

const Content = styleImplementation.render;

const renderResult = renderToDOMSpec(
(refCB) => <Content {...props} contentRef={refCB} />,
undefined
this.options.editor
);

return addStyleAttributes(
Expand All @@ -57,6 +59,26 @@ export function createReactStyleSpec<T extends StyleConfig>(
},
});

const markType = mark;

// this is a bit of a hack to register an `addMarkView` function on the mark type
//
// we can clean this once MarkViews land in tiptap
(markType as any).config.addMarkView = (mark: any, view: any) => {
const markView = new ReactMarkView({
editor: markType.child?.options.editor,
inline: true,
mark,
options: {
component: styleImplementation.render,
contentAs: "span",
},
view,
});
markView.render();
return markView;
};

return createInternalStyleSpec(styleConfig, {
mark,
});
Expand Down
108 changes: 108 additions & 0 deletions packages/react/src/schema/markviews/CoreMarkView.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import type { Mark } from "prosemirror-model";
import type {
EditorView,
MarkView,
ViewMutationRecord,
} from "prosemirror-view";

import type { BlockNoteEditor } from "@blocknote/core";
import type {
CoreMarkViewSpec,
CoreMarkViewUserOptions,
MarkViewDOMSpec,
} from "./CoreMarkViewOptions.js";

/* eslint-disable curly */

export class CoreMarkView<ComponentType> implements MarkView {
dom: HTMLElement;
contentDOM: HTMLElement | undefined;
mark: Mark;
view: EditorView;
inline: boolean;
options: CoreMarkViewUserOptions<ComponentType>;
editor: BlockNoteEditor<any, any, any>;

#createElement(as?: MarkViewDOMSpec) {
const { inline, mark } = this;
return as == null
? document.createElement(inline ? "span" : "div")
: as instanceof HTMLElement
? as
: as instanceof Function
? as(mark)
: document.createElement(as);
}

createDOM(as?: MarkViewDOMSpec) {
return this.#createElement(as);
}

createContentDOM(as?: MarkViewDOMSpec) {
return this.#createElement(as);
}

constructor({
mark,
view,
inline,
options,
editor, // BlockNote specific
}: CoreMarkViewSpec<ComponentType>) {
this.mark = mark;
this.view = view;
this.inline = inline;
this.options = options;
this.editor = editor;

this.dom = this.createDOM(options.as);
this.contentDOM = options.contentAs
? this.createContentDOM(options.contentAs)
: undefined;
this.dom.setAttribute("data-mark-view-root", "true");
if (this.contentDOM) {
this.contentDOM.setAttribute("data-mark-view-content", "true");
this.contentDOM.style.whiteSpace = "inherit";
}
}

get component() {
return this.options.component;
}

shouldIgnoreMutation: (mutation: ViewMutationRecord) => boolean = (
mutation
) => {
if (!this.dom || !this.contentDOM) return true;

if (mutation.type === "selection") return false;

if (this.contentDOM === mutation.target && mutation.type === "attributes")
return true;

if (this.contentDOM.contains(mutation.target)) return false;

return true;
};

ignoreMutation: (mutation: ViewMutationRecord) => boolean = (mutation) => {
if (!this.dom || !this.contentDOM) return true;

let result;

const userIgnoreMutation = this.options.ignoreMutation;

if (userIgnoreMutation) result = userIgnoreMutation(mutation);

if (typeof result !== "boolean")
result = this.shouldIgnoreMutation(mutation);

return result;
};

public destroy() {
this.options.destroy?.();
this.dom.remove();
this.contentDOM?.remove();
}
}
32 changes: 32 additions & 0 deletions packages/react/src/schema/markviews/CoreMarkViewOptions.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, this is subtly incompatible with the implementation that I wrote for Tiptap V3: https://github.com/ueberdosis/tiptap/blob/next/packages/react/src/ReactMarkViewRenderer.tsx

May be worth reviewing the differences and seeing if we should upstream some of this or if we should follow the one I wrote for V3.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any specific parts that seem incompatible? We don't expose markviews directly (only indirectly via React custom Styles), so I wonder if it's an issue. Of course, I could see if I can swap out the current implementation for the one you linked to

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked into it more, specifically some of the CoreMarkViewUserOptions (as and contentAs), but it does not look like it is even used here so it should be fine.

We can deal with this later when we either want to expose this integration or migrate to the Tiptap V3 implementation of it. So, let's put it off until later

Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { BlockNoteEditor } from "@blocknote/core";
import type { Mark } from "prosemirror-model";
import type { EditorView, ViewMutationRecord } from "prosemirror-view";

export type MarkViewDOMSpec =
| string
| HTMLElement
| ((mark: Mark) => HTMLElement);

export interface CoreMarkViewUserOptions<Component> {
// DOM
as?: MarkViewDOMSpec;
contentAs?: MarkViewDOMSpec;

// Component
component: Component;

// Overrides
ignoreMutation?: (mutation: ViewMutationRecord) => boolean | void;
destroy?: () => void;
}

export interface CoreMarkViewSpec<Component> {
mark: Mark;
view: EditorView;
inline: boolean;

options: CoreMarkViewUserOptions<Component>;

// BlockNote specific
editor: BlockNoteEditor<any, any, any>;
}
1 change: 1 addition & 0 deletions packages/react/src/schema/markviews/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The implementation of MarkViews in this directory is based on `prosemirror-adapter`. We might want to migrate to the Tiptap implementation of MarkViews once this lands in Tiptap v3.
16 changes: 16 additions & 0 deletions packages/react/src/schema/markviews/ReactMarkViewOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { FC } from "react";
import type {
CoreMarkViewSpec,
CoreMarkViewUserOptions,
} from "./CoreMarkViewOptions.js";

// export type ReactMarkViewComponent = ComponentType<Record<string, never>>;

export type ReactMarkViewComponent =
| FC<{ contentRef: (el: HTMLElement | null) => void }>
| FC<{ contentRef: (el: HTMLElement | null) => void; value: string }>;

export type ReactMarkViewSpec = CoreMarkViewSpec<ReactMarkViewComponent>;

export type ReactMarkViewUserOptions =
CoreMarkViewUserOptions<ReactMarkViewComponent>;
70 changes: 70 additions & 0 deletions packages/react/src/schema/markviews/ReactMarkViewRenderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { CoreMarkView } from "./CoreMarkView.js";
import type { MarkViewContext } from "./markViewContext.js";
import type { ReactMarkViewComponent } from "./ReactMarkViewOptions.js";

/* eslint-disable curly */

export class ReactMarkView extends CoreMarkView<ReactMarkViewComponent> {
// implements ReactRenderer<MarkViewContext>
// key: string = nanoid();
id = Math.floor(Math.random() * 0xffffffff).toString();

context: MarkViewContext = {
contentRef: (element) => {
if (element && this.contentDOM && element.firstChild !== this.contentDOM)
element.appendChild(this.contentDOM);
},
view: this.view,

mark: this.mark,
};

updateContext = () => {
Object.assign(this.context, {
mark: this.mark,
});
};

// render = () => {
// const UserComponent = this.component;

// return createPortal(
// <markViewContext.Provider value={this.context}>
// <UserComponent />
// </markViewContext.Provider>,
// this.dom,
// this.key
// );
// };

render = () => {
this.editor._tiptapEditor.contentComponent.setRenderer(
this.id,
this.renderer()
);
};

destroy = () => {
super.destroy();
this.editor._tiptapEditor.contentComponent.removeRenderer(this.id);
};

renderer = () => {
const UserComponent = this.component;

const props: any = {};

if (this.mark.attrs.stringValue) {
props.value = this.mark.attrs.stringValue;
}

return {
reactElement: (
// <markViewContext.Provider value={this.context}>
<UserComponent contentRef={this.context.contentRef} {...props} />
// </markViewContext.Provider>
),
element: this.dom,
};
};
}
33 changes: 33 additions & 0 deletions packages/react/src/schema/markviews/markViewContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { Mark } from "prosemirror-model";
import type { EditorView, MarkViewConstructor } from "prosemirror-view";
import { createContext, useContext } from "react";
import type { ReactMarkViewUserOptions } from "./ReactMarkViewOptions.js";

export type MarkViewContentRef = (node: HTMLElement | null) => void;

export interface MarkViewContext {
// won't change
contentRef: MarkViewContentRef;
view: EditorView;
mark: Mark;
}

export const markViewContext = createContext<MarkViewContext>({
contentRef: () => {
// nothing to do
},
view: null as never,
mark: null as never,
});

export const useMarkViewContext = () => useContext(markViewContext);

export const createMarkViewContext = createContext<
(options: ReactMarkViewUserOptions) => MarkViewConstructor
>((_options) => {
throw new Error(
"No ProsemirrorAdapterProvider detected, maybe you need to wrap the component with the Editor with ProsemirrorAdapterProvider?"
);
});

export const useMarkViewFactory = () => useContext(createMarkViewContext);
Loading