diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index bc30d58092..519adfa033 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -359,6 +359,7 @@ export class BlockNoteEditor< public readonly _tiptapEditor: TiptapEditor & { contentComponent: any; + isEditorContentInitialized?: boolean; }; /** @@ -729,12 +730,17 @@ export class BlockNoteEditor< */ public mount = (element: HTMLElement) => { this._tiptapEditor.mount({ mount: element }); + // TipTap's ReactRenderer uses this flag to render new node/mark views + // synchronously. Otherwise, inserting React-styled text can place the + // caret before it because its contentDOM is not attached yet. + this._tiptapEditor.isEditorContentInitialized = true; }; /** * Unmount the editor from the DOM element it is bound to */ public unmount = () => { + this._tiptapEditor.isEditorContentInitialized = false; this._tiptapEditor.unmount(); }; diff --git a/packages/react/src/schema/ReactStyleSpec.browser.test.tsx b/packages/react/src/schema/ReactStyleSpec.browser.test.tsx new file mode 100644 index 0000000000..e57f9b3abb --- /dev/null +++ b/packages/react/src/schema/ReactStyleSpec.browser.test.tsx @@ -0,0 +1,88 @@ +import { + BlockNoteEditor, + BlockNoteSchema, + defaultStyleSpecs, +} from "@blocknote/core"; +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { expect, it } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import { BlockNoteViewRaw } from "../editor/BlockNoteView.js"; +import { createReactStyleSpec } from "./ReactStyleSpec.js"; + +const highlight = createReactStyleSpec( + { type: "highlight", propSchema: "boolean" }, + { render: ({ contentRef }) => }, +); +const schema = BlockNoteSchema.create({ + styleSpecs: { ...defaultStyleSpecs, highlight }, +}); + +it.each(["initial mount", "remount", "API remount"])( + "keeps typing after text inserted with a React style (%s)", + async (mountMode) => { + const editor = BlockNoteEditor.create({ + schema, + initialContent: [{ type: "paragraph", content: [] }], + }); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + + function mount() { + flushSync(() => { + root.render( + <> + + + , + ); + }); + } + + try { + mount(); + if (mountMode === "remount") { + flushSync(() => root.render(null)); + mount(); + } + if (mountMode === "API remount") { + const element = editor.prosemirrorView.dom.parentElement!; + editor.unmount(); + expect(editor._tiptapEditor.isEditorContentInitialized).toBe(false); + editor.mount(element); + } + + await userEvent.click(host.querySelector("button")!); + await userEvent.keyboard(" continued"); + + expect(editor.prosemirrorState.doc.textContent).toBe( + "Styled text continued", + ); + } finally { + root.unmount(); + editor._tiptapEditor.destroy(); + host.remove(); + } + }, +); diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts index d4e59a60b4..0d0bd6893c 100644 --- a/packages/react/vite.config.ts +++ b/packages/react/vite.config.ts @@ -1,7 +1,7 @@ import react from "@vitejs/plugin-react"; import * as path from "path"; import { webpackStats } from "rollup-plugin-webpack-stats"; -import { defineConfig, type UserConfig } from "vite-plus"; +import { configDefaults, defineConfig, type UserConfig } from "vite-plus"; import pkg from "./package.json"; // import eslintPlugin from "vite-plugin-eslint"; @@ -26,6 +26,8 @@ export default defineConfig( test: { environment: "jsdom", setupFiles: ["./vitestSetup.ts"], + // Browser tests run in the tests package's Docker browser suite. + exclude: [...configDefaults.exclude, "**/*.browser.test.*"], }, plugins: [react(), webpackStats()], // used so that vitest resolves the core package from the sources instead of the built version