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 0df3ebd796..b8a8896331 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 @@ -47,6 +47,11 @@ import { createLayoutMetrics as createLayoutMetricsFromHelper } from './layout/P import { buildFootnotesInput, type NoteRenderOverride } from './layout/FootnotesBuilder.js'; import { safeCleanup } from './utils/SafeCleanup.js'; import { createHiddenHost } from './dom/HiddenHost.js'; +import { + elementsToRangeRects, + findRenderedCommentElements, + findRenderedTrackedChangeElementsStrict, +} from './dom/EntityRectFinder.js'; import { RemoteCursorManager, type RenderDependencies } from './remote-cursors/RemoteCursorManager.js'; import { EditorInputManager } from './pointer-events/EditorInputManager.js'; import { SelectionSyncCoordinator } from './selection/SelectionSyncCoordinator.js'; @@ -2120,6 +2125,56 @@ export class PresentationEditor extends EventEmitter { }; } + /** + * Viewport-coords rect lookup for an entity (comment / tracked + * change) painted in the editor surface. Drives the + * `superdoc/ui` `ui.viewport.getRect` substrate so consumers can + * pin sticky cards / floating toolbars next to inline highlights + * without reaching into DOM, PM positions, or painter selectors. + * + * Returns plain value rects (not live `DOMRect`) in viewport + * coordinates. An empty array means the entity isn't currently + * painted — virtualized page, story not active, or id not present + * in the document. Callers can choose to scroll first then retry, + * or render the card detached. + * + * @param target - The entity to locate. `entityType` is one of + * `'comment'` or `'trackedChange'`. `story` is + * optional; when provided, results are filtered to + * that story so an id that exists in body and a + * footer doesn't return rects from both. + */ + getEntityRects(target: { entityType?: unknown; entityId?: unknown; story?: unknown }): RangeRect[] { + if (!target || typeof target !== 'object') return []; + const entityType = target.entityType; + const entityId = target.entityId; + if (typeof entityType !== 'string' || typeof entityId !== 'string' || entityId.length === 0) { + return []; + } + const host = this.#visibleHost; + if (!host) return []; + const storyKey = resolveStoryKeyFromAddress(target.story); + let elements: HTMLElement[]; + if (entityType === 'trackedChange') { + // Use a strict story filter for the viewport read path. The + // navigation helper `#findRenderedTrackedChangeElements` falls + // back to all same-id matches when no exact story match wins a + // heuristic — that's correct for "scroll to this change", but + // wrong here: a sticky card asked to anchor a header/footer + // change must not silently anchor to a body copy of the same + // id. Empty result when the requested story has no painted copy + // is the correct signal — the UI controller maps it to + // `not-mounted` so the consumer can pre-mount via + // `viewport.scrollIntoView` and retry. + elements = findRenderedTrackedChangeElementsStrict(host, entityId, escapeAttrValue, storyKey); + } else if (entityType === 'comment') { + elements = findRenderedCommentElements(host, entityId, storyKey); + } else { + return []; + } + return elementsToRangeRects(elements); + } + #getThreadSelectionBounds( data: { storyKey?: unknown; start?: unknown; end?: unknown; pos?: unknown }, relativeTo: HTMLElement | undefined, diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EntityRectFinder.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EntityRectFinder.test.ts new file mode 100644 index 0000000000..833e1e9a87 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EntityRectFinder.test.ts @@ -0,0 +1,210 @@ +/** + * @vitest-environment jsdom + */ +import { describe, expect, it } from 'vitest'; +import { + elementsToRangeRects, + findRenderedCommentElements, + findRenderedTrackedChangeElementsStrict, +} from './EntityRectFinder.js'; + +const BODY_STORY_KEY = 'body'; + +function makeHost(): HTMLElement { + const host = document.createElement('div'); + document.body.appendChild(host); + return host; +} + +function paintCommentRun(host: HTMLElement, ids: string, opts: { storyKey?: string; pageIndex?: number } = {}) { + const page = document.createElement('div'); + page.className = 'superdoc-page'; + page.dataset.pageIndex = String(opts.pageIndex ?? 0); + const run = document.createElement('span'); + run.dataset.commentIds = ids; + if (opts.storyKey != null) { + run.dataset.storyKey = opts.storyKey; + } + page.appendChild(run); + host.appendChild(page); + return run; +} + +describe('findRenderedCommentElements', () => { + it('returns runs that include the comment id as an exact comma-separated token', () => { + const host = makeHost(); + const a = paintCommentRun(host, 'c1'); + const b = paintCommentRun(host, 'c2'); + const ab = paintCommentRun(host, 'c1,c2'); + + const matches = findRenderedCommentElements(host, 'c1'); + expect(matches).toHaveLength(2); + expect(matches).toContain(a); + expect(matches).toContain(ab); + expect(matches).not.toContain(b); + }); + + it('does NOT partial-match overlapping ids (c1 must not match c12)', () => { + const host = makeHost(); + const c12 = paintCommentRun(host, 'c12'); + const c123 = paintCommentRun(host, 'c12,c123'); + + const matches = findRenderedCommentElements(host, 'c1'); + expect(matches).toHaveLength(0); + expect(matches).not.toContain(c12); + expect(matches).not.toContain(c123); + + const c12Matches = findRenderedCommentElements(host, 'c12'); + expect(c12Matches).toContain(c12); + expect(c12Matches).toContain(c123); + }); + + it('tolerates whitespace around comma-separated tokens', () => { + const host = makeHost(); + const run = paintCommentRun(host, 'c1, c2 , c3'); + expect(findRenderedCommentElements(host, 'c2')).toContain(run); + expect(findRenderedCommentElements(host, 'c3')).toContain(run); + }); + + it('returns [] when host or commentId is empty', () => { + expect(findRenderedCommentElements(null as unknown as HTMLElement, 'c1')).toEqual([]); + expect(findRenderedCommentElements(makeHost(), '')).toEqual([]); + }); + + it('filters by story key when provided', () => { + const host = makeHost(); + const bodyRun = paintCommentRun(host, 'c1', { storyKey: BODY_STORY_KEY }); + const headerRun = paintCommentRun(host, 'c1', { storyKey: 'story:headerFooterPart:rId1' }); + + const bodyOnly = findRenderedCommentElements(host, 'c1', BODY_STORY_KEY); + expect(bodyOnly).toContain(bodyRun); + expect(bodyOnly).not.toContain(headerRun); + + const headerOnly = findRenderedCommentElements(host, 'c1', 'story:headerFooterPart:rId1'); + expect(headerOnly).toContain(headerRun); + expect(headerOnly).not.toContain(bodyRun); + }); + + it('matches body-targeted lookups against runs whose data-story-key is missing', () => { + const host = makeHost(); + const legacyRun = paintCommentRun(host, 'c1'); // no data-story-key + expect(findRenderedCommentElements(host, 'c1', BODY_STORY_KEY)).toContain(legacyRun); + }); + + it('returns runs across all stories when storyKey is omitted', () => { + const host = makeHost(); + const bodyRun = paintCommentRun(host, 'c1', { storyKey: BODY_STORY_KEY }); + const headerRun = paintCommentRun(host, 'c1', { storyKey: 'story:headerFooterPart:rId1' }); + + const all = findRenderedCommentElements(host, 'c1'); + expect(all).toContain(bodyRun); + expect(all).toContain(headerRun); + }); +}); + +describe('findRenderedTrackedChangeElementsStrict', () => { + function paintTrackedChangeRun(host: HTMLElement, id: string, opts: { storyKey?: string; pageIndex?: number } = {}) { + const page = document.createElement('div'); + page.className = 'superdoc-page'; + page.dataset.pageIndex = String(opts.pageIndex ?? 0); + const run = document.createElement('span'); + run.dataset.trackChangeId = id; + if (opts.storyKey != null) run.dataset.storyKey = opts.storyKey; + page.appendChild(run); + host.appendChild(page); + return run; + } + + const escape = (value: string) => value.replace(/["\\]/g, (c) => `\\${c}`); + + it('returns only exact-story matches when a storyKey is provided (strict, no fallback)', () => { + const host = makeHost(); + const bodyRun = paintTrackedChangeRun(host, 'tc1', { storyKey: 'body' }); + const headerRun = paintTrackedChangeRun(host, 'tc1', { storyKey: 'story:headerFooterPart:rId1' }); + + const headerOnly = findRenderedTrackedChangeElementsStrict(host, 'tc1', escape, 'story:headerFooterPart:rId1'); + expect(headerOnly).toEqual([headerRun]); + expect(headerOnly).not.toContain(bodyRun); + }); + + it('returns [] when the requested story has no painted copy (strict, no cross-story fallback)', () => { + const host = makeHost(); + paintTrackedChangeRun(host, 'tc1', { storyKey: 'body' }); + paintTrackedChangeRun(host, 'tc1', { storyKey: 'story:footerPart:rId2' }); + + // Asking for a header copy must NOT fall back to body or footer rects + // — a sticky card asked to anchor a header tracked change would + // otherwise silently anchor to the wrong story. + const headerOnly = findRenderedTrackedChangeElementsStrict(host, 'tc1', escape, 'story:headerFooterPart:rId1'); + expect(headerOnly).toEqual([]); + }); + + it('returns every painted copy across stories when no storyKey is provided', () => { + const host = makeHost(); + const a = paintTrackedChangeRun(host, 'tc1', { storyKey: 'body' }); + const b = paintTrackedChangeRun(host, 'tc1', { storyKey: 'story:headerFooterPart:rId1' }); + const all = findRenderedTrackedChangeElementsStrict(host, 'tc1', escape); + expect(all).toContain(a); + expect(all).toContain(b); + }); + + it('escapes ids that contain CSS-special characters', () => { + const host = makeHost(); + const run = paintTrackedChangeRun(host, 'tc"with"quotes'); + const cssEscape = (value: string) => + typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(value) : value.replace(/["\\]/g, (c) => `\\${c}`); + const matches = findRenderedTrackedChangeElementsStrict(host, 'tc"with"quotes', cssEscape); + expect(matches).toContain(run); + }); +}); + +describe('elementsToRangeRects', () => { + it('emits plain value rects (not live DOMRect) with pageIndex from enclosing .superdoc-page', () => { + const host = makeHost(); + const run = paintCommentRun(host, 'c1', { pageIndex: 3 }); + // jsdom returns zero-rects but they're finite, so the helper accepts them. + const [rect] = elementsToRangeRects([run]); + expect(rect).toBeDefined(); + expect(rect).toMatchObject({ + pageIndex: 3, + left: expect.any(Number), + top: expect.any(Number), + right: expect.any(Number), + bottom: expect.any(Number), + width: expect.any(Number), + height: expect.any(Number), + }); + // The result must be a plain value object, not a DOMRect. + expect(typeof DOMRect !== 'undefined' ? rect instanceof DOMRect : false).toBe(false); + }); + + it('drops elements whose getBoundingClientRect returns non-finite numbers', () => { + const host = makeHost(); + const run = paintCommentRun(host, 'c1'); + const original = run.getBoundingClientRect.bind(run); + run.getBoundingClientRect = () => + ({ + top: NaN, + left: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + x: 0, + y: 0, + toJSON: () => ({}), + }) as DOMRect; + expect(elementsToRangeRects([run])).toEqual([]); + run.getBoundingClientRect = original; + }); + + it('defaults to pageIndex=0 when no .superdoc-page wrapper is present', () => { + const host = makeHost(); + const run = document.createElement('span'); + run.dataset.commentIds = 'c1'; + host.appendChild(run); // no .superdoc-page wrapper + + const [rect] = elementsToRangeRects([run]); + expect(rect.pageIndex).toBe(0); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EntityRectFinder.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EntityRectFinder.ts new file mode 100644 index 0000000000..c4e60c1da1 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EntityRectFinder.ts @@ -0,0 +1,106 @@ +import type { RangeRect } from '../types.js'; +import { BODY_STORY_KEY } from '../../../document-api-adapters/story-runtime/story-key.js'; + +/** + * Pure DOM helpers shared by `PresentationEditor.getEntityRects` and + * tests. Kept module-local so the rendering lookup stays a private + * implementation detail of the presentation editor — `superdoc/ui` + * never sees the elements, only the resulting rect value objects. + */ + +/** + * Find painted text-run elements that anchor a given comment. + * + * The painter writes `data-comment-ids="c1,c2,c3"` (comma-separated) + * on every text run that carries one or more comment annotations. + * CSS attribute selectors split tokens on whitespace, not commas, so + * a naive `[data-comment-ids~="c1"]` would miss every match and a + * naive `[data-comment-ids*="c1"]` would partial-match `c12` (and + * any other id whose string contains `c1`). Hand-parse the attribute + * and compare each token by exact equality. + * + * `storyKey` filters by the painted run's enclosing story: + * - undefined: match across all stories. + * - BODY_STORY_KEY: match runs whose `data-story-key` is body, or + * whose attribute is missing entirely (legacy / body runs may + * omit the attribute). + * - any other: exact match required. + */ +export function findRenderedCommentElements(host: HTMLElement, commentId: string, storyKey?: string): HTMLElement[] { + if (!host || !commentId) return []; + const candidates = Array.from(host.querySelectorAll('[data-comment-ids]')); + return candidates.filter((el) => { + const raw = el.dataset.commentIds; + if (!raw) return false; + const matchesId = raw.split(',').some((token) => token.trim() === commentId); + if (!matchesId) return false; + if (!storyKey) return true; + const elStoryKey = el.dataset.storyKey; + if (elStoryKey) return elStoryKey === storyKey; + return storyKey === BODY_STORY_KEY; + }); +} + +/** + * Find painted text-run elements that anchor a given tracked change. + * + * Strictly story-filtered. The PresentationEditor's existing + * navigation helper (`#findRenderedTrackedChangeElements`) deliberately + * falls back to *all* same-id matches when an exact story match + * doesn't satisfy a navigation heuristic — that fallback is correct + * for "scroll to this change" because it lets navigation jump to + * whichever copy is mounted, but it's wrong for `ui.viewport.getRect`: + * a sticky card asked to anchor a header/footer change must NOT get a + * body rect just because the body copy happens to be painted. When a + * `storyKey` is provided here we return *only* exact matches; when no + * story is provided we return every painted occurrence. + * + * The CSS escape inside the selector is mandatory because tracked + * change ids may contain attribute-special characters (quotes, + * backslashes); pass an escape function so this helper stays free of + * the platform-specific `CSS.escape` shim that PresentationEditor + * already owns. + */ +export function findRenderedTrackedChangeElementsStrict( + host: HTMLElement, + entityId: string, + escapeAttrValue: (value: string) => string, + storyKey?: string, +): HTMLElement[] { + if (!host || !entityId) return []; + const baseSelector = `[data-track-change-id="${escapeAttrValue(entityId)}"]`; + if (!storyKey) { + return Array.from(host.querySelectorAll(baseSelector)); + } + const storySelector = `${baseSelector}[data-story-key="${escapeAttrValue(storyKey)}"]`; + return Array.from(host.querySelectorAll(storySelector)); +} + +/** + * Convert painted DOM elements to plain viewport-coord `RangeRect` + * value objects. Drops elements whose `getBoundingClientRect` + * returns non-finite numbers (defensive: jsdom can return `NaN` for + * unmounted nodes) and resolves the page index from the enclosing + * `.superdoc-page` wrapper so callers can route per-page geometry. + */ +export function elementsToRangeRects(elements: HTMLElement[]): RangeRect[] { + const result: RangeRect[] = []; + for (const element of elements) { + const rect = element.getBoundingClientRect(); + if (![rect.top, rect.left, rect.right, rect.bottom, rect.width, rect.height].every(Number.isFinite)) { + continue; + } + const pageEl = element.closest('.superdoc-page'); + const pageIndexAttr = Number(pageEl?.dataset?.pageIndex ?? 0); + result.push({ + pageIndex: Number.isFinite(pageIndexAttr) ? pageIndexAttr : 0, + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + width: rect.width, + height: rect.height, + }); + } + return result; +} diff --git a/packages/super-editor/src/index.ts b/packages/super-editor/src/index.ts index 19b282b7f3..4b1d2c69be 100644 --- a/packages/super-editor/src/index.ts +++ b/packages/super-editor/src/index.ts @@ -179,4 +179,8 @@ export type { SuperDocUI, SuperDocUIOptions, SuperDocUIState, + ViewportGetRectInput, + ViewportHandle, + ViewportRect, + ViewportRectResult, } from './ui/types.js'; 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 5bd75a8b1a..eb0a2aa82d 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -7,7 +7,13 @@ import type { PublicToolbarItemId, ToolbarSnapshot, } from '../headless-toolbar/types.js'; -import type { CommentsListResult, Receipt, ScrollIntoViewOutput, TrackChangesListResult } from '@superdoc/document-api'; +import type { + CommentsListResult, + Receipt, + ScrollIntoViewInput, + ScrollIntoViewOutput, + TrackChangesListResult, +} from '@superdoc/document-api'; import { shallowEqual } from './equality.js'; import type { CommandHandle, @@ -25,6 +31,10 @@ import type { Subscribable, ToolbarCommandHandleState, ToolbarHandle, + ViewportGetRectInput, + ViewportHandle, + ViewportRect, + ViewportRectResult, } from './types.js'; /** @@ -876,6 +886,103 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { }, }; + // ---- ui.viewport ------------------------------------------------------- + // + // Imperative geometry surface. No state slice, no subscription — + // sticky-card / floating-toolbar consumers already listen to a + // transaction / paint / scroll event upstream and call `getRect` + // from there. Returns plain value rects, never live `DOMRect`s. + // The DOM lookup itself lives in `PresentationEditor.getEntityRects` + // so DOM elements / painter selectors never escape through the UI. + // + // Text-anchored paths (TextAddress / TextTarget) are deferred to a + // follow-up — the type signature accepts them today so consumer + // call sites are forward-compatible, but those branches return + // `{ success: false, reason: 'invalid-target' }` until the + // story-aware text resolver lands. + + const toViewportRect = (rect: { + pageIndex: number; + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; + }): ViewportRect => ({ + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + pageIndex: rect.pageIndex, + }); + + const viewport: ViewportHandle = { + getRect(input: ViewportGetRectInput): ViewportRectResult { + const target = input?.target; + if (!target || typeof target !== 'object') { + return { success: false, reason: 'invalid-target' }; + } + + const editor = resolveRoutedEditor(superdoc); + const presentation = editor?.presentationEditor; + if (!presentation || typeof presentation.getEntityRects !== 'function') { + 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. + if (!('kind' in target) || (target as { kind?: unknown }).kind !== 'entity') { + return { success: false, reason: 'invalid-target' }; + } + + const entity = target as { kind: 'entity'; entityType?: unknown; entityId?: unknown; story?: unknown }; + if (typeof entity.entityType !== 'string' || typeof entity.entityId !== 'string' || !entity.entityId) { + return { success: false, reason: 'invalid-target' }; + } + // Reject unsupported entity types up front so a typo or unsupported + // address (e.g. `bookmark`, `field`) returns `invalid-target` rather + // than falling through to `getEntityRects` which would emit `[]` + // and surface as `not-mounted` — that would mislead consumers into + // retrying / scroll-and-retry loops for a target shape we don't + // handle. Keep this list aligned with the supported branches in + // `PresentationEditor.getEntityRects`. + if (entity.entityType !== 'comment' && entity.entityType !== 'trackedChange') { + return { success: false, reason: 'invalid-target' }; + } + + const rangeRects = presentation.getEntityRects({ + entityType: entity.entityType, + entityId: entity.entityId, + story: entity.story, + }); + if (!rangeRects || rangeRects.length === 0) { + return { success: false, reason: 'not-mounted' }; + } + + const rects = rangeRects.map(toViewportRect); + return { + success: true, + rect: rects[0], + rects, + pageIndex: rects[0].pageIndex, + }; + }, + + async scrollIntoView(input: ScrollIntoViewInput): Promise { + const editor = resolveRoutedEditor(superdoc); + const api = editor?.doc?.ranges; + if (!api?.scrollIntoView) { + return { success: false }; + } + return (api.scrollIntoView as (input: unknown) => Promise).call(api, input); + }, + }; + const destroy = () => { if (destroyed) return; destroyed = true; @@ -892,5 +999,5 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { teardown.length = 0; }; - return { select, toolbar, commands, comments, review, destroy }; + return { select, toolbar, commands, comments, review, viewport, destroy }; } diff --git a/packages/super-editor/src/ui/index.ts b/packages/super-editor/src/ui/index.ts index 987d366015..c2f956cc7f 100644 --- a/packages/super-editor/src/ui/index.ts +++ b/packages/super-editor/src/ui/index.ts @@ -34,4 +34,8 @@ export type { SuperDocUI, SuperDocUIOptions, SuperDocUIState, + ViewportGetRectInput, + ViewportHandle, + ViewportRect, + ViewportRectResult, } from './types.js'; diff --git a/packages/super-editor/src/ui/types.ts b/packages/super-editor/src/ui/types.ts index b7267d8993..a86856bb45 100644 --- a/packages/super-editor/src/ui/types.ts +++ b/packages/super-editor/src/ui/types.ts @@ -92,6 +92,25 @@ export interface SuperDocEditorLike { decide?(input: unknown, options?: unknown): unknown; }; }; + /** + * PresentationEditor handle. Browser-only. The controller calls + * `presentationEditor.getEntityRects(target)` from `ui.viewport.getRect` + * to look up the painted-DOM rectangles for an entity (comment or + * tracked change) without leaking DOM elements through the public + * `ui.viewport` surface. Optional in the structural typing to keep + * SSR / non-browser stubs valid. + */ + presentationEditor?: { + getEntityRects?(target: { entityType?: unknown; entityId?: unknown; story?: unknown }): Array<{ + pageIndex: number; + left: number; + right: number; + top: number; + bottom: number; + width: number; + height: number; + }>; + } | null; } /** @@ -281,6 +300,15 @@ export interface SuperDocUI { */ review: ReviewHandle; + /** + * Viewport domain — imperative geometry queries for sticky-card / + * floating-toolbar placement against painted entities and ranges. + * No subscription substrate — viewport rects are read on-demand by + * the consumer (e.g. on hover, on scroll, on layout-change events + * the consumer already listens to). Browser-only by definition. + */ + viewport: ViewportHandle; + /** * Tear down all internal subscriptions to the editor / SuperDoc * instance / presentation editor. After destroy, no listeners will @@ -445,3 +473,111 @@ export interface ReviewHandle { */ setRecording(enabled: boolean): void; } + +/** + * Plain value rectangle in viewport coordinates. Always a snapshot, + * never a live `DOMRect`. Coordinates measure from the top-left of + * the user's viewport, not the editor host, so consumers can position + * fixed/absolute elements directly with the returned `top` / `left`. + */ +export interface ViewportRect { + top: number; + left: number; + width: number; + height: number; + /** + * Page index of the painted page that contains this rect. Useful + * for per-page sidebars or footers that render once per page. + */ + pageIndex: number; +} + +export interface ViewportGetRectInput { + /** + * The thing to look up. Mirrors `editor.doc.ranges.scrollIntoView`'s + * target shapes: + * - `EntityAddress` (comment / tracked change by id) + * - `TextAddress` (single-block text range) — deferred + * - `TextTarget` (multi-segment text target) — deferred + * + * The text-anchored paths land in a follow-up to keep this PR small; + * the union is widened in the type so consumers can author future- + * compatible call sites today. + */ + target: + | import('@superdoc/document-api').EntityAddress + | import('@superdoc/document-api').TextAddress + | import('@superdoc/document-api').TextTarget; +} + +export type ViewportRectResult = + | { + success: true; + /** + * Primary anchor rect — the first painted occurrence of the + * target, suitable as the anchor point for a sidebar card or + * floating toolbar. For multi-page / multi-line targets, + * `rects` carries the full set in document order. + */ + rect: ViewportRect; + /** Every painted occurrence of the target, in document order. */ + rects: ViewportRect[]; + /** Page index of the primary anchor (`rect.pageIndex`). */ + pageIndex: number; + } + | { + success: false; + reason: /** + * Editor / presentation editor not initialized yet — no + * active editor, or layout has not bootstrapped. The caller + * can retry after `editorCreate` fires. + */ + | 'not-ready' + /** + * Caller-shape error: `target` is missing, has the wrong + * `kind`, or refers to an `entityType` the controller does + * not handle. Indicates a programming mistake, not a + * transient state. + */ + | 'invalid-target' + /** + * Target's referenced block / entity is not in the model + * (e.g. a stale id from a closed snapshot). Reserved for the + * text-anchored paths once they land; the entity-anchored + * path returns `not-mounted` for unknown ids since the DOM + * lookup can't distinguish "doesn't exist" from "currently + * virtualized". + */ + | 'unresolved' + /** + * Valid target but currently virtualized / offscreen — the + * page or story isn't painted in the DOM. Caller can call + * `viewport.scrollIntoView` first to mount it, then retry. + * Same posture as `editor.doc.ranges.scrollIntoView` for + * non-body stories on virtualized pages (SD-2750). + */ + | 'not-mounted'; + }; + +/** + * Imperative viewport-geometry surface. No subscription primitive — + * rects are read on demand. Consumers who need to reflow on layout + * change typically already listen to a `transaction` / `paint` / + * `scroll` event upstream and call `getRect` from there. + */ +export interface ViewportHandle { + /** + * Look up the painted rectangle(s) of an entity or text range in + * viewport coordinates. Synchronous — no DOM mutation required. + */ + getRect(input: ViewportGetRectInput): ViewportRectResult; + /** + * Scroll the viewport so the target is visible. Thin pass-through + * to `editor.doc.ranges.scrollIntoView` for parity with the rest + * of the controller surface, so consumers don't have to dip into + * `editor.doc` for scroll geometry that pairs with `getRect`. + */ + scrollIntoView( + input: import('@superdoc/document-api').ScrollIntoViewInput, + ): Promise; +} diff --git a/packages/super-editor/src/ui/viewport.test.ts b/packages/super-editor/src/ui/viewport.test.ts new file mode 100644 index 0000000000..2fa2e72c89 --- /dev/null +++ b/packages/super-editor/src/ui/viewport.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createSuperDocUI } from './create-super-doc-ui.js'; +import type { SuperDocLike } from './types.js'; + +/** + * Stub for `ui.viewport` tests. Models the minimal surface the + * controller calls: `editor.presentationEditor.getEntityRects` for + * geometry lookups, and `editor.doc.ranges.scrollIntoView` for the + * thin pass-through. + */ +function makeStubs( + initial: { + rectsById?: Record< + string, + Array<{ + pageIndex: number; + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; + }> + >; + } = {}, +) { + const rectsById = initial.rectsById ?? {}; + + const getEntityRects = vi.fn((target: { entityType?: unknown; entityId?: unknown; story?: unknown }) => { + if (typeof target.entityId !== 'string') return []; + return rectsById[target.entityId] ?? []; + }); + const scrollIntoView = vi.fn(async (_input: unknown) => ({ success: true as const })); + + const editor: { + on: ReturnType; + off: ReturnType; + doc: unknown; + presentationEditor: + | { + getEntityRects: typeof getEntityRects; + getActiveEditor: () => unknown; + } + | undefined; + } = { + on: vi.fn(), + off: vi.fn(), + 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 }, + })), + }, + ranges: { scrollIntoView }, + }, + presentationEditor: undefined, + }; + // Self-reference so `presentationEditor.getActiveEditor()` returns the + // same stub editor the toolbar source resolver expects when present. + editor.presentationEditor = { + getEntityRects, + getActiveEditor: () => editor, + }; + + const superdoc: SuperDocLike = { + activeEditor: editor as never, + config: { documentMode: 'editing' }, + on: vi.fn(), + off: vi.fn(), + }; + + return { superdoc, editor, mocks: { getEntityRects, scrollIntoView } }; +} + +describe('ui.viewport.getRect — entity targets', () => { + it('returns success with primary rect + full rects[] for a painted comment', () => { + const { superdoc, mocks } = makeStubs({ + rectsById: { + c1: [ + { pageIndex: 0, left: 100, top: 200, right: 220, bottom: 220, width: 120, height: 20 }, + { pageIndex: 0, left: 100, top: 224, right: 180, bottom: 244, width: 80, height: 20 }, + ], + }, + }); + const ui = createSuperDocUI({ superdoc }); + + const result = ui.viewport.getRect({ + target: { kind: 'entity', entityType: 'comment', entityId: 'c1' }, + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.rect).toEqual({ top: 200, left: 100, width: 120, height: 20, pageIndex: 0 }); + expect(result.rects).toHaveLength(2); + expect(result.pageIndex).toBe(0); + expect(mocks.getEntityRects).toHaveBeenCalledWith({ + entityType: 'comment', + entityId: 'c1', + story: undefined, + }); + + ui.destroy(); + }); + + it('forwards the story when provided so non-body entities resolve correctly', () => { + const { superdoc, mocks } = makeStubs({ + rectsById: { + 'tc-header': [{ pageIndex: 1, left: 0, top: 0, right: 50, bottom: 12, width: 50, height: 12 }], + }, + }); + const ui = createSuperDocUI({ superdoc }); + + ui.viewport.getRect({ + target: { + kind: 'entity', + entityType: 'trackedChange', + entityId: 'tc-header', + story: { kind: 'story', storyType: 'headerFooterPart', refId: 'rId1' }, + } as never, + }); + + expect(mocks.getEntityRects).toHaveBeenCalledWith({ + entityType: 'trackedChange', + entityId: 'tc-header', + story: { kind: 'story', storyType: 'headerFooterPart', refId: 'rId1' }, + }); + + ui.destroy(); + }); + + it('returns not-mounted when the entity is not painted (empty rects)', () => { + const { superdoc } = makeStubs({ rectsById: {} }); + const ui = createSuperDocUI({ superdoc }); + + const result = ui.viewport.getRect({ + target: { kind: 'entity', entityType: 'comment', entityId: 'c-missing' }, + }); + + expect(result).toEqual({ success: false, reason: 'not-mounted' }); + ui.destroy(); + }); + + it('returns invalid-target for missing or malformed targets', () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + expect(ui.viewport.getRect({ target: null as never })).toEqual({ + success: false, + reason: 'invalid-target', + }); + expect( + ui.viewport.getRect({ + target: { kind: 'entity', entityType: 'comment', entityId: '' } as never, + }), + ).toEqual({ success: false, reason: 'invalid-target' }); + expect( + ui.viewport.getRect({ + target: { kind: 'entity', entityType: 'comment' } as never, + }), + ).toEqual({ success: false, reason: 'invalid-target' }); + + ui.destroy(); + }); + + it('returns invalid-target for unsupported entity types (e.g. typos, future kinds)', () => { + const { superdoc, mocks } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + // A bogus entity type must short-circuit to `invalid-target` rather + // than fall through to `getEntityRects` (which would emit `[]` and + // surface as `not-mounted`, misleading consumers into retry loops). + const result = ui.viewport.getRect({ + target: { kind: 'entity', entityType: 'mystery', entityId: 'x' } as never, + }); + expect(result).toEqual({ success: false, reason: 'invalid-target' }); + // We never even consulted the engine for an unsupported type. + expect(mocks.getEntityRects).not.toHaveBeenCalled(); + ui.destroy(); + }); + + it('returns invalid-target for text-anchored targets (deferred path)', () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + const result = ui.viewport.getRect({ + target: { kind: 'text', blockId: 'b1', range: { start: 0, end: 5 } } as never, + }); + + expect(result).toEqual({ success: false, reason: 'invalid-target' }); + ui.destroy(); + }); + + it('returns not-ready when no presentation editor is mounted', () => { + const { superdoc } = makeStubs(); + // Drop presentationEditor from the stub editor + (superdoc.activeEditor as unknown as { presentationEditor: unknown }).presentationEditor = undefined; + const ui = createSuperDocUI({ superdoc }); + + const result = ui.viewport.getRect({ + target: { kind: 'entity', entityType: 'comment', entityId: 'c1' }, + }); + + expect(result).toEqual({ success: false, reason: 'not-ready' }); + ui.destroy(); + }); + + it('emits plain value rects (no DOMRect) — getRect outputs are JSON-serializable', () => { + const { superdoc } = makeStubs({ + rectsById: { + c1: [{ pageIndex: 2, left: 10, top: 20, right: 30, bottom: 40, width: 20, height: 20 }], + }, + }); + const ui = createSuperDocUI({ superdoc }); + + const result = ui.viewport.getRect({ + target: { kind: 'entity', entityType: 'comment', entityId: 'c1' }, + }); + + if (!result.success) throw new Error('expected success'); + const json = JSON.parse(JSON.stringify(result.rect)); + expect(json).toEqual({ top: 20, left: 10, width: 20, height: 20, pageIndex: 2 }); + // pageIndex on the result mirrors the primary rect's pageIndex. + expect(result.pageIndex).toBe(2); + + ui.destroy(); + }); +}); + +describe('ui.viewport.scrollIntoView', () => { + it('passes the input straight through to editor.doc.ranges.scrollIntoView', async () => { + const { superdoc, mocks } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + const input = { + target: { kind: 'entity' as const, entityType: 'comment' as const, entityId: 'c1' }, + block: 'center' as const, + behavior: 'smooth' as const, + }; + const result = await ui.viewport.scrollIntoView(input); + + expect(result).toEqual({ success: true }); + expect(mocks.scrollIntoView).toHaveBeenCalledWith(input); + ui.destroy(); + }); + + it('returns { success: false } when no editor / ranges API is available', async () => { + const { superdoc } = makeStubs(); + (superdoc.activeEditor as unknown as { doc: { ranges: unknown } }).doc.ranges = undefined as never; + const ui = createSuperDocUI({ superdoc }); + + const result = await ui.viewport.scrollIntoView({ + target: { kind: 'entity', entityType: 'comment', entityId: 'c1' }, + }); + + expect(result).toEqual({ success: false }); + ui.destroy(); + }); +}); diff --git a/packages/superdoc/src/ui.d.ts b/packages/superdoc/src/ui.d.ts index f1aa5e4923..d38e73189f 100644 --- a/packages/superdoc/src/ui.d.ts +++ b/packages/superdoc/src/ui.d.ts @@ -15,4 +15,8 @@ export { type SuperDocUI, type SuperDocUIOptions, type SuperDocUIState, + type ViewportGetRectInput, + type ViewportHandle, + type ViewportRect, + type ViewportRectResult, } from '@superdoc/super-editor';