diff --git a/codecov.yml b/codecov.yml index da07cff790..4cd290c72e 100644 --- a/codecov.yml +++ b/codecov.yml @@ -8,3 +8,10 @@ coverage: default: target: 90% threshold: 0% + +# Type-only declaration files (`.d.ts`) carry no runnable code — they +# describe shapes consumed at compile time. Coverage tools count any +# changed line as "0% covered" because there's nothing to instrument. +# Excluding them keeps the patch coverage signal honest. +ignore: + - '**/*.d.ts' diff --git a/packages/document-api/scripts/check-contract-parity.ts b/packages/document-api/scripts/check-contract-parity.ts index 34efc7c107..e8c51fca4f 100644 --- a/packages/document-api/scripts/check-contract-parity.ts +++ b/packages/document-api/scripts/check-contract-parity.ts @@ -22,26 +22,10 @@ import { OPERATION_REFERENCE_DOC_PATH_MAP } from '../src/contract/reference-doc- import { buildDispatchTable } from '../src/invoke/invoke.js'; /** - * Meta-methods and helper methods on DocumentApi that are not contract - * operations: - * - * - `ranges.scrollIntoView` is a browser-only UI side-effect (scrolls - * the viewport via the presentation editor). It has no headless - * implementation, so it is intentionally excluded from the RPC - * dispatch surface and the CLI command catalog. Direct calls through - * `editor.doc.ranges.scrollIntoView()` are still supported. - * - `selection.onChange` is a subscription primitive (push-based, no - * request/response shape) rather than a request-response operation, - * so it is not represented in `OPERATION_DEFINITIONS` / schemas / - * dispatch. Direct calls through `editor.doc.selection.onChange()` - * are still supported. + * Meta-methods on DocumentApi that are not contract operations: the + * dispatcher itself plus the documented reference aliases. */ -const META_MEMBER_PATHS = [ - 'invoke', - 'ranges.scrollIntoView', - 'selection.onChange', - ...REFERENCE_OPERATION_ALIASES.map((alias) => alias.memberPath), -]; +const META_MEMBER_PATHS = ['invoke', ...REFERENCE_OPERATION_ALIASES.map((alias) => alias.memberPath)]; function collectFunctionMemberPaths(value: unknown, prefix = ''): string[] { if (!value || typeof value !== 'object') return []; diff --git a/packages/document-api/src/index.test.ts b/packages/document-api/src/index.test.ts index ff63295362..2e27a3c8df 100644 --- a/packages/document-api/src/index.test.ts +++ b/packages/document-api/src/index.test.ts @@ -2173,18 +2173,6 @@ describe('createDocumentApi', () => { expect(e.code).toBe('SELECTION_ADAPTER_UNAVAILABLE'); } }); - - it('throws SELECTION_ADAPTER_UNAVAILABLE when selection.onChange is called without a selection adapter', () => { - const api = makeApiWithoutSelection(); - try { - api.selection.onChange(() => {}); - expect.fail('expected SELECTION_ADAPTER_UNAVAILABLE to be thrown'); - } catch (err: unknown) { - const e = err as { name: string; code: string }; - expect(e.name).toBe('DocumentApiValidationError'); - expect(e.code).toBe('SELECTION_ADAPTER_UNAVAILABLE'); - } - }); }); describe('comments.patch target validation', () => { diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index 47e880c4c7..fa7b7fab30 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -28,16 +28,9 @@ export type { RangeResolverAdapter, ScrollIntoViewInput, ScrollIntoViewOutput, - RangeScrollAdapter, } from './ranges/index.js'; -export { executeResolveRange, executeScrollIntoView } from './ranges/index.js'; -export type { - SelectionApi, - SelectionAdapter, - SelectionCurrentInput, - SelectionInfo, - SelectionChangeListener, -} from './selection/selection.js'; +export { executeResolveRange } from './ranges/index.js'; +export type { SelectionApi, SelectionAdapter, SelectionCurrentInput, SelectionInfo } from './selection/selection.js'; export { executeSelectionCurrent } from './selection/selection.js'; export type { HeaderFootersAdapter, HeaderFootersApi } from './header-footers/header-footers.js'; export * from './header-footers/header-footers.types.js'; @@ -136,22 +129,9 @@ import { import type { InsertInput } from './insert/insert.js'; import { executeDelete } from './delete/delete.js'; import { executeResolveRange } from './ranges/resolve.js'; -import { executeScrollIntoView } from './ranges/scroll-into-view.js'; -import type { - RangeResolverAdapter, - ResolveRangeInput, - ResolveRangeOutput, - ScrollIntoViewInput, - ScrollIntoViewOutput, -} from './ranges/ranges.types.js'; +import type { RangeResolverAdapter, ResolveRangeInput, ResolveRangeOutput } from './ranges/ranges.types.js'; import { executeSelectionCurrent } from './selection/selection.js'; -import type { - SelectionApi, - SelectionAdapter, - SelectionCurrentInput, - SelectionInfo, - SelectionChangeListener, -} from './selection/selection.js'; +import type { SelectionApi, SelectionAdapter, SelectionCurrentInput, SelectionInfo } from './selection/selection.js'; import { executeInsert } from './insert/insert.js'; import type { ListsAdapter, ListsApi } from './lists/lists.js'; import type { @@ -1508,19 +1488,10 @@ export interface MutationsApi { export interface RangesApi { resolve(input: ResolveRangeInput): ResolveRangeOutput; - /** - * Scroll the editor viewport so the target range is visible. Handles - * paginated, virtualized layouts by mounting the target page on demand. - * Async — resolves once the scroll settles (or the page-mount timeout - * expires). Target accepts `TextAddress`, `TextTarget`, or `EntityAddress` - * (comment / tracked change by id). - */ - scrollIntoView(input: ScrollIntoViewInput): Promise; } export interface RangesAdapter { resolve(input: ResolveRangeInput): ResolveRangeOutput; - scrollIntoView(input: ScrollIntoViewInput): Promise; } export interface QueryAdapter { @@ -3184,9 +3155,6 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { resolve(input: ResolveRangeInput): ResolveRangeOutput { return executeResolveRange(adapters.ranges, input); }, - scrollIntoView(input: ScrollIntoViewInput): Promise { - return executeScrollIntoView(adapters.ranges, input); - }, }, selection: { current(input?: SelectionCurrentInput): SelectionInfo { @@ -3199,16 +3167,6 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { } return executeSelectionCurrent(adapter, input); }, - onChange(listener: SelectionChangeListener): () => void { - const adapter = adapters.selection; - if (!adapter) { - throw new DocumentApiValidationError( - 'SELECTION_ADAPTER_UNAVAILABLE', - 'No selection adapter was registered. Pass `selection` in DocumentApiAdapters to call selection.onChange().', - ); - } - return adapter.onChange(listener); - }, }, mutations: { preview(input: MutationsPreviewInput): MutationsPreviewOutput { diff --git a/packages/document-api/src/ranges/index.ts b/packages/document-api/src/ranges/index.ts index 2c4aa60b53..578efc0c1d 100644 --- a/packages/document-api/src/ranges/index.ts +++ b/packages/document-api/src/ranges/index.ts @@ -10,7 +10,5 @@ export type { RangeResolverAdapter, ScrollIntoViewInput, ScrollIntoViewOutput, - RangeScrollAdapter, } from './ranges.types.js'; export { executeResolveRange } from './resolve.js'; -export { executeScrollIntoView } from './scroll-into-view.js'; diff --git a/packages/document-api/src/ranges/ranges.types.ts b/packages/document-api/src/ranges/ranges.types.ts index b5d69c7173..7d51bf7d18 100644 --- a/packages/document-api/src/ranges/ranges.types.ts +++ b/packages/document-api/src/ranges/ranges.types.ts @@ -122,13 +122,14 @@ export interface RangeResolverAdapter { } // --------------------------------------------------------------------------- -// scrollIntoView +// scrollIntoView — input/output value types // --------------------------------------------------------------------------- /** - * Input for `ranges.scrollIntoView` — scrolls the editor viewport so the - * given text target is visible. Handles paginated, virtualized layouts by - * mounting the target page if it isn't yet in the DOM. + * Input for `ui.viewport.scrollIntoView` — scrolls the editor + * viewport so the given target is visible. Handles paginated, + * virtualized layouts by mounting the target page if it isn't yet in + * the DOM. */ export interface ScrollIntoViewInput { /** @@ -146,18 +147,10 @@ export interface ScrollIntoViewInput { } /** - * Result of `ranges.scrollIntoView`. - * `success: false` when the target couldn't be resolved or a page failed to - * mount within the navigation timeout. + * Result of `ui.viewport.scrollIntoView`. `success: false` when the + * target couldn't be resolved or a page failed to mount within the + * navigation timeout. */ export interface ScrollIntoViewOutput { success: boolean; } - -/** - * Adapter method for `ranges.scrollIntoView`. Async because virtualized - * pages may need to mount before the scroll completes. - */ -export interface RangeScrollAdapter { - scrollIntoView(input: ScrollIntoViewInput): Promise; -} diff --git a/packages/document-api/src/ranges/scroll-into-view.test.ts b/packages/document-api/src/ranges/scroll-into-view.test.ts deleted file mode 100644 index c9eaa0d3b5..0000000000 --- a/packages/document-api/src/ranges/scroll-into-view.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { describe, expect, it, mock } from 'bun:test'; -import { executeScrollIntoView } from './scroll-into-view.js'; -import type { RangeScrollAdapter, ScrollIntoViewInput, ScrollIntoViewOutput } from './ranges.types.js'; - -function makeAdapter(output: ScrollIntoViewOutput = { success: true }): RangeScrollAdapter & { - scrollIntoView: ReturnType; -} { - const scrollIntoView = mock(async () => output); - return { scrollIntoView } as unknown as RangeScrollAdapter & { scrollIntoView: ReturnType }; -} - -async function expectValidationError(fn: () => Promise, code: string, messageMatch: string): Promise { - try { - await fn(); - throw new Error(`expected ${code}, nothing thrown`); - } catch (err: unknown) { - const e = err as { name?: string; code?: string; message?: string }; - expect(e.name).toBe('DocumentApiValidationError'); - expect(e.code).toBe(code); - expect(e.message ?? '').toContain(messageMatch); - } -} - -describe('executeScrollIntoView — validation', () => { - it('rejects a null / undefined input', async () => { - const adapter = makeAdapter(); - await expectValidationError( - () => executeScrollIntoView(adapter, null as unknown as ScrollIntoViewInput), - 'INVALID_INPUT', - 'non-null object', - ); - await expectValidationError( - () => executeScrollIntoView(adapter, undefined as unknown as ScrollIntoViewInput), - 'INVALID_INPUT', - 'non-null object', - ); - }); - - it('rejects inputs with unknown fields', async () => { - const adapter = makeAdapter(); - await expectValidationError( - () => - executeScrollIntoView(adapter, { - target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, - somethingElse: true, - } as unknown as ScrollIntoViewInput), - 'INVALID_INPUT', - 'Unknown field', - ); - }); - - it('rejects when target is missing', async () => { - const adapter = makeAdapter(); - await expectValidationError( - () => executeScrollIntoView(adapter, {} as unknown as ScrollIntoViewInput), - 'INVALID_TARGET', - 'requires a target', - ); - }); - - it('rejects when target is malformed', async () => { - const adapter = makeAdapter(); - await expectValidationError( - () => executeScrollIntoView(adapter, { target: { kind: 'nope' } } as unknown as ScrollIntoViewInput), - 'INVALID_TARGET', - 'TextAddress, TextTarget, or EntityAddress', - ); - }); - - it('rejects entity targets with unknown entityType', async () => { - const adapter = makeAdapter(); - await expectValidationError( - () => - executeScrollIntoView(adapter, { - target: { kind: 'entity', entityType: 'mystery', entityId: 'x_1' }, - } as unknown as ScrollIntoViewInput), - 'INVALID_TARGET', - 'TextAddress, TextTarget, or EntityAddress', - ); - }); - - it('rejects entity targets with empty entityId', async () => { - const adapter = makeAdapter(); - await expectValidationError( - () => - executeScrollIntoView(adapter, { - target: { kind: 'entity', entityType: 'comment', entityId: '' }, - } as unknown as ScrollIntoViewInput), - 'INVALID_TARGET', - 'TextAddress, TextTarget, or EntityAddress', - ); - }); - - it('rejects block outside the allowed enum', async () => { - const adapter = makeAdapter(); - await expectValidationError( - () => - executeScrollIntoView(adapter, { - target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, - block: 'top' as 'start', - }), - 'INVALID_INPUT', - 'block must be', - ); - }); - - it('rejects behavior outside the allowed enum', async () => { - const adapter = makeAdapter(); - await expectValidationError( - () => - executeScrollIntoView(adapter, { - target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, - behavior: 'instant' as 'auto', - }), - 'INVALID_INPUT', - 'behavior must be', - ); - }); -}); - -describe('executeScrollIntoView — delegation', () => { - it('accepts a TextAddress target and forwards it unchanged', async () => { - const adapter = makeAdapter(); - const input: ScrollIntoViewInput = { - target: { kind: 'text', blockId: 'p1', range: { start: 3, end: 9 } }, - }; - const out = await executeScrollIntoView(adapter, input); - expect(out).toEqual({ success: true }); - expect(adapter.scrollIntoView).toHaveBeenCalledWith(input); - }); - - it('accepts a multi-segment TextTarget target', async () => { - const adapter = makeAdapter(); - const input: ScrollIntoViewInput = { - target: { - kind: 'text', - segments: [ - { blockId: 'p1', range: { start: 0, end: 5 } }, - { blockId: 'p2', range: { start: 0, end: 3 } }, - ], - }, - block: 'start', - behavior: 'auto', - }; - const out = await executeScrollIntoView(adapter, input); - expect(out).toEqual({ success: true }); - expect(adapter.scrollIntoView).toHaveBeenCalledWith(input); - }); - - it('accepts an EntityAddress (comment) target', async () => { - const adapter = makeAdapter(); - const input: ScrollIntoViewInput = { - target: { kind: 'entity', entityType: 'comment', entityId: 'c_1' }, - }; - await executeScrollIntoView(adapter, input); - expect(adapter.scrollIntoView).toHaveBeenCalledWith(input); - }); - - it('accepts an EntityAddress (trackedChange) target', async () => { - const adapter = makeAdapter(); - const input: ScrollIntoViewInput = { - target: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc_1' }, - }; - await executeScrollIntoView(adapter, input); - expect(adapter.scrollIntoView).toHaveBeenCalledWith(input); - }); - - it('returns whatever the adapter returns (e.g. success: false)', async () => { - const adapter = makeAdapter({ success: false }); - const out = await executeScrollIntoView(adapter, { - target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, - }); - expect(out).toEqual({ success: false }); - }); -}); diff --git a/packages/document-api/src/ranges/scroll-into-view.ts b/packages/document-api/src/ranges/scroll-into-view.ts deleted file mode 100644 index 0f58196719..0000000000 --- a/packages/document-api/src/ranges/scroll-into-view.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * `ranges.scrollIntoView` operation — scrolls the editor so the target - * text range is visible. Handles paginated, virtualized layouts by mounting - * the target page on demand. - * - * Primitive for custom sidebars (comments, track changes, mentions) that - * need to navigate the document to a specific range on user interaction. - */ - -import type { RangeScrollAdapter, ScrollIntoViewInput, ScrollIntoViewOutput } from './ranges.types.js'; -import { DocumentApiValidationError } from '../errors.js'; -import { isRecord, isTextAddress, isTextTarget, assertNoUnknownFields } from '../validation-primitives.js'; - -const VALID_ENTITY_TYPES: ReadonlySet = new Set(['comment', 'trackedChange']); - -function isEntityAddress(value: unknown): boolean { - if (!isRecord(value)) return false; - if (value.kind !== 'entity') return false; - if (typeof value.entityType !== 'string' || !VALID_ENTITY_TYPES.has(value.entityType)) return false; - if (typeof value.entityId !== 'string' || value.entityId.length === 0) return false; - return true; -} - -const SCROLL_INTO_VIEW_ALLOWED_KEYS = new Set(['target', 'block', 'behavior']); -const VALID_BLOCK_VALUES: ReadonlySet = new Set(['start', 'center', 'end', 'nearest']); -const VALID_BEHAVIOR_VALUES: ReadonlySet = new Set(['auto', 'smooth']); - -function validateScrollIntoViewInput(input: unknown): asserts input is ScrollIntoViewInput { - if (!isRecord(input)) { - throw new DocumentApiValidationError('INVALID_INPUT', 'ranges.scrollIntoView input must be a non-null object.'); - } - - assertNoUnknownFields(input, SCROLL_INTO_VIEW_ALLOWED_KEYS, 'ranges.scrollIntoView'); - - const { target, block, behavior } = input; - - if (target === undefined || target === null) { - throw new DocumentApiValidationError('INVALID_TARGET', 'ranges.scrollIntoView requires a target.', { - field: 'target', - }); - } - if (!isTextAddress(target) && !isTextTarget(target) && !isEntityAddress(target)) { - throw new DocumentApiValidationError( - 'INVALID_TARGET', - 'target must be a TextAddress, TextTarget, or EntityAddress object.', - { field: 'target', value: target }, - ); - } - - if (block !== undefined && (typeof block !== 'string' || !VALID_BLOCK_VALUES.has(block))) { - throw new DocumentApiValidationError( - 'INVALID_INPUT', - `block must be one of "start" | "center" | "end" | "nearest", got ${JSON.stringify(block)}.`, - { field: 'block', value: block }, - ); - } - - if (behavior !== undefined && (typeof behavior !== 'string' || !VALID_BEHAVIOR_VALUES.has(behavior))) { - throw new DocumentApiValidationError( - 'INVALID_INPUT', - `behavior must be "auto" or "smooth", got ${JSON.stringify(behavior)}.`, - { field: 'behavior', value: behavior }, - ); - } -} - -export async function executeScrollIntoView( - adapter: RangeScrollAdapter, - input: ScrollIntoViewInput, -): Promise { - validateScrollIntoViewInput(input); - return adapter.scrollIntoView(input); -} diff --git a/packages/document-api/src/selection/selection.ts b/packages/document-api/src/selection/selection.ts index 05e48608d9..d1ee218918 100644 --- a/packages/document-api/src/selection/selection.ts +++ b/packages/document-api/src/selection/selection.ts @@ -13,25 +13,12 @@ import { isRecord, assertNoUnknownFields } from '../validation-primitives.js'; export type { SelectionCurrentInput, SelectionInfo } from './selection.types.js'; -/** - * Callback invoked whenever the editor's selection changes, with the - * current {@link SelectionInfo}. Consumers may dedupe on `target` / - * `activeMarks` identity if their UI doesn't need every transaction. - */ -export type SelectionChangeListener = (info: SelectionInfo) => void; - /** * Engine-specific adapter for the selection API. */ export interface SelectionAdapter { /** Read the editor's current selection. */ current(input: SelectionCurrentInput): SelectionInfo; - /** - * Subscribe to selection changes. Returns an unsubscribe function. - * Implementations should debounce to at most once per tick to avoid - * storms during multi-step transactions. - */ - onChange(listener: SelectionChangeListener): () => void; } /** @@ -46,16 +33,6 @@ export interface SelectionApi { * pass the resulting `target` directly to `comments.create`. */ current(input?: SelectionCurrentInput): SelectionInfo; - - /** - * Subscribe to selection changes. The listener fires with a fresh - * {@link SelectionInfo} (as `includeText: false`) whenever the user's - * selection, stored marks, or containing block changes. - * - * Returns an unsubscribe function — call it in cleanup (React - * `useEffect` return, Vue `onUnmounted`, etc.). - */ - onChange(listener: SelectionChangeListener): () => void; } const SELECTION_CURRENT_ALLOWED_KEYS = new Set(['includeText']); diff --git a/packages/super-editor/package.json b/packages/super-editor/package.json index c25eab8cec..3f4d03c509 100644 --- a/packages/super-editor/package.json +++ b/packages/super-editor/package.json @@ -97,6 +97,8 @@ "types:build": "tsc -p tsconfig.build.json", "test": "vitest", "test:debug": "vitest --inspect-brk --pool threads --poolOptions.threads.singleThread", + "test:ui": "vitest run src/ui", + "test:ui:watch": "vitest src/ui", "preview": "vite preview", "clean": "rm -rf dist types", "pack": "rm *.tgz 2>/dev/null || true && pnpm run build && pnpm pack && mv harbour-enterprises-super-editor-0.0.1-alpha.0.tgz ./super-editor.tgz" 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/editors/v1/document-api-adapters/assemble-adapters.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/assemble-adapters.test.ts index bbde44f7f0..9aff5d2d76 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/assemble-adapters.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/assemble-adapters.test.ts @@ -99,9 +99,7 @@ describe('assembleDocumentApiAdapters', () => { expect(adapters).toHaveProperty('toc.update'); expect(adapters).toHaveProperty('toc.remove'); expect(adapters).toHaveProperty('ranges.resolve'); - expect(adapters).toHaveProperty('ranges.scrollIntoView'); expect(adapters).toHaveProperty('selection.current'); - expect(adapters).toHaveProperty('selection.onChange'); }); it('returns functions for all adapter methods', () => { @@ -132,8 +130,6 @@ describe('assembleDocumentApiAdapters', () => { expect(typeof adapters.toc.update).toBe('function'); expect(typeof adapters.toc.remove).toBe('function'); expect(typeof adapters.ranges.resolve).toBe('function'); - expect(typeof adapters.ranges.scrollIntoView).toBe('function'); expect(typeof adapters.selection!.current).toBe('function'); - expect(typeof adapters.selection!.onChange).toBe('function'); }); }); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/assemble-adapters.ts b/packages/super-editor/src/editors/v1/document-api-adapters/assemble-adapters.ts index c08bf28159..760b0b615b 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/assemble-adapters.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/assemble-adapters.ts @@ -99,8 +99,7 @@ import { executePlan } from './plan-engine/executor.js'; import { previewPlan } from './plan-engine/preview.js'; import { queryMatchAdapter } from './plan-engine/query-match-adapter.js'; import { resolveRange } from './helpers/range-resolver.js'; -import { scrollRangeIntoView } from './helpers/scroll-into-view-adapter.js'; -import { resolveCurrentSelectionInfo, subscribeToSelection } from './helpers/selection-info-resolver.js'; +import { resolveCurrentSelectionInfo } from './helpers/selection-info-resolver.js'; import { initRevision, trackRevisions } from './plan-engine/revision-tracker.js'; import { initStoryRevisionStore } from './story-runtime/story-revision-store.js'; import { registerBuiltInExecutors } from './plan-engine/register-executors.js'; @@ -723,11 +722,9 @@ export function assembleDocumentApiAdapters(editor: Editor): DocumentApiAdapters }, ranges: { resolve: (input) => resolveRange(editor, input), - scrollIntoView: (input) => scrollRangeIntoView(editor, input), }, selection: { current: (input) => resolveCurrentSelectionInfo(editor, input), - onChange: (listener) => subscribeToSelection(editor, listener), }, query: { match: (input) => queryMatchAdapter(editor, input), diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/scroll-into-view-adapter.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/scroll-into-view-adapter.test.ts deleted file mode 100644 index efb04b225c..0000000000 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/scroll-into-view-adapter.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import type { Editor } from '../../core/Editor.js'; -import type { ScrollIntoViewInput } from '@superdoc/document-api'; - -vi.mock('./adapter-utils.js', async () => { - const actual = await vi.importActual('./adapter-utils.js'); - return { ...actual, resolveTextTarget: vi.fn() }; -}); - -import { resolveTextTarget } from './adapter-utils.js'; -import { scrollRangeIntoView } from './scroll-into-view-adapter.js'; - -function makeEditor( - presentationStub: { - scrollToPositionAsync?: ReturnType; - navigateTo?: ReturnType; - } | null = {}, -): Editor { - const presentation = presentationStub - ? { - scrollToPositionAsync: presentationStub.scrollToPositionAsync ?? vi.fn().mockResolvedValue(true), - navigateTo: presentationStub.navigateTo ?? vi.fn().mockResolvedValue(true), - } - : null; - return { presentationEditor: presentation } as unknown as Editor; -} - -describe('scrollRangeIntoView — TextAddress / TextTarget', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('resolves a TextAddress target and delegates to scrollToPositionAsync', async () => { - vi.mocked(resolveTextTarget).mockReturnValue({ from: 42, to: 48 }); - const editor = makeEditor(); - const scroll = editor.presentationEditor!.scrollToPositionAsync as ReturnType; - - const out = await scrollRangeIntoView(editor, { - target: { kind: 'text', blockId: 'p1', range: { start: 3, end: 9 } }, - }); - - expect(out).toEqual({ success: true }); - expect(resolveTextTarget).toHaveBeenCalledWith(editor, { - kind: 'text', - blockId: 'p1', - range: { start: 3, end: 9 }, - }); - expect(scroll).toHaveBeenCalledWith(42, { block: 'center', behavior: 'smooth' }); - }); - - it('resolves a multi-segment TextTarget using the first segment', async () => { - vi.mocked(resolveTextTarget).mockReturnValue({ from: 100, to: 110 }); - const editor = makeEditor(); - const scroll = editor.presentationEditor!.scrollToPositionAsync as ReturnType; - - await scrollRangeIntoView(editor, { - target: { - kind: 'text', - segments: [ - { blockId: 'p1', range: { start: 2, end: 10 } }, - { blockId: 'p2', range: { start: 0, end: 5 } }, - ], - }, - }); - - // Only the FIRST segment is passed to resolveTextTarget — the helper - // scrolls to where the selection begins. - expect(resolveTextTarget).toHaveBeenCalledWith(editor, { - kind: 'text', - blockId: 'p1', - range: { start: 2, end: 10 }, - }); - expect(scroll).toHaveBeenCalledWith(100, { block: 'center', behavior: 'smooth' }); - }); - - it('passes through block and behavior options when provided', async () => { - vi.mocked(resolveTextTarget).mockReturnValue({ from: 1, to: 2 }); - const editor = makeEditor(); - const scroll = editor.presentationEditor!.scrollToPositionAsync as ReturnType; - - await scrollRangeIntoView(editor, { - target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 1 } }, - block: 'start', - behavior: 'auto', - }); - - expect(scroll).toHaveBeenCalledWith(1, { block: 'start', behavior: 'auto' }); - }); - - it('returns { success: false } when the text target cannot be resolved', async () => { - vi.mocked(resolveTextTarget).mockReturnValue(null); - const editor = makeEditor(); - const scroll = editor.presentationEditor!.scrollToPositionAsync as ReturnType; - - const out = await scrollRangeIntoView(editor, { - target: { kind: 'text', blockId: 'missing', range: { start: 0, end: 1 } }, - }); - - expect(out).toEqual({ success: false }); - expect(scroll).not.toHaveBeenCalled(); - }); - - it('returns { success: false } when the presentation editor reports failure', async () => { - vi.mocked(resolveTextTarget).mockReturnValue({ from: 7, to: 9 }); - const editor = makeEditor({ scrollToPositionAsync: vi.fn().mockResolvedValue(false) }); - - const out = await scrollRangeIntoView(editor, { - target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 2 } }, - }); - - expect(out).toEqual({ success: false }); - }); - - it('returns { success: false } when resolveTextTarget throws (ambiguous block id)', async () => { - // Production resolver throws `DocumentApiAdapterError` for ambiguous - // block IDs. The adapter must catch and convert to success: false - // rather than leak the error to the caller. - vi.mocked(resolveTextTarget).mockImplementation(() => { - throw new Error('Ambiguous block id'); - }); - const editor = makeEditor(); - - const out = await scrollRangeIntoView(editor, { - target: { kind: 'text', blockId: 'duplicated', range: { start: 0, end: 5 } }, - }); - - expect(out).toEqual({ success: false }); - }); - - it('returns { success: false } when scrollToPositionAsync rejects', async () => { - vi.mocked(resolveTextTarget).mockReturnValue({ from: 10, to: 15 }); - const editor = makeEditor({ - scrollToPositionAsync: vi.fn().mockRejectedValue(new Error('layout not ready')), - }); - - const out = await scrollRangeIntoView(editor, { - target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, - }); - - expect(out).toEqual({ success: false }); - }); -}); - -describe('scrollRangeIntoView — EntityAddress', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('delegates a comment EntityAddress to presentation.navigateTo', async () => { - const navigateTo = vi.fn().mockResolvedValue(true); - const editor = makeEditor({ navigateTo }); - const input: ScrollIntoViewInput = { - target: { kind: 'entity', entityType: 'comment', entityId: 'c_1' }, - }; - - const out = await scrollRangeIntoView(editor, input); - - expect(out).toEqual({ success: true }); - expect(navigateTo).toHaveBeenCalledWith(input.target); - // Text-path helpers must not be invoked for entity targets. - expect(resolveTextTarget).not.toHaveBeenCalled(); - }); - - it('delegates a trackedChange EntityAddress (including story) to presentation.navigateTo', async () => { - const navigateTo = vi.fn().mockResolvedValue(true); - const editor = makeEditor({ navigateTo }); - const input: ScrollIntoViewInput = { - // trackedChange in a footnote story — story must reach navigateTo - // unchanged so it can activate the right editor surface before - // scrolling. - target: { - kind: 'entity', - entityType: 'trackedChange', - entityId: 'tc_42', - story: { storyType: 'footnote', refId: 'fn_1' }, - } as ScrollIntoViewInput['target'], - }; - - await scrollRangeIntoView(editor, input); - - expect(navigateTo).toHaveBeenCalledWith(input.target); - }); - - it('returns whatever navigateTo returns (e.g. success: false)', async () => { - const navigateTo = vi.fn().mockResolvedValue(false); - const editor = makeEditor({ navigateTo }); - - const out = await scrollRangeIntoView(editor, { - target: { kind: 'entity', entityType: 'comment', entityId: 'c_missing' }, - }); - - expect(out).toEqual({ success: false }); - }); - - it('returns { success: false } when navigateTo throws', async () => { - const navigateTo = vi.fn().mockRejectedValue(new Error('boom')); - const editor = makeEditor({ navigateTo }); - - const out = await scrollRangeIntoView(editor, { - target: { kind: 'entity', entityType: 'trackedChange', entityId: 'tc_boom' }, - }); - - expect(out).toEqual({ success: false }); - }); -}); - -describe('scrollRangeIntoView — presentation unavailable', () => { - it('returns { success: false } when the editor has no presentationEditor', async () => { - vi.mocked(resolveTextTarget).mockReturnValue({ from: 5, to: 10 }); - const editor = makeEditor(null); - - const out = await scrollRangeIntoView(editor, { - target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 1 } }, - }); - - expect(out).toEqual({ success: false }); - }); -}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/scroll-into-view-adapter.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/scroll-into-view-adapter.ts deleted file mode 100644 index b5d6b26260..0000000000 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/scroll-into-view-adapter.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { ScrollIntoViewInput, ScrollIntoViewOutput } from '@superdoc/document-api'; -import type { Editor } from '../../core/Editor.js'; -import { resolveTextTarget } from './adapter-utils.js'; - -/** - * Implementation of `editor.doc.ranges.scrollIntoView`. - * - * Two paths: - * - EntityAddress (comment / tracked change by id) → delegates to - * `presentation.navigateTo(target)`, which handles paginated layouts, - * virtualized page mounting, AND story activation for entities in - * header/footer/footnote/endnote stories. `block` and `behavior` - * options are not applied here — `navigateTo` picks sensible viewport - * alignment per entity type. - * - TextAddress / TextTarget → resolves the first segment to a PM - * position and calls `scrollToPositionAsync` with caller-provided - * `block` / `behavior` options. This path is body-only today; text - * targets that reference non-body stories are out of scope for this - * operation. - * - * Both paths honor the `{ success: boolean }` contract: - * thrown errors from resolvers (e.g. ambiguous block IDs) and rejected - * scroll promises are caught and converted into `{ success: false }` - * rather than propagating to the caller. - * - * Known limitation: for a tracked change that lives in a non-body story - * (header, footer, footnote, endnote) on a page that is not currently - * mounted in the DOM (virtualized), `presentation.navigateTo` returns - * `false` — the non-body navigation path activates the story surface via - * rendered DOM candidates, and offscreen pages have none. This returns - * `{ success: false }`. A fix needs body-side reference resolution so the - * containing page can be pre-mounted; tracked as a follow-up. - */ -export async function scrollRangeIntoView(editor: Editor, input: ScrollIntoViewInput): Promise { - const presentation = editor.presentationEditor; - if (!presentation) { - return { success: false }; - } - - // EntityAddress path — hand off to the presentation editor so it can - // activate the right story (footnotes, header/footer) before scrolling. - if ('kind' in input.target && input.target.kind === 'entity') { - if (typeof presentation.navigateTo !== 'function') { - return { success: false }; - } - try { - const ok = await presentation.navigateTo(input.target); - return { success: ok }; - } catch { - return { success: false }; - } - } - - // TextAddress / TextTarget path — resolve to a PM position in the body - // and scroll directly. TextTarget resolves the FIRST segment, so a - // multi-block selection scrolls to where the selection begins. - if (typeof presentation.scrollToPositionAsync !== 'function') { - return { success: false }; - } - - try { - const firstSegment = - 'segments' in input.target - ? input.target.segments[0] - : { blockId: input.target.blockId, range: input.target.range }; - if (!firstSegment) return { success: false }; - - const resolved = resolveTextTarget(editor, { - kind: 'text', - blockId: firstSegment.blockId, - range: firstSegment.range, - }); - if (!resolved) return { success: false }; - - const ok = await presentation.scrollToPositionAsync(resolved.from, { - block: input.block ?? 'center', - behavior: input.behavior ?? 'smooth', - }); - return { success: ok }; - } catch { - // `resolveTextTarget` throws `DocumentApiAdapterError` for ambiguous - // block IDs; `scrollToPositionAsync` can reject on layout or mount - // failures. Convert either into `{ success: false }` so the caller - // sees a single predictable result type. - return { success: false }; - } -} diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/selection-info-resolver.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/selection-info-resolver.test.ts index 3006fb886f..512468c540 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/selection-info-resolver.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/selection-info-resolver.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { Node as ProseMirrorNode } from 'prosemirror-model'; import type { Editor } from '../../core/Editor.js'; -import { resolveCurrentSelectionInfo, subscribeToSelection } from './selection-info-resolver.js'; +import { resolveCurrentSelectionInfo } from './selection-info-resolver.js'; // Stub `groupTrackedChanges` so tests don't need a fully PM-shaped // editor with `editor.state.doc.textBetween` and the tracked-change @@ -557,113 +557,3 @@ describe('resolveCurrentSelectionInfo > entity ids', () => { }); }); -// --------------------------------------------------------------------------- -// subscribeToSelection -// --------------------------------------------------------------------------- - -describe('subscribeToSelection', () => { - it('fires the listener once per tick when selection updates', async () => { - const docNode = doc([textBlock('p1', 'Hi')]); - const editor = makeEditor(docNode, { from: 1, to: 3 }) as Editor & { __fire(event: string): void }; - const listener = vi.fn(); - - const unsubscribe = subscribeToSelection(editor, listener); - editor.__fire('selectionUpdate'); - editor.__fire('selectionUpdate'); - // Multiple events in one tick coalesce via queueMicrotask. - - await Promise.resolve(); // flush the microtask - expect(listener).toHaveBeenCalledTimes(1); - - unsubscribe(); - }); - - it('stops firing after unsubscribe', async () => { - const docNode = doc([textBlock('p1', 'Hi')]); - const editor = makeEditor(docNode, { from: 1, to: 3 }) as Editor & { __fire(event: string): void }; - const listener = vi.fn(); - - const unsubscribe = subscribeToSelection(editor, listener); - unsubscribe(); - editor.__fire('selectionUpdate'); - await Promise.resolve(); - - expect(listener).not.toHaveBeenCalled(); - }); - - it('cancels a microtask queued just before unsubscribe (no stale fire)', async () => { - // Regression: before the cancel flag, a microtask queued by the last - // pre-unmount event could still invoke the listener after unsubscribe - // returned — a classic source of stale state updates during React - // component unmount. - const docNode = doc([textBlock('p1', 'Hi')]); - const editor = makeEditor(docNode, { from: 1, to: 3 }) as Editor & { __fire(event: string): void }; - const listener = vi.fn(); - - const unsubscribe = subscribeToSelection(editor, listener); - editor.__fire('selectionUpdate'); // queues a microtask - unsubscribe(); // must mark the queued microtask as no-op - await Promise.resolve(); - - expect(listener).not.toHaveBeenCalled(); - }); - - it('dedupes events that produce identical SelectionInfo (typing without moving caret)', async () => { - // Regression: the `transaction` subscription is needed to catch - // programmatic selection changes that don't emit `selectionUpdate`, - // but it ALSO fires on every keystroke. Without content dedupe the - // listener ran per character even when the projected SelectionInfo - // was unchanged. Multiple ticks emitting the same selection state - // should fire the listener exactly once. - const docNode = doc([textBlock('p1', 'Hi')]); - const editor = makeEditor(docNode, { from: 1, to: 3 }) as Editor & { __fire(event: string): void }; - const listener = vi.fn(); - - const unsubscribe = subscribeToSelection(editor, listener); - - // First tick: a transaction event with the selection at [1, 3]. - editor.__fire('transaction'); - await Promise.resolve(); - expect(listener).toHaveBeenCalledTimes(1); - - // Second tick: another transaction with no selection change. - // Without dedupe this would re-fire; with dedupe it skips. - editor.__fire('transaction'); - await Promise.resolve(); - expect(listener).toHaveBeenCalledTimes(1); - - // Third tick: same again — still one call. - editor.__fire('transaction'); - await Promise.resolve(); - expect(listener).toHaveBeenCalledTimes(1); - - unsubscribe(); - }); - - it('fires again when SelectionInfo changes after a deduped tick', async () => { - // Dedupe must not become sticky: a real selection change after a - // deduped tick must still invoke the listener. - const docNode = doc([textBlock('p1', 'Hello')]); - const editor = makeEditor(docNode, { from: 1, to: 3 }) as Editor & { - __fire(event: string): void; - }; - const listener = vi.fn(); - - const unsubscribe = subscribeToSelection(editor, listener); - editor.__fire('transaction'); - await Promise.resolve(); - expect(listener).toHaveBeenCalledTimes(1); - - // Change the selection on the editor stub, then fire again. - (editor.state as { selection: { from: number; to: number; empty: boolean } }).selection = { - from: 2, - to: 4, - empty: false, - }; - editor.__fire('transaction'); - await Promise.resolve(); - expect(listener).toHaveBeenCalledTimes(2); - - unsubscribe(); - }); -}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/selection-info-resolver.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/selection-info-resolver.ts index 7789701561..c90834f5db 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/selection-info-resolver.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/selection-info-resolver.ts @@ -1,11 +1,5 @@ import type { Node as ProseMirrorNode } from 'prosemirror-model'; -import type { - SelectionCurrentInput, - SelectionInfo, - SelectionChangeListener, - TextTarget, - TextSegment, -} from '@superdoc/document-api'; +import type { SelectionCurrentInput, SelectionInfo, TextTarget, TextSegment } from '@superdoc/document-api'; import type { Editor } from '../../core/Editor.js'; import { pmPositionToTextOffset } from './text-offset-resolver.js'; import { groupTrackedChanges } from './tracked-change-resolver.js'; @@ -196,8 +190,8 @@ function mapRawChangeIdsToCanonical(editor: Editor, rawIds: string[]): string[] * Walks text nodes in one pass; bounded allocation. Co-located with * `collectActiveMarks` so the resolver only walks the selection * range twice (once for mark-name intersection, once here for - * id-attribute union) — the dedup at the subscriber level - * (`selectionInfoKey`) keeps this cheap on the hot path. + * id-attribute union) — the controller substrate dedups subscribers + * with `shallowEqual`, keeping this cheap on the hot path. */ function collectActiveEntityIds( state: { selection: any; storedMarks?: any; doc: ProseMirrorNode }, @@ -273,68 +267,6 @@ function collectActiveMarks( return Array.from(names); } -/** - * Subscribe to selection changes on the editor. - * - * - Microtask coalescing so a single user action that dispatches multiple - * PM transactions fires the listener at most once per tick. - * - Content dedupe so doc-only transactions (typing without moving the - * caret) don't re-fire the listener with an identical SelectionInfo. - * The transaction event is needed to catch programmatic selection - * changes that don't emit `selectionUpdate`, but it also fires on - * every keystroke; without dedupe the listener runs per character. - */ -export function subscribeToSelection(editor: Editor, listener: SelectionChangeListener): () => void { - let scheduled = false; - let cancelled = false; - let lastEmittedKey: string | null = null; - const flush = () => { - scheduled = false; - if (cancelled) return; - const info = resolveCurrentSelectionInfo(editor, {}); - const key = selectionInfoKey(info); - if (key === lastEmittedKey) return; - lastEmittedKey = key; - listener(info); - }; - const schedule = () => { - if (scheduled || cancelled) return; - scheduled = true; - queueMicrotask(flush); - }; - - editor.on('selectionUpdate', schedule); - editor.on('transaction', schedule); - - return () => { - // Mark cancelled first so a microtask already queued by `schedule` - // no-ops when it finally fires — otherwise the listener can be invoked - // after unsubscribe returns (stale state updates during unmount). - cancelled = true; - editor.off?.('selectionUpdate', schedule); - editor.off?.('transaction', schedule); - }; -} - -/** - * Build a stable string key from a SelectionInfo for content-dedupe. - * Two infos that produce the same key represent the same observable - * selection state — the listener can skip the second one. - */ -function selectionInfoKey(info: SelectionInfo): string { - const target = info.target; - let targetKey: string; - if (!target) { - targetKey = 'null'; - } else { - targetKey = target.segments.map((s) => `${s.blockId}:${s.range.start}-${s.range.end}`).join('|'); - } - const marks = [...info.activeMarks].sort().join(','); - const comments = [...info.activeCommentIds].sort().join(','); - const changes = [...info.activeChangeIds].sort().join(','); - return `${info.empty ? '1' : '0'}:${targetKey}:${marks}:c=${comments}:tc=${changes}`; -} - function markTypesPresentEverywhere(doc: ProseMirrorNode, from: number, to: number): Set { // Intersect mark-name sets per text node, not per character. `selection. // onChange` fires frequently during editing, so allocating one Set per diff --git a/packages/super-editor/src/editors/v1/index.js b/packages/super-editor/src/editors/v1/index.js index ddcf8a8f01..7e2989eebc 100644 --- a/packages/super-editor/src/editors/v1/index.js +++ b/packages/super-editor/src/editors/v1/index.js @@ -18,6 +18,7 @@ import { headlessToolbarConstants, headlessToolbarHelpers, } from '../../headless-toolbar/index.js'; +import { createSuperDocUI, shallowEqual } from '../../ui/index.js'; import { SuperToolbar } from './components/toolbar/super-toolbar.js'; import { DocxEncryptionError, DocxEncryptionErrorCode, DocxZipper, helpers } from './core/index.js'; import { Editor } from './core/Editor.js'; @@ -120,6 +121,8 @@ export { createHeadlessToolbar, headlessToolbarConstants, headlessToolbarHelpers, + createSuperDocUI, + shallowEqual, getStarterExtensions, /** @internal */ getRichTextExtensions, diff --git a/packages/super-editor/src/headless-toolbar/create-toolbar-snapshot.ts b/packages/super-editor/src/headless-toolbar/create-toolbar-snapshot.ts index 57f068ede9..0bb96f9112 100644 --- a/packages/super-editor/src/headless-toolbar/create-toolbar-snapshot.ts +++ b/packages/super-editor/src/headless-toolbar/create-toolbar-snapshot.ts @@ -32,7 +32,23 @@ const buildCommandStateMap = ({ ] as const; } - return [command, entry.state({ context, superdoc })] as const; + // Per-command resilience: if a single deriver throws (editor + // mid-construction, partial PresentationEditor route, test stub + // not modelling full PM state), default that command to disabled + // rather than killing the whole snapshot. Other commands still + // resolve, and the next event tick re-derives once the editor is + // stable. + try { + return [command, entry.state({ context, superdoc })] as const; + } catch { + return [ + command, + { + active: false, + disabled: true, + }, + ] as const; + } }); return Object.fromEntries(entries) as ToolbarCommandStates; diff --git a/packages/super-editor/src/index.ts b/packages/super-editor/src/index.ts index 62d5e9d70d..6a776ec8e9 100644 --- a/packages/super-editor/src/index.ts +++ b/packages/super-editor/src/index.ts @@ -22,7 +22,6 @@ export type { SelectionApi, SelectionInfo, SelectionCurrentInput, - SelectionChangeListener, ScrollIntoViewInput, ScrollIntoViewOutput, TextAddress, @@ -162,3 +161,25 @@ export type { ToolbarTarget, ToolbarValueMap, } from './headless-toolbar/types.js'; + +// superdoc/ui public types (browser UI controller) +export type { + CommentsHandle, + CommentsSlice, + EqualityFn, + ReviewHandle, + ReviewItem, + ReviewSlice, + SelectorFn, + SelectionSlice, + Subscribable, + SuperDocEditorLike, + SuperDocLike, + SuperDocUI, + SuperDocUIOptions, + SuperDocUIState, + ViewportGetRectInput, + ViewportHandle, + ViewportRect, + ViewportRectResult, +} from './ui/types.js'; diff --git a/packages/super-editor/src/ui/comments.test.ts b/packages/super-editor/src/ui/comments.test.ts new file mode 100644 index 0000000000..2907e9b000 --- /dev/null +++ b/packages/super-editor/src/ui/comments.test.ts @@ -0,0 +1,383 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createSuperDocUI } from './create-super-doc-ui.js'; +import type { SuperDocLike } from './types.js'; + +/** + * Stub builder for `ui.comments` tests. Models the parts of the + * editor's `doc.comments` / `doc.selection` / `doc.ranges` surface + * the controller's comments domain reads or routes through. + */ +function makeStubs( + initial: { + comments?: Array<{ id: string; commentId: string; text?: string; status?: 'open' | 'resolved' }>; + activeCommentIds?: string[]; + selectionTarget?: unknown; + } = {}, +) { + const editorListeners = new Map void>>(); + const superdocListeners = new Map void>>(); + + let commentsList = initial.comments ?? []; + const create = vi.fn((input: { target: unknown; text: string }) => ({ + success: true as const, + inserted: [{ kind: 'entity', entityType: 'comment', entityId: `c-${commentsList.length + 1}` }], + target: input.target, + text: input.text, + })); + const patch = vi.fn((_input: { commentId: string; status?: string; text?: string }) => ({ + success: true as const, + })); + const del = vi.fn((_input: { commentId: string }) => ({ success: true as const })); + const list = vi.fn(() => ({ + evaluatedRevision: 'r1', + total: commentsList.length, + items: commentsList.map((c) => ({ + id: c.id, + handle: { ref: `comment:${c.commentId}`, refStability: 'stable' as const, targetKind: 'comment' as const }, + address: { kind: 'entity' as const, entityType: 'comment' as const, entityId: c.commentId }, + commentId: c.commentId, + status: c.status ?? ('open' as const), + text: c.text, + })), + page: { limit: 50, offset: 0, returned: commentsList.length }, + })); + const navigateTo = vi.fn(async (_target: unknown) => true); + + const editor: { + on: ReturnType; + off: ReturnType; + doc: unknown; + presentationEditor: { + navigateTo: typeof navigateTo; + getActiveEditor: () => unknown; + }; + } = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (!editorListeners.has(event)) editorListeners.set(event, new Set()); + editorListeners.get(event)!.add(handler); + }), + off: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + editorListeners.get(event)?.delete(handler); + }), + doc: { + selection: { + current: vi.fn(() => ({ + empty: initial.selectionTarget == null, + text: '', + target: initial.selectionTarget ?? null, + activeCommentIds: initial.activeCommentIds ?? [], + activeChangeIds: [], + })), + }, + comments: { create, patch, delete: del, list }, + }, + // Self-reference assigned below so toolbar source resolution sees + // the same routed editor as the rest of the stub. + presentationEditor: undefined as never, + }; + editor.presentationEditor = { navigateTo, getActiveEditor: () => editor }; + + const superdoc: SuperDocLike & { + fireEditor(event: string, ...args: unknown[]): void; + setComments(next: typeof commentsList): void; + } = { + activeEditor: editor as never, + config: { documentMode: 'editing' }, + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (!superdocListeners.has(event)) superdocListeners.set(event, new Set()); + superdocListeners.get(event)!.add(handler); + }), + off: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + superdocListeners.get(event)?.delete(handler); + }), + fireEditor(event, ...args) { + const handlers = editorListeners.get(event); + if (!handlers) return; + [...handlers].forEach((handler) => handler(...args)); + }, + setComments(next) { + commentsList = next; + }, + }; + + return { superdoc, editor, mocks: { create, patch, delete: del, list, navigateTo } }; +} + +const flushMicrotasks = () => Promise.resolve(); + +describe('ui.comments — snapshot', () => { + it('exposes the initial comments list synchronously', () => { + const { superdoc, mocks } = makeStubs({ + comments: [ + { id: 'c1', commentId: 'c1', text: 'first' }, + { id: 'c2', commentId: 'c2', text: 'second' }, + ], + }); + const ui = createSuperDocUI({ superdoc }); + + const snap = ui.comments.getSnapshot(); + expect(snap.total).toBe(2); + expect(snap.items.map((i) => i.commentId)).toEqual(['c1', 'c2']); + expect(snap.activeIds).toEqual([]); + + expect(mocks.list).toHaveBeenCalled(); + ui.destroy(); + }); + + it('subscribe fires once with the initial snapshot', () => { + const { superdoc } = makeStubs({ comments: [{ id: 'c1', commentId: 'c1' }] }); + const ui = createSuperDocUI({ superdoc }); + + const cb = vi.fn(); + const off = ui.comments.subscribe(cb); + + expect(cb).toHaveBeenCalledTimes(1); + const arg = cb.mock.calls[0][0] as { snapshot: { total: number } }; + expect(arg.snapshot.total).toBe(1); + + off(); + ui.destroy(); + }); + + it('refreshes the cache on commentsUpdate and re-fires subscribers', async () => { + const { superdoc } = makeStubs({ comments: [{ id: 'c1', commentId: 'c1' }] }); + const ui = createSuperDocUI({ superdoc }); + + const cb = vi.fn(); + ui.comments.subscribe(cb); + expect(cb).toHaveBeenCalledTimes(1); + + superdoc.setComments([ + { id: 'c1', commentId: 'c1' }, + { id: 'c2', commentId: 'c2', text: 'new' }, + ]); + superdoc.fireEditor('commentsUpdate'); + await flushMicrotasks(); + + expect(cb).toHaveBeenCalledTimes(2); + const latest = cb.mock.calls[1][0] as { snapshot: { total: number; items: Array<{ commentId: string }> } }; + expect(latest.snapshot.total).toBe(2); + expect(latest.snapshot.items.map((i) => i.commentId)).toEqual(['c1', 'c2']); + + ui.destroy(); + }); + + it('mirrors selection.current().activeCommentIds into snapshot.activeIds', () => { + const { superdoc } = makeStubs({ comments: [{ id: 'c1', commentId: 'c1' }], activeCommentIds: ['c1'] }); + const ui = createSuperDocUI({ superdoc }); + + const snap = ui.comments.getSnapshot(); + expect(snap.activeIds).toEqual(['c1']); + + ui.destroy(); + }); + + it('clears the cache when comments.list() throws on refresh (no cross-document stale leakage)', async () => { + const { superdoc, mocks } = makeStubs({ + comments: [ + { id: 'c1', commentId: 'c1', text: 'first' }, + { id: 'c2', commentId: 'c2', text: 'second' }, + ], + }); + const ui = createSuperDocUI({ superdoc }); + + // Start with a populated snapshot. + expect(ui.comments.getSnapshot().total).toBe(2); + + // Simulate a document/editor swap where the new editor's list() + // throws transiently. The cache must reset to empty rather than + // continue serving the old editor's items. + mocks.list.mockImplementationOnce(() => { + throw new Error('editor mid-swap'); + }); + superdoc.fireEditor('commentsUpdate'); + await flushMicrotasks(); + + const snap = ui.comments.getSnapshot(); + expect(snap.total).toBe(0); + expect(snap.items).toEqual([]); + + ui.destroy(); + }); + + it('returns the same array reference for empty activeIds across snapshots (shallowEqual stability)', () => { + // Pre-SD-2792 selection shape: no activeCommentIds. Without a + // shared sentinel, `?? []` would allocate a fresh array each + // computeState() call and trigger shallowEqual mismatch on the + // comments snapshot — every selection event would re-fire + // ui.comments.subscribe. + const { superdoc, editor } = makeStubs({ comments: [{ id: 'c1', commentId: 'c1' }] }); + (editor.doc.selection.current as unknown as () => { empty: boolean; target: null }) = vi.fn(() => ({ + empty: true, + target: null, + })); + const ui = createSuperDocUI({ superdoc }); + + const a = ui.comments.getSnapshot().activeIds; + const b = ui.comments.getSnapshot().activeIds; + expect(a).toBe(b); // same reference + + ui.destroy(); + }); + + it('does not re-fire ui.comments.subscribe when the resolver returns fresh-but-equal activeCommentIds arrays', async () => { + // Post-SD-2792 the resolver returns `Array.from(new Set(...))` on + // every call — fresh references even when the contents are + // identical. Without slice-level memoization piping through the + // comments slice, every keystroke / selectionUpdate would trip + // shallowEqual and re-render every comment-aware sidebar. + const { superdoc, editor } = makeStubs({ comments: [{ id: 'c1', commentId: 'c1' }] }); + (editor.doc.selection.current as unknown as () => unknown) = vi.fn(() => ({ + empty: true, + target: null, + activeMarks: [], + activeCommentIds: ['c1'], // fresh array literal each call + activeChangeIds: [], + })); + const ui = createSuperDocUI({ superdoc }); + + const cb = vi.fn(); + ui.comments.subscribe(cb); + expect(cb).toHaveBeenCalledTimes(1); // initial + + superdoc.fireEditor('selectionUpdate'); + await Promise.resolve(); + superdoc.fireEditor('selectionUpdate'); + await Promise.resolve(); + + expect(cb).toHaveBeenCalledTimes(1); + ui.destroy(); + }); + + it('refreshes the snapshot synchronously after own mutations (createFromSelection / resolve / delete)', () => { + const target = { kind: 'text' as const, segments: [{ blockId: 'p1', range: { start: 0, end: 5 } }] }; + const { superdoc, mocks } = makeStubs({ + comments: [{ id: 'c1', commentId: 'c1', text: 'first' }], + selectionTarget: target, + }); + const ui = createSuperDocUI({ superdoc }); + + const cb = vi.fn(); + ui.comments.subscribe(cb); + expect(cb).toHaveBeenCalledTimes(1); + + // Simulate the wrapper updating the comments store: as soon as + // ui.comments.createFromSelection completes, list() must return + // the new item. The own-mutation refresh re-reads list() so + // subscribers see the post-mutation state without needing a + // commentsUpdate event. + superdoc.setComments([ + { id: 'c1', commentId: 'c1', text: 'first' }, + { id: 'c2', commentId: 'c2', text: 'second' }, + ]); + ui.comments.createFromSelection({ text: 'second' }); + + expect(mocks.create).toHaveBeenCalledTimes(1); + // getSnapshot reflects the new state synchronously after the + // mutation (without needing a commentsUpdate event). + expect(ui.comments.getSnapshot().total).toBe(2); + + // Same pattern for resolve. + superdoc.setComments([ + { id: 'c1', commentId: 'c1', status: 'resolved' }, + { id: 'c2', commentId: 'c2', text: 'second' }, + ]); + ui.comments.resolve('c1'); + expect(ui.comments.getSnapshot().items[0].status).toBe('resolved'); + + // And for delete. + superdoc.setComments([{ id: 'c2', commentId: 'c2', text: 'second' }]); + ui.comments.delete('c1'); + expect(ui.comments.getSnapshot().total).toBe(1); + + ui.destroy(); + }); + + it('falls back to [] when selection.current() predates SD-2792 (no activeCommentIds field)', () => { + const { superdoc, editor } = makeStubs(); + // Override selection.current to return an SD-2668-shaped result + // (no activeCommentIds). The controller must not crash. + (editor.doc.selection.current as unknown as () => { empty: boolean; target: null }) = vi.fn(() => ({ + empty: true, + target: null, + })); + const ui = createSuperDocUI({ superdoc }); + + expect(ui.comments.getSnapshot().activeIds).toEqual([]); + + ui.destroy(); + }); +}); + +describe('ui.comments — actions route through editor.doc.*', () => { + it('createFromSelection forwards to comments.create with the selection target', () => { + const target = { kind: 'text' as const, segments: [{ blockId: 'p1', range: { start: 0, end: 5 } }] }; + const { superdoc, mocks } = makeStubs({ selectionTarget: target }); + const ui = createSuperDocUI({ superdoc }); + + const receipt = ui.comments.createFromSelection({ text: 'Looks good' }); + + expect(receipt.success).toBe(true); + expect(mocks.create).toHaveBeenCalledWith({ target, text: 'Looks good' }); + + ui.destroy(); + }); + + it('createFromSelection returns a NO_OP receipt when no selection target exists', () => { + const { superdoc, mocks } = makeStubs(); // no selectionTarget + const ui = createSuperDocUI({ superdoc }); + + const receipt = ui.comments.createFromSelection({ text: 'orphan' }); + + expect(receipt.success).toBe(false); + expect(mocks.create).not.toHaveBeenCalled(); + + ui.destroy(); + }); + + it('resolve forwards to comments.patch({ commentId, status: "resolved" })', () => { + const { superdoc, mocks } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + ui.comments.resolve('c-42'); + + expect(mocks.patch).toHaveBeenCalledWith({ commentId: 'c-42', status: 'resolved' }); + ui.destroy(); + }); + + it('reopen forwards to comments.patch({ commentId, status: "active" })', () => { + // Architecturally correct even though doc-api validation rejects + // 'active' until SD-2789 lands. The route is what we're asserting. + const { superdoc, mocks } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + ui.comments.reopen('c-42'); + + expect(mocks.patch).toHaveBeenCalledWith({ commentId: 'c-42', status: 'active' }); + ui.destroy(); + }); + + it('delete forwards to comments.delete({ commentId })', () => { + const { superdoc, mocks } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + ui.comments.delete('c-42'); + + expect(mocks.delete).toHaveBeenCalledWith({ commentId: 'c-42' }); + ui.destroy(); + }); + + it('scrollTo navigates to the comment EntityAddress via the presentation editor', async () => { + const { superdoc, mocks } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + await ui.comments.scrollTo('c-42'); + + expect(mocks.navigateTo).toHaveBeenCalledTimes(1); + const target = mocks.navigateTo.mock.calls[0][0] as { kind: string; entityType: string; entityId: string }; + expect(target).toEqual({ kind: 'entity', entityType: 'comment', entityId: 'c-42' }); + + ui.destroy(); + }); +}); 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 new file mode 100644 index 0000000000..10bc546e6f --- /dev/null +++ b/packages/super-editor/src/ui/create-super-doc-ui.test.ts @@ -0,0 +1,653 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createSuperDocUI } from './create-super-doc-ui.js'; +import { shallowEqual } from './equality.js'; +import type { SuperDocLike } from './types.js'; + +/** + * Builds a minimal stub of the SuperDoc instance + its activeEditor + * with a controllable event bus and a settable selection. Every test + * starts with a fresh stub so listener bookkeeping is isolated. + */ +function makeSuperdocStub( + initial: { + documentMode?: 'editing' | 'suggesting' | 'viewing'; + selection?: { empty: boolean; text?: string }; + } = {}, +) { + const editorListeners = new Map void>>(); + const superdocListeners = new Map void>>(); + + let selectionEmpty = initial.selection?.empty ?? true; + let selectionText = initial.selection?.text ?? ''; + + const editor = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (!editorListeners.has(event)) editorListeners.set(event, new Set()); + editorListeners.get(event)!.add(handler); + }), + off: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + editorListeners.get(event)?.delete(handler); + }), + doc: { + selection: { + current: vi.fn((input?: { includeText?: boolean }) => ({ + empty: selectionEmpty, + text: input?.includeText ? selectionText : undefined, + target: null, + })), + }, + }, + }; + + const superdoc: SuperDocLike & { + fireEditor(event: string, ...args: unknown[]): void; + fireSuperdoc(event: string, ...args: unknown[]): void; + setSelection(empty: boolean, text?: string): void; + setDocumentMode(mode: 'editing' | 'suggesting' | 'viewing'): void; + swapEditor(next: typeof editor | null): void; + editorListenerCount(event: string): number; + superdocListenerCount(event: string): number; + } = { + activeEditor: editor, + config: { documentMode: initial.documentMode ?? 'editing' }, + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (!superdocListeners.has(event)) superdocListeners.set(event, new Set()); + superdocListeners.get(event)!.add(handler); + }), + off: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + superdocListeners.get(event)?.delete(handler); + }), + + fireEditor(event: string, ...args: unknown[]) { + const handlers = editorListeners.get(event); + if (!handlers) return; + // Snapshot before iterating: handlers can mutate the registration + // set (e.g., presentation re-routing, headless-toolbar rebinding + // listeners on every change). A Set's forEach picks up newly-added + // handlers mid-loop, which produces unbounded recursion. Real + // editor event buses iterate a frozen list. + [...handlers].forEach((handler) => handler(...args)); + }, + fireSuperdoc(event: string, ...args: unknown[]) { + const handlers = superdocListeners.get(event); + if (!handlers) return; + [...handlers].forEach((handler) => handler(...args)); + }, + setSelection(empty: boolean, text = '') { + selectionEmpty = empty; + selectionText = text; + }, + setDocumentMode(mode) { + this.config!.documentMode = mode; + }, + swapEditor(next) { + this.activeEditor = next as never; + }, + editorListenerCount(event: string) { + return editorListeners.get(event)?.size ?? 0; + }, + superdocListenerCount(event: string) { + return superdocListeners.get(event)?.size ?? 0; + }, + }; + + return superdoc; +} + +const flushMicrotasks = () => Promise.resolve(); + +describe('createSuperDocUI', () => { + let teardown: Array<() => void> = []; + + afterEach(() => { + teardown.forEach((fn) => fn()); + teardown = []; + }); + + it('emits the initial value synchronously on subscribe', () => { + const superdoc = makeSuperdocStub({ documentMode: 'suggesting' }); + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + + const slice = ui.select((state) => state.documentMode); + const cb = vi.fn(); + slice.subscribe(cb); + + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith('suggesting'); + }); + + it('exposes get() that snapshots without subscribing', () => { + const superdoc = makeSuperdocStub({ documentMode: 'editing' }); + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + + const slice = ui.select((state) => state.documentMode); + expect(slice.get()).toBe('editing'); + }); + + it('does not re-fire the listener when the selected slice is unchanged', async () => { + const superdoc = makeSuperdocStub({ documentMode: 'editing' }); + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + + const cb = vi.fn(); + ui.select((state) => state.documentMode).subscribe(cb); + expect(cb).toHaveBeenCalledTimes(1); // initial + + // A transaction that doesn't change documentMode should not re-fire + superdoc.fireEditor('transaction'); + await flushMicrotasks(); + + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('re-fires when the selected slice changes', async () => { + const superdoc = makeSuperdocStub({ documentMode: 'editing' }); + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + + const cb = vi.fn(); + ui.select((state) => state.documentMode).subscribe(cb); + + superdoc.setDocumentMode('suggesting'); + superdoc.fireSuperdoc('document-mode-change'); + await flushMicrotasks(); + + expect(cb).toHaveBeenCalledTimes(2); + expect(cb).toHaveBeenLastCalledWith('suggesting'); + }); + + it('coalesces bursts of source events to a single notification per microtask', async () => { + const superdoc = makeSuperdocStub(); + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + + const cb = vi.fn(); + ui.select((state) => state.selection.empty).subscribe(cb); + expect(cb).toHaveBeenCalledTimes(1); + + superdoc.setSelection(false, 'hello'); + // Simulate a multi-step transaction firing many events in the same tick + superdoc.fireEditor('transaction'); + superdoc.fireEditor('selectionUpdate'); + superdoc.fireEditor('transaction'); + superdoc.fireEditor('commentsUpdate'); + await flushMicrotasks(); + + // Initial + one coalesced rebuild = 2 + expect(cb).toHaveBeenCalledTimes(2); + expect(cb).toHaveBeenLastCalledWith(false); + }); + + it('uses Object.is by default; shallowEqual lets object slices dedup', async () => { + const superdoc = makeSuperdocStub(); + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + + // Default Object.is: each rebuild creates a new object => listener fires + const defaultCb = vi.fn(); + ui.select((state) => ({ empty: state.selection.empty })).subscribe(defaultCb); + + // shallowEqual: structurally identical slices dedup + const shallowCb = vi.fn(); + ui.select((state) => ({ empty: state.selection.empty }), shallowEqual).subscribe(shallowCb); + + superdoc.fireEditor('transaction'); + await flushMicrotasks(); + + expect(defaultCb).toHaveBeenCalledTimes(2); // initial + rebuild + expect(shallowCb).toHaveBeenCalledTimes(1); // initial only + }); + + it('unsubscribe stops the individual listener but other subscribers keep firing', async () => { + const superdoc = makeSuperdocStub({ documentMode: 'editing' }); + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + + const slice = ui.select((state) => state.documentMode); + const cb1 = vi.fn(); + const cb2 = vi.fn(); + const off1 = slice.subscribe(cb1); + slice.subscribe(cb2); + + off1(); + + superdoc.setDocumentMode('viewing'); + superdoc.fireSuperdoc('document-mode-change'); + await flushMicrotasks(); + + expect(cb1).toHaveBeenCalledTimes(1); // initial only + expect(cb2).toHaveBeenCalledTimes(2); // initial + rebuild + }); + + it('does not leak controller-level listeners across select+subscribe+unsubscribe cycles', async () => { + const superdoc = makeSuperdocStub(); + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + + // 100 mount/unmount-shaped cycles. Without refcount, each select() + // would leave its onStateChange wired to the controller forever + // and re-run on every editor event. + const selector = vi.fn((state) => state.documentMode); + for (let i = 0; i < 100; i += 1) { + const slice = ui.select(selector); + const off = slice.subscribe(() => {}); + off(); + } + + // Reset to count only post-cycle invocations. + selector.mockClear(); + + // Fire one editor event and let the microtask drain. + superdoc.fireEditor('transaction'); + await flushMicrotasks(); + + // With the fix: 0 stale selectors fire. Without it: 100 would. + expect(selector).toHaveBeenCalledTimes(0); + }); + + it('an active subscriber holds the controller listener; it detaches only on the last unsubscribe', async () => { + const superdoc = makeSuperdocStub({ documentMode: 'editing' }); + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + + const selector = vi.fn((state) => state.documentMode); + const slice = ui.select(selector); + const off1 = slice.subscribe(() => {}); + const off2 = slice.subscribe(() => {}); + + selector.mockClear(); + superdoc.setDocumentMode('suggesting'); + superdoc.fireSuperdoc('document-mode-change'); + await flushMicrotasks(); + + // Both subscribers active: selector ran once for the event. + expect(selector).toHaveBeenCalledTimes(1); + + off1(); + + selector.mockClear(); + superdoc.setDocumentMode('viewing'); + superdoc.fireSuperdoc('document-mode-change'); + await flushMicrotasks(); + + // One subscriber still active: selector still runs. + expect(selector).toHaveBeenCalledTimes(1); + + off2(); + + selector.mockClear(); + superdoc.setDocumentMode('editing'); + superdoc.fireSuperdoc('document-mode-change'); + await flushMicrotasks(); + + // No subscribers: selector should not run. + expect(selector).toHaveBeenCalledTimes(0); + }); + + it('get() refreshes the snapshot when no subscribers are attached', () => { + const superdoc = makeSuperdocStub({ documentMode: 'editing' }); + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + + const slice = ui.select((state) => state.documentMode); + expect(slice.get()).toBe('editing'); + + // No subscribers — controller listener isn't running. get() must + // still return fresh state on the next call. + superdoc.setDocumentMode('suggesting'); + expect(slice.get()).toBe('suggesting'); + }); + + it('destroy detaches all source listeners', () => { + const superdoc = makeSuperdocStub(); + const ui = createSuperDocUI({ superdoc }); + + expect(superdoc.editorListenerCount('transaction')).toBeGreaterThan(0); + expect(superdoc.superdocListenerCount('document-mode-change')).toBeGreaterThan(0); + + ui.destroy(); + + expect(superdoc.editorListenerCount('transaction')).toBe(0); + expect(superdoc.editorListenerCount('selectionUpdate')).toBe(0); + expect(superdoc.editorListenerCount('commentsUpdate')).toBe(0); + expect(superdoc.superdocListenerCount('editorCreate')).toBe(0); + expect(superdoc.superdocListenerCount('document-mode-change')).toBe(0); + }); + + it('destroy stops further notifications even after a queued event', async () => { + const superdoc = makeSuperdocStub(); + const ui = createSuperDocUI({ superdoc }); + + const cb = vi.fn(); + ui.select((state) => state.documentMode).subscribe(cb); + expect(cb).toHaveBeenCalledTimes(1); + + // Queue a microtask, then destroy before it runs + superdoc.setDocumentMode('viewing'); + superdoc.fireSuperdoc('document-mode-change'); + ui.destroy(); + + await flushMicrotasks(); + + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('re-attaches editor listeners on editorCreate when the activeEditor swaps', async () => { + const superdoc = makeSuperdocStub(); + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + + const cb = vi.fn(); + ui.select((state) => state.selection.empty).subscribe(cb); + + // Swap to a new editor; old listeners should be torn down, new ones attached + const oldEditorTransactionCount = superdoc.editorListenerCount('transaction'); + expect(oldEditorTransactionCount).toBeGreaterThan(0); + + const newEditor = { + on: vi.fn(), + off: vi.fn(), + doc: { + selection: { + current: vi.fn(() => ({ empty: false, text: 'new', target: null })), + }, + }, + }; + superdoc.swapEditor(newEditor as never); + superdoc.fireSuperdoc('editorCreate'); + await flushMicrotasks(); + + // The new editor should have received .on() calls for the same events + expect(newEditor.on).toHaveBeenCalled(); + // And the slice should reflect the new editor's selection + expect(cb).toHaveBeenLastCalledWith(false); + }); + + it('routes selection through PresentationEditor.getActiveEditor() when active', async () => { + // Body editor with one selection; routed (header) editor with another. + const bodyListeners = new Map void>>(); + const bodyEditor = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (!bodyListeners.has(event)) bodyListeners.set(event, new Set()); + bodyListeners.get(event)!.add(handler); + }), + off: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + bodyListeners.get(event)?.delete(handler); + }), + state: { selection: { empty: true } }, + options: { documentId: 'doc-1', isHeaderOrFooter: false }, + isEditable: true, + doc: { selection: { current: vi.fn(() => ({ empty: true, text: '', target: null })) } }, + }; + + const headerListeners = new Map void>>(); + const headerEditor = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (!headerListeners.has(event)) headerListeners.set(event, new Set()); + headerListeners.get(event)!.add(handler); + }), + off: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + headerListeners.get(event)?.delete(handler); + }), + state: { selection: { empty: false } }, + options: { documentId: 'doc-1', isHeaderOrFooter: true, headerFooterType: 'header' }, + isEditable: true, + doc: { selection: { current: vi.fn(() => ({ empty: false, text: 'header text', target: null })) } }, + }; + + const presentationListeners = new Map void>>(); + const presentationEditor: Record = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (!presentationListeners.has(event)) presentationListeners.set(event, new Set()); + presentationListeners.get(event)!.add(handler); + }), + off: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + presentationListeners.get(event)?.delete(handler); + }), + isEditable: true, + state: { selection: { empty: false } }, + // Routed-editor pointer; the test flips this on activeSurfaceChange. + getActiveEditor: vi.fn(() => bodyEditor), + commands: {}, + }; + + // Stamp the presentation editor onto the body editor so + // resolveToolbarSources picks it up via the direct-owner path. + (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 cb = vi.fn(); + ui.select((state) => state.selection.quotedText).subscribe(cb); + + // Initial selection comes from the routed (body) editor. + expect(cb).toHaveBeenLastCalledWith(''); + + // Route to the header editor and fire activeSurfaceChange. + presentationEditor.getActiveEditor = vi.fn(() => headerEditor); + const surfaceChangeHandlers = presentationListeners.get('activeSurfaceChange'); + expect(surfaceChangeHandlers && surfaceChangeHandlers.size).toBeGreaterThan(0); + [...(surfaceChangeHandlers ?? [])].forEach((h) => h()); + await flushMicrotasks(); + + // Selection now reflects the header editor's selection. + expect(cb).toHaveBeenLastCalledWith('header text'); + + // The header editor should have received .on() registrations + // (transaction / selectionUpdate / etc.) when the controller + // re-routed. + expect(headerEditor.on).toHaveBeenCalled(); + }); + + it('state.selection mirrors full SelectionInfo (target, activeMarks, activeCommentIds, activeChangeIds, quotedText)', () => { + const superdoc = makeSuperdocStub(); + // Replace the default selection.current stub with one that returns + // the full SelectionInfo shape. + const target = { + kind: 'text' as const, + segments: [{ blockId: 'p1', range: { start: 0, end: 5 } }], + }; + (superdoc.activeEditor as { doc: { selection: { current: unknown } } }).doc.selection.current = vi.fn(() => ({ + empty: false, + text: 'Hello', + target, + activeMarks: ['bold', 'italic'], + activeCommentIds: ['c1'], + activeChangeIds: ['tc1'], + })); + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + + const slice = ui.select((state) => state.selection).get(); + expect(slice).toEqual({ + empty: false, + target, + activeMarks: ['bold', 'italic'], + activeCommentIds: ['c1'], + activeChangeIds: ['tc1'], + quotedText: 'Hello', + }); + }); + + it('state.selection slice keeps identity stable across recomputes when the projection has not changed', async () => { + const superdoc = makeSuperdocStub(); + const target = { + kind: 'text' as const, + segments: [{ blockId: 'p1', range: { start: 0, end: 5 } }], + }; + // Each call to selection.current returns FRESH arrays (mirrors the + // resolver behavior — `activeMarks`/`activeCommentIds`/`activeChangeIds` + // are produced per call, not memoized at the resolver level). + (superdoc.activeEditor as { doc: { selection: { current: unknown } } }).doc.selection.current = vi.fn(() => ({ + empty: false, + text: 'Hello', + target, + activeMarks: ['bold'], + activeCommentIds: ['c1'], + activeChangeIds: [], + })); + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + + const cb = vi.fn(); + ui.select((state) => state.selection, shallowEqual).subscribe(cb); + expect(cb).toHaveBeenCalledTimes(1); // initial + + // Fire two transactions that don't change the projection. Without + // slice-level memoization, shallowEqual on the slice would flip on + // every call because the inner arrays are fresh each time. + superdoc.fireEditor('transaction'); + await flushMicrotasks(); + superdoc.fireEditor('transaction'); + await flushMicrotasks(); + + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('state.selection slice changes identity when activeMarks change (typing into bold)', async () => { + const superdoc = makeSuperdocStub(); + let activeMarks: string[] = []; + (superdoc.activeEditor as { doc: { selection: { current: unknown } } }).doc.selection.current = vi.fn(() => ({ + empty: true, + text: '', + target: null, + activeMarks, + activeCommentIds: [], + activeChangeIds: [], + })); + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + + const cb = vi.fn(); + ui.select((state) => state.selection, shallowEqual).subscribe(cb); + expect(cb).toHaveBeenCalledTimes(1); + + activeMarks = ['bold']; + superdoc.fireEditor('selectionUpdate'); + await flushMicrotasks(); + + expect(cb).toHaveBeenCalledTimes(2); + const latestSlice = cb.mock.calls[1][0] as { activeMarks: string[] }; + expect(latestSlice.activeMarks).toEqual(['bold']); + }); + + it('state.selection falls back to safe defaults when selection.current is missing fields (legacy resolver)', () => { + const superdoc = makeSuperdocStub(); + // Legacy / partial resolver: only `empty` + `text` fields present. + (superdoc.activeEditor as { doc: { selection: { current: unknown } } }).doc.selection.current = vi.fn(() => ({ + empty: true, + text: '', + })); + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + + const slice = ui.select((state) => state.selection).get(); + expect(slice).toEqual({ + empty: true, + target: null, + activeMarks: [], + activeCommentIds: [], + activeChangeIds: [], + quotedText: '', + }); + }); + + it('ui.selection.getSnapshot returns the current slice synchronously', () => { + const superdoc = makeSuperdocStub(); + const target = { + kind: 'text' as const, + segments: [{ blockId: 'p1', range: { start: 0, end: 3 } }], + }; + (superdoc.activeEditor as { doc: { selection: { current: unknown } } }).doc.selection.current = vi.fn(() => ({ + empty: false, + text: 'foo', + target, + activeMarks: ['bold'], + activeCommentIds: ['c1'], + activeChangeIds: [], + })); + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + + const snap = ui.selection.getSnapshot(); + expect(snap).toEqual({ + empty: false, + target, + activeMarks: ['bold'], + activeCommentIds: ['c1'], + activeChangeIds: [], + quotedText: 'foo', + }); + }); + + it('ui.selection.subscribe fires once with the initial snapshot then on changes', async () => { + const superdoc = makeSuperdocStub(); + let activeMarks: string[] = []; + (superdoc.activeEditor as { doc: { selection: { current: unknown } } }).doc.selection.current = vi.fn(() => ({ + empty: true, + text: '', + target: null, + activeMarks, + activeCommentIds: [], + activeChangeIds: [], + })); + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + + const cb = vi.fn(); + const off = ui.selection.subscribe(cb); + expect(cb).toHaveBeenCalledTimes(1); // initial snapshot + + // No-op transaction: same projection, listener stays at one call. + superdoc.fireEditor('selectionUpdate'); + await flushMicrotasks(); + expect(cb).toHaveBeenCalledTimes(1); + + // Real change: caret enters bold → listener fires. + activeMarks = ['bold']; + superdoc.fireEditor('selectionUpdate'); + await flushMicrotasks(); + expect(cb).toHaveBeenCalledTimes(2); + const arg = cb.mock.calls[1][0] as { snapshot: { activeMarks: string[] } }; + expect(arg.snapshot.activeMarks).toEqual(['bold']); + + off(); + }); + + it('listener errors do not propagate to the editor or other subscribers', async () => { + const superdoc = makeSuperdocStub(); + const ui = createSuperDocUI({ superdoc }); + teardown.push(() => ui.destroy()); + + const slice = ui.select((state) => state.documentMode); + const buggy = vi.fn(() => { + throw new Error('listener boom'); + }); + const ok = vi.fn(); + slice.subscribe(buggy); + slice.subscribe(ok); + + // Initial subscribe already invoked both; the error must not have + // propagated out of subscribe() + expect(buggy).toHaveBeenCalledTimes(1); + expect(ok).toHaveBeenCalledTimes(1); + + superdoc.setDocumentMode('viewing'); + superdoc.fireSuperdoc('document-mode-change'); + await flushMicrotasks(); + + expect(buggy).toHaveBeenCalledTimes(2); + expect(ok).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/super-editor/src/ui/create-super-doc-ui.ts b/packages/super-editor/src/ui/create-super-doc-ui.ts new file mode 100644 index 0000000000..d3c0e623b2 --- /dev/null +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -0,0 +1,1155 @@ +import { createHeadlessToolbar } from '../headless-toolbar/index.js'; +import { resolveToolbarSources } from '../headless-toolbar/resolve-toolbar-sources.js'; +import { createToolbarRegistry } from '../headless-toolbar/toolbar-registry.js'; +import type { + HeadlessToolbarController, + HeadlessToolbarSuperdocHost, + PublicToolbarItemId, + ToolbarSnapshot, +} from '../headless-toolbar/types.js'; +import type { + CommentsListResult, + Receipt, + ScrollIntoViewInput, + ScrollIntoViewOutput, + TrackChangesListResult, +} from '@superdoc/document-api'; +import { shallowEqual } from './equality.js'; +import { scrollRangeIntoView } from './scroll-into-view.js'; +import type { + CommandHandle, + CommandsHandle, + CommentsHandle, + EqualityFn, + ReviewHandle, + ReviewItem, + ReviewSlice, + SelectionHandle, + SelectionSlice, + SelectorFn, + SuperDocEditorLike, + SuperDocUI, + SuperDocUIOptions, + SuperDocUIState, + Subscribable, + ToolbarCommandHandleState, + ToolbarHandle, + ViewportGetRectInput, + ViewportHandle, + ViewportRect, + ViewportRectResult, +} from './types.js'; + +/** + * Source events the controller listens to today. Domain tickets may + * widen this list as they land — the only invariant is that every + * event listed here triggers at most one snapshot rebuild per + * microtask via {@link scheduleNotify}. + * + * Multiple internal event names exist for the same domain (e.g. + * `commentsUpdate`, `commentsLoaded`, `comment-positions`); the + * controller normalizes them all into a single state-change signal so + * consumers never see editor-internal vocabulary. + */ +const EDITOR_EVENTS = [ + 'transaction', + 'selectionUpdate', + 'commentsUpdate', + 'commentsLoaded', + 'comment-positions', + 'tracked-changes-changed', +] as const; + +/** + * Editor events that should trigger a refresh of the cached + * `comments.list()` / `trackChanges.list()` results before notifying + * subscribers. The base `EDITOR_EVENTS` list also fires + * `scheduleNotify` for these, but we need the cache invalidation to + * happen *first* so `computeState()` sees fresh items. + * + * `tracked-changes-changed` is the canonical broadcast emitted by the + * tracked-change index whenever a transaction adds, removes, or + * invalidates tracked changes (including remote / collaborator-driven + * mutations). Without it, the cache only refreshes when the + * controller's own action methods call `refreshAndNotify`, leaving + * `ui.review` subscribers stale after normal editing. + */ +const LIST_REFRESH_EVENTS = ['commentsUpdate', 'commentsLoaded', 'tracked-changes-changed'] as const; + +const SUPERDOC_EVENTS = ['editorCreate', 'document-mode-change', 'zoomChange'] as const; + +/** + * Presentation-editor events the controller listens to. These signal + * routing changes (the user moved focus into a header/footer/note) and + * presentation-layer mutations that don't surface as `transaction` on + * the body editor. Mirrors the `subscribe-toolbar-events` set so the + * toolbar registry's snapshot rebuilds and the unified UI state + * recompute on the same triggers. + */ +const PRESENTATION_EVENTS = [ + 'headerFooterEditingContext', + 'headerFooterUpdate', + 'headerFooterTransaction', + 'activeSurfaceChange', + 'historyStateChange', +] as const; + +/** Default state for an unknown / missing toolbar command. */ +const FALLBACK_COMMAND_STATE: ToolbarCommandHandleState = { + active: false, + disabled: true, + value: undefined, +}; + +/** + * Full set of registered toolbar command ids, used to seed the + * internal `createHeadlessToolbar` call. Without this the controller + * defaults to `commands = []`, leaving `snapshot.commands` empty and + * every per-command observer (`ui.commands.bold.observe`) reporting + * the fallback `{ active: false, disabled: true }` forever. + * + * Computed once at module load by walking the registry returned from + * `createToolbarRegistry()`. Future custom-command registration + * (FRICTION S3) will need to extend this dynamically. + */ +const ALL_TOOLBAR_COMMAND_IDS: PublicToolbarItemId[] = Object.keys(createToolbarRegistry()) as PublicToolbarItemId[]; + +/** + * Frozen empty-array sentinel for `state.comments.activeIds` when + * `selection.current()` predates SD-2792 (no `activeCommentIds` + * field). Allocating a fresh `[]` per `computeState()` would change + * the array reference every call and defeat `shallowEqual` on the + * comments snapshot — every selection event would re-fire + * `ui.comments.subscribe` even when nothing in the slice changed. + */ +const EMPTY_ACTIVE_IDS: readonly string[] = Object.freeze([]); + +/** + * Resolve the **routed** editor — the body, header, footer, or note + * editor that PresentationEditor currently routes input/selection to. + * Falls back to `superdoc.activeEditor` when no presentation layer is + * active (e.g., simple non-paginated mounts, server-side stubs in + * tests). + * + * Reusing `resolveToolbarSources` keeps routing logic in one place; + * the toolbar registry and the UI controller agree on which editor + * owns the current selection at any moment. + */ +function resolveRoutedEditor(superdoc: SuperDocUIOptions['superdoc']): SuperDocEditorLike | null { + try { + const sources = resolveToolbarSources(superdoc as never); + return (sources.activeEditor as unknown as SuperDocEditorLike | null) ?? null; + } catch { + return (superdoc.activeEditor ?? null) as SuperDocEditorLike | null; + } +} + +/** + * Resolve the **host** (body) editor — the one that owns the document + * scope. Always `superdoc.activeEditor`, never the routed + * header/footer/note story editor. + * + * Document-wide operations (`trackChanges.decide`, + * `presentation.navigateTo`, `presentation.scrollToPositionAsync`) + * must run against the host so the adapter treats the body as the + * scope and routes to the right story via the target's `story` + * field. Calling these on a child story editor (when focus is in a + * header/footer) would scope the decision/scroll to that story + * instead of the document. + */ +function resolveHostEditor(superdoc: SuperDocUIOptions['superdoc']): SuperDocEditorLike | null { + return (superdoc.activeEditor ?? null) as SuperDocEditorLike | null; +} + +/** + * Resolve the PresentationEditor (when one exists), so we can + * subscribe to its events and re-route the active editor on surface + * changes. + */ +function resolvePresentationEditor(superdoc: SuperDocUIOptions['superdoc']): { + on?: (event: string, handler: (...args: unknown[]) => void) => unknown; + off?: (event: string, handler: (...args: unknown[]) => void) => unknown; +} | null { + try { + const sources = resolveToolbarSources(superdoc as never); + return (sources.presentationEditor as never) ?? null; + } catch { + return null; + } +} + +export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { + const { superdoc } = options; + + let destroyed = false; + const stateChangeListeners = new Set<() => void>(); + const teardown: Array<() => void> = []; + + let scheduled = false; + const scheduleNotify = () => { + if (scheduled || destroyed) return; + scheduled = true; + queueMicrotask(() => { + scheduled = false; + if (destroyed) return; + stateChangeListeners.forEach((listener) => { + try { + listener(); + } catch { + // Subscriber errors do not propagate — one buggy listener + // must not wedge the editor's event loop or block other + // listeners. Same posture as the in-flight onChange + // helpers in plan-engine wrappers. + } + }); + }); + }; + + // Internal headless-toolbar instance. Feeds `state.toolbar` so + // `ui.toolbar.subscribe` and `ui.commands..observe` ride the + // same selector substrate as the rest of the controller. Per-command + // state derivers in the registry are wrapped to default to disabled + // on throw, so a partial editor never wedges snapshot construction. + const toolbarController: HeadlessToolbarController = createHeadlessToolbar({ + superdoc: superdoc as unknown as HeadlessToolbarSuperdocHost, + // Pass the full registry so snapshot.commands is populated for + // every built-in command — without this `ui.commands..observe` + // emits only the fallback disabled state. + commands: ALL_TOOLBAR_COMMAND_IDS, + }); + let toolbarSnapshot: ToolbarSnapshot = toolbarController.getSnapshot(); + const offToolbarSubscribe = toolbarController.subscribe(({ snapshot }) => { + toolbarSnapshot = snapshot; + scheduleNotify(); + }); + teardown.push(() => { + offToolbarSubscribe(); + try { + toolbarController.destroy(); + } catch { + // best-effort + } + }); + + // Comments slice cache. `editor.doc.comments.list()` is O(N) and + // re-running it on every `computeState()` would tax the hot path — + // instead we cache the list result and refresh on `commentsUpdate` / + // `commentsLoaded` editor events. `selection.current().activeCommentIds` + // is read fresh in `computeState()` since it's already cheap (one + // selection walk). + const EMPTY_COMMENTS_LIST: CommentsListResult = { + evaluatedRevision: '', + total: 0, + items: [], + page: { limit: 0, offset: 0, returned: 0 }, + }; + let commentsListCache: CommentsListResult = EMPTY_COMMENTS_LIST; + const refreshCommentsListCache = () => { + const editor = resolveRoutedEditor(superdoc); + const list = editor?.doc?.comments?.list; + if (typeof list !== 'function') { + commentsListCache = EMPTY_COMMENTS_LIST; + return; + } + try { + const result = list.call(editor.doc!.comments, undefined) as CommentsListResult | undefined; + commentsListCache = result ?? EMPTY_COMMENTS_LIST; + } catch { + // Reset to empty rather than retaining the previous editor's + // cache. During document / editor swaps the new editor can + // throw transiently while initializing — keeping the prior + // value would leak the old document's comments into the new + // one's snapshot until the next successful refresh, which is a + // worse failure mode than briefly rendering an empty list. + commentsListCache = EMPTY_COMMENTS_LIST; + } + }; + refreshCommentsListCache(); + + // Tracked-changes cache. Same posture as comments — refresh on + // commentsUpdate / trackedChangesUpdate (track-changes events ride + // commentsUpdate today; the controller normalizes that for callers). + // `in: 'all'` is requested so non-body stories (header, footer, + // footnote, endnote) are included in the merged review feed. + const EMPTY_TRACK_CHANGES_LIST: TrackChangesListResult = { + evaluatedRevision: '', + total: 0, + items: [], + page: { limit: 0, offset: 0, returned: 0 }, + }; + let trackChangesListCache: TrackChangesListResult = EMPTY_TRACK_CHANGES_LIST; + const refreshTrackChangesListCache = () => { + const editor = resolveRoutedEditor(superdoc); + const list = editor?.doc?.trackChanges?.list; + if (typeof list !== 'function') { + trackChangesListCache = EMPTY_TRACK_CHANGES_LIST; + return; + } + try { + const result = list.call(editor.doc!.trackChanges, { in: 'all' }) as TrackChangesListResult | undefined; + trackChangesListCache = result ?? EMPTY_TRACK_CHANGES_LIST; + } catch { + // See refreshCommentsListCache rationale: cross-document leakage + // would be worse than briefly empty. + trackChangesListCache = EMPTY_TRACK_CHANGES_LIST; + } + }; + refreshTrackChangesListCache(); + + /** + * Internal `activeReviewId`. Mirrors selection-driven activity when + * the user moves the cursor to a different review item, and is + * updated by explicit `ui.review.next/previous/scrollTo` calls. + * Tracked separately from `lastSelectionDrivenId` so explicit + * navigation away from a still-selected item isn't immediately + * overwritten by the next computeState() call. + */ + let activeReviewId: string | null = null; + /** + * The selection-driven id observed during the last `computeState`. + * Only when this changes between calls does the controller mirror + * it onto `activeReviewId`; otherwise the user's `next() / + * previous() / scrollTo()` choice persists across recomputes. + */ + let lastSelectionDrivenId: string | null = null; + + /** + * Memoized review slice. The merged-feed array is rebuilt only when + * one of its inputs changes — comments items reference, tracked- + * changes items reference, or `activeReviewId`. Without this, + * shallowEqual on `state.review` would mismatch every keystroke + * because we'd allocate a fresh items array per computeState. + */ + let reviewMemo: { + commentsRef: CommentsListResult['items'] | null; + changesRef: TrackChangesListResult['items'] | null; + activeId: string | null; + slice: ReviewSlice; + } | null = null; + + /** + * Memoized selection slice. Slice identity is stable when the + * derived shape — empty, target (deep), activeMarks, activeCommentIds, + * activeChangeIds, quotedText — has not changed since the last + * computeState. Without this, a typing-only transaction (which leaves + * the projected SelectionInfo unchanged but allocates fresh arrays + * inside the resolver) would re-fire every `ui.select(s => s.selection)` + * subscriber per keystroke. + */ + let selectionMemo: { key: string; slice: SelectionSlice } | null = null; + + /** + * Stable string key over a SelectionInfo for slice memoization. Two + * infos producing the same key represent the same observable + * selection state, so the slice can be reused. + */ + const buildSelectionKey = ( + empty: boolean, + target: import('@superdoc/document-api').TextTarget | null, + activeMarks: string[], + activeCommentIds: string[], + activeChangeIds: string[], + quotedText: string, + ): string => { + const targetKey = target + ? target.segments.map((s) => `${s.blockId}:${s.range.start}-${s.range.end}`).join('|') + : 'null'; + const marks = [...activeMarks].sort().join(','); + const comments = [...activeCommentIds].sort().join(','); + const changes = [...activeChangeIds].sort().join(','); + return `${empty ? '1' : '0'}:${targetKey}:m=${marks}:c=${comments}:tc=${changes}:t=${quotedText}`; + }; + + const computeState = (): SuperDocUIState => { + // Route through PresentationEditor when active so selection state + // follows the body/header/footer/note editor the user is actually + // editing — `superdoc.activeEditor` stays on the body editor while + // `PresentationEditor.getActiveEditor()` follows the routed story. + const editor = resolveRoutedEditor(superdoc); + const ready = editor != null; + const selectionInfo = editor?.doc?.selection?.current?.({ includeText: true }); + const empty = selectionInfo ? selectionInfo.empty : true; + const quotedText = selectionInfo?.text ?? ''; + const documentMode = superdoc.config?.documentMode ?? null; + // `activeCommentIds` is post-SD-2792; older builds will have + // `selectionInfo.activeCommentIds === undefined`. Fall back to a + // frozen shared array so the array reference is stable across + // computeState() calls (otherwise shallowEqual on the comments + // snapshot re-fires every selection event). + const activeIds = (selectionInfo?.activeCommentIds ?? EMPTY_ACTIVE_IDS) as string[]; + const activeChangeIdsFromSelection = (selectionInfo?.activeChangeIds ?? EMPTY_ACTIVE_IDS) as string[]; + + // Reconcile activeReviewId. Mirror selection only when the + // *selection-driven* id has changed since the last computeState — + // otherwise an explicit next/previous/scrollTo is preserved across + // subsequent recomputes (the cursor hasn't moved). Sync logic: + // - selection moved to a non-null entity id → mirror it + // - selection moved to no entity (caret elsewhere) → keep + // activeReviewId so navigation persists, but clear it if the + // underlying item dropped out of the feed + const selectionDrivenActiveId = activeIds[0] ?? activeChangeIdsFromSelection[0] ?? null; + const selectionMoved = selectionDrivenActiveId !== lastSelectionDrivenId; + lastSelectionDrivenId = selectionDrivenActiveId; + if (selectionMoved && selectionDrivenActiveId) { + activeReviewId = selectionDrivenActiveId; + } + + // Build (or reuse) the merged review feed. Memo invalidates only + // when source caches or activeReviewId change, so unrelated + // transactions / selection events don't allocate a fresh items + // array and re-fire ui.review subscribers. + let reviewSlice: ReviewSlice; + if ( + reviewMemo && + reviewMemo.commentsRef === commentsListCache.items && + reviewMemo.changesRef === trackChangesListCache.items && + reviewMemo.activeId === activeReviewId + ) { + reviewSlice = reviewMemo.slice; + } else { + const items: ReviewItem[] = []; + let order = 0; + for (const comment of commentsListCache.items) { + // `comments.list()` returns `DiscoveryItem` whose + // canonical identifier lives on `id` (set from the underlying + // commentId by the adapter). The legacy `commentId` field is + // only on `CommentInfo` / `comments.get()` — not on this + // discovery shape. Reading it would emit `undefined` and break + // active-id matching + next/previous/scrollTo. + items.push({ kind: 'comment', id: comment.id, documentOrder: order++, comment }); + } + for (const change of trackChangesListCache.items) { + items.push({ kind: 'change', id: change.id, documentOrder: order++, change }); + } + let openCount = trackChangesListCache.total; + for (const c of commentsListCache.items) { + if (c.status !== 'resolved') openCount += 1; + } + // If the previously active id dropped out of the feed (e.g. an + // accept/delete/reject), reset to null. Compute *after* items is + // built so the final slice matches the eventual activeReviewId. + if (activeReviewId && !items.some((item) => item.id === activeReviewId)) { + activeReviewId = null; + } + reviewSlice = { items, openCount, activeId: activeReviewId }; + reviewMemo = { + commentsRef: commentsListCache.items, + changesRef: trackChangesListCache.items, + activeId: activeReviewId, + slice: reviewSlice, + }; + } + + // Build (or reuse) the rich selection slice. Memo key folds in + // every observable field so a typing-only transaction (which leaves + // the projected SelectionInfo unchanged but allocates fresh arrays + // inside the resolver) keeps the slice identity stable and lets + // `shallowEqual` short-circuit `ui.select(s => s.selection)` + // subscribers. + const selectionTarget = (selectionInfo?.target ?? null) as import('@superdoc/document-api').TextTarget | null; + const selectionActiveMarks = (selectionInfo?.activeMarks ?? EMPTY_ACTIVE_IDS) as string[]; + const selectionKey = buildSelectionKey( + empty, + selectionTarget, + selectionActiveMarks, + activeIds, + activeChangeIdsFromSelection, + quotedText, + ); + let selectionSlice: SelectionSlice; + if (selectionMemo && selectionMemo.key === selectionKey) { + selectionSlice = selectionMemo.slice; + } else { + selectionSlice = { + empty, + target: selectionTarget, + activeMarks: selectionActiveMarks, + activeCommentIds: activeIds, + activeChangeIds: activeChangeIdsFromSelection, + quotedText, + }; + selectionMemo = { key: selectionKey, slice: selectionSlice }; + } + + return { + ready, + documentMode, + selection: selectionSlice, + toolbar: toolbarSnapshot, + comments: { + total: commentsListCache.total, + items: commentsListCache.items, + // Plumb from the memoized selection slice so the array + // reference stays stable across recomputes when the active + // set hasn't changed. The resolver returns a fresh `[]` (or + // a fresh non-empty array) every call; without this the + // `shallowEqual` check on `state.comments` would mismatch + // every transaction / selectionUpdate even when nothing in + // the comments slice actually changed, re-firing every + // `ui.comments.subscribe` listener on the editing hot path. + activeIds: selectionSlice.activeCommentIds, + }, + review: reviewSlice, + }; + }; + + // Wire SuperDoc-instance events. The wrapper-side bus (editorCreate / + // document-mode-change / zoomChange) is the only path for some of + // these signals today; if the wrapper migrates them to the editor + // later, this is the single seam that needs to move. + if (typeof superdoc.on === 'function' && typeof superdoc.off === 'function') { + SUPERDOC_EVENTS.forEach((name) => { + superdoc.on?.(name, scheduleNotify); + }); + teardown.push(() => { + SUPERDOC_EVENTS.forEach((name) => superdoc.off?.(name, scheduleNotify)); + }); + } + + // Editor events: the routed editor swaps when the user moves between + // body / header / footer / note surfaces (PresentationEditor + // `activeSurfaceChange`), or when the active document changes + // (`editorCreate`). Re-attach listeners on either signal. + let currentEditor: SuperDocEditorLike | null = null; + let currentEditorTeardown: (() => void) | null = null; + + const refreshAndNotify = () => { + refreshCommentsListCache(); + refreshTrackChangesListCache(); + scheduleNotify(); + }; + + const attachEditorListeners = () => { + const next = resolveRoutedEditor(superdoc); + if (next === currentEditor) return; + currentEditorTeardown?.(); + currentEditorTeardown = null; + currentEditor = next; + if (!next || typeof next.on !== 'function' || typeof next.off !== 'function') return; + + EDITOR_EVENTS.forEach((name) => { + next.on?.(name, scheduleNotify); + }); + // Comment-list invalidation runs ahead of scheduleNotify so the + // subsequent state recompute sees the fresh items array. Without + // this, `state.comments.items` would lag one tick behind a create/ + // patch/delete. + LIST_REFRESH_EVENTS.forEach((name) => { + next.on?.(name, refreshAndNotify); + }); + currentEditorTeardown = () => { + EDITOR_EVENTS.forEach((name) => next.off?.(name, scheduleNotify)); + LIST_REFRESH_EVENTS.forEach((name) => next.off?.(name, refreshAndNotify)); + }; + // The set of source events changed and the routed editor swapped + // — refresh the comments cache for the new editor and recompute + // state so subscribers see the new selection. + refreshCommentsListCache(); + scheduleNotify(); + }; + + // PresentationEditor events: surface changes route the editor; other + // events surface presentation-layer mutations that don't reach the + // body editor's `transaction` event. Track presentation editor by + // identity so we re-attach if the SuperDoc instance swaps documents. + let currentPresentation: ReturnType = null; + let currentPresentationTeardown: (() => void) | null = null; + + const attachPresentationListeners = () => { + const next = resolvePresentationEditor(superdoc); + if (next === currentPresentation) return; + currentPresentationTeardown?.(); + currentPresentationTeardown = null; + currentPresentation = next; + if (!next || typeof next.on !== 'function' || typeof next.off !== 'function') return; + + const onPresentationChange = () => { + // Re-route to the (possibly new) active surface, then notify. + attachEditorListeners(); + scheduleNotify(); + }; + + PRESENTATION_EVENTS.forEach((name) => { + next.on?.(name, onPresentationChange); + }); + currentPresentationTeardown = () => { + PRESENTATION_EVENTS.forEach((name) => next.off?.(name, onPresentationChange)); + }; + }; + + attachPresentationListeners(); + attachEditorListeners(); + if (typeof superdoc.on === 'function') { + // editorCreate may bring a new PresentationEditor with a new active + // surface. Re-attach both layers so the controller follows. + superdoc.on?.('editorCreate', attachPresentationListeners); + superdoc.on?.('editorCreate', attachEditorListeners); + } + teardown.push(() => { + if (typeof superdoc.off === 'function') { + superdoc.off?.('editorCreate', attachPresentationListeners); + superdoc.off?.('editorCreate', attachEditorListeners); + } + currentPresentationTeardown?.(); + currentPresentationTeardown = null; + currentPresentation = null; + currentEditorTeardown?.(); + currentEditorTeardown = null; + currentEditor = null; + }); + + const select = ( + selector: SelectorFn, + equality: EqualityFn = Object.is, + ): Subscribable => { + let last = selector(computeState()); + const listeners = new Set<(value: TSlice) => void>(); + + const onStateChange = () => { + const next = selector(computeState()); + if (equality(last, next)) return; + last = next; + listeners.forEach((listener) => { + try { + listener(next); + } catch { + // see scheduleNotify + } + }); + }; + + // Refcount the controller-level listener: attach on first + // subscriber, detach when the last subscriber leaves. Without this + // each `ui.select(...)` would leak an `onStateChange` closure into + // `stateChangeListeners` for the lifetime of the controller — + // long-lived sessions where React/Vue components mount/unmount + // would accumulate dead closures that still recompute on every + // editor event. + return { + get(): TSlice { + // No subscribers means `last` isn't being kept fresh by + // `onStateChange`. Recompute so untracked snapshots stay + // accurate; tracked snapshots return the cached value. + if (listeners.size === 0) { + last = selector(computeState()); + } + return last; + }, + subscribe(listener) { + if (listeners.size === 0) { + // First subscriber: refresh `last` so the initial emit is + // not stale (state may have evolved between `select()` and + // `subscribe()`), then attach the controller-level listener. + last = selector(computeState()); + stateChangeListeners.add(onStateChange); + } + listeners.add(listener); + // Initial synchronous emit, matching CKEditor's `bind().to()` + // behavior and useSyncExternalStore semantics. New subscribers + // get the current value immediately rather than waiting for + // the next change. + try { + listener(last); + } catch { + // see scheduleNotify + } + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + stateChangeListeners.delete(onStateChange); + } + }; + }, + }; + }; + + // Aggregate toolbar handle. Mirrors HeadlessToolbarController so + // built-in SuperToolbar.vue (and external standalone-controller + // consumers) can swap to ui.toolbar without API churn. + const toolbar: ToolbarHandle = { + getSnapshot: () => toolbarController.getSnapshot(), + subscribe(listener) { + // Drives off the same selector substrate so subscribers receive + // the same coalesced burst pattern as ui.select consumers. + // Equality is set to "always different" because the headless + // controller already dedups internally; we want every emit it + // produces to propagate. + return select( + (state) => state.toolbar, + () => false, + ).subscribe((snapshot) => { + try { + listener({ snapshot }); + } catch { + // see scheduleNotify + } + }); + }, + execute: ((id: PublicToolbarItemId, payload?: unknown): boolean => { + // The controller's execute signature is conditionally typed + // (variadic per-id payload); cast here keeps the consumer-facing + // type strict while delegating at runtime. + return (toolbarController.execute as (id: PublicToolbarItemId, payload?: unknown) => boolean)(id, payload); + }) as ToolbarHandle['execute'], + }; + + // Per-command handles. Cached so handle identity is stable across + // repeated accesses (matters for React `useMemo` deps and consumers + // comparing handles). + const commandHandleCache = new Map>(); + + // Per-command Subscribable cache. Sharing one Subscribable across + // every `observe()` call for a given id means N components observing + // `bold` produce one selector + N downstream listeners, not N + // selectors. Each editor event recomputes once per command id, not + // once per active observer. + const commandSubscribableCache = new Map< + string, + Subscribable | undefined> + >(); + const getCommandSubscribable = (id: PublicToolbarItemId) => { + let sub = commandSubscribableCache.get(id); + if (sub) return sub; + sub = select( + (state) => state.toolbar.commands?.[id] as ToolbarCommandHandleState | undefined, + shallowEqual, + ); + commandSubscribableCache.set(id, sub); + return sub; + }; + + const buildCommandHandle = (id: PublicToolbarItemId): CommandHandle => { + return { + observe(listener) { + return getCommandSubscribable(id).subscribe((cmdState) => { + const next = cmdState ?? FALLBACK_COMMAND_STATE; + try { + listener(next as ToolbarCommandHandleState); + } catch { + // see scheduleNotify + } + }); + }, + execute: ((payload?: unknown): boolean => { + return (toolbarController.execute as (id: PublicToolbarItemId, payload?: unknown) => boolean)(id, payload); + }) as CommandHandle['execute'], + }; + }; + + const commands = new Proxy({} as CommandsHandle, { + get(_, prop) { + if (typeof prop !== 'string') return undefined; + let handle = commandHandleCache.get(prop); + if (handle) return handle; + handle = buildCommandHandle(prop as PublicToolbarItemId); + commandHandleCache.set(prop, handle); + return handle; + }, + }); + + // ---- ui.comments --------------------------------------------------------- + // + // Subscribe is built on the substrate so consumers ride the same + // microtask-coalesced burst pattern as `ui.select`. Action methods + // are convenience facades that route through `editor.doc.comments.*` + // — they do NOT introduce a parallel mutation contract; both + // `ui.comments.resolve(id)` and `editor.doc.comments.patch({ id, + // status: 'resolved' })` produce the same document mutation. + + const requireDocComments = () => { + const editor = resolveRoutedEditor(superdoc); + const api = editor?.doc?.comments; + if (!api) { + throw new Error('ui.comments: no active editor / comments API. Open a document first.'); + } + return api; + }; + + /** + * Run `scrollRangeIntoView` against the host editor — the + * presentation editor lives at the host level and its + * `navigateTo` is story-aware (the entity target's `story` field + * tells it which story to activate). Routing through a child story + * editor would scope navigation to that story instead of the + * document. + * + * Returns `{ success: false }` when no host editor is mounted. + */ + const runScrollIntoView = async (input: ScrollIntoViewInput): Promise => { + const editor = resolveHostEditor(superdoc); + if (!editor) return { success: false }; + return scrollRangeIntoView(editor as unknown as Parameters[0], input); + }; + + const comments: CommentsHandle = { + getSnapshot: () => computeState().comments, + subscribe(listener) { + return select((state) => state.comments, shallowEqual).subscribe((snapshot) => { + try { + listener({ snapshot }); + } catch { + // see scheduleNotify + } + }); + }, + createFromSelection({ text }) { + const editor = resolveRoutedEditor(superdoc); + const target = editor?.doc?.selection?.current?.()?.target; + if (!target) { + return { + success: false, + failure: { code: 'NO_OP', message: 'ui.comments.createFromSelection: no addressable selection target.' }, + }; + } + const api = requireDocComments(); + const receipt = (api.create as (input: unknown, options?: unknown) => Receipt).call(api, { target, text }); + // Refresh + notify ourselves: the underlying wrappers don't + // emit a single canonical event for every comments mutation + // (some go through `transaction` only, some emit + // `commentsUpdate` ahead of the entity-store finishing). Doing + // it here means the next snapshot subscribers see is the + // post-mutation state, regardless of which event the wrapper + // happens to fire. + refreshAndNotify(); + return receipt; + }, + resolve(commentId) { + const api = requireDocComments(); + const receipt = (api.patch as (input: unknown, options?: unknown) => Receipt).call(api, { + commentId, + status: 'resolved', + }); + refreshAndNotify(); + return receipt; + }, + reopen(commentId) { + // Routes through `comments.patch({ status: 'active' })`. Today + // doc-api validation rejects anything other than 'resolved' — + // SD-2789 widens the union and ships the lifecycle inverse. + // Until then this surfaces an INVALID_INPUT receipt or throws, + // which is the correct visible behavior for a not-yet-shipped + // operation rather than a silent no-op. + const api = requireDocComments(); + const receipt = (api.patch as (input: unknown, options?: unknown) => Receipt).call(api, { + commentId, + status: 'active', + }); + refreshAndNotify(); + return receipt; + }, + delete(commentId) { + const api = requireDocComments(); + const receipt = (api.delete as (input: unknown, options?: unknown) => Receipt).call(api, { commentId }); + refreshAndNotify(); + return receipt; + }, + async scrollTo(commentId) { + // `CommentAddress` is body-scoped in the contract — it has no + // `story` field today. Story-aware comment navigation lands as + // a separate doc-API extension; until then, just route the id + // and let `presentation.navigateTo` resolve through the comment + // entity store. + return runScrollIntoView({ + target: { kind: 'entity', entityType: 'comment', entityId: commentId }, + block: 'center', + behavior: 'smooth', + }); + }, + }; + + // ---- ui.review ---------------------------------------------------------- + // + // Same architectural rules as `ui.comments`: every mutation routes + // through the Document API (`editor.doc.trackChanges.decide`); next + // / previous / scrollTo are UI-only navigation helpers. Track-changes + // recording state is intentionally absent here — it lives on + // documentMode today and lands as a dedicated primitive in + // SD-2667/S4 (filed separately). + + const requireDocTrackChanges = () => { + // Always go through the host editor — `trackChanges.decide` is + // document-wide and the change's own `address.story` (carried in + // the decide target) tells the adapter which story to operate + // against. Routing through a child story editor when focus is in + // a header/footer would scope the decision to that story. + const editor = resolveHostEditor(superdoc); + const api = editor?.doc?.trackChanges; + if (!api?.decide) { + throw new Error('ui.review: no active editor / trackChanges API. Open a document first.'); + } + return api; + }; + + /** Determine the entity kind for a given id from the current feed. */ + const entityKindForId = (id: string): 'comment' | 'change' | null => { + const feed = computeState().review.items; + const item = feed.find((i) => i.id === id); + return item?.kind ?? null; + }; + + /** + * Build the `target` payload for `trackChanges.decide` for a single + * change id. Looks up the change in the cached feed; when its + * `address.story` is non-body (header / footer / footnote / + * endnote), include the story so the doc-API adapter can route + * the decision to the right story instead of defaulting to body and + * failing with target-not-found. Body-anchored changes omit the + * field for parity with the doc-API's body-default contract. + */ + const buildChangeDecideTarget = (changeId: string): { id: string; story?: unknown } => { + const item = trackChangesListCache.items.find((c) => c.id === changeId); + const story = (item as unknown as { address?: { story?: unknown } } | undefined)?.address?.story; + if (story != null) return { id: changeId, story }; + return { id: changeId }; + }; + + /** + * Look up a review item's `address.story` so navigation / + * scrollTo can carry it into the EntityAddress target. Without this, + * `presentation.navigateTo({ entityId: 'tc-header-x' })` defaults + * to body and either fails with target-not-found or anchors to a + * same-id body change. Returns `undefined` for body-anchored items + * so the EntityAddress stays minimal. + */ + const lookupItemStory = (id: string): unknown | undefined => { + const change = trackChangesListCache.items.find((c) => c.id === id); + if (change) { + return (change as unknown as { address?: { story?: unknown } }).address?.story; + } + const comment = commentsListCache.items.find((c) => c.id === id); + return (comment as unknown as { address?: { story?: unknown } } | undefined)?.address?.story; + }; + + const review: ReviewHandle = { + getSnapshot: () => computeState().review, + subscribe(listener) { + return select((state) => state.review, shallowEqual).subscribe((snapshot) => { + try { + listener({ snapshot }); + } catch { + // see scheduleNotify + } + }); + }, + accept(changeId) { + const api = requireDocTrackChanges(); + const receipt = (api.decide as (input: unknown, options?: unknown) => Receipt).call(api, { + decision: 'accept', + target: buildChangeDecideTarget(changeId), + }); + refreshAndNotify(); + return receipt; + }, + reject(changeId) { + const api = requireDocTrackChanges(); + const receipt = (api.decide as (input: unknown, options?: unknown) => Receipt).call(api, { + decision: 'reject', + target: buildChangeDecideTarget(changeId), + }); + refreshAndNotify(); + return receipt; + }, + acceptAll() { + const api = requireDocTrackChanges(); + const receipt = (api.decide as (input: unknown, options?: unknown) => Receipt).call(api, { + decision: 'accept', + target: { scope: 'all' }, + }); + refreshAndNotify(); + return receipt; + }, + rejectAll() { + const api = requireDocTrackChanges(); + const receipt = (api.decide as (input: unknown, options?: unknown) => Receipt).call(api, { + decision: 'reject', + target: { scope: 'all' }, + }); + refreshAndNotify(); + return receipt; + }, + next() { + const items = computeState().review.items; + if (items.length === 0) return null; + const current = activeReviewId ? items.findIndex((i) => i.id === activeReviewId) : -1; + // Wrap-around: after last → first; null active → first. + const nextIndex = current < 0 || current >= items.length - 1 ? 0 : current + 1; + activeReviewId = items[nextIndex]!.id; + scheduleNotify(); + return activeReviewId; + }, + previous() { + const items = computeState().review.items; + if (items.length === 0) return null; + const current = activeReviewId ? items.findIndex((i) => i.id === activeReviewId) : -1; + // Wrap-around: before first → last; null active → last. + const prevIndex = current <= 0 ? items.length - 1 : current - 1; + activeReviewId = items[prevIndex]!.id; + scheduleNotify(); + return activeReviewId; + }, + async scrollTo(id) { + const kind = entityKindForId(id); + activeReviewId = id; + scheduleNotify(); + // `EntityAddress` is a discriminated union: `CommentAddress` + // doesn't carry a `story` field, only `TrackedChangeAddress` + // does. Branch on `kind` so the constructed target matches the + // right union member exactly. + let target: import('@superdoc/document-api').EntityAddress; + if (kind === 'change') { + const story = lookupItemStory(id) as import('@superdoc/document-api').TrackedChangeAddress['story']; + target = + story != null + ? { kind: 'entity', entityType: 'trackedChange', entityId: id, story } + : { kind: 'entity', entityType: 'trackedChange', entityId: id }; + } else { + target = { kind: 'entity', entityType: 'comment', entityId: id }; + } + return runScrollIntoView({ + target, + block: 'center', + behavior: 'smooth', + }); + }, + }; + + // ---- 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' }; + } + + // Resolve through the **host** editor — `presentationEditor` + // lives on the body / host, not the routed child story editor + // (header / footer / note). When focus is in a child story, + // `resolveRoutedEditor` returns that child, whose + // `presentationEditor` is undefined; the rect lookup would + // wrongly return `not-ready`. Story-aware routing happens + // through the entity address's `story` field inside + // `getEntityRects`. Same posture as `runScrollIntoView`. + const editor = resolveHostEditor(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 { + return runScrollIntoView(input); + }, + }; + + // ---- ui.selection ------------------------------------------------------ + // + // Same shape as `ui.comments` / `ui.review` / `ui.toolbar`: + // synchronous `getSnapshot()` + memoized `subscribe()`. Sugar over + // `ui.select((s) => s.selection, shallowEqual)` so consumers writing + // floating bubble menus / format toolbars / mention popovers / + // "comment here" hints have the same ergonomic surface as the + // other domain handles instead of dipping into the lower-level + // selector substrate. + const selection: SelectionHandle = { + getSnapshot: () => computeState().selection, + subscribe(listener) { + return select((state) => state.selection, shallowEqual).subscribe((snapshot) => { + try { + listener({ snapshot }); + } catch { + // see scheduleNotify + } + }); + }, + }; + + const destroy = () => { + if (destroyed) return; + destroyed = true; + stateChangeListeners.clear(); + commandHandleCache.clear(); + commandSubscribableCache.clear(); + teardown.forEach((fn) => { + try { + fn(); + } catch { + // teardown is best-effort + } + }); + teardown.length = 0; + }; + + return { select, toolbar, commands, comments, review, selection, viewport, destroy }; +} diff --git a/packages/super-editor/src/ui/equality.ts b/packages/super-editor/src/ui/equality.ts new file mode 100644 index 0000000000..8da596ad6f --- /dev/null +++ b/packages/super-editor/src/ui/equality.ts @@ -0,0 +1,34 @@ +/** + * Equality helpers for `ui.select(selector, equality)`. + * + * Default equality on `select()` is `Object.is`. For object slices, + * consumers should pass {@link shallowEqual} or a custom equality — + * otherwise every state recompute will produce a new object and re-fire + * the listener. Same posture as TipTap's `useEditorState` and Slate's + * `useSlateSelector`. + */ + +/** Shallow structural equality for plain objects and arrays. */ +export function shallowEqual(a: T, b: T): boolean { + if (Object.is(a, b)) return true; + if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) return false; + + if (Array.isArray(a)) { + if (!Array.isArray(b) || a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (!Object.is(a[i], b[i])) return false; + } + return true; + } + + if (Array.isArray(b)) return false; + + const keysA = Object.keys(a as Record); + const keysB = Object.keys(b as Record); + if (keysA.length !== keysB.length) return false; + for (const key of keysA) { + if (!Object.prototype.hasOwnProperty.call(b, key)) return false; + if (!Object.is((a as Record)[key], (b as Record)[key])) return false; + } + return true; +} diff --git a/packages/super-editor/src/ui/index.ts b/packages/super-editor/src/ui/index.ts new file mode 100644 index 0000000000..551cf7808b --- /dev/null +++ b/packages/super-editor/src/ui/index.ts @@ -0,0 +1,62 @@ +/** + * `superdoc/ui` — browser-only UI controller for SuperDoc. + * + * The architectural counterpart to the Document API contract: + * + * - `editor.doc.*` — request/response operations, runs server + client + * - `createSuperDocUI({ superdoc })` — browser-only state controller + * + * Domain namespaces (`ui.toolbar`, `ui.commands`, `ui.comments`, + * `ui.review`, `ui.viewport`, `ui.selection`) are filed as sibling + * tickets under SD-2667 and layer on top of the `ui.select` substrate + * exported here. + * + * Source lives in `packages/super-editor/src/ui/`; the public sub-entry + * is `superdoc/ui` (re-exported from `packages/superdoc/src/ui.js`), + * mirroring the `superdoc/headless-toolbar` pattern. + */ + +export { createSuperDocUI } from './create-super-doc-ui.js'; +export { shallowEqual } from './equality.js'; + +export type { + // Substrate + EqualityFn, + SelectorFn, + Subscribable, + + // Host shapes (structural) + SuperDocEditorLike, + SuperDocLike, + + // Controller + SuperDocUI, + SuperDocUIOptions, + SuperDocUIState, + + // Selection + SelectionHandle, + SelectionSlice, + + // Toolbar + commands + CommandHandle, + CommandsHandle, + ToolbarCommandHandleState, + ToolbarHandle, + ToolbarSnapshotSlice, + + // Comments + CommentsHandle, + CommentsSlice, + + // Review + ReviewHandle, + ReviewItem, + ReviewSlice, + + // Viewport + ViewportGetRectInput, + ViewportHandle, + ViewportRect, + ViewportRectResult, +} from './types.js'; diff --git a/packages/super-editor/src/ui/review.test.ts b/packages/super-editor/src/ui/review.test.ts new file mode 100644 index 0000000000..1fa7309e96 --- /dev/null +++ b/packages/super-editor/src/ui/review.test.ts @@ -0,0 +1,590 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createSuperDocUI } from './create-super-doc-ui.js'; +import type { SuperDocLike } from './types.js'; + +/** + * Stub builder for `ui.review` tests. Models the merged feed shape + * — `editor.doc.comments.list()` + `editor.doc.trackChanges.list()` + * + `editor.doc.trackChanges.decide()` + selection routing. + */ +function makeStubs( + initial: { + comments?: Array<{ id: string; commentId: string; text?: string; status?: 'open' | 'resolved' }>; + trackedChanges?: Array<{ + id: string; + type?: 'insert' | 'delete' | 'format'; + excerpt?: string; + story?: unknown; + }>; + activeCommentIds?: string[]; + activeChangeIds?: string[]; + } = {}, +) { + const editorListeners = new Map void>>(); + const superdocListeners = new Map void>>(); + + let commentsList = initial.comments ?? []; + let changesList = initial.trackedChanges ?? []; + + const listComments = vi.fn(() => ({ + evaluatedRevision: 'r1', + total: commentsList.length, + // Mirror the production discovery-item shape: canonical id is on + // `id`, set from the underlying commentId by the adapter. There is + // no `commentId` field on `DiscoveryItem` itself. + items: commentsList.map((c) => ({ + id: c.commentId, + handle: { ref: `comment:${c.commentId}`, refStability: 'stable' as const, targetKind: 'comment' as const }, + address: { kind: 'entity' as const, entityType: 'comment' as const, entityId: c.commentId }, + status: c.status ?? ('open' as const), + text: c.text, + })), + page: { limit: 50, offset: 0, returned: commentsList.length }, + })); + const listChanges = vi.fn((_query?: unknown) => ({ + evaluatedRevision: 'r1', + total: changesList.length, + items: changesList.map((tc) => ({ + id: tc.id, + handle: { + ref: `tracked-change:${tc.id}`, + refStability: 'stable' as const, + targetKind: 'trackedChange' as const, + }, + address: { + kind: 'entity' as const, + entityType: 'trackedChange' as const, + entityId: tc.id, + ...(tc.story != null ? { story: tc.story } : {}), + }, + type: tc.type ?? ('insert' as const), + excerpt: tc.excerpt, + })), + page: { limit: 50, offset: 0, returned: changesList.length }, + })); + const decide = vi.fn((_input: unknown) => ({ success: true as const })); + const navigateTo = vi.fn(async (_target: unknown) => true); + const setDocumentMode = vi.fn(); + + const editor: { + on: ReturnType; + off: ReturnType; + doc: unknown; + presentationEditor: { + navigateTo: typeof navigateTo; + getActiveEditor: () => unknown; + }; + } = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (!editorListeners.has(event)) editorListeners.set(event, new Set()); + editorListeners.get(event)!.add(handler); + }), + off: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + editorListeners.get(event)?.delete(handler); + }), + doc: { + selection: { + current: vi.fn(() => ({ + empty: true, + text: '', + target: null, + activeCommentIds: initial.activeCommentIds ?? [], + activeChangeIds: initial.activeChangeIds ?? [], + })), + }, + comments: { list: listComments, create: vi.fn(), patch: vi.fn(), delete: vi.fn() }, + trackChanges: { list: listChanges, decide }, + }, + // Self-reference assigned below so toolbar source resolution sees + // the same routed editor as the rest of the stub. + presentationEditor: undefined as never, + }; + editor.presentationEditor = { navigateTo, getActiveEditor: () => editor }; + + const superdoc: SuperDocLike & { + fireEditor(event: string, ...args: unknown[]): void; + setComments(next: typeof commentsList): void; + setTrackedChanges(next: typeof changesList): void; + setActiveSelection(commentIds?: string[], changeIds?: string[]): void; + } = { + activeEditor: editor as never, + config: { documentMode: 'editing' }, + setDocumentMode: setDocumentMode as never, + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (!superdocListeners.has(event)) superdocListeners.set(event, new Set()); + superdocListeners.get(event)!.add(handler); + }), + off: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + superdocListeners.get(event)?.delete(handler); + }), + fireEditor(event, ...args) { + const handlers = editorListeners.get(event); + if (!handlers) return; + [...handlers].forEach((handler) => handler(...args)); + }, + setComments(next) { + commentsList = next; + }, + setTrackedChanges(next) { + changesList = next; + }, + setActiveSelection(commentIds = [], changeIds = []) { + (editor.doc.selection.current as unknown as () => unknown) = vi.fn(() => ({ + empty: commentIds.length === 0 && changeIds.length === 0, + text: '', + target: null, + activeCommentIds: commentIds, + activeChangeIds: changeIds, + })); + }, + }; + + return { superdoc, editor, mocks: { listComments, listChanges, decide, navigateTo, setDocumentMode } }; +} + +describe('ui.review — snapshot', () => { + it('merges comments and tracked changes into one feed with dense documentOrder', () => { + const { superdoc } = makeStubs({ + comments: [ + { id: 'c1', commentId: 'c1' }, + { id: 'c2', commentId: 'c2' }, + ], + trackedChanges: [ + { id: 'tc1', type: 'insert' }, + { id: 'tc2', type: 'delete' }, + ], + }); + const ui = createSuperDocUI({ superdoc }); + + const snap = ui.review.getSnapshot(); + expect(snap.items).toHaveLength(4); + expect(snap.items.map((i) => ({ kind: i.kind, id: i.id, order: i.documentOrder }))).toEqual([ + { kind: 'comment', id: 'c1', order: 0 }, + { kind: 'comment', id: 'c2', order: 1 }, + { kind: 'change', id: 'tc1', order: 2 }, + { kind: 'change', id: 'tc2', order: 3 }, + ]); + + ui.destroy(); + }); + + it('openCount counts every tracked change + every non-resolved comment', () => { + const { superdoc } = makeStubs({ + comments: [ + { id: 'c1', commentId: 'c1' }, + { id: 'c2', commentId: 'c2', status: 'resolved' }, + { id: 'c3', commentId: 'c3' }, + ], + trackedChanges: [{ id: 'tc1' }, { id: 'tc2' }], + }); + const ui = createSuperDocUI({ superdoc }); + + expect(ui.review.getSnapshot().openCount).toBe(4); // 2 open comments + 2 changes + + ui.destroy(); + }); + + it('activeId mirrors selection.activeCommentIds[0] when on a comment', () => { + const { superdoc } = makeStubs({ + comments: [{ id: 'c1', commentId: 'c1' }], + trackedChanges: [{ id: 'tc1' }], + activeCommentIds: ['c1'], + }); + const ui = createSuperDocUI({ superdoc }); + + expect(ui.review.getSnapshot().activeId).toBe('c1'); + + ui.destroy(); + }); + + it('activeId falls back to selection.activeChangeIds[0] when no active comment', () => { + const { superdoc } = makeStubs({ + comments: [{ id: 'c1', commentId: 'c1' }], + trackedChanges: [{ id: 'tc1' }], + activeChangeIds: ['tc1'], + }); + const ui = createSuperDocUI({ superdoc }); + + expect(ui.review.getSnapshot().activeId).toBe('tc1'); + + ui.destroy(); + }); + + it('subscribe fires once with the initial snapshot', () => { + const { superdoc } = makeStubs({ comments: [{ id: 'c1', commentId: 'c1' }] }); + const ui = createSuperDocUI({ superdoc }); + + const cb = vi.fn(); + const off = ui.review.subscribe(cb); + + expect(cb).toHaveBeenCalledTimes(1); + const arg = cb.mock.calls[0][0] as { snapshot: { items: unknown[] } }; + expect(arg.snapshot.items).toHaveLength(1); + + off(); + ui.destroy(); + }); +}); + +describe('ui.review — decide actions route through editor.doc.trackChanges.*', () => { + it('accept(id) routes to decide({ decision: "accept", target: { id } })', () => { + const { superdoc, mocks } = makeStubs({ trackedChanges: [{ id: 'tc1' }] }); + const ui = createSuperDocUI({ superdoc }); + + ui.review.accept('tc1'); + + expect(mocks.decide).toHaveBeenCalledWith({ decision: 'accept', target: { id: 'tc1' } }); + ui.destroy(); + }); + + it('reject(id) routes to decide({ decision: "reject", target: { id } })', () => { + const { superdoc, mocks } = makeStubs({ trackedChanges: [{ id: 'tc1' }] }); + const ui = createSuperDocUI({ superdoc }); + + ui.review.reject('tc1'); + + expect(mocks.decide).toHaveBeenCalledWith({ decision: 'reject', target: { id: 'tc1' } }); + ui.destroy(); + }); + + it('acceptAll() routes to decide({ scope: "all" })', () => { + const { superdoc, mocks } = makeStubs({ trackedChanges: [{ id: 'tc1' }, { id: 'tc2' }] }); + const ui = createSuperDocUI({ superdoc }); + + ui.review.acceptAll(); + + expect(mocks.decide).toHaveBeenCalledWith({ decision: 'accept', target: { scope: 'all' } }); + ui.destroy(); + }); + + it('rejectAll() routes to decide({ scope: "all" })', () => { + const { superdoc, mocks } = makeStubs({ trackedChanges: [{ id: 'tc1' }, { id: 'tc2' }] }); + const ui = createSuperDocUI({ superdoc }); + + ui.review.rejectAll(); + + expect(mocks.decide).toHaveBeenCalledWith({ decision: 'reject', target: { scope: 'all' } }); + ui.destroy(); + }); +}); + +describe('ui.review — next/previous navigation', () => { + it('next() advances activeId in document order', () => { + const { superdoc } = makeStubs({ + comments: [ + { id: 'c1', commentId: 'c1' }, + { id: 'c2', commentId: 'c2' }, + ], + trackedChanges: [{ id: 'tc1' }], + }); + const ui = createSuperDocUI({ superdoc }); + + expect(ui.review.next()).toBe('c1'); + expect(ui.review.getSnapshot().activeId).toBe('c1'); + + expect(ui.review.next()).toBe('c2'); + expect(ui.review.next()).toBe('tc1'); + }); + + it('next() wraps from the last item to the first', () => { + const { superdoc } = makeStubs({ + comments: [{ id: 'c1', commentId: 'c1' }], + trackedChanges: [{ id: 'tc1' }], + }); + const ui = createSuperDocUI({ superdoc }); + + ui.review.next(); // c1 + ui.review.next(); // tc1 + expect(ui.review.next()).toBe('c1'); // wrap + }); + + it('previous() walks backward and wraps from first to last', () => { + const { superdoc } = makeStubs({ + comments: [{ id: 'c1', commentId: 'c1' }], + trackedChanges: [{ id: 'tc1' }, { id: 'tc2' }], + }); + const ui = createSuperDocUI({ superdoc }); + + expect(ui.review.previous()).toBe('tc2'); // null → wrap to last + expect(ui.review.previous()).toBe('tc1'); + expect(ui.review.previous()).toBe('c1'); + expect(ui.review.previous()).toBe('tc2'); // wrap + }); + + it('next() / previous() return null when the feed is empty', () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + expect(ui.review.next()).toBe(null); + expect(ui.review.previous()).toBe(null); + expect(ui.review.getSnapshot().activeId).toBe(null); + + ui.destroy(); + }); +}); + +describe('ui.review — scrollTo', () => { + it('scrollTo(id) navigates to the right EntityAddress via the presentation editor', async () => { + const { superdoc, mocks } = makeStubs({ + comments: [{ id: 'c1', commentId: 'c1' }], + trackedChanges: [{ id: 'tc1' }], + }); + const ui = createSuperDocUI({ superdoc }); + + await ui.review.scrollTo('c1'); + let target = mocks.navigateTo.mock.calls[0][0] as { kind: string; entityType: string; entityId: string }; + expect(target).toEqual({ kind: 'entity', entityType: 'comment', entityId: 'c1' }); + + await ui.review.scrollTo('tc1'); + target = mocks.navigateTo.mock.calls[1][0] as { kind: string; entityType: string; entityId: string }; + expect(target).toEqual({ kind: 'entity', entityType: 'trackedChange', entityId: 'tc1' }); + + ui.destroy(); + }); +}); + +describe('ui.review — regression: comment row id sourced from discovery.id', () => { + it('comment ReviewItem.id mirrors the discovery item id (not undefined commentId)', () => { + const { superdoc } = makeStubs({ + comments: [ + { id: 'c1', commentId: 'c1' }, + { id: 'c2', commentId: 'c2' }, + ], + }); + const ui = createSuperDocUI({ superdoc }); + + const ids = ui.review.getSnapshot().items.map((i) => i.id); + // Without the fix every comment row would expose `id: undefined` + // because `DiscoveryItem` has no `commentId` field. + expect(ids).toEqual(['c1', 'c2']); + expect(ids.every((id) => typeof id === 'string' && id.length > 0)).toBe(true); + + // And navigation must work on those ids end-to-end. + expect(ui.review.next()).toBe('c1'); + expect(ui.review.next()).toBe('c2'); + + ui.destroy(); + }); +}); + +describe('ui.review — regression: navigation persists past the selected item', () => { + it('next() while the cursor is on the active item is not overwritten by the unchanged selection', async () => { + const { superdoc } = makeStubs({ + comments: [ + { id: 'c1', commentId: 'c1' }, + { id: 'c2', commentId: 'c2' }, + ], + trackedChanges: [{ id: 'tc1' }], + activeCommentIds: ['c1'], + }); + const ui = createSuperDocUI({ superdoc }); + + // Selection lands on c1 → activeId mirrors selection + expect(ui.review.getSnapshot().activeId).toBe('c1'); + + // User clicks "Next" in the sidebar — selection has not moved (still on c1) + expect(ui.review.next()).toBe('c2'); + expect(ui.review.getSnapshot().activeId).toBe('c2'); + + // A subsequent recompute (e.g. typing emits transaction → selectionUpdate) + // must NOT snap activeReviewId back to the selection-driven id, because + // the selection has not moved since the last computeState. + superdoc.fireEditor('selectionUpdate'); + await Promise.resolve(); + expect(ui.review.getSnapshot().activeId).toBe('c2'); + + superdoc.fireEditor('transaction'); + await Promise.resolve(); + expect(ui.review.getSnapshot().activeId).toBe('c2'); + + ui.destroy(); + }); +}); + +describe('ui.review — regression: tracked-changes-changed refreshes cache', () => { + it('a tracked-changes-changed event surfaces fresh items in the next snapshot', async () => { + const { superdoc } = makeStubs({ + trackedChanges: [{ id: 'tc1' }], + }); + const ui = createSuperDocUI({ superdoc }); + + expect(ui.review.getSnapshot().items.map((i) => i.id)).toEqual(['tc1']); + + superdoc.setTrackedChanges([{ id: 'tc1' }, { id: 'tc2' }]); + // The tracked-change index broadcasts `tracked-changes-changed` + // (not `trackedChangesUpdate`) on every transaction that adds / + // removes / invalidates changes. The controller listens to that + // event so collaborator-driven mutations refresh the cache too. + superdoc.fireEditor('tracked-changes-changed'); + await Promise.resolve(); + + expect(ui.review.getSnapshot().items.map((i) => i.id)).toEqual(['tc1', 'tc2']); + + ui.destroy(); + }); +}); + +describe('ui.review — regression: decide carries non-body story', () => { + it('accept(id) on a header change includes target.story so the adapter routes correctly', () => { + const { superdoc, mocks } = makeStubs({ + trackedChanges: [{ id: 'tc-header', story: 'header:rId1' }], + }); + const ui = createSuperDocUI({ superdoc }); + + ui.review.accept('tc-header'); + + expect(mocks.decide).toHaveBeenCalledWith({ + decision: 'accept', + target: { id: 'tc-header', story: 'header:rId1' }, + }); + + ui.destroy(); + }); + + it('reject(id) on a footer change includes target.story', () => { + const { superdoc, mocks } = makeStubs({ + trackedChanges: [{ id: 'tc-footer', story: 'footer:rId2' }], + }); + const ui = createSuperDocUI({ superdoc }); + + ui.review.reject('tc-footer'); + + expect(mocks.decide).toHaveBeenCalledWith({ + decision: 'reject', + target: { id: 'tc-footer', story: 'footer:rId2' }, + }); + + ui.destroy(); + }); + + it('accept(id) on a body change omits target.story (parity with body-default contract)', () => { + const { superdoc, mocks } = makeStubs({ + trackedChanges: [{ id: 'tc-body' }], + }); + const ui = createSuperDocUI({ superdoc }); + + ui.review.accept('tc-body'); + + expect(mocks.decide).toHaveBeenCalledWith({ + decision: 'accept', + target: { id: 'tc-body' }, + }); + + ui.destroy(); + }); +}); + +describe('ui.review — regression: scrollTo carries non-body story', () => { + it('scrollTo on a header change passes target.story to navigateTo', async () => { + const { superdoc, mocks } = makeStubs({ + trackedChanges: [{ id: 'tc-header', story: 'header:rId1' }], + }); + const ui = createSuperDocUI({ superdoc }); + + await ui.review.scrollTo('tc-header'); + + expect(mocks.navigateTo).toHaveBeenCalledTimes(1); + expect(mocks.navigateTo).toHaveBeenCalledWith({ + kind: 'entity', + entityType: 'trackedChange', + entityId: 'tc-header', + story: 'header:rId1', + }); + ui.destroy(); + }); + + it('scrollTo on a body change omits target.story (parity with body-default)', async () => { + const { superdoc, mocks } = makeStubs({ + trackedChanges: [{ id: 'tc-body' }], + }); + const ui = createSuperDocUI({ superdoc }); + + await ui.review.scrollTo('tc-body'); + + expect(mocks.navigateTo).toHaveBeenCalledWith({ + kind: 'entity', + entityType: 'trackedChange', + entityId: 'tc-body', + }); + ui.destroy(); + }); +}); + +describe('ui.review — regression: decisions route through the host editor', () => { + it('accept(id) goes through superdoc.activeEditor (host) even when toolbar routing returns a child story editor', () => { + const { superdoc, mocks } = makeStubs({ + trackedChanges: [{ id: 'tc1' }], + }); + + // Plant a child story editor that the toolbar source resolver + // would return (simulating "focus is in a header"). Its decide + // mock must NEVER be called — review decisions are document-wide + // and must route through the host editor. + const childDecide = vi.fn((_input: unknown) => ({ success: false as const })); + const childEditor = { + doc: { trackChanges: { decide: childDecide } }, + }; + const hostEditor = superdoc.activeEditor as unknown as { + presentationEditor: { getActiveEditor: () => unknown }; + }; + hostEditor.presentationEditor.getActiveEditor = () => childEditor; + + const ui = createSuperDocUI({ superdoc }); + + ui.review.accept('tc1'); + + expect(mocks.decide).toHaveBeenCalledTimes(1); // host editor's decide + expect(childDecide).not.toHaveBeenCalled(); // child editor's decide untouched + + ui.destroy(); + }); + + it('acceptAll() routes through the host editor too', () => { + const { superdoc, mocks } = makeStubs({ + trackedChanges: [{ id: 'tc1' }, { id: 'tc2' }], + }); + const childDecide = vi.fn((_input: unknown) => ({ success: false as const })); + const hostEditor = superdoc.activeEditor as unknown as { + presentationEditor: { getActiveEditor: () => unknown }; + }; + hostEditor.presentationEditor.getActiveEditor = () => ({ + doc: { trackChanges: { decide: childDecide } }, + }); + + const ui = createSuperDocUI({ superdoc }); + + ui.review.acceptAll(); + + expect(mocks.decide).toHaveBeenCalledWith({ decision: 'accept', target: { scope: 'all' } }); + expect(childDecide).not.toHaveBeenCalled(); + + ui.destroy(); + }); +}); + +describe('ui.review — regression: subscribers are not re-fired on unrelated transactions', () => { + it('a typing-only event (transaction without comments/trackedChanges change) does not re-fire ui.review subscribers', async () => { + const { superdoc } = makeStubs({ + comments: [{ id: 'c1', commentId: 'c1' }], + trackedChanges: [{ id: 'tc1' }], + }); + const ui = createSuperDocUI({ superdoc }); + + const cb = vi.fn(); + const off = ui.review.subscribe(cb); + expect(cb).toHaveBeenCalledTimes(1); // initial snapshot + + superdoc.fireEditor('transaction'); + await Promise.resolve(); + superdoc.fireEditor('selectionUpdate'); + await Promise.resolve(); + + // Memoization keeps the slice identity-stable when the source caches and + // activeReviewId have not changed, so shallowEqual short-circuits. + expect(cb).toHaveBeenCalledTimes(1); + + off(); + ui.destroy(); + }); +}); diff --git a/packages/super-editor/src/ui/scroll-into-view.ts b/packages/super-editor/src/ui/scroll-into-view.ts new file mode 100644 index 0000000000..99b95321a4 --- /dev/null +++ b/packages/super-editor/src/ui/scroll-into-view.ts @@ -0,0 +1,100 @@ +/** + * Viewport-scroll helper for `superdoc/ui`. Drives + * `presentation.navigateTo()` for entity targets (comment / + * tracked-change ids — story-aware) and + * `presentation.scrollToPositionAsync()` for text targets (body-only + * today). Used by `ui.viewport.scrollIntoView`, `ui.comments.scrollTo`, + * and `ui.review.scrollTo`. + */ + +import type { ScrollIntoViewInput, ScrollIntoViewOutput } 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'; + +/** + * Two paths: + * - EntityAddress (comment / tracked change by id) → delegates to + * `presentation.navigateTo(target)`, which handles paginated layouts, + * virtualized page mounting, AND story activation for entities in + * header/footer/footnote/endnote stories. `block` and `behavior` + * options are not applied here — `navigateTo` picks sensible viewport + * alignment per entity type. + * - TextAddress / TextTarget → resolves the first segment to a PM + * position and calls `scrollToPositionAsync` with caller-provided + * `block` / `behavior` options. This path is body-only today; text + * targets that reference non-body stories are out of scope. + * + * Both paths honor the `{ success: boolean }` contract: thrown errors + * from resolvers (e.g. ambiguous block IDs) and rejected scroll + * promises are caught and converted into `{ success: false }` rather + * than propagating. + * + * Known limitation: for a tracked change that lives in a non-body + * story (header, footer, footnote, endnote) on a page that is not + * currently mounted in the DOM (virtualized), + * `presentation.navigateTo` returns `false` — the non-body navigation + * path activates the story surface via rendered DOM candidates, and + * offscreen pages have none. Returns `{ success: false }` in that case. + */ +export async function scrollRangeIntoView(editor: Editor, input: ScrollIntoViewInput): Promise { + const presentation = editor.presentationEditor; + if (!presentation) { + return { success: false }; + } + + // Narrow to the entity branch via discriminated-union check on + // `kind`. `TextAddress`, `TextTarget`, and `EntityAddress` all have + // a `kind` field, so the equality check narrows `target` directly + // without a cast — `target` is `EntityAddress` inside the block and + // `TextAddress | TextTarget` after the early return. + const target = input.target; + if (target.kind === 'entity') { + if (typeof presentation.navigateTo !== 'function') { + return { success: false }; + } + try { + const ok = await presentation.navigateTo(target); + return { success: Boolean(ok) }; + } catch { + return { success: false }; + } + } + + if (typeof presentation.scrollToPositionAsync !== 'function') { + return { success: false }; + } + + try { + // After the entity early-return, `target` narrows to + // `TextAddress | TextTarget`. Discriminate by checking for a + // non-empty `segments` array — a bare `'segments' in target` + // type-guard would mis-classify a hybrid payload that happens to + // carry both `segments` (empty) and `blockId`/`range` because + // `'segments' in {}` answers shape, not content. + const isMultiSegmentTarget = + Array.isArray((target as { segments?: unknown }).segments) && + ((target as { segments: unknown[] }).segments.length ?? 0) > 0; + const firstSegment = isMultiSegmentTarget + ? (target as { segments: Array<{ blockId: string; range: { start: number; end: number } }> }).segments[0] + : { + blockId: (target as { blockId: string }).blockId, + range: (target as { range: { start: number; end: number } }).range, + }; + if (!firstSegment) return { success: false }; + + const resolved = resolveTextTarget(editor, { + kind: 'text', + blockId: firstSegment.blockId, + range: firstSegment.range, + }); + if (!resolved) return { success: false }; + + const ok = await presentation.scrollToPositionAsync(resolved.from, { + block: input.block ?? 'center', + behavior: input.behavior ?? 'smooth', + }); + return { success: Boolean(ok) }; + } catch { + return { success: false }; + } +} diff --git a/packages/super-editor/src/ui/toolbar.test.ts b/packages/super-editor/src/ui/toolbar.test.ts new file mode 100644 index 0000000000..b443ae5877 --- /dev/null +++ b/packages/super-editor/src/ui/toolbar.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createSuperDocUI } from './create-super-doc-ui.js'; +import type { SuperDocLike } from './types.js'; + +/** + * Stub builder for `ui.toolbar` / `ui.commands` tests. + * + * The internal headless-toolbar reads `editor.state`, `editor.options`, + * and `editor.commands` to compute its snapshot. We supply only what + * `resolveToolbarSources` and the registry's state derivers need to + * produce a non-empty snapshot — the real Editor wires far more, but + * that's out of scope for these unit tests. + */ +function makeStubs() { + const editorListeners = new Map void>>(); + const superdocListeners = new Map void>>(); + + const editor = { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (!editorListeners.has(event)) editorListeners.set(event, new Set()); + editorListeners.get(event)!.add(handler); + }), + off: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + editorListeners.get(event)?.delete(handler); + }), + state: { + selection: { empty: true, from: 0, to: 0 }, + }, + options: { documentId: 'doc-1', isHeaderOrFooter: false }, + commands: { + toggleBold: vi.fn(() => true), + toggleItalic: vi.fn(() => true), + }, + isEditable: true, + doc: { + selection: { + current: vi.fn(() => ({ empty: true, text: '', target: null })), + }, + }, + }; + + const superdoc: SuperDocLike & { + fireEditor(event: string, ...args: unknown[]): void; + fireSuperdoc(event: string, ...args: unknown[]): void; + } = { + activeEditor: editor as never, + config: { documentMode: 'editing' }, + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (!superdocListeners.has(event)) superdocListeners.set(event, new Set()); + superdocListeners.get(event)!.add(handler); + }), + off: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + superdocListeners.get(event)?.delete(handler); + }), + fireEditor(event, ...args) { + const handlers = editorListeners.get(event); + if (!handlers) return; + [...handlers].forEach((handler) => handler(...args)); + }, + fireSuperdoc(event, ...args) { + const handlers = superdocListeners.get(event); + if (!handlers) return; + [...handlers].forEach((handler) => handler(...args)); + }, + }; + + return { superdoc, editor }; +} + +describe('ui.toolbar', () => { + it('exposes getSnapshot / subscribe / execute compatible with HeadlessToolbarController', () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + const snapshot = ui.toolbar.getSnapshot(); + expect(snapshot).toBeDefined(); + expect(snapshot.commands).toBeDefined(); + // Snapshot must include built-in commands — without passing the + // full command list to createHeadlessToolbar, snapshot.commands + // would be empty and ui.commands..observe would always emit + // the fallback disabled state. + expect(Object.keys(snapshot.commands).length).toBeGreaterThan(0); + expect(snapshot.commands).toHaveProperty('bold'); + + expect(typeof ui.toolbar.subscribe).toBe('function'); + expect(typeof ui.toolbar.execute).toBe('function'); + + ui.destroy(); + }); + + it('emits the initial snapshot synchronously on subscribe', () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + const cb = vi.fn(); + const off = ui.toolbar.subscribe(cb); + + expect(cb).toHaveBeenCalledTimes(1); + expect(cb.mock.calls[0][0]).toHaveProperty('snapshot'); + + off(); + ui.destroy(); + }); + + it('forwards execute to the internal controller', () => { + const { superdoc, editor } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + ui.toolbar.execute('bold'); + expect(editor.commands.toggleBold).toHaveBeenCalled(); + + ui.destroy(); + }); +}); + +describe('ui.commands', () => { + it('returns a stable handle per command id (reference equality across accesses)', () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + const a = ui.commands.bold; + const b = ui.commands.bold; + + expect(a).toBe(b); + + ui.destroy(); + }); + + it('observe fires synchronously with initial command state', () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + const cb = vi.fn(); + const off = ui.commands.bold.observe(cb); + + expect(cb).toHaveBeenCalledTimes(1); + const initial = cb.mock.calls[0][0]; + expect(initial).toHaveProperty('active'); + expect(initial).toHaveProperty('disabled'); + + off(); + ui.destroy(); + }); + + it('falls back to a no-op state for unknown command ids', () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + const cb = vi.fn(); + // 'company.aiRewrite' is not a built-in id; observe should still fire + // initially with the fallback state rather than throwing. + (ui.commands as unknown as Record void) => () => void }>)[ + 'company.aiRewrite' + ].observe(cb); + + expect(cb).toHaveBeenCalledTimes(1); + const state = cb.mock.calls[0][0]; + expect(state).toMatchObject({ active: false, disabled: true }); + + ui.destroy(); + }); + + it('execute forwards to the internal toolbar controller', () => { + const { superdoc, editor } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + ui.commands.bold.execute(); + expect(editor.commands.toggleBold).toHaveBeenCalled(); + + ui.destroy(); + }); + + it('shares a single Subscribable per command id across observe() calls', async () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + // 50 observers on the same command id. Without sharing the + // Subscribable, each observe() would create a fresh selector with + // its own onStateChange in stateChangeListeners — 50 selector + // recomputes per editor event. + const cbs: Array> = []; + const offs: Array<() => void> = []; + for (let i = 0; i < 50; i += 1) { + const cb = vi.fn(); + cbs.push(cb); + offs.push(ui.commands.bold.observe(cb)); + } + + // Every observer received its initial emit. + cbs.forEach((cb) => expect(cb).toHaveBeenCalledTimes(1)); + + // Half unsubscribe; remaining observers continue. + for (let i = 0; i < 25; i += 1) { + offs[i]?.(); + } + cbs.slice(25).forEach((cb) => cb.mockClear()); + + // Fire one editor event; coalesced microtask drains. + superdoc.fireEditor('transaction'); + await Promise.resolve(); + + // Each remaining observer fires at most once. The specific + // assertion: no observer fires twice in the same tick, because the + // Subscribable is shared per command id and emits once. + cbs.slice(25).forEach((cb) => { + expect(cb.mock.calls.length).toBeLessThanOrEqual(1); + }); + + offs.slice(25).forEach((off) => off()); + ui.destroy(); + }); + + it('a per-command observer is unaffected when destroy clears the cache', () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + const cb = vi.fn(); + const off = ui.commands.bold.observe(cb); + expect(cb).toHaveBeenCalledTimes(1); + + ui.destroy(); + // After destroy, no further events propagate. The unsubscribe is + // still callable (idempotent / no-throw). + expect(() => off()).not.toThrow(); + }); +}); diff --git a/packages/super-editor/src/ui/types.ts b/packages/super-editor/src/ui/types.ts new file mode 100644 index 0000000000..e5423e9cc1 --- /dev/null +++ b/packages/super-editor/src/ui/types.ts @@ -0,0 +1,652 @@ +/** + * Public types for `superdoc/ui` (the browser UI controller). + * + * The controller exposes a single observation pipeline (the **selector + * substrate**) that domain namespaces — `ui.toolbar`, `ui.commands`, + * `ui.comments`, `ui.review`, `ui.viewport`, `ui.selection` — are + * implemented on top of in sibling tickets. + * + * The skeleton in this package ships only: + * - `createSuperDocUI({ superdoc })` factory + * - `ui.select(selector, equality)` substrate + * - `ui.destroy()` lifecycle + * + * Consumers building custom UI layer their state on top of `ui.select`. + * Domain namespaces are added by sibling tickets. + */ + +export type EqualityFn = (a: T, b: T) => boolean; + +export type SelectorFn = (state: TState) => TSlice; + +/** + * A read-only signal. `get()` is synchronous; `subscribe()` invokes the + * listener once with the current value, then again whenever the value + * changes by the controller's equality function. + */ +export interface Subscribable { + /** Snapshot the current value. */ + get(): T; + /** + * Subscribe to value changes. The listener fires once synchronously + * with the current value, then again whenever the value changes. + * Returns an unsubscribe function. + */ + subscribe(listener: (value: T) => void): () => void; +} + +/** + * Structural typing for the SuperDoc instance — keeps the UI controller + * loose from the SuperDoc Vue package's specific class type. The + * controller only needs an event bus and an `activeEditor` reference. + */ +export interface SuperDocLike { + on?(event: string, handler: (...args: unknown[]) => void): unknown; + off?(event: string, handler: (...args: unknown[]) => void): unknown; + activeEditor?: SuperDocEditorLike | null; + config?: { documentMode?: 'editing' | 'suggesting' | 'viewing' }; + /** + * Optional setter for documentMode. Reserved for future + * `ui.` surfaces (SD-2799) that move document-mode and + * other UI-only commands off the toolbar registry into dedicated + * handles. Not consumed by the controller today. + */ + setDocumentMode?(mode: 'editing' | 'suggesting' | 'viewing'): unknown; +} + +export interface SuperDocEditorLike { + on?(event: string, handler: (...args: unknown[]) => void): unknown; + off?(event: string, handler: (...args: unknown[]) => void): unknown; + doc?: { + selection?: { + current?(input?: { includeText?: boolean }): { + empty: boolean; + text?: string; + target?: unknown; + /** Active mark names at the caret / across the selection. */ + activeMarks?: string[]; + /** Present after SD-2792; absent on older builds — controller falls back to []. */ + activeCommentIds?: string[]; + activeChangeIds?: string[]; + }; + }; + /** + * Comments member on the Document API. The structural typing + * keeps the controller loose from the real `CommentsApi` interface + * to allow stub-driven unit tests without pulling in the full + * adapter graph; runtime calls forward to the real `editor.doc`. + */ + comments?: { + list?(query?: unknown): unknown; + create?(input: unknown, options?: unknown): unknown; + patch?(input: unknown, options?: unknown): unknown; + delete?(input: unknown, options?: unknown): unknown; + }; + /** + * Tracked-changes member on the Document API. Used by + * `ui.review.*` for accept/reject and the merged feed. + */ + trackChanges?: { + list?(query?: unknown): unknown; + 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; +} + +/** + * The unified UI state model. + * + * The skeleton ships the minimum slice needed to prove the substrate + * end-to-end. Sibling tickets extend this via TypeScript module + * augmentation as their domains land: + * - SD-2796 adds `commands` (per-command active/disabled state) + * - SD-2790 adds `comments` + * - SD-2791 adds `trackedChanges` + * - SD-2792 reads add `selection.activeCommentIds` / `activeChangeIds` + * + * Implementation note: the selector substrate recomputes the full state + * snapshot on every source event today, then dedups per-subscriber via + * the equality function. Lazy/incremental computation is an + * optimization that does not change the public API. + */ +export interface SuperDocUIState { + /** True when SuperDoc has an active editor mounted. */ + ready: boolean; + /** Mirror of `superdoc.config.documentMode`. */ + documentMode: 'editing' | 'suggesting' | 'viewing' | null; + /** Selection slice (minimal in the skeleton). */ + selection: SelectionSlice; + /** + * Toolbar snapshot — `{ context, commands }`. Sourced from the + * internal headless-toolbar instance. Domain consumers normally read + * this through `ui.toolbar` (aggregate) or `ui.commands.` + * (fine-grained per-command observables). + */ + toolbar: ToolbarSnapshotSlice; + /** + * Comments slice. Sourced from `editor.doc.comments.list()` and + * cached at the controller level — the list is refreshed on + * `commentsUpdate` / `commentsLoaded` events, not recomputed per + * `computeState()` call. `activeIds` mirrors + * `selection.current().activeCommentIds` so a comment-aware sidebar + * can highlight the active card without a separate subscription. + */ + comments: CommentsSlice; + /** + * Review slice — merged comments + tracked-changes feed for the + * Word / Google Docs review sidebar pattern. Cached at controller + * level alongside the comments slice; refreshes on the same events + * plus tracked-change events. + */ + review: ReviewSlice; +} + +/** + * Toolbar snapshot exposed on `state.toolbar`. Aliased to the existing + * `ToolbarSnapshot` type from `headless-toolbar` so downstream consumers + * see the same shape they would from the standalone controller. + */ +export type ToolbarSnapshotSlice = import('../headless-toolbar/types.js').ToolbarSnapshot; + +/** + * Snapshot of the editor's current selection — the full + * {@link import('@superdoc/document-api').SelectionInfo} projection + * mirrored on the controller so a single `ui.select(s => s.selection, + * shallowEqual)` subscribe gives consumers everything they need to + * drive a floating bubble menu, format toolbar, mention popover, or + * "comment here" hint without dipping back into `editor.doc.selection.current()`. + */ +export interface SelectionSlice { + /** True when the selection is empty (cursor only, no range). */ + empty: boolean; + /** + * The selection anchored to text content as a portable + * {@link import('@superdoc/document-api').TextTarget}, or `null` when + * the selection is not in text (empty document, node selection, no + * focus). Multi-segment when the selection spans multiple blocks. + * Pass directly to `editor.doc.comments.create({ target })`. + */ + target: import('@superdoc/document-api').TextTarget | null; + /** + * Active marks at the caret or across the selection. Names are + * ProseMirror mark type names (`'bold'`, `'italic'`, `'link'`). + * Drives toolbar active-state rendering. Intersection semantics: a + * mark name is included only if every character in the range carries + * it (or, when empty, the caret/stored marks). + */ + activeMarks: string[]; + /** + * Comment ids whose `commentMark` overlaps the selection (or sits + * under the caret when empty). Union semantics: an id is included + * when *any* character in the range carries the mark. Use to + * highlight the active sidebar card or render a "comment here" hint. + * Same array as `state.comments.activeIds` — duplicated for the + * single-subscribe ergonomic. + */ + activeCommentIds: string[]; + /** + * Tracked-change ids whose mark (`trackInsert` / `trackDelete` / + * `trackFormat`) overlaps the selection. Union semantics. Mirrors + * `state.review.activeId` (which picks the first id) for consumers + * that want the full set. + */ + activeChangeIds: string[]; + /** + * Quoted text of the selection. Always present on the slice; + * empty string when the selection is collapsed. Equivalent to + * `editor.doc.selection.current({ includeText: true }).text ?? ''`. + */ + quotedText: string; +} + +/** + * Snapshot of the comments collection exposed on `state.comments`. + * + * Items use the same shape `editor.doc.comments.list()` returns + * (`DiscoveryItem`), so consumers that already consume + * that contract see no shape mismatch. `activeIds` is a denormalized + * convenience driven by `selection.current().activeCommentIds`. + */ +export interface CommentsSlice { + /** Total count from the list result (before pagination, if any). */ + total: number; + /** Items from `editor.doc.comments.list()`. Empty array on error or no editor. */ + items: import('@superdoc/document-api').CommentsListResult['items']; + /** + * Comment IDs whose `commentMark` overlaps the current selection + * (or covers the caret when empty). Empty array when the editor's + * `selection.current()` predates SD-2792 (no `activeCommentIds` + * field) — the controller falls back gracefully. + */ + activeIds: string[]; +} + +/** + * One item in the merged review feed (comments + tracked changes). + * + * Discriminated by `kind`. `documentOrder` is a dense rank within the + * snapshot — comparing two items' `documentOrder` tells you which + * appears first; consuming UIs don't need to recompute it. + */ +export type ReviewItem = + | { + kind: 'comment'; + id: string; + documentOrder: number; + comment: import('@superdoc/document-api').CommentsListResult['items'][number]; + } + | { + kind: 'change'; + id: string; + documentOrder: number; + change: import('@superdoc/document-api').TrackChangesListResult['items'][number]; + }; + +/** + * Snapshot of the merged review feed exposed on `state.review`. + * + * Document-order ranking note (per SD-2791 ticket): both + * `editor.doc.trackChanges.list()` and tracked-change groupings are + * already returned in PM-position order, but cross-list interleaving + * between comments and tracked changes is *not* fully resolved + * because public `TrackChangeInfo` lacks a positional `target` today + * (separate ticket). The initial implementation interleaves comments + * (in their `comments.list()` order) ahead of tracked changes (in + * their `list()` order); migration-guide consumers get a stable + * iteration order and dense `documentOrder` ranks for next/previous + * navigation. When `TrackChangeInfo.target` lands, the merge sort + * gets refined transparently. + */ +export interface ReviewSlice { + /** Merged feed, sorted by `documentOrder`. */ + items: ReviewItem[]; + /** + * Number of unresolved review items (open comments + every tracked + * change). Drives sidebar-header counts. + */ + openCount: number; + /** + * The currently active item id — driven by selection + * (`activeCommentIds[0] ?? activeChangeIds[0]`) plus + * `ui.review.next/previous/scrollTo` calls. `null` when nothing is + * focused. + */ + activeId: string | null; +} + +export interface SuperDocUIOptions { + superdoc: SuperDocLike; +} + +export interface SuperDocUI { + /** + * Subscribe to a slice of the unified UI state. Returns a {@link + * Subscribable} that fires whenever the selected slice changes by the + * given equality function. + * + * Default equality is `Object.is`. For object slices, pass + * {@link shallowEqual} or a custom equality — otherwise every state + * recompute will re-fire your listener. + */ + select(selector: SelectorFn, equality?: EqualityFn): Subscribable; + + /** + * Aggregate toolbar surface. Mirrors the `HeadlessToolbarController` + * shape from `superdoc/headless-toolbar`, sourced from the same + * internal controller. Equivalent to subscribing to the toolbar slice + * via `ui.select((s) => s.toolbar, ...)` plus a passthrough + * `execute` and `getSnapshot`. + */ + toolbar: ToolbarHandle; + + /** + * Per-command observables and executors — one handle per + * {@link import('../headless-toolbar/types.js').PublicToolbarItemId}. + * Pattern lifted from CKEditor 5's per-command `Observable`s: each + * button binds to its own command's state, so unrelated state + * changes don't trigger a re-render. + */ + commands: CommandsHandle; + + /** + * Comments domain — single subscription + actions surface. Subscribe + * to receive snapshot updates (items + activeIds + total); call + * action methods to mutate. All mutations route through + * `editor.doc.comments.*` (the Document API contract); this handle + * exists to give UI consumers a stable surface, not to be a parallel + * mutation contract. + */ + comments: CommentsHandle; + + /** + * Review domain — merged comments + tracked-changes feed for + * Word/Google-Docs review sidebars. Same shape as `comments` but + * with accept/reject/next/previous semantics. + */ + review: ReviewHandle; + + /** + * Selection domain — single subscription + read surface for + * floating bubble menus, format toolbars, mention popovers, and + * "comment here" hints. The handle is sugar over + * `ui.select((s) => s.selection, shallowEqual)` plus a synchronous + * `getSnapshot()`; the lower-level selector substrate stays + * available for finer-grained slices. + * + * The slice mirrors `editor.doc.selection.current()` — + * `target` (TextTarget | null), `activeMarks`, `activeCommentIds`, + * `activeChangeIds`, `quotedText`, `empty` — memoized at the + * controller so subscribers don't re-fire on transactions that + * leave the projection unchanged. + */ + selection: SelectionHandle; + + /** + * 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 + * fire and `select(...)` should not be called. + */ + destroy(): void; +} + +/** + * Selection domain handle exposed on `ui.selection`. Same shape as + * `CommentsHandle` / `ReviewHandle`: snapshot + subscription. Mirrors + * the full `SelectionInfo` projection through the memoized + * `state.selection` slice. + */ +export interface SelectionHandle { + /** Snapshot the current selection slice synchronously. */ + getSnapshot(): SelectionSlice; + /** + * Subscribe to selection slice changes. The listener fires once + * with the initial snapshot, then again only when the projected + * selection state actually changes (memoized — no re-fire on + * typing-only transactions). Returns an unsubscribe. + */ + subscribe(listener: (event: { snapshot: SelectionSlice }) => void): () => void; +} + +/** + * Aggregate toolbar handle exposed on `ui.toolbar`. Compatible with + * `HeadlessToolbarController` from `superdoc/headless-toolbar` so the + * built-in `SuperToolbar.vue` (and any external consumer using the + * standalone controller today) can be migrated without API churn. + */ +export interface ToolbarHandle { + /** Snapshot the current `{ context, commands }` payload synchronously. */ + getSnapshot(): ToolbarSnapshotSlice; + /** + * Subscribe to toolbar snapshot changes. Listener receives an event + * with the latest snapshot. Returns an unsubscribe. + */ + subscribe(listener: (event: { snapshot: ToolbarSnapshotSlice }) => void): () => void; + /** + * Execute a built-in toolbar command. Type-safe payload is enforced + * via the existing `ToolbarPayloadMap`. + */ + execute( + ...args: import('../headless-toolbar/types.js').ToolbarPayloadMap[Id] extends never + ? [id: Id] + : [id: Id, payload: import('../headless-toolbar/types.js').ToolbarPayloadMap[Id]] + ): boolean; +} + +/** + * Per-command handle: state observation + execution for a single + * toolbar command id. + */ +export type CommandHandle = { + /** + * Subscribe to changes in this command's state. The listener fires + * once synchronously with the current state, then again whenever the + * state changes by shallow equality. Returns unsubscribe. + */ + observe(listener: (state: ToolbarCommandHandleState) => void): () => void; + /** Execute this command. Payload is type-checked per-command. */ + execute( + ...args: import('../headless-toolbar/types.js').ToolbarPayloadMap[Id] extends never + ? [] + : [payload: import('../headless-toolbar/types.js').ToolbarPayloadMap[Id]] + ): boolean; +}; + +/** + * Stable per-command state shape. `value` is omitted (`undefined`) when + * the underlying command has no value (e.g., bold), and typed + * per-command via `ToolbarValueMap` otherwise (e.g., `font-size` + * resolves to `string | undefined`). + */ +export type ToolbarCommandHandleState = { + active: boolean; + disabled: boolean; + value: import('../headless-toolbar/types.js').ToolbarValueMap[Id] | undefined; +}; + +/** + * Map of every toolbar command id to its handle. Indexed via + * `ui.commands.bold.observe(...)` etc. The runtime exposes a Proxy so + * any `PublicToolbarItemId` key works without pre-enumerating. + */ +export type CommandsHandle = { + [Id in import('../headless-toolbar/types.js').PublicToolbarItemId]: CommandHandle; +}; + +/** + * Comments domain handle exposed on `ui.comments`. The execute + * methods are convenience facades over `editor.doc.comments.*` — + * they produce identical document mutations to direct doc-API calls. + */ +export interface CommentsHandle { + /** Snapshot the current comments slice synchronously. */ + getSnapshot(): CommentsSlice; + /** + * Subscribe to comments-snapshot changes. Listener fires once + * synchronously with the current snapshot, then again whenever + * items, activeIds, or total change (shallow equality). + * Returns an unsubscribe. + */ + subscribe(listener: (event: { snapshot: CommentsSlice }) => void): () => void; + /** + * Create a comment anchored to the current selection. Reads the + * routed editor's `selection.current().target` and routes through + * `editor.doc.comments.create`. Returns the operation receipt. + */ + createFromSelection(input: { text: string }): import('@superdoc/document-api').Receipt; + /** Resolve a comment via `editor.doc.comments.patch`. */ + resolve(commentId: string): import('@superdoc/document-api').Receipt; + /** + * Reopen a resolved comment via `editor.doc.comments.patch({ status: + * 'active' })`. Currently throws `INVALID_INPUT` on the doc-API + * because the patch input only accepts `'resolved'`; SD-2789 adds + * the lifecycle inverse and reroutes this method to succeed. + */ + reopen(commentId: string): import('@superdoc/document-api').Receipt; + /** Delete a comment via `editor.doc.comments.delete`. */ + delete(commentId: string): import('@superdoc/document-api').Receipt; + /** + * Scroll the viewport to the comment's anchor via + * `ui.viewport.scrollIntoView({ target: EntityAddress })`. Resolves + * to a `{ success: boolean }` receipt. + */ + scrollTo(commentId: string): Promise; +} + +/** + * Review domain handle exposed on `ui.review`. Same architectural + * posture as `CommentsHandle`: every mutation routes through + * `editor.doc.trackChanges.*` (the Document API contract); next / + * previous / scrollTo are UI-only navigation helpers. + */ +export interface ReviewHandle { + /** Snapshot the merged review feed synchronously. */ + getSnapshot(): ReviewSlice; + /** + * Subscribe to review-snapshot changes (items, openCount, activeId). + * Listener fires once synchronously with the current snapshot, then + * again whenever the slice changes by shallow equality. Returns an + * unsubscribe. + */ + subscribe(listener: (event: { snapshot: ReviewSlice }) => void): () => void; + /** Accept a single tracked change via `trackChanges.decide`. */ + accept(changeId: string): import('@superdoc/document-api').Receipt; + /** Reject a single tracked change via `trackChanges.decide`. */ + reject(changeId: string): import('@superdoc/document-api').Receipt; + /** Accept every tracked change via `trackChanges.decide({ scope: 'all' })`. */ + acceptAll(): import('@superdoc/document-api').Receipt; + /** Reject every tracked change via `trackChanges.decide({ scope: 'all' })`. */ + rejectAll(): import('@superdoc/document-api').Receipt; + /** + * Move `activeId` to the next item in the merged feed (document + * order). Wraps to the first item past the last. Returns the new + * active id, or `null` if the feed is empty. + */ + next(): string | null; + /** + * Move `activeId` to the previous item in the merged feed. Wraps + * to the last item past the first. Returns the new active id, or + * `null` if the feed is empty. + */ + previous(): string | null; + /** + * Scroll the viewport to the given item (comment or tracked + * change) and set it as `activeId`. Routes through + * `ui.viewport.scrollIntoView({ target: EntityAddress })`. + */ + scrollTo(id: string): Promise; +} + +/** + * 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 { + /** + * Entity to look up — comment or tracked change by id. Today + * `getRect` resolves rects via the painter's data attributes + * (`data-comment-ids`, `data-track-change-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: import('@superdoc/document-api').EntityAddress; +} + +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 the underlying scroll path 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. Browser-only by + * definition: drives `presentation.navigateTo()` for entity targets + * (story-aware) and `presentation.scrollToPositionAsync()` for text + * targets. Lives on `ui.*` rather than `editor.doc.*` because + * viewport scroll is a UI side-effect, not a request/response + * Document API operation. + */ + 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..38e5990fcb --- /dev/null +++ b/packages/super-editor/src/ui/viewport.test.ts @@ -0,0 +1,309 @@ +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: `presentationEditor.getEntityRects` for geometry + * lookups and `presentationEditor.navigateTo` for entity scroll. + */ +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 navigateTo = vi.fn(async (_target: unknown) => true); + + const editor: { + on: ReturnType; + off: ReturnType; + doc: unknown; + presentationEditor: + | { + getEntityRects: typeof getEntityRects; + navigateTo: typeof navigateTo; + 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 }, + })), + }, + }, + presentationEditor: undefined, + }; + // Self-reference so `presentationEditor.getActiveEditor()` returns the + // same stub editor the toolbar source resolver expects when present. + editor.presentationEditor = { + getEntityRects, + navigateTo, + getActiveEditor: () => editor, + }; + + const superdoc: SuperDocLike = { + activeEditor: editor as never, + config: { documentMode: 'editing' }, + on: vi.fn(), + off: vi.fn(), + }; + + return { superdoc, editor, mocks: { getEntityRects, navigateTo } }; +} + +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(); + }); + + it('regression: getRect resolves through the host editor even when toolbar routing returns a child story editor', () => { + // When focus is in a header / footer / note, the toolbar source + // resolver returns the child story editor — but + // `presentationEditor` lives on the host (body) editor only. + // Routing getRect through the routed child would wrongly return + // `not-ready`. The host's `getEntityRects` is the right call; + // the entity target's `story` field carries the story info. + const { superdoc, mocks } = makeStubs({ + rectsById: { + 'tc-header': [{ pageIndex: 1, left: 5, top: 6, right: 25, bottom: 18, width: 20, height: 12 }], + }, + }); + // Plant a child story editor without its own `presentationEditor` + // and route through it. Without the host fix, getRect would see + // `presentation` undefined and return `not-ready`. + const hostEditor = superdoc.activeEditor as unknown as { + presentationEditor: { getActiveEditor: () => unknown }; + }; + hostEditor.presentationEditor.getActiveEditor = () => ({ doc: {} }); + + const ui = createSuperDocUI({ superdoc }); + + const result = ui.viewport.getRect({ + target: { + kind: 'entity', + entityType: 'trackedChange', + entityId: 'tc-header', + }, + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.rect.width).toBe(20); + expect(mocks.getEntityRects).toHaveBeenCalledTimes(1); + + ui.destroy(); + }); +}); + +describe('ui.viewport.scrollIntoView', () => { + it('navigates entity targets through the presentation editor', 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.navigateTo).toHaveBeenCalledWith(input.target); + ui.destroy(); + }); + + it('returns { success: false } when no presentation editor is mounted', async () => { + const { superdoc } = makeStubs(); + (superdoc.activeEditor as unknown as { presentationEditor: unknown }).presentationEditor = undefined; + 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/package.json b/packages/superdoc/package.json index 2a9b9f4328..508f8faec7 100644 --- a/packages/superdoc/package.json +++ b/packages/superdoc/package.json @@ -47,6 +47,11 @@ "source": "./src/headless-toolbar.js", "import": "./dist/headless-toolbar.es.js" }, + "./ui": { + "types": "./dist/superdoc/src/ui.d.ts", + "source": "./src/ui.js", + "import": "./dist/ui.es.js" + }, "./headless-toolbar/react": { "types": "./dist/superdoc/src/headless-toolbar-react.d.ts", "source": "./src/headless-toolbar-react.js", @@ -68,6 +73,9 @@ "headless-toolbar": [ "./dist/superdoc/src/headless-toolbar.d.ts" ], + "ui": [ + "./dist/superdoc/src/ui.d.ts" + ], "headless-toolbar/react": [ "./dist/superdoc/src/headless-toolbar-react.d.ts" ], diff --git a/packages/superdoc/src/index.js b/packages/superdoc/src/index.js index f1fa825179..8316f0ec47 100644 --- a/packages/superdoc/src/index.js +++ b/packages/superdoc/src/index.js @@ -110,7 +110,6 @@ import { getSchemaIntrospection } from './helpers/schema-introspection.js'; * @typedef {import('@superdoc/super-editor').SelectionApi} SelectionApi * @typedef {import('@superdoc/super-editor').SelectionInfo} SelectionInfo * @typedef {import('@superdoc/super-editor').SelectionCurrentInput} SelectionCurrentInput - * @typedef {import('@superdoc/super-editor').SelectionChangeListener} SelectionChangeListener * @typedef {import('@superdoc/super-editor').ScrollIntoViewInput} ScrollIntoViewInput * @typedef {import('@superdoc/super-editor').ScrollIntoViewOutput} ScrollIntoViewOutput * @typedef {import('@superdoc/super-editor').TextTarget} TextTarget diff --git a/packages/superdoc/src/ui.d.ts b/packages/superdoc/src/ui.d.ts new file mode 100644 index 0000000000..d68201ac10 --- /dev/null +++ b/packages/superdoc/src/ui.d.ts @@ -0,0 +1,28 @@ +export { + createSuperDocUI, + shallowEqual, + type CommandHandle, + type CommandsHandle, + type CommentsHandle, + type CommentsSlice, + type EqualityFn, + type ReviewHandle, + type ReviewItem, + type ReviewSlice, + type SelectionHandle, + type SelectionSlice, + type SelectorFn, + type Subscribable, + type SuperDocEditorLike, + type SuperDocLike, + type SuperDocUI, + type SuperDocUIOptions, + type SuperDocUIState, + type ToolbarCommandHandleState, + type ToolbarHandle, + type ToolbarSnapshotSlice, + type ViewportGetRectInput, + type ViewportHandle, + type ViewportRect, + type ViewportRectResult, +} from '@superdoc/super-editor'; diff --git a/packages/superdoc/src/ui.js b/packages/superdoc/src/ui.js new file mode 100644 index 0000000000..a765e92f72 --- /dev/null +++ b/packages/superdoc/src/ui.js @@ -0,0 +1,14 @@ +/** + * Public sub-entry: `superdoc/ui` + * + * Re-exports the browser-only UI controller from + * `@superdoc/super-editor`. A dedicated `@superdoc/super-editor/ui` + * sub-export (with its own Vite build entry) would tighten bundle + * + IDE-resolve hygiene for consumers; tracked as a follow-up. For + * now consumers only pull the two named runtime exports below, so + * tree-shaking already drops the rest at the consumer's bundler + * step. + * + * Source: `packages/super-editor/src/ui/` + */ +export { createSuperDocUI, shallowEqual } from '@superdoc/super-editor'; diff --git a/packages/superdoc/vite.config.js b/packages/superdoc/vite.config.js index a62fe415ef..3704440f82 100644 --- a/packages/superdoc/vite.config.js +++ b/packages/superdoc/vite.config.js @@ -180,6 +180,7 @@ export default defineConfig(({ mode, command }) => { 'src/headless-toolbar.js', 'src/headless-toolbar-react.js', 'src/headless-toolbar-vue.js', + 'src/ui.js', // Pure JSDoc typedef files (body is `export {}`, no runtime code) 'src/core/types/**', '**/types.js', @@ -202,6 +203,7 @@ export default defineConfig(({ mode, command }) => { 'headless-toolbar': 'src/headless-toolbar.js', 'headless-toolbar-react': 'src/headless-toolbar-react.js', 'headless-toolbar-vue': 'src/headless-toolbar-vue.js', + 'ui': 'src/ui.js', 'super-editor': 'src/super-editor.js', 'types': 'src/types.ts', 'super-editor/docx-zipper': '@core/DocxZipper', diff --git a/tests/behavior/harness/main.ts b/tests/behavior/harness/main.ts index 6639e26425..581b1a1994 100644 --- a/tests/behavior/harness/main.ts +++ b/tests/behavior/harness/main.ts @@ -1,5 +1,8 @@ import 'superdoc/style.css'; import { SuperDoc } from 'superdoc'; +import { createSuperDocUI } from 'superdoc/ui'; + +type SuperDocUIInstance = ReturnType; type SuperDocConfig = ConstructorParameters[0]; type SuperDocInstance = InstanceType; @@ -43,6 +46,15 @@ type HarnessWindow = Window & editor?: unknown; behaviorHarness?: BehaviorHarnessApi; behaviorHarnessInit?: (input?: ContentOverrideInput) => void; + /** + * Optional `superdoc/ui` controller — created lazily by behavior + * tests that exercise `createSuperDocUI`. Tests call + * `window.__bootSuperDocUI()` after `superdocReady` flips, then + * read state through `window.superdocUI` (or call its action + * methods directly). + */ + superdocUI?: SuperDocUIInstance; + __bootSuperDocUI?: () => SuperDocUIInstance; }; const harnessWindow = window as HarnessWindow; @@ -168,6 +180,17 @@ function init(file?: File, content?: ContentOverrideInput) { harnessWindow.editor = (payload as { editor: unknown }).editor; }); harnessWindow.behaviorHarness = buildBehaviorHarnessApi(); + // Lazy-construct the `superdoc/ui` controller on first request. + // We don't auto-build it because most behavior tests don't need + // it, and constructing it eagerly would add edge events to the + // editor for every test run. Tests that exercise `createSuperDocUI` + // call `window.__bootSuperDocUI()` after `superdocReady`. + harnessWindow.__bootSuperDocUI = () => { + if (!harnessWindow.superdocUI) { + harnessWindow.superdocUI = createSuperDocUI({ superdoc }); + } + return harnessWindow.superdocUI; + }; harnessWindow.superdocReady = true; }, }; diff --git a/tests/behavior/helpers/document-api.ts b/tests/behavior/helpers/document-api.ts index 6b24facabb..881d8265b0 100644 --- a/tests/behavior/helpers/document-api.ts +++ b/tests/behavior/helpers/document-api.ts @@ -271,6 +271,17 @@ export async function resolveComment(page: Page, input: { commentId: string }): ); } +/** + * Reopen a previously-resolved comment via the public Document API. + * Routes through `comments.patch({ status: 'active' })` (SD-2789). + */ +export async function reopenComment(page: Page, input: { commentId: string }): Promise { + await page.evaluate( + (payload) => (window as any).editor.doc.comments.patch({ commentId: payload.commentId, status: 'active' }), + input, + ); +} + export async function listComments( page: Page, query: { includeResolved?: boolean } = { includeResolved: true }, diff --git a/tests/behavior/tests/comments/selection-active-comment-ids.spec.ts b/tests/behavior/tests/comments/selection-active-comment-ids.spec.ts new file mode 100644 index 0000000000..bf4186a239 --- /dev/null +++ b/tests/behavior/tests/comments/selection-active-comment-ids.spec.ts @@ -0,0 +1,153 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import type { Page } from '@playwright/test'; +import { addCommentByText, assertDocumentApiReady } from '../../helpers/document-api.js'; + +test.use({ config: { toolbar: 'full', comments: 'on' } }); + +/** + * SD-2792 — `editor.doc.selection.current()` exposes + * `activeCommentIds` and `activeChangeIds` so custom sidebars can + * answer "is there a comment / tracked change under the cursor?" + * without DOM-shaped workarounds. + * + * Unit tests cover the resolver in isolation. This Playwright spec + * runs the real PM transactions against a live editor + Document API + * surface and verifies the projected ids reflect cursor placement + * end-to-end. + */ + +async function activeCommentIds(page: Page): Promise { + return page.evaluate(() => { + const result = (window as any).editor.doc.selection.current({ includeText: false }); + return Array.isArray(result?.activeCommentIds) ? [...result.activeCommentIds] : []; + }); +} + +/** + * Walk the PM doc and return the first inline-text PM position that + * carries a `commentMark` with the given id. Used to drop the caret + * inside a known comment span. + */ +async function pmPositionInsideComment(page: Page, commentId: string): Promise { + return page.evaluate((id) => { + const editor = (window as any).editor; + let pos: number | null = null; + editor.state.doc.descendants((node: any, nodePos: number) => { + if (pos != null) return false; + if (!node.isText || !Array.isArray(node.marks)) return true; + const hit = node.marks.some((m: any) => m.type?.name === 'commentMark' && m.attrs?.commentId === id); + if (hit && node.nodeSize > 1) { + // Mid-text caret = nodePos + 1 lands on the second char, + // which is unambiguously inside the mark. + pos = nodePos + 1; + return false; + } + return true; + }); + return pos; + }, commentId); +} + +/** + * Walk the PM doc and return the first inline-text PM position whose + * inline node has NO `commentMark` (any id). Used to drop the caret + * outside every comment. + */ +async function pmPositionOutsideAnyComment(page: Page): Promise { + return page.evaluate(() => { + const editor = (window as any).editor; + let pos: number | null = null; + editor.state.doc.descendants((node: any, nodePos: number) => { + if (pos != null) return false; + if (!node.isText || node.nodeSize <= 1) return true; + const marks = Array.isArray(node.marks) ? node.marks : []; + const hasComment = marks.some((m: any) => m.type?.name === 'commentMark'); + if (!hasComment) { + pos = nodePos + 1; + return false; + } + return true; + }); + return pos; + }); +} + +test('caret inside a comment span surfaces commentId in selection.current().activeCommentIds', async ({ superdoc }) => { + await assertDocumentApiReady(superdoc.page); + + await superdoc.type('Cursor target inside a comment span here'); + await superdoc.waitForStable(); + + const commentId = await addCommentByText(superdoc.page, { + pattern: 'inside', + text: 'comment for selection probe', + }); + await superdoc.waitForStable(); + + const insidePos = await pmPositionInsideComment(superdoc.page, commentId); + expect(insidePos).toBeGreaterThan(0); + + await superdoc.setTextSelection(insidePos as number); + await superdoc.waitForStable(); + + const ids = await activeCommentIds(superdoc.page); + expect(ids).toContain(commentId); +}); + +test('caret outside any comment span returns an empty activeCommentIds array', async ({ superdoc }) => { + await assertDocumentApiReady(superdoc.page); + + await superdoc.type('Outside zone with one commented word.'); + await superdoc.waitForStable(); + + await addCommentByText(superdoc.page, { + pattern: 'commented', + text: 'isolated comment', + }); + await superdoc.waitForStable(); + + const outsidePos = await pmPositionOutsideAnyComment(superdoc.page); + expect(outsidePos).toBeGreaterThan(0); + + await superdoc.setTextSelection(outsidePos as number); + await superdoc.waitForStable(); + + const ids = await activeCommentIds(superdoc.page); + expect(ids).toEqual([]); +}); + +test('moving the caret between two distinct comment spans switches the active id', async ({ superdoc }) => { + await assertDocumentApiReady(superdoc.page); + + await superdoc.type('alpha bravo charlie delta'); + await superdoc.waitForStable(); + + const commentA = await addCommentByText(superdoc.page, { + pattern: 'bravo', + text: 'comment A', + }); + await superdoc.waitForStable(); + const commentB = await addCommentByText(superdoc.page, { + pattern: 'charlie', + text: 'comment B', + }); + await superdoc.waitForStable(); + + // Caret inside A → activeCommentIds reports A only. + const insideA = await pmPositionInsideComment(superdoc.page, commentA); + expect(insideA).toBeGreaterThan(0); + await superdoc.setTextSelection(insideA as number); + await superdoc.waitForStable(); + const idsA = await activeCommentIds(superdoc.page); + expect(idsA).toContain(commentA); + expect(idsA).not.toContain(commentB); + + // Caret inside B → switches to B only. + const insideB = await pmPositionInsideComment(superdoc.page, commentB); + expect(insideB).toBeGreaterThan(0); + await superdoc.setTextSelection(insideB as number); + await superdoc.waitForStable(); + const idsB = await activeCommentIds(superdoc.page); + expect(idsB).toContain(commentB); + expect(idsB).not.toContain(commentA); +}); diff --git a/tests/behavior/tests/comments/viewport-get-rect.spec.ts b/tests/behavior/tests/comments/viewport-get-rect.spec.ts new file mode 100644 index 0000000000..4c9313c9e8 --- /dev/null +++ b/tests/behavior/tests/comments/viewport-get-rect.spec.ts @@ -0,0 +1,116 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import { addCommentByText, assertDocumentApiReady } from '../../helpers/document-api.js'; + +test.use({ config: { toolbar: 'full', comments: 'on' } }); + +/** + * SD-2793 — `ui.viewport.getRect({ target })` returns the painted-DOM + * rectangle for an entity (comment or tracked change) so consumers + * can pin sticky cards / floating toolbars without reaching into PM + * positions or painter selectors. + * + * Unit tests cover the resolver in jsdom. This Playwright spec runs + * the real layout-engine + painted DOM and verifies: + * + * - getRect returns `success: true` with finite, plausible rect dims + * - `rect.top/left/width/height` match the painted DOM element's + * `getBoundingClientRect()` (within ±1px tolerance for sub-pixel + * rounding across browsers) + * - `pageIndex` is the painted page's index + * - getRect on an unmounted / unknown entity returns + * `success: false, reason: 'not-mounted'` + */ + +test('ui.viewport.getRect returns rects matching the painted comment highlight', async ({ superdoc }) => { + await assertDocumentApiReady(superdoc.page); + + await superdoc.type('viewport rect probe target text'); + await superdoc.waitForStable(); + + const commentId = await addCommentByText(superdoc.page, { + pattern: 'probe', + text: 'comment for getRect probe', + }); + await superdoc.waitForStable(); + await superdoc.assertCommentHighlightExists({ text: 'probe', timeoutMs: 20_000 }); + + const probe = await superdoc.page.evaluate((id) => { + const ui = (window as any).__bootSuperDocUI?.(); + if (!ui) return { uiAvailable: false }; + const result = ui.viewport.getRect({ + target: { kind: 'entity', entityType: 'comment', entityId: id }, + }); + if (!result.success) { + return { uiAvailable: true, success: false, reason: result.reason }; + } + // Capture the first painted highlight's bounding rect for + // cross-comparison. The painter stamps `data-comment-ids=",..."` + // on every text run that anchors the comment. + const highlights = Array.from(document.querySelectorAll('[data-comment-ids]')).filter((el) => + (el.dataset.commentIds ?? '').split(',').some((token) => token.trim() === id), + ); + const first = highlights[0]?.getBoundingClientRect(); + return { + uiAvailable: true, + success: true, + rectsLength: result.rects.length, + rect: result.rect, + pageIndex: result.pageIndex, + paintedFirst: first ? { top: first.top, left: first.left, width: first.width, height: first.height } : null, + }; + }, commentId); + + expect(probe.uiAvailable).toBe(true); + expect(probe.success).toBe(true); + expect(probe.rectsLength).toBeGreaterThan(0); + expect(Number.isFinite((probe as any).rect.top)).toBe(true); + expect(Number.isFinite((probe as any).rect.left)).toBe(true); + expect((probe as any).rect.width).toBeGreaterThan(0); + expect((probe as any).rect.height).toBeGreaterThan(0); + expect(typeof (probe as any).pageIndex).toBe('number'); + + // The rect returned by getRect should align with the painted + // highlight element's own `getBoundingClientRect`. Allow a small + // tolerance — sub-pixel rounding can drift by 1px across browsers + // and zoom levels. + expect((probe as any).paintedFirst).toBeTruthy(); + const dx = Math.abs((probe as any).rect.left - (probe as any).paintedFirst.left); + const dy = Math.abs((probe as any).rect.top - (probe as any).paintedFirst.top); + expect(dx).toBeLessThanOrEqual(1); + expect(dy).toBeLessThanOrEqual(1); +}); + +test('ui.viewport.getRect returns not-mounted for an unknown comment id', async ({ superdoc }) => { + await assertDocumentApiReady(superdoc.page); + + await superdoc.type('any document'); + await superdoc.waitForStable(); + + const result = await superdoc.page.evaluate(() => { + const ui = (window as any).__bootSuperDocUI?.(); + if (!ui) return { uiAvailable: false }; + return ui.viewport.getRect({ + target: { kind: 'entity', entityType: 'comment', entityId: 'no-such-comment-id' }, + }); + }); + + expect((result as any).uiAvailable !== false).toBe(true); + expect((result as any).success).toBe(false); + expect((result as any).reason).toBe('not-mounted'); +}); + +test('ui.viewport.getRect rejects unsupported entity types with invalid-target', async ({ superdoc }) => { + await assertDocumentApiReady(superdoc.page); + + const result = await superdoc.page.evaluate(() => { + const ui = (window as any).__bootSuperDocUI?.(); + if (!ui) return { uiAvailable: false }; + return ui.viewport.getRect({ + target: { kind: 'entity', entityType: 'mystery', entityId: 'x' }, + }); + }); + + expect((result as any).uiAvailable !== false).toBe(true); + expect((result as any).success).toBe(false); + expect((result as any).reason).toBe('invalid-target'); +}); diff --git a/tests/consumer-typecheck/src/customer-scenario.ts b/tests/consumer-typecheck/src/customer-scenario.ts index aa3120a724..3e2006e5a6 100644 --- a/tests/consumer-typecheck/src/customer-scenario.ts +++ b/tests/consumer-typecheck/src/customer-scenario.ts @@ -122,12 +122,11 @@ import type { SelectionApi, SelectionInfo, SelectionCurrentInput, - SelectionChangeListener, TextTarget, TextAddress, TextSegment, - // Ranges — scrollIntoView + // Viewport scroll (now exposed via ui.viewport.scrollIntoView) ScrollIntoViewInput, ScrollIntoViewOutput, EntityAddress, @@ -481,23 +480,21 @@ function testSelectionAPI(pe: PresentationEditor) { } // ============================================ -// SECTION 8c: Document API — ranges.scrollIntoView +// SECTION 8c: Viewport scroll — `ui.viewport.scrollIntoView` // ============================================ /** - * Smoke test for `editor.doc.ranges.scrollIntoView`. Consumers need to - * construct all three target shapes (TextAddress, TextTarget, EntityAddress) - * and await the returned Promise. The function is type-checked only — - * it is not called at runtime. + * Type-only smoke test for `ui.viewport.scrollIntoView`. Consumers + * construct `ScrollIntoViewInput` (TextAddress, TextTarget, or + * EntityAddress) and pass it to the viewport handle, which returns + * `Promise`. */ -async function testRangesScrollIntoView(editor: Editor) { - const api = (editor as any).doc.ranges as { - scrollIntoView(input: ScrollIntoViewInput): Promise; - }; - +async function testViewportScrollIntoView(viewport: { + scrollIntoView(input: ScrollIntoViewInput): Promise; +}) { // TextAddress — single-block target. const textAddress: TextAddress = { kind: 'text', blockId: 'p1', range: { start: 0, end: 10 } }; - const resTextAddr: ScrollIntoViewOutput = await api.scrollIntoView({ target: textAddress }); + const resTextAddr: ScrollIntoViewOutput = await viewport.scrollIntoView({ target: textAddress }); const successA: boolean = resTextAddr.success; void successA; @@ -507,7 +504,7 @@ async function testRangesScrollIntoView(editor: Editor) { kind: 'text', segments: [seg, { blockId: 'p2', range: { start: 0, end: 3 } }], }; - const resTextTarget: ScrollIntoViewOutput = await api.scrollIntoView({ + const resTextTarget: ScrollIntoViewOutput = await viewport.scrollIntoView({ target: textTarget, block: 'start', behavior: 'auto', @@ -521,8 +518,8 @@ async function testRangesScrollIntoView(editor: Editor) { entityType: 'trackedChange', entityId: 'tc_1', }; - await api.scrollIntoView({ target: commentAddr, behavior: 'smooth' }); - await api.scrollIntoView({ target: trackedAddr, block: 'center' }); + await viewport.scrollIntoView({ target: commentAddr, behavior: 'smooth' }); + await viewport.scrollIntoView({ target: trackedAddr, block: 'center' }); // Construct a full input object and pass it through — verifies the // combined type compiles for consumers who build inputs programmatically. @@ -531,7 +528,7 @@ async function testRangesScrollIntoView(editor: Editor) { block: 'nearest', behavior: 'auto', }; - await api.scrollIntoView(fullInput); + await viewport.scrollIntoView(fullInput); } // ============================================ @@ -593,19 +590,10 @@ function testDocSelectionPrimitives(editor: Editor) { void ta; void tt; - // Subscription: onChange returns an unsubscribe. Listener receives - // a SelectionInfo. The parameter is annotated explicitly because - // document-api types are surfaced via ambient `any` shims in the - // published package (workspace-package privacy), so type inference - // through SelectionChangeListener collapses to implicit any. - const listener: SelectionChangeListener = (next: SelectionInfo) => { - const nextTarget: TextTarget | null = next.target; - void nextTarget; - }; - const unsubscribe: () => void = api.onChange(listener); - unsubscribe(); - - // The exported SelectionCurrentInput type is the argument shape. + // The Document API contract is request/response: `current()` is + // the read primitive. For change subscriptions, use the + // `superdoc/ui` selector substrate + // (`createSuperDocUI({ superdoc }).select(s => s.selection, ...)`). const input: SelectionCurrentInput = { includeText: true }; api.current(input); } @@ -846,6 +834,88 @@ function testVueComponents() { const slashMenu = SlashMenu; } +// ============================================ +// SECTION 18: superdoc/ui sub-entry — `createSuperDocUI({ superdoc })` +// ============================================ + +/** + * Type-level smoke test for the published `superdoc/ui` sub-entry. + * + * Mirrors the `superdoc/headless-toolbar` shim pattern: this module + * is a thin re-export of the browser-only UI controller from + * `@superdoc/super-editor`. Without a consumer-perspective import, + * the published sub-entry would only be type-checked from inside the + * monorepo and a broken re-export could ship undetected. + */ +import { + createSuperDocUI, + shallowEqual, + type CommentsHandle, + type CommentsSlice, + type EqualityFn, + type ReviewHandle, + type ReviewItem, + type ReviewSlice, + type SelectionSlice, + type SelectorFn, + type Subscribable, + type SuperDocEditorLike, + type SuperDocLike, + type SuperDocUI, + type SuperDocUIOptions, + type SuperDocUIState, + type ViewportGetRectInput, + type ViewportHandle, + type ViewportRect, + type ViewportRectResult, +} from 'superdoc/ui'; + +function testSuperDocUISubEntry() { + // Runtime exports compile and have callable shapes. + const factory: (options: SuperDocUIOptions) => SuperDocUI = createSuperDocUI; + const eq: EqualityFn = shallowEqual; + void factory; + void eq; + + // Public handle / slice types resolve through the sub-entry. + type AssertHandles = { + toolbar: SuperDocUI['toolbar']; + commands: SuperDocUI['commands']; + comments: CommentsHandle; + review: ReviewHandle; + viewport: ViewportHandle; + state: SuperDocUIState; + }; + type AssertSlices = { + selection: SelectionSlice; + comments: CommentsSlice; + review: ReviewSlice; + reviewItem: ReviewItem; + }; + type AssertViewportShapes = { + input: ViewportGetRectInput; + rect: ViewportRect; + result: ViewportRectResult; + }; + type AssertSubstrate = { + selector: SelectorFn; + sub: Subscribable; + }; + type AssertHostShapes = { + superdoc: SuperDocLike; + editor: SuperDocEditorLike; + }; + + // `void` the type aliases so the file stays a smoke test, not a + // sample. Touching each at value level via `null as never` keeps + // the typechecker honest without runtime work. + void (null as never as AssertHandles); + void (null as never as AssertSlices); + void (null as never as AssertViewportShapes); + void (null as never as AssertSubstrate); + void (null as never as AssertHostShapes); +} + export { testTypeShapes, testEditorOptions, @@ -856,6 +926,7 @@ export { testReplaceFile, testPresentationEditorMethods, testSelectionAPI, + testViewportScrollIntoView, testEditorEvents, testPresentationEditorEvents, testToolbar, @@ -871,4 +942,5 @@ export { testAdditionalFunctions, testAdditionalClasses, testVueComponents, + testSuperDocUISubEntry, };