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..4e53a2c828 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); + } + + /** + * 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 acquireEditingLock(): () => void { + return this._stateManager.acquireEditingLock(); + } + /** * 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..22eeed4dbc --- /dev/null +++ b/packages/core/src/editor/managers/StateManager.test.ts @@ -0,0 +1,386 @@ +/** @vitest-environment jsdom */ +import { Plugin } from "prosemirror-state"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from "vite-plus/test"; +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(["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 independent lock is released", () => { + const releasePreview = editor.acquireEditingLock(); + const releaseUpload = editor.acquireEditingLock(); + expect(editor.isEditable).toBe(false); + + releasePreview(); + releasePreview(); + expect(editor.isEditable).toBe(false); + + 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", () => { + const release = editor.acquireEditingLock(); + editor.isEditable = true; + expect(editor.isEditable).toBe(false); + + editor.isEditable = false; + release(); + 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); + }); + + const release = editor.acquireEditingLock(); + expect(editableStates.length).toBeGreaterThan(0); + expect(editableStates.every((editable) => !editable)).toBe(true); + editableStates.length = 0; + const releaseOther = editor.acquireEditingLock(); + releaseOther(); + expect(editableStates).toEqual([]); + release(); + + 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; + const releasePreview = editor.acquireEditingLock(); + editor.isEditable = true; + const releaseUpload = editor.acquireEditingLock(); + releasePreview(); + expect(metadata).toEqual([]); + expect(editor.isEditable).toBe(false); + + releaseUpload(); + 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", () => { + const release = editor.transact((tr) => { + tr.insertText("hello", 1); + return editor.acquireEditingLock(); + }); + expect(editor.prosemirrorState.doc.textContent).toContain("hello"); + expect(editor.isEditable).toBe(false); + + editor.transact(release); + 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)); + const releasePreview = editor.acquireEditingLock(); + expectEditable(false); + const releaseUpload = editor.acquireEditingLock(); + expectEditable(false); + releasePreview(); + editor.isEditable = true; + expectEditable(false); + releaseUpload(); + expectEditable(!mounted || pluginAllowsEditing); + releaseUpload(); + editor.isEditable = true; + expectEditable(!mounted || pluginAllowsEditing); + const releaseOther = editor.acquireEditingLock(); + expectEditable(false); + editor.isEditable = false; + expectEditable(false); + releaseOther(); + expectEditable(false); + + unsubscribe(); + unsubscribe(); + editor.isEditable = true; + editor.acquireEditingLock()(); + 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); + + const release = editor.transact(() => { + let release: () => void; + if (source === "application") { + editor.isEditable = false; + release = () => { + editor.isEditable = true; + }; + } else { + release = editor.acquireEditingLock(); + } + // 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(release); + 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..3e78fb5a58 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 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. */ + 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,52 @@ 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 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) { + 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..7d84a5192d 100644 --- a/packages/core/src/extensions/Loading/Loading.test.ts +++ b/packages/core/src/extensions/Loading/Loading.test.ts @@ -1,16 +1,13 @@ // @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"); }); it("blocks editing until every loading token is released", () => { @@ -32,24 +29,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"); + const releasePreview = editor.acquireEditingLock(); release(); expect(editor.isEditable).toBe(false); editor.isEditable = true; expect(editor.isEditable).toBe(false); - readOnly.setReadOnly(false, "preview"); + releasePreview(); 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..ba51fdd858 100644 --- a/packages/core/src/extensions/Loading/Loading.ts +++ b/packages/core/src/extensions/Loading/Loading.ts @@ -2,30 +2,23 @@ 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 }) => { - const store = createStore(new Set(), { - onUpdate(state, prevState) { - if (state.size > 0 === prevState.size > 0) { - return; - } - const readOnly = editor.getExtension(ReadOnlyExtension); - readOnly?.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/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..ae546c7c17 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"; @@ -23,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 }, @@ -45,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); @@ -68,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(); @@ -108,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"]); @@ -168,7 +172,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, @@ -219,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 9b402cabfd..0d9361da56 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"; @@ -56,6 +55,8 @@ export function createVersioningExtension< } let previewOpen = false; + function noop() {} + let releaseEditingLock = noop; const mode = createVersioning({ get storage() { @@ -130,11 +131,14 @@ export function createVersioningExtension< }, }, setReadOnly(enabled) { - const readOnly = editor.getExtension(ReadOnlyExtension); - if (!readOnly) { - throw new Error("Versioning requires the ReadOnly extension"); + if (enabled) { + if (releaseEditingLock === noop) { + releaseEditingLock = editor.acquireEditingLock(); + } + } else { + releaseEditingLock(); + releaseEditingLock = noop; } - readOnly.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";