From edde1aa7019b62ede361e1a92c105aa2480a8dc0 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 28 Apr 2026 18:30:35 -0300 Subject: [PATCH 01/16] feat(superdoc/ui): createSuperDocUI skeleton + selector substrate (SD-2794) Foundational ticket for the `superdoc/ui` package. Sibling tickets layer domain namespaces (toolbar, commands, comments, review, viewport, selection) on top of `ui.select`. The architectural counterpart to the Document API: - `editor.doc.*`: stateless contract, server + client, request/response - `createSuperDocUI({ superdoc })`: browser-only state controller Why this exists: - Subscriptions, viewport geometry, and per-command observables don't fit on a request/response contract. The closed PR #2978 surfaced this by forcing a `META_MEMBER_PATHS` exemption on the doc-api. - `superdoc/headless-toolbar` already shipped a working controller pattern; this package is the same shape but extends to comments, tracked changes, viewport, and selection. API: ```ts import { createSuperDocUI, shallowEqual } from 'superdoc/ui'; const ui = createSuperDocUI({ superdoc }); const sub = ui.select( (state) => ({ mode: state.documentMode, empty: state.selection.empty }), shallowEqual, ); const off = sub.subscribe((slice) => render(slice)); ui.destroy(); ``` Selector substrate is the canonical observation primitive everything else is built on. Domain namespaces, per-command observables, and React/ Vue adapters are wrappers over `ui.select`. Default equality is `Object.is`; `shallowEqual` is exported for object slices. Internals: - One unified state model (selection slice + documentMode for now). Sibling tickets extend the shape via TypeScript module augmentation. - Source events normalized: `transaction`, `selectionUpdate`, `commentsUpdate`, `commentsLoaded`, `comment-positions`, `trackedChangesUpdate` from the editor; `editorCreate`, `document-mode-change`, `zoomChange` from SuperDoc. Consumers never see editor-internal vocabulary. - Bursts coalesced to one snapshot rebuild per microtask so multi-step transactions and DOCX reload don't storm subscribers. - Listener errors are caught so one buggy subscriber can't wedge the editor's event loop. - `editorCreate` re-attaches editor listeners when activeEditor swaps. - `destroy()` tears down all source listeners. Package layout mirrors `superdoc/headless-toolbar`: - Source: `packages/super-editor/src/ui/` - Public sub-entry: `superdoc/ui` via re-export shim at `packages/superdoc/src/ui.js` - Sub-paths reserved for `superdoc/ui/react` and `superdoc/ui/vue` (filed separately). Verified: - 11 unit tests cover initial-emit, dedup-by-equality, microtask coalescing, multi-subscriber lifecycle, destroy teardown, editor swap, listener-error isolation - super-editor: 11898/13 skipped/0 fail - superdoc package builds: `dist/ui.es.js` + `dist/superdoc/src/ui.d.ts` emit; `import { createSuperDocUI } from 'superdoc/ui'` resolves at runtime and type-check Out of scope (sibling tickets under SD-2667): - ui.toolbar / ui.commands.* (SD-2796) - ui.comments (SD-2790) - ui.review (SD-2791) - ui.viewport (SD-2793) - React + Vue adapters - Refactoring SuperToolbar.vue / CommentsLayer to consume superdoc/ui - Migrating selection.onChange off doc-api (SD-2795) --- packages/super-editor/src/editors/v1/index.js | 3 + packages/super-editor/src/index.ts | 13 + .../src/ui/create-super-doc-ui.test.ts | 306 ++++++++++++++++++ .../src/ui/create-super-doc-ui.ts | 180 +++++++++++ packages/super-editor/src/ui/equality.ts | 34 ++ packages/super-editor/src/ui/index.ts | 32 ++ packages/super-editor/src/ui/types.ts | 117 +++++++ packages/superdoc/package.json | 8 + packages/superdoc/src/ui.js | 11 + packages/superdoc/vite.config.js | 2 + 10 files changed, 706 insertions(+) create mode 100644 packages/super-editor/src/ui/create-super-doc-ui.test.ts create mode 100644 packages/super-editor/src/ui/create-super-doc-ui.ts create mode 100644 packages/super-editor/src/ui/equality.ts create mode 100644 packages/super-editor/src/ui/index.ts create mode 100644 packages/super-editor/src/ui/types.ts create mode 100644 packages/superdoc/src/ui.js 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/index.ts b/packages/super-editor/src/index.ts index 62d5e9d70d..e176cd2740 100644 --- a/packages/super-editor/src/index.ts +++ b/packages/super-editor/src/index.ts @@ -162,3 +162,16 @@ export type { ToolbarTarget, ToolbarValueMap, } from './headless-toolbar/types.js'; + +// superdoc/ui public types (browser UI controller) +export type { + EqualityFn, + SelectorFn, + SelectionSlice, + Subscribable, + SuperDocEditorLike, + SuperDocLike, + SuperDocUI, + SuperDocUIOptions, + SuperDocUIState, +} from './ui/types.js'; 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..8f609e1870 --- /dev/null +++ b/packages/super-editor/src/ui/create-super-doc-ui.test.ts @@ -0,0 +1,306 @@ +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[]) { + editorListeners.get(event)?.forEach((handler) => handler(...args)); + }, + fireSuperdoc(event: string, ...args: unknown[]) { + superdocListeners.get(event)?.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('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('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..447528b07c --- /dev/null +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -0,0 +1,180 @@ +import type { + EqualityFn, + SelectorFn, + SuperDocEditorLike, + SuperDocUI, + SuperDocUIOptions, + SuperDocUIState, + Subscribable, +} 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', + 'trackedChangesUpdate', +] as const; + +const SUPERDOC_EVENTS = ['editorCreate', 'document-mode-change', 'zoomChange'] as const; + +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. + } + }); + }); + }; + + const computeState = (): SuperDocUIState => { + const editor = superdoc.activeEditor ?? null; + 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; + return { + ready, + documentMode, + selection: { empty, quotedText }, + }; + }; + + // 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 activeEditor reference can swap on + // `editorCreate`, so we re-attach listeners each time it does. + let currentEditor: SuperDocEditorLike | null = null; + let currentEditorTeardown: (() => void) | null = null; + + const attachEditorListeners = () => { + const next = superdoc.activeEditor ?? null; + 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); + }); + currentEditorTeardown = () => { + EDITOR_EVENTS.forEach((name) => next.off?.(name, scheduleNotify)); + }; + }; + + attachEditorListeners(); + if (typeof superdoc.on === 'function') { + superdoc.on?.('editorCreate', attachEditorListeners); + } + teardown.push(() => { + if (typeof superdoc.off === 'function') { + superdoc.off?.('editorCreate', attachEditorListeners); + } + 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 + } + }); + }; + + stateChangeListeners.add(onStateChange); + + return { + get(): TSlice { + return last; + }, + subscribe(listener) { + 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); + }; + }, + }; + }; + + const destroy = () => { + if (destroyed) return; + destroyed = true; + stateChangeListeners.clear(); + teardown.forEach((fn) => { + try { + fn(); + } catch { + // teardown is best-effort + } + }); + teardown.length = 0; + }; + + return { select, 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..d895b34b2f --- /dev/null +++ b/packages/super-editor/src/ui/index.ts @@ -0,0 +1,32 @@ +/** + * `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 { + EqualityFn, + SelectorFn, + SelectionSlice, + Subscribable, + SuperDocEditorLike, + SuperDocLike, + SuperDocUI, + SuperDocUIOptions, + SuperDocUIState, +} from './types.js'; diff --git a/packages/super-editor/src/ui/types.ts b/packages/super-editor/src/ui/types.ts new file mode 100644 index 0000000000..b431cd654e --- /dev/null +++ b/packages/super-editor/src/ui/types.ts @@ -0,0 +1,117 @@ +/** + * 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' }; +} + +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; + }; + }; + }; +} + +/** + * 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; +} + +export interface SelectionSlice { + empty: boolean; + /** The selected text, or '' when the selection is collapsed. */ + quotedText: string; +} + +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; + + /** + * 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; +} 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/ui.js b/packages/superdoc/src/ui.js new file mode 100644 index 0000000000..36c64c21f0 --- /dev/null +++ b/packages/superdoc/src/ui.js @@ -0,0 +1,11 @@ +/** + * Public sub-entry: `superdoc/ui` + * + * Re-exports the browser-only UI controller from `@superdoc/super-editor`, + * mirroring the `superdoc/headless-toolbar` shim pattern. + * + * Source: `packages/super-editor/src/ui/` + * Domain namespaces (toolbar, comments, review, viewport, selection, + * commands) are filed under SD-2667 and layer on top of `ui.select`. + */ +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', From 9eefaf57c9919e63b64f4cc4e19bd72725f66b6a Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 28 Apr 2026 19:12:13 -0300 Subject: [PATCH 02/16] fix(superdoc/ui): route selection through PresentationEditor + expose public types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two review findings on PR #2979: 1. **Selection followed `superdoc.activeEditor` instead of the routed editor.** When the user is editing a header / footer / footnote / endnote inside a paginated SuperDoc, `superdoc.activeEditor` stays on the body editor while `PresentationEditor.getActiveEditor()` routes to the story editor. Reading selection from `superdoc.activeEditor.doc` left `state.selection` on the body selection, and the controller never subscribed to the routed editor's events — so any UI built on `ui.select((s) => s.selection, ...)` was wrong outside the body. Fix: reuse the existing `resolveToolbarSources` helper from `headless-toolbar` so the UI controller, the toolbar registry, and any future domain agree on which editor is active. Read selection through that routed editor in `computeState()`. Subscribe to the PresentationEditor's `headerFooterEditingContext`, `headerFooterUpdate`, `headerFooterTransaction`, `activeSurfaceChange`, and `historyStateChange` events; on `activeSurfaceChange` re-attach editor listeners to the new routed editor and notify subscribers. Falls back cleanly to `superdoc.activeEditor` when no PresentationEditor is mounted (e.g. server-side stubs in tests). 2. **`superdoc/ui` public sub-entry didn't re-export types.** The shim at `packages/superdoc/src/ui.js` only re-exported values, so the generated `dist/superdoc/src/ui.d.ts` didn't carry the `SuperDocUI`, `SuperDocUIState`, `SuperDocUIOptions`, etc. types. `import type { SuperDocUIState } from 'superdoc/ui'` failed for consumers building typed wrappers. Fix: add a sibling `packages/superdoc/src/ui.d.ts` that re-exports types alongside values, mirroring the existing `headless-toolbar.d.ts` pattern. Test stub also got the snapshot-on-iterate fix (mirrors the SD-2796 PR's stub fix): when handlers re-attach to the same Set during iteration (now possible via presentation re-routing), the test bus must iterate a frozen list to avoid recursion. Verified: 12 unit tests (added presentation-routing test). Full super-editor suite: 11899 / 13 skipped / 0 fail. `pnpm --filter superdoc build` clean — `dist/superdoc/src/ui.d.ts` now re-exports the type set, `import type { SuperDocUIState } from 'superdoc/ui'` resolves. --- .../src/ui/create-super-doc-ui.test.ts | 96 +++++++++++++++- .../src/ui/create-super-doc-ui.ts | 108 +++++++++++++++++- packages/superdoc/src/ui.d.ts | 13 +++ 3 files changed, 211 insertions(+), 6 deletions(-) create mode 100644 packages/superdoc/src/ui.d.ts diff --git a/packages/super-editor/src/ui/create-super-doc-ui.test.ts b/packages/super-editor/src/ui/create-super-doc-ui.test.ts index 8f609e1870..9733b7bd9b 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.test.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.test.ts @@ -60,10 +60,18 @@ function makeSuperdocStub( }), fireEditor(event: string, ...args: unknown[]) { - editorListeners.get(event)?.forEach((handler) => handler(...args)); + const handlers = editorListeners.get(event); + if (!handlers) return; + // Snapshot before iterating: handlers can mutate the registration + // set (e.g., re-attach on surface change), and a Set's forEach + // picks up newly-added handlers mid-loop. Real editor event buses + // iterate a frozen list. + [...handlers].forEach((handler) => handler(...args)); }, fireSuperdoc(event: string, ...args: unknown[]) { - superdocListeners.get(event)?.forEach((handler) => handler(...args)); + const handlers = superdocListeners.get(event); + if (!handlers) return; + [...handlers].forEach((handler) => handler(...args)); }, setSelection(empty: boolean, text = '') { selectionEmpty = empty; @@ -278,6 +286,90 @@ describe('createSuperDocUI', () => { 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('listener errors do not propagate to the editor or other subscribers', async () => { const superdoc = makeSuperdocStub(); const ui = createSuperDocUI({ superdoc }); diff --git a/packages/super-editor/src/ui/create-super-doc-ui.ts b/packages/super-editor/src/ui/create-super-doc-ui.ts index 447528b07c..4471889c4b 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -1,3 +1,4 @@ +import { resolveToolbarSources } from '../headless-toolbar/resolve-toolbar-sources.js'; import type { EqualityFn, SelectorFn, @@ -30,6 +31,59 @@ const EDITOR_EVENTS = [ 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; + +/** + * 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 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; @@ -58,7 +112,11 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { }; const computeState = (): SuperDocUIState => { - const editor = superdoc.activeEditor ?? null; + // 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; @@ -84,13 +142,15 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { }); } - // Editor events: the activeEditor reference can swap on - // `editorCreate`, so we re-attach listeners each time it does. + // 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 attachEditorListeners = () => { - const next = superdoc.activeEditor ?? null; + const next = resolveRoutedEditor(superdoc); if (next === currentEditor) return; currentEditorTeardown?.(); currentEditorTeardown = null; @@ -103,16 +163,56 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { currentEditorTeardown = () => { EDITOR_EVENTS.forEach((name) => next.off?.(name, scheduleNotify)); }; + // The set of source events changed — recompute state so subscribers + // see the new routed editor's selection. + 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; diff --git a/packages/superdoc/src/ui.d.ts b/packages/superdoc/src/ui.d.ts new file mode 100644 index 0000000000..9b39f862ba --- /dev/null +++ b/packages/superdoc/src/ui.d.ts @@ -0,0 +1,13 @@ +export { + createSuperDocUI, + shallowEqual, + type EqualityFn, + type SelectorFn, + type SelectionSlice, + type Subscribable, + type SuperDocEditorLike, + type SuperDocLike, + type SuperDocUI, + type SuperDocUIOptions, + type SuperDocUIState, +} from '@superdoc/super-editor'; From b595c58866f72a825dce417af87ca0b8509ee20f Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 28 Apr 2026 19:15:40 -0300 Subject: [PATCH 03/16] fix(superdoc/ui): refcount selector listener so unsubscribed selectors don't leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Codex P1 finding on PR #2979. Before: `ui.select(selector).subscribe(cb)` permanently registered an `onStateChange` closure in the controller's `stateChangeListeners`, even after the consumer's unsubscribe ran. Long-lived sessions with React/Vue components that mount and unmount accumulated dead closures that still recomputed on every editor event — unbounded memory growth and O(N) extra work per event. Fix: refcount the controller-level listener. - First `subscribe()` on a slice attaches `onStateChange` to `stateChangeListeners` and refreshes `last` so the initial emit isn't stale (state may have evolved between `select()` and `subscribe()`). - Last unsubscribe detaches `onStateChange`. Subsequent editor events do not invoke this slice's selector. - `get()` recomputes when no subscribers are attached so untracked snapshots stay accurate. Regression tests: - 100 select+subscribe+unsubscribe cycles followed by one editor event must invoke the selector zero times (no stale closures). - A slice with two subscribers; first unsubscribe keeps the listener active, second detaches it. - `get()` returns fresh state after upstream changes when no subscribers are attached. --- .../src/ui/create-super-doc-ui.test.ts | 79 +++++++++++++++++++ .../src/ui/create-super-doc-ui.ts | 25 +++++- 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/ui/create-super-doc-ui.test.ts b/packages/super-editor/src/ui/create-super-doc-ui.test.ts index 9733b7bd9b..35c858297c 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.test.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.test.ts @@ -221,6 +221,85 @@ describe('createSuperDocUI', () => { 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 }); diff --git a/packages/super-editor/src/ui/create-super-doc-ui.ts b/packages/super-editor/src/ui/create-super-doc-ui.ts index 4471889c4b..ee45ad66e1 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -238,13 +238,31 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { }); }; - stateChangeListeners.add(onStateChange); - + // 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 @@ -257,6 +275,9 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { } return () => { listeners.delete(listener); + if (listeners.size === 0) { + stateChangeListeners.delete(onStateChange); + } }; }, }; From 4733adbbd83bc21d3602cc9dbf348dedadfaf00a Mon Sep 17 00:00:00 2001 From: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:37:27 -0300 Subject: [PATCH 04/16] feat(superdoc/ui): toolbar domain + per-command observables (SD-2796) (#2980) Adds `ui.toolbar` (aggregate) and `ui.commands.` (per-command) on top of the SD-2794 selector substrate. The toolbar surface is the first domain to land on `superdoc/ui`; it makes "bring your own toolbar" a single mental model that consumers wire to React, Vue, or vanilla DOM. ```ts import { createSuperDocUI } from 'superdoc/ui'; const ui = createSuperDocUI({ superdoc }); // Aggregate (HeadlessToolbarController-shaped) ui.toolbar.subscribe(({ snapshot }) => render(snapshot)); ui.toolbar.execute('bold'); // Per-command observable (CKEditor-style fine-grained binding) ui.commands.bold.observe(({ active, disabled }) => boldBtn.set(active)); ui.commands.bold.execute(); ui.commands['font-size'].observe(({ value }) => fontInput.set(value)); ui.commands['font-size'].execute('14pt'); ``` Implementation: - Internal `createHeadlessToolbar({ superdoc })` is instantiated once per `createSuperDocUI` call and feeds `state.toolbar`. `ui.toolbar` delegates `getSnapshot` / `execute` to that controller and routes `subscribe` through the substrate so subscribers ride the same microtask-coalesced burst pattern as `ui.select` consumers. - `ui.commands` is a Proxy returning per-id handles, cached by id so reference identity is stable (matters for React `useMemo` deps and for consumers comparing handles). Each handle's `observe` is internally `ui.select((s) => s.toolbar.commands[id], shallowEqual)`, so a button bound to one command does not re-render when an unrelated command's state changes. - Unknown command ids fall back to `{ active: false, disabled: true, value: undefined }` rather than throwing, so consumer code paths remain forgiving while custom-command registration (FRICTION S3) is filed as follow-up scope. - Built-in `SuperToolbar.vue` and `superdoc/headless-toolbar` are unchanged here; both will migrate to consume `ui.toolbar` in follow-up tickets so this PR stays small and review-friendly. Why these three shapes coexist (per SD-2667 research): - Aggregate (`ui.toolbar.subscribe`): matches today's `HeadlessToolbarController` shape; lets external consumers using `superdoc/headless-toolbar` migrate without rewriting render code. - Per-command (`ui.commands..observe`): CKEditor 5 pattern. A 50- button toolbar with per-command observables only re-renders the button whose state changed, not the whole bar. - Selector substrate (`ui.select`): the underlying primitive. Custom toolbars with cross-command derived state (e.g. "is any heading active?") consume that. Stub-bug found in the SD-2794 test fixture: `fireEditor` iterated a live Set while the internal headless-toolbar rebound listeners on every change, picking up the new handler mid-loop and recursing. Fix snapshots the handler list before iterating, matching how real editor event buses behave. Verified: 19 unit tests (11 selector substrate + 8 toolbar/commands). Full super-editor suite: 11906/13 skipped/0 fail. `pnpm --filter superdoc build` clean. Out of scope (filed under SD-2796 as follow-ups inside SD-2667): - `ui.commands.register({ id, execute, getState })` (closes FRICTION S3, the closed toolbar registry). - Refactor `superdoc/headless-toolbar` into a shim around `createSuperDocUI({ superdoc }).toolbar`. - Refactor `SuperToolbar.vue` to consume `ui.toolbar` (visual / behavior parity validation). --- .../create-toolbar-snapshot.ts | 18 +- .../src/ui/create-super-doc-ui.test.ts | 7 +- .../src/ui/create-super-doc-ui.ts | 162 ++++++++++++- packages/super-editor/src/ui/toolbar.test.ts | 227 ++++++++++++++++++ packages/super-editor/src/ui/types.ts | 97 ++++++++ 5 files changed, 506 insertions(+), 5 deletions(-) create mode 100644 packages/super-editor/src/ui/toolbar.test.ts 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/ui/create-super-doc-ui.test.ts b/packages/super-editor/src/ui/create-super-doc-ui.test.ts index 35c858297c..028be44f3e 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.test.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.test.ts @@ -63,9 +63,10 @@ function makeSuperdocStub( const handlers = editorListeners.get(event); if (!handlers) return; // Snapshot before iterating: handlers can mutate the registration - // set (e.g., re-attach on surface change), and a Set's forEach - // picks up newly-added handlers mid-loop. Real editor event buses - // iterate a frozen list. + // 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[]) { diff --git a/packages/super-editor/src/ui/create-super-doc-ui.ts b/packages/super-editor/src/ui/create-super-doc-ui.ts index ee45ad66e1..de8baec2c0 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -1,5 +1,16 @@ +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 { shallowEqual } from './equality.js'; +import type { + CommandHandle, + CommandsHandle, EqualityFn, SelectorFn, SuperDocEditorLike, @@ -7,6 +18,8 @@ import type { SuperDocUIOptions, SuperDocUIState, Subscribable, + ToolbarCommandHandleState, + ToolbarHandle, } from './types.js'; /** @@ -47,6 +60,28 @@ const PRESENTATION_EVENTS = [ '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[]; + /** * Resolve the **routed** editor — the body, header, footer, or note * editor that PresentationEditor currently routes input/selection to. @@ -111,6 +146,44 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { }); }; + // 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. The Vue UI + // and any external `superdoc/headless-toolbar` consumer can keep + // using their existing entry points; this is the single source of + // truth at runtime. + // + // The structural cast is safe at runtime: the SuperDoc Vue instance + // satisfies HeadlessToolbarSuperdocHost (with `Editor` for + // activeEditor) at runtime; we accept the looser SuperDocLike at the + // public boundary so this controller can be unit-tested with stubs. + // 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 now 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 + } + }); + const computeState = (): SuperDocUIState => { // Route through PresentationEditor when active so selection state // follows the body/header/footer/note editor the user is actually @@ -126,6 +199,7 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { ready, documentMode, selection: { empty, quotedText }, + toolbar: toolbarSnapshot, }; }; @@ -283,10 +357,96 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { }; }; + // 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; + }, + }); + const destroy = () => { if (destroyed) return; destroyed = true; stateChangeListeners.clear(); + commandHandleCache.clear(); + commandSubscribableCache.clear(); teardown.forEach((fn) => { try { fn(); @@ -297,5 +457,5 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { teardown.length = 0; }; - return { select, destroy }; + return { select, toolbar, commands, destroy }; } 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 index b431cd654e..da7aa775ab 100644 --- a/packages/super-editor/src/ui/types.ts +++ b/packages/super-editor/src/ui/types.ts @@ -84,8 +84,22 @@ export interface SuperDocUIState { 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; } +/** + * 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; + export interface SelectionSlice { empty: boolean; /** The selected text, or '' when the selection is collapsed. */ @@ -108,6 +122,24 @@ export interface SuperDocUI { */ 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; + /** * Tear down all internal subscriptions to the editor / SuperDoc * instance / presentation editor. After destroy, no listeners will @@ -115,3 +147,68 @@ export interface SuperDocUI { */ destroy(): 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; +}; From fb69482ff407c69834e72b667029c1f6de2b1c4e Mon Sep 17 00:00:00 2001 From: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:08:33 -0300 Subject: [PATCH 05/16] feat(superdoc/ui): comments domain (subscribe + actions) (SD-2790) (#2982) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(superdoc/ui): comments domain (subscribe + actions) (SD-2790) Adds `ui.comments` to `createSuperDocUI`: a single subscription + actions surface for custom comments sidebars built on top of SuperDoc. ```ts import { createSuperDocUI } from 'superdoc/ui'; const ui = createSuperDocUI({ superdoc }); // Snapshot ui.comments.subscribe(({ snapshot }) => { // snapshot.items: CommentInfo[] // snapshot.activeIds: string[] (mirrors selection.current().activeCommentIds) // snapshot.total: number }); // Actions — every mutation routes through editor.doc.* (Document API) ui.comments.createFromSelection({ text }); ui.comments.resolve(commentId); ui.comments.reopen(commentId); // routes to comments.patch({ status: 'active' }) ui.comments.delete(commentId); ui.comments.scrollTo(commentId); // ranges.scrollIntoView({ kind: 'entity', ... }) ``` Architecture: - Subscribe runs through the SD-2794 selector substrate (`shallowEqual`-deduped). Comments-list is cached at controller level and refreshed on `commentsUpdate` / `commentsLoaded` editor events ahead of `scheduleNotify` so `state.comments.items` is fresh when subscribers see the next snapshot. `activeIds` reads from `selection.current()` per `computeState()` call (cheap; one walk the resolver already does for `activeMarks`). - `activeIds` falls back to `[]` when `selection.current()` predates SD-2792 (no `activeCommentIds` field). When SD-2792 lands, the fallback becomes never-used; nothing in this PR breaks if SD-2792 is reverted. - All execution paths route through `editor.doc.comments.*` and `editor.doc.ranges.scrollIntoView`. `ui.comments` is a UI-facing adapter, NOT a parallel mutation contract — `ui.comments.resolve(id)` and `editor.doc.comments.patch({ id, status: 'resolved' })` produce the same document mutation by construction. - `ui.comments.reopen(id)` routes to `comments.patch({ status: 'active' })`. Today doc-API validation rejects 'active' until SD-2789 ships the lifecycle inverse — that surfaces an INVALID_INPUT receipt rather than a silent no-op, which is the correct visible behavior for a not-yet-shipped operation. Stacks on PR #2979 (SD-2794 skeleton). Unrelated to PR #2980 (SD-2796 toolbar) and PR #2981 (SD-2792 active ids); rebases when those merge. Verified: 26 unit tests (15 substrate + 11 comments). Full super-editor: 11913 / 13 skipped / 0 fail. `pnpm --filter superdoc build` clean — `dist/superdoc/src/ui.d.ts` re-exports the new `CommentsHandle` and `CommentsSlice` types. Out of scope (separate tickets): - Threaded reply UX - `ui.comments.reopen` body — depends on SD-2789 (today routes to a doc-API path that throws INVALID_INPUT) - Refactoring `dropin-assessment` SuperDocAdapter to consume `ui.comments` instead of `superdoc.on('commentsUpdate')` — separate PR once stack lands * fix(superdoc/ui): clear comments cache when list() refresh fails Addresses PR #2982 review (P1). When `editor.doc.comments.list()` threw mid-refresh, the previous code preserved the prior cache so a "transient" failure wouldn't blank the UI. Real failure mode that matters more: during a document / editor swap, the new editor can throw transiently while initializing, and keeping the prior value silently leaks the old document's comments into the new editor's snapshot until the next successful refresh. Reset to `EMPTY_COMMENTS_LIST` on failure. Briefly rendering an empty list is a much better failure mode than rendering the wrong document's comments. Regression test: a `list()` that throws on `commentsUpdate` clears the cache so the next snapshot reports `total: 0` / `items: []`. * fix(superdoc/ui): refresh on own mutations + stable activeIds + barrel exports Addresses three review findings on PR #2982: P1: own-mutation refresh. Each ui.comments.* mutation now calls refreshAndNotify() after the doc-API call returns. The underlying wrappers don't emit a single canonical event for every mutation (some go through transaction only, some emit commentsUpdate ahead of the entity-store finishing) — relying on those events alone left subscribers seeing stale items/total until some later event fired. Refreshing here makes the post-mutation state visible synchronously to the next snapshot. P2-3: activeIds reference stability. Pre-SD-2792 selection results have no activeCommentIds field; the previous fallback `?? []` allocated a fresh array per computeState() call, defeating shallowEqual on the comments snapshot — every selection event re-fired ui.comments.subscribe even when the slice was unchanged. Use a frozen module-scope sentinel (`EMPTY_ACTIVE_IDS`) so the array reference is stable. P2-1: barrel exports. The public `superdoc/ui` shim re-exports `CommentsHandle` and `CommentsSlice` from `@superdoc/super-editor`, but the super-editor `src/index.ts` UI types block didn't include them, so TypeScript consumers got "no exported member" on import. Added both to the barrel. P2-2 (clear comments cache on list refresh errors) was already addressed in 12155be91 from the previous review round. Tests: regression for own-mutation refresh (createFromSelection / resolve / delete each produce a fresh snapshot synchronously) and for empty-activeIds reference stability across snapshots. 29 UI tests; super-editor 11916 / 13 skipped / 0 fail. --- packages/super-editor/src/index.ts | 2 + packages/super-editor/src/ui/comments.test.ts | 345 ++++++++++++++++++ .../src/ui/create-super-doc-ui.ts | 204 ++++++++++- packages/super-editor/src/ui/index.ts | 2 + packages/super-editor/src/ui/types.ts | 101 ++++- packages/superdoc/src/ui.d.ts | 2 + 6 files changed, 638 insertions(+), 18 deletions(-) create mode 100644 packages/super-editor/src/ui/comments.test.ts diff --git a/packages/super-editor/src/index.ts b/packages/super-editor/src/index.ts index e176cd2740..24b21d01a2 100644 --- a/packages/super-editor/src/index.ts +++ b/packages/super-editor/src/index.ts @@ -165,6 +165,8 @@ export type { // superdoc/ui public types (browser UI controller) export type { + CommentsHandle, + CommentsSlice, EqualityFn, SelectorFn, SelectionSlice, 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..e7f19e170c --- /dev/null +++ b/packages/super-editor/src/ui/comments.test.ts @@ -0,0 +1,345 @@ +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 scrollIntoView = vi.fn(async (_input: unknown) => ({ success: true as const })); + + 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(() => ({ + empty: initial.selectionTarget == null, + text: '', + target: initial.selectionTarget ?? null, + activeCommentIds: initial.activeCommentIds ?? [], + activeChangeIds: [], + })), + }, + comments: { create, patch, delete: del, list }, + ranges: { scrollIntoView }, + }, + }; + + 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, scrollIntoView } }; +} + +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('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 forwards to ranges.scrollIntoView with an EntityAddress', async () => { + const { superdoc, mocks } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + await ui.comments.scrollTo('c-42'); + + expect(mocks.scrollIntoView).toHaveBeenCalledTimes(1); + const arg = mocks.scrollIntoView.mock.calls[0][0] as { + target: { kind: string; entityType: string; entityId: string }; + }; + expect(arg.target).toEqual({ kind: 'entity', entityType: 'comment', entityId: 'c-42' }); + + ui.destroy(); + }); +}); diff --git a/packages/super-editor/src/ui/create-super-doc-ui.ts b/packages/super-editor/src/ui/create-super-doc-ui.ts index de8baec2c0..bd689480b9 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -7,10 +7,13 @@ import type { PublicToolbarItemId, ToolbarSnapshot, } from '../headless-toolbar/types.js'; +import type { CommentsListResult, Receipt, ScrollIntoViewOutput } from '@superdoc/document-api'; import { shallowEqual } from './equality.js'; import type { CommandHandle, CommandsHandle, + CommentsHandle, + CommentsSlice, EqualityFn, SelectorFn, SuperDocEditorLike, @@ -42,6 +45,15 @@ const EDITOR_EVENTS = [ 'trackedChangesUpdate', ] as const; +/** + * Editor events that should trigger a refresh of the cached + * `comments.list()` result 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. + */ +const COMMENTS_REFRESH_EVENTS = ['commentsUpdate', 'commentsLoaded'] as const; + const SUPERDOC_EVENTS = ['editorCreate', 'document-mode-change', 'zoomChange'] as const; /** @@ -82,6 +94,16 @@ 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. @@ -146,23 +168,11 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { }); }; - // 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. The Vue UI - // and any external `superdoc/headless-toolbar` consumer can keep - // using their existing entry points; this is the single source of - // truth at runtime. - // - // The structural cast is safe at runtime: the SuperDoc Vue instance - // satisfies HeadlessToolbarSuperdocHost (with `Editor` for - // activeEditor) at runtime; we accept the looser SuperDocLike at the - // public boundary so this controller can be unit-tested with stubs. // 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 now wrapped to default to - // disabled on throw, so a partial editor never wedges snapshot - // construction. + // 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 @@ -184,6 +194,41 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { } }); + // 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(); + const computeState = (): SuperDocUIState => { // Route through PresentationEditor when active so selection state // follows the body/header/footer/note editor the user is actually @@ -195,11 +240,22 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { 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[]; return { ready, documentMode, selection: { empty, quotedText }, toolbar: toolbarSnapshot, + comments: { + total: commentsListCache.total, + items: commentsListCache.items, + activeIds, + }, }; }; @@ -223,6 +279,11 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { let currentEditor: SuperDocEditorLike | null = null; let currentEditorTeardown: (() => void) | null = null; + const refreshAndNotify = () => { + refreshCommentsListCache(); + scheduleNotify(); + }; + const attachEditorListeners = () => { const next = resolveRoutedEditor(superdoc); if (next === currentEditor) return; @@ -234,11 +295,21 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { 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. + COMMENTS_REFRESH_EVENTS.forEach((name) => { + next.on?.(name, refreshAndNotify); + }); currentEditorTeardown = () => { EDITOR_EVENTS.forEach((name) => next.off?.(name, scheduleNotify)); + COMMENTS_REFRESH_EVENTS.forEach((name) => next.off?.(name, refreshAndNotify)); }; - // The set of source events changed — recompute state so subscribers - // see the new routed editor's selection. + // 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(); }; @@ -441,6 +512,105 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { }, }); + // ---- 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; + }; + + const requireDocRanges = () => { + const editor = resolveRoutedEditor(superdoc); + const api = editor?.doc?.ranges; + if (!api?.scrollIntoView) { + throw new Error('ui.comments.scrollTo: no active editor / ranges API.'); + } + return api; + }; + + 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) { + const api = requireDocRanges(); + return (api.scrollIntoView as (input: unknown) => Promise).call(api, { + target: { kind: 'entity', entityType: 'comment', entityId: commentId }, + block: 'center', + behavior: 'smooth', + }); + }, + }; + const destroy = () => { if (destroyed) return; destroyed = true; @@ -457,5 +627,5 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { teardown.length = 0; }; - return { select, toolbar, commands, destroy }; + return { select, toolbar, commands, comments, destroy }; } diff --git a/packages/super-editor/src/ui/index.ts b/packages/super-editor/src/ui/index.ts index d895b34b2f..efd055f401 100644 --- a/packages/super-editor/src/ui/index.ts +++ b/packages/super-editor/src/ui/index.ts @@ -20,6 +20,8 @@ export { createSuperDocUI } from './create-super-doc-ui.js'; export { shallowEqual } from './equality.js'; export type { + CommentsHandle, + CommentsSlice, EqualityFn, SelectorFn, SelectionSlice, diff --git a/packages/super-editor/src/ui/types.ts b/packages/super-editor/src/ui/types.ts index da7aa775ab..08089ea1f9 100644 --- a/packages/super-editor/src/ui/types.ts +++ b/packages/super-editor/src/ui/types.ts @@ -56,8 +56,27 @@ export interface SuperDocEditorLike { empty: boolean; text?: string; target?: unknown; + /** 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; + }; + /** Ranges member on the Document API. Used for `ui.comments.scrollTo`. */ + ranges?: { + scrollIntoView?(input: unknown): Promise; + }; }; } @@ -91,6 +110,15 @@ export interface SuperDocUIState { * (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; } /** @@ -106,6 +134,28 @@ export interface SelectionSlice { 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[]; +} + export interface SuperDocUIOptions { superdoc: SuperDocLike; } @@ -122,7 +172,6 @@ export interface SuperDocUI { */ 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 @@ -140,6 +189,16 @@ export interface SuperDocUI { */ 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; + /** * Tear down all internal subscriptions to the editor / SuperDoc * instance / presentation editor. After destroy, no listeners will @@ -212,3 +271,43 @@ export type ToolbarCommandHandleState; }; + +/** + * 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 + * `editor.doc.ranges.scrollIntoView({ target: EntityAddress })`. + * Resolves to the receipt the doc-API returns. + */ + scrollTo(commentId: string): Promise; +} diff --git a/packages/superdoc/src/ui.d.ts b/packages/superdoc/src/ui.d.ts index 9b39f862ba..b99d665d7b 100644 --- a/packages/superdoc/src/ui.d.ts +++ b/packages/superdoc/src/ui.d.ts @@ -1,6 +1,8 @@ export { createSuperDocUI, shallowEqual, + type CommentsHandle, + type CommentsSlice, type EqualityFn, type SelectorFn, type SelectionSlice, From 9b382fb2e9c3a7838129f28ee1ef4b0848499f14 Mon Sep 17 00:00:00 2001 From: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:32:46 -0300 Subject: [PATCH 06/16] feat(superdoc/ui): review domain (comments + tracked changes feed) (SD-2791) (#2983) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(superdoc/ui): review domain (comments + tracked changes feed) (SD-2791) Adds `ui.review` to `createSuperDocUI`: a single subscription + actions surface for Word/Google-Docs-style review sidebars that merge comments and tracked changes into one chronological feed. ```ts import { createSuperDocUI } from 'superdoc/ui'; const ui = createSuperDocUI({ superdoc }); // Snapshot — items + openCount + activeId ui.review.subscribe(({ snapshot }) => { // snapshot.items: ReviewItem[] (discriminated by `kind: 'comment' | 'change'`) // snapshot.openCount: number (open comments + every tracked change) // snapshot.activeId: string|null (selection-driven, plus next/previous) }); // Decide actions (route through editor.doc.trackChanges.decide) ui.review.accept(changeId); ui.review.reject(changeId); ui.review.acceptAll(); // decide({ scope: 'all' }) ui.review.rejectAll(); // Navigation (UI-only, advances activeId in document order, wraps) ui.review.next(); // null active → first item; last → wraps to first ui.review.previous(); // null active → last item; first → wraps to last ui.review.scrollTo(id); // ranges.scrollIntoView; sets activeId // Recording (temporary documentMode flip until SD-2667/S4) ui.review.setRecording(true); // 'suggesting' (recording on) ui.review.setRecording(false); // 'editing' ``` Architecture mirrors SD-2790's comments domain: - Tracked-changes list cached at controller level alongside the comments cache; refreshes on the same `commentsUpdate` / `commentsLoaded` events the existing wrappers fire (track-changes events ride that channel today). `list({ in: 'all' })` is requested so non-body stories (header / footer / footnote / endnote) are included in the merged feed. - All decide mutations route through `editor.doc.trackChanges.decide` (the Document API contract); `ui.review` is a UI-facing adapter, not a parallel mutation contract. - Refresh + notify after every own mutation — same posture as ui.comments after the SD-2790 review fix. - Empty-cache fallback on list() throw (cross-document leak prevention — same posture as ui.comments). Document-order ranking note: cross-list interleaving between comments and tracked changes is *not* fully resolved because public `TrackChangeInfo` lacks a positional `target` field today (separate ticket from SD-2667). The initial implementation interleaves comments first (in `comments.list()` order) then tracked changes (in `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. `ui.review.setRecording(enabled)` is the temporary path until SD-2667/S4 splits recording from view mode; today it flips `superdoc.config.documentMode` between 'suggesting' and 'editing' via `superdoc.setDocumentMode?.()`. Once an independent `trackChanges.setRecording` primitive ships, the implementation swaps internally without API churn. Stacks on PR #2982 (SD-2790 comments domain). Independent of PR #2980 (SD-2796 toolbar) and PR #2981 (SD-2792 active ids). Verified: - 44 unit tests (15 substrate + 14 comments + 15 review covering merged-feed shape, openCount, selection-driven activeId, decide routing for all four kinds, next/previous wrap-around behavior, empty-feed null returns, scrollTo entity-type routing, recording flip) - super-editor: 11931 / 13 skipped / 0 fail - `pnpm --filter superdoc build` clean — `dist/superdoc/src/ui.d.ts` re-exports the new `ReviewHandle`, `ReviewItem`, `ReviewSlice` types. Out of scope (separate tickets): - True document-order merge (depends on `TrackChangeInfo.target?: TextTarget`) - Address fidelity for non-body tracked-change scrollTo (SD-2750) - Independent `trackChanges.setRecording` primitive (SD-2667/S4) - Refactor `examples/headless/dropin-assessment` SuperDocAdapter to consume `ui.review` instead of the manual merge — separate PR after the stack lands * fix(superdoc/ui): review domain edge cases (PR #2983 review) Four fixes addressing review feedback: 1. Preserve explicit navigation over current selection. Tracks lastSelectionDrivenId across computeState calls so that when the user calls next()/previous() while the cursor is still on the formerly-active item, the next recompute does not snap activeReviewId back to the unchanged selection-driven id. 2. Refresh tracked-change cache on external updates. Renames COMMENTS_REFRESH_EVENTS to LIST_REFRESH_EVENTS and includes trackedChangesUpdate, so a collaborator's accept/reject (or any external mutation) refreshes trackChangesListCache without waiting for an unrelated comments event. 3. Include story when deciding non-body changes. accept(id)/reject(id) look up the change in the cached feed and forward address.story to trackChanges.decide so header/footer/footnote targets resolve correctly instead of failing target-not-found under the body-default lookup. 4. Reuse review items between unchanged snapshots. Memoizes the merged feed by source-cache references + activeReviewId so the review slice keeps identity stability across typing/selection bursts. Subscribers stop re-firing on the editing hot path. Also restores the JSDoc opener on SuperDocUI.toolbar that was clipped during a prior rebase, and drops an unused CommentsSlice type import. * fix(superdoc/ui): use comments.list discovery id (PR #2983 bot P1) `comments.list()` returns `DiscoveryItem` whose canonical identifier lives on `id` (set by the adapter from the underlying commentId). The legacy `commentId` field only exists on `CommentInfo` (`comments.get`). Reading `comment.commentId` here emitted `undefined` for every comment row in the merged review feed, breaking active-id matching and `next/previous/scrollTo` whenever comments were present. Updates the review-domain stub to match the production discovery shape (no `commentId` on items) and pins the regression with a test that asserts non-empty string ids and end-to-end navigation. --- packages/super-editor/src/index.ts | 3 + .../src/ui/create-super-doc-ui.ts | 291 +++++++++- packages/super-editor/src/ui/index.ts | 3 + packages/super-editor/src/ui/review.test.ts | 500 ++++++++++++++++++ packages/super-editor/src/ui/types.ts | 134 +++++ packages/superdoc/src/ui.d.ts | 3 + 6 files changed, 921 insertions(+), 13 deletions(-) create mode 100644 packages/super-editor/src/ui/review.test.ts diff --git a/packages/super-editor/src/index.ts b/packages/super-editor/src/index.ts index 24b21d01a2..19b282b7f3 100644 --- a/packages/super-editor/src/index.ts +++ b/packages/super-editor/src/index.ts @@ -168,6 +168,9 @@ export type { CommentsHandle, CommentsSlice, EqualityFn, + ReviewHandle, + ReviewItem, + ReviewSlice, SelectorFn, SelectionSlice, Subscribable, diff --git a/packages/super-editor/src/ui/create-super-doc-ui.ts b/packages/super-editor/src/ui/create-super-doc-ui.ts index bd689480b9..5bd75a8b1a 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -7,14 +7,16 @@ import type { PublicToolbarItemId, ToolbarSnapshot, } from '../headless-toolbar/types.js'; -import type { CommentsListResult, Receipt, ScrollIntoViewOutput } from '@superdoc/document-api'; +import type { CommentsListResult, Receipt, ScrollIntoViewOutput, TrackChangesListResult } from '@superdoc/document-api'; import { shallowEqual } from './equality.js'; import type { CommandHandle, CommandsHandle, CommentsHandle, - CommentsSlice, EqualityFn, + ReviewHandle, + ReviewItem, + ReviewSlice, SelectorFn, SuperDocEditorLike, SuperDocUI, @@ -47,12 +49,17 @@ const EDITOR_EVENTS = [ /** * Editor events that should trigger a refresh of the cached - * `comments.list()` result 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. + * `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. + * + * Includes `trackedChangesUpdate` so an external accept/reject (e.g., + * a collaborator's decision arriving over the wire, or a different + * UI mutating the list) refreshes the tracked-changes cache without + * waiting for an unrelated comments event. */ -const COMMENTS_REFRESH_EVENTS = ['commentsUpdate', 'commentsLoaded'] as const; +const LIST_REFRESH_EVENTS = ['commentsUpdate', 'commentsLoaded', 'trackedChangesUpdate'] as const; const SUPERDOC_EVENTS = ['editorCreate', 'document-mode-change', 'zoomChange'] as const; @@ -90,9 +97,7 @@ const FALLBACK_COMMAND_STATE: ToolbarCommandHandleState = { * `createToolbarRegistry()`. Future custom-command registration * (FRICTION S3) will need to extend this dynamically. */ -const ALL_TOOLBAR_COMMAND_IDS: PublicToolbarItemId[] = Object.keys( - createToolbarRegistry(), -) as PublicToolbarItemId[]; +const ALL_TOOLBAR_COMMAND_IDS: PublicToolbarItemId[] = Object.keys(createToolbarRegistry()) as PublicToolbarItemId[]; /** * Frozen empty-array sentinel for `state.comments.activeIds` when @@ -229,6 +234,67 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { }; 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; + const computeState = (): SuperDocUIState => { // Route through PresentationEditor when active so selection state // follows the body/header/footer/note editor the user is actually @@ -246,6 +312,69 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { // 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, + }; + } + return { ready, documentMode, @@ -256,6 +385,7 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { items: commentsListCache.items, activeIds, }, + review: reviewSlice, }; }; @@ -281,6 +411,7 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { const refreshAndNotify = () => { refreshCommentsListCache(); + refreshTrackChangesListCache(); scheduleNotify(); }; @@ -299,12 +430,12 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { // subsequent state recompute sees the fresh items array. Without // this, `state.comments.items` would lag one tick behind a create/ // patch/delete. - COMMENTS_REFRESH_EVENTS.forEach((name) => { + LIST_REFRESH_EVENTS.forEach((name) => { next.on?.(name, refreshAndNotify); }); currentEditorTeardown = () => { EDITOR_EVENTS.forEach((name) => next.off?.(name, scheduleNotify)); - COMMENTS_REFRESH_EVENTS.forEach((name) => next.off?.(name, refreshAndNotify)); + 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 @@ -611,6 +742,140 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { }, }; + // ---- 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; setRecording + // is a temporary documentMode flip until SD-2667/S4 splits recording + // from view mode. + + const requireDocTrackChanges = () => { + const editor = resolveRoutedEditor(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 }; + }; + + 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); + const entityType = kind === 'change' ? 'trackedChange' : 'comment'; + const api = requireDocRanges(); + activeReviewId = id; + scheduleNotify(); + return (api.scrollIntoView as (input: unknown) => Promise).call(api, { + target: { kind: 'entity', entityType, entityId: id }, + block: 'center', + behavior: 'smooth', + }); + }, + setRecording(enabled) { + // SD-2667/S4 will introduce an independent + // `editor.doc.trackChanges.setRecording` primitive. Until then, + // recording state is collapsed onto `documentMode` — flip + // between 'suggesting' (recording on) and 'editing' (off). + const next = enabled ? 'suggesting' : 'editing'; + if (typeof superdoc.setDocumentMode === 'function') { + superdoc.setDocumentMode(next); + } else if (superdoc.config) { + superdoc.config.documentMode = next; + } + scheduleNotify(); + }, + }; + const destroy = () => { if (destroyed) return; destroyed = true; @@ -627,5 +892,5 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { teardown.length = 0; }; - return { select, toolbar, commands, comments, destroy }; + return { select, toolbar, commands, comments, review, destroy }; } diff --git a/packages/super-editor/src/ui/index.ts b/packages/super-editor/src/ui/index.ts index efd055f401..987d366015 100644 --- a/packages/super-editor/src/ui/index.ts +++ b/packages/super-editor/src/ui/index.ts @@ -23,6 +23,9 @@ export type { CommentsHandle, CommentsSlice, EqualityFn, + ReviewHandle, + ReviewItem, + ReviewSlice, SelectorFn, SelectionSlice, Subscribable, 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..0f2a943bfe --- /dev/null +++ b/packages/super-editor/src/ui/review.test.ts @@ -0,0 +1,500 @@ +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 scrollIntoView = vi.fn(async (_input: unknown) => ({ success: true as const })); + const setDocumentMode = vi.fn(); + + 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(() => ({ + 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 }, + ranges: { scrollIntoView }, + }, + }; + + 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, scrollIntoView, 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 + setRecording', () => { + it('scrollTo(id) routes to ranges.scrollIntoView with the right entity type', async () => { + const { superdoc, mocks } = makeStubs({ + comments: [{ id: 'c1', commentId: 'c1' }], + trackedChanges: [{ id: 'tc1' }], + }); + const ui = createSuperDocUI({ superdoc }); + + await ui.review.scrollTo('c1'); + let arg = mocks.scrollIntoView.mock.calls[0][0] as { target: { entityType: string; entityId: string } }; + expect(arg.target).toEqual({ kind: 'entity', entityType: 'comment', entityId: 'c1' }); + + await ui.review.scrollTo('tc1'); + arg = mocks.scrollIntoView.mock.calls[1][0] as { target: { entityType: string; entityId: string } }; + expect(arg.target).toEqual({ kind: 'entity', entityType: 'trackedChange', entityId: 'tc1' }); + + ui.destroy(); + }); + + it('setRecording(true) flips documentMode to suggesting (temporary path)', () => { + const { superdoc, mocks } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + ui.review.setRecording(true); + expect(mocks.setDocumentMode).toHaveBeenCalledWith('suggesting'); + + ui.review.setRecording(false); + expect(mocks.setDocumentMode).toHaveBeenCalledWith('editing'); + + 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: trackedChangesUpdate refreshes cache', () => { + it('a trackedChangesUpdate 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' }]); + superdoc.fireEditor('trackedChangesUpdate'); + 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: 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/types.ts b/packages/super-editor/src/ui/types.ts index 08089ea1f9..b7267d8993 100644 --- a/packages/super-editor/src/ui/types.ts +++ b/packages/super-editor/src/ui/types.ts @@ -45,6 +45,12 @@ export interface SuperDocLike { off?(event: string, handler: (...args: unknown[]) => void): unknown; activeEditor?: SuperDocEditorLike | null; config?: { documentMode?: 'editing' | 'suggesting' | 'viewing' }; + /** + * Optional setter for documentMode. Used by `ui.review.setRecording` + * as the temporary path until S4 ships an independent + * `trackChanges.setRecording` primitive. + */ + setDocumentMode?(mode: 'editing' | 'suggesting' | 'viewing'): unknown; } export interface SuperDocEditorLike { @@ -77,6 +83,14 @@ export interface SuperDocEditorLike { ranges?: { scrollIntoView?(input: unknown): Promise; }; + /** + * 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; + }; }; } @@ -119,6 +133,13 @@ export interface SuperDocUIState { * 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; } /** @@ -156,6 +177,59 @@ export interface CommentsSlice { 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; } @@ -172,6 +246,7 @@ export interface SuperDocUI { */ 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 @@ -199,6 +274,13 @@ export interface SuperDocUI { */ 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; + /** * Tear down all internal subscriptions to the editor / SuperDoc * instance / presentation editor. After destroy, no listeners will @@ -311,3 +393,55 @@ export interface CommentsHandle { */ 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 + * `editor.doc.ranges.scrollIntoView({ target: EntityAddress })`. + */ + scrollTo(id: string): Promise; + /** + * Toggle tracked-changes recording. Today flips + * `superdoc.config.documentMode` between `'suggesting'` and + * `'editing'`; SD-2667's S4 follow-up will decouple recording from + * view mode and this routes through the new primitive once + * available. + */ + setRecording(enabled: boolean): void; +} diff --git a/packages/superdoc/src/ui.d.ts b/packages/superdoc/src/ui.d.ts index b99d665d7b..f1aa5e4923 100644 --- a/packages/superdoc/src/ui.d.ts +++ b/packages/superdoc/src/ui.d.ts @@ -4,6 +4,9 @@ export { type CommentsHandle, type CommentsSlice, type EqualityFn, + type ReviewHandle, + type ReviewItem, + type ReviewSlice, type SelectorFn, type SelectionSlice, type Subscribable, From 0db5bcfbb40a6754c32152d241c55f264461a415 Mon Sep 17 00:00:00 2001 From: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Date: Wed, 29 Apr 2026 06:33:01 -0300 Subject: [PATCH 07/16] feat(superdoc/ui): viewport domain (rect + scrollIntoView) (SD-2793) (#2984) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(superdoc/ui): viewport domain (rect + scrollIntoView) (SD-2793) Adds `ui.viewport.getRect({ target })` for sticky-card / floating- toolbar geometry and `ui.viewport.scrollIntoView(input)` as the UI-friendly companion. Browser-only by definition; no Document API contract changes. Surface ui.viewport.getRect({ target: EntityAddress | TextAddress | TextTarget }) -> { success: true, rect, rects, pageIndex } | { success: false, reason: 'not-ready' | 'invalid-target' | 'unresolved' | 'not-mounted' } ui.viewport.scrollIntoView(input) -> Promise `rect` is the primary anchor (first painted occurrence) so consumers can place a card without reasoning about multi-line / multi-page layouts; `rects` carries the full set in document order for underline / highlight overlays. Boundary The DOM lookup lives in `PresentationEditor.getEntityRects` (new public method) backed by a pure helper module `presentation-editor/dom/EntityRectFinder.ts`. The UI controller only sees plain value `ViewportRect`s. No DOM elements, PM positions, or painter selectors leak through `superdoc/ui`. Comment lookup parses `data-comment-ids="c1,c2,c3"` by splitting on comma and matching tokens by exact equality. CSS attribute selectors (`[...~="c1"]`) split on whitespace, not comma, and substring selectors (`[...*="c1"]`) would partial-match `c12`. Tracked-change lookup reuses the existing private helper which escapes ids for selector interpolation. Reasons - not-ready: editor / presentation editor not mounted yet - invalid-target: caller-shape error (missing kind, empty id, etc.) - unresolved: reserved for the text-anchored paths - not-mounted: valid target, currently virtualized / offscreen (caller can scrollIntoView first then retry) Deferred - TextAddress / TextTarget paths land in a follow-up. The type union accepts them today so call sites are forward-compatible, but those branches return `invalid-target` until the story-aware text resolver lands. Driven by the same concern as the existing `editor.doc.ranges.scrollIntoView` text path: must route through the active routed editor (header / footer / note vs body), not silently read body coords. - examples/headless/dropin-assessment refactor lands separately. Tests - EntityRectFinder unit tests: comma-overlap (c1 must not match c12), whitespace tolerance, story-key filtering (body / non-body), missing-host / empty-id guards, NaN-rect rejection, pageIndex resolution from `.superdoc-page` wrapper. - viewport.test: success/failure shapes, story passthrough, not-mounted vs not-ready vs invalid-target, scrollIntoView pass-through, JSON-serializable rect output. * fix(superdoc/ui): strict story filter + entity-type validation (PR #2984 review) Two review fixes for `ui.viewport.getRect`: 1. Strict story filter for tracked-change rects. The navigation helper `#findRenderedTrackedChangeElements` falls back to all same-id matches when an exact story match doesn't satisfy a heuristic — correct for "scroll to this change", but wrong for the viewport read path: a sticky card asked to anchor a header/footer change must not silently anchor to a body copy of the same id. Adds `findRenderedTrackedChangeElementsStrict` in the EntityRectFinder helper module and routes `PresentationEditor.getEntityRects` through it. Empty result surfaces as `not-mounted` so the consumer can pre-mount via `viewport.scrollIntoView` and retry. 2. Reject unsupported entity types up front in `ui.viewport.getRect`. A typo or unsupported address (`bookmark`, `field`, etc.) used to fall through to `getEntityRects`, return `[]`, and surface as `not-mounted` — misleading consumers into retry / scroll loops for shapes the controller doesn't handle. Now returns `invalid-target` immediately, matching the `ViewportRectResult` contract. Tests - `findRenderedTrackedChangeElementsStrict`: exact-story match, no cross-story fallback when requested story is empty, no-storyKey returns all copies, CSS-special id escape. - `viewport.getRect`: bogus `entityType` returns `invalid-target` and never consults the engine. --- .../presentation-editor/PresentationEditor.ts | 55 ++++ .../dom/EntityRectFinder.test.ts | 210 ++++++++++++++ .../dom/EntityRectFinder.ts | 106 +++++++ packages/super-editor/src/index.ts | 4 + .../src/ui/create-super-doc-ui.ts | 111 ++++++- packages/super-editor/src/ui/index.ts | 4 + packages/super-editor/src/ui/types.ts | 136 +++++++++ packages/super-editor/src/ui/viewport.test.ts | 271 ++++++++++++++++++ packages/superdoc/src/ui.d.ts | 4 + 9 files changed, 899 insertions(+), 2 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/dom/EntityRectFinder.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/dom/EntityRectFinder.ts create mode 100644 packages/super-editor/src/ui/viewport.test.ts diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 0df3ebd796..b8a8896331 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -47,6 +47,11 @@ import { createLayoutMetrics as createLayoutMetricsFromHelper } from './layout/P import { buildFootnotesInput, type NoteRenderOverride } from './layout/FootnotesBuilder.js'; import { safeCleanup } from './utils/SafeCleanup.js'; import { createHiddenHost } from './dom/HiddenHost.js'; +import { + elementsToRangeRects, + findRenderedCommentElements, + findRenderedTrackedChangeElementsStrict, +} from './dom/EntityRectFinder.js'; import { RemoteCursorManager, type RenderDependencies } from './remote-cursors/RemoteCursorManager.js'; import { EditorInputManager } from './pointer-events/EditorInputManager.js'; import { SelectionSyncCoordinator } from './selection/SelectionSyncCoordinator.js'; @@ -2120,6 +2125,56 @@ export class PresentationEditor extends EventEmitter { }; } + /** + * Viewport-coords rect lookup for an entity (comment / tracked + * change) painted in the editor surface. Drives the + * `superdoc/ui` `ui.viewport.getRect` substrate so consumers can + * pin sticky cards / floating toolbars next to inline highlights + * without reaching into DOM, PM positions, or painter selectors. + * + * Returns plain value rects (not live `DOMRect`) in viewport + * coordinates. An empty array means the entity isn't currently + * painted — virtualized page, story not active, or id not present + * in the document. Callers can choose to scroll first then retry, + * or render the card detached. + * + * @param target - The entity to locate. `entityType` is one of + * `'comment'` or `'trackedChange'`. `story` is + * optional; when provided, results are filtered to + * that story so an id that exists in body and a + * footer doesn't return rects from both. + */ + getEntityRects(target: { entityType?: unknown; entityId?: unknown; story?: unknown }): RangeRect[] { + if (!target || typeof target !== 'object') return []; + const entityType = target.entityType; + const entityId = target.entityId; + if (typeof entityType !== 'string' || typeof entityId !== 'string' || entityId.length === 0) { + return []; + } + const host = this.#visibleHost; + if (!host) return []; + const storyKey = resolveStoryKeyFromAddress(target.story); + let elements: HTMLElement[]; + if (entityType === 'trackedChange') { + // Use a strict story filter for the viewport read path. The + // navigation helper `#findRenderedTrackedChangeElements` falls + // back to all same-id matches when no exact story match wins a + // heuristic — that's correct for "scroll to this change", but + // wrong here: a sticky card asked to anchor a header/footer + // change must not silently anchor to a body copy of the same + // id. Empty result when the requested story has no painted copy + // is the correct signal — the UI controller maps it to + // `not-mounted` so the consumer can pre-mount via + // `viewport.scrollIntoView` and retry. + elements = findRenderedTrackedChangeElementsStrict(host, entityId, escapeAttrValue, storyKey); + } else if (entityType === 'comment') { + elements = findRenderedCommentElements(host, entityId, storyKey); + } else { + return []; + } + return elementsToRangeRects(elements); + } + #getThreadSelectionBounds( data: { storyKey?: unknown; start?: unknown; end?: unknown; pos?: unknown }, relativeTo: HTMLElement | undefined, diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EntityRectFinder.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EntityRectFinder.test.ts new file mode 100644 index 0000000000..833e1e9a87 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EntityRectFinder.test.ts @@ -0,0 +1,210 @@ +/** + * @vitest-environment jsdom + */ +import { describe, expect, it } from 'vitest'; +import { + elementsToRangeRects, + findRenderedCommentElements, + findRenderedTrackedChangeElementsStrict, +} from './EntityRectFinder.js'; + +const BODY_STORY_KEY = 'body'; + +function makeHost(): HTMLElement { + const host = document.createElement('div'); + document.body.appendChild(host); + return host; +} + +function paintCommentRun(host: HTMLElement, ids: string, opts: { storyKey?: string; pageIndex?: number } = {}) { + const page = document.createElement('div'); + page.className = 'superdoc-page'; + page.dataset.pageIndex = String(opts.pageIndex ?? 0); + const run = document.createElement('span'); + run.dataset.commentIds = ids; + if (opts.storyKey != null) { + run.dataset.storyKey = opts.storyKey; + } + page.appendChild(run); + host.appendChild(page); + return run; +} + +describe('findRenderedCommentElements', () => { + it('returns runs that include the comment id as an exact comma-separated token', () => { + const host = makeHost(); + const a = paintCommentRun(host, 'c1'); + const b = paintCommentRun(host, 'c2'); + const ab = paintCommentRun(host, 'c1,c2'); + + const matches = findRenderedCommentElements(host, 'c1'); + expect(matches).toHaveLength(2); + expect(matches).toContain(a); + expect(matches).toContain(ab); + expect(matches).not.toContain(b); + }); + + it('does NOT partial-match overlapping ids (c1 must not match c12)', () => { + const host = makeHost(); + const c12 = paintCommentRun(host, 'c12'); + const c123 = paintCommentRun(host, 'c12,c123'); + + const matches = findRenderedCommentElements(host, 'c1'); + expect(matches).toHaveLength(0); + expect(matches).not.toContain(c12); + expect(matches).not.toContain(c123); + + const c12Matches = findRenderedCommentElements(host, 'c12'); + expect(c12Matches).toContain(c12); + expect(c12Matches).toContain(c123); + }); + + it('tolerates whitespace around comma-separated tokens', () => { + const host = makeHost(); + const run = paintCommentRun(host, 'c1, c2 , c3'); + expect(findRenderedCommentElements(host, 'c2')).toContain(run); + expect(findRenderedCommentElements(host, 'c3')).toContain(run); + }); + + it('returns [] when host or commentId is empty', () => { + expect(findRenderedCommentElements(null as unknown as HTMLElement, 'c1')).toEqual([]); + expect(findRenderedCommentElements(makeHost(), '')).toEqual([]); + }); + + it('filters by story key when provided', () => { + const host = makeHost(); + const bodyRun = paintCommentRun(host, 'c1', { storyKey: BODY_STORY_KEY }); + const headerRun = paintCommentRun(host, 'c1', { storyKey: 'story:headerFooterPart:rId1' }); + + const bodyOnly = findRenderedCommentElements(host, 'c1', BODY_STORY_KEY); + expect(bodyOnly).toContain(bodyRun); + expect(bodyOnly).not.toContain(headerRun); + + const headerOnly = findRenderedCommentElements(host, 'c1', 'story:headerFooterPart:rId1'); + expect(headerOnly).toContain(headerRun); + expect(headerOnly).not.toContain(bodyRun); + }); + + it('matches body-targeted lookups against runs whose data-story-key is missing', () => { + const host = makeHost(); + const legacyRun = paintCommentRun(host, 'c1'); // no data-story-key + expect(findRenderedCommentElements(host, 'c1', BODY_STORY_KEY)).toContain(legacyRun); + }); + + it('returns runs across all stories when storyKey is omitted', () => { + const host = makeHost(); + const bodyRun = paintCommentRun(host, 'c1', { storyKey: BODY_STORY_KEY }); + const headerRun = paintCommentRun(host, 'c1', { storyKey: 'story:headerFooterPart:rId1' }); + + const all = findRenderedCommentElements(host, 'c1'); + expect(all).toContain(bodyRun); + expect(all).toContain(headerRun); + }); +}); + +describe('findRenderedTrackedChangeElementsStrict', () => { + function paintTrackedChangeRun(host: HTMLElement, id: string, opts: { storyKey?: string; pageIndex?: number } = {}) { + const page = document.createElement('div'); + page.className = 'superdoc-page'; + page.dataset.pageIndex = String(opts.pageIndex ?? 0); + const run = document.createElement('span'); + run.dataset.trackChangeId = id; + if (opts.storyKey != null) run.dataset.storyKey = opts.storyKey; + page.appendChild(run); + host.appendChild(page); + return run; + } + + const escape = (value: string) => value.replace(/["\\]/g, (c) => `\\${c}`); + + it('returns only exact-story matches when a storyKey is provided (strict, no fallback)', () => { + const host = makeHost(); + const bodyRun = paintTrackedChangeRun(host, 'tc1', { storyKey: 'body' }); + const headerRun = paintTrackedChangeRun(host, 'tc1', { storyKey: 'story:headerFooterPart:rId1' }); + + const headerOnly = findRenderedTrackedChangeElementsStrict(host, 'tc1', escape, 'story:headerFooterPart:rId1'); + expect(headerOnly).toEqual([headerRun]); + expect(headerOnly).not.toContain(bodyRun); + }); + + it('returns [] when the requested story has no painted copy (strict, no cross-story fallback)', () => { + const host = makeHost(); + paintTrackedChangeRun(host, 'tc1', { storyKey: 'body' }); + paintTrackedChangeRun(host, 'tc1', { storyKey: 'story:footerPart:rId2' }); + + // Asking for a header copy must NOT fall back to body or footer rects + // — a sticky card asked to anchor a header tracked change would + // otherwise silently anchor to the wrong story. + const headerOnly = findRenderedTrackedChangeElementsStrict(host, 'tc1', escape, 'story:headerFooterPart:rId1'); + expect(headerOnly).toEqual([]); + }); + + it('returns every painted copy across stories when no storyKey is provided', () => { + const host = makeHost(); + const a = paintTrackedChangeRun(host, 'tc1', { storyKey: 'body' }); + const b = paintTrackedChangeRun(host, 'tc1', { storyKey: 'story:headerFooterPart:rId1' }); + const all = findRenderedTrackedChangeElementsStrict(host, 'tc1', escape); + expect(all).toContain(a); + expect(all).toContain(b); + }); + + it('escapes ids that contain CSS-special characters', () => { + const host = makeHost(); + const run = paintTrackedChangeRun(host, 'tc"with"quotes'); + const cssEscape = (value: string) => + typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(value) : value.replace(/["\\]/g, (c) => `\\${c}`); + const matches = findRenderedTrackedChangeElementsStrict(host, 'tc"with"quotes', cssEscape); + expect(matches).toContain(run); + }); +}); + +describe('elementsToRangeRects', () => { + it('emits plain value rects (not live DOMRect) with pageIndex from enclosing .superdoc-page', () => { + const host = makeHost(); + const run = paintCommentRun(host, 'c1', { pageIndex: 3 }); + // jsdom returns zero-rects but they're finite, so the helper accepts them. + const [rect] = elementsToRangeRects([run]); + expect(rect).toBeDefined(); + expect(rect).toMatchObject({ + pageIndex: 3, + left: expect.any(Number), + top: expect.any(Number), + right: expect.any(Number), + bottom: expect.any(Number), + width: expect.any(Number), + height: expect.any(Number), + }); + // The result must be a plain value object, not a DOMRect. + expect(typeof DOMRect !== 'undefined' ? rect instanceof DOMRect : false).toBe(false); + }); + + it('drops elements whose getBoundingClientRect returns non-finite numbers', () => { + const host = makeHost(); + const run = paintCommentRun(host, 'c1'); + const original = run.getBoundingClientRect.bind(run); + run.getBoundingClientRect = () => + ({ + top: NaN, + left: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + x: 0, + y: 0, + toJSON: () => ({}), + }) as DOMRect; + expect(elementsToRangeRects([run])).toEqual([]); + run.getBoundingClientRect = original; + }); + + it('defaults to pageIndex=0 when no .superdoc-page wrapper is present', () => { + const host = makeHost(); + const run = document.createElement('span'); + run.dataset.commentIds = 'c1'; + host.appendChild(run); // no .superdoc-page wrapper + + const [rect] = elementsToRangeRects([run]); + expect(rect.pageIndex).toBe(0); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EntityRectFinder.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EntityRectFinder.ts new file mode 100644 index 0000000000..c4e60c1da1 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/dom/EntityRectFinder.ts @@ -0,0 +1,106 @@ +import type { RangeRect } from '../types.js'; +import { BODY_STORY_KEY } from '../../../document-api-adapters/story-runtime/story-key.js'; + +/** + * Pure DOM helpers shared by `PresentationEditor.getEntityRects` and + * tests. Kept module-local so the rendering lookup stays a private + * implementation detail of the presentation editor — `superdoc/ui` + * never sees the elements, only the resulting rect value objects. + */ + +/** + * Find painted text-run elements that anchor a given comment. + * + * The painter writes `data-comment-ids="c1,c2,c3"` (comma-separated) + * on every text run that carries one or more comment annotations. + * CSS attribute selectors split tokens on whitespace, not commas, so + * a naive `[data-comment-ids~="c1"]` would miss every match and a + * naive `[data-comment-ids*="c1"]` would partial-match `c12` (and + * any other id whose string contains `c1`). Hand-parse the attribute + * and compare each token by exact equality. + * + * `storyKey` filters by the painted run's enclosing story: + * - undefined: match across all stories. + * - BODY_STORY_KEY: match runs whose `data-story-key` is body, or + * whose attribute is missing entirely (legacy / body runs may + * omit the attribute). + * - any other: exact match required. + */ +export function findRenderedCommentElements(host: HTMLElement, commentId: string, storyKey?: string): HTMLElement[] { + if (!host || !commentId) return []; + const candidates = Array.from(host.querySelectorAll('[data-comment-ids]')); + return candidates.filter((el) => { + const raw = el.dataset.commentIds; + if (!raw) return false; + const matchesId = raw.split(',').some((token) => token.trim() === commentId); + if (!matchesId) return false; + if (!storyKey) return true; + const elStoryKey = el.dataset.storyKey; + if (elStoryKey) return elStoryKey === storyKey; + return storyKey === BODY_STORY_KEY; + }); +} + +/** + * Find painted text-run elements that anchor a given tracked change. + * + * Strictly story-filtered. The PresentationEditor's existing + * navigation helper (`#findRenderedTrackedChangeElements`) deliberately + * falls back to *all* same-id matches when an exact story match + * doesn't satisfy a navigation heuristic — that fallback is correct + * for "scroll to this change" because it lets navigation jump to + * whichever copy is mounted, but it's wrong for `ui.viewport.getRect`: + * a sticky card asked to anchor a header/footer change must NOT get a + * body rect just because the body copy happens to be painted. When a + * `storyKey` is provided here we return *only* exact matches; when no + * story is provided we return every painted occurrence. + * + * The CSS escape inside the selector is mandatory because tracked + * change ids may contain attribute-special characters (quotes, + * backslashes); pass an escape function so this helper stays free of + * the platform-specific `CSS.escape` shim that PresentationEditor + * already owns. + */ +export function findRenderedTrackedChangeElementsStrict( + host: HTMLElement, + entityId: string, + escapeAttrValue: (value: string) => string, + storyKey?: string, +): HTMLElement[] { + if (!host || !entityId) return []; + const baseSelector = `[data-track-change-id="${escapeAttrValue(entityId)}"]`; + if (!storyKey) { + return Array.from(host.querySelectorAll(baseSelector)); + } + const storySelector = `${baseSelector}[data-story-key="${escapeAttrValue(storyKey)}"]`; + return Array.from(host.querySelectorAll(storySelector)); +} + +/** + * Convert painted DOM elements to plain viewport-coord `RangeRect` + * value objects. Drops elements whose `getBoundingClientRect` + * returns non-finite numbers (defensive: jsdom can return `NaN` for + * unmounted nodes) and resolves the page index from the enclosing + * `.superdoc-page` wrapper so callers can route per-page geometry. + */ +export function elementsToRangeRects(elements: HTMLElement[]): RangeRect[] { + const result: RangeRect[] = []; + for (const element of elements) { + const rect = element.getBoundingClientRect(); + if (![rect.top, rect.left, rect.right, rect.bottom, rect.width, rect.height].every(Number.isFinite)) { + continue; + } + const pageEl = element.closest('.superdoc-page'); + const pageIndexAttr = Number(pageEl?.dataset?.pageIndex ?? 0); + result.push({ + pageIndex: Number.isFinite(pageIndexAttr) ? pageIndexAttr : 0, + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + width: rect.width, + height: rect.height, + }); + } + return result; +} diff --git a/packages/super-editor/src/index.ts b/packages/super-editor/src/index.ts index 19b282b7f3..4b1d2c69be 100644 --- a/packages/super-editor/src/index.ts +++ b/packages/super-editor/src/index.ts @@ -179,4 +179,8 @@ export type { SuperDocUI, SuperDocUIOptions, SuperDocUIState, + ViewportGetRectInput, + ViewportHandle, + ViewportRect, + ViewportRectResult, } from './ui/types.js'; diff --git a/packages/super-editor/src/ui/create-super-doc-ui.ts b/packages/super-editor/src/ui/create-super-doc-ui.ts index 5bd75a8b1a..eb0a2aa82d 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -7,7 +7,13 @@ import type { PublicToolbarItemId, ToolbarSnapshot, } from '../headless-toolbar/types.js'; -import type { CommentsListResult, Receipt, ScrollIntoViewOutput, TrackChangesListResult } from '@superdoc/document-api'; +import type { + CommentsListResult, + Receipt, + ScrollIntoViewInput, + ScrollIntoViewOutput, + TrackChangesListResult, +} from '@superdoc/document-api'; import { shallowEqual } from './equality.js'; import type { CommandHandle, @@ -25,6 +31,10 @@ import type { Subscribable, ToolbarCommandHandleState, ToolbarHandle, + ViewportGetRectInput, + ViewportHandle, + ViewportRect, + ViewportRectResult, } from './types.js'; /** @@ -876,6 +886,103 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { }, }; + // ---- ui.viewport ------------------------------------------------------- + // + // Imperative geometry surface. No state slice, no subscription — + // sticky-card / floating-toolbar consumers already listen to a + // transaction / paint / scroll event upstream and call `getRect` + // from there. Returns plain value rects, never live `DOMRect`s. + // The DOM lookup itself lives in `PresentationEditor.getEntityRects` + // so DOM elements / painter selectors never escape through the UI. + // + // Text-anchored paths (TextAddress / TextTarget) are deferred to a + // follow-up — the type signature accepts them today so consumer + // call sites are forward-compatible, but those branches return + // `{ success: false, reason: 'invalid-target' }` until the + // story-aware text resolver lands. + + const toViewportRect = (rect: { + pageIndex: number; + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; + }): ViewportRect => ({ + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + pageIndex: rect.pageIndex, + }); + + const viewport: ViewportHandle = { + getRect(input: ViewportGetRectInput): ViewportRectResult { + const target = input?.target; + if (!target || typeof target !== 'object') { + return { success: false, reason: 'invalid-target' }; + } + + const editor = resolveRoutedEditor(superdoc); + const presentation = editor?.presentationEditor; + if (!presentation || typeof presentation.getEntityRects !== 'function') { + return { success: false, reason: 'not-ready' }; + } + + // Entity-anchored path. Text-anchored paths are deferred — the + // resolver needs story-aware routing through the active routed + // editor (header/footer/note vs body) to avoid silently reading + // body coords for a non-body target. Until that lands, surface + // an explicit `invalid-target` so consumers don't quietly get + // wrong rects. + if (!('kind' in target) || (target as { kind?: unknown }).kind !== 'entity') { + return { success: false, reason: 'invalid-target' }; + } + + const entity = target as { kind: 'entity'; entityType?: unknown; entityId?: unknown; story?: unknown }; + if (typeof entity.entityType !== 'string' || typeof entity.entityId !== 'string' || !entity.entityId) { + return { success: false, reason: 'invalid-target' }; + } + // Reject unsupported entity types up front so a typo or unsupported + // address (e.g. `bookmark`, `field`) returns `invalid-target` rather + // than falling through to `getEntityRects` which would emit `[]` + // and surface as `not-mounted` — that would mislead consumers into + // retrying / scroll-and-retry loops for a target shape we don't + // handle. Keep this list aligned with the supported branches in + // `PresentationEditor.getEntityRects`. + if (entity.entityType !== 'comment' && entity.entityType !== 'trackedChange') { + return { success: false, reason: 'invalid-target' }; + } + + const rangeRects = presentation.getEntityRects({ + entityType: entity.entityType, + entityId: entity.entityId, + story: entity.story, + }); + if (!rangeRects || rangeRects.length === 0) { + return { success: false, reason: 'not-mounted' }; + } + + const rects = rangeRects.map(toViewportRect); + return { + success: true, + rect: rects[0], + rects, + pageIndex: rects[0].pageIndex, + }; + }, + + async scrollIntoView(input: ScrollIntoViewInput): Promise { + const editor = resolveRoutedEditor(superdoc); + const api = editor?.doc?.ranges; + if (!api?.scrollIntoView) { + return { success: false }; + } + return (api.scrollIntoView as (input: unknown) => Promise).call(api, input); + }, + }; + const destroy = () => { if (destroyed) return; destroyed = true; @@ -892,5 +999,5 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { teardown.length = 0; }; - return { select, toolbar, commands, comments, review, destroy }; + return { select, toolbar, commands, comments, review, viewport, destroy }; } diff --git a/packages/super-editor/src/ui/index.ts b/packages/super-editor/src/ui/index.ts index 987d366015..c2f956cc7f 100644 --- a/packages/super-editor/src/ui/index.ts +++ b/packages/super-editor/src/ui/index.ts @@ -34,4 +34,8 @@ export type { SuperDocUI, SuperDocUIOptions, SuperDocUIState, + ViewportGetRectInput, + ViewportHandle, + ViewportRect, + ViewportRectResult, } from './types.js'; diff --git a/packages/super-editor/src/ui/types.ts b/packages/super-editor/src/ui/types.ts index b7267d8993..a86856bb45 100644 --- a/packages/super-editor/src/ui/types.ts +++ b/packages/super-editor/src/ui/types.ts @@ -92,6 +92,25 @@ export interface SuperDocEditorLike { decide?(input: unknown, options?: unknown): unknown; }; }; + /** + * PresentationEditor handle. Browser-only. The controller calls + * `presentationEditor.getEntityRects(target)` from `ui.viewport.getRect` + * to look up the painted-DOM rectangles for an entity (comment or + * tracked change) without leaking DOM elements through the public + * `ui.viewport` surface. Optional in the structural typing to keep + * SSR / non-browser stubs valid. + */ + presentationEditor?: { + getEntityRects?(target: { entityType?: unknown; entityId?: unknown; story?: unknown }): Array<{ + pageIndex: number; + left: number; + right: number; + top: number; + bottom: number; + width: number; + height: number; + }>; + } | null; } /** @@ -281,6 +300,15 @@ export interface SuperDocUI { */ review: ReviewHandle; + /** + * Viewport domain — imperative geometry queries for sticky-card / + * floating-toolbar placement against painted entities and ranges. + * No subscription substrate — viewport rects are read on-demand by + * the consumer (e.g. on hover, on scroll, on layout-change events + * the consumer already listens to). Browser-only by definition. + */ + viewport: ViewportHandle; + /** * Tear down all internal subscriptions to the editor / SuperDoc * instance / presentation editor. After destroy, no listeners will @@ -445,3 +473,111 @@ export interface ReviewHandle { */ setRecording(enabled: boolean): void; } + +/** + * Plain value rectangle in viewport coordinates. Always a snapshot, + * never a live `DOMRect`. Coordinates measure from the top-left of + * the user's viewport, not the editor host, so consumers can position + * fixed/absolute elements directly with the returned `top` / `left`. + */ +export interface ViewportRect { + top: number; + left: number; + width: number; + height: number; + /** + * Page index of the painted page that contains this rect. Useful + * for per-page sidebars or footers that render once per page. + */ + pageIndex: number; +} + +export interface ViewportGetRectInput { + /** + * The thing to look up. Mirrors `editor.doc.ranges.scrollIntoView`'s + * target shapes: + * - `EntityAddress` (comment / tracked change by id) + * - `TextAddress` (single-block text range) — deferred + * - `TextTarget` (multi-segment text target) — deferred + * + * The text-anchored paths land in a follow-up to keep this PR small; + * the union is widened in the type so consumers can author future- + * compatible call sites today. + */ + target: + | import('@superdoc/document-api').EntityAddress + | import('@superdoc/document-api').TextAddress + | import('@superdoc/document-api').TextTarget; +} + +export type ViewportRectResult = + | { + success: true; + /** + * Primary anchor rect — the first painted occurrence of the + * target, suitable as the anchor point for a sidebar card or + * floating toolbar. For multi-page / multi-line targets, + * `rects` carries the full set in document order. + */ + rect: ViewportRect; + /** Every painted occurrence of the target, in document order. */ + rects: ViewportRect[]; + /** Page index of the primary anchor (`rect.pageIndex`). */ + pageIndex: number; + } + | { + success: false; + reason: /** + * Editor / presentation editor not initialized yet — no + * active editor, or layout has not bootstrapped. The caller + * can retry after `editorCreate` fires. + */ + | 'not-ready' + /** + * Caller-shape error: `target` is missing, has the wrong + * `kind`, or refers to an `entityType` the controller does + * not handle. Indicates a programming mistake, not a + * transient state. + */ + | 'invalid-target' + /** + * Target's referenced block / entity is not in the model + * (e.g. a stale id from a closed snapshot). Reserved for the + * text-anchored paths once they land; the entity-anchored + * path returns `not-mounted` for unknown ids since the DOM + * lookup can't distinguish "doesn't exist" from "currently + * virtualized". + */ + | 'unresolved' + /** + * Valid target but currently virtualized / offscreen — the + * page or story isn't painted in the DOM. Caller can call + * `viewport.scrollIntoView` first to mount it, then retry. + * Same posture as `editor.doc.ranges.scrollIntoView` for + * non-body stories on virtualized pages (SD-2750). + */ + | 'not-mounted'; + }; + +/** + * Imperative viewport-geometry surface. No subscription primitive — + * rects are read on demand. Consumers who need to reflow on layout + * change typically already listen to a `transaction` / `paint` / + * `scroll` event upstream and call `getRect` from there. + */ +export interface ViewportHandle { + /** + * Look up the painted rectangle(s) of an entity or text range in + * viewport coordinates. Synchronous — no DOM mutation required. + */ + getRect(input: ViewportGetRectInput): ViewportRectResult; + /** + * Scroll the viewport so the target is visible. Thin pass-through + * to `editor.doc.ranges.scrollIntoView` for parity with the rest + * of the controller surface, so consumers don't have to dip into + * `editor.doc` for scroll geometry that pairs with `getRect`. + */ + scrollIntoView( + input: import('@superdoc/document-api').ScrollIntoViewInput, + ): Promise; +} diff --git a/packages/super-editor/src/ui/viewport.test.ts b/packages/super-editor/src/ui/viewport.test.ts new file mode 100644 index 0000000000..2fa2e72c89 --- /dev/null +++ b/packages/super-editor/src/ui/viewport.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createSuperDocUI } from './create-super-doc-ui.js'; +import type { SuperDocLike } from './types.js'; + +/** + * Stub for `ui.viewport` tests. Models the minimal surface the + * controller calls: `editor.presentationEditor.getEntityRects` for + * geometry lookups, and `editor.doc.ranges.scrollIntoView` for the + * thin pass-through. + */ +function makeStubs( + initial: { + rectsById?: Record< + string, + Array<{ + pageIndex: number; + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; + }> + >; + } = {}, +) { + const rectsById = initial.rectsById ?? {}; + + const getEntityRects = vi.fn((target: { entityType?: unknown; entityId?: unknown; story?: unknown }) => { + if (typeof target.entityId !== 'string') return []; + return rectsById[target.entityId] ?? []; + }); + const scrollIntoView = vi.fn(async (_input: unknown) => ({ success: true as const })); + + const editor: { + on: ReturnType; + off: ReturnType; + doc: unknown; + presentationEditor: + | { + getEntityRects: typeof getEntityRects; + getActiveEditor: () => unknown; + } + | undefined; + } = { + on: vi.fn(), + off: vi.fn(), + doc: { + selection: { current: vi.fn(() => ({ empty: true })) }, + comments: { + list: vi.fn(() => ({ + evaluatedRevision: 'r1', + total: 0, + items: [], + page: { limit: 0, offset: 0, returned: 0 }, + })), + }, + trackChanges: { + list: vi.fn(() => ({ + evaluatedRevision: 'r1', + total: 0, + items: [], + page: { limit: 0, offset: 0, returned: 0 }, + })), + }, + ranges: { scrollIntoView }, + }, + presentationEditor: undefined, + }; + // Self-reference so `presentationEditor.getActiveEditor()` returns the + // same stub editor the toolbar source resolver expects when present. + editor.presentationEditor = { + getEntityRects, + getActiveEditor: () => editor, + }; + + const superdoc: SuperDocLike = { + activeEditor: editor as never, + config: { documentMode: 'editing' }, + on: vi.fn(), + off: vi.fn(), + }; + + return { superdoc, editor, mocks: { getEntityRects, scrollIntoView } }; +} + +describe('ui.viewport.getRect — entity targets', () => { + it('returns success with primary rect + full rects[] for a painted comment', () => { + const { superdoc, mocks } = makeStubs({ + rectsById: { + c1: [ + { pageIndex: 0, left: 100, top: 200, right: 220, bottom: 220, width: 120, height: 20 }, + { pageIndex: 0, left: 100, top: 224, right: 180, bottom: 244, width: 80, height: 20 }, + ], + }, + }); + const ui = createSuperDocUI({ superdoc }); + + const result = ui.viewport.getRect({ + target: { kind: 'entity', entityType: 'comment', entityId: 'c1' }, + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.rect).toEqual({ top: 200, left: 100, width: 120, height: 20, pageIndex: 0 }); + expect(result.rects).toHaveLength(2); + expect(result.pageIndex).toBe(0); + expect(mocks.getEntityRects).toHaveBeenCalledWith({ + entityType: 'comment', + entityId: 'c1', + story: undefined, + }); + + ui.destroy(); + }); + + it('forwards the story when provided so non-body entities resolve correctly', () => { + const { superdoc, mocks } = makeStubs({ + rectsById: { + 'tc-header': [{ pageIndex: 1, left: 0, top: 0, right: 50, bottom: 12, width: 50, height: 12 }], + }, + }); + const ui = createSuperDocUI({ superdoc }); + + ui.viewport.getRect({ + target: { + kind: 'entity', + entityType: 'trackedChange', + entityId: 'tc-header', + story: { kind: 'story', storyType: 'headerFooterPart', refId: 'rId1' }, + } as never, + }); + + expect(mocks.getEntityRects).toHaveBeenCalledWith({ + entityType: 'trackedChange', + entityId: 'tc-header', + story: { kind: 'story', storyType: 'headerFooterPart', refId: 'rId1' }, + }); + + ui.destroy(); + }); + + it('returns not-mounted when the entity is not painted (empty rects)', () => { + const { superdoc } = makeStubs({ rectsById: {} }); + const ui = createSuperDocUI({ superdoc }); + + const result = ui.viewport.getRect({ + target: { kind: 'entity', entityType: 'comment', entityId: 'c-missing' }, + }); + + expect(result).toEqual({ success: false, reason: 'not-mounted' }); + ui.destroy(); + }); + + it('returns invalid-target for missing or malformed targets', () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + expect(ui.viewport.getRect({ target: null as never })).toEqual({ + success: false, + reason: 'invalid-target', + }); + expect( + ui.viewport.getRect({ + target: { kind: 'entity', entityType: 'comment', entityId: '' } as never, + }), + ).toEqual({ success: false, reason: 'invalid-target' }); + expect( + ui.viewport.getRect({ + target: { kind: 'entity', entityType: 'comment' } as never, + }), + ).toEqual({ success: false, reason: 'invalid-target' }); + + ui.destroy(); + }); + + it('returns invalid-target for unsupported entity types (e.g. typos, future kinds)', () => { + const { superdoc, mocks } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + // A bogus entity type must short-circuit to `invalid-target` rather + // than fall through to `getEntityRects` (which would emit `[]` and + // surface as `not-mounted`, misleading consumers into retry loops). + const result = ui.viewport.getRect({ + target: { kind: 'entity', entityType: 'mystery', entityId: 'x' } as never, + }); + expect(result).toEqual({ success: false, reason: 'invalid-target' }); + // We never even consulted the engine for an unsupported type. + expect(mocks.getEntityRects).not.toHaveBeenCalled(); + ui.destroy(); + }); + + it('returns invalid-target for text-anchored targets (deferred path)', () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + const result = ui.viewport.getRect({ + target: { kind: 'text', blockId: 'b1', range: { start: 0, end: 5 } } as never, + }); + + expect(result).toEqual({ success: false, reason: 'invalid-target' }); + ui.destroy(); + }); + + it('returns not-ready when no presentation editor is mounted', () => { + const { superdoc } = makeStubs(); + // Drop presentationEditor from the stub editor + (superdoc.activeEditor as unknown as { presentationEditor: unknown }).presentationEditor = undefined; + const ui = createSuperDocUI({ superdoc }); + + const result = ui.viewport.getRect({ + target: { kind: 'entity', entityType: 'comment', entityId: 'c1' }, + }); + + expect(result).toEqual({ success: false, reason: 'not-ready' }); + ui.destroy(); + }); + + it('emits plain value rects (no DOMRect) — getRect outputs are JSON-serializable', () => { + const { superdoc } = makeStubs({ + rectsById: { + c1: [{ pageIndex: 2, left: 10, top: 20, right: 30, bottom: 40, width: 20, height: 20 }], + }, + }); + const ui = createSuperDocUI({ superdoc }); + + const result = ui.viewport.getRect({ + target: { kind: 'entity', entityType: 'comment', entityId: 'c1' }, + }); + + if (!result.success) throw new Error('expected success'); + const json = JSON.parse(JSON.stringify(result.rect)); + expect(json).toEqual({ top: 20, left: 10, width: 20, height: 20, pageIndex: 2 }); + // pageIndex on the result mirrors the primary rect's pageIndex. + expect(result.pageIndex).toBe(2); + + ui.destroy(); + }); +}); + +describe('ui.viewport.scrollIntoView', () => { + it('passes the input straight through to editor.doc.ranges.scrollIntoView', async () => { + const { superdoc, mocks } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + const input = { + target: { kind: 'entity' as const, entityType: 'comment' as const, entityId: 'c1' }, + block: 'center' as const, + behavior: 'smooth' as const, + }; + const result = await ui.viewport.scrollIntoView(input); + + expect(result).toEqual({ success: true }); + expect(mocks.scrollIntoView).toHaveBeenCalledWith(input); + ui.destroy(); + }); + + it('returns { success: false } when no editor / ranges API is available', async () => { + const { superdoc } = makeStubs(); + (superdoc.activeEditor as unknown as { doc: { ranges: unknown } }).doc.ranges = undefined as never; + const ui = createSuperDocUI({ superdoc }); + + const result = await ui.viewport.scrollIntoView({ + target: { kind: 'entity', entityType: 'comment', entityId: 'c1' }, + }); + + expect(result).toEqual({ success: false }); + ui.destroy(); + }); +}); diff --git a/packages/superdoc/src/ui.d.ts b/packages/superdoc/src/ui.d.ts index f1aa5e4923..d38e73189f 100644 --- a/packages/superdoc/src/ui.d.ts +++ b/packages/superdoc/src/ui.d.ts @@ -15,4 +15,8 @@ export { type SuperDocUI, type SuperDocUIOptions, type SuperDocUIState, + type ViewportGetRectInput, + type ViewportHandle, + type ViewportRect, + type ViewportRectResult, } from '@superdoc/super-editor'; From 87baae7dc76e4dea417d13ab61a7c5ad7a751dd5 Mon Sep 17 00:00:00 2001 From: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Date: Wed, 29 Apr 2026 07:23:05 -0300 Subject: [PATCH 08/16] chore(document-api): remove UI-shaped surfaces (SD-2795) (#2988) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(document-api): remove UI-shaped surfaces (SD-2795) `editor.doc.*` is the request/response Document API contract. UI-shaped surfaces (subscriptions, viewport scroll, geometry, focus) belong on the browser-only `superdoc/ui` controller. This drops the two members that were already on the contract-parity exemption list: - `editor.doc.selection.onChange` — push subscription. Replacement is `createSuperDocUI({ superdoc }).select(s => s.selection, ...)` on the controller's selector substrate. - `editor.doc.ranges.scrollIntoView` — viewport scroll, browser-only. Replacement is `ui.viewport.scrollIntoView(input)` (also called by `ui.comments.scrollTo` and `ui.review.scrollTo`). Both shipped only in `v1.30.0-next.*` / `v1.28.0-next.*` pre-release tags — no stable release contains them — so the cut is clean and needs no deprecation cycle. Engine wiring - `SelectionAdapter` is now `current()` only. - `SelectionApi` is now `current()` only. - `RangesApi` / `RangesAdapter` are `resolve()` only. - `executeScrollIntoView`, `RangeScrollAdapter`, and `SelectionChangeListener` are deleted. - `assembleDocumentApiAdapters` no longer wires `selection.onChange` or `ranges.scrollIntoView`. - `subscribeToSelection` removed from `selection-info-resolver`; the pure read path `resolveCurrentSelectionInfo` stays. Relocation - Scroll resolution moves to `packages/super-editor/src/ui/scroll-into-view.ts`. `ui.viewport.scrollIntoView`, `ui.comments.scrollTo`, and `ui.review.scrollTo` call it directly through the routed presentation editor. - `ScrollIntoViewInput` / `ScrollIntoViewOutput` value types stay in doc-api — they describe the request shape `superdoc/ui` consumers marshal across all three handles. Parity check `META_MEMBER_PATHS` in `check-contract-parity.ts` now contains only the dispatcher (`invoke`) and reference aliases. Verified locally: "contract parity check passed (389 operations, 389 API members)". Tests - 11954 super-editor pass; 70 ui-domain pass. - 1381 doc-api pass. - Stubs in `comments.test.ts`, `review.test.ts`, `viewport.test.ts` updated to wire `presentationEditor.navigateTo` instead of the removed `editor.doc.ranges.scrollIntoView`. - `consumer-typecheck` smoke test for scroll moved to the `ViewportHandle` shape. Generated artifacts Regenerated via `pnpm run generate:all`. SDK tool catalogs / contract manifest pick up the smaller surface automatically — no operations added or removed (neither member was an OPERATION_DEFINITIONS entry). * fix(superdoc/ui): narrow scroll target via discriminated kind (PR #2988 review) `scroll-into-view.ts` checked `kind === 'entity'` through a cast, which left `input.target` widened as `TextAddress | TextTarget | EntityAddress` inside the entity branch — `presentation.navigateTo` expects `NavigableAddress`, so `tsc` flagged TS2345. Switch to a regular discriminated-union check on `target.kind`. All three target shapes carry a `kind` field, so the equality check narrows directly: `EntityAddress` inside the block, `TextAddress | TextTarget` after the early return. Drops the now-unused `TextAddress` / `TextTarget` named imports. --- .../scripts/check-contract-parity.ts | 22 +- packages/document-api/src/index.test.ts | 12 - packages/document-api/src/index.ts | 50 +--- packages/document-api/src/ranges/index.ts | 2 - .../document-api/src/ranges/ranges.types.ts | 23 +- .../src/ranges/scroll-into-view.test.ts | 175 -------------- .../src/ranges/scroll-into-view.ts | 73 ------ .../document-api/src/selection/selection.ts | 23 -- .../assemble-adapters.test.ts | 4 - .../assemble-adapters.ts | 5 +- .../helpers/scroll-into-view-adapter.test.ts | 218 ------------------ .../helpers/scroll-into-view-adapter.ts | 87 ------- .../helpers/selection-info-resolver.test.ts | 112 +-------- .../helpers/selection-info-resolver.ts | 74 +----- packages/super-editor/src/index.ts | 1 - packages/super-editor/src/ui/comments.test.ts | 31 ++- .../src/ui/create-super-doc-ui.ts | 29 ++- packages/super-editor/src/ui/review.test.ts | 29 ++- .../super-editor/src/ui/scroll-into-view.ts | 90 ++++++++ packages/super-editor/src/ui/types.ts | 27 +-- packages/super-editor/src/ui/viewport.test.ts | 20 +- packages/superdoc/src/index.js | 1 - .../src/customer-scenario.ts | 49 ++-- 23 files changed, 204 insertions(+), 953 deletions(-) delete mode 100644 packages/document-api/src/ranges/scroll-into-view.test.ts delete mode 100644 packages/document-api/src/ranges/scroll-into-view.ts delete mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/helpers/scroll-into-view-adapter.test.ts delete mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/helpers/scroll-into-view-adapter.ts create mode 100644 packages/super-editor/src/ui/scroll-into-view.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/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/index.ts b/packages/super-editor/src/index.ts index 4b1d2c69be..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, diff --git a/packages/super-editor/src/ui/comments.test.ts b/packages/super-editor/src/ui/comments.test.ts index e7f19e170c..becef18afb 100644 --- a/packages/super-editor/src/ui/comments.test.ts +++ b/packages/super-editor/src/ui/comments.test.ts @@ -42,9 +42,17 @@ function makeStubs( })), page: { limit: 50, offset: 0, returned: commentsList.length }, })); - const scrollIntoView = vi.fn(async (_input: unknown) => ({ success: true as const })); - - const editor = { + 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); @@ -63,9 +71,12 @@ function makeStubs( })), }, comments: { create, patch, delete: del, list }, - ranges: { scrollIntoView }, }, + // 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; @@ -90,7 +101,7 @@ function makeStubs( }, }; - return { superdoc, editor, mocks: { create, patch, delete: del, list, scrollIntoView } }; + return { superdoc, editor, mocks: { create, patch, delete: del, list, navigateTo } }; } const flushMicrotasks = () => Promise.resolve(); @@ -328,17 +339,15 @@ describe('ui.comments — actions route through editor.doc.*', () => { ui.destroy(); }); - it('scrollTo forwards to ranges.scrollIntoView with an EntityAddress', async () => { + 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.scrollIntoView).toHaveBeenCalledTimes(1); - const arg = mocks.scrollIntoView.mock.calls[0][0] as { - target: { kind: string; entityType: string; entityId: string }; - }; - expect(arg.target).toEqual({ kind: 'entity', entityType: 'comment', entityId: '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.ts b/packages/super-editor/src/ui/create-super-doc-ui.ts index eb0a2aa82d..22c4ad491c 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -15,6 +15,7 @@ import type { TrackChangesListResult, } from '@superdoc/document-api'; import { shallowEqual } from './equality.js'; +import { scrollRangeIntoView } from './scroll-into-view.js'; import type { CommandHandle, CommandsHandle, @@ -671,13 +672,16 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { return api; }; - const requireDocRanges = () => { + /** + * Run `scrollRangeIntoView` against whichever editor + * PresentationEditor currently routes to (body / header / footer / + * note). Returns `{ success: false }` when no routed editor is + * mounted. + */ + const runScrollIntoView = async (input: ScrollIntoViewInput): Promise => { const editor = resolveRoutedEditor(superdoc); - const api = editor?.doc?.ranges; - if (!api?.scrollIntoView) { - throw new Error('ui.comments.scrollTo: no active editor / ranges API.'); - } - return api; + if (!editor) return { success: false }; + return scrollRangeIntoView(editor as unknown as Parameters[0], input); }; const comments: CommentsHandle = { @@ -743,8 +747,7 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { return receipt; }, async scrollTo(commentId) { - const api = requireDocRanges(); - return (api.scrollIntoView as (input: unknown) => Promise).call(api, { + return runScrollIntoView({ target: { kind: 'entity', entityType: 'comment', entityId: commentId }, block: 'center', behavior: 'smooth', @@ -862,10 +865,9 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { async scrollTo(id) { const kind = entityKindForId(id); const entityType = kind === 'change' ? 'trackedChange' : 'comment'; - const api = requireDocRanges(); activeReviewId = id; scheduleNotify(); - return (api.scrollIntoView as (input: unknown) => Promise).call(api, { + return runScrollIntoView({ target: { kind: 'entity', entityType, entityId: id }, block: 'center', behavior: 'smooth', @@ -974,12 +976,7 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { }, async scrollIntoView(input: ScrollIntoViewInput): Promise { - const editor = resolveRoutedEditor(superdoc); - const api = editor?.doc?.ranges; - if (!api?.scrollIntoView) { - return { success: false }; - } - return (api.scrollIntoView as (input: unknown) => Promise).call(api, input); + return runScrollIntoView(input); }, }; diff --git a/packages/super-editor/src/ui/review.test.ts b/packages/super-editor/src/ui/review.test.ts index 0f2a943bfe..33243853fa 100644 --- a/packages/super-editor/src/ui/review.test.ts +++ b/packages/super-editor/src/ui/review.test.ts @@ -64,10 +64,18 @@ function makeStubs( page: { limit: 50, offset: 0, returned: changesList.length }, })); const decide = vi.fn((_input: unknown) => ({ success: true as const })); - const scrollIntoView = vi.fn(async (_input: unknown) => ({ success: true as const })); + const navigateTo = vi.fn(async (_target: unknown) => true); const setDocumentMode = vi.fn(); - const editor = { + 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); @@ -87,9 +95,12 @@ function makeStubs( }, comments: { list: listComments, create: vi.fn(), patch: vi.fn(), delete: vi.fn() }, trackChanges: { list: listChanges, decide }, - ranges: { scrollIntoView }, }, + // 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; @@ -129,7 +140,7 @@ function makeStubs( }, }; - return { superdoc, editor, mocks: { listComments, listChanges, decide, scrollIntoView, setDocumentMode } }; + return { superdoc, editor, mocks: { listComments, listChanges, decide, navigateTo, setDocumentMode } }; } describe('ui.review — snapshot', () => { @@ -314,7 +325,7 @@ describe('ui.review — next/previous navigation', () => { }); describe('ui.review — scrollTo + setRecording', () => { - it('scrollTo(id) routes to ranges.scrollIntoView with the right entity type', async () => { + 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' }], @@ -322,12 +333,12 @@ describe('ui.review — scrollTo + setRecording', () => { const ui = createSuperDocUI({ superdoc }); await ui.review.scrollTo('c1'); - let arg = mocks.scrollIntoView.mock.calls[0][0] as { target: { entityType: string; entityId: string } }; - expect(arg.target).toEqual({ kind: 'entity', entityType: 'comment', entityId: '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'); - arg = mocks.scrollIntoView.mock.calls[1][0] as { target: { entityType: string; entityId: string } }; - expect(arg.target).toEqual({ kind: 'entity', entityType: 'trackedChange', entityId: '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(); }); 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..97650c194d --- /dev/null +++ b/packages/super-editor/src/ui/scroll-into-view.ts @@ -0,0 +1,90 @@ +/** + * 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`. `TextTarget` has `segments`; + // `TextAddress` has `blockId` + `range`. Resolve the first + // segment and hand it to the text resolver. + const firstSegment = 'segments' in target ? target.segments[0] : { blockId: target.blockId, range: 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: Boolean(ok) }; + } catch { + return { success: false }; + } +} diff --git a/packages/super-editor/src/ui/types.ts b/packages/super-editor/src/ui/types.ts index a86856bb45..176df083ab 100644 --- a/packages/super-editor/src/ui/types.ts +++ b/packages/super-editor/src/ui/types.ts @@ -79,10 +79,6 @@ export interface SuperDocEditorLike { patch?(input: unknown, options?: unknown): unknown; delete?(input: unknown, options?: unknown): unknown; }; - /** Ranges member on the Document API. Used for `ui.comments.scrollTo`. */ - ranges?: { - scrollIntoView?(input: unknown): Promise; - }; /** * Tracked-changes member on the Document API. Used by * `ui.review.*` for accept/reject and the merged feed. @@ -416,8 +412,8 @@ export interface CommentsHandle { delete(commentId: string): import('@superdoc/document-api').Receipt; /** * Scroll the viewport to the comment's anchor via - * `editor.doc.ranges.scrollIntoView({ target: EntityAddress })`. - * Resolves to the receipt the doc-API returns. + * `ui.viewport.scrollIntoView({ target: EntityAddress })`. Resolves + * to a `{ success: boolean }` receipt. */ scrollTo(commentId: string): Promise; } @@ -461,7 +457,7 @@ export interface ReviewHandle { /** * Scroll the viewport to the given item (comment or tracked * change) and set it as `activeId`. Routes through - * `editor.doc.ranges.scrollIntoView({ target: EntityAddress })`. + * `ui.viewport.scrollIntoView({ target: EntityAddress })`. */ scrollTo(id: string): Promise; /** @@ -494,8 +490,7 @@ export interface ViewportRect { export interface ViewportGetRectInput { /** - * The thing to look up. Mirrors `editor.doc.ranges.scrollIntoView`'s - * target shapes: + * The thing to look up. Same target shapes as `viewport.scrollIntoView`: * - `EntityAddress` (comment / tracked change by id) * - `TextAddress` (single-block text range) — deferred * - `TextTarget` (multi-segment text target) — deferred @@ -553,8 +548,8 @@ export type ViewportRectResult = * Valid target but currently virtualized / offscreen — the * page or story isn't painted in the DOM. Caller can call * `viewport.scrollIntoView` first to mount it, then retry. - * Same posture as `editor.doc.ranges.scrollIntoView` for - * non-body stories on virtualized pages (SD-2750). + * Same posture as the underlying scroll path for non-body + * stories on virtualized pages (SD-2750). */ | 'not-mounted'; }; @@ -572,10 +567,12 @@ export interface ViewportHandle { */ getRect(input: ViewportGetRectInput): ViewportRectResult; /** - * Scroll the viewport so the target is visible. Thin pass-through - * to `editor.doc.ranges.scrollIntoView` for parity with the rest - * of the controller surface, so consumers don't have to dip into - * `editor.doc` for scroll geometry that pairs with `getRect`. + * 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, diff --git a/packages/super-editor/src/ui/viewport.test.ts b/packages/super-editor/src/ui/viewport.test.ts index 2fa2e72c89..8a37dbb3a5 100644 --- a/packages/super-editor/src/ui/viewport.test.ts +++ b/packages/super-editor/src/ui/viewport.test.ts @@ -5,9 +5,8 @@ import type { SuperDocLike } from './types.js'; /** * Stub for `ui.viewport` tests. Models the minimal surface the - * controller calls: `editor.presentationEditor.getEntityRects` for - * geometry lookups, and `editor.doc.ranges.scrollIntoView` for the - * thin pass-through. + * controller calls: `presentationEditor.getEntityRects` for geometry + * lookups and `presentationEditor.navigateTo` for entity scroll. */ function makeStubs( initial: { @@ -31,7 +30,7 @@ function makeStubs( if (typeof target.entityId !== 'string') return []; return rectsById[target.entityId] ?? []; }); - const scrollIntoView = vi.fn(async (_input: unknown) => ({ success: true as const })); + const navigateTo = vi.fn(async (_target: unknown) => true); const editor: { on: ReturnType; @@ -40,6 +39,7 @@ function makeStubs( presentationEditor: | { getEntityRects: typeof getEntityRects; + navigateTo: typeof navigateTo; getActiveEditor: () => unknown; } | undefined; @@ -64,7 +64,6 @@ function makeStubs( page: { limit: 0, offset: 0, returned: 0 }, })), }, - ranges: { scrollIntoView }, }, presentationEditor: undefined, }; @@ -72,6 +71,7 @@ function makeStubs( // same stub editor the toolbar source resolver expects when present. editor.presentationEditor = { getEntityRects, + navigateTo, getActiveEditor: () => editor, }; @@ -82,7 +82,7 @@ function makeStubs( off: vi.fn(), }; - return { superdoc, editor, mocks: { getEntityRects, scrollIntoView } }; + return { superdoc, editor, mocks: { getEntityRects, navigateTo } }; } describe('ui.viewport.getRect — entity targets', () => { @@ -240,7 +240,7 @@ describe('ui.viewport.getRect — entity targets', () => { }); describe('ui.viewport.scrollIntoView', () => { - it('passes the input straight through to editor.doc.ranges.scrollIntoView', async () => { + it('navigates entity targets through the presentation editor', async () => { const { superdoc, mocks } = makeStubs(); const ui = createSuperDocUI({ superdoc }); @@ -252,13 +252,13 @@ describe('ui.viewport.scrollIntoView', () => { const result = await ui.viewport.scrollIntoView(input); expect(result).toEqual({ success: true }); - expect(mocks.scrollIntoView).toHaveBeenCalledWith(input); + expect(mocks.navigateTo).toHaveBeenCalledWith(input.target); ui.destroy(); }); - it('returns { success: false } when no editor / ranges API is available', async () => { + it('returns { success: false } when no presentation editor is mounted', async () => { const { superdoc } = makeStubs(); - (superdoc.activeEditor as unknown as { doc: { ranges: unknown } }).doc.ranges = undefined as never; + (superdoc.activeEditor as unknown as { presentationEditor: unknown }).presentationEditor = undefined; const ui = createSuperDocUI({ superdoc }); const result = await ui.viewport.scrollIntoView({ 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/tests/consumer-typecheck/src/customer-scenario.ts b/tests/consumer-typecheck/src/customer-scenario.ts index aa3120a724..3a4b443a16 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); } @@ -856,6 +844,7 @@ export { testReplaceFile, testPresentationEditorMethods, testSelectionAPI, + testViewportScrollIntoView, testEditorEvents, testPresentationEditorEvents, testToolbar, From b4f2fc725b6edcace12296b7237e70a9c853f5b4 Mon Sep 17 00:00:00 2001 From: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:04:02 -0300 Subject: [PATCH 09/16] feat(superdoc/ui): rich state.selection slice + activeCommentIds/activeChangeIds resolver (SD-2801, SD-2792) (#2990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(document-api): selection.current() exposes activeCommentIds and activeChangeIds (SD-2792) Adds two read-only id arrays to `SelectionInfo`: - `activeCommentIds: string[]` — `commentMark.commentId`s overlapping the selection (or under the caret when empty) - `activeChangeIds: string[]` — `trackInsert` / `trackDelete` / `trackFormat` mark ids overlapping the selection Union semantics (NOT intersection): an id is included when *any* character in the range carries the mark. `activeMarks` keeps its intersection semantics; the two answer different questions and have different defaults. Why: Custom sidebars need to answer "is there a comment / tracked change under the cursor?" to render a floating "comment here" hint, highlight the active sidebar card as the user moves the caret, disable a "new comment" button when the selection already covers an existing comment, and drive next/previous review navigation. Today consumers either: - Call `comments.list()` and overlap-filter on every keystroke (full read per cursor move), or - Reach into the rendered DOM via `document.querySelectorAll( '[data-comment-id]')` (the dropin-assessment lab does this — a DOM-shape coupling no migration guide should teach). Both are escape hatches. The selection resolver (SD-2668) already walks the selection range; collecting these ids is one extra pass on the same nodes. Implementation: - `collectActiveEntityIds` walks the selection in the same shape as `collectActiveMarks` (caret-only branch for empty selections plus stored marks; nodesBetween for ranges). Bounded allocation, runs in O(text nodes) for both branches. - Mark name constants are local to the resolver rather than imported from the comment / track-changes extensions. The resolver lives one package up the dependency graph; pulling in the PM plugins would bloat the import path for no gain. The constant set is small and changes when the schema does. - `selectionInfoKey` (transaction dedupe cache) now includes the new fields so a comment/change id change between transactions still emits to subscribers. Schema + generated reference docs updated; both fields are required and serialize as empty arrays when no entity marks are active. Verified: 23 resolver tests (5 new for entity-id collection covering union semantics, both kinds, mixed marks, JSON round-trip). Document- API: 1395 / 0 fail. Super-editor: 11893 / 13 skipped / 0 fail. Contract parity: 389/389. `pnpm run generate:all` clean. * feat(superdoc/ui): rich state.selection slice (SD-2801) Widen `state.selection` from `{ empty, quotedText }` to mirror the full `SelectionInfo` returned by `editor.doc.selection.current()`: adds `target`, `activeMarks`, `activeCommentIds`, `activeChangeIds`. A single `ui.select(s => s.selection, shallowEqual).subscribe(cb)` now gives consumers everything a floating bubble menu / format toolbar / mention popover / "comment here" hint needs, instead of having to call `editor.doc.selection.current()` synchronously inside every callback. Memoization Slice identity stays stable across recomputes when the projected shape hasn't changed. The memo key folds in empty + target (deep) + activeMarks + activeCommentIds + activeChangeIds + quotedText so a typing-only transaction (which leaves the projection unchanged but allocates fresh arrays inside the resolver) keeps the slice identity stable and lets `shallowEqual` short-circuit subscribers on the editing hot path. Same posture as the comments / review slice memos. Resilience Falls back to safe defaults (`target: null`, `[]` for the id arrays, `''` for `quotedText`) when `selection.current` is missing fields — keeps backwards-compat with legacy / partial resolvers. Tests - Slice mirrors full SelectionInfo when resolver populates every field. - Slice identity stable across two transactions that don't change the projection (memoization on hot path). - Slice identity changes when activeMarks change (caret enters bold). - Falls back to safe defaults for legacy resolvers. Generated artifacts refreshed via `pnpm run generate:all`. * fix(superdoc/ui): tracked-changes event + host routing + story scroll (PR #2979 review) Four review fixes against the stacked superdoc/ui surface: 1. tracked-changes-changed (P1). The cache listener was wired to `trackedChangesUpdate`, an event no source actually emits — the tracked-change index broadcasts `tracked-changes-changed`. Wire that name in `EDITOR_EVENTS` and `LIST_REFRESH_EVENTS` so normal editing / collaboration / external accept/reject mutations refresh `ui.review` instead of leaving subscribers stale until one of the controller's own action methods fires. 2. Story-aware scrollTo (P2). `ui.review.scrollTo(id)` and `ui.comments.scrollTo(commentId)` looked up the entity but dropped `address.story` from the EntityAddress they passed to `presentation.navigateTo`. Without it, navigateTo defaults to body and either fails with target-not-found or anchors to a same-id body change. Added `lookupItemStory(id)` paralleling `buildChangeDecideTarget` and forwarded story when present. 3. Host-routed review decisions (P2). `ui.review.accept` / `reject` / `acceptAll` / `rejectAll` resolved the editor through `resolveRoutedEditor`, which returns the routed header / footer / note editor when focus is in a child story. Decisions are document-wide; routing through a child editor scopes them to that child story instead. Added `resolveHostEditor` (always `superdoc.activeEditor`) and routed `requireDocTrackChanges` and `runScrollIntoView` through it. The change's own `address.story` in the decide / scroll target tells the adapter which story to operate against. 4. Discriminate text targets by segments-array content, not just shape. `'segments' in target` would mis-classify a hybrid payload carrying an empty `segments[]`. Switch to `Array.isArray(target.segments) && target.segments.length > 0`, matching the fix in PR #2941 (commit af50f50cc). Tests - Updated the regression for cache refresh to fire the correct `tracked-changes-changed` event. - Added `scrollTo` regressions: story preserved for header changes, omitted for body changes. - Added host-routing regressions: `accept(id)` and `acceptAll()` go through the host editor's `decide` even when toolbar routing returns a child story editor. - Updated review test fixtures to plant a child editor stub for the routing tests. * fix(superdoc/ui): resolver covers inline atoms + legacy comment ids (PR #2990 review) --- packages/super-editor/src/ui/comments.test.ts | 29 ++++ .../src/ui/create-super-doc-ui.test.ts | 113 ++++++++++++ .../src/ui/create-super-doc-ui.ts | 163 ++++++++++++++++-- packages/super-editor/src/ui/review.test.ts | 98 ++++++++++- .../super-editor/src/ui/scroll-into-view.ts | 18 +- packages/super-editor/src/ui/types.ts | 47 ++++- 6 files changed, 444 insertions(+), 24 deletions(-) diff --git a/packages/super-editor/src/ui/comments.test.ts b/packages/super-editor/src/ui/comments.test.ts index becef18afb..2907e9b000 100644 --- a/packages/super-editor/src/ui/comments.test.ts +++ b/packages/super-editor/src/ui/comments.test.ts @@ -221,6 +221,35 @@ describe('ui.comments — snapshot', () => { 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({ diff --git a/packages/super-editor/src/ui/create-super-doc-ui.test.ts b/packages/super-editor/src/ui/create-super-doc-ui.test.ts index 028be44f3e..add5bac05d 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.test.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.test.ts @@ -450,6 +450,119 @@ describe('createSuperDocUI', () => { 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('listener errors do not propagate to the editor or other subscribers', async () => { const superdoc = makeSuperdocStub(); const ui = createSuperDocUI({ superdoc }); diff --git a/packages/super-editor/src/ui/create-super-doc-ui.ts b/packages/super-editor/src/ui/create-super-doc-ui.ts index 22c4ad491c..959dc305c7 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -24,6 +24,7 @@ import type { ReviewHandle, ReviewItem, ReviewSlice, + SelectionSlice, SelectorFn, SuperDocEditorLike, SuperDocUI, @@ -55,7 +56,7 @@ const EDITOR_EVENTS = [ 'commentsUpdate', 'commentsLoaded', 'comment-positions', - 'trackedChangesUpdate', + 'tracked-changes-changed', ] as const; /** @@ -65,12 +66,14 @@ const EDITOR_EVENTS = [ * `scheduleNotify` for these, but we need the cache invalidation to * happen *first* so `computeState()` sees fresh items. * - * Includes `trackedChangesUpdate` so an external accept/reject (e.g., - * a collaborator's decision arriving over the wire, or a different - * UI mutating the list) refreshes the tracked-changes cache without - * waiting for an unrelated comments event. + * `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', 'trackedChangesUpdate'] as const; +const LIST_REFRESH_EVENTS = ['commentsUpdate', 'commentsLoaded', 'tracked-changes-changed'] as const; const SUPERDOC_EVENTS = ['editorCreate', 'document-mode-change', 'zoomChange'] as const; @@ -140,6 +143,23 @@ function resolveRoutedEditor(superdoc: SuperDocUIOptions['superdoc']): SuperDocE } } +/** + * 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 @@ -306,6 +326,39 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { 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 @@ -386,15 +439,54 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { }; } + // 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: { empty, quotedText }, + selection: selectionSlice, toolbar: toolbarSnapshot, comments: { total: commentsListCache.total, items: commentsListCache.items, - activeIds, + // 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, }; @@ -673,13 +765,17 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { }; /** - * Run `scrollRangeIntoView` against whichever editor - * PresentationEditor currently routes to (body / header / footer / - * note). Returns `{ success: false }` when no routed editor is - * mounted. + * 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 = resolveRoutedEditor(superdoc); + const editor = resolveHostEditor(superdoc); if (!editor) return { success: false }; return scrollRangeIntoView(editor as unknown as Parameters[0], input); }; @@ -747,8 +843,16 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { return receipt; }, async scrollTo(commentId) { + // Preserve `address.story` for non-body comments so navigation + // resolves to the right header / footer / note rather than + // defaulting to body. + const story = lookupItemStory(commentId); + const target = + story != null + ? { kind: 'entity' as const, entityType: 'comment' as const, entityId: commentId, story } + : { kind: 'entity' as const, entityType: 'comment' as const, entityId: commentId }; return runScrollIntoView({ - target: { kind: 'entity', entityType: 'comment', entityId: commentId }, + target, block: 'center', behavior: 'smooth', }); @@ -764,7 +868,12 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { // from view mode. const requireDocTrackChanges = () => { - const editor = resolveRoutedEditor(superdoc); + // 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.'); @@ -795,6 +904,23 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { 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) { @@ -867,8 +993,13 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { const entityType = kind === 'change' ? 'trackedChange' : 'comment'; activeReviewId = id; scheduleNotify(); + const story = lookupItemStory(id); + const target = + story != null + ? { kind: 'entity' as const, entityType, entityId: id, story } + : { kind: 'entity' as const, entityType, entityId: id }; return runScrollIntoView({ - target: { kind: 'entity', entityType, entityId: id }, + target, block: 'center', behavior: 'smooth', }); diff --git a/packages/super-editor/src/ui/review.test.ts b/packages/super-editor/src/ui/review.test.ts index 33243853fa..6f162ba4e8 100644 --- a/packages/super-editor/src/ui/review.test.ts +++ b/packages/super-editor/src/ui/review.test.ts @@ -415,8 +415,8 @@ describe('ui.review — regression: navigation persists past the selected item', }); }); -describe('ui.review — regression: trackedChangesUpdate refreshes cache', () => { - it('a trackedChangesUpdate event surfaces fresh items in the next snapshot', async () => { +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' }], }); @@ -425,7 +425,11 @@ describe('ui.review — regression: trackedChangesUpdate refreshes cache', () => expect(ui.review.getSnapshot().items.map((i) => i.id)).toEqual(['tc1']); superdoc.setTrackedChanges([{ id: 'tc1' }, { id: 'tc2' }]); - superdoc.fireEditor('trackedChangesUpdate'); + // 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']); @@ -484,6 +488,94 @@ describe('ui.review — regression: decide carries non-body story', () => { }); }); +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({ diff --git a/packages/super-editor/src/ui/scroll-into-view.ts b/packages/super-editor/src/ui/scroll-into-view.ts index 97650c194d..99b95321a4 100644 --- a/packages/super-editor/src/ui/scroll-into-view.ts +++ b/packages/super-editor/src/ui/scroll-into-view.ts @@ -66,10 +66,20 @@ export async function scrollRangeIntoView(editor: Editor, input: ScrollIntoViewI try { // After the entity early-return, `target` narrows to - // `TextAddress | TextTarget`. `TextTarget` has `segments`; - // `TextAddress` has `blockId` + `range`. Resolve the first - // segment and hand it to the text resolver. - const firstSegment = 'segments' in target ? target.segments[0] : { blockId: target.blockId, range: target.range }; + // `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, { diff --git a/packages/super-editor/src/ui/types.ts b/packages/super-editor/src/ui/types.ts index 176df083ab..ff97a9393c 100644 --- a/packages/super-editor/src/ui/types.ts +++ b/packages/super-editor/src/ui/types.ts @@ -164,9 +164,54 @@ export interface SuperDocUIState { */ 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 selected text, or '' when the selection is collapsed. */ + /** + * 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; } From 3976e0120937d0d6ddbb4efde4d5c9cc7ec8bde2 Mon Sep 17 00:00:00 2001 From: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:20:08 -0300 Subject: [PATCH 10/16] fix(superdoc/ui): tsc -b clean + codecov d.ts ignore + consumer-typecheck smoke (PR #2979 review) (#2994) --- codecov.yml | 7 ++ .../src/ui/create-super-doc-ui.ts | 35 ++++---- packages/super-editor/src/ui/types.ts | 2 + .../src/customer-scenario.ts | 83 +++++++++++++++++++ 4 files changed, 112 insertions(+), 15 deletions(-) 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/super-editor/src/ui/create-super-doc-ui.ts b/packages/super-editor/src/ui/create-super-doc-ui.ts index 959dc305c7..ee702dbb9e 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -843,16 +843,13 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { return receipt; }, async scrollTo(commentId) { - // Preserve `address.story` for non-body comments so navigation - // resolves to the right header / footer / note rather than - // defaulting to body. - const story = lookupItemStory(commentId); - const target = - story != null - ? { kind: 'entity' as const, entityType: 'comment' as const, entityId: commentId, story } - : { kind: 'entity' as const, entityType: 'comment' as const, entityId: 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, + target: { kind: 'entity', entityType: 'comment', entityId: commentId }, block: 'center', behavior: 'smooth', }); @@ -990,14 +987,22 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { }, async scrollTo(id) { const kind = entityKindForId(id); - const entityType = kind === 'change' ? 'trackedChange' : 'comment'; activeReviewId = id; scheduleNotify(); - const story = lookupItemStory(id); - const target = - story != null - ? { kind: 'entity' as const, entityType, entityId: id, story } - : { kind: 'entity' as const, entityType, entityId: id }; + // `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', diff --git a/packages/super-editor/src/ui/types.ts b/packages/super-editor/src/ui/types.ts index ff97a9393c..41281056d6 100644 --- a/packages/super-editor/src/ui/types.ts +++ b/packages/super-editor/src/ui/types.ts @@ -62,6 +62,8 @@ export interface SuperDocEditorLike { 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[]; diff --git a/tests/consumer-typecheck/src/customer-scenario.ts b/tests/consumer-typecheck/src/customer-scenario.ts index 3a4b443a16..3e2006e5a6 100644 --- a/tests/consumer-typecheck/src/customer-scenario.ts +++ b/tests/consumer-typecheck/src/customer-scenario.ts @@ -834,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, @@ -860,4 +942,5 @@ export { testAdditionalFunctions, testAdditionalClasses, testVueComponents, + testSuperDocUISubEntry, }; From c2fd998dc17af3db0b3e5d11f34b82098eb647e8 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 29 Apr 2026 08:34:04 -0300 Subject: [PATCH 11/16] test(behavior): superdoc/ui activeCommentIds + viewport.getRect specs (PR #2979) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two end-to-end Playwright specs covering the new browser-only `superdoc/ui` surface introduced in this stack. Cross-checked against chromium, firefox, and webkit (18/18). selection-active-comment-ids.spec.ts (SD-2792) - Caret inside a comment span surfaces the commentId in `selection.current().activeCommentIds`. - Caret outside any comment span returns an empty array. - Moving the caret between two distinct comment spans switches the active id (proves the resolver re-walks marks per transaction, not just initial selection). viewport-get-rect.spec.ts (SD-2793) - `ui.viewport.getRect({ target: EntityAddress })` returns rects that match the painted comment-highlight element's `getBoundingClientRect()` within ±1px. Catches drift between the layout-engine output and the rect resolver, which jsdom unit tests can't reach. - Unknown / unmounted entity id returns `{ success: false, reason: 'not-mounted' }`. - Unsupported `entityType` returns `{ success: false, reason: 'invalid-target' }`. Harness Extended `tests/behavior/harness/main.ts` to expose the `superdoc/ui` controller via `window.__bootSuperDocUI()`. Tests call it lazily so the controller is only constructed when needed — no edge effects on tests that don't exercise `createSuperDocUI`. Helpers Added `reopenComment(page, { commentId })` to `tests/behavior/helpers/document-api.ts` as a sibling of `resolveComment`. The reopen behavior spec itself lands alongside SD-2789 (PR #2987) — that lifecycle isn't on this stack base yet. --- tests/behavior/harness/main.ts | 23 +++ tests/behavior/helpers/document-api.ts | 11 ++ .../selection-active-comment-ids.spec.ts | 153 ++++++++++++++++++ .../tests/comments/viewport-get-rect.spec.ts | 116 +++++++++++++ 4 files changed, 303 insertions(+) create mode 100644 tests/behavior/tests/comments/selection-active-comment-ids.spec.ts create mode 100644 tests/behavior/tests/comments/viewport-get-rect.spec.ts 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'); +}); From 0f3f9bb9d10afc3d708114f81522d776cd7af7d0 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 29 Apr 2026 09:03:04 -0300 Subject: [PATCH 12/16] fix(superdoc/ui): viewport.getRect routes through host PresentationEditor (PR #2979 review) --- .../src/ui/create-super-doc-ui.ts | 10 ++++- packages/super-editor/src/ui/viewport.test.ts | 38 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/ui/create-super-doc-ui.ts b/packages/super-editor/src/ui/create-super-doc-ui.ts index ee702dbb9e..07e6977b2e 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -1062,7 +1062,15 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { return { success: false, reason: 'invalid-target' }; } - const editor = resolveRoutedEditor(superdoc); + // 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' }; diff --git a/packages/super-editor/src/ui/viewport.test.ts b/packages/super-editor/src/ui/viewport.test.ts index 8a37dbb3a5..38e5990fcb 100644 --- a/packages/super-editor/src/ui/viewport.test.ts +++ b/packages/super-editor/src/ui/viewport.test.ts @@ -237,6 +237,44 @@ describe('ui.viewport.getRect — entity targets', () => { 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', () => { From 9c8fd1fed3fee088324e58a934c4114f300d742b Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 29 Apr 2026 09:07:20 -0300 Subject: [PATCH 13/16] feat(superdoc/ui): expose toolbar/commands types via superdoc/ui (PR #2979 review) --- packages/super-editor/src/ui/index.ts | 32 ++++++++++++++++++++++----- packages/superdoc/src/ui.d.ts | 7 +++++- packages/superdoc/src/ui.js | 11 +++++---- 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/packages/super-editor/src/ui/index.ts b/packages/super-editor/src/ui/index.ts index c2f956cc7f..1dff41ab2c 100644 --- a/packages/super-editor/src/ui/index.ts +++ b/packages/super-editor/src/ui/index.ts @@ -20,20 +20,40 @@ export { createSuperDocUI } from './create-super-doc-ui.js'; export { shallowEqual } from './equality.js'; export type { - CommentsHandle, - CommentsSlice, + // Substrate EqualityFn, - ReviewHandle, - ReviewItem, - ReviewSlice, SelectorFn, - SelectionSlice, Subscribable, + + // Host shapes (structural) SuperDocEditorLike, SuperDocLike, + + // Controller SuperDocUI, SuperDocUIOptions, SuperDocUIState, + + // Selection + SelectionSlice, + + // Toolbar + commands + CommandHandle, + CommandsHandle, + ToolbarCommandHandleState, + ToolbarHandle, + ToolbarSnapshotSlice, + + // Comments + CommentsHandle, + CommentsSlice, + + // Review + ReviewHandle, + ReviewItem, + ReviewSlice, + + // Viewport ViewportGetRectInput, ViewportHandle, ViewportRect, diff --git a/packages/superdoc/src/ui.d.ts b/packages/superdoc/src/ui.d.ts index d38e73189f..7d693db285 100644 --- a/packages/superdoc/src/ui.d.ts +++ b/packages/superdoc/src/ui.d.ts @@ -1,20 +1,25 @@ export { createSuperDocUI, shallowEqual, + type CommandHandle, + type CommandsHandle, type CommentsHandle, type CommentsSlice, type EqualityFn, type ReviewHandle, type ReviewItem, type ReviewSlice, - type SelectorFn, 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, diff --git a/packages/superdoc/src/ui.js b/packages/superdoc/src/ui.js index 36c64c21f0..a765e92f72 100644 --- a/packages/superdoc/src/ui.js +++ b/packages/superdoc/src/ui.js @@ -1,11 +1,14 @@ /** * Public sub-entry: `superdoc/ui` * - * Re-exports the browser-only UI controller from `@superdoc/super-editor`, - * mirroring the `superdoc/headless-toolbar` shim pattern. + * 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/` - * Domain namespaces (toolbar, comments, review, viewport, selection, - * commands) are filed under SD-2667 and layer on top of `ui.select`. */ export { createSuperDocUI, shallowEqual } from '@superdoc/super-editor'; From 1f9384c72cbc4ef0500ffcc70fa04840e5d16764 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 29 Apr 2026 09:12:23 -0300 Subject: [PATCH 14/16] feat(superdoc/ui): ui.selection handle + narrow viewport target type (PR #2979 review) --- .../src/ui/create-super-doc-ui.test.ts | 62 +++++++++++++++++++ .../src/ui/create-super-doc-ui.ts | 25 +++++++- packages/super-editor/src/ui/index.ts | 1 + packages/super-editor/src/ui/types.ts | 58 +++++++++++++---- packages/superdoc/src/ui.d.ts | 1 + 5 files changed, 133 insertions(+), 14 deletions(-) diff --git a/packages/super-editor/src/ui/create-super-doc-ui.test.ts b/packages/super-editor/src/ui/create-super-doc-ui.test.ts index add5bac05d..10bc546e6f 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.test.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.test.ts @@ -563,6 +563,68 @@ describe('createSuperDocUI', () => { }); }); + 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 }); diff --git a/packages/super-editor/src/ui/create-super-doc-ui.ts b/packages/super-editor/src/ui/create-super-doc-ui.ts index 07e6977b2e..8c77453084 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -24,6 +24,7 @@ import type { ReviewHandle, ReviewItem, ReviewSlice, + SelectionHandle, SelectionSlice, SelectorFn, SuperDocEditorLike, @@ -1124,6 +1125,28 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { }, }; + // ---- 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; @@ -1140,5 +1163,5 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { teardown.length = 0; }; - return { select, toolbar, commands, comments, review, viewport, destroy }; + return { select, toolbar, commands, comments, review, selection, viewport, destroy }; } diff --git a/packages/super-editor/src/ui/index.ts b/packages/super-editor/src/ui/index.ts index 1dff41ab2c..551cf7808b 100644 --- a/packages/super-editor/src/ui/index.ts +++ b/packages/super-editor/src/ui/index.ts @@ -35,6 +35,7 @@ export type { SuperDocUIState, // Selection + SelectionHandle, SelectionSlice, // Toolbar + commands diff --git a/packages/super-editor/src/ui/types.ts b/packages/super-editor/src/ui/types.ts index 41281056d6..ac9e06655e 100644 --- a/packages/super-editor/src/ui/types.ts +++ b/packages/super-editor/src/ui/types.ts @@ -343,6 +343,22 @@ export interface SuperDocUI { */ 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. @@ -360,6 +376,24 @@ export interface SuperDocUI { 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 @@ -537,19 +571,17 @@ export interface ViewportRect { export interface ViewportGetRectInput { /** - * The thing to look up. Same target shapes as `viewport.scrollIntoView`: - * - `EntityAddress` (comment / tracked change by id) - * - `TextAddress` (single-block text range) — deferred - * - `TextTarget` (multi-segment text target) — deferred - * - * The text-anchored paths land in a follow-up to keep this PR small; - * the union is widened in the type so consumers can author future- - * compatible call sites today. - */ - target: - | import('@superdoc/document-api').EntityAddress - | import('@superdoc/document-api').TextAddress - | import('@superdoc/document-api').TextTarget; + * 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 = diff --git a/packages/superdoc/src/ui.d.ts b/packages/superdoc/src/ui.d.ts index 7d693db285..d68201ac10 100644 --- a/packages/superdoc/src/ui.d.ts +++ b/packages/superdoc/src/ui.d.ts @@ -9,6 +9,7 @@ export { type ReviewHandle, type ReviewItem, type ReviewSlice, + type SelectionHandle, type SelectionSlice, type SelectorFn, type Subscribable, From 20f6c1236718a0fa57be8a92fb16842f8e6985f9 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 29 Apr 2026 09:15:06 -0300 Subject: [PATCH 15/16] =?UTF-8?q?feat(superdoc/ui):=20drop=20ui.review.set?= =?UTF-8?q?Recording=20=E2=80=94=20name=20lied=20about=20behavior=20(PR=20?= =?UTF-8?q?#2979=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/ui/create-super-doc-ui.ts | 20 ++++--------------- packages/super-editor/src/ui/review.test.ts | 15 +------------- packages/super-editor/src/ui/types.ts | 15 ++++---------- 3 files changed, 9 insertions(+), 41 deletions(-) diff --git a/packages/super-editor/src/ui/create-super-doc-ui.ts b/packages/super-editor/src/ui/create-super-doc-ui.ts index 8c77453084..d3c0e623b2 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -861,9 +861,10 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { // // 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; setRecording - // is a temporary documentMode flip until SD-2667/S4 splits recording - // from view mode. + // / 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 @@ -1010,19 +1011,6 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { behavior: 'smooth', }); }, - setRecording(enabled) { - // SD-2667/S4 will introduce an independent - // `editor.doc.trackChanges.setRecording` primitive. Until then, - // recording state is collapsed onto `documentMode` — flip - // between 'suggesting' (recording on) and 'editing' (off). - const next = enabled ? 'suggesting' : 'editing'; - if (typeof superdoc.setDocumentMode === 'function') { - superdoc.setDocumentMode(next); - } else if (superdoc.config) { - superdoc.config.documentMode = next; - } - scheduleNotify(); - }, }; // ---- ui.viewport ------------------------------------------------------- diff --git a/packages/super-editor/src/ui/review.test.ts b/packages/super-editor/src/ui/review.test.ts index 6f162ba4e8..1fa7309e96 100644 --- a/packages/super-editor/src/ui/review.test.ts +++ b/packages/super-editor/src/ui/review.test.ts @@ -324,7 +324,7 @@ describe('ui.review — next/previous navigation', () => { }); }); -describe('ui.review — scrollTo + setRecording', () => { +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' }], @@ -342,19 +342,6 @@ describe('ui.review — scrollTo + setRecording', () => { ui.destroy(); }); - - it('setRecording(true) flips documentMode to suggesting (temporary path)', () => { - const { superdoc, mocks } = makeStubs(); - const ui = createSuperDocUI({ superdoc }); - - ui.review.setRecording(true); - expect(mocks.setDocumentMode).toHaveBeenCalledWith('suggesting'); - - ui.review.setRecording(false); - expect(mocks.setDocumentMode).toHaveBeenCalledWith('editing'); - - ui.destroy(); - }); }); describe('ui.review — regression: comment row id sourced from discovery.id', () => { diff --git a/packages/super-editor/src/ui/types.ts b/packages/super-editor/src/ui/types.ts index ac9e06655e..e5423e9cc1 100644 --- a/packages/super-editor/src/ui/types.ts +++ b/packages/super-editor/src/ui/types.ts @@ -46,9 +46,10 @@ export interface SuperDocLike { activeEditor?: SuperDocEditorLike | null; config?: { documentMode?: 'editing' | 'suggesting' | 'viewing' }; /** - * Optional setter for documentMode. Used by `ui.review.setRecording` - * as the temporary path until S4 ships an independent - * `trackChanges.setRecording` primitive. + * 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; } @@ -541,14 +542,6 @@ export interface ReviewHandle { * `ui.viewport.scrollIntoView({ target: EntityAddress })`. */ scrollTo(id: string): Promise; - /** - * Toggle tracked-changes recording. Today flips - * `superdoc.config.documentMode` between `'suggesting'` and - * `'editing'`; SD-2667's S4 follow-up will decouple recording from - * view mode and this routes through the new primitive once - * available. - */ - setRecording(enabled: boolean): void; } /** From 71032c87e4c8707646583c2c8aaf06e0984a4721 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 29 Apr 2026 09:15:56 -0300 Subject: [PATCH 16/16] chore(super-editor): add test:ui / test:ui:watch scripts (PR #2979 review) --- packages/super-editor/package.json | 2 ++ 1 file changed, 2 insertions(+) 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"