Skip to content

Commit 61c3e6d

Browse files
authored
refactor: new react mounting system (#1438)
* refactor: new react mounting system * remove ts ignores * additional cleanup * fix * revert * add comment * fix
1 parent b829a47 commit 61c3e6d

File tree

4 files changed

+107
-99
lines changed

4 files changed

+107
-99
lines changed

packages/core/src/editor/BlockNoteEditor.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -589,8 +589,11 @@ export class BlockNoteEditor<
589589
*
590590
* @warning Not needed to call manually when using React, use BlockNoteView to take care of mounting
591591
*/
592-
public mount = (parentElement?: HTMLElement | null) => {
593-
this._tiptapEditor.mount(parentElement);
592+
public mount = (
593+
parentElement?: HTMLElement | null,
594+
contentComponent?: any
595+
) => {
596+
this._tiptapEditor.mount(parentElement, contentComponent);
594597
};
595598

596599
public get prosemirrorView() {

packages/core/src/editor/BlockNoteTipTapEditor.ts

Lines changed: 27 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,9 @@ export type BlockNoteTipTapEditorOptions = Partial<
2121
* Custom Editor class that extends TiptapEditor and separates
2222
* the creation of the view from the constructor.
2323
*/
24-
// @ts-ignore
2524
export class BlockNoteTipTapEditor extends TiptapEditor {
2625
private _state: EditorState;
27-
private _creating = false;
26+
2827
public static create = (
2928
options: BlockNoteTipTapEditorOptions,
3029
styleSchema: StyleSchema
@@ -150,56 +149,47 @@ export class BlockNoteTipTapEditor extends TiptapEditor {
150149
/**
151150
* Replace the default `createView` method with a custom one - which we call on mount
152151
*/
153-
private createViewAlternative() {
154-
this._creating = true;
155-
// Without queueMicrotask, custom IC / styles will give a React FlushSync error
156-
queueMicrotask(() => {
157-
if (!this._creating) {
158-
return;
152+
private createViewAlternative(contentComponent?: any) {
153+
(this as any).contentComponent = contentComponent;
154+
155+
this.view = new EditorView(
156+
{ mount: this.options.element as any }, // use mount option so that we reuse the existing element instead of creating a new one
157+
{
158+
...this.options.editorProps,
159+
// @ts-ignore
160+
dispatchTransaction: this.dispatchTransaction.bind(this),
161+
state: this.state,
159162
}
160-
this.view = new EditorView(
161-
{ mount: this.options.element as any }, // use mount option so that we reuse the existing element instead of creating a new one
162-
{
163-
...this.options.editorProps,
164-
// @ts-ignore
165-
dispatchTransaction: this.dispatchTransaction.bind(this),
166-
state: this.state,
167-
}
168-
);
163+
);
169164

170-
// `editor.view` is not yet available at this time.
171-
// Therefore we will add all plugins and node views directly afterwards.
172-
const newState = this.state.reconfigure({
173-
plugins: this.extensionManager.plugins,
174-
});
165+
// `editor.view` is not yet available at this time.
166+
// Therefore we will add all plugins and node views directly afterwards.
167+
const newState = this.state.reconfigure({
168+
plugins: this.extensionManager.plugins,
169+
});
175170

176-
this.view.updateState(newState);
171+
this.view.updateState(newState);
177172

178-
this.createNodeViews();
173+
this.createNodeViews();
179174

180-
// emit the created event, call here manually because we blocked the default call in the constructor
181-
// (https://github.com/ueberdosis/tiptap/blob/45bac803283446795ad1b03f43d3746fa54a68ff/packages/core/src/Editor.ts#L117)
182-
this.commands.focus(this.options.autofocus);
183-
this.emit("create", { editor: this });
184-
this.isInitialized = true;
185-
this._creating = false;
186-
});
175+
// emit the created event, call here manually because we blocked the default call in the constructor
176+
// (https://github.com/ueberdosis/tiptap/blob/45bac803283446795ad1b03f43d3746fa54a68ff/packages/core/src/Editor.ts#L117)
177+
this.commands.focus(this.options.autofocus);
178+
this.emit("create", { editor: this });
179+
this.isInitialized = true;
187180
}
188181

189182
/**
190183
* Mounts / unmounts the editor to a dom element
191184
*
192185
* @param element DOM element to mount to, ur null / undefined to destroy
193186
*/
194-
public mount = (element?: HTMLElement | null) => {
187+
public mount = (element?: HTMLElement | null, contentComponent?: any) => {
195188
if (!element) {
196189
this.destroy();
197-
// cancel pending microtask
198-
this._creating = false;
199190
} else {
200191
this.options.element = element;
201-
// @ts-ignore
202-
this.createViewAlternative();
192+
this.createViewAlternative(contentComponent);
203193
}
204194
};
205195
}
@@ -210,5 +200,6 @@ export class BlockNoteTipTapEditor extends TiptapEditor {
210200
// We should call `createView` manually only when a DOM element is available
211201

212202
// additional fix because onPaste and onDrop depend on installing plugins in constructor which we don't support
203+
// (note: can probably be removed after tiptap upgrade fixed in 2.8.0)
213204
this.options.onPaste = this.options.onDrop = undefined;
214205
};

packages/react/src/editor/BlockNoteView.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
BlockNoteDefaultUI,
2424
BlockNoteDefaultUIProps,
2525
} from "./BlockNoteDefaultUI.js";
26-
import { EditorContent } from "./EditorContent.js";
26+
import { Portals, getContentComponent } from "./EditorContent.js";
2727
import { ElementRenderer } from "./ElementRenderer.js";
2828
import "./styles.css";
2929

@@ -150,11 +150,23 @@ function BlockNoteViewComponent<
150150
[editor]
151151
);
152152

153+
const portalManager = useMemo(() => {
154+
return getContentComponent();
155+
}, []);
156+
157+
const mount = useCallback(
158+
(element: HTMLElement | null) => {
159+
editor.mount(element, portalManager);
160+
},
161+
[editor, portalManager]
162+
);
163+
153164
return (
154165
<BlockNoteContext.Provider value={context as any}>
155166
<ElementRenderer ref={setElementRenderer} />
156167
{!editor.headless && (
157-
<EditorContent editor={editor}>
168+
<>
169+
<Portals contentComponent={portalManager} />
158170
<div
159171
className={mergeCSSClasses(
160172
"bn-container",
@@ -167,12 +179,12 @@ function BlockNoteViewComponent<
167179
<div
168180
aria-autocomplete="list"
169181
aria-haspopup="listbox"
170-
ref={editor.mount}
182+
ref={mount}
171183
{...contentEditableProps}
172184
/>
173185
{renderChildren}
174186
</div>
175-
</EditorContent>
187+
</>
176188
)}
177189
</BlockNoteContext.Provider>
178190
);
Lines changed: 59 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,69 @@
1-
import { BlockNoteEditor } from "@blocknote/core";
21
import { ReactRenderer } from "@tiptap/react";
3-
import { useEffect, useState } from "react";
2+
import { useSyncExternalStore } from "react";
43
import { createPortal } from "react-dom";
54

6-
const Portals: React.FC<{ renderers: Record<string, ReactRenderer> }> = ({
7-
renderers,
8-
}) => {
9-
return (
10-
<>
11-
{Object.entries(renderers).map(([key, renderer]) => {
12-
return createPortal(renderer.reactElement, renderer.element, key);
13-
})}
14-
</>
15-
);
16-
};
5+
// this file takes the methods we need from
6+
// https://github.com/ueberdosis/tiptap/blob/develop/packages/react/src/EditorContent.tsx
177

18-
/**
19-
* Replacement of https://github.com/ueberdosis/tiptap/blob/6676c7e034a46117afdde560a1b25fe75411a21d/packages/react/src/EditorContent.tsx
20-
* that only takes care of the Portals.
21-
*
22-
* Original implementation is messy, and we use a "mount" system in BlockNoteTiptapEditor.tsx that makes this cleaner
23-
*/
24-
export function EditorContent(props: {
25-
editor: BlockNoteEditor<any, any, any>;
26-
children: any;
27-
}) {
28-
const [renderers, setRenderers] = useState<Record<string, ReactRenderer>>({});
8+
export function getContentComponent() {
9+
const subscribers = new Set<() => void>();
10+
let renderers: Record<string, React.ReactPortal> = {};
2911

30-
useEffect(() => {
31-
props.editor._tiptapEditor.contentComponent = {
32-
/**
33-
* Used by TipTap
34-
*/
35-
setRenderer(id: string, renderer: ReactRenderer) {
36-
setRenderers((renderers) => ({ ...renderers, [id]: renderer }));
37-
},
12+
return {
13+
/**
14+
* Subscribe to the editor instance's changes.
15+
*/
16+
subscribe(callback: () => void) {
17+
subscribers.add(callback);
18+
return () => {
19+
subscribers.delete(callback);
20+
};
21+
},
22+
getSnapshot() {
23+
return renderers;
24+
},
25+
getServerSnapshot() {
26+
return renderers;
27+
},
28+
/**
29+
* Adds a new NodeView Renderer to the editor.
30+
*/
31+
setRenderer(id: string, renderer: ReactRenderer) {
32+
renderers = {
33+
...renderers,
34+
[id]: createPortal(renderer.reactElement, renderer.element, id),
35+
};
3836

39-
/**
40-
* Used by TipTap
41-
*/
42-
removeRenderer(id: string) {
43-
setRenderers((renderers) => {
44-
const nextRenderers = { ...renderers };
37+
subscribers.forEach((subscriber) => subscriber());
38+
},
39+
/**
40+
* Removes a NodeView Renderer from the editor.
41+
*/
42+
removeRenderer(id: string) {
43+
const nextRenderers = { ...renderers };
4544

46-
delete nextRenderers[id];
45+
delete nextRenderers[id];
46+
renderers = nextRenderers;
47+
subscribers.forEach((subscriber) => subscriber());
48+
},
49+
};
50+
}
4751

48-
return nextRenderers;
49-
});
50-
},
51-
};
52-
// Without queueMicrotask, custom IC / styles will give a React FlushSync error
53-
queueMicrotask(() => {
54-
props.editor._tiptapEditor.createNodeViews();
55-
});
56-
return () => {
57-
props.editor._tiptapEditor.contentComponent = null;
58-
};
59-
}, [props.editor._tiptapEditor]);
52+
type ContentComponent = ReturnType<typeof getContentComponent>;
6053

61-
return (
62-
<>
63-
<Portals renderers={renderers} />
64-
{props.children}
65-
</>
54+
/**
55+
* This component renders all of the editor's node views.
56+
*/
57+
export const Portals: React.FC<{ contentComponent: ContentComponent }> = ({
58+
contentComponent,
59+
}) => {
60+
// For performance reasons, we render the node view portals on state changes only
61+
const renderers = useSyncExternalStore(
62+
contentComponent.subscribe,
63+
contentComponent.getSnapshot,
64+
contentComponent.getServerSnapshot
6665
);
67-
}
66+
67+
// This allows us to directly render the portals without any additional wrapper
68+
return <>{Object.values(renderers)}</>;
69+
};

0 commit comments

Comments
 (0)