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 60dfe2a0b3..f36be2a8b9 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 @@ -2159,6 +2159,22 @@ export class PresentationEditor extends EventEmitter { return this.#computeRangeRects(from, to, relativeTo); } + /** + * Like {@link getRangeRects} but pins the body surface, ignoring any + * active header/footer/note session. Used by `ui.viewport.getRect`'s + * text-target path (SD-3329): a body-anchored target must return body + * geometry even while the user is editing a header/footer, where + * `getRangeRects` would otherwise route to the active non-body surface. + * + * @param from - Start position in the body ProseMirror document + * @param to - End position in the body ProseMirror document + * @param relativeTo - Optional element for coordinate reference (see {@link getRangeRects}) + * @returns Array of body-surface rects (pageIndex + position data) + */ + getBodyRangeRects(from: number, to: number, relativeTo?: HTMLElement): RangeRect[] { + return this.#computeRangeRects(from, to, relativeTo, { forceBodySurface: true }); + } + /** * Get selection bounds for a document range with aggregated bounding box. * Returns null if layout is unavailable or the range is invalid. 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 3d61f0b670..0533b5a796 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -28,6 +28,7 @@ import { scrollRangeIntoView } from './scroll-into-view.js'; import { getSelectionAnchorRect, getSelectionRects } from './selection-rects.js'; import { restoreSelection } from './selection-restore.js'; import { createCustomCommandsRegistry } from './custom-commands.js'; +import { resolveTextTarget } from '../editors/v1/document-api-adapters/helpers/adapter-utils.js'; import { createScope } from './scope.js'; import type { CommandHandle, @@ -1950,6 +1951,71 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { pageIndex: rect.pageIndex, }); + // Text-target branch for `ui.viewport.getRect` (SD-3329). Resolves a + // Document API `TextAddress` (one block) or `TextTarget` (segments) to + // PM positions via `resolveTextTarget`, then to painted body rects via + // `getBodyRangeRects`. Body story only: a non-body `story` returns + // `unresolved` (valid target, story-aware text rects are a follow-up) + // rather than `invalid-target` (a caller-shape error). Multi-segment + // targets produce per-segment rects concatenated in document order, not + // a collapsed first→last range, so discontinuous / imported spans paint + // correctly. + const resolveTextTargetRects = ( + hostEditor: SuperDocEditorLike, + presentation: NonNullable, + target: { story?: unknown; blockId?: unknown; range?: unknown; segments?: unknown }, + ): ViewportRectResult => { + const story = target.story as { storyType?: unknown } | undefined; + if (story && story.storyType !== undefined && story.storyType !== 'body') { + return { success: false, reason: 'unresolved' }; + } + if (typeof presentation.getBodyRangeRects !== 'function') { + return { success: false, reason: 'not-ready' }; + } + const segments = Array.isArray(target.segments) + ? (target.segments as Array<{ blockId?: unknown; range?: unknown }>) + : [{ blockId: target.blockId, range: target.range }]; + if (segments.length === 0) return { success: false, reason: 'invalid-target' }; + + const rects: ViewportRect[] = []; + for (const seg of segments) { + const blockId = seg?.blockId; + const range = seg?.range as { start?: unknown; end?: unknown } | undefined; + if ( + typeof blockId !== 'string' || + !blockId || + !range || + typeof range.start !== 'number' || + typeof range.end !== 'number' + ) { + return { success: false, reason: 'invalid-target' }; + } + let resolved: { from: number; to: number } | null; + try { + resolved = resolveTextTarget(hostEditor as unknown as Parameters[0], { + kind: 'text', + blockId, + range: { start: range.start, end: range.end }, + }); + } catch { + // `resolveTextTarget` throws on ambiguous block ids (and other + // adapter/target errors). A public geometry read must never throw + // out — surface it as a structured failure. The target shape is + // valid; it just can't be resolved to a unique range. + return { success: false, reason: 'unresolved' }; + } + if (!resolved) return { success: false, reason: 'unresolved' }; + for (const r of presentation.getBodyRangeRects(resolved.from, resolved.to)) { + rects.push(toViewportRect(r)); + } + } + // All segments resolved to model positions but nothing is painted — + // the page/story is virtualized or offscreen (same posture as the + // entity path's `not-mounted`). + if (rects.length === 0) return { success: false, reason: 'not-mounted' }; + return { success: true, rect: rects[0], rects, pageIndex: rects[0].pageIndex }; + }; + const viewport: ViewportHandle = { getRect(input: ViewportGetRectInput): ViewportRectResult { const target = input?.target; @@ -1971,12 +2037,19 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { return { success: false, reason: 'not-ready' }; } - // Entity-anchored path. Text-anchored paths are deferred — the - // resolver needs story-aware routing through the active routed - // editor (header/footer/note vs body) to avoid silently reading - // body coords for a non-body target. Until that lands, surface - // an explicit `invalid-target` so consumers don't quietly get - // wrong rects. + // Text-anchored path (SD-3329): a `TextAddress` / `TextTarget` + // resolves to painted body rects. Body story only for now; non-body + // text targets return `unresolved` (story-aware text rects are a + // follow-up). Entity targets stay story-aware below. + if ('kind' in target && (target as { kind?: unknown }).kind === 'text') { + return resolveTextTargetRects( + editor, + presentation, + target as { story?: unknown; blockId?: unknown; range?: unknown; segments?: unknown }, + ); + } + + // Entity-anchored path. Any other `kind` is a caller-shape error. if (!('kind' in target) || (target as { kind?: unknown }).kind !== 'entity') { return { success: false, reason: 'invalid-target' }; } diff --git a/packages/super-editor/src/ui/types.ts b/packages/super-editor/src/ui/types.ts index 3517136ce2..80f686db2d 100644 --- a/packages/super-editor/src/ui/types.ts +++ b/packages/super-editor/src/ui/types.ts @@ -211,6 +211,25 @@ export interface SuperDocEditorLike { width: number; height: number; }>; + /** + * Body-surface variant of `getRangeRects`, consumed by + * `ui.viewport.getRect`'s text-target path so a body-anchored target + * returns body geometry even while a header/footer/note session is + * active. Optional in the structural typing for stub validity. + */ + getBodyRangeRects?( + from: number, + to: number, + relativeTo?: HTMLElement, + ): Array<{ + pageIndex: number; + left: number; + right: number; + top: number; + bottom: number; + width: number; + height: number; + }>; /** * Painted-DOM host element. `ui.viewport.entityAt` reads it to * confirm the hit returned by `document.elementFromPoint` lives @@ -1797,17 +1816,24 @@ export type ViewportEntityAddress = import('@superdoc/document-api').EntityAddre export interface ViewportGetRectInput { /** - * Entity to look up — comment, tracked change, or content control - * (SDT) by id. Today `getRect` resolves rects via the painter's - * data attributes (`data-comment-ids`, `data-track-change-id`, - * `data-sdt-id`) which only stamp entity addresses, not - * text-anchored ranges. Text targets (`TextAddress` / `TextTarget`) - * are intentionally not in the union: surface should match real - * behavior so a typed call site isn't lying about what works at - * runtime. They land via a follow-up that adds story-aware text - * resolution to the rect helper. - */ - target: ViewportEntityAddress; + * What to measure. Either: + * - an entity address (comment / tracked change / content control by + * id), resolved via the painter's data attributes; or + * - a Document API text address/target (`TextAddress` single block, or + * `TextTarget` multi-segment), resolved to document positions and + * then to painted rects. A `TextTarget` yields one set of rects per + * segment, concatenated in document order. + * + * Text targets are **body-story only** today: a target carrying a + * non-body `story` (header/footer/footnote/endnote) returns + * `{ success: false, reason: 'unresolved' }`. Story-aware text rects + * are a follow-up. Entity targets remain story-aware via their + * `story` field. + */ + target: + | ViewportEntityAddress + | import('@superdoc/document-api').TextAddress + | import('@superdoc/document-api').TextTarget; } export type ViewportRectResult = diff --git a/packages/super-editor/src/ui/viewport.test.ts b/packages/super-editor/src/ui/viewport.test.ts index 5ec9ee543b..b138728815 100644 --- a/packages/super-editor/src/ui/viewport.test.ts +++ b/packages/super-editor/src/ui/viewport.test.ts @@ -1,7 +1,17 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createSuperDocUI } from './create-super-doc-ui.js'; import type { SuperDocLike } from './types.js'; +import { resolveTextTarget } from '../editors/v1/document-api-adapters/helpers/adapter-utils.js'; + +// getRect's text-target path resolves block ids against the real editor's +// block index, which the lightweight stubs here don't model. Stub just +// that resolver; keep the module's other exports intact. +vi.mock('../editors/v1/document-api-adapters/helpers/adapter-utils.js', async (importActual) => { + const actual = await importActual(); + return { ...actual, resolveTextTarget: vi.fn() }; +}); +const mockResolveTextTarget = vi.mocked(resolveTextTarget); /** * Stub for `ui.viewport` tests. Models the minimal surface the @@ -30,6 +40,16 @@ function makeStubs( if (typeof target.entityId !== 'string') return []; return rectsById[target.entityId] ?? []; }); + type StubRect = { + pageIndex: number; + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; + }; + const getBodyRangeRects = vi.fn((_from: number, _to: number): StubRect[] => []); const navigateTo = vi.fn(async (_target: unknown) => true); const editor: { @@ -39,6 +59,7 @@ function makeStubs( presentationEditor: | { getEntityRects: typeof getEntityRects; + getBodyRangeRects: typeof getBodyRangeRects; navigateTo: typeof navigateTo; getActiveEditor: () => unknown; } @@ -71,6 +92,7 @@ function makeStubs( // same stub editor the toolbar source resolver expects when present. editor.presentationEditor = { getEntityRects, + getBodyRangeRects, navigateTo, getActiveEditor: () => editor, }; @@ -82,7 +104,7 @@ function makeStubs( off: vi.fn(), }; - return { superdoc, editor, mocks: { getEntityRects, navigateTo } }; + return { superdoc, editor, mocks: { getEntityRects, getBodyRangeRects, navigateTo } }; } describe('ui.viewport.getRect — entity targets', () => { @@ -191,12 +213,12 @@ describe('ui.viewport.getRect — entity targets', () => { ui.destroy(); }); - it('returns invalid-target for text-anchored targets (deferred path)', () => { + it('returns invalid-target for an unknown target kind (neither entity nor text)', () => { const { superdoc } = makeStubs(); const ui = createSuperDocUI({ superdoc }); const result = ui.viewport.getRect({ - target: { kind: 'text', blockId: 'b1', range: { start: 0, end: 5 } } as never, + target: { kind: 'mystery', id: 'x' } as never, }); expect(result).toEqual({ success: false, reason: 'invalid-target' }); @@ -277,6 +299,179 @@ describe('ui.viewport.getRect — entity targets', () => { }); }); +describe('ui.viewport.getRect — text targets (SD-3329)', () => { + const rect = (pageIndex: number, top: number) => ({ + pageIndex, + left: 10, + top, + right: 110, + bottom: top + 20, + width: 100, + height: 20, + }); + + beforeEach(() => { + mockResolveTextTarget.mockReset(); + }); + + it('resolves a TextAddress to rects and returns { rect, rects, pageIndex }', () => { + const { superdoc, mocks } = makeStubs(); + mockResolveTextTarget.mockReturnValue({ from: 5, to: 10 }); + mocks.getBodyRangeRects.mockReturnValue([rect(0, 200)]); + const ui = createSuperDocUI({ superdoc }); + + const result = ui.viewport.getRect({ + target: { kind: 'text', blockId: 'b1', range: { start: 0, end: 5 } }, + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.rect).toEqual({ top: 200, left: 10, width: 100, height: 20, pageIndex: 0 }); + expect(result.rects).toHaveLength(1); + expect(result.pageIndex).toBe(0); + // Resolved against the text address; body-surface rects requested for the range. + expect(mockResolveTextTarget).toHaveBeenCalledWith(expect.anything(), { + kind: 'text', + blockId: 'b1', + range: { start: 0, end: 5 }, + }); + expect(mocks.getBodyRangeRects).toHaveBeenCalledWith(5, 10); + + ui.destroy(); + }); + + it('resolves a multi-segment TextTarget per segment and concatenates rects', () => { + const { superdoc, mocks } = makeStubs(); + mockResolveTextTarget.mockReturnValueOnce({ from: 5, to: 10 }).mockReturnValueOnce({ from: 40, to: 55 }); + mocks.getBodyRangeRects.mockReturnValueOnce([rect(0, 200)]).mockReturnValueOnce([rect(1, 300)]); + const ui = createSuperDocUI({ superdoc }); + + const result = ui.viewport.getRect({ + target: { + kind: 'text', + segments: [ + { blockId: 'b1', range: { start: 0, end: 5 } }, + { blockId: 'b2', range: { start: 0, end: 8 } }, + ], + }, + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.rects).toHaveLength(2); + expect(result.rect.pageIndex).toBe(0); // primary = first segment's first rect + expect(result.rects[1].pageIndex).toBe(1); + expect(mocks.getBodyRangeRects).toHaveBeenNthCalledWith(1, 5, 10); + expect(mocks.getBodyRangeRects).toHaveBeenNthCalledWith(2, 40, 55); + + ui.destroy(); + }); + + it('returns unresolved when a segment block id cannot be resolved', () => { + const { superdoc, mocks } = makeStubs(); + mockResolveTextTarget.mockReturnValue(null); + const ui = createSuperDocUI({ superdoc }); + + const result = ui.viewport.getRect({ + target: { kind: 'text', blockId: 'gone', range: { start: 0, end: 5 } }, + }); + + expect(result).toEqual({ success: false, reason: 'unresolved' }); + expect(mocks.getBodyRangeRects).not.toHaveBeenCalled(); + + ui.destroy(); + }); + + it('returns a structured failure (does not throw) when resolveTextTarget throws (ambiguous block id)', () => { + const { superdoc, mocks } = makeStubs(); + // resolveTextTarget throws DocumentApiAdapterError('INVALID_TARGET') on + // ambiguous block ids; a public geometry read must not throw out. + mockResolveTextTarget.mockImplementation(() => { + throw new Error('Block ID "b1" is ambiguous: matched 2 text blocks.'); + }); + const ui = createSuperDocUI({ superdoc }); + + let result: ReturnType | undefined; + expect(() => { + result = ui.viewport.getRect({ + target: { kind: 'text', blockId: 'b1', range: { start: 0, end: 5 } }, + }); + }).not.toThrow(); + expect(result).toEqual({ success: false, reason: 'unresolved' }); + expect(mocks.getBodyRangeRects).not.toHaveBeenCalled(); + + ui.destroy(); + }); + + it('returns not-mounted when the range resolves but paints no rects (virtualized)', () => { + const { superdoc, mocks } = makeStubs(); + mockResolveTextTarget.mockReturnValue({ from: 5, to: 10 }); + mocks.getBodyRangeRects.mockReturnValue([]); + const ui = createSuperDocUI({ superdoc }); + + const result = ui.viewport.getRect({ + target: { kind: 'text', blockId: 'b1', range: { start: 0, end: 5 } }, + }); + + expect(result).toEqual({ success: false, reason: 'not-mounted' }); + + ui.destroy(); + }); + + it('returns invalid-target for a malformed text target (missing block id / range)', () => { + const { superdoc, mocks } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + const result = ui.viewport.getRect({ target: { kind: 'text' } as never }); + + expect(result).toEqual({ success: false, reason: 'invalid-target' }); + expect(mockResolveTextTarget).not.toHaveBeenCalled(); + expect(mocks.getBodyRangeRects).not.toHaveBeenCalled(); + + ui.destroy(); + }); + + it('returns unresolved for a non-body story target (story-aware text rects deferred)', () => { + const { superdoc, mocks } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + const result = ui.viewport.getRect({ + target: { + kind: 'text', + blockId: 'h1', + range: { start: 0, end: 5 }, + story: { kind: 'story', storyType: 'headerFooterPart', refId: 'rId1' }, + } as never, + }); + + expect(result).toEqual({ success: false, reason: 'unresolved' }); + // Short-circuits before touching the resolver / rect engine. + expect(mockResolveTextTarget).not.toHaveBeenCalled(); + expect(mocks.getBodyRangeRects).not.toHaveBeenCalled(); + + ui.destroy(); + }); + + it('leaves entity-target behavior unchanged', () => { + const { superdoc, mocks } = makeStubs({ + rectsById: { c1: [rect(0, 200)] }, + }); + const ui = createSuperDocUI({ superdoc }); + + const result = ui.viewport.getRect({ + target: { kind: 'entity', entityType: 'comment', entityId: 'c1' }, + }); + + expect(result.success).toBe(true); + expect(mocks.getEntityRects).toHaveBeenCalledTimes(1); + // The text path must not run for entity targets. + expect(mockResolveTextTarget).not.toHaveBeenCalled(); + expect(mocks.getBodyRangeRects).not.toHaveBeenCalled(); + + ui.destroy(); + }); +}); + describe('ui.viewport.scrollIntoView', () => { it('navigates entity targets through the presentation editor', async () => { const { superdoc, mocks } = makeStubs(); @@ -628,8 +823,18 @@ function makeEmitter() { function makeGeometryStub() { const sd = makeEmitter(); const pres = makeEmitter(); - const emptyList = () => ({ evaluatedRevision: 'r1', total: 0, items: [], page: { limit: 0, offset: 0, returned: 0 } }); - const editor: { on: ReturnType; off: ReturnType; doc: unknown; presentationEditor: unknown } = { + const emptyList = () => ({ + evaluatedRevision: 'r1', + total: 0, + items: [], + page: { limit: 0, offset: 0, returned: 0 }, + }); + const editor: { + on: ReturnType; + off: ReturnType; + doc: unknown; + presentationEditor: unknown; + } = { on: vi.fn(), off: vi.fn(), doc: {