From c6634d2c120697c9b328eaee2392cae808b25d9f Mon Sep 17 00:00:00 2001 From: yousefed Date: Mon, 17 Feb 2025 11:40:56 +0100 Subject: [PATCH 1/7] refactor: new react mounting system --- packages/core/src/editor/BlockNoteEditor.ts | 7 +- .../core/src/editor/BlockNoteTipTapEditor.ts | 60 ++++----- packages/react/src/editor/BlockNoteView.tsx | 20 ++- packages/react/src/editor/EditorContent.tsx | 116 +++++++++--------- 4 files changed, 106 insertions(+), 97 deletions(-) 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..de0193a846 100644 --- a/packages/core/src/editor/BlockNoteTipTapEditor.ts +++ b/packages/core/src/editor/BlockNoteTipTapEditor.ts @@ -24,7 +24,7 @@ export type BlockNoteTipTapEditorOptions = Partial< // @ts-ignore export class BlockNoteTipTapEditor extends TiptapEditor { private _state: EditorState; - private _creating = false; + public static create = ( options: BlockNoteTipTapEditorOptions, styleSchema: StyleSchema @@ -150,40 +150,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 +185,13 @@ 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); } }; } 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)}; +}; From 6b201c752e521f2bbfc6a77cab0291d93e5d13e4 Mon Sep 17 00:00:00 2001 From: yousefed Date: Mon, 17 Feb 2025 11:52:46 +0100 Subject: [PATCH 2/7] remove ts ignores --- packages/core/src/editor/BlockNoteTipTapEditor.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/core/src/editor/BlockNoteTipTapEditor.ts b/packages/core/src/editor/BlockNoteTipTapEditor.ts index de0193a846..f786f3f36f 100644 --- a/packages/core/src/editor/BlockNoteTipTapEditor.ts +++ b/packages/core/src/editor/BlockNoteTipTapEditor.ts @@ -21,7 +21,6 @@ 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; @@ -190,7 +189,6 @@ export class BlockNoteTipTapEditor extends TiptapEditor { this.destroy(); } else { this.options.element = element; - // @ts-ignore this.createViewAlternative(contentComponent); } }; From 2d6c856de63edfc2c4b680ef3e6634a6052ce49a Mon Sep 17 00:00:00 2001 From: yousefed Date: Mon, 17 Feb 2025 11:52:51 +0100 Subject: [PATCH 3/7] additional cleanup --- packages/core/src/editor/BlockNoteTipTapEditor.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/core/src/editor/BlockNoteTipTapEditor.ts b/packages/core/src/editor/BlockNoteTipTapEditor.ts index f786f3f36f..ccf8c736a8 100644 --- a/packages/core/src/editor/BlockNoteTipTapEditor.ts +++ b/packages/core/src/editor/BlockNoteTipTapEditor.ts @@ -193,12 +193,3 @@ export class BlockNoteTipTapEditor extends TiptapEditor { } }; } - -(BlockNoteTipTapEditor.prototype as any).createView = function () { - // no-op - // Disable default call to `createView` in the Editor constructor. - // 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 - this.options.onPaste = this.options.onDrop = undefined; -}; From 10d15a0651af270ca4cef6798373f30b86940bbc Mon Sep 17 00:00:00 2001 From: yousefed Date: Mon, 17 Feb 2025 11:54:17 +0100 Subject: [PATCH 4/7] fix --- packages/core/src/editor/BlockNoteTipTapEditor.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/core/src/editor/BlockNoteTipTapEditor.ts b/packages/core/src/editor/BlockNoteTipTapEditor.ts index ccf8c736a8..1f43217873 100644 --- a/packages/core/src/editor/BlockNoteTipTapEditor.ts +++ b/packages/core/src/editor/BlockNoteTipTapEditor.ts @@ -193,3 +193,9 @@ export class BlockNoteTipTapEditor extends TiptapEditor { } }; } + +(BlockNoteTipTapEditor.prototype as any).createView = function () { + // no-op + // Disable default call to `createView` in the Editor constructor. + // We should call `createView` manually only when a DOM element is available +}; From e60fcc734577651bd235b744397ca99c6704841e Mon Sep 17 00:00:00 2001 From: yousefed Date: Mon, 17 Feb 2025 13:11:53 +0100 Subject: [PATCH 5/7] revert --- .../core/src/editor/BlockNoteTipTapEditor.ts | 66 +++++++++++-------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/packages/core/src/editor/BlockNoteTipTapEditor.ts b/packages/core/src/editor/BlockNoteTipTapEditor.ts index 1f43217873..83ef2a6ab0 100644 --- a/packages/core/src/editor/BlockNoteTipTapEditor.ts +++ b/packages/core/src/editor/BlockNoteTipTapEditor.ts @@ -21,9 +21,10 @@ 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 @@ -149,34 +150,40 @@ export class BlockNoteTipTapEditor extends TiptapEditor { /** * Replace the default `createView` method with a custom one - which we call on mount */ - 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, + private createViewAlternative() { + this._creating = true; + // Without queueMicrotask, custom IC / styles will give a React FlushSync error + queueMicrotask(() => { + if (!this._creating) { + return; } - ); + 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; + // 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; + }); } /** @@ -184,12 +191,15 @@ export class BlockNoteTipTapEditor extends TiptapEditor { * * @param element DOM element to mount to, ur null / undefined to destroy */ - public mount = (element?: HTMLElement | null, contentComponent?: any) => { + public mount = (element?: HTMLElement | null) => { if (!element) { this.destroy(); + // cancel pending microtask + this._creating = false; } else { this.options.element = element; - this.createViewAlternative(contentComponent); + // @ts-ignore + this.createViewAlternative(); } }; } @@ -198,4 +208,8 @@ export class BlockNoteTipTapEditor extends TiptapEditor { // no-op // Disable default call to `createView` in the Editor constructor. // 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) + this.options.onPaste = this.options.onDrop = undefined; }; From 758d2bd6d3bf916e1cbe098e67a63e9cab509b65 Mon Sep 17 00:00:00 2001 From: yousefed Date: Mon, 17 Feb 2025 13:12:09 +0100 Subject: [PATCH 6/7] add comment --- packages/core/src/editor/BlockNoteTipTapEditor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/editor/BlockNoteTipTapEditor.ts b/packages/core/src/editor/BlockNoteTipTapEditor.ts index 83ef2a6ab0..30ea4562d4 100644 --- a/packages/core/src/editor/BlockNoteTipTapEditor.ts +++ b/packages/core/src/editor/BlockNoteTipTapEditor.ts @@ -210,6 +210,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) + // (note: can probably be removed after tiptap upgrade fixed in 2.8.0) this.options.onPaste = this.options.onDrop = undefined; }; From aeccf1db2234c630c845aca6c6da548fb9681c38 Mon Sep 17 00:00:00 2001 From: yousefed Date: Mon, 17 Feb 2025 13:32:51 +0100 Subject: [PATCH 7/7] fix --- .../core/src/editor/BlockNoteTipTapEditor.ts | 62 ++++++++----------- 1 file changed, 26 insertions(+), 36 deletions(-) diff --git a/packages/core/src/editor/BlockNoteTipTapEditor.ts b/packages/core/src/editor/BlockNoteTipTapEditor.ts index 30ea4562d4..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); } }; }