Skip to content

fix: serialize React content with existing React tree #641

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 3 commits into from
Mar 14, 2024
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
23 changes: 22 additions & 1 deletion packages/react/src/editor/EditorContent.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { BlockNoteEditor } from "@blocknote/core";
import { ReactRenderer } from "@tiptap/react";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { createPortal, flushSync } from "react-dom";

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

useEffect(() => {
props.editor._tiptapEditor.contentComponent = {
/**
* Used by TipTap
*/
setRenderer(id: string, renderer: ReactRenderer) {
setRenderers((renderers) => ({ ...renderers, [id]: renderer }));
},

/**
* Used by TipTap
*/
removeRenderer(id: string) {
setRenderers((renderers) => {
const nextRenderers = { ...renderers };
Expand All @@ -42,6 +49,18 @@ export function EditorContent(props: {
return nextRenderers;
});
},

/**
* Render a single node to a container within the React context (used by BlockNote renderToDOMSpec)
*/
renderToElement(node: React.ReactNode, container: HTMLElement) {
flushSync(() => {
setSingleRenderData({ node, container });
});

// clear after it's been rendered to `container`
setSingleRenderData(undefined);
},
};
// Without queueMicrotask, custom IC / styles will give a React FlushSync error
queueMicrotask(() => {
Expand All @@ -55,6 +74,8 @@ export function EditorContent(props: {
return (
<>
<Portals renderers={renderers} />
{singleRenderData &&
createPortal(singleRenderData.node, singleRenderData.container)}
{props.children}
</>
);
Expand Down
29 changes: 22 additions & 7 deletions packages/react/src/schema/@util/ReactRenderUtil.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,30 @@
import { BlockNoteEditor } from "@blocknote/core";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { Root, createRoot } from "react-dom/client";

export function renderToDOMSpec(
fc: (refCB: (ref: HTMLElement | null) => void) => React.ReactNode
fc: (refCB: (ref: HTMLElement | null) => void) => React.ReactNode,
editor: BlockNoteEditor<any, any, any> | undefined
) {
let contentDOM: HTMLElement | undefined;
const div = document.createElement("div");
const root = createRoot(div);
flushSync(() => {
root.render(fc((el) => (contentDOM = el || undefined)));
});

let root: Root | undefined;
if (!editor) {
// If no editor is provided, use a temporary root.
// This is currently only used for Styles. In this case, react context etc. won't be available inside `fc`
root = createRoot(div);
flushSync(() => {
root!.render(fc((el) => (contentDOM = el || undefined)));
});
} else {
// Render temporarily using `EditorContent` (which is stored somewhat hacky on `editor._tiptapEditor.contentComponent`)
// This way React Context will still work, as `fc` will be rendered inside the existing React tree
editor._tiptapEditor.contentComponent.renderToElement(
fc((el) => (contentDOM = el || undefined)),
div
);
}

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

root.unmount();
root?.unmount();

return {
dom,
Expand Down
31 changes: 17 additions & 14 deletions packages/react/src/schema/ReactBlockSpec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,19 +184,22 @@ export function createReactBlockSpec<
node.options.domAttributes?.blockContent || {};

const BlockContent = blockImplementation.render;
const output = renderToDOMSpec((refCB) => (
<BlockContentWrapper
blockType={block.type}
blockProps={block.props}
propSchema={blockConfig.propSchema}
domAttributes={blockContentDOMAttributes}>
<BlockContent
block={block as any}
editor={editor as any}
contentRef={refCB}
/>
</BlockContentWrapper>
));
const output = renderToDOMSpec(
(refCB) => (
<BlockContentWrapper
blockType={block.type}
blockProps={block.props}
propSchema={blockConfig.propSchema}
domAttributes={blockContentDOMAttributes}>
<BlockContent
block={block as any}
editor={editor as any}
contentRef={refCB}
/>
</BlockContentWrapper>
),
editor
);
output.contentDOM?.setAttribute("data-editable", "");

return output;
Expand All @@ -221,7 +224,7 @@ export function createReactBlockSpec<
/>
</BlockContentWrapper>
);
});
}, editor);
output.contentDOM?.setAttribute("data-editable", "");

return output;
Expand Down
7 changes: 4 additions & 3 deletions packages/react/src/schema/ReactInlineContentSpec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,10 @@ export function createReactInlineContentSpec<
editor.schema.styleSchema
) as any as InlineContentFromConfig<T, S>; // TODO: fix cast
const Content = inlineContentImplementation.render;
const output = renderToDOMSpec((refCB) => (
<Content inlineContent={ic} contentRef={refCB} />
));
const output = renderToDOMSpec(
(refCB) => <Content inlineContent={ic} contentRef={refCB} />,
editor
);

return addInlineContentAttributes(
output,
Expand Down
7 changes: 4 additions & 3 deletions packages/react/src/schema/ReactStyleSpec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,10 @@ export function createReactStyleSpec<T extends StyleConfig>(
}

const Content = styleImplementation.render;
const renderResult = renderToDOMSpec((refCB) => (
<Content {...props} contentRef={refCB} />
));
const renderResult = renderToDOMSpec(
(refCB) => <Content {...props} contentRef={refCB} />,
undefined
);

return addStyleAttributes(
renderResult,
Expand Down
25 changes: 25 additions & 0 deletions packages/react/src/test/__snapshots__/nodeConversion.test.tsx.snap
Original file line number Diff line number Diff line change
@@ -1,5 +1,30 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`Test React BlockNote-Prosemirror conversion > Case: custom react block schema > Convert reactContextParagraph/basic to/from prosemirror 1`] = `
{
"attrs": {
"backgroundColor": "default",
"id": "1",
"textColor": "default",
},
"content": [
{
"attrs": {
"textAlignment": "left",
},
"content": [
{
"text": "React Context Paragraph",
"type": "text",
},
],
"type": "reactContextParagraph",
},
],
"type": "blockContainer",
}
`;

exports[`Test React BlockNote-Prosemirror conversion > Case: custom react block schema > Convert reactCustomParagraph/basic to/from prosemirror 1`] = `
{
"attrs": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div data-editable="">React Context Paragraph</div>
Original file line number Diff line number Diff line change
@@ -0,0 +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>
23 changes: 20 additions & 3 deletions packages/react/src/test/htmlConversion.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,14 @@ import {
createInternalHTMLSerializer,
partialBlocksToBlocksForTesting,
} from "@blocknote/core";
import { flushSync } from "react-dom";
import { Root, createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { customReactBlockSchemaTestCases } from "./testCases/customReactBlocks";
import { BlockNoteView } from "../editor/BlockNoteView";
import {
TestContext,
customReactBlockSchemaTestCases,
} from "./testCases/customReactBlocks";
import { customReactInlineContentTestCases } from "./testCases/customReactInlineContent";
import { customReactStylesTestCases } from "./testCases/customReactStyles";

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

beforeEach(() => {
editor = testCase.createEditor();
editor.mount(div);

const el = (
<TestContext.Provider value={true}>
<BlockNoteView editor={editor} />
</TestContext.Provider>
);
root = createRoot(div);
flushSync(() => {
// eslint-disable-next-line testing-library/no-render-in-setup
root.render(el);
});
});

afterEach(() => {
editor.mount(undefined);
root.unmount();
editor._tiptapEditor.destroy();
editor = undefined as any;

Expand Down
14 changes: 12 additions & 2 deletions packages/react/src/test/nodeConversion.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import {
nodeToBlock,
partialBlockToBlockForTesting,
} from "@blocknote/core";
import { flushSync } from "react-dom";
import { Root, createRoot } from "react-dom/client";
import { BlockNoteView } from "../editor/BlockNoteView";
import { customReactBlockSchemaTestCases } from "./testCases/customReactBlocks";
import { customReactInlineContentTestCases } from "./testCases/customReactInlineContent";
import { customReactStylesTestCases } from "./testCases/customReactStyles";
Expand Down Expand Up @@ -59,15 +62,22 @@ describe("Test React BlockNote-Prosemirror conversion", () => {
for (const testCase of testCases) {
describe("Case: " + testCase.name, () => {
let editor: BlockNoteEditor<any, any, any>;
let root: Root;
const div = document.createElement("div");

beforeEach(() => {
editor = testCase.createEditor();
editor.mount(div);

const el = <BlockNoteView editor={editor} />;
root = createRoot(div);
flushSync(() => {
// eslint-disable-next-line testing-library/no-render-in-setup
root.render(el);
});
});

afterEach(() => {
editor.mount(undefined);
root.unmount();
editor._tiptapEditor.destroy();
editor = undefined as any;

Expand Down
33 changes: 33 additions & 0 deletions packages/react/src/test/testCases/customReactBlocks.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createContext, useContext } from "react";
import {
BlockNoteEditor,
BlockNoteSchema,
Expand Down Expand Up @@ -39,11 +40,34 @@ const SimpleReactCustomParagraph = createReactBlockSpec(
}
);

export const TestContext = createContext<true | undefined>(undefined);

const ReactContextParagraphComponent = (props: any) => {
const testData = useContext(TestContext);
if (testData === undefined) {
throw Error();
}

return <div ref={props.contentRef} />;
};

const ReactContextParagraph = createReactBlockSpec(
{
type: "reactContextParagraph",
propSchema: defaultProps,
content: "inline",
},
{
render: ReactContextParagraphComponent,
}
);

const schema = BlockNoteSchema.create({
blockSpecs: {
...defaultBlockSpecs,
reactCustomParagraph: ReactCustomParagraph,
simpleReactCustomParagraph: SimpleReactCustomParagraph,
reactContextParagraph: ReactContextParagraph,
},
});

Expand Down Expand Up @@ -200,5 +224,14 @@ export const customReactBlockSchemaTestCases: EditorTestCases<
},
],
},
{
name: "reactContextParagraph/basic",
blocks: [
{
type: "reactContextParagraph",
content: "React Context Paragraph",
},
],
},
],
};
24 changes: 24 additions & 0 deletions packages/react/vitestSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,28 @@ class DragEventMock extends Event {
},
};
}
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {
//
}, // Deprecated
removeListener: () => {
//
}, // Deprecated
addEventListener: () => {
//
},
removeEventListener: () => {
//
},
dispatchEvent: () => {
//
},
}),
});

(global as any).DragEvent = DragEventMock;