diff --git a/packages/react/src/schema/ReactInlineContentSpec.browser.test.tsx b/packages/react/src/schema/ReactInlineContentSpec.browser.test.tsx new file mode 100644 index 0000000000..ec5991464c --- /dev/null +++ b/packages/react/src/schema/ReactInlineContentSpec.browser.test.tsx @@ -0,0 +1,134 @@ +import "@blocknote/core/style.css"; + +import { BlockNoteEditor, BlockNoteSchema } from "@blocknote/core"; +import { TextSelection } from "@tiptap/pm/state"; +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import { BlockNoteViewRaw } from "../editor/BlockNoteView.js"; +import { createReactInlineContentSpec } from "./ReactInlineContentSpec.js"; + +const tag = createReactInlineContentSpec( + { type: "tag", propSchema: {}, content: "styled" }, + { + render: ({ contentRef }) => ( + + # + + + ), + }, +); +const schema = BlockNoteSchema.create().extend({ + inlineContentSpecs: { tag }, +}); + +it.each([ + { name: "initial content", initial: true, text: "seed" }, + { name: "an inserted empty tag", initial: false, text: "" }, + { name: "an inserted tag with text", initial: false, text: "seed" }, +])( + "keeps $name connected to the document (#2142)", + async ({ initial, text }) => { + const editor = BlockNoteEditor.create({ + schema, + trailingBlock: false, + initialContent: [ + { + id: "paragraph", + type: "paragraph", + content: initial ? [{ type: "tag", content: text }] : [], + }, + ], + }); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + + try { + flushSync(() => { + root.render( + , + ); + }); + + if (!initial) { + editor.insertInlineContent([{ type: "tag", content: text }]); + } + + const editable = await vi.waitFor(() => { + const element = host.querySelector( + '[data-test="tag-content"]', + ); + expect(element).not.toBeNull(); + expect(element?.textContent).toBe(text); + return element!; + }); + + editor.transact((tr) => { + tr.doc.descendants((node, pos) => { + if (node.type.name === "tag") { + tr.setSelection( + TextSelection.create(tr.doc, pos + 1 + text.length), + ); + return false; + } + return undefined; + }); + }); + editor.focus(); + + if (text === "") { + // Match the report: click the visible empty input before typing into it. + await userEvent.click(editable); + } + + const selection = window.getSelection(); + const inputContext = JSON.stringify({ + parent: editor.prosemirrorState.selection.$from.parent.type.name, + anchor: selection?.anchorNode?.parentElement?.outerHTML, + anchorOffset: selection?.anchorOffset, + content: editable.outerHTML, + rect: editable.getBoundingClientRect().toJSON(), + }); + + // Both keyboard input and native edits must reach ProseMirror through this + // node view's content DOM, so editor.document matches the visible text. + if (text === "") { + await userEvent.keyboard("added"); + } else { + document.execCommand("insertText", false, "added"); + } + + await vi.waitFor(() => { + expect( + host.querySelector('[data-test="tag-content"]')?.textContent, + `${inputContext}; after input: ${host.innerHTML}; document: ${JSON.stringify(editor.document)}`, + ).toBe(`${text}added`); + expect(editor.document[0].content).toEqual([ + { + type: "tag", + props: {}, + content: [{ type: "text", text: `${text}added`, styles: {} }], + }, + ]); + }); + } finally { + root.unmount(); + editor._tiptapEditor.destroy(); + host.remove(); + } + }, +); diff --git a/packages/react/src/schema/ReactInlineContentSpec.test.tsx b/packages/react/src/schema/ReactInlineContentSpec.test.tsx new file mode 100644 index 0000000000..4ca5568b6e --- /dev/null +++ b/packages/react/src/schema/ReactInlineContentSpec.test.tsx @@ -0,0 +1,64 @@ +import { BlockNoteEditor, BlockNoteSchema } from "@blocknote/core"; +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +import { BlockNoteViewRaw } from "../editor/BlockNoteView.js"; +import { createReactInlineContentSpec } from "./ReactInlineContentSpec.js"; + +function createSchema(content: "styled" | "plain") { + const config = + content === "styled" + ? ({ type: "tag", propSchema: {}, content: "styled" } as const) + : ({ type: "tag", propSchema: {}, content: "plain" } as const); + const tag = createReactInlineContentSpec(config, { + render: ({ contentRef }) => ( + + # + + ), + }); + return BlockNoteSchema.create().extend({ + inlineContentSpecs: { tag }, + }); +} + +it.each(["styled", "plain"] as const)( + "renders inserted %s inline content inside its contentRef element (#2142)", + async (content) => { + const editor = BlockNoteEditor.create({ + schema: createSchema(content), + trailingBlock: false, + initialContent: [{ id: "paragraph", type: "paragraph" }], + }); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + + try { + flushSync(() => { + root.render( + , + ); + }); + editor.insertInlineContent([{ type: "tag", content: "seed" }]); + + await vi.waitFor(() => { + expect( + host.querySelector('[data-test="tag-content"]')?.textContent, + host.innerHTML, + ).toBe("seed"); + }); + } finally { + root.unmount(); + editor._tiptapEditor.destroy(); + host.remove(); + } + }, +); diff --git a/packages/react/src/schema/ReactInlineContentSpec.tsx b/packages/react/src/schema/ReactInlineContentSpec.tsx index 863d5421a3..ef91283ef5 100644 --- a/packages/react/src/schema/ReactInlineContentSpec.tsx +++ b/packages/react/src/schema/ReactInlineContentSpec.tsx @@ -226,6 +226,10 @@ export function createReactInlineContentSpec< contentRef={(element) => { ref(element); if (element) { + // The initial React ref runs before TipTap creates the + // content DOM. Mark its target so the node view can attach + // the content there once its constructor resumes. + element.dataset.nodeViewContent = ""; element.dataset.editable = ""; } }}