From 066be46ff539697f5adbb3fb4feb9fa38cfe7254 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 4 Nov 2026 18:46:51 +0100 Subject: [PATCH 1/3] refactor(core): move read-only state into the editor state manager --- 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 --- .../Versioning/Versioning.node.test.ts | 3 +- .../src/extensions/Versioning/Versioning.ts | 7 +- packages/core/src/extensions/index.ts | 1 - 13 files changed, 653 insertions(+), 314 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/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"; From db58b8e50046f9a4fe38820e3f2dfeb4190d3beb Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 4 Nov 2026 18:47:04 +0100 Subject: [PATCH 2/3] test(core): keep popup regressions in the stacked popup PR --- .../src/editor/managers/StateManager.test.ts | 64 +------------------ 1 file changed, 1 insertion(+), 63 deletions(-) diff --git a/packages/core/src/editor/managers/StateManager.test.ts b/packages/core/src/editor/managers/StateManager.test.ts index 4269ba4ea8..a3878af719 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, TextSelection } from "prosemirror-state"; +import { Plugin } from "prosemirror-state"; import { afterEach, beforeEach, @@ -8,9 +8,6 @@ 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"; @@ -29,65 +26,6 @@ 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) => { From 0dc1165437f98731efe8659e0c91adf5340f7d4c Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 4 Nov 2026 20:14:42 +0100 Subject: [PATCH 3/3] refactor(core): replace keyed read-only restrictions with editing locks --- packages/core/src/editor/BlockNoteEditor.ts | 10 +-- .../src/editor/managers/StateManager.test.ts | 79 ++++++++++--------- .../core/src/editor/managers/StateManager.ts | 26 +++--- .../src/extensions/Loading/Loading.test.ts | 5 +- .../core/src/extensions/Loading/Loading.ts | 11 +-- .../Versioning/Versioning.node.test.ts | 12 +++ .../src/extensions/Versioning/Versioning.ts | 11 ++- 7 files changed, 90 insertions(+), 64 deletions(-) diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 29919ed19f..4e53a2c828 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -1166,12 +1166,12 @@ export class BlockNoteEditor< } /** - * 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. + * Prevent editing until the returned release function is called. + * Each call acquires an independent lock. Releasing it is idempotent and + * preserves other locks and the application's isEditable preference. */ - public setReadOnly(readOnly: boolean, key: string) { - this._stateManager.setReadOnly(readOnly, key); + public acquireEditingLock(): () => void { + return this._stateManager.acquireEditingLock(); } /** diff --git a/packages/core/src/editor/managers/StateManager.test.ts b/packages/core/src/editor/managers/StateManager.test.ts index a3878af719..22eeed4dbc 100644 --- a/packages/core/src/editor/managers/StateManager.test.ts +++ b/packages/core/src/editor/managers/StateManager.test.ts @@ -77,27 +77,32 @@ describe("StateManager editability", () => { }, ); - it("keeps editing disabled until every feature releases its restriction", () => { - editor.setReadOnly(true, "preview"); - editor.setReadOnly(true, "upload"); - editor.setReadOnly(true, "upload"); + it("keeps editing disabled until every independent lock is released", () => { + const releasePreview = editor.acquireEditingLock(); + const releaseUpload = editor.acquireEditingLock(); expect(editor.isEditable).toBe(false); - editor.setReadOnly(false, "preview"); - editor.setReadOnly(false, "unrelated"); + releasePreview(); + releasePreview(); expect(editor.isEditable).toBe(false); - editor.setReadOnly(false, "upload"); + releaseUpload(); + expect(editor.isEditable).toBe(true); + + const releaseOther = editor.acquireEditingLock(); + releaseUpload(); + expect(editor.isEditable).toBe(false); + releaseOther(); expect(editor.isEditable).toBe(true); }); it("preserves the application's latest editable setting", () => { - editor.setReadOnly(true, "preview"); + const release = editor.acquireEditingLock(); editor.isEditable = true; expect(editor.isEditable).toBe(false); editor.isEditable = false; - editor.setReadOnly(false, "preview"); + release(); expect(editor.isEditable).toBe(false); editor.isEditable = true; @@ -112,13 +117,14 @@ describe("StateManager editability", () => { editableStates.push(editor.isEditable); }); - editor.setReadOnly(true, "preview"); + const release = editor.acquireEditingLock(); expect(editableStates.length).toBeGreaterThan(0); expect(editableStates.every((editable) => !editable)).toBe(true); editableStates.length = 0; - editor.setReadOnly(true, "preview"); + const releaseOther = editor.acquireEditingLock(); + releaseOther(); expect(editableStates).toEqual([]); - editor.setReadOnly(false, "preview"); + release(); expect(editableStates.length).toBeGreaterThan(0); expect(editableStates.every((editable) => editable)).toBe(true); @@ -136,14 +142,14 @@ describe("StateManager editability", () => { editor.isEditable = false; expect(metadata.filter((value) => value !== undefined)).toEqual([true]); metadata.length = 0; - editor.setReadOnly(true, "preview"); + const releasePreview = editor.acquireEditingLock(); editor.isEditable = true; - editor.setReadOnly(true, "upload"); - editor.setReadOnly(false, "preview"); + const releaseUpload = editor.acquireEditingLock(); + releasePreview(); expect(metadata).toEqual([]); expect(editor.isEditable).toBe(false); - editor.setReadOnly(false, "upload"); + releaseUpload(); expect(metadata.filter((value) => value !== undefined)).toEqual([true]); expect(editor.isEditable).toBe(true); expect(changes).not.toHaveBeenCalled(); @@ -199,14 +205,14 @@ describe("StateManager editability", () => { }); it("composes with pending document and metadata-only transactions", () => { - editor.transact((tr) => { + const release = editor.transact((tr) => { tr.insertText("hello", 1); - editor.setReadOnly(true, "preview"); + return editor.acquireEditingLock(); }); expect(editor.prosemirrorState.doc.textContent).toContain("hello"); expect(editor.isEditable).toBe(false); - editor.transact(() => editor.setReadOnly(false, "preview")); + editor.transact(release); expect(editor.isEditable).toBe(true); }); @@ -295,31 +301,29 @@ describe("StateManager editability", () => { expectEditable(previous); editor.isEditable = !initiallyEditable; expectEditable(!initiallyEditable && (!mounted || pluginAllowsEditing)); - editor.setReadOnly(true, "preview"); + const releasePreview = editor.acquireEditingLock(); expectEditable(false); - editor.setReadOnly(true, "preview"); + const releaseUpload = editor.acquireEditingLock(); expectEditable(false); - editor.setReadOnly(true, "upload"); - editor.setReadOnly(false, "preview"); + releasePreview(); editor.isEditable = true; expectEditable(false); - editor.setReadOnly(false, "upload"); + releaseUpload(); expectEditable(!mounted || pluginAllowsEditing); - editor.setReadOnly(false, "unrelated"); + releaseUpload(); editor.isEditable = true; expectEditable(!mounted || pluginAllowsEditing); - editor.setReadOnly(true, "preview"); + const releaseOther = editor.acquireEditingLock(); expectEditable(false); editor.isEditable = false; expectEditable(false); - editor.setReadOnly(false, "preview"); + releaseOther(); expectEditable(false); unsubscribe(); unsubscribe(); editor.isEditable = true; - editor.setReadOnly(true, "preview"); - editor.setReadOnly(false, "preview"); + editor.acquireEditingLock()(); expect(observed).toEqual(expected); }, ); @@ -332,24 +336,25 @@ describe("StateManager editability", () => { }); const unsubscribe = editor.onEditableChange(observed); - function setReadOnly(restricted: boolean) { + const release = editor.transact(() => { + let release: () => void; if (source === "application") { - editor.isEditable = !restricted; + editor.isEditable = false; + release = () => { + editor.isEditable = true; + }; } else { - editor.setReadOnly(restricted, "preview"); + release = editor.acquireEditingLock(); } - } - - 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]]); + return release; }); expect(editor.prosemirrorView.editable).toBe(false); - editor.transact(() => setReadOnly(false)); + editor.transact(release); expect(editor.prosemirrorView.editable).toBe(true); expect(editor.isEditable).toBe(true); expect(observed.mock.calls).toEqual([[false], [true]]); diff --git a/packages/core/src/editor/managers/StateManager.ts b/packages/core/src/editor/managers/StateManager.ts index 01501ec254..3e78fb5a58 100644 --- a/packages/core/src/editor/managers/StateManager.ts +++ b/packages/core/src/editor/managers/StateManager.ts @@ -8,8 +8,8 @@ import { BlockNoteEditor } from "../BlockNoteEditor.js"; type EditabilityState = { /** The application's requested editing preference. */ editable: boolean; - /** Independent feature keys currently preventing editing. */ - restrictions: Set; + /** Independent editing locks currently held by features. */ + 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. */ @@ -272,14 +272,20 @@ export class StateManager { 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 acquireEditingLock(): () => void { + const token = Symbol(); + this.editability.setState({ + ...this.editability.state, + restrictions: new Set([...this.editability.state.restrictions, token]), + }); + return () => { + if (!this.editability.state.restrictions.has(token)) { + return; + } + const restrictions = new Set(this.editability.state.restrictions); + restrictions.delete(token); + this.editability.setState({ ...this.editability.state, restrictions }); + }; } public onEditableChange(callback: (editable: boolean) => void) { diff --git a/packages/core/src/extensions/Loading/Loading.test.ts b/packages/core/src/extensions/Loading/Loading.test.ts index 457c639722..7d84a5192d 100644 --- a/packages/core/src/extensions/Loading/Loading.test.ts +++ b/packages/core/src/extensions/Loading/Loading.test.ts @@ -8,7 +8,6 @@ const loading = editor.getExtension(LoadingExtension)!; afterEach(() => { editor.isEditable = true; - editor.setReadOnly(false, "preview"); }); it("blocks editing until every loading token is released", () => { @@ -30,12 +29,12 @@ it("blocks editing until every loading token is released", () => { it("preserves application and independent feature restrictions", () => { const release = loading.acquire(); editor.isEditable = false; - editor.setReadOnly(true, "preview"); + const releasePreview = editor.acquireEditingLock(); release(); expect(editor.isEditable).toBe(false); editor.isEditable = true; expect(editor.isEditable).toBe(false); - editor.setReadOnly(false, "preview"); + releasePreview(); expect(editor.isEditable).toBe(true); }); diff --git a/packages/core/src/extensions/Loading/Loading.ts b/packages/core/src/extensions/Loading/Loading.ts index 5839a11b86..ba51fdd858 100644 --- a/packages/core/src/extensions/Loading/Loading.ts +++ b/packages/core/src/extensions/Loading/Loading.ts @@ -5,25 +5,20 @@ import { /** Keeps the editor read-only and the loader visible until every token is released. */ export const LoadingExtension = createExtension(({ editor }) => { - const store = createStore(new Set(), { - onUpdate(state, prevState) { - if (state.size > 0 === prevState.size > 0) { - return; - } - editor.setReadOnly(state.size > 0, "loading"); - }, - }); + const store = createStore(new Set()); return { key: "loading", store, acquire() { const token = Symbol(); + const releaseEditingLock = editor.acquireEditingLock(); store.setState(new Set([...store.state, token])); return function release() { if (store.state.has(token)) { const tokens = new Set(store.state); tokens.delete(token); + releaseEditingLock(); store.setState(tokens); } }; diff --git a/packages/core/src/extensions/Versioning/Versioning.node.test.ts b/packages/core/src/extensions/Versioning/Versioning.node.test.ts index 2683ffed58..ae546c7c17 100644 --- a/packages/core/src/extensions/Versioning/Versioning.node.test.ts +++ b/packages/core/src/extensions/Versioning/Versioning.node.test.ts @@ -22,6 +22,7 @@ it("does not publish binding changes while opening, switching or closing a previ adapter: { supportsComparison: true, open() { + expect(editor.isEditable).toBe(false); show("opening"); return { current: { content: "live", capturedAt: 1 }, @@ -44,6 +45,7 @@ it("does not publish binding changes while opening, switching or closing a previ async restore() { // Like local storage, restore the live view before editing it. editor.getExtension("versioning")!.close(); + expect(editor.isEditable).toBe(false); live = "restored"; show(live); return success(undefined); @@ -67,11 +69,13 @@ it("does not publish binding changes while opening, switching or closing a previ await mode.select({ type: "current" }, { compareTo: "old" }); await mode.select({ type: "current" }); mode.close(); + expect(editor.isEditable).toBe(true); expect(changes).toEqual([]); editor.updateBlock("paragraph", { content: "edited" }); expect(changes).toEqual(["edited"]); mode.open(); await mode.restore("old"); + expect(editor.isEditable).toBe(true); expect(changes).toEqual(["edited", "restored"]); } finally { mode.dispose(); @@ -107,6 +111,7 @@ it("resumes change notifications if acquiring a preview throws", () => { expect(() => editor.getExtension(Versions)!.open()).toThrow( "Cannot open preview", ); + expect(editor.isEditable).toBe(true); expect(changes).toEqual([]); editor.updateBlock("paragraph", { content: "edited" }); expect(changes).toEqual(["edited"]); @@ -218,10 +223,17 @@ it("configures once per editor during construction", async () => { expect(mode.canRename).toBe(false); expect(await mode.rename("missing")).toEqual({ status: "unavailable" }); mode.open(); + mode.open(); + expect(editor.isEditable).toBe(false); expect(await mode.list()).toEqual({ status: "done" }); mode.close(); + expect(editor.isEditable).toBe(true); + const releaseOther = editor.acquireEditingLock(); mode.open(); mode.close(); + expect(editor.isEditable).toBe(false); + releaseOther(); + expect(editor.isEditable).toBe(true); expect(configure).toHaveBeenCalledTimes(editors.length); } expect(editors[0].getExtension(Versions)!.userStore).not.toBe( diff --git a/packages/core/src/extensions/Versioning/Versioning.ts b/packages/core/src/extensions/Versioning/Versioning.ts index dd8819f56c..0d9361da56 100644 --- a/packages/core/src/extensions/Versioning/Versioning.ts +++ b/packages/core/src/extensions/Versioning/Versioning.ts @@ -55,6 +55,8 @@ export function createVersioningExtension< } let previewOpen = false; + function noop() {} + let releaseEditingLock = noop; const mode = createVersioning({ get storage() { @@ -129,7 +131,14 @@ export function createVersioningExtension< }, }, setReadOnly(enabled) { - editor.setReadOnly(enabled, "versioning"); + if (enabled) { + if (releaseEditingLock === noop) { + releaseEditingLock = editor.acquireEditingLock(); + } + } else { + releaseEditingLock(); + releaseEditingLock = noop; + } }, }); const key = "versioning";