From 1f51bb661f1f95e7741365c135716cb92141ff8c Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Wed, 18 Mar 2026 18:00:01 -0700 Subject: [PATCH 1/2] feat(super-editor): bridge editor selection into Document API commands --- packages/super-editor/src/core/Editor.ts | 133 +++++- .../src/core/extensions/index.d.ts | 1 + .../super-editor/src/core/extensions/index.js | 1 + .../src/core/extensions/selection-handle.js | 10 + .../presentation-editor/PresentationEditor.ts | 141 ++++++ .../dom/DecorationBridge.ts | 2 +- .../src/core/presentation-editor/index.ts | 3 + ...PresentationEditor.selectionBridge.test.ts | 251 +++++++++++ .../src/core/selection-state.test.ts | 283 ++++++++++++ .../super-editor/src/core/selection-state.ts | 233 ++++++++++ .../helpers/range-resolver.ts | 43 +- .../helpers/selection-range-resolver.test.ts | 424 ++++++++++++++++++ .../helpers/selection-range-resolver.ts | 196 ++++++++ .../custom-selection/custom-selection.js | 34 +- .../src/extensions/history/history.js | 3 +- .../src/extensions/linked-styles/helpers.js | 2 +- packages/super-editor/src/index.d.ts | 90 ++++ 17 files changed, 1820 insertions(+), 30 deletions(-) create mode 100644 packages/super-editor/src/core/extensions/selection-handle.js create mode 100644 packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.selectionBridge.test.ts create mode 100644 packages/super-editor/src/core/selection-state.test.ts create mode 100644 packages/super-editor/src/core/selection-state.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/selection-range-resolver.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/helpers/selection-range-resolver.ts diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index f8c56d88ce..297f4ac98b 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -18,7 +18,14 @@ import { ExtensionService } from './ExtensionService.js'; import { CommandService } from './CommandService.js'; import { Attribute } from './Attribute.js'; import { SuperConverter } from '@core/super-converter/SuperConverter.js'; -import { Commands, Editable, EditorFocus, Keymap, PositionTrackerExtension } from './extensions/index.js'; +import { + Commands, + Editable, + EditorFocus, + Keymap, + PositionTrackerExtension, + SelectionHandleExtension, +} from './extensions/index.js'; import { createDocument } from './helpers/createDocument.js'; import { isActive } from './helpers/isActive.js'; import { trackedTransaction } from '@extensions/track-changes/trackChangesHelpers/trackedTransaction.js'; @@ -59,9 +66,18 @@ import { ProseMirrorRenderer } from './renderers/ProseMirrorRenderer.js'; import { BLANK_DOCX_DATA_URI } from './blank-docx.js'; import { getArrayBufferFromUrl } from '@core/super-converter/helpers.js'; import { Telemetry, COMMUNITY_LICENSE_KEY } from '@superdoc/common'; -import type { DocumentApi } from '@superdoc/document-api'; +import type { DocumentApi, ResolveRangeOutput } from '@superdoc/document-api'; import { createDocumentApi } from '@superdoc/document-api'; import { getDocumentApiAdapters } from '../document-api-adapters/index.js'; +import { + resolveCurrentEditorSelectionRange, + resolveEffectiveEditorSelectionRange, + selectCurrentPmSelection, + selectEffectivePmSelection, + resolvePmSelectionToRange, +} from '../document-api-adapters/helpers/selection-range-resolver.js'; +import { captureSelectionHandle, resolveHandleToSelection, releaseSelectionHandle } from './selection-state.js'; +import type { SelectionHandle } from './selection-state.js'; import { initPartsRuntime } from './parts/init-parts-runtime.js'; import { syncPackageMetadata } from './opc/sync-package-metadata.js'; @@ -1280,6 +1296,110 @@ export class Editor extends EventEmitter { return this.#documentApi; } + // ------------------------------------------------------------------- + // Selection bridge — tracked handles + snapshot convenience + // ------------------------------------------------------------------- + + /** + * Capture the live PM selection as a tracked handle. + * + * The handle's bookmark is automatically mapped through every subsequent + * transaction, so it always reflects the current document. When ready, + * call {@link resolveSelectionHandle} to get a fresh `ResolveRangeOutput`. + * + * Use this for deferred UI flows (AI, confirmation dialogs, async chains) + * where a delay exists between selection capture and mutation. + * + * Local-only — captures from **this** editor's `state.selection`. + */ + captureCurrentSelectionHandle(surface: 'body' | 'header' | 'footer' = 'body'): SelectionHandle { + this.#assertState('ready', 'saving'); + const selection = selectCurrentPmSelection(this); + return captureSelectionHandle(this, selection, surface); + } + + /** + * Capture the "effective" selection as a tracked handle. + * + * Uses the same fallback chain as {@link getEffectiveSelectionRange}: + * live non-collapsed → preserved → live. The resulting bookmark is then + * mapped through every subsequent transaction. + * + * Local-only — captures from **this** editor. + */ + captureEffectiveSelectionHandle(surface: 'body' | 'header' | 'footer' = 'body'): SelectionHandle { + this.#assertState('ready', 'saving'); + const selection = selectEffectivePmSelection(this); + return captureSelectionHandle(this, selection, surface); + } + + /** + * Resolve a previously captured handle into a fresh `ResolveRangeOutput`. + * + * The handle's bookmark has been mapped through all intervening transactions + * in the owning editor's plugin state, so the returned target reflects the + * current document — no revision plumbing needed. + * + * The handle is always resolved against its owning editor (the one that + * captured it), regardless of which editor is currently active. This + * ensures correct behavior when header/footer sessions change. + * + * Returns `null` when: + * - the handle was released + * - a previously non-empty selection collapsed (content was deleted) + * + * Always release handles when done via {@link releaseSelectionHandle}. + */ + resolveSelectionHandle(handle: SelectionHandle): ResolveRangeOutput | null { + this.#assertState('ready', 'saving'); + const selection = resolveHandleToSelection(handle); + if (!selection) return null; + // Use the owning editor for range resolution, not `this`. The bookmark + // positions are relative to the owner's document — interpreting them + // against a different editor's doc would produce wrong results. + return resolvePmSelectionToRange(handle._owner as Editor, selection); + } + + /** + * Release a tracked selection handle, removing it from plugin state. + * + * Always call this when the handle is no longer needed to avoid + * unbounded accumulation of bookmarks. + */ + releaseSelectionHandle(handle: SelectionHandle): void { + this.#assertState('ready', 'saving'); + releaseSelectionHandle(handle); + } + + /** + * Snapshot convenience: resolve the live PM `state.selection` into a + * canonical Document API range immediately. + * + * Equivalent to `captureCurrentSelectionHandle()` + `resolveSelectionHandle()` + * in one call. Use this for immediate mutations where no delay exists + * between reading the selection and acting on it. + * + * Local-only — always resolves against **this** editor. + */ + getCurrentSelectionRange(): ResolveRangeOutput { + this.#assertState('ready', 'saving'); + return resolveCurrentEditorSelectionRange(this); + } + + /** + * Snapshot convenience: resolve the "effective" selection into a + * canonical Document API range immediately. + * + * Uses the same fallback chain as `captureEffectiveSelectionHandle`: + * live non-collapsed → preserved → live. + * + * Local-only — always resolves against **this** editor. + */ + getEffectiveSelectionRange(): ResolveRangeOutput { + this.#assertState('ready', 'saving'); + return resolveEffectiveEditorSelectionRange(this); + } + /** * Get extension helpers. */ @@ -1684,7 +1804,14 @@ export class Editor extends EventEmitter { #createExtensionService(): void { const allowedExtensions = ['extension', 'node', 'mark']; - const coreExtensions = [Editable, Commands, EditorFocus, Keymap, PositionTrackerExtension]; + const coreExtensions = [ + Editable, + Commands, + EditorFocus, + Keymap, + PositionTrackerExtension, + SelectionHandleExtension, + ]; const externalExtensions = this.options.externalExtensions || []; const allExtensions = [...coreExtensions, ...this.options.extensions!].filter((extension) => { diff --git a/packages/super-editor/src/core/extensions/index.d.ts b/packages/super-editor/src/core/extensions/index.d.ts index 69292628ce..6867f32a9e 100644 --- a/packages/super-editor/src/core/extensions/index.d.ts +++ b/packages/super-editor/src/core/extensions/index.d.ts @@ -3,3 +3,4 @@ export const Editable: any; export const EditorFocus: any; export const Keymap: any; export const PositionTrackerExtension: any; +export const SelectionHandleExtension: any; diff --git a/packages/super-editor/src/core/extensions/index.js b/packages/super-editor/src/core/extensions/index.js index bac56fc0e6..6d3b1bf8f2 100644 --- a/packages/super-editor/src/core/extensions/index.js +++ b/packages/super-editor/src/core/extensions/index.js @@ -3,3 +3,4 @@ export { Keymap } from './keymap.js'; export { Editable } from './editable.js'; export { EditorFocus } from './editorFocus.js'; export { PositionTrackerExtension } from './position-tracker.js'; +export { SelectionHandleExtension } from './selection-handle.js'; diff --git a/packages/super-editor/src/core/extensions/selection-handle.js b/packages/super-editor/src/core/extensions/selection-handle.js new file mode 100644 index 0000000000..85c7d4db70 --- /dev/null +++ b/packages/super-editor/src/core/extensions/selection-handle.js @@ -0,0 +1,10 @@ +import { Extension } from '../Extension.js'; +import { createSelectionHandlePlugin } from '../selection-state.js'; + +export const SelectionHandleExtension = Extension.create({ + name: 'selectionHandle', + + addPmPlugins() { + return [createSelectionHandlePlugin()]; + }, +}); diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 85b8f23c56..de9ab24639 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -113,6 +113,9 @@ import { isInRegisteredSurface } from './utils/uiSurfaceRegistry.js'; import { buildSemanticFootnoteBlocks } from './semantic-flow-footnotes.js'; import { splitRunsAtDecorationBoundaries } from './layout/SplitRunsAtDecorationBoundaries.js'; +import type { ResolveRangeOutput, DocumentApi } from '@superdoc/document-api'; +import type { SelectionHandle } from '../selection-state.js'; + // Types import type { PageSize, @@ -166,6 +169,21 @@ export type { TelemetryEvent, } from './types.js'; +/** + * Bundles the active editing surface's editor, document API, surface label, + * and resolved selection range into a single coherent object. + * + * Guarantees that `doc` and `range` refer to the same editing surface. + * This is the canonical layout-mode command surface — use it whenever the + * active context (body / header / footer) matters for the follow-up mutation. + */ +export type SelectionCommandContext = { + editor: Editor; + doc: DocumentApi; + surface: 'body' | 'header' | 'footer'; + range: ResolveRangeOutput; +}; + // Mark name constants import { CommentMarkName } from '@extensions/comment/comments-constants.js'; import { TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName } from '@extensions/track-changes/constants.js'; @@ -963,6 +981,129 @@ export class PresentationEditor extends EventEmitter { return activeHfEditor; } + // ------------------------------------------------------------------- + // Selection bridge — tracked handles + snapshot convenience + // ------------------------------------------------------------------- + + /** + * Inspects `#headerFooterSession` to determine which editing surface is active. + */ + #resolveActiveSurface(): 'body' | 'header' | 'footer' { + const mode = this.#headerFooterSession?.session?.mode ?? 'body'; + if (mode === 'header') return 'header'; + if (mode === 'footer') return 'footer'; + return 'body'; + } + + // --- Tracked handle API --- + + /** + * Capture the live PM selection on the active editor as a tracked handle. + * + * The handle is bound to the specific editor that captured it (not just + * the surface label), so it remains valid even if the active header/footer + * session changes later. + */ + captureCurrentSelectionHandle(): SelectionHandle { + const surface = this.#resolveActiveSurface(); + return this.getActiveEditor().captureCurrentSelectionHandle(surface); + } + + /** + * Capture the "effective" selection on the active editor as a tracked handle. + * Uses the same fallback chain: live non-collapsed → preserved → live. + */ + captureEffectiveSelectionHandle(): SelectionHandle { + const surface = this.#resolveActiveSurface(); + return this.getActiveEditor().captureEffectiveSelectionHandle(surface); + } + + /** + * Resolve a previously captured handle into a `SelectionCommandContext`. + * + * The handle carries a reference to the editor that captured it, so + * resolution always reads from the correct editor's plugin state — + * even if the active header/footer session has changed since capture. + * + * Returns `null` when: + * - the handle was released + * - a previously non-empty selection collapsed (content was deleted) + */ + resolveSelectionHandle(handle: SelectionHandle): SelectionCommandContext | null { + // The handle's _owner is the Editor that captured it. We use it to + // resolve the range, but we need the Editor type for the context. + // Since _owner satisfies SelectionHandleOwner (which Editor implements), + // and capture always passes `this` (an Editor), this cast is safe. + const ownerEditor = handle._owner as Editor; + const range = ownerEditor.resolveSelectionHandle(handle); + if (!range) return null; + return { editor: ownerEditor, doc: ownerEditor.doc, surface: handle.surface, range }; + } + + /** + * Release a tracked selection handle. + * + * Routes to the owning editor regardless of the current active surface. + */ + releaseSelectionHandle(handle: SelectionHandle): void { + (handle._owner as Editor).releaseSelectionHandle(handle); + } + + // --- Snapshot convenience API --- + + /** + * Snapshot convenience: resolve the live PM selection on the active editor + * into a canonical Document API range immediately. + */ + getCurrentSelectionRange(): ResolveRangeOutput { + return this.getActiveEditor().getCurrentSelectionRange(); + } + + /** + * Snapshot convenience: resolve the "effective" selection on the active + * editor into a canonical Document API range immediately. + */ + getEffectiveSelectionRange(): ResolveRangeOutput { + return this.getActiveEditor().getEffectiveSelectionRange(); + } + + /** + * Snapshot convenience: returns the current live selection plus the active + * editing context. Guarantees `doc` and `range` refer to the same surface. + */ + getCurrentSelectionContext(): SelectionCommandContext { + const activeEditor = this.getActiveEditor(); + return { + editor: activeEditor, + doc: activeEditor.doc, + surface: this.#resolveActiveSurface(), + range: activeEditor.getCurrentSelectionRange(), + }; + } + + /** + * Snapshot convenience: returns the effective selection plus the active + * editing context. The canonical layout-mode command surface. + * + * @example + * ```ts + * const ctx = presentationEditor.getEffectiveSelectionContext(); + * ctx.doc.replace({ + * target: ctx.range.target, + * text: 'New content', + * }); + * ``` + */ + getEffectiveSelectionContext(): SelectionCommandContext { + const activeEditor = this.getActiveEditor(); + return { + editor: activeEditor, + doc: activeEditor.doc, + surface: this.#resolveActiveSurface(), + range: activeEditor.getEffectiveSelectionRange(), + }; + } + /** * Undo the last action in the active editor. */ diff --git a/packages/super-editor/src/core/presentation-editor/dom/DecorationBridge.ts b/packages/super-editor/src/core/presentation-editor/dom/DecorationBridge.ts index 3a35dfe071..0007603aa6 100644 --- a/packages/super-editor/src/core/presentation-editor/dom/DecorationBridge.ts +++ b/packages/super-editor/src/core/presentation-editor/dom/DecorationBridge.ts @@ -6,7 +6,7 @@ import { TrackChangesBasePluginKey } from '@extensions/track-changes/plugins/ind import { CommentsPluginKey } from '@extensions/comment/comments-plugin.js'; import { customSearchHighlightsKey } from '@extensions/search/search.js'; import { AiPluginKey } from '@extensions/ai/ai-plugin.js'; -import { CustomSelectionPluginKey } from '@extensions/custom-selection/custom-selection.js'; +import { CustomSelectionPluginKey } from '@core/selection-state.js'; import { LinkedStylesPluginKey } from '@extensions/linked-styles/plugin.js'; import { NodeResizerKey } from '@extensions/noderesizer/noderesizer.js'; diff --git a/packages/super-editor/src/core/presentation-editor/index.ts b/packages/super-editor/src/core/presentation-editor/index.ts index 0b2bf6882b..e6d4d907d6 100644 --- a/packages/super-editor/src/core/presentation-editor/index.ts +++ b/packages/super-editor/src/core/presentation-editor/index.ts @@ -8,6 +8,9 @@ // Main class export { PresentationEditor } from './PresentationEditor.js'; +// Selection bridge types +export type { SelectionCommandContext } from './PresentationEditor.js'; + // Public types export type { PageSize, diff --git a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.selectionBridge.test.ts b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.selectionBridge.test.ts new file mode 100644 index 0000000000..770d2e964a --- /dev/null +++ b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.selectionBridge.test.ts @@ -0,0 +1,251 @@ +/** + * Tests for the PresentationEditor selection bridge methods. + * + * Verifies: + * - local-only (Editor) vs active-context-aware (PresentationEditor) contract + * - tracked selection handle ownership — handles are bound to their capturing + * editor, not the surface label, so switching HF sessions doesn't break them + * - SelectionCommandContext bundling prevents surface mismatches + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { ResolveRangeOutput, DocumentApi } from '@superdoc/document-api'; +import type { Editor } from '../../Editor.js'; +import type { SelectionCommandContext } from '../PresentationEditor.js'; +import type { SelectionHandle, SelectionHandleOwner } from '../../selection-state.js'; + +// --------------------------------------------------------------------------- +// Mock helpers +// --------------------------------------------------------------------------- + +function makeMockRange(label: string): ResolveRangeOutput { + return { + evaluatedRevision: `rev-${label}`, + handle: { ref: `text:${label}`, refStability: 'ephemeral', coversFullTarget: true }, + target: { + kind: 'selection', + start: { kind: 'text', blockId: `block-${label}`, offset: 0 }, + end: { kind: 'text', blockId: `block-${label}`, offset: 5 }, + }, + preview: { text: label, truncated: false, blocks: [] }, + }; +} + +let nextMockHandleId = 1; + +function makeMockEditor(label: string): Editor { + const currentRange = makeMockRange(`${label}-current`); + const effectiveRange = makeMockRange(`${label}-effective`); + const resolvedRange = makeMockRange(`${label}-resolved`); + const doc = { _label: `doc-${label}` } as unknown as DocumentApi; + + // Build the editor object first, then wire up capture to reference it + // as `_owner` — this mirrors the real code where `_owner` is `this`. + const editor: Record = { + getCurrentSelectionRange: vi.fn(() => currentRange), + getEffectiveSelectionRange: vi.fn(() => effectiveRange), + resolveSelectionHandle: vi.fn(() => resolvedRange), + releaseSelectionHandle: vi.fn(), + doc, + _label: label, + _currentRange: currentRange, + _effectiveRange: effectiveRange, + _resolvedRange: resolvedRange, + }; + + // Capture methods return handles whose _owner is this editor instance + editor.captureCurrentSelectionHandle = vi.fn( + (surface: string): SelectionHandle => ({ + id: nextMockHandleId++, + surface: surface as 'body' | 'header' | 'footer', + wasNonEmpty: true, + _owner: editor as unknown as SelectionHandleOwner, + }), + ); + editor.captureEffectiveSelectionHandle = vi.fn( + (surface: string): SelectionHandle => ({ + id: nextMockHandleId++, + surface: surface as 'body' | 'header' | 'footer', + wasNonEmpty: true, + _owner: editor as unknown as SelectionHandleOwner, + }), + ); + + return editor as unknown as Editor & { + _label: string; + _currentRange: ResolveRangeOutput; + _effectiveRange: ResolveRangeOutput; + _resolvedRange: ResolveRangeOutput; + }; +} + +/** + * Minimal PresentationEditor stub that replicates the owner-bound handle + * routing from the production code. The key change from the old design: + * resolve/release use handle._owner (cast to Editor), not surface routing. + */ +function makePresentationEditorStub( + bodyEditor: ReturnType, + activeEditor: ReturnType, + surface: 'body' | 'header' | 'footer', +) { + return { + getActiveEditor: () => activeEditor, + + // Handle API — mirrors production code + captureCurrentSelectionHandle: (): SelectionHandle => activeEditor.captureCurrentSelectionHandle(surface), + captureEffectiveSelectionHandle: (): SelectionHandle => activeEditor.captureEffectiveSelectionHandle(surface), + resolveSelectionHandle: (handle: SelectionHandle): SelectionCommandContext | null => { + // Production code: const ownerEditor = handle._owner as Editor; + const ownerEditor = handle._owner as unknown as Editor; + const range = ownerEditor.resolveSelectionHandle(handle); + if (!range) return null; + return { editor: ownerEditor, doc: ownerEditor.doc, surface: handle.surface, range }; + }, + releaseSelectionHandle: (handle: SelectionHandle): void => { + (handle._owner as unknown as Editor).releaseSelectionHandle(handle); + }, + + // Snapshot API + getCurrentSelectionRange: () => activeEditor.getCurrentSelectionRange(), + getEffectiveSelectionRange: () => activeEditor.getEffectiveSelectionRange(), + getCurrentSelectionContext: (): SelectionCommandContext => ({ + editor: activeEditor, + doc: activeEditor.doc, + surface, + range: activeEditor.getCurrentSelectionRange(), + }), + getEffectiveSelectionContext: (): SelectionCommandContext => ({ + editor: activeEditor, + doc: activeEditor.doc, + surface, + range: activeEditor.getEffectiveSelectionRange(), + }), + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('PresentationEditor selection bridge — snapshot routing', () => { + let bodyEditor: ReturnType; + let headerEditor: ReturnType; + + beforeEach(() => { + nextMockHandleId = 1; + bodyEditor = makeMockEditor('body'); + headerEditor = makeMockEditor('header'); + }); + + describe('when body is active', () => { + it('getCurrentSelectionRange delegates to body editor', () => { + const pe = makePresentationEditorStub(bodyEditor, bodyEditor, 'body'); + const result = pe.getCurrentSelectionRange(); + expect(result.evaluatedRevision).toBe('rev-body-current'); + }); + + it('getEffectiveSelectionContext surface is "body"', () => { + const pe = makePresentationEditorStub(bodyEditor, bodyEditor, 'body'); + const ctx = pe.getEffectiveSelectionContext(); + expect(ctx.surface).toBe('body'); + expect(ctx.editor).toBe(bodyEditor); + expect(ctx.doc).toBe(bodyEditor.doc); + }); + }); + + describe('when header is active', () => { + it('getCurrentSelectionRange delegates to header editor', () => { + const pe = makePresentationEditorStub(bodyEditor, headerEditor, 'header'); + const result = pe.getCurrentSelectionRange(); + expect(result.evaluatedRevision).toBe('rev-header-current'); + expect(bodyEditor.getCurrentSelectionRange).not.toHaveBeenCalled(); + }); + + it('context doc and range come from the same editor (no mismatch)', () => { + const pe = makePresentationEditorStub(bodyEditor, headerEditor, 'header'); + const ctx = pe.getEffectiveSelectionContext(); + expect(ctx.doc).toBe(headerEditor.doc); + expect(ctx.doc).not.toBe(bodyEditor.doc); + }); + }); + + describe('local-only vs active-context-aware boundary', () => { + it('body Editor stays local when header is active in PE', () => { + const pe = makePresentationEditorStub(bodyEditor, headerEditor, 'header'); + const bodyRange = bodyEditor.getCurrentSelectionRange(); + const peRange = pe.getCurrentSelectionRange(); + expect(bodyRange.evaluatedRevision).not.toBe(peRange.evaluatedRevision); + }); + }); +}); + +describe('PresentationEditor selection bridge — tracked handle ownership', () => { + let bodyEditor: ReturnType; + let headerEditorA: ReturnType; + let headerEditorB: ReturnType; + + beforeEach(() => { + nextMockHandleId = 1; + bodyEditor = makeMockEditor('body'); + headerEditorA = makeMockEditor('headerA'); + headerEditorB = makeMockEditor('headerB'); + }); + + it('capture routes to the currently active editor', () => { + const pe = makePresentationEditorStub(bodyEditor, headerEditorA, 'header'); + const handle = pe.captureCurrentSelectionHandle(); + expect(handle.surface).toBe('header'); + expect(headerEditorA.captureCurrentSelectionHandle).toHaveBeenCalledWith('header'); + }); + + it('resolve uses handle._owner, not the currently active editor', () => { + // Capture while header A is active + const pe = makePresentationEditorStub(bodyEditor, headerEditorA, 'header'); + const handle = pe.captureEffectiveSelectionHandle(); + + // Now "switch" to header B — rebuild the stub with a new active editor + const pe2 = makePresentationEditorStub(bodyEditor, headerEditorB, 'header'); + + // Resolve should go to header A (the owner), not header B (the new active) + const ctx = pe2.resolveSelectionHandle(handle); + expect(ctx).not.toBeNull(); + // The owner is headerEditorA's internal ref, so resolveSelectionHandle was called on it + expect(headerEditorA.resolveSelectionHandle).toHaveBeenCalledWith(handle); + expect(headerEditorB.resolveSelectionHandle).not.toHaveBeenCalled(); + }); + + it('release uses handle._owner, not the currently active editor', () => { + const pe = makePresentationEditorStub(bodyEditor, headerEditorA, 'header'); + const handle = pe.captureCurrentSelectionHandle(); + + // Switch to header B + const pe2 = makePresentationEditorStub(bodyEditor, headerEditorB, 'header'); + pe2.releaseSelectionHandle(handle); + + expect(headerEditorA.releaseSelectionHandle).toHaveBeenCalledWith(handle); + expect(headerEditorB.releaseSelectionHandle).not.toHaveBeenCalled(); + }); + + it('body handle resolves against body editor when header is active', () => { + // Capture on body + const peBody = makePresentationEditorStub(bodyEditor, bodyEditor, 'body'); + const bodyHandle = peBody.captureCurrentSelectionHandle(); + + // Switch to header mode + const peHeader = makePresentationEditorStub(bodyEditor, headerEditorA, 'header'); + const ctx = peHeader.resolveSelectionHandle(bodyHandle); + + expect(ctx).not.toBeNull(); + expect(ctx!.surface).toBe('body'); + expect(bodyEditor.resolveSelectionHandle).toHaveBeenCalledWith(bodyHandle); + }); + + it('resolveSelectionHandle returns null when underlying resolve returns null', () => { + const pe = makePresentationEditorStub(bodyEditor, headerEditorA, 'header'); + (headerEditorA.resolveSelectionHandle as ReturnType).mockReturnValue(null); + const handle = pe.captureCurrentSelectionHandle(); + const ctx = pe.resolveSelectionHandle(handle); + expect(ctx).toBeNull(); + }); +}); diff --git a/packages/super-editor/src/core/selection-state.test.ts b/packages/super-editor/src/core/selection-state.test.ts new file mode 100644 index 0000000000..4579a520e9 --- /dev/null +++ b/packages/super-editor/src/core/selection-state.test.ts @@ -0,0 +1,283 @@ +/** + * Tests for the tracked selection handle system in selection-state.ts. + * + * These tests verify that SelectionBookmark-backed handles correctly map + * through document changes, degrade gracefully when content is deleted, + * and stay bound to their owning editor instance. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { EditorState, TextSelection } from 'prosemirror-state'; +import { Schema } from 'prosemirror-model'; + +import { + createSelectionHandlePlugin, + captureSelectionHandle, + resolveHandleToSelection, + releaseSelectionHandle, + _resetHandleIdCounter, +} from './selection-state.js'; +import type { SelectionHandleOwner } from './selection-state.js'; + +// --------------------------------------------------------------------------- +// Minimal schema + helpers +// --------------------------------------------------------------------------- + +const schema = new Schema({ + nodes: { + doc: { content: 'paragraph+' }, + paragraph: { content: 'text*', toDOM: () => ['p', 0] }, + text: { inline: true }, + }, +}); + +/** + * A minimal owner that holds mutable state — mirrors how Editor works. + * Dispatching a transaction updates the owner's state in place. + */ +function createOwner(initialState: EditorState): SelectionHandleOwner & { state: EditorState } { + const owner: SelectionHandleOwner & { state: EditorState } = { + state: initialState, + dispatch(tr) { + owner.state = owner.state.apply(tr); + }, + }; + return owner; +} + +function createState(text: string): EditorState { + const doc = schema.node('doc', null, [schema.node('paragraph', null, text ? [schema.text(text)] : [])]); + return EditorState.create({ doc, plugins: [createSelectionHandlePlugin()] }); +} + +function createMultiParaState(texts: string[]): EditorState { + const paras = texts.map((t) => schema.node('paragraph', null, t ? [schema.text(t)] : [])); + const doc = schema.node('doc', null, paras); + return EditorState.create({ doc, plugins: [createSelectionHandlePlugin()] }); +} + +// --------------------------------------------------------------------------- +// Setup +// --------------------------------------------------------------------------- + +beforeEach(() => { + _resetHandleIdCounter(); +}); + +// --------------------------------------------------------------------------- +// Capture + resolve round-trip +// --------------------------------------------------------------------------- + +describe('captureSelectionHandle + resolveHandleToSelection', () => { + it('round-trips a text selection without document changes', () => { + const owner = createOwner(createState('Hello world')); + const sel = TextSelection.create(owner.state.doc, 7, 12); + + const handle = captureSelectionHandle(owner, sel, 'body'); + + const resolved = resolveHandleToSelection(handle); + expect(resolved).not.toBeNull(); + expect(resolved!.from).toBe(7); + expect(resolved!.to).toBe(12); + }); + + it('maps handle positions forward through an insertion before the range', () => { + const owner = createOwner(createState('Hello world')); + const sel = TextSelection.create(owner.state.doc, 7, 12); + const handle = captureSelectionHandle(owner, sel, 'body'); + + // Insert "XX" at position 1 (before "Hello") + owner.dispatch(owner.state.tr.insertText('XX', 1)); + + const resolved = resolveHandleToSelection(handle); + expect(resolved).not.toBeNull(); + expect(resolved!.from).toBe(9); + expect(resolved!.to).toBe(14); + }); + + it('maps handle positions through an insertion inside the range', () => { + const owner = createOwner(createState('Hello world')); + const sel = TextSelection.create(owner.state.doc, 4, 10); + const handle = captureSelectionHandle(owner, sel, 'body'); + + owner.dispatch(owner.state.tr.insertText('X', 6)); + + const resolved = resolveHandleToSelection(handle); + expect(resolved).not.toBeNull(); + expect(resolved!.from).toBe(4); + expect(resolved!.to).toBe(11); + }); + + it('tracks through multiple successive transactions', () => { + const owner = createOwner(createState('ABCDE')); + const sel = TextSelection.create(owner.state.doc, 2, 5); + const handle = captureSelectionHandle(owner, sel, 'body'); + + owner.dispatch(owner.state.tr.insertText('X', 1)); + owner.dispatch(owner.state.tr.insertText('Y', 1)); + + const resolved = resolveHandleToSelection(handle); + expect(resolved).not.toBeNull(); + expect(resolved!.from).toBe(4); + expect(resolved!.to).toBe(7); + }); +}); + +// --------------------------------------------------------------------------- +// Collapsed selection degradation +// --------------------------------------------------------------------------- + +describe('handle degradation when content is deleted', () => { + it('returns null when a non-empty selection is fully deleted', () => { + const owner = createOwner(createMultiParaState(['Hello', 'World'])); + const sel = TextSelection.create(owner.state.doc, 1, 6); + const handle = captureSelectionHandle(owner, sel, 'body'); + expect(handle.wasNonEmpty).toBe(true); + + owner.dispatch(owner.state.tr.delete(1, 6)); + + expect(resolveHandleToSelection(handle)).toBeNull(); + }); + + it('preserves a collapsed selection handle (caret) even after edits', () => { + const owner = createOwner(createState('Hello')); + const sel = TextSelection.create(owner.state.doc, 3, 3); + const handle = captureSelectionHandle(owner, sel, 'body'); + expect(handle.wasNonEmpty).toBe(false); + + owner.dispatch(owner.state.tr.insertText('X', 1)); + + const resolved = resolveHandleToSelection(handle); + expect(resolved).not.toBeNull(); + expect(resolved!.from).toBe(4); + expect(resolved!.to).toBe(4); + }); +}); + +// --------------------------------------------------------------------------- +// Release +// --------------------------------------------------------------------------- + +describe('releaseSelectionHandle', () => { + it('makes subsequent resolve return null', () => { + const owner = createOwner(createState('Hello')); + const sel = TextSelection.create(owner.state.doc, 1, 6); + const handle = captureSelectionHandle(owner, sel, 'body'); + + releaseSelectionHandle(handle); + + expect(resolveHandleToSelection(handle)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Multiple handles +// --------------------------------------------------------------------------- + +describe('multiple concurrent handles', () => { + it('tracks multiple handles independently', () => { + const owner = createOwner(createMultiParaState(['Hello', 'World'])); + const sel1 = TextSelection.create(owner.state.doc, 1, 6); + const handle1 = captureSelectionHandle(owner, sel1, 'body'); + + const sel2 = TextSelection.create(owner.state.doc, 8, 13); + const handle2 = captureSelectionHandle(owner, sel2, 'body'); + + owner.dispatch(owner.state.tr.insertText('XX', 1)); + + const resolved1 = resolveHandleToSelection(handle1); + const resolved2 = resolveHandleToSelection(handle2); + expect(resolved1).not.toBeNull(); + expect(resolved2).not.toBeNull(); + expect(resolved1!.from).toBe(3); + expect(resolved1!.to).toBe(8); + expect(resolved2!.from).toBe(10); + expect(resolved2!.to).toBe(15); + }); + + it('releasing one handle does not affect another', () => { + const owner = createOwner(createState('Hello')); + const handle1 = captureSelectionHandle(owner, TextSelection.create(owner.state.doc, 1, 3), 'body'); + const handle2 = captureSelectionHandle(owner, TextSelection.create(owner.state.doc, 3, 6), 'body'); + + releaseSelectionHandle(handle1); + + expect(resolveHandleToSelection(handle1)).toBeNull(); + expect(resolveHandleToSelection(handle2)).not.toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Owner binding +// --------------------------------------------------------------------------- + +describe('handle is bound to its owning editor', () => { + it('resolves against the capturing owner, not a different one', () => { + const ownerA = createOwner(createState('Hello')); + const ownerB = createOwner(createState('World')); + + const sel = TextSelection.create(ownerA.state.doc, 1, 6); + const handle = captureSelectionHandle(ownerA, sel, 'header'); + + // handle._owner is ownerA, so resolve reads ownerA's plugin state + expect(handle._owner).toBe(ownerA); + + const resolved = resolveHandleToSelection(handle); + expect(resolved).not.toBeNull(); + expect(resolved!.from).toBe(1); + expect(resolved!.to).toBe(6); + }); + + it('survives the active editor changing (simulates HF session switch)', () => { + // Simulate: capture in header editor A, then "switch" to header editor B + const headerEditorA = createOwner(createState('Header A content')); + const headerEditorB = createOwner(createState('Header B content')); + + const sel = TextSelection.create(headerEditorA.state.doc, 1, 8); + const handle = captureSelectionHandle(headerEditorA, sel, 'header'); + + // Edits happen in header editor A + headerEditorA.dispatch(headerEditorA.state.tr.insertText('XX', 1)); + + // Even though we could "switch" to header editor B, resolve still + // reads from header editor A because the handle is bound to it. + const resolved = resolveHandleToSelection(handle); + expect(resolved).not.toBeNull(); + expect(resolved!.from).toBe(3); + expect(resolved!.to).toBe(10); + }); +}); + +// --------------------------------------------------------------------------- +// Surface encoding +// --------------------------------------------------------------------------- + +describe('handle surface encoding', () => { + it('preserves the surface label from capture', () => { + const owner = createOwner(createState('Hello')); + const sel = TextSelection.create(owner.state.doc, 1, 6); + + const bodyHandle = captureSelectionHandle(owner, sel, 'body'); + const headerHandle = captureSelectionHandle(owner, sel, 'header'); + const footerHandle = captureSelectionHandle(owner, sel, 'footer'); + + expect(bodyHandle.surface).toBe('body'); + expect(headerHandle.surface).toBe('header'); + expect(footerHandle.surface).toBe('footer'); + }); +}); + +// --------------------------------------------------------------------------- +// Plugin absent +// --------------------------------------------------------------------------- + +describe('graceful behavior without plugin', () => { + it('returns null when the plugin is not registered', () => { + const doc = schema.node('doc', null, [schema.node('paragraph', null, [schema.text('Hello')])]); + const stateNoPlugin = EditorState.create({ doc }); + const ownerNoPlugin = { state: stateNoPlugin, dispatch() {} }; + + const fakeHandle = { id: 999, surface: 'body' as const, wasNonEmpty: true, _owner: ownerNoPlugin }; + expect(resolveHandleToSelection(fakeHandle)).toBeNull(); + }); +}); diff --git a/packages/super-editor/src/core/selection-state.ts b/packages/super-editor/src/core/selection-state.ts new file mode 100644 index 0000000000..9258c38806 --- /dev/null +++ b/packages/super-editor/src/core/selection-state.ts @@ -0,0 +1,233 @@ +/** + * Shared selection-state module. + * + * Owns: + * - the canonical plugin key for custom selection management + * - a reader for the transaction-mapped preserved selection + * - the tracked selection handle plugin and types + * + * All code that needs to read preserved selection state or reference the + * custom-selection plugin key should import from this module — not from the + * custom-selection extension directly. This keeps the dependency graph clean: + * + * shared state module ← extension + * shared state module ← adapters + * shared state module ← other extensions + */ + +import { Plugin, PluginKey } from 'prosemirror-state'; +import type { EditorState, Selection, SelectionBookmark, Transaction } from 'prosemirror-state'; + +// --------------------------------------------------------------------------- +// Custom selection plugin key + preserved selection reader +// --------------------------------------------------------------------------- + +/** + * Plugin key for the custom selection management plugin. + * + * Previously defined in `extensions/custom-selection/custom-selection.js`. + * Moved here so that adapter code and other extensions can reference it + * without importing from the extension module. + */ +export const CustomSelectionPluginKey = new PluginKey('CustomSelection'); + +/** + * Reads the transaction-mapped preserved selection from the custom-selection + * PM plugin state. + * + * Returns `null` when: + * - the plugin is not registered (headless mode, minimal configs, tests) + * - the plugin has no preserved selection + * + * This is the only safe source of preserved selection for the selection + * bridge. `editor.options.preservedSelection` and `editor.options.lastSelection` + * are raw snapshots that are never transaction-mapped — do not use them. + */ +export function getPreservedSelection(state: EditorState): Selection | null { + const focusState = CustomSelectionPluginKey.getState(state); + return focusState?.preservedSelection ?? null; +} + +// --------------------------------------------------------------------------- +// Tracked selection handles +// --------------------------------------------------------------------------- + +/** + * Accessor interface for resolving and releasing handles against the editor + * that owns them. This avoids a direct import of `Editor` (which would + * create a circular dependency) while still binding each handle to its + * specific editor instance. + */ +export interface SelectionHandleOwner { + readonly state: EditorState; + dispatch(tr: Transaction): void; +} + +/** + * An opaque, session-local handle representing a captured editor selection. + * + * The handle stores a `SelectionBookmark` that is automatically mapped through + * every transaction in the owning editor's plugin state. When you're ready + * to act on it, call `editor.resolveSelectionHandle(handle)` or + * `presentationEditor.resolveSelectionHandle(handle)` to get a fresh + * `ResolveRangeOutput` / `SelectionCommandContext`. + * + * Handles are the correct abstraction for deferred UI command flows (AI, + * confirmation dialogs, async toolbar chains) where a delay exists between + * selection capture and mutation. + * + * For immediate mutations (toolbar click → instant command), use the snapshot + * convenience methods (`getCurrentSelectionRange` / `getEffectiveSelectionRange`) + * which capture and resolve in one call. + * + * **Important**: the handle is bound to the specific editor instance that + * captured it. In layout mode, switching header/footer sessions does not + * invalidate existing handles — they continue to resolve against their + * owning editor. The `surface` label is stored for context construction only. + */ +export type SelectionHandle = { + /** Opaque identifier for this handle. */ + readonly id: number; + /** Which editing surface the selection was captured on. */ + readonly surface: 'body' | 'header' | 'footer'; + /** Whether the original captured selection was non-empty. */ + readonly wasNonEmpty: boolean; + /** + * The editor instance that owns this handle's bookmark. + * Opaque to callers — used internally by resolve/release. + * @internal + */ + readonly _owner: SelectionHandleOwner; +}; + +/** Internal entry stored in the plugin state. Not exported. */ +type HandleEntry = { + id: number; + bookmark: SelectionBookmark; + wasNonEmpty: boolean; +}; + +type HandlePluginState = { + entries: Map; +}; + +type HandlePluginMeta = { action: 'capture'; entry: HandleEntry } | { action: 'release'; id: number }; + +let nextHandleId = 1; + +export const SelectionHandlePluginKey = new PluginKey('selectionHandle'); + +/** + * Creates the tracked selection handle plugin. + * + * On every transaction, all stored bookmarks are mapped through the transform + * so handle positions stay current. This is the same mechanism ProseMirror's + * history uses to track selections across edits. + */ +export function createSelectionHandlePlugin(): Plugin { + return new Plugin({ + key: SelectionHandlePluginKey, + state: { + init(): HandlePluginState { + return { entries: new Map() }; + }, + apply(tr: Transaction, prev: HandlePluginState): HandlePluginState { + const meta = tr.getMeta(SelectionHandlePluginKey) as HandlePluginMeta | undefined; + + // Start from the previous entries — we may need to map them. + let entries = prev.entries; + + // Map all bookmarks through document changes + if (tr.docChanged && entries.size > 0) { + const next = new Map(); + for (const [id, entry] of entries) { + next.set(id, { ...entry, bookmark: entry.bookmark.map(tr.mapping) }); + } + entries = next; + } + + // Apply meta actions + if (meta?.action === 'capture') { + if (entries === prev.entries) entries = new Map(entries); + entries.set(meta.entry.id, meta.entry); + } else if (meta?.action === 'release') { + if (entries.has(meta.id)) { + if (entries === prev.entries) entries = new Map(entries); + entries.delete(meta.id); + } + } + + return entries === prev.entries ? prev : { entries }; + }, + }, + }); +} + +/** + * Captures a PM selection as a tracked handle, stored in the owner's + * plugin state. + * + * The returned handle is permanently bound to `owner` — resolving and + * releasing always go through that specific editor instance, even if the + * active header/footer session changes. + */ +export function captureSelectionHandle( + owner: SelectionHandleOwner, + selection: Selection, + surface: 'body' | 'header' | 'footer', +): SelectionHandle { + const id = nextHandleId++; + const bookmark = selection.getBookmark(); + const wasNonEmpty = !selection.empty; + + const entry: HandleEntry = { id, bookmark, wasNonEmpty }; + const tr = owner.state.tr.setMeta(SelectionHandlePluginKey, { action: 'capture', entry } satisfies HandlePluginMeta); + owner.dispatch(tr); + + return { id, surface, wasNonEmpty, _owner: owner }; +} + +/** + * Resolves a tracked handle back into a live PM selection by reading from + * the owning editor's plugin state. + * + * Returns `null` when: + * - the handle has been released + * - the plugin is not registered + * - a previously non-empty selection collapsed to empty (content was deleted) + */ +export function resolveHandleToSelection(handle: SelectionHandle): Selection | null { + const { state } = handle._owner; + const pluginState = SelectionHandlePluginKey.getState(state); + if (!pluginState) return null; + + const entry = pluginState.entries.get(handle.id); + if (!entry) return null; + + const resolved = entry.bookmark.resolve(state.doc); + + // If the original selection was non-empty but has collapsed (the content + // was deleted), return null rather than silently acting at a caret. + if (entry.wasNonEmpty && resolved.empty) return null; + + return resolved; +} + +/** + * Releases a tracked handle, removing it from the owning editor's plugin state. + * + * Always release handles when done to avoid unbounded accumulation. + */ +export function releaseSelectionHandle(handle: SelectionHandle): void { + const { state, dispatch } = handle._owner; + const tr = state.tr.setMeta(SelectionHandlePluginKey, { + action: 'release', + id: handle.id, + } satisfies HandlePluginMeta); + dispatch(tr); +} + +/** Resets the handle ID counter. Only for tests. */ +export function _resetHandleIdCounter(): void { + nextHandleId = 1; +} diff --git a/packages/super-editor/src/document-api-adapters/helpers/range-resolver.ts b/packages/super-editor/src/document-api-adapters/helpers/range-resolver.ts index 7bdc4e01ba..11d19902ff 100644 --- a/packages/super-editor/src/document-api-adapters/helpers/range-resolver.ts +++ b/packages/super-editor/src/document-api-adapters/helpers/range-resolver.ts @@ -419,11 +419,19 @@ function rangeContainsOnlyTextBlocks(index: BlockIndex, absFrom: number, absTo: // --------------------------------------------------------------------------- /** - * Resolves two explicit anchors into a contiguous document range. + * Builds a complete `ResolveRangeOutput` from absolute PM positions. * - * Returns a transparent SelectionTarget, a mutation-ready ref, and preview metadata. + * This is the shared core that both `resolveRange` (anchor-based) and the + * UI-selection bridge (`selection-range-resolver.ts`) use. + * + * When `expectedRevision` is provided, it is checked against the current + * document revision. When omitted (typical for UI-selection bridge calls), + * the current revision is read and returned as `evaluatedRevision`. */ -export function resolveRange(editor: Editor, input: ResolveRangeInput): ResolveRangeOutput { +export function resolveAbsoluteRange( + editor: Editor, + input: { absFrom: number; absTo: number; expectedRevision?: string }, +): ResolveRangeOutput { const revision = getRevision(editor); if (input.expectedRevision !== undefined) { @@ -432,13 +440,9 @@ export function resolveRange(editor: Editor, input: ResolveRangeInput): ResolveR const index = getBlockIndex(editor); - // Resolve both anchors to absolute PM positions - const rawFrom = resolveAnchor(editor, input.start, revision, index); - const rawTo = resolveAnchor(editor, input.end, revision, index); - // Normalize to document order - const absFrom = Math.min(rawFrom, rawTo); - const absTo = Math.max(rawFrom, rawTo); + const absFrom = Math.min(input.absFrom, input.absTo); + const absTo = Math.max(input.absFrom, input.absTo); const target = buildSelectionTarget(editor, index, absFrom, absTo); @@ -460,3 +464,24 @@ export function resolveRange(editor: Editor, input: ResolveRangeInput): ResolveR preview: buildPreview(editor, index, absFrom, absTo), }; } + +/** + * Resolves two explicit anchors into a contiguous document range. + * + * Returns a transparent SelectionTarget, a mutation-ready ref, and preview metadata. + */ +export function resolveRange(editor: Editor, input: ResolveRangeInput): ResolveRangeOutput { + const revision = getRevision(editor); + + if (input.expectedRevision !== undefined) { + checkRevision(editor, input.expectedRevision); + } + + const index = getBlockIndex(editor); + + // Resolve both anchors to absolute PM positions + const rawFrom = resolveAnchor(editor, input.start, revision, index); + const rawTo = resolveAnchor(editor, input.end, revision, index); + + return resolveAbsoluteRange(editor, { absFrom: rawFrom, absTo: rawTo }); +} diff --git a/packages/super-editor/src/document-api-adapters/helpers/selection-range-resolver.test.ts b/packages/super-editor/src/document-api-adapters/helpers/selection-range-resolver.test.ts new file mode 100644 index 0000000000..5e46731277 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/selection-range-resolver.test.ts @@ -0,0 +1,424 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { NodeSelection } from 'prosemirror-state'; +import type { ResolveRangeOutput } from '@superdoc/document-api'; +import type { Editor } from '../../core/Editor.js'; +import { + resolveCurrentEditorSelectionRange, + resolveEffectiveEditorSelectionRange, +} from './selection-range-resolver.js'; + +// --------------------------------------------------------------------------- +// Module mocks +// --------------------------------------------------------------------------- + +const mocks = vi.hoisted(() => ({ + resolveAbsoluteRange: vi.fn(), + getPreservedSelection: vi.fn(() => null), + mapBlockNodeType: vi.fn(), +})); + +vi.mock('./range-resolver.js', () => ({ + resolveAbsoluteRange: mocks.resolveAbsoluteRange, +})); + +vi.mock('../../core/selection-state.js', () => ({ + CustomSelectionPluginKey: { getState: vi.fn() }, + getPreservedSelection: mocks.getPreservedSelection, +})); + +vi.mock('./node-address-resolver.js', () => ({ + mapBlockNodeType: mocks.mapBlockNodeType, +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeMockOutput(absFrom: number, absTo: number): ResolveRangeOutput { + return { + evaluatedRevision: '42', + handle: { ref: `text:mock-${absFrom}-${absTo}`, refStability: 'ephemeral', coversFullTarget: true }, + target: { + kind: 'selection', + start: { kind: 'text', blockId: 'p1', offset: absFrom }, + end: { kind: 'text', blockId: 'p1', offset: absTo }, + }, + preview: { text: 'mock', truncated: false, blocks: [] }, + }; +} + +function makeTextSelection(from: number, to: number, empty?: boolean) { + return { + from, + to, + empty: empty ?? from === to, + }; +} + +/** + * Creates a mock that passes `instanceof NodeSelection`. + * + * ProseMirror's `Selection` base class uses getters for `from`/`to`/`empty`, + * so we must use `defineProperty` to override them on the prototype chain. + */ +function makeRealNodeSelection( + from: number, + to: number, + node: { type: { name: string }; isBlock: boolean; isLeaf: boolean; isInline: boolean; nodeSize: number }, +) { + const sel = Object.create(NodeSelection.prototype); + Object.defineProperty(sel, 'from', { value: from, configurable: true }); + Object.defineProperty(sel, 'to', { value: to, configurable: true }); + Object.defineProperty(sel, 'empty', { value: false, configurable: true }); + Object.defineProperty(sel, 'node', { value: node, configurable: true }); + return sel as NodeSelection; +} + +function makeCellSelection(from: number, to: number) { + return { + from, + to, + empty: false, + $anchorCell: {}, // marker property for CellSelection detection + }; +} + +function makeAllSelection(docContentSize: number) { + return { + from: 0, + to: docContentSize, + empty: false, + }; +} + +function makeEditor( + selection: unknown, + docOptions?: { resolve?: (pos: number) => unknown; contentSize?: number }, +): Editor { + return { + state: { + selection, + doc: { + content: { size: docOptions?.contentSize ?? 100 }, + resolve: docOptions?.resolve ?? (() => ({ parent: { inlineContent: true } })), + }, + }, + } as unknown as Editor; +} + +// --------------------------------------------------------------------------- +// Setup +// --------------------------------------------------------------------------- + +beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveAbsoluteRange.mockImplementation((_editor: Editor, input: { absFrom: number; absTo: number }) => + makeMockOutput(input.absFrom, input.absTo), + ); + // Default: mapBlockNodeType returns undefined (not a block) + mocks.mapBlockNodeType.mockReturnValue(undefined); +}); + +// --------------------------------------------------------------------------- +// resolveCurrentEditorSelectionRange +// --------------------------------------------------------------------------- + +describe('resolveCurrentEditorSelectionRange', () => { + it('resolves a non-collapsed TextSelection', () => { + const selection = makeTextSelection(5, 15); + const editor = makeEditor(selection); + + const result = resolveCurrentEditorSelectionRange(editor); + + expect(mocks.resolveAbsoluteRange).toHaveBeenCalledWith(editor, { absFrom: 5, absTo: 15 }); + expect(result.evaluatedRevision).toBe('42'); + }); + + it('resolves a collapsed TextSelection (caret)', () => { + const selection = makeTextSelection(10, 10, true); + const editor = makeEditor(selection); + + const result = resolveCurrentEditorSelectionRange(editor); + + expect(mocks.resolveAbsoluteRange).toHaveBeenCalledWith(editor, { absFrom: 10, absTo: 10 }); + expect(result).toBeDefined(); + }); + + it('ignores preserved selection', () => { + const liveSelection = makeTextSelection(10, 10, true); + const preservedSelection = makeTextSelection(5, 20); + mocks.getPreservedSelection.mockReturnValue(preservedSelection); + + const editor = makeEditor(liveSelection); + resolveCurrentEditorSelectionRange(editor); + + expect(mocks.resolveAbsoluteRange).toHaveBeenCalledWith(editor, { absFrom: 10, absTo: 10 }); + }); + + it('resolves AllSelection through normal from/to path', () => { + const selection = makeAllSelection(100); + const editor = makeEditor(selection); + + resolveCurrentEditorSelectionRange(editor); + + expect(mocks.resolveAbsoluteRange).toHaveBeenCalledWith(editor, { absFrom: 0, absTo: 100 }); + }); + + it('rejects CellSelection with INVALID_CONTEXT error', () => { + const selection = makeCellSelection(5, 25); + const editor = makeEditor(selection); + + expect(() => resolveCurrentEditorSelectionRange(editor)).toThrow( + 'CellSelection cannot be converted to SelectionTarget', + ); + }); + + it('returns payload with evaluatedRevision, handle, target, preview', () => { + const selection = makeTextSelection(3, 8); + const editor = makeEditor(selection); + + const result = resolveCurrentEditorSelectionRange(editor); + + expect(result).toHaveProperty('evaluatedRevision'); + expect(result).toHaveProperty('handle.ref'); + expect(result).toHaveProperty('handle.coversFullTarget'); + expect(result).toHaveProperty('target'); + expect(result).toHaveProperty('preview'); + }); +}); + +// --------------------------------------------------------------------------- +// resolveEffectiveEditorSelectionRange +// --------------------------------------------------------------------------- + +describe('resolveEffectiveEditorSelectionRange', () => { + it('prefers non-collapsed live selection over preserved selection', () => { + const liveSelection = makeTextSelection(5, 20); + const preservedSelection = makeTextSelection(1, 50); + mocks.getPreservedSelection.mockReturnValue(preservedSelection); + + const editor = makeEditor(liveSelection); + resolveEffectiveEditorSelectionRange(editor); + + expect(mocks.resolveAbsoluteRange).toHaveBeenCalledWith(editor, { absFrom: 5, absTo: 20 }); + }); + + it('falls back to preserved selection when live is collapsed', () => { + const liveSelection = makeTextSelection(10, 10, true); + const preservedSelection = makeTextSelection(5, 20); + mocks.getPreservedSelection.mockReturnValue(preservedSelection); + + const editor = makeEditor(liveSelection); + resolveEffectiveEditorSelectionRange(editor); + + expect(mocks.resolveAbsoluteRange).toHaveBeenCalledWith(editor, { absFrom: 5, absTo: 20 }); + }); + + it('falls back to collapsed live selection when preserved is absent', () => { + const liveSelection = makeTextSelection(10, 10, true); + mocks.getPreservedSelection.mockReturnValue(null); + + const editor = makeEditor(liveSelection); + resolveEffectiveEditorSelectionRange(editor); + + expect(mocks.resolveAbsoluteRange).toHaveBeenCalledWith(editor, { absFrom: 10, absTo: 10 }); + }); + + it('falls back to collapsed live selection when preserved is also collapsed', () => { + const liveSelection = makeTextSelection(10, 10, true); + const preservedSelection = makeTextSelection(15, 15, true); + mocks.getPreservedSelection.mockReturnValue(preservedSelection); + + const editor = makeEditor(liveSelection); + resolveEffectiveEditorSelectionRange(editor); + + expect(mocks.resolveAbsoluteRange).toHaveBeenCalledWith(editor, { absFrom: 10, absTo: 10 }); + }); + + it('reads preserved selection from PM plugin state via getPreservedSelection', () => { + const liveSelection = makeTextSelection(10, 10, true); + const preservedSelection = makeTextSelection(2, 8); + mocks.getPreservedSelection.mockReturnValue(preservedSelection); + + const editor = makeEditor(liveSelection); + resolveEffectiveEditorSelectionRange(editor); + + expect(mocks.getPreservedSelection).toHaveBeenCalledWith(editor.state); + }); + + it('gracefully skips when custom-selection plugin is absent', () => { + const liveSelection = makeTextSelection(10, 10, true); + mocks.getPreservedSelection.mockReturnValue(null); + + const editor = makeEditor(liveSelection); + const result = resolveEffectiveEditorSelectionRange(editor); + + expect(result).toBeDefined(); + expect(mocks.resolveAbsoluteRange).toHaveBeenCalledWith(editor, { absFrom: 10, absTo: 10 }); + }); + + it('rejects CellSelection even as preserved fallback', () => { + const liveSelection = makeTextSelection(10, 10, true); + const preservedCellSelection = makeCellSelection(5, 25); + mocks.getPreservedSelection.mockReturnValue(preservedCellSelection); + + const editor = makeEditor(liveSelection); + + expect(() => resolveEffectiveEditorSelectionRange(editor)).toThrow( + 'CellSelection cannot be converted to SelectionTarget', + ); + }); +}); + +// --------------------------------------------------------------------------- +// NodeSelection classification — mapped block types +// --------------------------------------------------------------------------- + +describe('NodeSelection classification with mapped block types', () => { + it('allows NodeSelection on a plain paragraph (maps to "paragraph")', () => { + const node = { type: { name: 'paragraph' }, isBlock: true, isLeaf: false, isInline: false, nodeSize: 10 }; + mocks.mapBlockNodeType.mockReturnValue('paragraph'); + + const selection = makeRealNodeSelection(5, 15, node); + const editor = makeEditor(selection); + + resolveCurrentEditorSelectionRange(editor); + + expect(mocks.resolveAbsoluteRange).toHaveBeenCalledWith(editor, { absFrom: 5, absTo: 15 }); + }); + + it('rejects NodeSelection on a list paragraph (maps to "listItem")', () => { + const node = { type: { name: 'paragraph' }, isBlock: true, isLeaf: false, isInline: false, nodeSize: 10 }; + // A numbered paragraph: PM type is "paragraph" but adapter maps it to "listItem" + mocks.mapBlockNodeType.mockReturnValue('listItem'); + + const selection = makeRealNodeSelection(5, 15, node); + const editor = makeEditor(selection); + + expect(() => resolveCurrentEditorSelectionRange(editor)).toThrow( + 'NodeSelection for node type "listItem" cannot be converted to SelectionTarget', + ); + }); + + it('allows NodeSelection on structuredContentBlock (maps to "sdt")', () => { + const node = { + type: { name: 'structuredContentBlock' }, + isBlock: true, + isLeaf: false, + isInline: false, + nodeSize: 20, + }; + // PM type is "structuredContentBlock" but adapter maps it to "sdt" (allowed) + mocks.mapBlockNodeType.mockReturnValue('sdt'); + + const selection = makeRealNodeSelection(10, 30, node); + const editor = makeEditor(selection); + + resolveCurrentEditorSelectionRange(editor); + + expect(mocks.resolveAbsoluteRange).toHaveBeenCalledWith(editor, { absFrom: 10, absTo: 30 }); + }); + + it('rejects NodeSelection on tableRow (maps to "tableRow")', () => { + const node = { type: { name: 'tableRow' }, isBlock: true, isLeaf: false, isInline: false, nodeSize: 50 }; + mocks.mapBlockNodeType.mockReturnValue('tableRow'); + + const selection = makeRealNodeSelection(5, 55, node); + const editor = makeEditor(selection); + + expect(() => resolveCurrentEditorSelectionRange(editor)).toThrow( + 'NodeSelection for node type "tableRow" cannot be converted to SelectionTarget', + ); + }); + + it('rejects NodeSelection on tableCell (maps to "tableCell")', () => { + const node = { type: { name: 'tableCell' }, isBlock: true, isLeaf: false, isInline: false, nodeSize: 30 }; + mocks.mapBlockNodeType.mockReturnValue('tableCell'); + + const selection = makeRealNodeSelection(5, 35, node); + const editor = makeEditor(selection); + + expect(() => resolveCurrentEditorSelectionRange(editor)).toThrow( + 'NodeSelection for node type "tableCell" cannot be converted to SelectionTarget', + ); + }); + + it('allows NodeSelection on table (maps to "table")', () => { + const node = { type: { name: 'table' }, isBlock: true, isLeaf: false, isInline: false, nodeSize: 100 }; + mocks.mapBlockNodeType.mockReturnValue('table'); + + const selection = makeRealNodeSelection(5, 105, node); + const editor = makeEditor(selection); + + resolveCurrentEditorSelectionRange(editor); + + expect(mocks.resolveAbsoluteRange).toHaveBeenCalledWith(editor, { absFrom: 5, absTo: 105 }); + }); + + it('allows inline image NodeSelection (isLeaf + isInline inside text block)', () => { + const node = { type: { name: 'image' }, isBlock: false, isLeaf: true, isInline: true, nodeSize: 1 }; + // mapBlockNodeType returns undefined for inline nodes (they're not block-level) + mocks.mapBlockNodeType.mockReturnValue(undefined); + + const selection = makeRealNodeSelection(5, 6, node); + const editor = makeEditor(selection, { + resolve: () => ({ parent: { inlineContent: true } }), + }); + + resolveCurrentEditorSelectionRange(editor); + + expect(mocks.resolveAbsoluteRange).toHaveBeenCalledWith(editor, { absFrom: 5, absTo: 6 }); + }); + + it('rejects NodeSelection on unknown block type (mapBlockNodeType returns undefined)', () => { + const node = { type: { name: 'unknownBlock' }, isBlock: true, isLeaf: false, isInline: false, nodeSize: 10 }; + mocks.mapBlockNodeType.mockReturnValue(undefined); + + const selection = makeRealNodeSelection(5, 15, node); + const editor = makeEditor(selection); + + expect(() => resolveCurrentEditorSelectionRange(editor)).toThrow('cannot be converted to SelectionTarget'); + }); + + it('includes mapped type in error details', () => { + const node = { type: { name: 'paragraph' }, isBlock: true, isLeaf: false, isInline: false, nodeSize: 10 }; + mocks.mapBlockNodeType.mockReturnValue('listItem'); + + const selection = makeRealNodeSelection(5, 15, node); + const editor = makeEditor(selection); + + try { + resolveCurrentEditorSelectionRange(editor); + expect.unreachable('should have thrown'); + } catch (err: any) { + expect(err.code).toBe('INVALID_CONTEXT'); + expect(err.details).toMatchObject({ nodeType: 'listItem', pmNodeType: 'paragraph' }); + } + }); +}); + +// --------------------------------------------------------------------------- +// current vs effective — observably different +// --------------------------------------------------------------------------- + +describe('current vs effective are observably different', () => { + it('current returns collapsed, effective returns preserved when selection is preserved', () => { + const liveSelection = makeTextSelection(10, 10, true); + const preservedSelection = makeTextSelection(3, 12); + mocks.getPreservedSelection.mockReturnValue(preservedSelection); + + const editor = makeEditor(liveSelection); + + const currentResult = resolveCurrentEditorSelectionRange(editor); + const effectiveResult = resolveEffectiveEditorSelectionRange(editor); + + // Current should use the live collapsed selection + expect(mocks.resolveAbsoluteRange).toHaveBeenNthCalledWith(1, editor, { absFrom: 10, absTo: 10 }); + // Effective should use the preserved selection + expect(mocks.resolveAbsoluteRange).toHaveBeenNthCalledWith(2, editor, { absFrom: 3, absTo: 12 }); + + // They produce different outputs + expect(currentResult.target.start.offset).toBe(10); + expect(effectiveResult.target.start.offset).toBe(3); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/helpers/selection-range-resolver.ts b/packages/super-editor/src/document-api-adapters/helpers/selection-range-resolver.ts new file mode 100644 index 0000000000..ed7f833335 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/helpers/selection-range-resolver.ts @@ -0,0 +1,196 @@ +/** + * Selection range resolver — bridges live editor UI selection state to the + * canonical Document API range model (`ResolveRangeOutput`). + * + * This module is the single source of truth for: + * - what "current selection" means (live PM state.selection) + * - what "effective selection" means (current > preserved fallback) + * - when preserved selection is consulted + * - which PM selection kinds are supported + * - how a PM selection is validated and converted to absolute positions + * + * It does NOT scatter selection-source logic across Editor, PresentationEditor, + * toolbar, AI, or context-menu code. All those callers delegate here. + */ + +import type { Selection } from 'prosemirror-state'; +import { NodeSelection } from 'prosemirror-state'; +import type { ResolveRangeOutput } from '@superdoc/document-api'; +import { SELECTION_EDGE_NODE_TYPES } from '@superdoc/document-api'; + +import type { Editor } from '../../core/Editor.js'; +import { getPreservedSelection } from '../../core/selection-state.js'; +import { resolveAbsoluteRange } from './range-resolver.js'; +import { mapBlockNodeType } from './node-address-resolver.js'; +import { DocumentApiAdapterError } from '../errors.js'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const EDGE_NODE_TYPES: ReadonlySet = new Set(SELECTION_EDGE_NODE_TYPES); + +// --------------------------------------------------------------------------- +// PM selection source policy (exported for reuse by handle resolution) +// --------------------------------------------------------------------------- + +/** + * Returns the live ProseMirror selection. No fallback, no preserved selection. + */ +export function selectCurrentPmSelection(editor: Editor): Selection { + return editor.state.selection; +} + +/** + * Returns the "effective" PM selection — the one the UI considers actionable. + * + * Fallback chain: + * 1. Live `state.selection` if non-collapsed + * 2. PM plugin preserved selection (transaction-mapped) if present and non-empty + * 3. Live `state.selection` (even if collapsed) + * + * `editor.options.preservedSelection` and `editor.options.lastSelection` are + * intentionally excluded — they are unmapped snapshots that drift after + * document-changing transactions. + */ +export function selectEffectivePmSelection(editor: Editor): Selection { + const liveSelection = editor.state.selection; + + if (!liveSelection.empty) { + return liveSelection; + } + + const preserved = getPreservedSelection(editor.state); + if (preserved && !preserved.empty) { + return preserved; + } + + return liveSelection; +} + +// --------------------------------------------------------------------------- +// NodeSelection classification +// --------------------------------------------------------------------------- + +type NodeSelectionClass = 'block-edge' | 'inline-leaf' | 'reject'; + +/** + * Classifies a `NodeSelection` into one of three categories **before** the + * generic position resolver runs. This prevents `resolveGapPosition` from + * silently mis-mapping unsupported structural nodes. + * + * - `block-edge`: selected node maps to a supported edge node type via the + * adapter's `mapBlockNodeType` (not the raw PM schema name). This matters + * because PM `'paragraph'` can map to `'listItem'` (excluded) and PM + * `'structuredContentBlock'` maps to `'sdt'` (allowed). + * - `inline-leaf`: selected node is an inline leaf inside a text block (e.g. inline image) + * - `reject`: unsupported structural node — must throw before resolving + */ +function classifyNodeSelection(editor: Editor, selection: NodeSelection): NodeSelectionClass { + const node = selection.node; + + // Use the adapter's mapped block type, not the raw PM schema name. + // This ensures list paragraphs → 'listItem' (excluded) and + // structuredContentBlock → 'sdt' (allowed) are handled correctly. + const mappedType = mapBlockNodeType(node); + if (mappedType && EDGE_NODE_TYPES.has(mappedType)) { + return 'block-edge'; + } + + if (node.isLeaf && node.isInline) { + const $pos = editor.state.doc.resolve(selection.from); + if ($pos.parent.inlineContent) { + return 'inline-leaf'; + } + } + + return 'reject'; +} + +// --------------------------------------------------------------------------- +// PM Selection → absolute range (exported for reuse by handle resolution) +// --------------------------------------------------------------------------- + +/** + * Validates a PM selection and extracts absolute positions for range resolution. + * + * - `TextSelection` / `AllSelection`: use `selection.from` / `selection.to` + * - `NodeSelection`: classify first, reject unsupported cases + * - `CellSelection`: always reject + */ +export function extractAbsoluteRange(editor: Editor, selection: Selection): { absFrom: number; absTo: number } { + // CellSelection — reject before any position resolution + if ('$anchorCell' in selection) { + throw new DocumentApiAdapterError( + 'INVALID_CONTEXT', + 'CellSelection cannot be converted to SelectionTarget. Use table-specific APIs for rectangular table selections.', + { selectionType: selection.constructor.name }, + ); + } + + // NodeSelection — three-way classification before resolving + if (selection instanceof NodeSelection) { + const classification = classifyNodeSelection(editor, selection); + if (classification === 'reject') { + const mappedType = mapBlockNodeType(selection.node); + const displayType = mappedType ?? selection.node.type.name; + throw new DocumentApiAdapterError( + 'INVALID_CONTEXT', + `NodeSelection for node type "${displayType}" cannot be converted to SelectionTarget.`, + { nodeType: displayType, pmNodeType: selection.node.type.name }, + ); + } + // block-edge and inline-leaf both proceed with selection.from / selection.to + } + + return { absFrom: selection.from, absTo: selection.to }; +} + +// --------------------------------------------------------------------------- +// PM Selection → ResolveRangeOutput (shared pipeline) +// --------------------------------------------------------------------------- + +/** + * Validates a PM selection, extracts positions, and builds a full + * `ResolveRangeOutput`. This is the shared pipeline used by both + * snapshot methods and handle resolution. + */ +export function resolvePmSelectionToRange(editor: Editor, selection: Selection): ResolveRangeOutput { + const { absFrom, absTo } = extractAbsoluteRange(editor, selection); + return resolveAbsoluteRange(editor, { absFrom, absTo }); +} + +// --------------------------------------------------------------------------- +// Public snapshot API +// --------------------------------------------------------------------------- + +/** + * Resolves the live PM `state.selection` into a `ResolveRangeOutput`. + * + * Does NOT consult preserved selection or any fallback — returns exactly what + * the current PM selection describes. + * + * This is a convenience wrapper: captures the current selection and resolves + * immediately. For deferred flows, use `captureCurrentSelectionHandle` instead. + */ +export function resolveCurrentEditorSelectionRange(editor: Editor): ResolveRangeOutput { + const selection = selectCurrentPmSelection(editor); + return resolvePmSelectionToRange(editor, selection); +} + +/** + * Resolves the "effective" selection into a `ResolveRangeOutput`. + * + * The effective selection is what the UI considers actionable for commands: + * - a non-collapsed live selection wins + * - otherwise, the transaction-mapped preserved selection from the + * custom-selection PM plugin is used (if present and non-empty) + * - otherwise, the current (possibly collapsed) live selection is returned + * + * This is a convenience wrapper: captures the effective selection and resolves + * immediately. For deferred flows, use `captureEffectiveSelectionHandle` instead. + */ +export function resolveEffectiveEditorSelectionRange(editor: Editor): ResolveRangeOutput { + const selection = selectEffectivePmSelection(editor); + return resolvePmSelectionToRange(editor, selection); +} diff --git a/packages/super-editor/src/extensions/custom-selection/custom-selection.js b/packages/super-editor/src/extensions/custom-selection/custom-selection.js index 11164e9b8b..a471f137cf 100644 --- a/packages/super-editor/src/extensions/custom-selection/custom-selection.js +++ b/packages/super-editor/src/extensions/custom-selection/custom-selection.js @@ -1,9 +1,10 @@ // @ts-nocheck /* global Element */ import { Extension } from '@core/Extension.js'; -import { Plugin, PluginKey, TextSelection } from 'prosemirror-state'; +import { Plugin, TextSelection } from 'prosemirror-state'; import { Decoration, DecorationSet } from 'prosemirror-view'; import { shouldAllowNativeContextMenu } from '../../utils/contextmenu-helpers.js'; +import { CustomSelectionPluginKey } from '@core/selection-state.js'; export const DEFAULT_SELECTION_STATE = Object.freeze({ focused: false, @@ -39,11 +40,9 @@ const normalizeSelectionState = (state = {}) => ({ * }); */ -/** - * Plugin key for custom selection management - * @private - */ -export const CustomSelectionPluginKey = new PluginKey('CustomSelection'); +// CustomSelectionPluginKey is imported from @core/selection-state.js and re-exported +// for backward compatibility with existing consumers of this module. +export { CustomSelectionPluginKey } from '@core/selection-state.js'; /** * Handle clicks outside the editor @@ -101,23 +100,28 @@ function getFocusState(state) { /** * Map a preserved selection through a document-changing transaction. - * Uses inclusive mapping so inserted text at either boundary stays highlighted. + * + * Uses SelectionBookmark to preserve the original selection kind + * (TextSelection, NodeSelection, AllSelection) through document changes. + * Previously this always rebuilt a TextSelection, silently degrading + * preserved NodeSelection/AllSelection after any edit. * * @private - * @param {Object|null} selection - Previous preserved selection-like object + * @param {Object|null} selection - Previous preserved PM Selection * @param {Object} tr - Transaction - * @returns {Object|null} Remapped TextSelection or null if range collapsed/invalid + * @returns {Object|null} Remapped selection (same kind) or null if invalid */ function mapPreservedSelection(selection, tr) { if (!selection || !tr.docChanged) return selection; - if (typeof selection.from !== 'number' || typeof selection.to !== 'number') return null; - - const from = tr.mapping.map(selection.from, -1); - const to = tr.mapping.map(selection.to, 1); - if (from >= to) return null; + if (typeof selection.getBookmark !== 'function') return null; try { - return TextSelection.create(tr.doc, from, to); + const bookmark = selection.getBookmark(); + const mapped = bookmark.map(tr.mapping); + const resolved = mapped.resolve(tr.doc); + // If the selection was non-empty but collapsed, treat as invalid + if (!selection.empty && resolved.empty) return null; + return resolved; } catch { return null; } diff --git a/packages/super-editor/src/extensions/history/history.js b/packages/super-editor/src/extensions/history/history.js index c4df191399..f960df91cf 100644 --- a/packages/super-editor/src/extensions/history/history.js +++ b/packages/super-editor/src/extensions/history/history.js @@ -3,7 +3,8 @@ import { TextSelection } from 'prosemirror-state'; import { history, redo as originalRedo, undo as originalUndo } from 'prosemirror-history'; import { undo as yUndo, redo as yRedo, yUndoPlugin } from 'y-prosemirror'; import { Extension } from '@core/Extension.js'; -import { CustomSelectionPluginKey, DEFAULT_SELECTION_STATE } from '../custom-selection/custom-selection.js'; +import { CustomSelectionPluginKey } from '@core/selection-state.js'; +import { DEFAULT_SELECTION_STATE } from '../custom-selection/custom-selection.js'; function applySelectionCleanup(editor, tr) { let cleaned = tr.setMeta(CustomSelectionPluginKey, DEFAULT_SELECTION_STATE); diff --git a/packages/super-editor/src/extensions/linked-styles/helpers.js b/packages/super-editor/src/extensions/linked-styles/helpers.js index 70b372b4b9..41e499305d 100644 --- a/packages/super-editor/src/extensions/linked-styles/helpers.js +++ b/packages/super-editor/src/extensions/linked-styles/helpers.js @@ -1,5 +1,5 @@ // @ts-check -import { CustomSelectionPluginKey } from '../custom-selection/custom-selection.js'; +import { CustomSelectionPluginKey } from '@core/selection-state.js'; import { getLineHeightValueString } from '@core/super-converter/helpers.js'; import { findParentNode } from '../../core/helpers/findParentNode.js'; import { kebabCase } from '@superdoc/common'; diff --git a/packages/super-editor/src/index.d.ts b/packages/super-editor/src/index.d.ts index 41a294ce88..5e21c11318 100644 --- a/packages/super-editor/src/index.d.ts +++ b/packages/super-editor/src/index.d.ts @@ -6,6 +6,40 @@ export type { EditorView } from 'prosemirror-view'; export type { EditorState, Transaction } from 'prosemirror-state'; export type { Schema } from 'prosemirror-model'; +export type { ResolveRangeOutput, DocumentApi } from '@superdoc/document-api'; + +/** + * An opaque, session-local handle representing a captured editor selection. + * + * The handle's bookmark is automatically mapped through every transaction. + * Resolve it via `editor.resolveSelectionHandle(handle)` to get a fresh + * `ResolveRangeOutput` reflecting the current document state. + * + * For deferred UI flows (AI, confirmation dialogs, async chains). + * For immediate mutations, use the snapshot convenience methods instead. + */ +export type SelectionHandle = { + readonly id: number; + readonly surface: 'body' | 'header' | 'footer'; + readonly wasNonEmpty: boolean; + /** @internal Opaque owner reference — do not use directly. */ + readonly _owner: unknown; +}; + +/** + * Bundles the active editing surface's editor, document API, surface label, + * and resolved selection range into a single coherent object. + * + * Returned by `PresentationEditor.getEffectiveSelectionContext()`, + * `PresentationEditor.getCurrentSelectionContext()`, and + * `PresentationEditor.resolveSelectionHandle()`. + */ +export type SelectionCommandContext = { + editor: Editor; + doc: DocumentApi; + surface: 'body' | 'header' | 'footer'; + range: ResolveRangeOutput; +}; // ============================================ // COMMAND TYPES (inlined from ChainedCommands.ts) @@ -552,6 +586,31 @@ export declare class Editor { */ isEmpty: boolean; + // --- Tracked selection handle API --- + + /** Capture the live PM selection as a tracked handle. Local-only. */ + captureCurrentSelectionHandle(surface?: 'body' | 'header' | 'footer'): SelectionHandle; + + /** Capture the "effective" selection as a tracked handle. Local-only. */ + captureEffectiveSelectionHandle(surface?: 'body' | 'header' | 'footer'): SelectionHandle; + + /** + * Resolve a previously captured handle into a fresh `ResolveRangeOutput`. + * Returns `null` if the handle was released or the selection collapsed. + */ + resolveSelectionHandle(handle: SelectionHandle): ResolveRangeOutput | null; + + /** Release a tracked selection handle. */ + releaseSelectionHandle(handle: SelectionHandle): void; + + // --- Snapshot convenience API --- + + /** Snapshot convenience: resolve the live PM selection immediately. Local-only. */ + getCurrentSelectionRange(): ResolveRangeOutput; + + /** Snapshot convenience: resolve the "effective" selection immediately. Local-only. */ + getEffectiveSelectionRange(): ResolveRangeOutput; + /** Allow additional properties */ [key: string]: any; } @@ -643,6 +702,37 @@ export declare class PresentationEditor { */ getActiveEditor(): Editor; + // --- Tracked selection handle API --- + + /** Capture the live PM selection on the active editor as a tracked handle. */ + captureCurrentSelectionHandle(): SelectionHandle; + + /** Capture the "effective" selection on the active editor as a tracked handle. */ + captureEffectiveSelectionHandle(): SelectionHandle; + + /** + * Resolve a captured handle into a `SelectionCommandContext`. + * Returns `null` if the handle was released or the selection collapsed. + */ + resolveSelectionHandle(handle: SelectionHandle): SelectionCommandContext | null; + + /** Release a tracked selection handle. */ + releaseSelectionHandle(handle: SelectionHandle): void; + + // --- Snapshot convenience API --- + + /** Snapshot convenience: resolve the live PM selection immediately. Routes through active editor. */ + getCurrentSelectionRange(): ResolveRangeOutput; + + /** Snapshot convenience: resolve the "effective" selection immediately. Routes through active editor. */ + getEffectiveSelectionRange(): ResolveRangeOutput; + + /** Snapshot convenience: current selection + active editing context. */ + getCurrentSelectionContext(): SelectionCommandContext; + + /** Snapshot convenience: effective selection + active editing context. The canonical layout-mode API. */ + getEffectiveSelectionContext(): SelectionCommandContext; + /** * Undo the last action in the active editor. */ From cb418da7d65da7be6fc9095d854403f6a6df4f14 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Wed, 18 Mar 2026 19:44:31 -0700 Subject: [PATCH 2/2] fix(super-editor): preserve inclusive selection tracking for Document API bridge --- .../src/core/Editor.selection-handle.test.ts | 63 ++++++++++++ packages/super-editor/src/core/Editor.ts | 22 ++++- .../src/core/selection-state.test.ts | 24 ++++- .../super-editor/src/core/selection-state.ts | 48 ++++++++- .../src/core/types/EditorConfig.ts | 3 + .../custom-selection/custom-selection.js | 4 +- .../custom-selection/custom-selection.test.js | 22 +++++ .../pagination/pagination-helpers.js | 1 + .../pagination/pagination-helpers.test.js | 97 +++++++++++++++++++ 9 files changed, 274 insertions(+), 10 deletions(-) create mode 100644 packages/super-editor/src/core/Editor.selection-handle.test.ts create mode 100644 packages/super-editor/src/extensions/pagination/pagination-helpers.test.js diff --git a/packages/super-editor/src/core/Editor.selection-handle.test.ts b/packages/super-editor/src/core/Editor.selection-handle.test.ts new file mode 100644 index 0000000000..27f34dfc00 --- /dev/null +++ b/packages/super-editor/src/core/Editor.selection-handle.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; + +import { getStarterExtensions } from '@extensions/index.js'; +import { loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; + +import { Editor } from './Editor.js'; + +let blankDocData: { docx: unknown; mediaFiles: unknown; fonts: unknown }; +const editors: Editor[] = []; + +beforeAll(async () => { + blankDocData = await loadTestDataForEditorTests('blank-doc.docx'); +}); + +afterEach(() => { + while (editors.length > 0) { + editors.pop()?.destroy(); + } +}); + +function createTestEditor(options: Partial[0]> = {}): Editor { + const editor = new Editor({ + isHeadless: true, + deferDocumentLoad: true, + mode: 'docx', + extensions: getStarterExtensions(), + suppressDefaultDocxStyles: true, + ...options, + }); + editors.push(editor); + return editor; +} + +function getBlankDocOptions() { + return { + mode: 'docx' as const, + content: blankDocData.docx, + mediaFiles: blankDocData.mediaFiles, + fonts: blankDocData.fonts, + }; +} + +describe('Editor selection-handle surface inference', () => { + it('defaults direct header editor captures to the header surface', async () => { + const editor = createTestEditor({ + isHeaderOrFooter: true, + headerFooterType: 'header', + }); + await editor.open(undefined, getBlankDocOptions()); + + expect(editor.captureCurrentSelectionHandle().surface).toBe('header'); + }); + + it('defaults direct footer editor captures to the footer surface', async () => { + const editor = createTestEditor({ + isHeaderOrFooter: true, + headerFooterType: 'footer', + }); + await editor.open(undefined, getBlankDocOptions()); + + expect(editor.captureEffectiveSelectionHandle().surface).toBe('footer'); + }); +}); diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index 297f4ac98b..7ab46d37db 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -1300,6 +1300,20 @@ export class Editor extends EventEmitter { // Selection bridge — tracked handles + snapshot convenience // ------------------------------------------------------------------- + /** + * Infers the default capture surface for this editor instance. + * + * Body editors report `body`. Header/footer child editors created by the + * pagination helpers persist their concrete surface kind in + * `options.headerFooterType`, allowing direct calls on + * `presentationEditor.getActiveEditor()` to produce handles with the + * correct surface label without requiring every caller to pass it manually. + */ + #getDefaultSelectionHandleSurface(): 'body' | 'header' | 'footer' { + const explicitType = this.options.headerFooterType; + return explicitType === 'header' || explicitType === 'footer' ? explicitType : 'body'; + } + /** * Capture the live PM selection as a tracked handle. * @@ -1312,10 +1326,10 @@ export class Editor extends EventEmitter { * * Local-only — captures from **this** editor's `state.selection`. */ - captureCurrentSelectionHandle(surface: 'body' | 'header' | 'footer' = 'body'): SelectionHandle { + captureCurrentSelectionHandle(surface?: 'body' | 'header' | 'footer'): SelectionHandle { this.#assertState('ready', 'saving'); const selection = selectCurrentPmSelection(this); - return captureSelectionHandle(this, selection, surface); + return captureSelectionHandle(this, selection, surface ?? this.#getDefaultSelectionHandleSurface()); } /** @@ -1327,10 +1341,10 @@ export class Editor extends EventEmitter { * * Local-only — captures from **this** editor. */ - captureEffectiveSelectionHandle(surface: 'body' | 'header' | 'footer' = 'body'): SelectionHandle { + captureEffectiveSelectionHandle(surface?: 'body' | 'header' | 'footer'): SelectionHandle { this.#assertState('ready', 'saving'); const selection = selectEffectivePmSelection(this); - return captureSelectionHandle(this, selection, surface); + return captureSelectionHandle(this, selection, surface ?? this.#getDefaultSelectionHandleSurface()); } /** diff --git a/packages/super-editor/src/core/selection-state.test.ts b/packages/super-editor/src/core/selection-state.test.ts index 4579a520e9..7324a3ffc7 100644 --- a/packages/super-editor/src/core/selection-state.test.ts +++ b/packages/super-editor/src/core/selection-state.test.ts @@ -108,6 +108,22 @@ describe('captureSelectionHandle + resolveHandleToSelection', () => { expect(resolved!.to).toBe(11); }); + it('keeps text selections inclusive when content is inserted exactly at the left edge', () => { + const owner = createOwner(createState('Hello world')); + // Select "world" (positions 7..12) + const sel = TextSelection.create(owner.state.doc, 7, 12); + const handle = captureSelectionHandle(owner, sel, 'body'); + + owner.dispatch(owner.state.tr.insertText('big ', 7)); + + const resolved = resolveHandleToSelection(handle); + expect(resolved).not.toBeNull(); + // The inserted text should remain inside the tracked selection. + expect(resolved!.from).toBe(7); + expect(resolved!.to).toBe(16); + expect(owner.state.doc.textBetween(resolved!.from, resolved!.to)).toBe('big world'); + }); + it('tracks through multiple successive transactions', () => { const owner = createOwner(createState('ABCDE')); const sel = TextSelection.create(owner.state.doc, 2, 5); @@ -189,7 +205,9 @@ describe('multiple concurrent handles', () => { const resolved2 = resolveHandleToSelection(handle2); expect(resolved1).not.toBeNull(); expect(resolved2).not.toBeNull(); - expect(resolved1!.from).toBe(3); + // Handle 1 starts exactly at the insertion point, so the inserted text + // stays inside the tracked selection. + expect(resolved1!.from).toBe(1); expect(resolved1!.to).toBe(8); expect(resolved2!.from).toBe(10); expect(resolved2!.to).toBe(15); @@ -243,7 +261,9 @@ describe('handle is bound to its owning editor', () => { // reads from header editor A because the handle is bound to it. const resolved = resolveHandleToSelection(handle); expect(resolved).not.toBeNull(); - expect(resolved!.from).toBe(3); + // The selection started at the insertion point, so the inserted text + // remains part of the tracked selection. + expect(resolved!.from).toBe(1); expect(resolved!.to).toBe(10); }); }); diff --git a/packages/super-editor/src/core/selection-state.ts b/packages/super-editor/src/core/selection-state.ts index 9258c38806..6d9f13f60f 100644 --- a/packages/super-editor/src/core/selection-state.ts +++ b/packages/super-editor/src/core/selection-state.ts @@ -15,7 +15,7 @@ * shared state module ← other extensions */ -import { Plugin, PluginKey } from 'prosemirror-state'; +import { Plugin, PluginKey, TextSelection } from 'prosemirror-state'; import type { EditorState, Selection, SelectionBookmark, Transaction } from 'prosemirror-state'; // --------------------------------------------------------------------------- @@ -63,6 +63,50 @@ export interface SelectionHandleOwner { dispatch(tr: Transaction): void; } +/** + * Custom bookmark for non-empty TextSelections that keeps both range edges + * inclusive when content is inserted exactly at the boundary. + * + * ProseMirror's built-in TextBookmark maps both ends with default assoc=1, + * which shifts the left edge rightward on exact-boundary inserts. For this + * feature we want the preserved/tracked text range to continue covering the + * inserted content on both sides, matching the pre-bookmark behavior. + */ +class InclusiveTextSelectionBookmark implements SelectionBookmark { + constructor( + readonly anchor: number, + readonly head: number, + ) {} + + map(mapping: Transaction['mapping']): SelectionBookmark { + const isForward = this.anchor <= this.head; + return new InclusiveTextSelectionBookmark( + mapping.map(this.anchor, isForward ? -1 : 1), + mapping.map(this.head, isForward ? 1 : -1), + ); + } + + resolve(doc: EditorState['doc']): Selection { + return TextSelection.between(doc.resolve(this.anchor), doc.resolve(this.head)); + } +} + +/** + * Returns the bookmark representation used by tracked selection handles and + * preserved selection remapping. + * + * Non-empty TextSelections use an inclusive bookmark so inserts at either + * edge remain inside the tracked range. Other selection kinds use ProseMirror's + * built-in bookmark implementation to preserve their native semantics. + */ +export function createSelectionTrackingBookmark(selection: Selection): SelectionBookmark { + if (selection instanceof TextSelection && !selection.empty) { + return new InclusiveTextSelectionBookmark(selection.anchor, selection.head); + } + + return selection.getBookmark(); +} + /** * An opaque, session-local handle representing a captured editor selection. * @@ -177,7 +221,7 @@ export function captureSelectionHandle( surface: 'body' | 'header' | 'footer', ): SelectionHandle { const id = nextHandleId++; - const bookmark = selection.getBookmark(); + const bookmark = createSelectionTrackingBookmark(selection); const wasNonEmpty = !selection.empty; const entry: HandleEntry = { id, bookmark, wasNonEmpty }; diff --git a/packages/super-editor/src/core/types/EditorConfig.ts b/packages/super-editor/src/core/types/EditorConfig.ts index 705e7f08e1..65c6ce6e3d 100644 --- a/packages/super-editor/src/core/types/EditorConfig.ts +++ b/packages/super-editor/src/core/types/EditorConfig.ts @@ -361,6 +361,9 @@ export interface EditorOptions { /** Whether this is a header or footer editor */ isHeaderOrFooter?: boolean; + /** Concrete header/footer surface kind for child editors */ + headerFooterType?: 'header' | 'footer'; + /** Optional pagination metadata */ lastSelection?: unknown | null; diff --git a/packages/super-editor/src/extensions/custom-selection/custom-selection.js b/packages/super-editor/src/extensions/custom-selection/custom-selection.js index a471f137cf..f2edef3fda 100644 --- a/packages/super-editor/src/extensions/custom-selection/custom-selection.js +++ b/packages/super-editor/src/extensions/custom-selection/custom-selection.js @@ -4,7 +4,7 @@ import { Extension } from '@core/Extension.js'; import { Plugin, TextSelection } from 'prosemirror-state'; import { Decoration, DecorationSet } from 'prosemirror-view'; import { shouldAllowNativeContextMenu } from '../../utils/contextmenu-helpers.js'; -import { CustomSelectionPluginKey } from '@core/selection-state.js'; +import { CustomSelectionPluginKey, createSelectionTrackingBookmark } from '@core/selection-state.js'; export const DEFAULT_SELECTION_STATE = Object.freeze({ focused: false, @@ -116,7 +116,7 @@ function mapPreservedSelection(selection, tr) { if (typeof selection.getBookmark !== 'function') return null; try { - const bookmark = selection.getBookmark(); + const bookmark = createSelectionTrackingBookmark(selection); const mapped = bookmark.map(tr.mapping); const resolved = mapped.resolve(tr.doc); // If the selection was non-empty but collapsed, treat as invalid diff --git a/packages/super-editor/src/extensions/custom-selection/custom-selection.test.js b/packages/super-editor/src/extensions/custom-selection/custom-selection.test.js index e5b187135d..0f6d0514bc 100644 --- a/packages/super-editor/src/extensions/custom-selection/custom-selection.test.js +++ b/packages/super-editor/src/extensions/custom-selection/custom-selection.test.js @@ -288,6 +288,28 @@ describe('CustomSelection plugin', () => { expect(view.state.doc.textBetween(firstDeco.from, firstDeco.to)).toBe('planet'); }); + it('keeps preserved text selections inclusive when inserting exactly at the left edge', () => { + const { plugin, view } = createEnvironment(); + + view.dispatch( + view.state.tr.setMeta(CustomSelectionPluginKey, { + focused: true, + preservedSelection: view.state.selection, + showVisualSelection: true, + }), + ); + + const { from } = view.state.selection; + view.dispatch(view.state.tr.insertText('big ', from)); + + const decorations = plugin.props.decorations(view.state); + expect(decorations).toBeInstanceOf(DecorationSet); + const [firstDeco] = decorations.find(); + expect(firstDeco).toBeDefined(); + expect(firstDeco.from).toBe(from); + expect(view.state.doc.textBetween(firstDeco.from, firstDeco.to)).toBe('big Hello'); + }); + it('clears preserved visual selection when mapped range collapses', () => { const { plugin, view } = createEnvironment(); diff --git a/packages/super-editor/src/extensions/pagination/pagination-helpers.js b/packages/super-editor/src/extensions/pagination/pagination-helpers.js index 10016fb5c9..6ccc80581c 100644 --- a/packages/super-editor/src/extensions/pagination/pagination-helpers.js +++ b/packages/super-editor/src/extensions/pagination/pagination-helpers.js @@ -210,6 +210,7 @@ export const createHeaderFooterEditor = ({ mediaFiles: editor.storage.image.media, fonts: editor.options.fonts, isHeaderOrFooter: true, // This flag prevents pagination from being enabled + headerFooterType: type, isHeadless: editor.options.isHeadless, pagination: false, // Explicitly disable pagination annotations: true, diff --git a/packages/super-editor/src/extensions/pagination/pagination-helpers.test.js b/packages/super-editor/src/extensions/pagination/pagination-helpers.test.js new file mode 100644 index 0000000000..3d19f7d52f --- /dev/null +++ b/packages/super-editor/src/extensions/pagination/pagination-helpers.test.js @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { MockEditor, getStarterExtensions, applyStyleIsolationClass } = vi.hoisted(() => { + class MockEditor { + constructor(options) { + this.options = options; + this.on = vi.fn(); + this.off = vi.fn(); + this.once = vi.fn(); + this.emit = vi.fn(); + this.setEditable = vi.fn(); + this.view = { dom: document.createElement('div') }; + this.storage = { image: { media: {} } }; + } + } + + return { + MockEditor: vi.fn((options) => new MockEditor(options)), + getStarterExtensions: vi.fn(() => []), + applyStyleIsolationClass: vi.fn(), + }; +}); + +vi.mock('@core/Editor.js', () => ({ + Editor: MockEditor, +})); + +vi.mock('@extensions/index.js', () => ({ + getStarterExtensions, +})); + +vi.mock('@utils/styleIsolation.js', () => ({ + applyStyleIsolationClass, +})); + +vi.mock('@extensions/collaboration/part-sync/index.js', () => ({ + isApplyingRemotePartChanges: vi.fn(() => false), +})); + +vi.mock('@core/parts/adapters/header-footer-sync.js', () => ({ + exportSubEditorToPart: vi.fn(), +})); + +import { createHeaderFooterEditor } from './pagination-helpers.js'; + +function createParentEditor() { + return { + options: { + role: 'editor', + fonts: {}, + isHeadless: true, + }, + storage: { + image: { + media: {}, + }, + }, + converter: { + getDocumentDefaultStyles() { + return { + fontSizePt: 12, + typeface: 'Arial', + fontFamilyCss: 'Arial', + }; + }, + }, + }; +} + +describe('createHeaderFooterEditor', () => { + beforeEach(() => { + MockEditor.mockClear(); + getStarterExtensions.mockClear(); + applyStyleIsolationClass.mockClear(); + }); + + it('passes headerFooterType through to child editors so capture defaults use the right surface', () => { + const editorHost = document.createElement('div'); + const editorContainer = document.createElement('div'); + + createHeaderFooterEditor({ + editor: createParentEditor(), + data: { type: 'doc', content: [{ type: 'paragraph' }] }, + editorContainer, + editorHost, + sectionId: 'rId-footer-default', + type: 'footer', + }); + + expect(MockEditor).toHaveBeenCalledWith( + expect.objectContaining({ + isHeaderOrFooter: true, + headerFooterType: 'footer', + }), + ); + }); +});