diff --git a/packages/core/src/editor/managers/StateManager.test.ts b/packages/core/src/editor/managers/StateManager.test.ts index 22eeed4dbc..00ac28c9d8 100644 --- a/packages/core/src/editor/managers/StateManager.test.ts +++ b/packages/core/src/editor/managers/StateManager.test.ts @@ -1,5 +1,5 @@ /** @vitest-environment jsdom */ -import { Plugin } from "prosemirror-state"; +import { Plugin, TextSelection } from "prosemirror-state"; import { afterEach, beforeEach, @@ -8,6 +8,9 @@ import { it, vi, } from "vite-plus/test"; +import { BlockNoteSchema } from "../../blocks/BlockNoteSchema.js"; +import { SourceInlineContentWithPreviewExtension } from "../../extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.js"; +import { createInlineContentSpec } from "../../schema/inlineContent/createSpec.js"; import { BlockNoteEditor } from "../BlockNoteEditor.js"; import { createExtension } from "../BlockNoteExtension.js"; @@ -26,6 +29,65 @@ describe("StateManager editability", () => { prosemirrorPlugins: [new Plugin({ props: { editable: () => false } })], })); + it.each(["application", "plugin"] as const)( + "reconciles inline popup state after remounting with the %s restriction", + (source) => { + editor.unmount(); + const preview = createInlineContentSpec( + { type: "preview", propSchema: {}, content: "plain" }, + { + meta: { hasPreview: true }, + render() { + const dom = document.createElement("span"); + return { dom, contentDOM: dom }; + }, + }, + ); + const schema = BlockNoteSchema.create().extend({ + inlineContentSpecs: { preview }, + }); + const previewEditor = BlockNoteEditor.create({ + schema, + extensions: source === "plugin" ? [restriction()] : [], + initialContent: [ + { type: "paragraph", content: [{ type: "preview", content: "x" }] }, + ], + }); + const host = document.createElement("div"); + try { + previewEditor.mount(host); + let previewPos = -1; + previewEditor.prosemirrorState.doc.descendants((node, pos) => { + if (node.type.name === "preview") { + previewPos = pos; + } + }); + expect(previewPos).toBeGreaterThan(0); + previewEditor.transact((tr) => + tr.setSelection(TextSelection.create(tr.doc, previewPos + 1)), + ); + const popup = previewEditor.getExtension( + SourceInlineContentWithPreviewExtension, + )!; + expect(popup.store.state.selected).toBe( + source === "plugin" ? undefined : previewPos, + ); + previewEditor.unmount(); + if (source === "application") { + previewEditor.isEditable = false; + } + previewEditor.mount(host); + expect(previewEditor.isEditable).toBe(false); + expect(previewEditor.prosemirrorState.selection.from).toBe( + previewPos + 1, + ); + expect(popup.store.state.selected).toBeUndefined(); + } finally { + previewEditor.unmount(); + } + }, + ); + it.each(["mount", "register", "replace", "unregister"] as const)( "allows editability subscribers to register plugins during %s without duplicate views", (operation) => { diff --git a/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts b/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts index 9ab67e93a7..f15c5ce470 100644 --- a/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts +++ b/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts @@ -14,7 +14,8 @@ import { * rather than the extension being configured with a type. * * Unlike the block version, the popup isn't toggled with a separate state flag: - * it's open exactly when the selection is inside the inline content's source. + * it's open when the editor is editable and the selection is inside the inline + * content's source. * The store therefore only tracks which inline content (by its position) holds * the selection - moving the selection in opens its popup, moving it out closes * it. Since the source popup is always laid out (just hidden via opacity), the @@ -38,7 +39,7 @@ export const SourceInlineContentWithPreviewExtension = createExtension( ?.hasPreview; // Moves the selection out of the inline content, to just `"before"` or - // `"after"` it, which closes the popup via the selection-change handler + // `"after"` it, which closes the popup via the selection listener // below. Lets the keyboard commit-and-exit the source the same way arrowing // past its edge does, keeps Enter from splitting the block while editing the // source, and lets the up/down arrows step out of the source rather than @@ -92,19 +93,31 @@ export const SourceInlineContentWithPreviewExtension = createExtension( }, }, mount: ({ dom, signal }) => { - // The popup is open exactly when the selection is inside the inline - // content, so we just track which inline content (if any) holds it. - const unsubscribeSelectionChange = editor.onSelectionChange(() => { - const { $from } = editor.prosemirrorState.selection; - const node = $from.node(); - - store.setState({ - selected: nodeHasPreview(node.type.name) - ? $from.before() - : undefined, + function updateSelected() { + if (!editor.isEditable) { + store.setState({ selected: undefined }); + return; + } + editor.transact((tr) => { + const { $from } = tr.selection; + store.setState({ + selected: nodeHasPreview($from.node().type.name) + ? $from.before() + : undefined, + }); }); - }); - signal.addEventListener("abort", unsubscribeSelectionChange); + } + signal.addEventListener( + "abort", + editor.onSelectionChange(updateSelected), + ); + signal.addEventListener( + "abort", + editor.onEditableChange(updateSelected), + ); + // Selection and editability may have changed while this extension was + // unmounted, when neither subscription was attached. + updateSelected(); // Sets `visibility: hidden` on the popup for a single frame when pressing up/down arrow // keys. The popup is normally hidden through `opacity: 0`, which means it's still visible diff --git a/packages/diagram-block/src/block/sourcePopup.browser.test.tsx b/packages/diagram-block/src/block/sourcePopup.browser.test.tsx new file mode 100644 index 0000000000..86a397ad41 --- /dev/null +++ b/packages/diagram-block/src/block/sourcePopup.browser.test.tsx @@ -0,0 +1,73 @@ +import "@blocknote/core/style.css"; + +import { BlockNoteEditor, BlockNoteSchema } from "@blocknote/core"; +import { SourceBlockWithPreviewExtension } from "@blocknote/core/extensions"; +import { BlockNoteViewRaw } from "@blocknote/react"; +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 { createReactDiagramBlockSpec } from "./createReactDiagramBlockSpec.js"; + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { diagram: createReactDiagramBlockSpec() }, +}); + +it.each([false, true])( + "only opens a diagram preview on click when editable (editable: %s)", + async (editable) => { + const editor = BlockNoteEditor.create({ + schema, + trailingBlock: false, + initialContent: [ + { id: "diagram", type: "diagram", content: "graph TD; A --> B" }, + ], + }); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + + try { + flushSync(() => { + root.render(); + }); + + const preview = await vi.waitFor(() => { + const preview = host.querySelector("[role='img']"); + if (!preview) { + throw new Error("Diagram preview not rendered yet"); + } + return preview; + }); + const extension = editor.getExtension(SourceBlockWithPreviewExtension)!; + flushSync(() => editor.setTextCursorPosition("diagram", "end")); + expect(extension.store.state.selected).toBe("diagram"); + // Unlike inline math, selecting a diagram never opens its popup. + expect(extension.store.state.popupOpen).toBeUndefined(); + + await userEvent.click(preview); + expect(extension.store.state.popupOpen).toBe( + editable ? "diagram" : undefined, + ); + await vi.waitFor(() => + expect( + host + .querySelector(".bn-preview-with-source-popup") + ?.getAttribute("data-open"), + ).toBe(String(editable)), + ); + + if (!editable) { + const content = editor.getBlock("diagram")?.content; + await userEvent.keyboard("{Enter}"); + expect(extension.store.state.popupOpen).toBeUndefined(); + expect(editor.getBlock("diagram")?.content).toEqual(content); + } + } finally { + root.unmount(); + editor._tiptapEditor.destroy(); + host.remove(); + } + }, +); diff --git a/packages/math-block/src/inlineContent/sourcePopup.browser.test.tsx b/packages/math-block/src/inlineContent/sourcePopup.browser.test.tsx new file mode 100644 index 0000000000..eecf915eb0 --- /dev/null +++ b/packages/math-block/src/inlineContent/sourcePopup.browser.test.tsx @@ -0,0 +1,169 @@ +import "@blocknote/core/style.css"; + +import { + BlockNoteEditor, + BlockNoteSchema, + createExtension, +} from "@blocknote/core"; +import { SourceInlineContentWithPreviewExtension } from "@blocknote/core/extensions"; +import { BlockNoteViewRaw } from "@blocknote/react"; +import { Plugin, TextSelection } from "prosemirror-state"; +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +import { createReactInlineMathSpec } from "./createReactMathInlineContentSpec.js"; + +const schema = BlockNoteSchema.create().extend({ + inlineContentSpecs: { math: createReactInlineMathSpec() }, +}); + +it.each([ + { editable: false, batched: false, pluginReadOnly: false }, + { editable: true, batched: false, pluginReadOnly: false }, + { editable: true, batched: true, pluginReadOnly: false }, + { editable: true, batched: false, pluginReadOnly: true }, + { editable: true, batched: true, pluginReadOnly: true }, +])( + "inline math popup follows editability (#3291): %j", + async ({ editable, batched, pluginReadOnly }) => { + const pluginEditability = createExtension(() => ({ + key: "testPluginEditability", + prosemirrorPlugins: [ + new Plugin({ props: { editable: () => !pluginReadOnly } }), + ], + })); + const editor = BlockNoteEditor.create({ + schema, + extensions: [pluginEditability()], + trailingBlock: false, + initialContent: [ + { + type: "paragraph", + content: [{ type: "math", content: "x^2" }], + }, + ], + }); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + + try { + flushSync(() => { + root.render(); + }); + + let mathPos: number | undefined; + editor.prosemirrorState.doc.descendants((node, pos) => { + if (node.type.name === "math") { + mathPos = pos; + } + }); + if (mathPos === undefined) { + throw new Error("Inline math node not found"); + } + const sourcePos = mathPos + 1; + + async function expectPopup(open: boolean) { + expect(editor.prosemirrorState.selection.from).toBe(sourcePos); + expect( + editor.getExtension(SourceInlineContentWithPreviewExtension)!.store + .state.selected, + ).toBe(open ? mathPos : undefined); + await vi.waitFor(() => + expect( + host + .querySelector(".bn-preview-with-source-popup") + ?.getAttribute("data-open"), + ).toBe(String(open)), + ); + } + + // Read-only ProseMirror views still move the selection on click. Exercise + // that selection path directly, independently of the guarded preview click. + flushSync(() => { + editor.transact((tr) => + tr.setSelection(TextSelection.create(tr.doc, sourcePos)), + ); + }); + + await expectPopup(editable && !pluginReadOnly); + + if (editable) { + flushSync(() => { + if (batched) { + editor.transact(() => { + editor.isEditable = false; + }); + } else { + editor.isEditable = false; + } + }); + + await expectPopup(false); + + flushSync(() => { + if (batched) { + editor.transact(() => { + editor.isEditable = true; + }); + } else { + editor.isEditable = true; + } + }); + + await expectPopup(!pluginReadOnly); + } + } finally { + root.unmount(); + editor._tiptapEditor.destroy(); + host.remove(); + } + }, +); + +it("tracks inline source popups through the editor API", () => { + const editor = BlockNoteEditor.create({ + schema, + initialContent: [ + { type: "paragraph", content: [{ type: "math", content: "x^2" }] }, + ], + }); + const host = document.createElement("div"); + document.body.append(host); + + try { + editor.mount(host); + expect(editor.prosemirrorView).toBeDefined(); + let mathPos: number | undefined; + editor.prosemirrorState.doc.descendants((node, pos) => { + if (node.type.name === "math") { + mathPos = pos; + } + }); + if (mathPos === undefined) { + throw new Error("Inline math node not found"); + } + const sourcePos = mathPos + 1; + editor.transact((tr) => + tr.setSelection(TextSelection.create(tr.doc, sourcePos)), + ); + expect( + editor.getExtension(SourceInlineContentWithPreviewExtension)!.store.state + .selected, + ).toBe(mathPos); + editor.isEditable = false; + expect( + editor.getExtension(SourceInlineContentWithPreviewExtension)!.store.state + .selected, + ).toBeUndefined(); + editor.isEditable = true; + expect( + editor.getExtension(SourceInlineContentWithPreviewExtension)!.store.state + .selected, + ).toBe(mathPos); + } finally { + editor._tiptapEditor.destroy(); + host.remove(); + } +});