From fe79026a83fe5f1fa741f2d61e3baf1cd5d5cc12 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 5 May 2026 10:40:28 -0300 Subject: [PATCH 1/5] feat(ui): add ui.viewport.getHost and positionAt (SD-2943) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two primitives consumers building custom UI keep reaching for and not finding on the public surface: ui.viewport.getHost() returns the editor's painted host element so custom-UI components scope DOM listeners to the editor without a CSS class filter. The information already lives on presentationEditor.visibleHost; this lifts it onto the controller. ui.viewport.positionAt({ x, y }) resolves a viewport coordinate to a caret position on the routed editor's PM document, returning both the SelectionPoint and the SelectionTarget shapes so consumers can pass the result straight to editor.doc.insert / replace / etc. The natural pair to entityAt: while entityAt answers "what entity is under this point?", positionAt answers "what caret position is under this point?" — the missing primitive that lets right-click menus offer "Paste here" / "Insert at this point" honestly, instead of dispatching against the user's previous selection. Both methods scope to the controller's painted host: a multi-instance page can't have one controller's positionAt return positions in another's PM doc, and post-destroy calls return null. Tests cover the happy path, the no-editor-mounted case, and the missing-posAtCoords stub case. --- .../src/ui/create-super-doc-ui.ts | 20 ++++ packages/super-editor/src/ui/index.ts | 2 + packages/super-editor/src/ui/position-at.ts | 105 ++++++++++++++++++ packages/super-editor/src/ui/types.ts | 77 +++++++++++++ packages/super-editor/src/ui/viewport.test.ts | 44 ++++++++ packages/superdoc/src/ui.d.ts | 2 + 6 files changed, 250 insertions(+) create mode 100644 packages/super-editor/src/ui/position-at.ts diff --git a/packages/super-editor/src/ui/create-super-doc-ui.ts b/packages/super-editor/src/ui/create-super-doc-ui.ts index 8a3a5c2877..ab2a938b69 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -16,6 +16,7 @@ import type { } from '@superdoc/document-api'; import { collectEntityHitsFromChain } from './entity-at.js'; import { shallowEqual } from './equality.js'; +import { resolvePositionAt } from './position-at.js'; import { shortcutFromEvent } from './keyboard-shortcuts.js'; import { scrollRangeIntoView } from './scroll-into-view.js'; import { getSelectionAnchorRect, getSelectionRects } from './selection-rects.js'; @@ -51,6 +52,8 @@ import type { ViewportEntityAtInput, ViewportEntityHit, ViewportGetRectInput, + ViewportPositionAtInput, + ViewportPositionHit, ViewportHandle, ViewportRect, ViewportRectResult, @@ -1658,6 +1661,23 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { if (!startEl || !host.contains(startEl)) return []; return collectEntityHitsFromChain(startEl); }, + + getHost(): HTMLElement | null { + const editor = resolveHostEditor(superdoc); + return editor?.presentationEditor?.visibleHost ?? null; + }, + + positionAt(input: ViewportPositionAtInput): ViewportPositionHit | null { + if (!input || typeof input.x !== 'number' || typeof input.y !== 'number') return null; + const hostEditor = resolveHostEditor(superdoc); + const routedEditor = resolveRoutedEditor(superdoc); + return resolvePositionAt( + hostEditor as unknown as Parameters[0], + routedEditor as unknown as Parameters[1], + input.x, + input.y, + ); + }, }; // ---- ui.selection ------------------------------------------------------ diff --git a/packages/super-editor/src/ui/index.ts b/packages/super-editor/src/ui/index.ts index 84447bb5b1..e9a34bc72f 100644 --- a/packages/super-editor/src/ui/index.ts +++ b/packages/super-editor/src/ui/index.ts @@ -128,6 +128,8 @@ export type { ViewportEntityHit, ViewportGetRectInput, ViewportHandle, + ViewportPositionAtInput, + ViewportPositionHit, ViewportRect, ViewportRectResult, diff --git a/packages/super-editor/src/ui/position-at.ts b/packages/super-editor/src/ui/position-at.ts new file mode 100644 index 0000000000..63483387d5 --- /dev/null +++ b/packages/super-editor/src/ui/position-at.ts @@ -0,0 +1,105 @@ +/** + * `ui.viewport.positionAt({ x, y })` helper. Resolves a viewport + * coordinate to a {@link SelectionPoint} / {@link SelectionTarget} + * pair on the routed editor's PM document. The natural pair to + * `entityAt`: while `entityAt` answers "what entity is under this + * point?", `positionAt` answers "what caret position is under this + * point?" — the missing primitive that lets right-click menus offer + * actions like "Paste here" / "Insert clause at this point" without + * dispatching against the user's previous selection. + */ + +import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import type { SelectionPoint, SelectionTarget } from '@superdoc/document-api'; +import type { Editor } from '../editors/v1/core/Editor.js'; +import { pmPositionToTextOffset } from '../editors/v1/document-api-adapters/helpers/text-offset-resolver.js'; +import type { ViewportPositionHit } from './types.js'; + +interface HostEditor { + presentationEditor?: { + posAtCoords?(coords: { clientX: number; clientY: number }): { pos: number; inside: number } | null; + visibleHost?: HTMLElement; + } | null; +} + +/** + * Resolve a viewport (x, y) coordinate to the caret position under + * that point. Returns `null` for points outside the painted host or + * when no editor is mounted. + * + * `hostEditor` is the controller's host editor (where the painted + * host and the coord-to-position helper live). `routedEditor` is the + * editor whose PM document the caret belongs to — for clicks inside a + * header/footer/footnote story while focus is in that surface, the + * routed editor is the story editor; otherwise it is the host. + */ +export function resolvePositionAt( + hostEditor: (Editor & HostEditor) | null, + routedEditor: Editor | null, + x: number, + y: number, +): ViewportPositionHit | null { + if (!hostEditor || !routedEditor) return null; + const presentation = hostEditor.presentationEditor; + if (!presentation || typeof presentation.posAtCoords !== 'function') return null; + + // Scope to this controller's painted host. `posAtCoords` itself is + // already painter-scoped (returns null for points outside the + // layout), but checking the host upfront also avoids running the + // coord lookup for clicks that obviously aren't ours, e.g. on a + // sidebar that happens to overlap the painted page. + const host = presentation.visibleHost; + if (host && typeof document !== 'undefined') { + const elAtPoint = document.elementFromPoint?.(x, y); + if (elAtPoint && !host.contains(elAtPoint)) return null; + } + + let result: { pos: number; inside: number } | null = null; + try { + result = presentation.posAtCoords({ clientX: x, clientY: y }); + } catch { + return null; + } + if (!result) return null; + + const block = findContainingTextBlock(routedEditor.state?.doc as ProseMirrorNode | undefined, result.pos); + if (!block) return null; + + const offset = pmPositionToTextOffset(block.node, block.pos, result.pos); + const point: SelectionPoint = { kind: 'text', blockId: block.blockId, offset }; + const target: SelectionTarget = { kind: 'selection', start: point, end: point }; + return { point, target }; +} + +interface BlockMatch { + node: ProseMirrorNode; + pos: number; + blockId: string; +} + +/** + * Walk the doc to find the textblock that contains `pmPos`. Same + * shape `collectTextSegments` uses for selections, but specialized + * for a single point so we don't allocate a segments array. + */ +function findContainingTextBlock(doc: ProseMirrorNode | undefined, pmPos: number): BlockMatch | null { + if (!doc) return null; + let match: BlockMatch | null = null; + doc.descendants((node, pos) => { + if (match) return false; + if (!node.isTextblock) return true; + const blockStart = pos; + const blockEnd = pos + node.nodeSize; + if (pmPos < blockStart || pmPos > blockEnd) return false; + const blockId = readBlockId(node); + if (!blockId) return false; + match = { node, pos, blockId }; + return false; + }); + return match; +} + +function readBlockId(node: ProseMirrorNode): string | null { + const id = (node.attrs as { id?: unknown })?.id; + return typeof id === 'string' && id.length > 0 ? id : null; +} diff --git a/packages/super-editor/src/ui/types.ts b/packages/super-editor/src/ui/types.ts index aea60fe988..880ce1602d 100644 --- a/packages/super-editor/src/ui/types.ts +++ b/packages/super-editor/src/ui/types.ts @@ -177,6 +177,12 @@ export interface SuperDocEditorLike { * from the wrong instance. */ visibleHost?: HTMLElement; + /** + * Coordinate-to-position helper. Consumed by + * `ui.viewport.positionAt` to resolve a viewport `(x, y)` to a + * caret position in the editor's PM document. + */ + posAtCoords?(coords: { clientX: number; clientY: number }): { pos: number; inside: number } | null; } | null; } @@ -1574,6 +1580,77 @@ export interface ViewportHandle { * compatible. */ entityAt(input: ViewportEntityAtInput): ViewportEntityHit[]; + /** + * The painted-DOM host element for this controller's editor, or + * `null` when no editor is mounted (SSR, post-destroy, before + * `onReady` fires). + * + * Custom UI consumers reach for the host element to scope their + * own DOM listeners — `contextmenu`, hover tooltips, drag-and-drop + * — to events that originate inside the editor. Without this, + * consumers either listen on `document` and filter by a CSS class + * they control (fragile, breaks when the wrapper class is renamed) + * or pass the editor's container down through their own component + * tree (verbose). + * + * The returned element is the host SuperDoc paints into. The + * editor's hidden ProseMirror DOM is appended elsewhere and is not + * inside this host — events whose target is in the hidden PM DOM + * (most keyboard events after focus moves into the editor) won't + * pass `host.contains(target)` checks. For coordinate-based hit + * tests use {@link entityAt} or {@link positionAt} instead, both of + * which scope correctly across painted-DOM and hidden-DOM events. + */ + getHost(): HTMLElement | null; + /** + * Resolve a viewport coordinate to a position in the editor's + * document, or `null` when the point is outside the painted host or + * no editor is mounted. + * + * The natural pair to {@link entityAt}: while `entityAt` answers + * "what entity is under this point?", `positionAt` answers "what + * caret position is under this point?". Right-click menus offering + * "Paste here", "Insert clause at this point", or "Add comment at + * this point" need this to dispatch their action against the click + * coordinate rather than the user's previous selection somewhere + * else in the document. + * + * Returns a {@link ViewportPositionHit} with both the resolved + * `point` (a `SelectionPoint` consumers can pass straight to + * `editor.doc.insert({ target })` and similar APIs) and the + * `target` (a `SelectionTarget` for selection-shaped operations). + * The two shapes are derived from the same underlying position, + * just packaged differently to match the doc-api method that's + * about to consume them. + */ + positionAt(input: ViewportPositionAtInput): ViewportPositionHit | null; +} + +/** + * Input shape for {@link ViewportHandle.positionAt}. Same coordinate + * space as `MouseEvent.clientX` / `clientY` and {@link ViewportRect}. + */ +export interface ViewportPositionAtInput { + x: number; + y: number; +} + +/** + * Resolved caret position returned by {@link ViewportHandle.positionAt}. + * + * `point` is the {@link import('@superdoc/document-api').SelectionPoint} + * shape used by point-anchored doc-api operations (`editor.doc.insert( + * { target: { kind: 'selection', start: point, end: point } })` for a + * collapsed insert at the click site). + * + * `target` is the equivalent {@link import('@superdoc/document-api').SelectionTarget} + * — a collapsed selection at the click point — for operations that + * accept a target shape directly. Same underlying position, two + * packagings; consumers pick the shape their downstream call needs. + */ +export interface ViewportPositionHit { + point: import('@superdoc/document-api').SelectionPoint; + target: import('@superdoc/document-api').SelectionTarget; } /** diff --git a/packages/super-editor/src/ui/viewport.test.ts b/packages/super-editor/src/ui/viewport.test.ts index 2d71ff3a34..6bdf745d1a 100644 --- a/packages/super-editor/src/ui/viewport.test.ts +++ b/packages/super-editor/src/ui/viewport.test.ts @@ -362,3 +362,47 @@ describe('ui.viewport.entityAt — host scoping', () => { ui.destroy(); }); }); + +describe('ui.viewport.getHost', () => { + it('returns the painted host element when one is mounted', () => { + const { superdoc } = makeStubs(); + const host = document.createElement('div'); + document.body.appendChild(host); + ( + superdoc.activeEditor as unknown as { presentationEditor: { visibleHost: HTMLElement } } + ).presentationEditor.visibleHost = host; + + const ui = createSuperDocUI({ superdoc }); + expect(ui.viewport.getHost()).toBe(host); + + host.remove(); + ui.destroy(); + }); + + it('returns null when no editor is mounted', () => { + const { superdoc } = makeStubs(); + (superdoc.activeEditor as unknown as { presentationEditor: unknown }).presentationEditor = undefined; + const ui = createSuperDocUI({ superdoc }); + expect(ui.viewport.getHost()).toBeNull(); + ui.destroy(); + }); +}); + +describe('ui.viewport.positionAt — input validation', () => { + it('returns null for invalid input (missing or non-numeric coordinates)', () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + expect(ui.viewport.positionAt({} as never)).toBeNull(); + expect(ui.viewport.positionAt({ x: 'a', y: 0 } as never)).toBeNull(); + + ui.destroy(); + }); + + it('returns null when posAtCoords is missing on the presentation stub', () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + expect(ui.viewport.positionAt({ x: 10, y: 10 })).toBeNull(); + ui.destroy(); + }); +}); diff --git a/packages/superdoc/src/ui.d.ts b/packages/superdoc/src/ui.d.ts index f64426a6ec..021e75a1f9 100644 --- a/packages/superdoc/src/ui.d.ts +++ b/packages/superdoc/src/ui.d.ts @@ -59,6 +59,8 @@ export { type ViewportEntityHit, type ViewportGetRectInput, type ViewportHandle, + type ViewportPositionAtInput, + type ViewportPositionHit, type ViewportRect, type ViewportRectResult, } from '@superdoc/super-editor/ui'; From 7e66b0572a43b6d2c300561578b5b3c149e9bce9 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 5 May 2026 11:59:52 -0300 Subject: [PATCH 2/5] fix(ui): canonical sdBlockId fallback + story scope on positionAt (SD-2943) readBlockId now uses the sdBlockId ?? id ?? blockId fallback the selection resolver already applies, so positionAt resolves paragraph clicks instead of returning null. Adds PresentationEditor.getActiveStoryLocator (unifies story-session and header/footer-session locators) and threads the result onto SelectionPoint.story / SelectionTarget.story so doc-api operations route to the active story instead of falling back to body. --- .../presentation-editor/PresentationEditor.ts | 21 ++++ packages/super-editor/src/ui/position-at.ts | 34 +++++- packages/super-editor/src/ui/types.ts | 9 ++ packages/super-editor/src/ui/viewport.test.ts | 114 ++++++++++++++++++ 4 files changed, 174 insertions(+), 4 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 158a7851c6..ebd67cab4f 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -1363,6 +1363,27 @@ export class PresentationEditor extends EventEmitter { return this.#storySessionManager; } + /** + * The {@link StoryLocator} for the currently routed editor, or `null` + * when the body editor is active. Notes (footnote/endnote) flow + * through the generic story-session manager; headers/footers flow + * through the legacy header-footer session. Both are unified here so + * external surfaces (selection / positionAt) can thread the locator + * onto a {@link SelectionTarget} without reaching into private state. + */ + getActiveStoryLocator(): StoryLocator | null { + const storySession = this.#storySessionManager?.getActiveSession(); + if (storySession) return storySession.locator; + + const session = this.#headerFooterSession?.session; + if (!session || session.mode === 'body' || !session.headerFooterRefId) return null; + return { + kind: 'story', + storyType: 'headerFooterPart', + refId: session.headerFooterRefId, + }; + } + /** * Exit any active non-body editing surface and restore the body editor. * diff --git a/packages/super-editor/src/ui/position-at.ts b/packages/super-editor/src/ui/position-at.ts index 63483387d5..e5924a722d 100644 --- a/packages/super-editor/src/ui/position-at.ts +++ b/packages/super-editor/src/ui/position-at.ts @@ -10,7 +10,7 @@ */ import type { Node as ProseMirrorNode } from 'prosemirror-model'; -import type { SelectionPoint, SelectionTarget } from '@superdoc/document-api'; +import type { SelectionPoint, SelectionTarget, StoryLocator } from '@superdoc/document-api'; import type { Editor } from '../editors/v1/core/Editor.js'; import { pmPositionToTextOffset } from '../editors/v1/document-api-adapters/helpers/text-offset-resolver.js'; import type { ViewportPositionHit } from './types.js'; @@ -19,6 +19,7 @@ interface HostEditor { presentationEditor?: { posAtCoords?(coords: { clientX: number; clientY: number }): { pos: number; inside: number } | null; visibleHost?: HTMLElement; + getActiveStoryLocator?(): StoryLocator | null; } | null; } @@ -66,11 +67,31 @@ export function resolvePositionAt( if (!block) return null; const offset = pmPositionToTextOffset(block.node, block.pos, result.pos); - const point: SelectionPoint = { kind: 'text', blockId: block.blockId, offset }; - const target: SelectionTarget = { kind: 'selection', start: point, end: point }; + // When the routed editor is a story (header/footer/note), the blockId + // resolves against the story's PM doc. Without `story`, downstream + // doc-api ops (`insert`, `replace`, etc.) default to body and fail to + // locate the block. Mirrors the locator the host editor would use when + // routing operations to the active story. + const story = readActiveStoryLocator(hostEditor); + const point: SelectionPoint = story + ? { kind: 'text', blockId: block.blockId, offset, story } + : { kind: 'text', blockId: block.blockId, offset }; + const target: SelectionTarget = story + ? { kind: 'selection', start: point, end: point, story } + : { kind: 'selection', start: point, end: point }; return { point, target }; } +function readActiveStoryLocator(hostEditor: Editor & HostEditor): StoryLocator | null { + const presentation = hostEditor.presentationEditor; + if (!presentation || typeof presentation.getActiveStoryLocator !== 'function') return null; + try { + return presentation.getActiveStoryLocator() ?? null; + } catch { + return null; + } +} + interface BlockMatch { node: ProseMirrorNode; pos: number; @@ -100,6 +121,11 @@ function findContainingTextBlock(doc: ProseMirrorNode | undefined, pmPos: number } function readBlockId(node: ProseMirrorNode): string | null { - const id = (node.attrs as { id?: unknown })?.id; + // Match the canonical fallback used by `selection-info-resolver.ts`: + // paragraphs (the most common textblock) only set `sdBlockId`; reading + // `attrs.id` alone returns null for every paragraph and silently + // bricks `positionAt` for the bulk of click targets. + const attrs = (node.attrs ?? {}) as Record; + const id = attrs.sdBlockId ?? attrs.id ?? attrs.blockId; return typeof id === 'string' && id.length > 0 ? id : null; } diff --git a/packages/super-editor/src/ui/types.ts b/packages/super-editor/src/ui/types.ts index 880ce1602d..5e04f9de85 100644 --- a/packages/super-editor/src/ui/types.ts +++ b/packages/super-editor/src/ui/types.ts @@ -183,6 +183,15 @@ export interface SuperDocEditorLike { * caret position in the editor's PM document. */ posAtCoords?(coords: { clientX: number; clientY: number }): { pos: number; inside: number } | null; + /** + * The story locator for the routed editor when the user is + * inside a header/footer/footnote/endnote, or `null` when the body + * editor is active. `ui.viewport.positionAt` threads this onto the + * returned `SelectionPoint` / `SelectionTarget` so consumers passing + * the target to `editor.doc.insert` / `replace` route to the right + * story instead of falling back to body. + */ + getActiveStoryLocator?(): import('@superdoc/document-api').StoryLocator | null; } | null; } diff --git a/packages/super-editor/src/ui/viewport.test.ts b/packages/super-editor/src/ui/viewport.test.ts index 6bdf745d1a..63ba51e601 100644 --- a/packages/super-editor/src/ui/viewport.test.ts +++ b/packages/super-editor/src/ui/viewport.test.ts @@ -406,3 +406,117 @@ describe('ui.viewport.positionAt — input validation', () => { ui.destroy(); }); }); + +describe('ui.viewport.positionAt - resolution', () => { + // Minimal PM-shaped textblock: a single paragraph carrying its block + // id under `sdBlockId` (the canonical attr the importer assigns to + // paragraphs). `id` and `blockId` are intentionally absent so the + // test catches a regression of the `attrs.id`-only readBlockId. + function makeDocWithParagraphAtSdBlockId(blockId: string, content: string) { + const text = { isText: true, isTextblock: false, text: content, nodeSize: content.length, attrs: {} }; + const paragraph = { + isText: false, + isTextblock: true, + nodeSize: content.length + 2, // open + content + close + attrs: { sdBlockId: blockId }, + content, + }; + const doc = { + descendants(callback: (node: unknown, pos: number) => boolean | void) { + // Walk: paragraph at pos=0, then its text child (skipped because + // !isTextblock returns true to descend, but we don't model the + // text child since findContainingTextBlock matches the textblock + // itself). + callback(paragraph, 0); + }, + }; + return { doc, paragraph, text }; + } + + function buildEditorStub( + doc: unknown, + posAtCoordsResult: { pos: number; inside: number } | null, + extras: { storyLocator?: unknown; visibleHost?: HTMLElement } = {}, + ) { + const editor: { + on: ReturnType; + off: ReturnType; + state: { doc: unknown }; + doc: unknown; + presentationEditor: { + getActiveEditor: () => unknown; + posAtCoords: (coords: { clientX: number; clientY: number }) => { pos: number; inside: number } | null; + visibleHost?: HTMLElement; + getActiveStoryLocator?: () => unknown; + }; + } = { + on: vi.fn(), + off: vi.fn(), + state: { doc }, + doc: { + selection: { current: vi.fn(() => ({ empty: true })) }, + comments: { + list: vi.fn(() => ({ + evaluatedRevision: 'r1', + total: 0, + items: [], + page: { limit: 0, offset: 0, returned: 0 }, + })), + }, + trackChanges: { + list: vi.fn(() => ({ + evaluatedRevision: 'r1', + total: 0, + items: [], + page: { limit: 0, offset: 0, returned: 0 }, + })), + }, + }, + presentationEditor: { + getActiveEditor: () => editor, + posAtCoords: vi.fn(() => posAtCoordsResult), + visibleHost: extras.visibleHost, + getActiveStoryLocator: extras.storyLocator !== undefined ? vi.fn(() => extras.storyLocator) : undefined, + }, + }; + return editor; + } + + it('resolves a paragraph whose block id is stored on `sdBlockId` (not `id`)', () => { + const { doc } = makeDocWithParagraphAtSdBlockId('p-42', 'hello'); + const editor = buildEditorStub(doc, { pos: 3, inside: 0 }); // pos inside paragraph + const superdoc: SuperDocLike = { + activeEditor: editor as never, + config: { documentMode: 'editing' }, + }; + const ui = createSuperDocUI({ superdoc }); + + const hit = ui.viewport.positionAt({ x: 10, y: 10 }); + expect(hit).not.toBeNull(); + expect(hit?.point.kind).toBe('text'); + expect((hit?.point as { blockId: string }).blockId).toBe('p-42'); + expect(hit?.target.kind).toBe('selection'); + expect((hit?.target.start as { blockId: string }).blockId).toBe('p-42'); + + ui.destroy(); + }); + + it('threads the active story locator onto the returned point and target', () => { + const { doc } = makeDocWithParagraphAtSdBlockId('hf-block-1', 'header'); + const storyLocator = { kind: 'story', storyType: 'headerFooterPart', refId: 'rId7' } as const; + const editor = buildEditorStub(doc, { pos: 4, inside: 0 }, { storyLocator }); + const superdoc: SuperDocLike = { + activeEditor: editor as never, + config: { documentMode: 'editing' }, + }; + const ui = createSuperDocUI({ superdoc }); + + const hit = ui.viewport.positionAt({ x: 10, y: 10 }); + expect(hit).not.toBeNull(); + expect(hit?.point).toMatchObject({ blockId: 'hf-block-1', story: storyLocator }); + expect(hit?.target).toMatchObject({ story: storyLocator }); + expect((hit?.target.start as { story?: unknown }).story).toEqual(storyLocator); + + ui.destroy(); + }); +}); From 50a391e5204757a0da369df9450166cf0f70504a Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 5 May 2026 13:16:34 -0300 Subject: [PATCH 3/5] feat(ui): story-aware selection state and capture/restore (SD-2954) The controller stamps the active story locator onto the live TextTarget when the routed editor is a header/footer/footnote/endnote, so state.selection.target / selectionTarget and ui.selection.capture() all carry the same routing information ui.viewport.positionAt got in SD-2943. ui.selection.restore now compares the captured story against the active surface and returns a typed 'stale' on mismatch instead of falling through to a less-specific resolver failure. Captures with no story keep the prior body/default behavior. The fix is scoped to the controller surface. Direct editor.doc.selection.current() calls still return body-scoped targets; threading story through the lower-level resolver is a separate change. --- .../src/ui/create-super-doc-ui.test.ts | 86 +++++++++++++++++ .../src/ui/create-super-doc-ui.ts | 64 ++++++++++++- .../src/ui/selection-restore.test.ts | 92 ++++++++++++++++++- .../super-editor/src/ui/selection-restore.ts | 60 +++++++++++- 4 files changed, 295 insertions(+), 7 deletions(-) diff --git a/packages/super-editor/src/ui/create-super-doc-ui.test.ts b/packages/super-editor/src/ui/create-super-doc-ui.test.ts index ed0f759206..2c27fae201 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.test.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.test.ts @@ -552,6 +552,92 @@ describe('createSuperDocUI', () => { }); }); + // SD-2954: when the live selection resolver returns a TextTarget + // without `story` (the resolver runs against the routed editor and + // has no path back to the host's PresentationEditor), the + // controller stamps the active story locator at the seam where + // both editors are reachable. Without this stamping the live + // selection slice carries body-scoped targets even when the user + // is editing a header, and downstream doc-api ops route to body + // and silently fail to find the block. + it('state.selection.target gets the active story locator stamped when the resolver omits it', async () => { + const headerStory = { kind: 'story', storyType: 'headerFooterPart', refId: 'rId7' }; + + const headerEditor = { + on: vi.fn(), + off: vi.fn(), + state: { selection: { empty: false } }, + isEditable: true, + doc: { + selection: { + current: vi.fn(() => ({ + empty: false, + text: 'header text', + // Resolver returns no story field. Controller must stamp it. + target: { kind: 'text', segments: [{ blockId: 'h1', range: { start: 0, end: 4 } }] }, + activeMarks: [], + activeCommentIds: [], + activeChangeIds: [], + })), + }, + }, + }; + + const presentationEditor: Record = { + on: vi.fn(), + off: vi.fn(), + isEditable: true, + state: { selection: { empty: false } }, + // Body editor is the host; routed editor is the header. + getActiveEditor: vi.fn(() => headerEditor), + getActiveStoryLocator: vi.fn(() => headerStory), + commands: {}, + }; + + const bodyEditor = { + on: vi.fn(), + off: vi.fn(), + state: { selection: { empty: true } }, + isEditable: true, + doc: { + selection: { + current: vi.fn(() => ({ + empty: true, + target: null, + activeMarks: [], + activeCommentIds: [], + activeChangeIds: [], + })), + }, + }, + }; + (bodyEditor as unknown as { _presentationEditor: unknown })._presentationEditor = presentationEditor; + (bodyEditor as unknown as { presentationEditor: unknown }).presentationEditor = presentationEditor; + + const superdoc = { + activeEditor: bodyEditor as never, + config: { documentMode: 'editing' as const }, + on: vi.fn(), + off: vi.fn(), + }; + + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + + const slice = ui.select((state) => state.selection).get(); + expect(slice.target).toEqual({ + kind: 'text', + segments: [{ blockId: 'h1', range: { start: 0, end: 4 } }], + story: headerStory, + }); + expect(slice.selectionTarget).toEqual({ + kind: 'selection', + start: { kind: 'text', blockId: 'h1', offset: 0, story: headerStory }, + end: { kind: 'text', blockId: 'h1', offset: 4, story: headerStory }, + story: headerStory, + }); + }); + it('state.selection.selectionTarget is null when target is null', () => { const superdoc = makeSuperdocStub(); (superdoc.activeEditor as { doc: { selection: { current: unknown } } }).doc.selection.current = vi.fn(() => ({ diff --git a/packages/super-editor/src/ui/create-super-doc-ui.ts b/packages/super-editor/src/ui/create-super-doc-ui.ts index ab2a938b69..825ab8d8c9 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -284,6 +284,44 @@ function resolvePresentationEditor(superdoc: SuperDocUIOptions['superdoc']): { * accept TextTarget directly (separate ticket); until then, * `selectionSlice.selectionTarget` is the consumer-facing shortcut. */ +/** + * Reads the currently routed story from the host's PresentationEditor. + * Returns `null` when the body editor is active or when the host + * doesn't expose the locator (older mounts, server-side stubs). + * + * The selection-info resolver runs against the routed editor and has + * no path back to the host, so the controller stamps the locator onto + * the live TextTarget at the seam where both editors are reachable. + * Same shape SD-2943's `ui.viewport.positionAt` uses for the same + * reason: without it, downstream doc-api ops fall back to body and + * fail to locate the block. + */ +function readActiveStoryLocator( + hostEditor: SuperDocEditorLike | null, +): import('@superdoc/document-api').StoryLocator | null { + const presentation = hostEditor?.presentationEditor; + if (!presentation || typeof presentation.getActiveStoryLocator !== 'function') return null; + try { + return presentation.getActiveStoryLocator() ?? null; + } catch { + return null; + } +} + +/** + * Stamp `story` onto a live TextTarget when the routed editor is a + * non-body story and the resolver didn't already attach it. Idempotent + * when `story` is already present (resolver-attached or otherwise). + */ +function attachStoryToTextTarget( + textTarget: import('@superdoc/document-api').TextTarget | null, + story: import('@superdoc/document-api').StoryLocator | null, +): import('@superdoc/document-api').TextTarget | null { + if (!textTarget || !story) return textTarget; + if ((textTarget as { story?: unknown }).story) return textTarget; + return { ...textTarget, story }; +} + function textTargetToSelectionTarget( textTarget: import('@superdoc/document-api').TextTarget | null, ): import('@superdoc/document-api').SelectionTarget | null { @@ -605,7 +643,20 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { // inside the resolver) keeps the slice identity stable and lets // `shallowEqual` short-circuit `ui.select(s => s.selection)` // subscribers. - const selectionTextTarget = (selectionInfo?.target ?? null) as import('@superdoc/document-api').TextTarget | null; + // SD-2954: when the routed editor is a non-body story, stamp the + // active story locator onto the live TextTarget. The selection + // resolver runs against the routed editor and has no path back to + // the host's PresentationEditor, so the controller seam is the + // only place where both are reachable. Direct + // `editor.doc.selection.current()` calls are unaffected by design; + // a deeper adapter change would be a separate ticket. + const hostEditor = resolveHostEditor(superdoc); + const routedIsStory = editor != null && hostEditor != null && editor !== hostEditor; + const activeStory = routedIsStory ? readActiveStoryLocator(hostEditor) : null; + const selectionTextTarget = attachStoryToTextTarget( + (selectionInfo?.target ?? null) as import('@superdoc/document-api').TextTarget | null, + activeStory, + ); const selectionActiveMarks = (selectionInfo?.activeMarks ?? EMPTY_ACTIVE_IDS) as string[]; const selectionKey = buildSelectionKey( empty, @@ -1761,8 +1812,17 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { // time, the routed editor is body and resolution returns // `'stale'` rather than placing the selection on the wrong // surface. + // + // Host editor (SD-2954): the restore helper reads + // `presentationEditor.getActiveStoryLocator()` from the host + // to short-circuit on cross-surface captures with a typed + // `'stale'`, so a capture in a header doesn't silently fall + // through to the body resolution. const editor = resolveRoutedEditor(superdoc); - return restoreSelection(editor as unknown as Parameters[0], capture); + const hostEditor = resolveHostEditor(superdoc); + return restoreSelection(editor as unknown as Parameters[0], capture, { + hostEditor: hostEditor as unknown as Parameters[2]['hostEditor'], + }); }, }; diff --git a/packages/super-editor/src/ui/selection-restore.test.ts b/packages/super-editor/src/ui/selection-restore.test.ts index d6b5b72971..0aaa3a4863 100644 --- a/packages/super-editor/src/ui/selection-restore.test.ts +++ b/packages/super-editor/src/ui/selection-restore.test.ts @@ -20,14 +20,25 @@ vi.mock('../editors/v1/document-api-adapters/helpers/adapter-utils.js', () => ({ * directly; the rest of the editor surface is unused so the stub is * minimal. */ -function makeStubs(opts: { isEditable?: boolean; resolves?: boolean; liveText?: string } = {}) { +function makeStubs( + opts: { + isEditable?: boolean; + resolves?: boolean; + liveText?: string; + activeStoryLocator?: unknown; + } = {}, +) { const isEditable = opts.isEditable ?? true; const resolves = opts.resolves ?? true; const liveText = opts.liveText ?? 'test'; const setTextSelection = vi.fn(() => true); + const getActiveStoryLocator = vi.fn(() => opts.activeStoryLocator ?? null); - const editor = { + // Self-reference threaded into presentationEditor below so that + // resolveToolbarSources (called from createHeadlessToolbar during + // setup) can route through `presentationEditor.getActiveEditor()`. + const editor: Record = { on: vi.fn(), off: vi.fn(), isEditable, @@ -39,6 +50,7 @@ function makeStubs(opts: { isEditable?: boolean; resolves?: boolean; liveText?: doc: { textBetween: () => liveText, }, + selection: { empty: true }, }, commands: { setTextSelection }, doc: { @@ -61,6 +73,15 @@ function makeStubs(opts: { isEditable?: boolean; resolves?: boolean; liveText?: }, }, }; + editor.presentationEditor = { + getActiveEditor: () => editor, + getActiveStoryLocator, + isEditable, + state: { selection: { empty: true } }, + commands: {}, + on: vi.fn(), + off: vi.fn(), + }; const superdoc: SuperDocLike = { activeEditor: editor as never, @@ -69,9 +90,24 @@ function makeStubs(opts: { isEditable?: boolean; resolves?: boolean; liveText?: off: vi.fn(), }; - return { superdoc, editor, mocks: { setTextSelection } }; + return { superdoc, editor, mocks: { setTextSelection, getActiveStoryLocator } }; } +const headerStory = Object.freeze({ kind: 'story', storyType: 'headerFooterPart', refId: 'rId7' }) as never; +const headerCapture = Object.freeze({ + empty: false, + target: { + kind: 'text', + segments: [{ blockId: 'b1', range: { start: 0, end: 4 } }], + story: headerStory, + }, + selectionTarget: null, + activeMarks: [], + activeCommentIds: [], + activeChangeIds: [], + quotedText: 'test', +}) as never; + const bodyCapture = Object.freeze({ empty: false, target: { kind: 'text', segments: [{ blockId: 'b1', range: { start: 0, end: 4 } }] }, @@ -169,4 +205,54 @@ describe('ui.selection.restore', () => { expect(ui.selection.restore(bodyCapture)).toEqual({ success: false, reason: 'not-ready' }); ui.destroy(); }); + + // SD-2954: a capture taken while editing a header carries + // `target.story`. Restore must verify the active surface still + // matches the captured story before resolving block ids. Body's + // PM doc usually doesn't contain the header's blockId, so the + // implicit failure path would surface as `'stale'` anyway, but + // the explicit check makes the intent unambiguous and lets the + // helper short-circuit before touching the resolver. + it('succeeds when the captured story matches the active story (header round-trip)', () => { + const { superdoc, mocks } = makeStubs({ activeStoryLocator: headerStory }); + const ui = createSuperDocUI({ superdoc }); + + expect(ui.selection.restore(headerCapture)).toEqual({ success: true }); + expect(mocks.setTextSelection).toHaveBeenCalledTimes(1); + ui.destroy(); + }); + + it('returns "stale" when the captured story is no longer active (focus moved back to body)', () => { + const { superdoc, mocks } = makeStubs({ activeStoryLocator: null }); + const ui = createSuperDocUI({ superdoc }); + + expect(ui.selection.restore(headerCapture)).toEqual({ success: false, reason: 'stale' }); + // Short-circuited before any resolver / dispatch work. + expect(mocks.setTextSelection).not.toHaveBeenCalled(); + ui.destroy(); + }); + + it('returns "stale" when the active story differs from the captured story (different header refId)', () => { + const otherHeader = { kind: 'story', storyType: 'headerFooterPart', refId: 'rId-OTHER' }; + const { superdoc, mocks } = makeStubs({ activeStoryLocator: otherHeader }); + const ui = createSuperDocUI({ superdoc }); + + expect(ui.selection.restore(headerCapture)).toEqual({ success: false, reason: 'stale' }); + expect(mocks.setTextSelection).not.toHaveBeenCalled(); + ui.destroy(); + }); + + it('matches the captured story by value, not by object identity', () => { + // The host emits a fresh locator object on every call to + // `getActiveStoryLocator()`. The match must hold even though the + // capture's story object and the active story object are not the + // same JS reference, so long as their discriminating fields agree. + const equivalentHeader = { kind: 'story', storyType: 'headerFooterPart', refId: 'rId7' }; + const { superdoc, mocks } = makeStubs({ activeStoryLocator: equivalentHeader }); + const ui = createSuperDocUI({ superdoc }); + + expect(ui.selection.restore(headerCapture)).toEqual({ success: true }); + expect(mocks.setTextSelection).toHaveBeenCalledTimes(1); + ui.destroy(); + }); }); diff --git a/packages/super-editor/src/ui/selection-restore.ts b/packages/super-editor/src/ui/selection-restore.ts index 7c313e57b2..185cc929fe 100644 --- a/packages/super-editor/src/ui/selection-restore.ts +++ b/packages/super-editor/src/ui/selection-restore.ts @@ -6,15 +6,38 @@ * needs (capture on open → restore on close). */ +import type { StoryLocator } from '@superdoc/document-api'; import type { Editor } from '../editors/v1/core/Editor.js'; import { resolveTextTarget } from '../editors/v1/document-api-adapters/helpers/adapter-utils.js'; -import type { SelectionCapture, SelectionRestoreResult } from './types.js'; +import type { SelectionCapture, SelectionRestoreResult, SuperDocEditorLike } from './types.js'; const SUCCESS: SelectionRestoreResult = { success: true }; -export function restoreSelection(editor: Editor | null, capture: SelectionCapture): SelectionRestoreResult { +export function restoreSelection( + editor: Editor | null, + capture: SelectionCapture, + options: { hostEditor?: SuperDocEditorLike | null } = {}, +): SelectionRestoreResult { if (!editor) return { success: false, reason: 'not-ready' }; + // SD-2954: when the capture carries a `story` locator, the + // captured block ids only make sense in that story's PM doc. If + // the user has switched surfaces between capture and restore (e.g. + // capture in a header, restore after focus moved back to body), + // the routed editor is body, the captured ids won't resolve, and + // we'd otherwise reach `resolveTextTarget` only to fail there with + // a less-specific reason. Compare the captured story against the + // currently routed story up-front so the typed `'stale'` reflects + // the real reason. Captures with no story keep current behavior + // (resolve against the routed editor, which is body in the common case). + const capturedStory = (capture.target as { story?: StoryLocator } | null | undefined)?.story ?? null; + if (capturedStory) { + const activeStory = readActiveStoryLocator(options.hostEditor ?? null); + if (!storyMatches(activeStory, capturedStory)) { + return { success: false, reason: 'stale' }; + } + } + // Read-only mode (viewing) refuses selection mutation. Same posture // as a doc-api mutation against an editor in `viewing` mode — the // editor is observable but not addressable. @@ -79,3 +102,36 @@ export function restoreSelection(editor: Editor | null, capture: SelectionCaptur if (!ok) return { success: false, reason: 'stale' }; return SUCCESS; } + +function readActiveStoryLocator(hostEditor: SuperDocEditorLike | null): StoryLocator | null { + const presentation = hostEditor?.presentationEditor; + if (!presentation || typeof presentation.getActiveStoryLocator !== 'function') return null; + try { + return presentation.getActiveStoryLocator() ?? null; + } catch { + return null; + } +} + +/** + * Compare a captured story locator against the currently routed + * story. Returns `true` only when both locators target the same + * story surface. The match is keyed on the discriminating fields of + * the StoryLocator union (storyType + per-variant id) so two + * different headers in the same document resolve as different + * stories. Same shape `buildSelectionKey` already uses to memoize + * the slice. + */ +function storyMatches(a: StoryLocator | null, b: StoryLocator | null): boolean { + if (!a || !b) return false; + if (a.storyType !== b.storyType) return false; + const ax = a as unknown as Record; + const bx = b as unknown as Record; + if (ax.refId !== bx.refId) return false; + if (ax.noteId !== bx.noteId) return false; + if (ax.headerFooterKind !== bx.headerFooterKind) return false; + if (ax.variant !== bx.variant) return false; + const aSection = JSON.stringify(ax.section ?? null); + const bSection = JSON.stringify(bx.section ?? null); + return aSection === bSection; +} From 56b88a357806933c8ba160d9b99b6e72749e747d Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 5 May 2026 13:28:18 -0300 Subject: [PATCH 4/5] fix(ui): jsdoc placement + restore guard order (SD-2954) Move readActiveStoryLocator and attachStoryToTextTarget below textTargetToSelectionTarget so the existing JSDoc reattaches to its function (it was orphaned between two JSDoc blocks). Move the SD-2954 story-mismatch check after the isEditable / setTextSelection guards so a header capture restored against a viewing-mode editor still surfaces 'read-only', matching what body captures already see in the same condition. Adds a regression test covering the read-only + story-capture path. --- .../src/ui/create-super-doc-ui.ts | 36 +++++++++---------- .../src/ui/selection-restore.test.ts | 14 ++++++++ .../super-editor/src/ui/selection-restore.ts | 24 +++++++------ 3 files changed, 46 insertions(+), 28 deletions(-) diff --git a/packages/super-editor/src/ui/create-super-doc-ui.ts b/packages/super-editor/src/ui/create-super-doc-ui.ts index 825ab8d8c9..544ecca9f7 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -284,6 +284,24 @@ function resolvePresentationEditor(superdoc: SuperDocUIOptions['superdoc']): { * accept TextTarget directly (separate ticket); until then, * `selectionSlice.selectionTarget` is the consumer-facing shortcut. */ +function textTargetToSelectionTarget( + textTarget: import('@superdoc/document-api').TextTarget | null, +): import('@superdoc/document-api').SelectionTarget | null { + if (!textTarget) return null; + const segments = textTarget.segments; + if (!segments || segments.length === 0) return null; + const first = segments[0]!; + const last = segments[segments.length - 1]!; + const story = (textTarget as { story?: import('@superdoc/document-api').SelectionTarget['story'] }).story; + const start: import('@superdoc/document-api').SelectionPoint = story + ? { kind: 'text', blockId: first.blockId, offset: first.range.start, story } + : { kind: 'text', blockId: first.blockId, offset: first.range.start }; + const end: import('@superdoc/document-api').SelectionPoint = story + ? { kind: 'text', blockId: last.blockId, offset: last.range.end, story } + : { kind: 'text', blockId: last.blockId, offset: last.range.end }; + return story ? { kind: 'selection', start, end, story } : { kind: 'selection', start, end }; +} + /** * Reads the currently routed story from the host's PresentationEditor. * Returns `null` when the body editor is active or when the host @@ -322,24 +340,6 @@ function attachStoryToTextTarget( return { ...textTarget, story }; } -function textTargetToSelectionTarget( - textTarget: import('@superdoc/document-api').TextTarget | null, -): import('@superdoc/document-api').SelectionTarget | null { - if (!textTarget) return null; - const segments = textTarget.segments; - if (!segments || segments.length === 0) return null; - const first = segments[0]!; - const last = segments[segments.length - 1]!; - const story = (textTarget as { story?: import('@superdoc/document-api').SelectionTarget['story'] }).story; - const start: import('@superdoc/document-api').SelectionPoint = story - ? { kind: 'text', blockId: first.blockId, offset: first.range.start, story } - : { kind: 'text', blockId: first.blockId, offset: first.range.start }; - const end: import('@superdoc/document-api').SelectionPoint = story - ? { kind: 'text', blockId: last.blockId, offset: last.range.end, story } - : { kind: 'text', blockId: last.blockId, offset: last.range.end }; - return story ? { kind: 'selection', start, end, story } : { kind: 'selection', start, end }; -} - export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { const { superdoc } = options; diff --git a/packages/super-editor/src/ui/selection-restore.test.ts b/packages/super-editor/src/ui/selection-restore.test.ts index 0aaa3a4863..6211635a7c 100644 --- a/packages/super-editor/src/ui/selection-restore.test.ts +++ b/packages/super-editor/src/ui/selection-restore.test.ts @@ -242,6 +242,20 @@ describe('ui.selection.restore', () => { ui.destroy(); }); + // The story-mismatch short-circuit must not pre-empt the + // `read-only` guard. A consumer storing a header capture and + // restoring against an editor that has been switched to viewing + // mode should still see `read-only`. Losing that typed reason + // would push consumers into branching on `'stale'` to detect + // viewing mode, which is what `read-only` exists to avoid. + it('returns "read-only" (not "stale") when a story capture meets a non-editable editor', () => { + const { superdoc } = makeStubs({ isEditable: false, activeStoryLocator: null }); + const ui = createSuperDocUI({ superdoc }); + + expect(ui.selection.restore(headerCapture)).toEqual({ success: false, reason: 'read-only' }); + ui.destroy(); + }); + it('matches the captured story by value, not by object identity', () => { // The host emits a fresh locator object on every call to // `getActiveStoryLocator()`. The match must hold even though the diff --git a/packages/super-editor/src/ui/selection-restore.ts b/packages/super-editor/src/ui/selection-restore.ts index 185cc929fe..d283b81675 100644 --- a/packages/super-editor/src/ui/selection-restore.ts +++ b/packages/super-editor/src/ui/selection-restore.ts @@ -20,6 +20,14 @@ export function restoreSelection( ): SelectionRestoreResult { if (!editor) return { success: false, reason: 'not-ready' }; + // Read-only mode (viewing) refuses selection mutation. Same posture + // as a doc-api mutation against an editor in `viewing` mode — the + // editor is observable but not addressable. + if (editor.isEditable === false) return { success: false, reason: 'read-only' }; + + const setTextSelection = editor.commands?.setTextSelection; + if (typeof setTextSelection !== 'function') return { success: false, reason: 'not-ready' }; + // SD-2954: when the capture carries a `story` locator, the // captured block ids only make sense in that story's PM doc. If // the user has switched surfaces between capture and restore (e.g. @@ -28,8 +36,12 @@ export function restoreSelection( // we'd otherwise reach `resolveTextTarget` only to fail there with // a less-specific reason. Compare the captured story against the // currently routed story up-front so the typed `'stale'` reflects - // the real reason. Captures with no story keep current behavior - // (resolve against the routed editor, which is body in the common case). + // the real reason. Runs after `isEditable` and the + // `setTextSelection` guard so read-only / unmounted editors + // continue to surface their existing typed reasons regardless of + // whether the capture carries a story. Captures with no story keep + // current behavior (resolve against the routed editor, which is + // body in the common case). const capturedStory = (capture.target as { story?: StoryLocator } | null | undefined)?.story ?? null; if (capturedStory) { const activeStory = readActiveStoryLocator(options.hostEditor ?? null); @@ -38,14 +50,6 @@ export function restoreSelection( } } - // Read-only mode (viewing) refuses selection mutation. Same posture - // as a doc-api mutation against an editor in `viewing` mode — the - // editor is observable but not addressable. - if (editor.isEditable === false) return { success: false, reason: 'read-only' }; - - const setTextSelection = editor.commands?.setTextSelection; - if (typeof setTextSelection !== 'function') return { success: false, reason: 'not-ready' }; - const segments = capture.target?.segments; if (!segments || segments.length === 0) return { success: false, reason: 'missing-target' }; From f2c42e0050efc318d5aca1ab8e8a41b78d460058 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 5 May 2026 13:32:14 -0300 Subject: [PATCH 5/5] fix(ui): resolve story locator via resolveToolbarSources for SD-2954 readActiveStoryLocator was reading hostEditor.presentationEditor directly, missing the legacy _presentationEditor field and the superdocStore.documents[].getPresentationEditor() lookup that resolveToolbarSources covers. Mounts using either fallback would still report body-scoped selection state and return 'stale' for valid story captures. Route the locator lookup through resolveToolbarSources so all three documented presentation-resolution paths surface the active story. Selection-restore drops its duplicate helper and accepts the pre-resolved locator from the controller, removing the separate code path. Adds a regression test covering the _presentationEditor fallback. --- .../src/ui/create-super-doc-ui.test.ts | 81 +++++++++++++++++++ .../src/ui/create-super-doc-ui.ts | 42 +++++++--- .../super-editor/src/ui/selection-restore.ts | 17 +--- 3 files changed, 113 insertions(+), 27 deletions(-) diff --git a/packages/super-editor/src/ui/create-super-doc-ui.test.ts b/packages/super-editor/src/ui/create-super-doc-ui.test.ts index 2c27fae201..3ba104f473 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.test.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.test.ts @@ -638,6 +638,87 @@ describe('createSuperDocUI', () => { }); }); + // SD-2954 regression: `resolveToolbarSources` resolves the + // PresentationEditor through three documented paths, the direct + // `activeEditor.presentationEditor` field, the legacy + // `activeEditor._presentationEditor` field, and the + // `superdocStore.documents[].getPresentationEditor()` lookup. + // `readActiveStoryLocator` reads the locator through the same + // pipeline so all three paths surface the active story. Reading + // `activeEditor.presentationEditor` directly would silently miss + // the latter two and the new selection slice would stay + // body-scoped on those mounts. + it('state.selection.target picks up the active story via the legacy _presentationEditor field', () => { + const headerStory = { kind: 'story', storyType: 'headerFooterPart', refId: 'rId-legacy' }; + + const headerEditor = { + on: vi.fn(), + off: vi.fn(), + state: { selection: { empty: false } }, + isEditable: true, + doc: { + selection: { + current: vi.fn(() => ({ + empty: false, + text: 'header text', + target: { kind: 'text', segments: [{ blockId: 'h1', range: { start: 0, end: 4 } }] }, + activeMarks: [], + activeCommentIds: [], + activeChangeIds: [], + })), + }, + }, + }; + + const presentationEditor: Record = { + on: vi.fn(), + off: vi.fn(), + isEditable: true, + state: { selection: { empty: false } }, + getActiveEditor: vi.fn(() => headerEditor), + getActiveStoryLocator: vi.fn(() => headerStory), + commands: {}, + }; + + // Mount only via the legacy `_presentationEditor` field. The new + // selection state must still pick up the active story. + const bodyEditor = { + on: vi.fn(), + off: vi.fn(), + state: { selection: { empty: true } }, + isEditable: true, + doc: { + selection: { + current: vi.fn(() => ({ + empty: true, + target: null, + activeMarks: [], + activeCommentIds: [], + activeChangeIds: [], + })), + }, + }, + }; + (bodyEditor as unknown as { _presentationEditor: unknown })._presentationEditor = presentationEditor; + + const superdoc = { + activeEditor: bodyEditor as never, + config: { documentMode: 'editing' as const }, + on: vi.fn(), + off: vi.fn(), + }; + + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + + const slice = ui.select((state) => state.selection).get(); + expect(slice.target).toEqual({ + kind: 'text', + segments: [{ blockId: 'h1', range: { start: 0, end: 4 } }], + story: headerStory, + }); + }); + it('state.selection.selectionTarget is null when target is null', () => { const superdoc = makeSuperdocStub(); (superdoc.activeEditor as { doc: { selection: { current: unknown } } }).doc.selection.current = vi.fn(() => ({ diff --git a/packages/super-editor/src/ui/create-super-doc-ui.ts b/packages/super-editor/src/ui/create-super-doc-ui.ts index 544ecca9f7..682f230c17 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -304,8 +304,17 @@ function textTargetToSelectionTarget( /** * Reads the currently routed story from the host's PresentationEditor. - * Returns `null` when the body editor is active or when the host - * doesn't expose the locator (older mounts, server-side stubs). + * Returns `null` when the body editor is active or when no presentation + * layer is reachable (older mounts, server-side stubs). + * + * Routes through `resolveToolbarSources` so all three documented + * presentation-resolution paths surface the locator: the direct + * `activeEditor.presentationEditor` field, the legacy + * `activeEditor._presentationEditor` field, and the + * `superdocStore.documents[].getPresentationEditor()` lookup that + * non-Vue mounts rely on. Reading `hostEditor.presentationEditor` + * directly would silently miss the latter two and the new selection + * slice would stay body-scoped on those setups. * * The selection-info resolver runs against the routed editor and has * no path back to the host, so the controller stamps the locator onto @@ -315,12 +324,18 @@ function textTargetToSelectionTarget( * fail to locate the block. */ function readActiveStoryLocator( - hostEditor: SuperDocEditorLike | null, + superdoc: SuperDocUIOptions['superdoc'], ): import('@superdoc/document-api').StoryLocator | null { - const presentation = hostEditor?.presentationEditor; + let presentation: { getActiveStoryLocator?: () => unknown } | null = null; + try { + const sources = resolveToolbarSources(superdoc as never); + presentation = (sources.presentationEditor as never) ?? null; + } catch { + return null; + } if (!presentation || typeof presentation.getActiveStoryLocator !== 'function') return null; try { - return presentation.getActiveStoryLocator() ?? null; + return (presentation.getActiveStoryLocator() ?? null) as import('@superdoc/document-api').StoryLocator | null; } catch { return null; } @@ -652,7 +667,7 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { // a deeper adapter change would be a separate ticket. const hostEditor = resolveHostEditor(superdoc); const routedIsStory = editor != null && hostEditor != null && editor !== hostEditor; - const activeStory = routedIsStory ? readActiveStoryLocator(hostEditor) : null; + const activeStory = routedIsStory ? readActiveStoryLocator(superdoc) : null; const selectionTextTarget = attachStoryToTextTarget( (selectionInfo?.target ?? null) as import('@superdoc/document-api').TextTarget | null, activeStory, @@ -1813,15 +1828,16 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { // `'stale'` rather than placing the selection on the wrong // surface. // - // Host editor (SD-2954): the restore helper reads - // `presentationEditor.getActiveStoryLocator()` from the host - // to short-circuit on cross-surface captures with a typed - // `'stale'`, so a capture in a header doesn't silently fall - // through to the body resolution. + // Story locator (SD-2954): pre-resolved here so the helper + // doesn't have to repeat the presentation-editor lookup. + // `readActiveStoryLocator` routes through `resolveToolbarSources` + // and covers the direct, legacy `_presentationEditor`, and + // `superdocStore.documents[].getPresentationEditor()` paths + // uniformly. const editor = resolveRoutedEditor(superdoc); - const hostEditor = resolveHostEditor(superdoc); + const activeStory = readActiveStoryLocator(superdoc); return restoreSelection(editor as unknown as Parameters[0], capture, { - hostEditor: hostEditor as unknown as Parameters[2]['hostEditor'], + activeStory, }); }, }; diff --git a/packages/super-editor/src/ui/selection-restore.ts b/packages/super-editor/src/ui/selection-restore.ts index d283b81675..ba14599470 100644 --- a/packages/super-editor/src/ui/selection-restore.ts +++ b/packages/super-editor/src/ui/selection-restore.ts @@ -9,14 +9,14 @@ import type { StoryLocator } from '@superdoc/document-api'; import type { Editor } from '../editors/v1/core/Editor.js'; import { resolveTextTarget } from '../editors/v1/document-api-adapters/helpers/adapter-utils.js'; -import type { SelectionCapture, SelectionRestoreResult, SuperDocEditorLike } from './types.js'; +import type { SelectionCapture, SelectionRestoreResult } from './types.js'; const SUCCESS: SelectionRestoreResult = { success: true }; export function restoreSelection( editor: Editor | null, capture: SelectionCapture, - options: { hostEditor?: SuperDocEditorLike | null } = {}, + options: { activeStory?: StoryLocator | null } = {}, ): SelectionRestoreResult { if (!editor) return { success: false, reason: 'not-ready' }; @@ -44,8 +44,7 @@ export function restoreSelection( // body in the common case). const capturedStory = (capture.target as { story?: StoryLocator } | null | undefined)?.story ?? null; if (capturedStory) { - const activeStory = readActiveStoryLocator(options.hostEditor ?? null); - if (!storyMatches(activeStory, capturedStory)) { + if (!storyMatches(options.activeStory ?? null, capturedStory)) { return { success: false, reason: 'stale' }; } } @@ -107,16 +106,6 @@ export function restoreSelection( return SUCCESS; } -function readActiveStoryLocator(hostEditor: SuperDocEditorLike | null): StoryLocator | null { - const presentation = hostEditor?.presentationEditor; - if (!presentation || typeof presentation.getActiveStoryLocator !== 'function') return null; - try { - return presentation.getActiveStoryLocator() ?? null; - } catch { - return null; - } -} - /** * Compare a captured story locator against the currently routed * story. Returns `true` only when both locators target the same