diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index e2ae4a7256..0178da4936 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -589,8 +589,11 @@ export class BlockNoteEditor< * * @warning Not needed to call manually when using React, use BlockNoteView to take care of mounting */ - public mount = (parentElement?: HTMLElement | null) => { - this._tiptapEditor.mount(parentElement); + public mount = ( + parentElement?: HTMLElement | null, + contentComponent?: any + ) => { + this._tiptapEditor.mount(parentElement, contentComponent); }; public get prosemirrorView() { diff --git a/packages/core/src/editor/BlockNoteTipTapEditor.ts b/packages/core/src/editor/BlockNoteTipTapEditor.ts index f4505d8153..de30b90047 100644 --- a/packages/core/src/editor/BlockNoteTipTapEditor.ts +++ b/packages/core/src/editor/BlockNoteTipTapEditor.ts @@ -21,10 +21,9 @@ export type BlockNoteTipTapEditorOptions = Partial< * Custom Editor class that extends TiptapEditor and separates * the creation of the view from the constructor. */ -// @ts-ignore export class BlockNoteTipTapEditor extends TiptapEditor { private _state: EditorState; - private _creating = false; + public static create = ( options: BlockNoteTipTapEditorOptions, styleSchema: StyleSchema @@ -150,40 +149,34 @@ export class BlockNoteTipTapEditor extends TiptapEditor { /** * Replace the default `createView` method with a custom one - which we call on mount */ - private createViewAlternative() { - this._creating = true; - // Without queueMicrotask, custom IC / styles will give a React FlushSync error - queueMicrotask(() => { - if (!this._creating) { - return; + private createViewAlternative(contentComponent?: any) { + (this as any).contentComponent = contentComponent; + + 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, } - 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, - } - ); + ); - // `editor.view` is not yet available at this time. - // Therefore we will add all plugins and node views directly afterwards. - const newState = this.state.reconfigure({ - plugins: this.extensionManager.plugins, - }); + // `editor.view` is not yet available at this time. + // Therefore we will add all plugins and node views directly afterwards. + const newState = this.state.reconfigure({ + plugins: this.extensionManager.plugins, + }); - this.view.updateState(newState); + this.view.updateState(newState); - this.createNodeViews(); + this.createNodeViews(); - // emit the created event, call here manually because we blocked the default call in the constructor - // (https://github.com/ueberdosis/tiptap/blob/45bac803283446795ad1b03f43d3746fa54a68ff/packages/core/src/Editor.ts#L117) - this.commands.focus(this.options.autofocus); - this.emit("create", { editor: this }); - this.isInitialized = true; - this._creating = false; - }); + // emit the created event, call here manually because we blocked the default call in the constructor + // (https://github.com/ueberdosis/tiptap/blob/45bac803283446795ad1b03f43d3746fa54a68ff/packages/core/src/Editor.ts#L117) + this.commands.focus(this.options.autofocus); + this.emit("create", { editor: this }); + this.isInitialized = true; } /** @@ -191,15 +184,12 @@ export class BlockNoteTipTapEditor extends TiptapEditor { * * @param element DOM element to mount to, ur null / undefined to destroy */ - public mount = (element?: HTMLElement | null) => { + public mount = (element?: HTMLElement | null, contentComponent?: any) => { if (!element) { this.destroy(); - // cancel pending microtask - this._creating = false; } else { this.options.element = element; - // @ts-ignore - this.createViewAlternative(); + this.createViewAlternative(contentComponent); } }; } @@ -210,5 +200,6 @@ export class BlockNoteTipTapEditor extends TiptapEditor { // We should call `createView` manually only when a DOM element is available // additional fix because onPaste and onDrop depend on installing plugins in constructor which we don't support + // (note: can probably be removed after tiptap upgrade fixed in 2.8.0) this.options.onPaste = this.options.onDrop = undefined; }; diff --git a/packages/react/src/editor/BlockNoteView.tsx b/packages/react/src/editor/BlockNoteView.tsx index 7423810eb9..a18b10e49d 100644 --- a/packages/react/src/editor/BlockNoteView.tsx +++ b/packages/react/src/editor/BlockNoteView.tsx @@ -23,7 +23,7 @@ import { BlockNoteDefaultUI, BlockNoteDefaultUIProps, } from "./BlockNoteDefaultUI.js"; -import { EditorContent } from "./EditorContent.js"; +import { Portals, getContentComponent } from "./EditorContent.js"; import { ElementRenderer } from "./ElementRenderer.js"; import "./styles.css"; @@ -150,11 +150,23 @@ function BlockNoteViewComponent< [editor] ); + const portalManager = useMemo(() => { + return getContentComponent(); + }, []); + + const mount = useCallback( + (element: HTMLElement | null) => { + editor.mount(element, portalManager); + }, + [editor, portalManager] + ); + return ( {!editor.headless && ( - + <> +
{renderChildren}
-
+ )}
); diff --git a/packages/react/src/editor/EditorContent.tsx b/packages/react/src/editor/EditorContent.tsx index 1bd6c9accb..10727789b4 100644 --- a/packages/react/src/editor/EditorContent.tsx +++ b/packages/react/src/editor/EditorContent.tsx @@ -1,67 +1,69 @@ -import { BlockNoteEditor } from "@blocknote/core"; import { ReactRenderer } from "@tiptap/react"; -import { useEffect, useState } from "react"; +import { useSyncExternalStore } from "react"; import { createPortal } from "react-dom"; -const Portals: React.FC<{ renderers: Record }> = ({ - renderers, -}) => { - return ( - <> - {Object.entries(renderers).map(([key, renderer]) => { - return createPortal(renderer.reactElement, renderer.element, key); - })} - - ); -}; +// this file takes the methods we need from +// https://github.com/ueberdosis/tiptap/blob/develop/packages/react/src/EditorContent.tsx -/** - * Replacement of https://github.com/ueberdosis/tiptap/blob/6676c7e034a46117afdde560a1b25fe75411a21d/packages/react/src/EditorContent.tsx - * that only takes care of the Portals. - * - * Original implementation is messy, and we use a "mount" system in BlockNoteTiptapEditor.tsx that makes this cleaner - */ -export function EditorContent(props: { - editor: BlockNoteEditor; - children: any; -}) { - const [renderers, setRenderers] = useState>({}); +export function getContentComponent() { + const subscribers = new Set<() => void>(); + let renderers: Record = {}; - useEffect(() => { - props.editor._tiptapEditor.contentComponent = { - /** - * Used by TipTap - */ - setRenderer(id: string, renderer: ReactRenderer) { - setRenderers((renderers) => ({ ...renderers, [id]: renderer })); - }, + return { + /** + * Subscribe to the editor instance's changes. + */ + subscribe(callback: () => void) { + subscribers.add(callback); + return () => { + subscribers.delete(callback); + }; + }, + getSnapshot() { + return renderers; + }, + getServerSnapshot() { + return renderers; + }, + /** + * Adds a new NodeView Renderer to the editor. + */ + setRenderer(id: string, renderer: ReactRenderer) { + renderers = { + ...renderers, + [id]: createPortal(renderer.reactElement, renderer.element, id), + }; - /** - * Used by TipTap - */ - removeRenderer(id: string) { - setRenderers((renderers) => { - const nextRenderers = { ...renderers }; + subscribers.forEach((subscriber) => subscriber()); + }, + /** + * Removes a NodeView Renderer from the editor. + */ + removeRenderer(id: string) { + const nextRenderers = { ...renderers }; - delete nextRenderers[id]; + delete nextRenderers[id]; + renderers = nextRenderers; + subscribers.forEach((subscriber) => subscriber()); + }, + }; +} - return nextRenderers; - }); - }, - }; - // Without queueMicrotask, custom IC / styles will give a React FlushSync error - queueMicrotask(() => { - props.editor._tiptapEditor.createNodeViews(); - }); - return () => { - props.editor._tiptapEditor.contentComponent = null; - }; - }, [props.editor._tiptapEditor]); +type ContentComponent = ReturnType; - return ( - <> - - {props.children} - +/** + * This component renders all of the editor's node views. + */ +export const Portals: React.FC<{ contentComponent: ContentComponent }> = ({ + contentComponent, +}) => { + // For performance reasons, we render the node view portals on state changes only + const renderers = useSyncExternalStore( + contentComponent.subscribe, + contentComponent.getSnapshot, + contentComponent.getServerSnapshot ); -} + + // This allows us to directly render the portals without any additional wrapper + return <>{Object.values(renderers)}; +};