Skip to content

Commit 2b01f5e

Browse files
fix: serialize React content with existing React tree (#641)
* serialize React content with existing React tree * fix tests * Added case test to serialize custom block with React context --------- Co-authored-by: Matthew Lipski <[email protected]>
1 parent 444c009 commit 2b01f5e

File tree

12 files changed

+185
-33
lines changed

12 files changed

+185
-33
lines changed

packages/react/src/editor/EditorContent.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { BlockNoteEditor } from "@blocknote/core";
22
import { ReactRenderer } from "@tiptap/react";
33
import { useEffect, useState } from "react";
4-
import { createPortal } from "react-dom";
4+
import { createPortal, flushSync } from "react-dom";
55

66
const Portals: React.FC<{ renderers: Record<string, ReactRenderer> }> = ({
77
renderers,
@@ -26,13 +26,20 @@ export function EditorContent(props: {
2626
children: any;
2727
}) {
2828
const [renderers, setRenderers] = useState<Record<string, ReactRenderer>>({});
29+
const [singleRenderData, setSingleRenderData] = useState<any>();
2930

3031
useEffect(() => {
3132
props.editor._tiptapEditor.contentComponent = {
33+
/**
34+
* Used by TipTap
35+
*/
3236
setRenderer(id: string, renderer: ReactRenderer) {
3337
setRenderers((renderers) => ({ ...renderers, [id]: renderer }));
3438
},
3539

40+
/**
41+
* Used by TipTap
42+
*/
3643
removeRenderer(id: string) {
3744
setRenderers((renderers) => {
3845
const nextRenderers = { ...renderers };
@@ -42,6 +49,18 @@ export function EditorContent(props: {
4249
return nextRenderers;
4350
});
4451
},
52+
53+
/**
54+
* Render a single node to a container within the React context (used by BlockNote renderToDOMSpec)
55+
*/
56+
renderToElement(node: React.ReactNode, container: HTMLElement) {
57+
flushSync(() => {
58+
setSingleRenderData({ node, container });
59+
});
60+
61+
// clear after it's been rendered to `container`
62+
setSingleRenderData(undefined);
63+
},
4564
};
4665
// Without queueMicrotask, custom IC / styles will give a React FlushSync error
4766
queueMicrotask(() => {
@@ -55,6 +74,8 @@ export function EditorContent(props: {
5574
return (
5675
<>
5776
<Portals renderers={renderers} />
77+
{singleRenderData &&
78+
createPortal(singleRenderData.node, singleRenderData.container)}
5879
{props.children}
5980
</>
6081
);

packages/react/src/schema/@util/ReactRenderUtil.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,30 @@
1+
import { BlockNoteEditor } from "@blocknote/core";
12
import { flushSync } from "react-dom";
2-
import { createRoot } from "react-dom/client";
3+
import { Root, createRoot } from "react-dom/client";
34

45
export function renderToDOMSpec(
5-
fc: (refCB: (ref: HTMLElement | null) => void) => React.ReactNode
6+
fc: (refCB: (ref: HTMLElement | null) => void) => React.ReactNode,
7+
editor: BlockNoteEditor<any, any, any> | undefined
68
) {
79
let contentDOM: HTMLElement | undefined;
810
const div = document.createElement("div");
9-
const root = createRoot(div);
10-
flushSync(() => {
11-
root.render(fc((el) => (contentDOM = el || undefined)));
12-
});
11+
12+
let root: Root | undefined;
13+
if (!editor) {
14+
// If no editor is provided, use a temporary root.
15+
// This is currently only used for Styles. In this case, react context etc. won't be available inside `fc`
16+
root = createRoot(div);
17+
flushSync(() => {
18+
root!.render(fc((el) => (contentDOM = el || undefined)));
19+
});
20+
} else {
21+
// Render temporarily using `EditorContent` (which is stored somewhat hacky on `editor._tiptapEditor.contentComponent`)
22+
// This way React Context will still work, as `fc` will be rendered inside the existing React tree
23+
editor._tiptapEditor.contentComponent.renderToElement(
24+
fc((el) => (contentDOM = el || undefined)),
25+
div
26+
);
27+
}
1328

1429
if (!div.childElementCount) {
1530
// TODO
@@ -28,7 +43,7 @@ export function renderToDOMSpec(
2843
) as HTMLElement | null;
2944
contentDOMClone?.removeAttribute("data-tmp-find");
3045

31-
root.unmount();
46+
root?.unmount();
3247

3348
return {
3449
dom,

packages/react/src/schema/ReactBlockSpec.tsx

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -184,19 +184,22 @@ export function createReactBlockSpec<
184184
node.options.domAttributes?.blockContent || {};
185185

186186
const BlockContent = blockImplementation.render;
187-
const output = renderToDOMSpec((refCB) => (
188-
<BlockContentWrapper
189-
blockType={block.type}
190-
blockProps={block.props}
191-
propSchema={blockConfig.propSchema}
192-
domAttributes={blockContentDOMAttributes}>
193-
<BlockContent
194-
block={block as any}
195-
editor={editor as any}
196-
contentRef={refCB}
197-
/>
198-
</BlockContentWrapper>
199-
));
187+
const output = renderToDOMSpec(
188+
(refCB) => (
189+
<BlockContentWrapper
190+
blockType={block.type}
191+
blockProps={block.props}
192+
propSchema={blockConfig.propSchema}
193+
domAttributes={blockContentDOMAttributes}>
194+
<BlockContent
195+
block={block as any}
196+
editor={editor as any}
197+
contentRef={refCB}
198+
/>
199+
</BlockContentWrapper>
200+
),
201+
editor
202+
);
200203
output.contentDOM?.setAttribute("data-editable", "");
201204

202205
return output;
@@ -221,7 +224,7 @@ export function createReactBlockSpec<
221224
/>
222225
</BlockContentWrapper>
223226
);
224-
});
227+
}, editor);
225228
output.contentDOM?.setAttribute("data-editable", "");
226229

227230
return output;

packages/react/src/schema/ReactInlineContentSpec.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,10 @@ export function createReactInlineContentSpec<
118118
editor.schema.styleSchema
119119
) as any as InlineContentFromConfig<T, S>; // TODO: fix cast
120120
const Content = inlineContentImplementation.render;
121-
const output = renderToDOMSpec((refCB) => (
122-
<Content inlineContent={ic} contentRef={refCB} />
123-
));
121+
const output = renderToDOMSpec(
122+
(refCB) => <Content inlineContent={ic} contentRef={refCB} />,
123+
editor
124+
);
124125

125126
return addInlineContentAttributes(
126127
output,

packages/react/src/schema/ReactStyleSpec.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,10 @@ export function createReactStyleSpec<T extends StyleConfig>(
4343
}
4444

4545
const Content = styleImplementation.render;
46-
const renderResult = renderToDOMSpec((refCB) => (
47-
<Content {...props} contentRef={refCB} />
48-
));
46+
const renderResult = renderToDOMSpec(
47+
(refCB) => <Content {...props} contentRef={refCB} />,
48+
undefined
49+
);
4950

5051
return addStyleAttributes(
5152
renderResult,

packages/react/src/test/__snapshots__/nodeConversion.test.tsx.snap

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,30 @@
11
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
22

3+
exports[`Test React BlockNote-Prosemirror conversion > Case: custom react block schema > Convert reactContextParagraph/basic to/from prosemirror 1`] = `
4+
{
5+
"attrs": {
6+
"backgroundColor": "default",
7+
"id": "1",
8+
"textColor": "default",
9+
},
10+
"content": [
11+
{
12+
"attrs": {
13+
"textAlignment": "left",
14+
},
15+
"content": [
16+
{
17+
"text": "React Context Paragraph",
18+
"type": "text",
19+
},
20+
],
21+
"type": "reactContextParagraph",
22+
},
23+
],
24+
"type": "blockContainer",
25+
}
26+
`;
27+
328
exports[`Test React BlockNote-Prosemirror conversion > Case: custom react block schema > Convert reactCustomParagraph/basic to/from prosemirror 1`] = `
429
{
530
"attrs": {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<div data-editable="">React Context Paragraph</div>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<div class="bn-block-group" data-node-type="blockGroup"><div class="bn-block-outer" data-node-type="blockOuter" data-id="1"><div class="bn-block" data-node-type="blockContainer" data-id="1"><div class="bn-block-content" data-content-type="reactContextParagraph" data-node-view-wrapper="" style="white-space: normal;"><div data-editable="">React Context Paragraph</div></div></div></div></div>

packages/react/src/test/htmlConversion.test.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,14 @@ import {
1111
createInternalHTMLSerializer,
1212
partialBlocksToBlocksForTesting,
1313
} from "@blocknote/core";
14+
import { flushSync } from "react-dom";
15+
import { Root, createRoot } from "react-dom/client";
1416
import { afterEach, beforeEach, describe, expect, it } from "vitest";
15-
import { customReactBlockSchemaTestCases } from "./testCases/customReactBlocks";
17+
import { BlockNoteView } from "../editor/BlockNoteView";
18+
import {
19+
TestContext,
20+
customReactBlockSchemaTestCases,
21+
} from "./testCases/customReactBlocks";
1622
import { customReactInlineContentTestCases } from "./testCases/customReactInlineContent";
1723
import { customReactStylesTestCases } from "./testCases/customReactStyles";
1824

@@ -75,15 +81,26 @@ describe("Test React HTML conversion", () => {
7581
for (const testCase of testCases) {
7682
describe("Case: " + testCase.name, () => {
7783
let editor: BlockNoteEditor<any, any, any>;
84+
let root: Root;
7885
const div = document.createElement("div");
7986

8087
beforeEach(() => {
8188
editor = testCase.createEditor();
82-
editor.mount(div);
89+
90+
const el = (
91+
<TestContext.Provider value={true}>
92+
<BlockNoteView editor={editor} />
93+
</TestContext.Provider>
94+
);
95+
root = createRoot(div);
96+
flushSync(() => {
97+
// eslint-disable-next-line testing-library/no-render-in-setup
98+
root.render(el);
99+
});
83100
});
84101

85102
afterEach(() => {
86-
editor.mount(undefined);
103+
root.unmount();
87104
editor._tiptapEditor.destroy();
88105
editor = undefined as any;
89106

packages/react/src/test/nodeConversion.test.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import {
88
nodeToBlock,
99
partialBlockToBlockForTesting,
1010
} from "@blocknote/core";
11+
import { flushSync } from "react-dom";
12+
import { Root, createRoot } from "react-dom/client";
13+
import { BlockNoteView } from "../editor/BlockNoteView";
1114
import { customReactBlockSchemaTestCases } from "./testCases/customReactBlocks";
1215
import { customReactInlineContentTestCases } from "./testCases/customReactInlineContent";
1316
import { customReactStylesTestCases } from "./testCases/customReactStyles";
@@ -59,15 +62,22 @@ describe("Test React BlockNote-Prosemirror conversion", () => {
5962
for (const testCase of testCases) {
6063
describe("Case: " + testCase.name, () => {
6164
let editor: BlockNoteEditor<any, any, any>;
65+
let root: Root;
6266
const div = document.createElement("div");
6367

6468
beforeEach(() => {
6569
editor = testCase.createEditor();
66-
editor.mount(div);
70+
71+
const el = <BlockNoteView editor={editor} />;
72+
root = createRoot(div);
73+
flushSync(() => {
74+
// eslint-disable-next-line testing-library/no-render-in-setup
75+
root.render(el);
76+
});
6777
});
6878

6979
afterEach(() => {
70-
editor.mount(undefined);
80+
root.unmount();
7181
editor._tiptapEditor.destroy();
7282
editor = undefined as any;
7383

packages/react/src/test/testCases/customReactBlocks.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createContext, useContext } from "react";
12
import {
23
BlockNoteEditor,
34
BlockNoteSchema,
@@ -39,11 +40,34 @@ const SimpleReactCustomParagraph = createReactBlockSpec(
3940
}
4041
);
4142

43+
export const TestContext = createContext<true | undefined>(undefined);
44+
45+
const ReactContextParagraphComponent = (props: any) => {
46+
const testData = useContext(TestContext);
47+
if (testData === undefined) {
48+
throw Error();
49+
}
50+
51+
return <div ref={props.contentRef} />;
52+
};
53+
54+
const ReactContextParagraph = createReactBlockSpec(
55+
{
56+
type: "reactContextParagraph",
57+
propSchema: defaultProps,
58+
content: "inline",
59+
},
60+
{
61+
render: ReactContextParagraphComponent,
62+
}
63+
);
64+
4265
const schema = BlockNoteSchema.create({
4366
blockSpecs: {
4467
...defaultBlockSpecs,
4568
reactCustomParagraph: ReactCustomParagraph,
4669
simpleReactCustomParagraph: SimpleReactCustomParagraph,
70+
reactContextParagraph: ReactContextParagraph,
4771
},
4872
});
4973

@@ -200,5 +224,14 @@ export const customReactBlockSchemaTestCases: EditorTestCases<
200224
},
201225
],
202226
},
227+
{
228+
name: "reactContextParagraph/basic",
229+
blocks: [
230+
{
231+
type: "reactContextParagraph",
232+
content: "React Context Paragraph",
233+
},
234+
],
235+
},
203236
],
204237
};

packages/react/vitestSetup.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,28 @@ class DragEventMock extends Event {
3232
},
3333
};
3434
}
35+
Object.defineProperty(window, "matchMedia", {
36+
writable: true,
37+
value: (query) => ({
38+
matches: false,
39+
media: query,
40+
onchange: null,
41+
addListener: () => {
42+
//
43+
}, // Deprecated
44+
removeListener: () => {
45+
//
46+
}, // Deprecated
47+
addEventListener: () => {
48+
//
49+
},
50+
removeEventListener: () => {
51+
//
52+
},
53+
dispatchEvent: () => {
54+
//
55+
},
56+
}),
57+
});
58+
3559
(global as any).DragEvent = DragEventMock;

0 commit comments

Comments
 (0)