diff --git a/packages/core/src/editor/BlockNoteExtensions.ts b/packages/core/src/editor/BlockNoteExtensions.ts index 28459bb456..d44d42480f 100644 --- a/packages/core/src/editor/BlockNoteExtensions.ts +++ b/packages/core/src/editor/BlockNoteExtensions.ts @@ -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"; @@ -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, @@ -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, diff --git a/packages/core/src/editor/BlockNoteTipTapEditor.ts b/packages/core/src/editor/BlockNoteTipTapEditor.ts index de30b90047..458a22a4c2 100644 --- a/packages/core/src/editor/BlockNoteTipTapEditor.ts +++ b/packages/core/src/editor/BlockNoteTipTapEditor.ts @@ -152,6 +152,15 @@ 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 { @@ -159,6 +168,7 @@ export class BlockNoteTipTapEditor extends TiptapEditor { // @ts-ignore dispatchTransaction: this.dispatchTransaction.bind(this), state: this.state, + markViews, } ); diff --git a/packages/react/src/schema/@util/ReactRenderUtil.ts b/packages/react/src/schema/@util/ReactRenderUtil.ts index 81c4e8bae9..2f2aaf6c9d 100644 --- a/packages/react/src/schema/@util/ReactRenderUtil.ts +++ b/packages/react/src/schema/@util/ReactRenderUtil.ts @@ -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))); }); diff --git a/packages/react/src/schema/ReactStyleSpec.tsx b/packages/react/src/schema/ReactStyleSpec.tsx index 72843d2866..43c56a3ce2 100644 --- a/packages/react/src/schema/ReactStyleSpec.tsx +++ b/packages/react/src/schema/ReactStyleSpec.tsx @@ -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 @@ -43,9 +44,10 @@ export function createReactStyleSpec( } const Content = styleImplementation.render; + const renderResult = renderToDOMSpec( (refCB) => , - undefined + this.options.editor ); return addStyleAttributes( @@ -57,6 +59,26 @@ export function createReactStyleSpec( }, }); + 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, }); diff --git a/packages/react/src/schema/markviews/CoreMarkView.ts b/packages/react/src/schema/markviews/CoreMarkView.ts new file mode 100644 index 0000000000..64cf4eb034 --- /dev/null +++ b/packages/react/src/schema/markviews/CoreMarkView.ts @@ -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 implements MarkView { + dom: HTMLElement; + contentDOM: HTMLElement | undefined; + mark: Mark; + view: EditorView; + inline: boolean; + options: CoreMarkViewUserOptions; + editor: BlockNoteEditor; + + #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) { + 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(); + } +} diff --git a/packages/react/src/schema/markviews/CoreMarkViewOptions.ts b/packages/react/src/schema/markviews/CoreMarkViewOptions.ts new file mode 100644 index 0000000000..dca5fa4909 --- /dev/null +++ b/packages/react/src/schema/markviews/CoreMarkViewOptions.ts @@ -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 { + // DOM + as?: MarkViewDOMSpec; + contentAs?: MarkViewDOMSpec; + + // Component + component: Component; + + // Overrides + ignoreMutation?: (mutation: ViewMutationRecord) => boolean | void; + destroy?: () => void; +} + +export interface CoreMarkViewSpec { + mark: Mark; + view: EditorView; + inline: boolean; + + options: CoreMarkViewUserOptions; + + // BlockNote specific + editor: BlockNoteEditor; +} diff --git a/packages/react/src/schema/markviews/README.md b/packages/react/src/schema/markviews/README.md new file mode 100644 index 0000000000..524902753f --- /dev/null +++ b/packages/react/src/schema/markviews/README.md @@ -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. diff --git a/packages/react/src/schema/markviews/ReactMarkViewOptions.ts b/packages/react/src/schema/markviews/ReactMarkViewOptions.ts new file mode 100644 index 0000000000..d61350cb56 --- /dev/null +++ b/packages/react/src/schema/markviews/ReactMarkViewOptions.ts @@ -0,0 +1,16 @@ +import type { FC } from "react"; +import type { + CoreMarkViewSpec, + CoreMarkViewUserOptions, +} from "./CoreMarkViewOptions.js"; + +// export type ReactMarkViewComponent = ComponentType>; + +export type ReactMarkViewComponent = + | FC<{ contentRef: (el: HTMLElement | null) => void }> + | FC<{ contentRef: (el: HTMLElement | null) => void; value: string }>; + +export type ReactMarkViewSpec = CoreMarkViewSpec; + +export type ReactMarkViewUserOptions = + CoreMarkViewUserOptions; diff --git a/packages/react/src/schema/markviews/ReactMarkViewRenderer.tsx b/packages/react/src/schema/markviews/ReactMarkViewRenderer.tsx new file mode 100644 index 0000000000..876969a64a --- /dev/null +++ b/packages/react/src/schema/markviews/ReactMarkViewRenderer.tsx @@ -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 { + // implements ReactRenderer + // 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( + // + // + // , + // 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: ( + // + + // + ), + element: this.dom, + }; + }; +} diff --git a/packages/react/src/schema/markviews/markViewContext.ts b/packages/react/src/schema/markviews/markViewContext.ts new file mode 100644 index 0000000000..ce5254b4e5 --- /dev/null +++ b/packages/react/src/schema/markviews/markViewContext.ts @@ -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({ + 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);