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/create-super-doc-ui.test.ts b/packages/super-editor/src/ui/create-super-doc-ui.test.ts index ed0f759206..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 @@ -552,6 +552,173 @@ 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, + }); + }); + + // 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 8a3a5c2877..682f230c17 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, @@ -299,6 +302,59 @@ function textTargetToSelectionTarget( 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 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 + * 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( + superdoc: SuperDocUIOptions['superdoc'], +): import('@superdoc/document-api').StoryLocator | null { + 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) as import('@superdoc/document-api').StoryLocator | 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 }; +} + export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { const { superdoc } = options; @@ -602,7 +658,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(superdoc) : 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, @@ -1658,6 +1727,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 ------------------------------------------------------ @@ -1741,8 +1827,18 @@ 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. + // + // 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); - return restoreSelection(editor as unknown as Parameters[0], capture); + const activeStory = readActiveStoryLocator(superdoc); + return restoreSelection(editor as unknown as Parameters[0], capture, { + activeStory, + }); }, }; 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..e5924a722d --- /dev/null +++ b/packages/super-editor/src/ui/position-at.ts @@ -0,0 +1,131 @@ +/** + * `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, 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'; + +interface HostEditor { + presentationEditor?: { + posAtCoords?(coords: { clientX: number; clientY: number }): { pos: number; inside: number } | null; + visibleHost?: HTMLElement; + getActiveStoryLocator?(): StoryLocator | null; + } | 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); + // 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; + 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 { + // 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/selection-restore.test.ts b/packages/super-editor/src/ui/selection-restore.test.ts index d6b5b72971..6211635a7c 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,68 @@ 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(); + }); + + // 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 + // 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..ba14599470 100644 --- a/packages/super-editor/src/ui/selection-restore.ts +++ b/packages/super-editor/src/ui/selection-restore.ts @@ -6,13 +6,18 @@ * 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'; const SUCCESS: SelectionRestoreResult = { success: true }; -export function restoreSelection(editor: Editor | null, capture: SelectionCapture): SelectionRestoreResult { +export function restoreSelection( + editor: Editor | null, + capture: SelectionCapture, + options: { activeStory?: StoryLocator | null } = {}, +): SelectionRestoreResult { if (!editor) return { success: false, reason: 'not-ready' }; // Read-only mode (viewing) refuses selection mutation. Same posture @@ -23,6 +28,27 @@ export function restoreSelection(editor: Editor | null, capture: SelectionCaptur 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. + // 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. 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) { + if (!storyMatches(options.activeStory ?? null, capturedStory)) { + return { success: false, reason: 'stale' }; + } + } + const segments = capture.target?.segments; if (!segments || segments.length === 0) return { success: false, reason: 'missing-target' }; @@ -79,3 +105,26 @@ export function restoreSelection(editor: Editor | null, capture: SelectionCaptur if (!ok) return { success: false, reason: 'stale' }; return SUCCESS; } + +/** + * 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; +} diff --git a/packages/super-editor/src/ui/types.ts b/packages/super-editor/src/ui/types.ts index aea60fe988..5e04f9de85 100644 --- a/packages/super-editor/src/ui/types.ts +++ b/packages/super-editor/src/ui/types.ts @@ -177,6 +177,21 @@ 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; + /** + * 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; } @@ -1574,6 +1589,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..63ba51e601 100644 --- a/packages/super-editor/src/ui/viewport.test.ts +++ b/packages/super-editor/src/ui/viewport.test.ts @@ -362,3 +362,161 @@ 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(); + }); +}); + +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(); + }); +}); 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';