diff --git a/packages/react/src/editor/EditorContent.tsx b/packages/react/src/editor/EditorContent.tsx index 7dedc6b178..c44d92a3d4 100644 --- a/packages/react/src/editor/EditorContent.tsx +++ b/packages/react/src/editor/EditorContent.tsx @@ -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 }> = ({ renderers, @@ -26,13 +26,20 @@ export function EditorContent(props: { children: any; }) { const [renderers, setRenderers] = useState>({}); + const [singleRenderData, setSingleRenderData] = useState(); 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 }; @@ -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(() => { @@ -55,6 +74,8 @@ export function EditorContent(props: { return ( <> + {singleRenderData && + createPortal(singleRenderData.node, singleRenderData.container)} {props.children} ); diff --git a/packages/react/src/schema/@util/ReactRenderUtil.ts b/packages/react/src/schema/@util/ReactRenderUtil.ts index 36262e9392..d88cd8637a 100644 --- a/packages/react/src/schema/@util/ReactRenderUtil.ts +++ b/packages/react/src/schema/@util/ReactRenderUtil.ts @@ -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 | 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 @@ -28,7 +43,7 @@ export function renderToDOMSpec( ) as HTMLElement | null; contentDOMClone?.removeAttribute("data-tmp-find"); - root.unmount(); + root?.unmount(); return { dom, diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index ce85ef0bb2..453f307409 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -184,19 +184,22 @@ export function createReactBlockSpec< node.options.domAttributes?.blockContent || {}; const BlockContent = blockImplementation.render; - const output = renderToDOMSpec((refCB) => ( - - - - )); + const output = renderToDOMSpec( + (refCB) => ( + + + + ), + editor + ); output.contentDOM?.setAttribute("data-editable", ""); return output; @@ -221,7 +224,7 @@ export function createReactBlockSpec< /> ); - }); + }, editor); output.contentDOM?.setAttribute("data-editable", ""); return output; diff --git a/packages/react/src/schema/ReactInlineContentSpec.tsx b/packages/react/src/schema/ReactInlineContentSpec.tsx index aa6627c578..fb70749b19 100644 --- a/packages/react/src/schema/ReactInlineContentSpec.tsx +++ b/packages/react/src/schema/ReactInlineContentSpec.tsx @@ -118,9 +118,10 @@ export function createReactInlineContentSpec< editor.schema.styleSchema ) as any as InlineContentFromConfig; // TODO: fix cast const Content = inlineContentImplementation.render; - const output = renderToDOMSpec((refCB) => ( - - )); + const output = renderToDOMSpec( + (refCB) => , + editor + ); return addInlineContentAttributes( output, diff --git a/packages/react/src/schema/ReactStyleSpec.tsx b/packages/react/src/schema/ReactStyleSpec.tsx index 63f3242a06..130f6b6c4a 100644 --- a/packages/react/src/schema/ReactStyleSpec.tsx +++ b/packages/react/src/schema/ReactStyleSpec.tsx @@ -43,9 +43,10 @@ export function createReactStyleSpec( } const Content = styleImplementation.render; - const renderResult = renderToDOMSpec((refCB) => ( - - )); + const renderResult = renderToDOMSpec( + (refCB) => , + undefined + ); return addStyleAttributes( renderResult, diff --git a/packages/react/src/test/__snapshots__/nodeConversion.test.tsx.snap b/packages/react/src/test/__snapshots__/nodeConversion.test.tsx.snap index 40418cd364..b768a152c3 100644 --- a/packages/react/src/test/__snapshots__/nodeConversion.test.tsx.snap +++ b/packages/react/src/test/__snapshots__/nodeConversion.test.tsx.snap @@ -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": { diff --git a/packages/react/src/test/__snapshots__/reactContextParagraph/basic/external.html b/packages/react/src/test/__snapshots__/reactContextParagraph/basic/external.html new file mode 100644 index 0000000000..ca43744a1e --- /dev/null +++ b/packages/react/src/test/__snapshots__/reactContextParagraph/basic/external.html @@ -0,0 +1 @@ +
React Context Paragraph
\ No newline at end of file diff --git a/packages/react/src/test/__snapshots__/reactContextParagraph/basic/internal.html b/packages/react/src/test/__snapshots__/reactContextParagraph/basic/internal.html new file mode 100644 index 0000000000..91fe557415 --- /dev/null +++ b/packages/react/src/test/__snapshots__/reactContextParagraph/basic/internal.html @@ -0,0 +1 @@ +
React Context Paragraph
\ No newline at end of file diff --git a/packages/react/src/test/htmlConversion.test.tsx b/packages/react/src/test/htmlConversion.test.tsx index 9fe8a2d97d..e10f9526b2 100644 --- a/packages/react/src/test/htmlConversion.test.tsx +++ b/packages/react/src/test/htmlConversion.test.tsx @@ -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"; @@ -75,15 +81,26 @@ describe("Test React HTML conversion", () => { for (const testCase of testCases) { describe("Case: " + testCase.name, () => { let editor: BlockNoteEditor; + let root: Root; const div = document.createElement("div"); beforeEach(() => { editor = testCase.createEditor(); - editor.mount(div); + + const el = ( + + + + ); + 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; diff --git a/packages/react/src/test/nodeConversion.test.tsx b/packages/react/src/test/nodeConversion.test.tsx index de3feb49f3..cf17daffc3 100644 --- a/packages/react/src/test/nodeConversion.test.tsx +++ b/packages/react/src/test/nodeConversion.test.tsx @@ -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"; @@ -59,15 +62,22 @@ describe("Test React BlockNote-Prosemirror conversion", () => { for (const testCase of testCases) { describe("Case: " + testCase.name, () => { let editor: BlockNoteEditor; + let root: Root; const div = document.createElement("div"); beforeEach(() => { editor = testCase.createEditor(); - editor.mount(div); + + const el = ; + 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; diff --git a/packages/react/src/test/testCases/customReactBlocks.tsx b/packages/react/src/test/testCases/customReactBlocks.tsx index 0d900420e3..738efcade9 100644 --- a/packages/react/src/test/testCases/customReactBlocks.tsx +++ b/packages/react/src/test/testCases/customReactBlocks.tsx @@ -1,3 +1,4 @@ +import { createContext, useContext } from "react"; import { BlockNoteEditor, BlockNoteSchema, @@ -39,11 +40,34 @@ const SimpleReactCustomParagraph = createReactBlockSpec( } ); +export const TestContext = createContext(undefined); + +const ReactContextParagraphComponent = (props: any) => { + const testData = useContext(TestContext); + if (testData === undefined) { + throw Error(); + } + + return
; +}; + +const ReactContextParagraph = createReactBlockSpec( + { + type: "reactContextParagraph", + propSchema: defaultProps, + content: "inline", + }, + { + render: ReactContextParagraphComponent, + } +); + const schema = BlockNoteSchema.create({ blockSpecs: { ...defaultBlockSpecs, reactCustomParagraph: ReactCustomParagraph, simpleReactCustomParagraph: SimpleReactCustomParagraph, + reactContextParagraph: ReactContextParagraph, }, }); @@ -200,5 +224,14 @@ export const customReactBlockSchemaTestCases: EditorTestCases< }, ], }, + { + name: "reactContextParagraph/basic", + blocks: [ + { + type: "reactContextParagraph", + content: "React Context Paragraph", + }, + ], + }, ], }; diff --git a/packages/react/vitestSetup.ts b/packages/react/vitestSetup.ts index 9a869521e0..1b6f13540e 100644 --- a/packages/react/vitestSetup.ts +++ b/packages/react/vitestSetup.ts @@ -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;