Skip to content

refactor: new react mounting system #1438

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 7 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
7 changes: 5 additions & 2 deletions packages/core/src/editor/BlockNoteEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
63 changes: 27 additions & 36 deletions packages/core/src/editor/BlockNoteTipTapEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -150,56 +149,47 @@ 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;
}

/**
* Mounts / unmounts the editor to a dom element
*
* @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);
}
};
}
Expand All @@ -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;
};
20 changes: 16 additions & 4 deletions packages/react/src/editor/BlockNoteView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 (
<BlockNoteContext.Provider value={context as any}>
<ElementRenderer ref={setElementRenderer} />
{!editor.headless && (
<EditorContent editor={editor}>
<>
<Portals contentComponent={portalManager} />
<div
className={mergeCSSClasses(
"bn-container",
Expand All @@ -167,12 +179,12 @@ function BlockNoteViewComponent<
<div
aria-autocomplete="list"
aria-haspopup="listbox"
ref={editor.mount}
ref={mount}
{...contentEditableProps}
/>
{renderChildren}
</div>
</EditorContent>
</>
)}
</BlockNoteContext.Provider>
);
Expand Down
116 changes: 59 additions & 57 deletions packages/react/src/editor/EditorContent.tsx
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice code 😂

Original file line number Diff line number Diff line change
@@ -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<string, ReactRenderer> }> = ({
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<any, any, any>;
children: any;
}) {
const [renderers, setRenderers] = useState<Record<string, ReactRenderer>>({});
export function getContentComponent() {
const subscribers = new Set<() => void>();
let renderers: Record<string, React.ReactPortal> = {};

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<typeof getContentComponent>;

return (
<>
<Portals renderers={renderers} />
{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)}</>;
};
Loading