From 03ed49ec337c15682f4a2d655f22837660a700b6 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 3 Nov 2026 10:51:27 +0100 Subject: [PATCH 1/5] fix(core): keep inline source popups closed in read-only editors --- .../SourceInlineContentWithPreview.ts | 11 +-- .../src/block/sourcePopup.browser.test.tsx | 73 ++++++++++++++++++ .../sourcePopup.browser.test.tsx | 76 +++++++++++++++++++ 3 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 packages/diagram-block/src/block/sourcePopup.browser.test.tsx create mode 100644 packages/math-block/src/inlineContent/sourcePopup.browser.test.tsx diff --git a/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts b/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts index 9ab67e93a7..bc168d9e7a 100644 --- a/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts +++ b/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts @@ -92,16 +92,17 @@ 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. + // The popup is open when the selection is inside the inline content + // and the editor is editable. Read-only editors still allow selection. const unsubscribeSelectionChange = editor.onSelectionChange(() => { const { $from } = editor.prosemirrorState.selection; const node = $from.node(); store.setState({ - selected: nodeHasPreview(node.type.name) - ? $from.before() - : undefined, + selected: + editor.isEditable && nodeHasPreview(node.type.name) + ? $from.before() + : undefined, }); }); signal.addEventListener("abort", unsubscribeSelectionChange); 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..f514c64f33 --- /dev/null +++ b/packages/math-block/src/inlineContent/sourcePopup.browser.test.tsx @@ -0,0 +1,76 @@ +import "@blocknote/core/style.css"; + +import { BlockNoteEditor, BlockNoteSchema } from "@blocknote/core"; +import { SourceInlineContentWithPreviewExtension } from "@blocknote/core/extensions"; +import { BlockNoteViewRaw } from "@blocknote/react"; +import { 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([false, true])( + "only opens selected inline math when editable (editable: %s, #3291)", + async (editable) => { + const editor = BlockNoteEditor.create({ + schema, + 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; + + // 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)), + ); + }); + + expect(editor.prosemirrorState.selection.from).toBe(sourcePos); + expect( + editor.getExtension(SourceInlineContentWithPreviewExtension)!.store + .state.selected, + ).toBe(editable ? mathPos : undefined); + await vi.waitFor(() => + expect( + host + .querySelector(".bn-preview-with-source-popup") + ?.getAttribute("data-open"), + ).toBe(String(editable)), + ); + } finally { + root.unmount(); + editor._tiptapEditor.destroy(); + host.remove(); + } + }, +); From f945b5a356da947a5c4495de3fe7f20ad3330516 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 3 Nov 2026 12:35:10 +0100 Subject: [PATCH 2/5] fix(core): close inline source popup when switching to read-only --- .../SourceInlineContentWithPreview.ts | 11 +++++++++++ .../sourcePopup.browser.test.tsx | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts b/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts index bc168d9e7a..67168e943e 100644 --- a/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts +++ b/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts @@ -5,6 +5,7 @@ import { createExtension, createStore, } from "../../editor/BlockNoteExtension.js"; +import { ReadOnlyExtension } from "../ReadOnly/ReadOnly.js"; /** * Inline-content counterpart of {@link SourceBlockWithPreviewExtension}. A @@ -107,6 +108,16 @@ export const SourceInlineContentWithPreviewExtension = createExtension( }); signal.addEventListener("abort", unsubscribeSelectionChange); + // Editability can change without moving the selection out of the source. + const unsubscribeReadOnlyChange = editor + .getExtension(ReadOnlyExtension)! + .store.subscribe(() => { + if (!editor.isEditable) { + store.setState({ selected: undefined }); + } + }); + signal.addEventListener("abort", unsubscribeReadOnlyChange); + // 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 // to the browser for navigation. Therefore, the up/down arrows can sometimes move the diff --git a/packages/math-block/src/inlineContent/sourcePopup.browser.test.tsx b/packages/math-block/src/inlineContent/sourcePopup.browser.test.tsx index f514c64f33..047801441b 100644 --- a/packages/math-block/src/inlineContent/sourcePopup.browser.test.tsx +++ b/packages/math-block/src/inlineContent/sourcePopup.browser.test.tsx @@ -67,6 +67,25 @@ it.each([false, true])( ?.getAttribute("data-open"), ).toBe(String(editable)), ); + + if (editable) { + flushSync(() => { + editor.isEditable = false; + }); + + expect(editor.prosemirrorState.selection.from).toBe(sourcePos); + expect( + editor.getExtension(SourceInlineContentWithPreviewExtension)!.store + .state.selected, + ).toBeUndefined(); + await vi.waitFor(() => + expect( + host + .querySelector(".bn-preview-with-source-popup") + ?.getAttribute("data-open"), + ).toBe("false"), + ); + } } finally { root.unmount(); editor._tiptapEditor.destroy(); From 56f42591abd506f9fef9158492bca751c841006e Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 3 Nov 2026 13:11:37 +0100 Subject: [PATCH 3/5] fix(core): sync inline source popups with read-only state --- .../SourceInlineContentWithPreview.ts | 38 +++++++----- .../sourcePopup.browser.test.tsx | 59 +++++++++++++++++-- 2 files changed, 78 insertions(+), 19 deletions(-) diff --git a/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts b/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts index 67168e943e..7d2633eb78 100644 --- a/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts +++ b/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts @@ -95,28 +95,36 @@ export const SourceInlineContentWithPreviewExtension = createExtension( mount: ({ dom, signal }) => { // The popup is open when the selection is inside the inline content // and the editor is editable. Read-only editors still allow selection. - const unsubscribeSelectionChange = editor.onSelectionChange(() => { - const { $from } = editor.prosemirrorState.selection; - const node = $from.node(); - - store.setState({ - selected: - editor.isEditable && nodeHasPreview(node.type.name) - ? $from.before() - : undefined, + function updateSelected(isEditable: boolean) { + editor.transact((tr) => { + const { $from } = tr.selection; + store.setState({ + selected: + isEditable && nodeHasPreview($from.node().type.name) + ? $from.before() + : undefined, + }); }); + } + + const unsubscribeSelectionChange = editor.onSelectionChange(() => { + updateSelected(editor.isEditable); }); signal.addEventListener("abort", unsubscribeSelectionChange); // Editability can change without moving the selection out of the source. const unsubscribeReadOnlyChange = editor - .getExtension(ReadOnlyExtension)! - .store.subscribe(() => { - if (!editor.isEditable) { - store.setState({ selected: undefined }); - } + .getExtension(ReadOnlyExtension) + ?.store.subscribe(({ currentVal }) => { + // The view may still reflect the previous editability inside a + // batched transaction, so use the updated read-only state directly. + updateSelected( + currentVal.isEditable && currentVal.enabledSet.size === 0, + ); }); - signal.addEventListener("abort", unsubscribeReadOnlyChange); + if (unsubscribeReadOnlyChange) { + signal.addEventListener("abort", unsubscribeReadOnlyChange); + } // 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/math-block/src/inlineContent/sourcePopup.browser.test.tsx b/packages/math-block/src/inlineContent/sourcePopup.browser.test.tsx index 047801441b..f388233754 100644 --- a/packages/math-block/src/inlineContent/sourcePopup.browser.test.tsx +++ b/packages/math-block/src/inlineContent/sourcePopup.browser.test.tsx @@ -14,9 +14,13 @@ const schema = BlockNoteSchema.create().extend({ inlineContentSpecs: { math: createReactInlineMathSpec() }, }); -it.each([false, true])( - "only opens selected inline math when editable (editable: %s, #3291)", - async (editable) => { +it.each([ + { editable: false, batched: false }, + { editable: true, batched: false }, + { editable: true, batched: true }, +])( + "inline math popup follows editability (#3291): %j", + async ({ editable, batched }) => { const editor = BlockNoteEditor.create({ schema, trailingBlock: false, @@ -70,7 +74,13 @@ it.each([false, true])( if (editable) { flushSync(() => { - editor.isEditable = false; + if (batched) { + editor.transact(() => { + editor.isEditable = false; + }); + } else { + editor.isEditable = false; + } }); expect(editor.prosemirrorState.selection.from).toBe(sourcePos); @@ -85,6 +95,29 @@ it.each([false, true])( ?.getAttribute("data-open"), ).toBe("false"), ); + + flushSync(() => { + if (batched) { + editor.transact(() => { + editor.isEditable = true; + }); + } else { + editor.isEditable = true; + } + }); + + expect(editor.prosemirrorState.selection.from).toBe(sourcePos); + expect( + editor.getExtension(SourceInlineContentWithPreviewExtension)!.store + .state.selected, + ).toBe(mathPos); + await vi.waitFor(() => + expect( + host + .querySelector(".bn-preview-with-source-popup") + ?.getAttribute("data-open"), + ).toBe("true"), + ); } } finally { root.unmount(); @@ -93,3 +126,21 @@ it.each([false, true])( } }, ); + +it("mounts inline source popup tracking without the readOnly extension", () => { + const editor = BlockNoteEditor.create({ disableExtensions: ["readOnly"] }); + const host = document.createElement("div"); + document.body.append(host); + + try { + editor.mount(host); + expect(editor.prosemirrorView).toBeDefined(); + expect( + editor.getExtension(SourceInlineContentWithPreviewExtension)!.store.state + .selected, + ).toBeUndefined(); + } finally { + editor._tiptapEditor.destroy(); + host.remove(); + } +}); From d207071f55a9e72e89269be92c4963c20ddb3366 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 3 Nov 2026 15:20:53 +0100 Subject: [PATCH 4/5] fix(core): respect effective view editability for inline source popups --- .../SourceInlineContentWithPreview.ts | 68 +++++++++---------- .../sourcePopup.browser.test.tsx | 56 +++++++++++---- 2 files changed, 75 insertions(+), 49 deletions(-) diff --git a/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts b/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts index 7d2633eb78..f92324329a 100644 --- a/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts +++ b/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts @@ -1,11 +1,10 @@ -import { Selection, TextSelection } from "prosemirror-state"; +import { Plugin, Selection, TextSelection } from "prosemirror-state"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; import { createExtension, createStore, } from "../../editor/BlockNoteExtension.js"; -import { ReadOnlyExtension } from "../ReadOnly/ReadOnly.js"; /** * Inline-content counterpart of {@link SourceBlockWithPreviewExtension}. A @@ -15,7 +14,8 @@ import { ReadOnlyExtension } from "../ReadOnly/ReadOnly.js"; * 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 @@ -39,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 plugin view update // 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 @@ -68,6 +68,33 @@ export const SourceInlineContentWithPreviewExtension = createExtension( return { key: "sourceInlineContentWithPreview", store, + prosemirrorPlugins: [ + new Plugin({ + view: (view) => { + let wasEditable = view.editable; + return { + update(view, prevState) { + if ( + view.editable === wasEditable && + view.state.selection.eq(prevState.selection) + ) { + return; + } + wasEditable = view.editable; + // Read effective editability after the view has applied the + // transaction, including batched toggles and plugin restrictions. + const { $from } = view.state.selection; + store.setState({ + selected: + view.editable && nodeHasPreview($from.node().type.name) + ? $from.before() + : undefined, + }); + }, + }; + }, + }), + ], keyboardShortcuts: { Enter: moveSelectionOut("after"), "Shift-Enter": moveSelectionOut("after"), @@ -93,39 +120,6 @@ export const SourceInlineContentWithPreviewExtension = createExtension( }, }, mount: ({ dom, signal }) => { - // The popup is open when the selection is inside the inline content - // and the editor is editable. Read-only editors still allow selection. - function updateSelected(isEditable: boolean) { - editor.transact((tr) => { - const { $from } = tr.selection; - store.setState({ - selected: - isEditable && nodeHasPreview($from.node().type.name) - ? $from.before() - : undefined, - }); - }); - } - - const unsubscribeSelectionChange = editor.onSelectionChange(() => { - updateSelected(editor.isEditable); - }); - signal.addEventListener("abort", unsubscribeSelectionChange); - - // Editability can change without moving the selection out of the source. - const unsubscribeReadOnlyChange = editor - .getExtension(ReadOnlyExtension) - ?.store.subscribe(({ currentVal }) => { - // The view may still reflect the previous editability inside a - // batched transaction, so use the updated read-only state directly. - updateSelected( - currentVal.isEditable && currentVal.enabledSet.size === 0, - ); - }); - if (unsubscribeReadOnlyChange) { - signal.addEventListener("abort", unsubscribeReadOnlyChange); - } - // 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 // to the browser for navigation. Therefore, the up/down arrows can sometimes move the diff --git a/packages/math-block/src/inlineContent/sourcePopup.browser.test.tsx b/packages/math-block/src/inlineContent/sourcePopup.browser.test.tsx index f388233754..1e9172d4b8 100644 --- a/packages/math-block/src/inlineContent/sourcePopup.browser.test.tsx +++ b/packages/math-block/src/inlineContent/sourcePopup.browser.test.tsx @@ -1,9 +1,13 @@ import "@blocknote/core/style.css"; -import { BlockNoteEditor, BlockNoteSchema } from "@blocknote/core"; +import { + BlockNoteEditor, + BlockNoteSchema, + createExtension, +} from "@blocknote/core"; import { SourceInlineContentWithPreviewExtension } from "@blocknote/core/extensions"; import { BlockNoteViewRaw } from "@blocknote/react"; -import { TextSelection } from "prosemirror-state"; +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"; @@ -15,14 +19,23 @@ const schema = BlockNoteSchema.create().extend({ }); it.each([ - { editable: false, batched: false }, - { editable: true, batched: false }, - { editable: true, batched: true }, + { 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 }) => { + 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: [ { @@ -63,13 +76,13 @@ it.each([ expect( editor.getExtension(SourceInlineContentWithPreviewExtension)!.store .state.selected, - ).toBe(editable ? mathPos : undefined); + ).toBe(editable && !pluginReadOnly ? mathPos : undefined); await vi.waitFor(() => expect( host .querySelector(".bn-preview-with-source-popup") ?.getAttribute("data-open"), - ).toBe(String(editable)), + ).toBe(String(editable && !pluginReadOnly)), ); if (editable) { @@ -110,13 +123,13 @@ it.each([ expect( editor.getExtension(SourceInlineContentWithPreviewExtension)!.store .state.selected, - ).toBe(mathPos); + ).toBe(pluginReadOnly ? undefined : mathPos); await vi.waitFor(() => expect( host .querySelector(".bn-preview-with-source-popup") ?.getAttribute("data-open"), - ).toBe("true"), + ).toBe(String(!pluginReadOnly)), ); } } finally { @@ -128,17 +141,36 @@ it.each([ ); it("mounts inline source popup tracking without the readOnly extension", () => { - const editor = BlockNoteEditor.create({ disableExtensions: ["readOnly"] }); + const editor = BlockNoteEditor.create({ + schema, + disableExtensions: ["readOnly"], + 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, - ).toBeUndefined(); + ).toBe(mathPos); } finally { editor._tiptapEditor.destroy(); host.remove(); From e90d6c2820f7854e121daf54c329444e570d4663 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 4 Nov 2026 18:46:02 +0100 Subject: [PATCH 5/5] refactor(core): centralize editor editability and popup subscriptions --- docs/content/docs/reference/editor/events.mdx | 20 + packages/core/src/editor/BlockNoteEditor.ts | 52 +- .../managers/ExtensionManager/extensions.ts | 2 - .../editor/managers/ExtensionManager/index.ts | 16 + .../src/editor/managers/StateManager.test.ts | 443 ++++++++++++++++++ .../core/src/editor/managers/StateManager.ts | 146 ++++-- .../src/extensions/Loading/Loading.test.ts | 19 +- .../core/src/extensions/Loading/Loading.ts | 4 +- .../src/extensions/ReadOnly/ReadOnly.test.ts | 181 ------- .../core/src/extensions/ReadOnly/ReadOnly.ts | 73 --- .../SourceInlineContentWithPreview.ts | 57 ++- .../Versioning/Versioning.node.test.ts | 3 +- .../src/extensions/Versioning/Versioning.ts | 7 +- packages/core/src/extensions/index.ts | 1 - .../sourcePopup.browser.test.tsx | 67 ++- 15 files changed, 710 insertions(+), 381 deletions(-) create mode 100644 packages/core/src/editor/managers/StateManager.test.ts delete mode 100644 packages/core/src/extensions/ReadOnly/ReadOnly.test.ts delete mode 100644 packages/core/src/extensions/ReadOnly/ReadOnly.ts diff --git a/docs/content/docs/reference/editor/events.mdx b/docs/content/docs/reference/editor/events.mdx index facac2a8f4..8602d9bd18 100644 --- a/docs/content/docs/reference/editor/events.mdx +++ b/docs/content/docs/reference/editor/events.mdx @@ -14,6 +14,7 @@ The editor emits events for: - **Editor lifecycle** - When the editor is created, mounted, unmounted, etc. - **Content changes** - When blocks are inserted, updated, or deleted - **Selection changes** - When the cursor position or selection changes +- **Editability changes** - When the editor becomes editable or read-only - **Focus changes** - When focus enters or leaves the editor ## `onMount` @@ -53,6 +54,25 @@ editor.onSelectionChange((editor) => { }); ``` +## `onEditableChange` + +The `onEditableChange` callback receives a boolean indicating whether the editor is currently editable, matching `editor.isEditable`. It runs only when that value changes, including when a feature temporarily prevents editing. + +The callback is not called when you subscribe. Read `editor.isEditable` for the initial state. + +```typescript +console.log("Initially editable:", editor.isEditable); + +const unsubscribe = editor.onEditableChange((editable) => { + console.log("Editor is editable:", editable); +}); + +// When you no longer need the listener: +unsubscribe(); +``` + +Changing editability does not change the document or trigger `onChange`. + ## `onFocusChange` The `onFocusChange` callback receives the editor and a context containing `focused` (a boolean) and `event` (the triggering DOM `FocusEvent`). By default, it reports focus entering or leaving the content area. diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 9c3586cd86..29919ed19f 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -4,6 +4,7 @@ import { FocusPosition, getSchema, Editor as TiptapEditor, + Extension as TiptapExtension, } from "@tiptap/core"; import { type Command, type Transaction } from "@tiptap/pm/state"; import { Node, Schema } from "prosemirror-model"; @@ -520,14 +521,28 @@ export class BlockNoteEditor< this.resolveFileUrl = newOptions.resolveFileUrl; this._eventManager = new EventManager(this as any); - this._extensionManager = new ExtensionManager(this, newOptions); + this._stateManager = new StateManager( + this as any, + newOptions._tiptapOptions?.editable, + ); + this._extensionManager = new ExtensionManager( + this, + newOptions, + this._stateManager, + ); const tiptapExtensions = this._extensionManager.getTiptapExtensions(); + tiptapExtensions.push( + TiptapExtension.create({ + name: "blocknote-state", + addProseMirrorPlugins: () => this._stateManager.prosemirrorPlugins, + }), + ); const tiptapOptions: EditorOptions = { ...blockNoteTipTapOptions, ...newOptions._tiptapOptions, - // ReadOnlyExtension owns editability, including the initial application preference. + // StateManager owns editability, including the initial application preference. editable: true, element: null, autofocus: newOptions.autofocus ?? false, @@ -625,7 +640,6 @@ export class BlockNoteEditor< this._exportManager = new ExportManager(this as any); this._selectionManager = new SelectionManager(this as any); - this._stateManager = new StateManager(this as any); this._styleManager = new StyleManager(this as any); this.emit("create"); @@ -738,7 +752,7 @@ export class BlockNoteEditor< */ public registerExtension: ExtensionManager["registerExtension"] = ( ...args: Parameters - ) => this._extensionManager.registerExtension(...args) as any; + ) => this._extensionManager.registerExtension(...args); /** * Atomically unregister old extensions and register new ones in a single @@ -784,11 +798,17 @@ export class BlockNoteEditor< * @warning Not needed to call manually when using React, use BlockNoteView to take care of mounting */ 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; + const previous = this._stateManager.updatingView; + this._stateManager.updatingView = true; + try { + 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; + } finally { + this._stateManager.updatingView = previous; + } }; /** @@ -1140,6 +1160,20 @@ export class BlockNoteEditor< this._stateManager.isEditable = editable; } + /** Subscribe to editability changes. Returns an unsubscribe function. */ + public onEditableChange(callback: (editable: boolean) => void) { + return this._stateManager.onEditableChange(callback); + } + + /** + * Apply or release a feature's read-only restriction. Repeated calls with the + * same key are idempotent. Releasing one key preserves other restrictions + * and the application's isEditable preference. + */ + public setReadOnly(readOnly: boolean, key: string) { + this._stateManager.setReadOnly(readOnly, key); + } + /** * Inserts new blocks into the editor. If a block's `id` is undefined, BlockNote generates one automatically. Throws an * error if the reference block could not be found. diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts index 2090cea491..ef6e89889d 100644 --- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts +++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts @@ -22,7 +22,6 @@ import { PlaceholderExtension, PositionMappingExtension, PreviousBlockTypeExtension, - ReadOnlyExtension, ShowSelectionExtension, SideMenuExtension, SourceBlockWithPreviewExtension, @@ -178,7 +177,6 @@ export function getDefaultExtensions( LoadingExtension(), NodeSelectionKeyboardExtension(), PlaceholderExtension(options), - ReadOnlyExtension({ editable: options._tiptapOptions?.editable }), ShowSelectionExtension(options), SideMenuExtension(options), SourceBlockWithPreviewExtension(), diff --git a/packages/core/src/editor/managers/ExtensionManager/index.ts b/packages/core/src/editor/managers/ExtensionManager/index.ts index 4b19e8d17d..7424bd6820 100644 --- a/packages/core/src/editor/managers/ExtensionManager/index.ts +++ b/packages/core/src/editor/managers/ExtensionManager/index.ts @@ -29,6 +29,7 @@ import type { ExtensionFactoryInstance, ExtensionFactory, } from "../../BlockNoteExtension.js"; +import type { StateManager } from "../StateManager.js"; import { originalFactorySymbol } from "./symbol.js"; import { getDefaultExtensions, @@ -83,6 +84,7 @@ export class ExtensionManager { constructor( private editor: BlockNoteEditor, private options: BlockNoteEditorOptions, + private stateManager: StateManager, ) { /** * When the editor is first mounted, we need to initialize all the extensions @@ -326,6 +328,20 @@ export class ExtensionManager { | ExtensionFactoryInstance | (Extension | ExtensionFactoryInstance)[], options?: { resetPluginStateFor: readonly PluginKey[] }, + ): void { + const previous = this.stateManager.updatingView; + this.stateManager.updatingView = true; + try { + this.updateExtensions(toUnregister, toRegister, options); + } finally { + this.stateManager.updatingView = previous; + } + } + + private updateExtensions( + ...[toUnregister, toRegister, options]: Parameters< + ExtensionManager["replaceExtension"] + > ): void { const extensionsToRemove = new Set(this.resolveExtensions(toUnregister)); diff --git a/packages/core/src/editor/managers/StateManager.test.ts b/packages/core/src/editor/managers/StateManager.test.ts new file mode 100644 index 0000000000..4269ba4ea8 --- /dev/null +++ b/packages/core/src/editor/managers/StateManager.test.ts @@ -0,0 +1,443 @@ +/** @vitest-environment jsdom */ +import { Plugin, TextSelection } from "prosemirror-state"; +import { + afterEach, + beforeEach, + describe, + expect, + 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"; + +describe("StateManager editability", () => { + let editor: BlockNoteEditor; + + beforeEach(() => { + editor = BlockNoteEditor.create(); + editor.mount(document.createElement("div")); + }); + + afterEach(() => editor.unmount()); + + const restriction = createExtension(() => ({ + key: "testRestriction", + 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) => { + editor.unmount(); + const createView = vi.fn(); + const destroyView = vi.fn(); + const updateView = vi.fn(); + const observer = createExtension(() => ({ + key: "testObserver", + prosemirrorPlugins: [ + new Plugin({ + view() { + createView(); + return { update: updateView, destroy: destroyView }; + }, + }), + ], + })); + editor = BlockNoteEditor.create({ + extensions: + operation === "mount" || operation === "unregister" + ? [restriction()] + : [], + }); + if (operation !== "mount") { + editor.mount(document.createElement("div")); + } + const observed: boolean[] = []; + const unsubscribe = editor.onEditableChange((editable) => { + observed.push(editable); + editor.registerExtension(observer()); + }); + if (operation === "mount") { + editor.mount(document.createElement("div")); + } else if (operation === "register") { + editor.registerExtension(restriction()); + } else if (operation === "replace") { + editor.replaceExtension(undefined, restriction()); + } else { + editor.unregisterExtension(restriction); + } + expect(observed).toEqual([operation === "unregister"]); + expect(createView).toHaveBeenCalledTimes(1); + editor.transact((tr) => tr.setMeta("testUpdate", true)); + expect(updateView).toHaveBeenCalledTimes(1); + unsubscribe(); + editor.unmount(); + expect(destroyView).toHaveBeenCalledTimes(1); + }, + ); + + it("keeps editing disabled until every feature releases its restriction", () => { + editor.setReadOnly(true, "preview"); + editor.setReadOnly(true, "upload"); + editor.setReadOnly(true, "upload"); + expect(editor.isEditable).toBe(false); + + editor.setReadOnly(false, "preview"); + editor.setReadOnly(false, "unrelated"); + expect(editor.isEditable).toBe(false); + + editor.setReadOnly(false, "upload"); + expect(editor.isEditable).toBe(true); + }); + + it("preserves the application's latest editable setting", () => { + editor.setReadOnly(true, "preview"); + editor.isEditable = true; + expect(editor.isEditable).toBe(false); + + editor.isEditable = false; + editor.setReadOnly(false, "preview"); + expect(editor.isEditable).toBe(false); + + editor.isEditable = true; + expect(editor.isEditable).toBe(true); + }); + + it("notifies transaction subscribers without reporting document changes", () => { + const changes = vi.fn(); + const editableStates: boolean[] = []; + editor.onChange(changes); + editor._tiptapEditor.on("transaction", () => { + editableStates.push(editor.isEditable); + }); + + editor.setReadOnly(true, "preview"); + expect(editableStates.length).toBeGreaterThan(0); + expect(editableStates.every((editable) => !editable)).toBe(true); + editableStates.length = 0; + editor.setReadOnly(true, "preview"); + expect(editableStates).toEqual([]); + editor.setReadOnly(false, "preview"); + + expect(editableStates.length).toBeGreaterThan(0); + expect(editableStates.every((editable) => editable)).toBe(true); + expect(changes).not.toHaveBeenCalled(); + }); + + it("uses editable metadata for both inputs and skips changes that keep editing locked", () => { + const metadata: unknown[] = []; + const changes = vi.fn(); + editor.onChange(changes); + editor._tiptapEditor.on("transaction", ({ transaction }) => { + metadata.push(transaction.getMeta("editable")); + }); + + editor.isEditable = false; + expect(metadata.filter((value) => value !== undefined)).toEqual([true]); + metadata.length = 0; + editor.setReadOnly(true, "preview"); + editor.isEditable = true; + editor.setReadOnly(true, "upload"); + editor.setReadOnly(false, "preview"); + expect(metadata).toEqual([]); + expect(editor.isEditable).toBe(false); + + editor.setReadOnly(false, "upload"); + expect(metadata.filter((value) => value !== undefined)).toEqual([true]); + expect(editor.isEditable).toBe(true); + expect(changes).not.toHaveBeenCalled(); + }); + + it("applies initial editability and preserves it across remounts", () => { + editor.unmount(); + editor = BlockNoteEditor.create({ _tiptapOptions: { editable: false } }); + editor.mount(document.createElement("div")); + expect(editor.isEditable).toBe(false); + expect(editor.prosemirrorView.editable).toBe(false); + + editor.isEditable = true; + expect(editor.isEditable).toBe(true); + editor.isEditable = false; + editor.unmount(); + editor.mount(document.createElement("div")); + expect(editor.prosemirrorView.editable).toBe(false); + }); + + it("honours editability set before mount", () => { + editor.unmount(); + editor = BlockNoteEditor.create(); + editor.isEditable = false; + expect(editor.isEditable).toBe(false); + editor.mount(document.createElement("div")); + expect(editor.isEditable).toBe(false); + expect(editor.prosemirrorView.editable).toBe(false); + }); + + it("groups application editability changes into the pending transaction", () => { + const transactions = vi.fn(); + const changes = vi.fn(); + editor._tiptapEditor.on("transaction", transactions); + editor.onChange(changes); + + editor.transact(() => { + editor.isEditable = false; + }); + expect(editor.isEditable).toBe(false); + expect(transactions).toHaveBeenCalled(); + expect(changes).not.toHaveBeenCalled(); + + transactions.mockClear(); + editor.transact((tr) => { + tr.insertText("hello", 1); + editor.isEditable = true; + }); + expect(editor.isEditable).toBe(true); + expect(editor.prosemirrorState.doc.textContent).toContain("hello"); + expect(transactions).toHaveBeenCalled(); + expect(changes).toHaveBeenCalledTimes(1); + }); + + it("composes with pending document and metadata-only transactions", () => { + editor.transact((tr) => { + tr.insertText("hello", 1); + editor.setReadOnly(true, "preview"); + }); + expect(editor.prosemirrorState.doc.textContent).toContain("hello"); + expect(editor.isEditable).toBe(false); + + editor.transact(() => editor.setReadOnly(false, "preview")); + expect(editor.isEditable).toBe(true); + }); + + it("publishes changes to a plugin's editability restriction", () => { + editor.unmount(); + let pluginAllowsEditing = true; + const restriction = createExtension(() => ({ + key: "testEditability", + prosemirrorPlugins: [ + new Plugin({ props: { editable: () => pluginAllowsEditing } }), + ], + })); + editor = BlockNoteEditor.create({ extensions: [restriction()] }); + expect(editor.isEditable).toBe(true); + editor.mount(document.createElement("div")); + + const observed: boolean[] = []; + const unsubscribe = editor.onEditableChange((editable) => { + expect(editable).toBe(editor.isEditable); + observed.push(editable); + }); + const changes = vi.fn(); + editor.onChange(changes); + + pluginAllowsEditing = false; + editor.transact((tr) => tr.setMeta("testEditability", true)); + expect(editor.isEditable).toBe(false); + expect(observed).toEqual([false]); + + pluginAllowsEditing = true; + editor.transact((tr) => tr.setMeta("testEditability", true)); + expect(editor.isEditable).toBe(true); + expect(observed).toEqual([false, true]); + + expect(changes).not.toHaveBeenCalled(); + unsubscribe(); + }); + + it.each( + [false, true].flatMap((mounted) => + [false, true].flatMap((initiallyEditable) => + [false, true].map((pluginAllowsEditing) => ({ + mounted, + initiallyEditable, + pluginAllowsEditing, + })), + ), + ), + )( + "keeps getters and subscriptions consistent (mounted=$mounted, initial=$initiallyEditable, plugin=$pluginAllowsEditing)", + ({ mounted, initiallyEditable, pluginAllowsEditing }) => { + editor.unmount(); + const restriction = createExtension(() => ({ + key: "testEditability", + prosemirrorPlugins: [ + new Plugin({ props: { editable: () => pluginAllowsEditing } }), + ], + })); + editor = BlockNoteEditor.create({ + _tiptapOptions: { editable: initiallyEditable }, + extensions: [restriction()], + }); + if (mounted) { + editor.mount(document.createElement("div")); + } + const observed: boolean[] = []; + const expected: boolean[] = []; + let previous = initiallyEditable && (!mounted || pluginAllowsEditing); + const unsubscribe = editor.onEditableChange((editable) => { + expect(editor.isEditable).toBe(editable); + observed.push(editable); + }); + + function expectEditable(editable: boolean) { + expect(editor.isEditable).toBe(editable); + if (mounted) { + expect(editor.prosemirrorView.editable).toBe(editable); + } + if (previous !== editable) { + expected.push(editable); + previous = editable; + } + expect(observed).toEqual(expected); + } + + expectEditable(previous); + editor.isEditable = !initiallyEditable; + expectEditable(!initiallyEditable && (!mounted || pluginAllowsEditing)); + editor.setReadOnly(true, "preview"); + expectEditable(false); + editor.setReadOnly(true, "preview"); + expectEditable(false); + editor.setReadOnly(true, "upload"); + editor.setReadOnly(false, "preview"); + editor.isEditable = true; + expectEditable(false); + editor.setReadOnly(false, "upload"); + expectEditable(!mounted || pluginAllowsEditing); + editor.setReadOnly(false, "unrelated"); + editor.isEditable = true; + expectEditable(!mounted || pluginAllowsEditing); + editor.setReadOnly(true, "preview"); + expectEditable(false); + editor.isEditable = false; + expectEditable(false); + editor.setReadOnly(false, "preview"); + expectEditable(false); + + unsubscribe(); + unsubscribe(); + editor.isEditable = true; + editor.setReadOnly(true, "preview"); + editor.setReadOnly(false, "preview"); + expect(observed).toEqual(expected); + }, + ); + + it.each(["application", "feature"] as const)( + "honours %s restrictions before a pending transaction reaches the view", + (source) => { + const observed = vi.fn((editable: boolean) => { + expect(editor.isEditable).toBe(editable); + }); + const unsubscribe = editor.onEditableChange(observed); + + function setReadOnly(restricted: boolean) { + if (source === "application") { + editor.isEditable = !restricted; + } else { + editor.setReadOnly(restricted, "preview"); + } + } + + editor.transact(() => { + setReadOnly(true); + // The view has not received the transaction yet, but cannot override + // the application's or a feature's read-only restriction. + expect(editor.prosemirrorView.editable).toBe(true); + expect(editor.isEditable).toBe(false); + expect(observed.mock.calls).toEqual([[false]]); + }); + expect(editor.prosemirrorView.editable).toBe(false); + editor.transact(() => setReadOnly(false)); + expect(editor.prosemirrorView.editable).toBe(true); + expect(editor.isEditable).toBe(true); + expect(observed.mock.calls).toEqual([[false], [true]]); + unsubscribe(); + }, + ); + + it("rechecks plugin restrictions on mount and clears them on unmount", () => { + editor.unmount(); + const restriction = createExtension(() => ({ + key: "testEditability", + prosemirrorPlugins: [new Plugin({ props: { editable: () => false } })], + })); + editor = BlockNoteEditor.create({ extensions: [restriction()] }); + const observed = vi.fn(); + const unsubscribe = editor.onEditableChange(observed); + const host = document.createElement("div"); + + expect(editor.isEditable).toBe(true); + editor.mount(host); + expect(editor.isEditable).toBe(false); + editor.unmount(); + expect(editor.isEditable).toBe(true); + editor.mount(host); + expect(editor.isEditable).toBe(false); + expect(observed.mock.calls).toEqual([[false], [true], [false]]); + unsubscribe(); + }); +}); diff --git a/packages/core/src/editor/managers/StateManager.ts b/packages/core/src/editor/managers/StateManager.ts index c6a2edbdcb..01501ec254 100644 --- a/packages/core/src/editor/managers/StateManager.ts +++ b/packages/core/src/editor/managers/StateManager.ts @@ -1,10 +1,83 @@ -import { Command, Transaction } from "prosemirror-state"; -import { ReadOnlyExtension } from "../../extensions/ReadOnly/ReadOnly.js"; +import { Command, Plugin, PluginKey, Transaction } from "prosemirror-state"; +import type { EditorView } from "prosemirror-view"; import type { HistoryExtension } from "../../extensions/History/History.js"; +import { Store } from "../../util/Store.js"; import { BlockNoteEditor } from "../BlockNoteEditor.js"; +/** Editor-owned permissions and the additional restriction observed from the view. */ +type EditabilityState = { + /** The application's requested editing preference. */ + editable: boolean; + /** Independent feature keys currently preventing editing. */ + restrictions: Set; + /** Last mounted view's permission, or undefined when no view is mounted. */ + viewIsEditable: boolean | undefined; + /** Suppress callbacks while ProseMirror is constructing plugin views. */ + updatingView: boolean; +}; + export class StateManager { - constructor(private editor: BlockNoteEditor) {} + private readonly editability: Store; + + constructor( + private editor: BlockNoteEditor, + editable = true, + ) { + this.editability = new Store( + { + editable, + restrictions: new Set(), + viewIsEditable: undefined, + updatingView: false, + }, + { + onUpdate: (state, previous) => { + if ( + (state.editable && state.restrictions.size === 0) !== + (previous.editable && previous.restrictions.size === 0) && + !editor.headless + ) { + // Reuse a pending transaction without reporting a document change. + this.transact((tr) => tr.setMeta("editable", true)); + } + }, + }, + ); + editor.onUnmount(() => { + // Plugin views can be recreated while mounted. Only discard their + // observed restriction when the editor actually unmounts. + this.editability.setState({ + ...this.editability.state, + viewIsEditable: undefined, + }); + }); + } + + /** Bridges editor-owned editability to ProseMirror, independently of extensions. */ + public readonly prosemirrorPlugins = [ + new Plugin({ + key: new PluginKey("bn-editability"), + props: { + editable: () => { + const { editable, restrictions } = this.editability.state; + return editable && restrictions.size === 0; + }, + }, + view: (view) => { + const store = this.editability; + function syncEditability(view: EditorView) { + if (store.state.viewIsEditable !== view.editable) { + store.setState({ + ...store.state, + viewIsEditable: view.editable, + }); + } + } + syncEditability(view); + return { update: syncEditability }; + }, + }), + ]; /** * Stores the currently active transaction, which is the accumulated transaction from all {@link dispatch} calls during a {@link transact} calls @@ -183,26 +256,9 @@ export class StateManager { * @returns True if the editor is editable, false otherwise. */ public get isEditable(): boolean { - if (!this.editor._tiptapEditor) { - if (!this.editor.headless) { - throw new Error("no editor, but also not headless?"); - } - return false; - } - if (this.editor.headless) { - // No live view while unmounted, so tiptap can't consult plugin props - // (its unmounted view stub reports editable: true). Mirror the - // ReadOnly plugin's `editable` prop directly so the application - // preference and feature restrictions still read back correctly, - // e.g. for static/server-side rendering via block render functions. - const state = this.editor.getExtension(ReadOnlyExtension)?.store.state; - if (state) { - return state.isEditable && state.enabledSet.size === 0; - } - } - return this.editor._tiptapEditor.isEditable === undefined - ? true - : this.editor._tiptapEditor.isEditable; + const { editable, restrictions, viewIsEditable } = this.editability.state; + // Internal restrictions always win; other plugins can only restrict further. + return editable && restrictions.size === 0 && viewIsEditable !== false; } /** @@ -210,14 +266,46 @@ export class StateManager { * @param editable True to make the editor editable, or false to lock it. */ public set isEditable(editable: boolean) { - if (!this.editor._tiptapEditor) { - if (!this.editor.headless) { - throw new Error("no editor, but also not headless?"); - } - // not relevant on headless + if (this.editability.state.editable === editable) { return; } - this.editor.getExtension(ReadOnlyExtension)!.setEditable(editable); + this.editability.setState({ ...this.editability.state, editable }); + } + + public setReadOnly(readOnly: boolean, key: string) { + const restrictions = new Set(this.editability.state.restrictions); + if (readOnly) { + restrictions.add(key); + } else { + restrictions.delete(key); + } + this.editability.setState({ ...this.editability.state, restrictions }); + } + + public onEditableChange(callback: (editable: boolean) => void) { + let previous = this.isEditable; + return this.editability.subscribe(() => { + if (this.updatingView) { + return; + } + const editable = this.isEditable; + if (editable !== previous) { + previous = editable; + callback(editable); + } + }); + } + + /** Mount/reconfiguration restores this flag when plugin views are ready. */ + public get updatingView(): boolean { + return this.editability.state.updatingView; + } + + public set updatingView(updatingView: boolean) { + if (this.updatingView !== updatingView) { + // Releasing the flag also notifies subscribers of the final editability. + this.editability.setState({ ...this.editability.state, updatingView }); + } } /** diff --git a/packages/core/src/extensions/Loading/Loading.test.ts b/packages/core/src/extensions/Loading/Loading.test.ts index 523912e1c2..457c639722 100644 --- a/packages/core/src/extensions/Loading/Loading.test.ts +++ b/packages/core/src/extensions/Loading/Loading.test.ts @@ -1,16 +1,14 @@ // @vitest-environment node import { afterEach, expect, it } from "vite-plus/test"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import { ReadOnlyExtension } from "../ReadOnly/ReadOnly.js"; import { LoadingExtension } from "./Loading.js"; const editor = BlockNoteEditor.create(); const loading = editor.getExtension(LoadingExtension)!; -const readOnly = editor.getExtension(ReadOnlyExtension)!; afterEach(() => { editor.isEditable = true; - readOnly.setReadOnly(false, "preview"); + editor.setReadOnly(false, "preview"); }); it("blocks editing until every loading token is released", () => { @@ -32,24 +30,29 @@ it("blocks editing until every loading token is released", () => { it("preserves application and independent feature restrictions", () => { const release = loading.acquire(); editor.isEditable = false; - readOnly.setReadOnly(true, "preview"); + editor.setReadOnly(true, "preview"); release(); expect(editor.isEditable).toBe(false); editor.isEditable = true; expect(editor.isEditable).toBe(false); - readOnly.setReadOnly(false, "preview"); + editor.setReadOnly(false, "preview"); expect(editor.isEditable).toBe(true); }); -it("tracks tokens without a ReadOnly extension", () => { - const editor = BlockNoteEditor.create({ disableExtensions: ["readOnly"] }); +it("preserves an initially read-only editor when loading completes", () => { + const editor = BlockNoteEditor.create({ + _tiptapOptions: { editable: false }, + }); try { const loading = editor.getExtension(LoadingExtension)!; - expect(editor.getExtension(ReadOnlyExtension)).toBeUndefined(); const release = loading.acquire(); expect(loading.store.state.size).toBe(1); + expect(editor.isEditable).toBe(false); release(); expect(loading.store.state.size).toBe(0); + expect(editor.isEditable).toBe(false); + editor.isEditable = true; + expect(editor.isEditable).toBe(true); } finally { editor._tiptapEditor.destroy(); } diff --git a/packages/core/src/extensions/Loading/Loading.ts b/packages/core/src/extensions/Loading/Loading.ts index 0d9a221679..5839a11b86 100644 --- a/packages/core/src/extensions/Loading/Loading.ts +++ b/packages/core/src/extensions/Loading/Loading.ts @@ -2,7 +2,6 @@ import { createExtension, createStore, } from "../../editor/BlockNoteExtension.js"; -import { ReadOnlyExtension } from "../ReadOnly/ReadOnly.js"; /** Keeps the editor read-only and the loader visible until every token is released. */ export const LoadingExtension = createExtension(({ editor }) => { @@ -11,8 +10,7 @@ export const LoadingExtension = createExtension(({ editor }) => { if (state.size > 0 === prevState.size > 0) { return; } - const readOnly = editor.getExtension(ReadOnlyExtension); - readOnly?.setReadOnly(state.size > 0, "loading"); + editor.setReadOnly(state.size > 0, "loading"); }, }); diff --git a/packages/core/src/extensions/ReadOnly/ReadOnly.test.ts b/packages/core/src/extensions/ReadOnly/ReadOnly.test.ts deleted file mode 100644 index 6cb09d0592..0000000000 --- a/packages/core/src/extensions/ReadOnly/ReadOnly.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** @vitest-environment jsdom */ -import { - afterEach, - beforeEach, - describe, - expect, - it, - vi, -} from "vite-plus/test"; -import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import { ReadOnlyExtension } from "./ReadOnly.js"; - -describe("ReadOnlyExtension", () => { - let editor: BlockNoteEditor; - let readOnly: ReturnType>; - - beforeEach(() => { - editor = BlockNoteEditor.create(); - editor.mount(document.createElement("div")); - readOnly = editor.getExtension(ReadOnlyExtension)!; - }); - - afterEach(() => editor.unmount()); - - it("keeps editing disabled until every feature releases its restriction", () => { - readOnly.setReadOnly(true, "preview"); - readOnly.setReadOnly(true, "upload"); - readOnly.setReadOnly(true, "upload"); - expect(editor.isEditable).toBe(false); - - readOnly.setReadOnly(false, "preview"); - readOnly.setReadOnly(false, "unrelated"); - expect(editor.isEditable).toBe(false); - - readOnly.setReadOnly(false, "upload"); - expect(editor.isEditable).toBe(true); - }); - - it("preserves the application's latest editable setting", () => { - readOnly.setReadOnly(true, "preview"); - editor.isEditable = true; - expect(editor.isEditable).toBe(false); - - editor.isEditable = false; - readOnly.setReadOnly(false, "preview"); - expect(editor.isEditable).toBe(false); - - editor.isEditable = true; - expect(editor.isEditable).toBe(true); - }); - - it("notifies transaction subscribers without reporting document changes", () => { - const changes = vi.fn(); - const editableStates: boolean[] = []; - editor.onChange(changes); - editor._tiptapEditor.on("transaction", () => { - editableStates.push(editor.isEditable); - }); - - readOnly.setReadOnly(true, "preview"); - expect(editableStates.length).toBeGreaterThan(0); - expect(editableStates.every((editable) => !editable)).toBe(true); - editableStates.length = 0; - readOnly.setReadOnly(true, "preview"); - expect(editableStates).toEqual([]); - readOnly.setReadOnly(false, "preview"); - - expect(editableStates.length).toBeGreaterThan(0); - expect(editableStates.every((editable) => editable)).toBe(true); - expect(changes).not.toHaveBeenCalled(); - }); - - it("uses editable metadata for both inputs and skips changes that keep editing locked", () => { - const metadata: unknown[] = []; - const changes = vi.fn(); - editor.onChange(changes); - editor._tiptapEditor.on("transaction", ({ transaction }) => { - metadata.push(transaction.getMeta("editable")); - }); - - editor.isEditable = false; - expect(metadata.filter((value) => value !== undefined)).toEqual([true]); - metadata.length = 0; - readOnly.setReadOnly(true, "preview"); - editor.isEditable = true; - readOnly.setReadOnly(true, "upload"); - readOnly.setReadOnly(false, "preview"); - expect(metadata).toEqual([]); - expect(editor.isEditable).toBe(false); - - readOnly.setReadOnly(false, "upload"); - expect(metadata.filter((value) => value !== undefined)).toEqual([true]); - expect(editor.isEditable).toBe(true); - expect(changes).not.toHaveBeenCalled(); - }); - - it("applies initial editability and preserves it across remounts", () => { - editor.unmount(); - editor = BlockNoteEditor.create({ _tiptapOptions: { editable: false } }); - editor.mount(document.createElement("div")); - expect(editor.isEditable).toBe(false); - expect(editor.prosemirrorView.editable).toBe(false); - - editor.isEditable = true; - expect(editor.isEditable).toBe(true); - editor.isEditable = false; - editor.unmount(); - editor.mount(document.createElement("div")); - expect(editor.prosemirrorView.editable).toBe(false); - }); - - it("reports application and feature editability while unmounted", () => { - editor.unmount(); - editor = BlockNoteEditor.create(); - readOnly = editor.getExtension(ReadOnlyExtension)!; - - expect(editor.isEditable).toBe(true); - - editor.isEditable = false; - expect(editor.isEditable).toBe(false); - - editor.isEditable = true; - expect(editor.isEditable).toBe(true); - - readOnly.setReadOnly(true, "preview"); - expect(editor.isEditable).toBe(false); - - editor.isEditable = false; - readOnly.setReadOnly(false, "preview"); - expect(editor.isEditable).toBe(false); - - editor.isEditable = true; - expect(editor.isEditable).toBe(true); - }); - - it("honours editability set before mount", () => { - editor.unmount(); - editor = BlockNoteEditor.create(); - editor.isEditable = false; - expect(editor.isEditable).toBe(false); - editor.mount(document.createElement("div")); - expect(editor.isEditable).toBe(false); - expect(editor.prosemirrorView.editable).toBe(false); - }); - - it("groups application editability changes into the pending transaction", () => { - const transactions = vi.fn(); - const changes = vi.fn(); - editor._tiptapEditor.on("transaction", transactions); - editor.onChange(changes); - - editor.transact(() => { - editor.isEditable = false; - }); - expect(editor.isEditable).toBe(false); - expect(transactions).toHaveBeenCalled(); - expect(changes).not.toHaveBeenCalled(); - - transactions.mockClear(); - editor.transact((tr) => { - tr.insertText("hello", 1); - editor.isEditable = true; - }); - expect(editor.isEditable).toBe(true); - expect(editor.prosemirrorState.doc.textContent).toContain("hello"); - expect(transactions).toHaveBeenCalled(); - expect(changes).toHaveBeenCalledTimes(1); - }); - - it("composes with pending document and metadata-only transactions", () => { - editor.transact((tr) => { - tr.insertText("hello", 1); - readOnly.setReadOnly(true, "preview"); - }); - expect(editor.prosemirrorState.doc.textContent).toContain("hello"); - expect(editor.isEditable).toBe(false); - - editor.transact(() => readOnly.setReadOnly(false, "preview")); - expect(editor.isEditable).toBe(true); - }); -}); diff --git a/packages/core/src/extensions/ReadOnly/ReadOnly.ts b/packages/core/src/extensions/ReadOnly/ReadOnly.ts deleted file mode 100644 index a15876bbb4..0000000000 --- a/packages/core/src/extensions/ReadOnly/ReadOnly.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Plugin, PluginKey } from "prosemirror-state"; -import { - createExtension, - createStore, - type ExtensionOptions, -} from "../../editor/BlockNoteExtension.js"; - -const PLUGIN_KEY = new PluginKey("bn-read-only"); - -/** Owns application editability and independent feature restrictions. */ -export const ReadOnlyExtension = createExtension( - ({ - editor, - options, - }: ExtensionOptions<{ editable?: boolean } | undefined>) => { - const store = createStore( - { - isEditable: options?.editable ?? true, - enabledSet: new Set(), - }, - { - onUpdate(state, prevState) { - if ( - (state.isEditable && state.enabledSet.size === 0) === - (prevState.isEditable && prevState.enabledSet.size === 0) - ) { - return; - } - if (!editor.headless) { - // Recompute plugin editability and notify UI subscribers without a - // document change. Reuse any transaction already in progress. - editor.transact((tr) => tr.setMeta("editable", true)); - } - }, - }, - ); - - return { - key: "readOnly", - store, - prosemirrorPlugins: [ - new Plugin({ - key: PLUGIN_KEY, - props: { - editable: () => - store.state.isEditable && store.state.enabledSet.size === 0, - }, - }), - ], - /** Set the application's preference without releasing feature restrictions. */ - setEditable(editable: boolean) { - if (store.state.isEditable === editable) { - return; - } - store.setState({ ...store.state, isEditable: editable }); - }, - /** - * Enable or disable read-only mode for a feature identified by key. - * Passing false releases only that feature's restriction; other features - * and the application's editor.isEditable setting still apply. - * Repeated calls with the same key are idempotent. - */ - setReadOnly(readOnly: boolean, key: string) { - store.setState({ - ...store.state, - enabledSet: readOnly - ? new Set([...store.state.enabledSet, key]) - : new Set([...store.state.enabledSet].filter((k) => k !== key)), - }); - }, - } as const; - }, -); diff --git a/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts b/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts index f92324329a..f15c5ce470 100644 --- a/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts +++ b/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts @@ -1,4 +1,4 @@ -import { Plugin, Selection, TextSelection } from "prosemirror-state"; +import { Selection, TextSelection } from "prosemirror-state"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; import { @@ -39,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 plugin view update + // `"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 @@ -68,33 +68,6 @@ export const SourceInlineContentWithPreviewExtension = createExtension( return { key: "sourceInlineContentWithPreview", store, - prosemirrorPlugins: [ - new Plugin({ - view: (view) => { - let wasEditable = view.editable; - return { - update(view, prevState) { - if ( - view.editable === wasEditable && - view.state.selection.eq(prevState.selection) - ) { - return; - } - wasEditable = view.editable; - // Read effective editability after the view has applied the - // transaction, including batched toggles and plugin restrictions. - const { $from } = view.state.selection; - store.setState({ - selected: - view.editable && nodeHasPreview($from.node().type.name) - ? $from.before() - : undefined, - }); - }, - }; - }, - }), - ], keyboardShortcuts: { Enter: moveSelectionOut("after"), "Shift-Enter": moveSelectionOut("after"), @@ -120,6 +93,32 @@ export const SourceInlineContentWithPreviewExtension = createExtension( }, }, mount: ({ dom, signal }) => { + 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", + 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 // to the browser for navigation. Therefore, the up/down arrows can sometimes move the diff --git a/packages/core/src/extensions/Versioning/Versioning.node.test.ts b/packages/core/src/extensions/Versioning/Versioning.node.test.ts index b27390c938..2683ffed58 100644 --- a/packages/core/src/extensions/Versioning/Versioning.node.test.ts +++ b/packages/core/src/extensions/Versioning/Versioning.node.test.ts @@ -5,7 +5,6 @@ import { createVersioningExtension, type VersioningController, } from "./Versioning.js"; -import { ReadOnlyExtension } from "../ReadOnly/ReadOnly.js"; import { success } from "./__test__/result.js"; import type { VersionDisplay } from "./types.js"; @@ -168,7 +167,7 @@ it("publishes restored live content when reconnecting a detached binding", async it("configures once per editor during construction", async () => { const configure = vi.fn((editor: BlockNoteEditor) => { - expect(editor.getExtension(ReadOnlyExtension)).toBeDefined(); + expect(editor.isEditable).toBe(true); expect(editor.document).toHaveLength(1); expect( editor.getExtension("versioning")?.store.state, diff --git a/packages/core/src/extensions/Versioning/Versioning.ts b/packages/core/src/extensions/Versioning/Versioning.ts index 9b402cabfd..dd8819f56c 100644 --- a/packages/core/src/extensions/Versioning/Versioning.ts +++ b/packages/core/src/extensions/Versioning/Versioning.ts @@ -5,7 +5,6 @@ import type { InlineContentSchema, StyleSchema, } from "../../schema/index.js"; -import { ReadOnlyExtension } from "../ReadOnly/ReadOnly.js"; import { LoadingExtension } from "../Loading/Loading.js"; import { createVersioning } from "./createVersioning.js"; import type { VersionStorage, VersionViewAdapter } from "./types.js"; @@ -130,11 +129,7 @@ export function createVersioningExtension< }, }, setReadOnly(enabled) { - const readOnly = editor.getExtension(ReadOnlyExtension); - if (!readOnly) { - throw new Error("Versioning requires the ReadOnly extension"); - } - readOnly.setReadOnly(enabled, "versioning"); + editor.setReadOnly(enabled, "versioning"); }, }); const key = "versioning"; diff --git a/packages/core/src/extensions/index.ts b/packages/core/src/extensions/index.ts index 78a5b26fae..b4c85c9899 100644 --- a/packages/core/src/extensions/index.ts +++ b/packages/core/src/extensions/index.ts @@ -11,7 +11,6 @@ export * from "./NodeSelectionKeyboard/NodeSelectionKeyboard.js"; export * from "./Placeholder/Placeholder.js"; export * from "./PositionMapping/PositionMapping.js"; export * from "./PreviousBlockType/PreviousBlockType.js"; -export * from "./ReadOnly/ReadOnly.js"; export * from "./ShowSelection/ShowSelection.js"; export * from "./SideMenu/SideMenu.js"; export * from "./SourceBlockWithPreview/SourceBlockWithPreview.js"; diff --git a/packages/math-block/src/inlineContent/sourcePopup.browser.test.tsx b/packages/math-block/src/inlineContent/sourcePopup.browser.test.tsx index 1e9172d4b8..eecf915eb0 100644 --- a/packages/math-block/src/inlineContent/sourcePopup.browser.test.tsx +++ b/packages/math-block/src/inlineContent/sourcePopup.browser.test.tsx @@ -64,6 +64,21 @@ it.each([ } 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(() => { @@ -72,18 +87,7 @@ it.each([ ); }); - expect(editor.prosemirrorState.selection.from).toBe(sourcePos); - expect( - editor.getExtension(SourceInlineContentWithPreviewExtension)!.store - .state.selected, - ).toBe(editable && !pluginReadOnly ? mathPos : undefined); - await vi.waitFor(() => - expect( - host - .querySelector(".bn-preview-with-source-popup") - ?.getAttribute("data-open"), - ).toBe(String(editable && !pluginReadOnly)), - ); + await expectPopup(editable && !pluginReadOnly); if (editable) { flushSync(() => { @@ -96,18 +100,7 @@ it.each([ } }); - expect(editor.prosemirrorState.selection.from).toBe(sourcePos); - expect( - editor.getExtension(SourceInlineContentWithPreviewExtension)!.store - .state.selected, - ).toBeUndefined(); - await vi.waitFor(() => - expect( - host - .querySelector(".bn-preview-with-source-popup") - ?.getAttribute("data-open"), - ).toBe("false"), - ); + await expectPopup(false); flushSync(() => { if (batched) { @@ -119,18 +112,7 @@ it.each([ } }); - expect(editor.prosemirrorState.selection.from).toBe(sourcePos); - expect( - editor.getExtension(SourceInlineContentWithPreviewExtension)!.store - .state.selected, - ).toBe(pluginReadOnly ? undefined : mathPos); - await vi.waitFor(() => - expect( - host - .querySelector(".bn-preview-with-source-popup") - ?.getAttribute("data-open"), - ).toBe(String(!pluginReadOnly)), - ); + await expectPopup(!pluginReadOnly); } } finally { root.unmount(); @@ -140,10 +122,9 @@ it.each([ }, ); -it("mounts inline source popup tracking without the readOnly extension", () => { +it("tracks inline source popups through the editor API", () => { const editor = BlockNoteEditor.create({ schema, - disableExtensions: ["readOnly"], initialContent: [ { type: "paragraph", content: [{ type: "math", content: "x^2" }] }, ], @@ -171,6 +152,16 @@ it("mounts inline source popup tracking without the readOnly extension", () => { 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();