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 97f16430bc..e454bcdcd6 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -14,15 +14,18 @@ import type { ScrollIntoViewOutput, TrackChangesListResult, } from '@superdoc/document-api'; +import { collectEntityHitsFromChain } from './entity-at.js'; import { shallowEqual } from './equality.js'; import { scrollRangeIntoView } from './scroll-into-view.js'; import { getSelectionAnchorRect, getSelectionRects } from './selection-rects.js'; +import { restoreSelection } from './selection-restore.js'; import { createCustomCommandsRegistry } from './custom-commands.js'; import { createScope } from './scope.js'; import type { CommandHandle, CommandsHandle, CommentsHandle, + ContextMenuItem, DocumentExportInput, DocumentHandle, DocumentSlice, @@ -44,6 +47,8 @@ import type { ToolbarHandle, ToolbarSnapshotSlice, UIToolbarCommandState, + ViewportEntityAtInput, + ViewportEntityHit, ViewportGetRectInput, ViewportHandle, ViewportRect, @@ -1162,6 +1167,15 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { return handle; }; } + // Custom-UI consumers building their own context menu pull + // contributed items here. Computed against the current snapshot + // (so `selection` matches what observers just saw) and the + // caller-supplied entities from `ui.viewport.entityAt`. + if (prop === 'getContextMenuItems') { + return (input?: { entities?: ViewportEntityHit[] }): ContextMenuItem[] => { + return customCommandsRegistry.getContextMenuItems(computeState(), input?.entities ?? []); + }; + } // Custom-registered ids surface a typed handle from the registry. // Built-in ids fall through to the existing per-id cache so they // keep the same observe/execute shape they had before SD-2802. @@ -1560,6 +1574,35 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { async scrollIntoView(input: ScrollIntoViewInput): Promise { return runScrollIntoView(input); }, + + // The painter stamps `data-track-change-id` and `data-comment-ids` + // on each painted run; reading them back is what consumers were + // doing imperatively from `event.target.closest(...)` in + // contextmenu handlers. Centralizing the lookup here keeps the + // attribute names an implementation detail of the painter and + // surfaces a typed `EntityHit[]` consumers can switch on. + entityAt(input: ViewportEntityAtInput): ViewportEntityHit[] { + if (!input || typeof input.x !== 'number' || typeof input.y !== 'number') return []; + // The DOM `document` is reached through `globalThis.document` + // because the local `document: DocumentHandle` declared below + // would otherwise shadow it for type-checking. Guard SSR / + // non-browser stubs explicitly so the call doesn't throw in + // test environments without a global `document`. + const dom = (globalThis as { document?: Document }).document; + if (!dom || typeof dom.elementFromPoint !== 'function') { + return []; + } + // Scope the lookup to this controller's editor: a page mounting + // two SuperDoc instances would otherwise have one's entityAt + // return ids from the other's painted DOM. A null host (no + // editor mounted, post-destroy, SSR test stub) returns []. + const editor = resolveHostEditor(superdoc); + const host = editor?.presentationEditor?.visibleHost; + if (!host) return []; + const startEl = dom.elementFromPoint(input.x, input.y); + if (!startEl || !host.contains(startEl)) return []; + return collectEntityHitsFromChain(startEl); + }, }; // ---- ui.selection ------------------------------------------------------ @@ -1636,6 +1679,16 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { capture, ); }, + restore(capture) { + // Routed editor: same rationale as `getRects(capture)` — block + // ids in a non-body capture only resolve in their own story + // editor's PM doc. When focus has moved to the body by call + // time, the routed editor is body and resolution returns + // `'stale'` rather than placing the selection on the wrong + // surface. + const editor = resolveRoutedEditor(superdoc); + return restoreSelection(editor as unknown as Parameters[0], capture); + }, }; // ---- ui.document ------------------------------------------------------- diff --git a/packages/super-editor/src/ui/custom-commands.test.ts b/packages/super-editor/src/ui/custom-commands.test.ts index 871761b949..9a99d00e51 100644 --- a/packages/super-editor/src/ui/custom-commands.test.ts +++ b/packages/super-editor/src/ui/custom-commands.test.ts @@ -949,3 +949,200 @@ describe('ui.commands.get', () => { ui.destroy(); }); }); + +describe('ui.commands.getContextMenuItems', () => { + it('returns [] when no registered command carries a contextMenu field', () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + ui.commands.register({ id: 'company.plain', execute: () => true }); + + expect(ui.commands.getContextMenuItems()).toEqual([]); + + ui.destroy(); + }); + + it('surfaces contributions, filling defaults for group / order', () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + ui.commands.register({ + id: 'company.acceptChange', + execute: () => true, + contextMenu: { label: 'Accept suggestion' }, + }); + + expect(ui.commands.getContextMenuItems()).toEqual([ + { id: 'company.acceptChange', label: 'Accept suggestion', group: 'custom', order: 0 }, + ]); + + ui.destroy(); + }); + + it('filters items by the when predicate using caller-supplied entities + current selection', () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + const whenSpy = vi.fn(({ entities }) => entities.some((e) => e.type === 'trackedChange')); + ui.commands.register({ + id: 'company.acceptChange', + execute: () => true, + contextMenu: { label: 'Accept suggestion', group: 'review', when: whenSpy }, + }); + + expect(ui.commands.getContextMenuItems({ entities: [{ type: 'comment', id: 'c1' }] })).toEqual([]); + expect(ui.commands.getContextMenuItems({ entities: [{ type: 'trackedChange', id: 'tc1' }] })).toHaveLength(1); + + expect(whenSpy).toHaveBeenCalledTimes(2); + expect(whenSpy.mock.calls[0]![0].selection).toBeDefined(); + + ui.destroy(); + }); + + it('sorts by built-in group order, then order, then registration sequence', () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + // Built-in group order: format(0), clipboard(1), review(2), comment(3), link(4) + ui.commands.register({ + id: 'a.review-2', + execute: () => true, + contextMenu: { label: 'Review B', group: 'review', order: 20 }, + }); + ui.commands.register({ + id: 'b.format-1', + execute: () => true, + contextMenu: { label: 'Format A', group: 'format', order: 0 }, + }); + ui.commands.register({ + id: 'c.review-1', + execute: () => true, + contextMenu: { label: 'Review A', group: 'review', order: 10 }, + }); + ui.commands.register({ + id: 'd.review-tie-second', + execute: () => true, + contextMenu: { label: 'Review C', group: 'review', order: 10 }, + }); + ui.commands.register({ + id: 'e.custom', + execute: () => true, + contextMenu: { label: 'Z', group: 'company.workflow', order: 0 }, + }); + + expect(ui.commands.getContextMenuItems().map((i) => i.id)).toEqual([ + 'b.format-1', + 'c.review-1', + 'd.review-tie-second', + 'a.review-2', + 'e.custom', + ]); + + ui.destroy(); + }); + + it('plain custom commands (no contextMenu) do not anchor a custom group rank', () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + // Register a plain command first (seq 0) — it has no contextMenu + // and must not claim the 'custom' fallback group's rank anchor. + ui.commands.register({ id: 'a.plain', execute: () => true }); + // Register a workflow contribution (seq 1). + ui.commands.register({ + id: 'b.workflow', + execute: () => true, + contextMenu: { label: 'Workflow A', group: 'company.workflow' }, + }); + // Register a 'custom' fallback group contribution (seq 2). + ui.commands.register({ + id: 'c.custom', + execute: () => true, + contextMenu: { label: 'Default A' }, + }); + + // 'company.workflow' (seq 1) must rank before 'custom' (seq 2). + // If the plain seq=0 command anchored 'custom', the order would + // flip. + expect(ui.commands.getContextMenuItems().map((i) => i.id)).toEqual(['b.workflow', 'c.custom']); + + ui.destroy(); + }); + + it('preserves a group rank anchor when one contributor is replaced and another remains', () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + // Group 'workflow' opens with two contributors at seq 0 and seq 1. + ui.commands.register({ + id: 'wf.first', + execute: () => true, + contextMenu: { label: 'WF 1', group: 'company.workflow', order: 0 }, + }); + ui.commands.register({ + id: 'wf.second', + execute: () => true, + contextMenu: { label: 'WF 2', group: 'company.workflow', order: 1 }, + }); + // A second custom group registers at seq 2. + ui.commands.register({ + id: 'rev.first', + execute: () => true, + contextMenu: { label: 'Rev 1', group: 'company.review-extras', order: 0 }, + }); + + // Now replace `wf.first` — the new seq becomes 3, but `wf.second` + // still carries the original seq 1, so the workflow group's + // anchor must stay at 1 and render before 'review-extras' (seq 2). + ui.commands.register({ + id: 'wf.first', + execute: () => true, + contextMenu: { label: 'WF 1 (replaced)', group: 'company.workflow', order: 0 }, + }); + + expect(ui.commands.getContextMenuItems().map((i) => i.id)).toEqual(['wf.first', 'wf.second', 'rev.first']); + + ui.destroy(); + }); + + it('hides items whose when predicate throws and logs the error once per distinct message', () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + ui.commands.register({ + id: 'company.flaky', + execute: () => true, + contextMenu: { + label: 'Flaky', + when: () => { + throw new Error('boom'); + }, + }, + }); + + expect(ui.commands.getContextMenuItems()).toEqual([]); + expect(ui.commands.getContextMenuItems()).toEqual([]); + expect(errorSpy).toHaveBeenCalledTimes(1); + + ui.destroy(); + }); + + it("refuses 'getContextMenuItems' as a custom command id (Proxy collision)", () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + const reg = ui.commands.register({ + id: 'getContextMenuItems', + execute: () => true, + contextMenu: { label: 'Should not register' }, + }); + + expect(warnSpy).toHaveBeenCalled(); + // Refused registrations get a no-op handle, so the contribution + // never enters the registry. + expect(ui.commands.getContextMenuItems()).toEqual([]); + reg.unregister(); + + ui.destroy(); + }); +}); diff --git a/packages/super-editor/src/ui/custom-commands.ts b/packages/super-editor/src/ui/custom-commands.ts index e82769f96a..ce6b4837e6 100644 --- a/packages/super-editor/src/ui/custom-commands.ts +++ b/packages/super-editor/src/ui/custom-commands.ts @@ -1,4 +1,6 @@ import type { + ContextMenuContribution, + ContextMenuItem, CustomCommandRegistration, CustomCommandRegistrationResult, CustomCommandHandle, @@ -8,8 +10,20 @@ import type { SuperDocUIState, Subscribable, UIToolbarCommandState, + ViewportEntityHit, } from './types.js'; +/** + * Built-in group ids in the order they render in the context menu. + * Custom groups land after these, ranked by the smallest registration + * seq currently contributing to the group — see `groupRank` below. + */ +const BUILTIN_CONTEXT_MENU_GROUPS = ['format', 'clipboard', 'review', 'comment', 'link'] as const; +const BUILTIN_GROUP_ORDER: ReadonlyMap = new Map( + BUILTIN_CONTEXT_MENU_GROUPS.map((g, i) => [g, i] as const), +); +const DEFAULT_CONTEXT_MENU_GROUP = 'custom'; + const DEFAULT_BUILTIN_COLLISION_MESSAGE = (id: string) => `[superdoc/ui] ui.commands.register(): id '${id}' collides with a built-in command. Pass { override: true } to replace deliberately. Registration refused.`; @@ -29,7 +43,13 @@ const DEFAULT_REPLACEMENT_MESSAGE = (id: string) => * fix is to choose a different id (a namespaced one like * `'company.has'` is the canonical workaround). */ -const RESERVED_PROXY_PROPERTY_NAMES: ReadonlySet = new Set(['register', 'get', 'has', 'require']); +const RESERVED_PROXY_PROPERTY_NAMES: ReadonlySet = new Set([ + 'register', + 'get', + 'has', + 'require', + 'getContextMenuItems', +]); const DEFAULT_RESERVED_NAME_MESSAGE = (id: string) => `[superdoc/ui] ui.commands.register(): id '${id}' shadows a Proxy method on ui.commands and would be unreachable through index access. Use a namespaced id (e.g. 'company.${id}') instead. Registration refused.`; @@ -51,12 +71,24 @@ interface InternalCustomEntry { execute: CustomCommandRegistration['execute']; getState: CustomCommandRegistration['getState']; override: boolean; + contextMenu: ContextMenuContribution | null; + /** + * Monotonic counter at registration time; ties in `(group, order)` + * are broken by this so the rendered menu is stable across + * snapshots and across re-registrations of unrelated commands. + */ + registrationSeq: number; /** * Most recent error message thrown from `getState`. Used to dedupe * `console.error` calls so a buggy `getState` doesn't flood the console * once per snapshot rebuild. */ lastErrorMessage: string | null; + /** + * Most recent error message thrown from `contextMenu.when`. Same + * dedupe posture as `lastErrorMessage`. + */ + lastContextMenuErrorMessage: string | null; } export interface CustomCommandsRegistry { @@ -87,6 +119,15 @@ export interface CustomCommandsRegistry { /** Run `execute` for a registered id. Returns false if not registered. */ execute(id: string, payload?: unknown): boolean | Promise; + /** + * Collect context-menu items contributed by registered customs. + * Filtered by each contribution's `when` predicate against the + * supplied entities + the current selection slice; sorted by + * `(group, order, registrationSeq)`. Errors from `when` are + * caught and the item is hidden for that query. + */ + getContextMenuItems(state: SuperDocUIState, entities: ViewportEntityHit[]): ContextMenuItem[]; + /** Drop every registration and tear down per-command Subscribables. */ destroy(): void; } @@ -134,6 +175,10 @@ export function createCustomCommandsRegistry(deps: CustomCommandsRegistryDeps): const entries = new Map(); const handleCache = new Map>(); const subscribableCache = new Map>(); + // Monotonic counter so `(group, order)` ties in context-menu sort + // are broken by registration time. Stable across snapshots, doesn't + // reuse values when entries are unregistered + re-registered. + let nextRegistrationSeq = 0; // Active observer disposers per command id. Lets `unregister` (and // replacement) actively tear down inner subscriptions instead of // waiting for the observer wrapper's lazy `!entries.has(id)` check @@ -306,7 +351,10 @@ export function createCustomCommandsRegistry(deps: CustomCommandsRegistryDeps): execute: execute as InternalCustomEntry['execute'], getState: getState as InternalCustomEntry['getState'], override, + contextMenu: registration.contextMenu ?? null, + registrationSeq: nextRegistrationSeq++, lastErrorMessage: null, + lastContextMenuErrorMessage: null, }; entries.set(id, ownEntry); @@ -426,6 +474,84 @@ export function createCustomCommandsRegistry(deps: CustomCommandsRegistryDeps): } }, + getContextMenuItems(state, entities) { + const items: ContextMenuItem[] = []; + for (const entry of entries.values()) { + const contribution = entry.contextMenu; + if (!contribution) continue; + + if (contribution.when) { + let applies = true; + try { + applies = contribution.when({ entities, selection: state.selection }) === true; + } catch (err) { + // Same dedupe posture as `getState` errors: log once per + // distinct message so a buggy `when` predicate doesn't + // flood the console on every right-click. + const message = err instanceof Error ? err.message : String(err); + if (entry.lastContextMenuErrorMessage !== message) { + entry.lastContextMenuErrorMessage = message; + console.error(`[superdoc/ui] custom command '${entry.id}' contextMenu.when threw:`, err); + } + applies = false; + } + if (!applies) continue; + } else { + entry.lastContextMenuErrorMessage = null; + } + + items.push({ + id: entry.id, + label: contribution.label, + group: contribution.group ?? DEFAULT_CONTEXT_MENU_GROUP, + order: contribution.order ?? 0, + }); + } + + // Rank each custom group by the smallest registration seq + // currently contributing to it. Two corners that drive this: + // + // - Skip entries with no `contextMenu` set. Otherwise a plain + // custom command (no contribution) would default to the + // `'custom'` fallback group via `?? DEFAULT_CONTEXT_MENU_GROUP` + // and silently anchor that group's rank from a non-contribution. + // - Use the *minimum* current seq, not the first one encountered. + // `entries` is a Map; replacement keeps the key at its original + // insertion index but stores the new (higher) seq, so reading + // the first encountered seq for a group's lone re-registered + // contributor would use the new seq and reorder the group. + // Min-of-current is stable: while *any* original-seq contributor + // remains in the group, the group's rank stays anchored. + const customGroupSeq = new Map(); + for (const entry of entries.values()) { + if (!entry.contextMenu) continue; + const group = entry.contextMenu.group ?? DEFAULT_CONTEXT_MENU_GROUP; + if (BUILTIN_GROUP_ORDER.has(group)) continue; + const existing = customGroupSeq.get(group); + if (existing === undefined || entry.registrationSeq < existing) { + customGroupSeq.set(group, entry.registrationSeq); + } + } + + const groupRank = (group: string): number => { + const builtin = BUILTIN_GROUP_ORDER.get(group); + if (builtin !== undefined) return builtin; + return BUILTIN_CONTEXT_MENU_GROUPS.length + (customGroupSeq.get(group) ?? 0); + }; + + const seqById = new Map(); + for (const entry of entries.values()) seqById.set(entry.id, entry.registrationSeq); + + items.sort((a, b) => { + const ga = groupRank(a.group); + const gb = groupRank(b.group); + if (ga !== gb) return ga - gb; + if (a.order !== b.order) return a.order - b.order; + return (seqById.get(a.id) ?? 0) - (seqById.get(b.id) ?? 0); + }); + return items; + }, + destroy() { // Dispose every active observer before clearing maps so the // inner Subscribables release their selector subscriptions; just diff --git a/packages/super-editor/src/ui/entity-at.test.ts b/packages/super-editor/src/ui/entity-at.test.ts new file mode 100644 index 0000000000..8ec1a276e7 --- /dev/null +++ b/packages/super-editor/src/ui/entity-at.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; + +import { collectEntityHitsFromChain } from './entity-at.js'; + +/** + * Build a chain of nested HTMLElements with the dataset stamps the + * painter would set. `layers[0]` becomes the innermost element; + * subsequent layers wrap it. Returns the innermost element — the one + * `document.elementFromPoint` would normally return for a click on + * the innermost painted run. + */ +function buildPaintedChain(layers: Array<{ trackChangeId?: string; commentIds?: string }>): HTMLElement { + const innerLayer = layers[0]!; + const inner = document.createElement('span'); + if (innerLayer.trackChangeId) inner.dataset.trackChangeId = innerLayer.trackChangeId; + if (innerLayer.commentIds) inner.dataset.commentIds = innerLayer.commentIds; + + let outer: HTMLElement = inner; + for (let i = 1; i < layers.length; i += 1) { + const layer = layers[i]!; + const wrapper = document.createElement('span'); + if (layer.trackChangeId) wrapper.dataset.trackChangeId = layer.trackChangeId; + if (layer.commentIds) wrapper.dataset.commentIds = layer.commentIds; + wrapper.appendChild(outer); + outer = wrapper; + } + document.body.appendChild(outer); + return inner; +} + +describe('collectEntityHitsFromChain', () => { + it('returns hits for tracked-change and comment data attributes, innermost first', () => { + const inner = buildPaintedChain([{ trackChangeId: 'tc-1' }, { commentIds: 'c-1' }]); + + expect(collectEntityHitsFromChain(inner)).toEqual([ + { type: 'trackedChange', id: 'tc-1' }, + { type: 'comment', id: 'c-1' }, + ]); + }); + + it('expands comma-separated comment ids into one hit per id', () => { + const inner = buildPaintedChain([{ commentIds: 'c-1,c-2,c-3' }]); + + expect(collectEntityHitsFromChain(inner)).toEqual([ + { type: 'comment', id: 'c-1' }, + { type: 'comment', id: 'c-2' }, + { type: 'comment', id: 'c-3' }, + ]); + }); + + it('deduplicates the same id when it appears multiple times in the chain', () => { + const inner = buildPaintedChain([{ commentIds: 'c-1' }, { commentIds: 'c-1' }]); + + expect(collectEntityHitsFromChain(inner)).toEqual([{ type: 'comment', id: 'c-1' }]); + }); + + it('combines trackedChange + comment + outer comment in document order (innermost → outermost)', () => { + const inner = buildPaintedChain([{ trackChangeId: 'tc-1' }, { commentIds: 'c-inner' }, { commentIds: 'c-outer' }]); + + expect(collectEntityHitsFromChain(inner)).toEqual([ + { type: 'trackedChange', id: 'tc-1' }, + { type: 'comment', id: 'c-inner' }, + { type: 'comment', id: 'c-outer' }, + ]); + }); + + it('returns [] when the chain has no painted entities', () => { + const inner = buildPaintedChain([{}]); + + expect(collectEntityHitsFromChain(inner)).toEqual([]); + }); + + it('returns [] for null or non-Element starts', () => { + expect(collectEntityHitsFromChain(null)).toEqual([]); + expect(collectEntityHitsFromChain({} as never)).toEqual([]); + }); + + it('skips empty ids in a malformed comma list', () => { + const inner = buildPaintedChain([{ commentIds: ',c-1,,c-2,' }]); + + expect(collectEntityHitsFromChain(inner)).toEqual([ + { type: 'comment', id: 'c-1' }, + { type: 'comment', id: 'c-2' }, + ]); + }); +}); diff --git a/packages/super-editor/src/ui/entity-at.ts b/packages/super-editor/src/ui/entity-at.ts new file mode 100644 index 0000000000..c4a97429ae --- /dev/null +++ b/packages/super-editor/src/ui/entity-at.ts @@ -0,0 +1,59 @@ +/** + * Walk a painted-DOM element chain (innermost → outermost) and + * collect entity hits for `ui.viewport.entityAt`. + * + * Pure function — takes a starting element, returns the hits. The + * `document.elementFromPoint` lookup that produces the starting + * element lives in the controller; this helper is what makes the + * data-attribute walk testable without stubbing globals. + */ + +import type { ViewportEntityHit } from './types.js'; + +/** + * Read painted entities off `el` and every ancestor up to the document + * root. Innermost-first ordering: a tracked change inside a comment + * highlight returns `[{ trackedChange }, { comment }]`, matching what + * a switch on `hits[0]` expects when picking the most specific entity. + * + * Returns `[]` for null / non-Element starts. Uses duck-typed + * `getAttribute` access so it works under any DOM implementation + * (happy-dom, jsdom, real browser) without an `instanceof` check that + * could fail across realms. + */ +export function collectEntityHitsFromChain(start: Element | null): ViewportEntityHit[] { + if (!start || typeof (start as { getAttribute?: unknown }).getAttribute !== 'function') { + return []; + } + + const hits: ViewportEntityHit[] = []; + const seen = new Set(); + let el: Element | null = start; + while (el) { + const node = el as { getAttribute(name: string): string | null }; + const trackChangeId = node.getAttribute('data-track-change-id'); + if (trackChangeId) { + const key = `trackedChange:${trackChangeId}`; + if (!seen.has(key)) { + seen.add(key); + hits.push({ type: 'trackedChange', id: trackChangeId }); + } + } + const commentIds = node.getAttribute('data-comment-ids'); + if (commentIds) { + // The painter stamps overlapping comments as a comma-separated + // list — surface each id as its own hit so a "Resolve this + // comment" item in a context menu can target the right one. + for (const id of commentIds.split(',')) { + if (!id) continue; + const key = `comment:${id}`; + if (!seen.has(key)) { + seen.add(key); + hits.push({ type: 'comment', id }); + } + } + } + el = el.parentElement; + } + return hits; +} diff --git a/packages/super-editor/src/ui/index.ts b/packages/super-editor/src/ui/index.ts index f3f1eacadc..84447bb5b1 100644 --- a/packages/super-editor/src/ui/index.ts +++ b/packages/super-editor/src/ui/index.ts @@ -95,11 +95,15 @@ export type { SelectionAnchorRectOptions, SelectionCapture, SelectionHandle, + SelectionRestoreResult, SelectionSlice, // Toolbar + commands CommandHandle, CommandsHandle, + ContextMenuContribution, + ContextMenuItem, + ContextMenuWhenInput, CustomCommandHandle, CustomCommandHandleState, CustomCommandRegistration, @@ -120,6 +124,8 @@ export type { TrackChangesSlice, // Viewport + ViewportEntityAtInput, + ViewportEntityHit, ViewportGetRectInput, ViewportHandle, ViewportRect, diff --git a/packages/super-editor/src/ui/selection-restore.test.ts b/packages/super-editor/src/ui/selection-restore.test.ts new file mode 100644 index 0000000000..d6b5b72971 --- /dev/null +++ b/packages/super-editor/src/ui/selection-restore.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createSuperDocUI } from './create-super-doc-ui.js'; +import type { SuperDocLike } from './types.js'; + +// Pin the resolver so tests can exercise the post-resolve code paths +// (text-equivalence check, dispatch to setTextSelection) without +// constructing a real PM document. +vi.mock('../editors/v1/document-api-adapters/helpers/adapter-utils.js', () => ({ + resolveTextTarget: (editor: unknown, target: { blockId: string }) => { + if ((editor as { __resolverMode?: string })?.__resolverMode === 'null') return null; + if (target.blockId === 'b1') return { from: 1, to: 5 }; + return null; + }, +})); + +/** + * Stub for `ui.selection.restore` tests. The helper accesses + * `editor.isEditable` and `editor.commands.setTextSelection({ from, to })` + * directly; the rest of the editor surface is unused so the stub is + * minimal. + */ +function makeStubs(opts: { isEditable?: boolean; resolves?: boolean; liveText?: string } = {}) { + const isEditable = opts.isEditable ?? true; + const resolves = opts.resolves ?? true; + const liveText = opts.liveText ?? 'test'; + + const setTextSelection = vi.fn(() => true); + + const editor = { + on: vi.fn(), + off: vi.fn(), + isEditable, + // Switches the mocked `resolveTextTarget` between "succeeds" and + // "returns null" without rebuilding a real PM document for unit + // boundary testing. + __resolverMode: resolves ? 'ok' : 'null', + state: { + doc: { + textBetween: () => liveText, + }, + }, + commands: { setTextSelection }, + 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 }, + })), + }, + }, + }; + + const superdoc: SuperDocLike = { + activeEditor: editor as never, + config: { documentMode: 'editing' }, + on: vi.fn(), + off: vi.fn(), + }; + + return { superdoc, editor, mocks: { setTextSelection } }; +} + +const bodyCapture = Object.freeze({ + empty: false, + target: { kind: 'text', segments: [{ blockId: 'b1', range: { start: 0, end: 4 } }] }, + selectionTarget: null, + activeMarks: [], + activeCommentIds: [], + activeChangeIds: [], + quotedText: 'test', +}) as never; + +describe('ui.selection.restore', () => { + it('returns { success: false, reason: "not-ready" } when no editor is mounted', () => { + const { superdoc } = makeStubs(); + (superdoc as unknown as { activeEditor: unknown }).activeEditor = null; + const ui = createSuperDocUI({ superdoc }); + + expect(ui.selection.restore(bodyCapture)).toEqual({ success: false, reason: 'not-ready' }); + ui.destroy(); + }); + + it('returns { success: false, reason: "read-only" } when editor.isEditable is false', () => { + const { superdoc } = makeStubs({ isEditable: false }); + const ui = createSuperDocUI({ superdoc }); + + expect(ui.selection.restore(bodyCapture)).toEqual({ success: false, reason: 'read-only' }); + ui.destroy(); + }); + + it('returns { success: false, reason: "missing-target" } for a capture with null target', () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + const empty = Object.freeze({ + empty: false, + target: null, + selectionTarget: null, + activeMarks: [], + activeCommentIds: [], + activeChangeIds: [], + quotedText: '', + }) as never; + + expect(ui.selection.restore(empty)).toEqual({ success: false, reason: 'missing-target' }); + ui.destroy(); + }); + + it('returns { success: false, reason: "stale" } when the captured block id no longer resolves', () => { + const { superdoc } = makeStubs({ resolves: false }); + const ui = createSuperDocUI({ superdoc }); + + expect(ui.selection.restore(bodyCapture)).toEqual({ success: false, reason: 'stale' }); + ui.destroy(); + }); + + it('returns { success: false, reason: "stale" } when the live text at the resolved range no longer matches the capture', () => { + // The block id still resolves and offsets stay in bounds, but the + // text at those offsets has shifted (e.g. a collaborator inserted + // text earlier in the same paragraph). The text-equivalence check + // catches this and refuses to silently report success. + const { superdoc } = makeStubs({ liveText: 'shifted contents at same offset' }); + const ui = createSuperDocUI({ superdoc }); + + expect(ui.selection.restore(bodyCapture)).toEqual({ success: false, reason: 'stale' }); + ui.destroy(); + }); + + it('skips the text-equivalence check for collapsed captures (no range to misplace)', () => { + // A collapsed capture has quotedText === ''. textBetween at equal + // positions is also '', so the check is a trivial pass — but the + // stub returns 'mismatch' to prove we don't run the comparison + // for collapsed captures. + const { superdoc, mocks } = makeStubs({ liveText: 'should not be compared' }); + const ui = createSuperDocUI({ superdoc }); + + const collapsed = Object.freeze({ + empty: true, + target: { kind: 'text', segments: [{ blockId: 'b1', range: { start: 0, end: 0 } }] }, + selectionTarget: null, + activeMarks: [], + activeCommentIds: [], + activeChangeIds: [], + quotedText: '', + }) as never; + + expect(ui.selection.restore(collapsed)).toEqual({ success: true }); + expect(mocks.setTextSelection).toHaveBeenCalledTimes(1); + ui.destroy(); + }); + + it('returns { success: false, reason: "not-ready" } when editor.commands.setTextSelection is missing', () => { + const { superdoc, editor } = makeStubs(); + (editor as unknown as { commands: unknown }).commands = {}; + const ui = createSuperDocUI({ superdoc }); + + expect(ui.selection.restore(bodyCapture)).toEqual({ success: false, reason: 'not-ready' }); + ui.destroy(); + }); +}); diff --git a/packages/super-editor/src/ui/selection-restore.ts b/packages/super-editor/src/ui/selection-restore.ts new file mode 100644 index 0000000000..7c313e57b2 --- /dev/null +++ b/packages/super-editor/src/ui/selection-restore.ts @@ -0,0 +1,81 @@ +/** + * `ui.selection.restore(capture)` helper. Resolves a captured target + * back into PM positions on the routed editor and dispatches the + * `setTextSelection` command so the visible selection rejoins where + * the user originally was. Closes the round-trip a sidebar composer + * needs (capture on open → restore on close). + */ + +import type { Editor } from '../editors/v1/core/Editor.js'; +import { resolveTextTarget } from '../editors/v1/document-api-adapters/helpers/adapter-utils.js'; +import type { SelectionCapture, SelectionRestoreResult } from './types.js'; + +const SUCCESS: SelectionRestoreResult = { success: true }; + +export function restoreSelection(editor: Editor | null, capture: SelectionCapture): SelectionRestoreResult { + if (!editor) return { success: false, reason: 'not-ready' }; + + // Read-only mode (viewing) refuses selection mutation. Same posture + // as a doc-api mutation against an editor in `viewing` mode — the + // editor is observable but not addressable. + if (editor.isEditable === false) return { success: false, reason: 'read-only' }; + + const setTextSelection = editor.commands?.setTextSelection; + if (typeof setTextSelection !== 'function') return { success: false, reason: 'not-ready' }; + + const segments = capture.target?.segments; + if (!segments || segments.length === 0) return { success: false, reason: 'missing-target' }; + + // Multi-segment captures collapse to a single PM range bounded by + // the first segment's start and the last segment's end — same + // shape `selection-rects.ts` uses, and matches how the doc-api + // represents a selection in the unified PM document. + const first = segments[0]!; + const last = segments[segments.length - 1]!; + + let fromResolved: { from: number; to: number } | null = null; + let toResolved: { from: number; to: number } | null = null; + try { + fromResolved = resolveTextTarget(editor, { + kind: 'text', + blockId: first.blockId, + range: first.range, + }); + toResolved = resolveTextTarget(editor, { + kind: 'text', + blockId: last.blockId, + range: last.range, + }); + } catch { + // Ambiguous block ids (resolveTextTarget throws when two blocks + // share an id) collapse to 'stale'. The sibling + // `ui.selection.getRects(capture)` path surfaces a console.warn + // for the same condition because rect lookups can run on every + // scroll/resize and a per-frame warn would still be one-shot per + // capture; restore runs once on composer close, so the typed + // `'stale'` reason is enough — consumers branching on the result + // can log themselves if they care. + return { success: false, reason: 'stale' }; + } + if (!fromResolved || !toResolved) return { success: false, reason: 'stale' }; + + // Block id + range can both still resolve while the text inside the + // range has shifted — a collaborator inserts text earlier in the + // same paragraph, the offsets stay in-bounds, the resolved position + // is now over different content than the user originally selected. + // Compare the live text at the resolved range against the snapshot + // the capture froze (`quotedText` mirrors + // `state.doc.textBetween(from, to, ' ')` at capture time per the + // selection-info resolver). Skip the check when the capture was + // collapsed (`quotedText === ''`) — there's no range to misplace. + if (capture.quotedText !== '') { + const liveText = editor.state?.doc?.textBetween?.(fromResolved.from, toResolved.to, ' '); + if (typeof liveText === 'string' && liveText !== capture.quotedText) { + return { success: false, reason: 'stale' }; + } + } + + const ok = setTextSelection({ from: fromResolved.from, to: toResolved.to }); + if (!ok) return { success: false, reason: 'stale' }; + return SUCCESS; +} diff --git a/packages/super-editor/src/ui/types.ts b/packages/super-editor/src/ui/types.ts index ef9bfc1765..7b48f53799 100644 --- a/packages/super-editor/src/ui/types.ts +++ b/packages/super-editor/src/ui/types.ts @@ -169,6 +169,14 @@ export interface SuperDocEditorLike { width: number; height: number; }>; + /** + * Painted-DOM host element. `ui.viewport.entityAt` reads it to + * confirm the hit returned by `document.elementFromPoint` lives + * inside this controller's editor — without that scope check, a + * page mounting two SuperDoc instances would return entity ids + * from the wrong instance. + */ + visibleHost?: HTMLElement; } | null; } @@ -767,12 +775,8 @@ export interface SelectionHandle { * The returned handle is a frozen value object, safe to store * on a React ref or in component state across renders. * - * Visual restore (re-focus the editor and highlight the captured - * range when the composer closes) is intentionally NOT on this - * surface today: the public Document API has no `selection.set` - * primitive yet, and `editor.doc.*` is the contract this - * controller routes through. A `restore()` method lands once the - * doc-api primitive does. + * Pair with {@link restore} to put the visible selection back when + * the composer closes. */ capture(): SelectionCapture | null; /** @@ -818,8 +822,53 @@ export interface SelectionHandle { * `'union'`. Returns `null` when there are no rects. */ getAnchorRect(options?: SelectionAnchorRectOptions, capture?: SelectionCapture | null): ViewportRect | null; + /** + * Inverse of {@link capture}. Set the editor's visible selection to + * the range a capture froze. Closes the round-trip a sidebar + * composer needs: capture on open, post on submit, restore on close + * so the user sees the editor with the same range highlighted. + * + * Returns a result object rather than `void` because captures go + * stale: an edit between capture-time and call-time can move or + * delete the captured block, the editor can switch into viewing + * mode, or the captured target may have been a non-text selection + * with no addressable range. The `reason` discriminator lets + * consumers distinguish "the editor hasn't mounted yet" from "the + * doc has changed under us" without inspecting state separately. + * + * Side effect: a successful restore also moves browser focus into + * the editor's painted host (via the underlying `setTextSelection` + * command). That is the right behavior for the canonical composer + * flow — the user submits and expects to keep typing — but it does + * mean callers triggering `restore` from contexts where focus + * shouldn't move (e.g. a "preview" toggle that should leave focus + * on a sidebar control) need to gate the call themselves. + * + * Cross-surface limitation: a capture taken in a header / footer / + * footnote / endnote restores correctly while the user remains in + * that story (the routed editor still owns the captured block ids). + * Once focus has moved to the body, the routed editor falls back + * and the captured non-body block ids no longer resolve there; + * `restore` returns `{ success: false, reason: 'stale' }` rather + * than placing the selection on the wrong surface. Same posture as + * {@link getRects}. + */ + restore(capture: SelectionCapture): SelectionRestoreResult; } +/** + * Result of {@link SelectionHandle.restore}. + * + * `'not-ready'` — no editor mounted (SSR, post-destroy). + * `'read-only'` — editor is in viewing mode; selection mutation refused. + * `'missing-target'` — capture had no addressable text target. + * `'stale'` — captured block ids don't resolve in the current document + * (the doc was edited or swapped between capture and restore). + */ +export type SelectionRestoreResult = + | { success: true } + | { success: false; reason: 'not-ready' | 'read-only' | 'missing-target' | 'stale' }; + /** * Options for {@link SelectionHandle.getAnchorRect}. */ @@ -841,8 +890,8 @@ export interface SelectionAnchorRectOptions { * * Same shape as {@link SelectionSlice}; declared as its own type * so consumers can name the captured value in their component - * state (`useState(null)`) and so the - * planned `restore(capture)` follow-up has a stable input type. + * state (`useState(null)`) and so + * {@link SelectionHandle.restore} has a stable input type. * * The runtime value is recursively `Object.freeze`d, so assigning * into `captured.target.segments[0].range.start` or @@ -998,6 +1047,37 @@ export type CommandsHandle = { * tests, internal command pipelines. */ require(id: string): DynamicCommandHandle; + + /** + * Collect the right-click context-menu items contributed by custom + * commands, filtered by their `when` predicate and sorted by + * `(group, order, registration time)`. Returns `[]` when no + * registered command carries a `contextMenu` field or none survives + * the predicate. + * + * The consumer renders the menu themselves. The typical flow: + * + * ```ts + * scope.on(editorHost, 'contextmenu', (event) => { + * event.preventDefault(); + * const entities = ui.viewport.entityAt({ x: event.clientX, y: event.clientY }); + * const items = ui.commands.getContextMenuItems({ entities }); + * renderMenu(items, event.clientX, event.clientY); + * }); + * ``` + * + * `entities` defaults to `[]` so menus that aren't point-anchored + * (keyboard shortcut, app-bar trigger) still resolve a useful + * subset. The current selection slice is read from controller state + * automatically. + * + * Built-in items are NOT in this list: SuperDoc's built-in + * context-menu extension still owns Bold / Italic / Copy / Paste + * when enabled. This surface exists for apps that disable that + * extension (`disableContextMenu: true`) and roll their own menu — + * built-in entries belong to the consumer's renderer at that point. + */ + getContextMenuItems(input?: { entities?: ViewportEntityHit[] }): ContextMenuItem[]; }; /** @@ -1104,8 +1184,83 @@ export type CustomCommandRegistration = { * a console warning. */ override?: boolean; + /** + * Optional contribution to the right-click context menu. When set, + * the command shows up in {@link CommandsHandle.getContextMenuItems} + * results (filtered by `when`) so a custom context-menu UI can + * render and dispatch it. Consumers using SuperDoc's built-in + * context-menu extension keep using that — this surface is for + * apps that turn the built-in off (`disableContextMenu`) and roll + * their own menu without losing the contribution model. + */ + contextMenu?: ContextMenuContribution; }; +/** + * Right-click context-menu contribution attached to a custom command. + * + * The consumer renders the menu themselves; SuperDoc just collects the + * items, applies `when`, and sorts. Click handling stays on the + * consumer's side and dispatches via `ui.commands.get(id).execute()`. + */ +export interface ContextMenuContribution { + /** Display label for the item. */ + label: string; + /** + * Logical group for sorting. Lets a contribution slot next to + * related built-ins. Custom group names are accepted; unknown groups + * are placed after the built-in groups in registration order. Built-in + * group ids: `'format'`, `'clipboard'`, `'review'`, `'comment'`, + * `'link'`. + */ + group?: string; + /** + * Sort order within the group. Lower runs earlier. Defaults to `0`; + * ties are broken by registration order so the rendered menu is + * stable across snapshots. + */ + order?: number; + /** + * Predicate scoping the item to specific contexts (the click landed + * on a tracked change, the selection is non-empty, etc.). Receives + * the entities under the click coordinate (call + * {@link ViewportHandle.entityAt} to populate them) and the current + * selection slice. Omitted predicate means "always applicable". + * + * Errors thrown from `when` are caught and the item is hidden for + * that query — same posture as `getState` on a custom command. + */ + when?: (input: ContextMenuWhenInput) => boolean; +} + +/** Input passed to {@link ContextMenuContribution.when}. */ +export interface ContextMenuWhenInput { + /** + * Entities under the right-click point, from + * {@link ViewportHandle.entityAt}. Empty array when the consumer + * didn't pass entities (e.g. the menu opens from a keyboard shortcut + * rather than a click) or when the point is over no painted entity. + */ + entities: ViewportEntityHit[]; + /** Current selection slice. Mirrors `state.selection`. */ + selection: SelectionSlice; +} + +/** + * One item returned by {@link CommandsHandle.getContextMenuItems}. + * + * The `id` matches a registered custom command; consumers dispatch on + * click via `ui.commands.get(item.id).execute()`. `group` and `order` + * are surfaced (rather than collapsed) so the consumer's renderer can + * insert separators between groups. + */ +export interface ContextMenuItem { + id: string; + label: string; + group: string; + order: number; +} + /** Return value from {@link CommandsHandle.register}. */ export type CustomCommandRegistrationResult = { /** @@ -1369,4 +1524,54 @@ export interface ViewportHandle { scrollIntoView( input: import('@superdoc/document-api').ScrollIntoViewInput, ): Promise; + /** + * Look up entities painted under a viewport coordinate. Used by + * right-click menus and hover tooltips to ask "what's at this point?" + * without consumers reading `data-track-change-id` / + * `data-comment-ids` off the painted DOM themselves; the + * data-attribute layout is an implementation detail of the painter + * that consumers shouldn't depend on. + * + * Returns an ordered array of {@link ViewportEntityHit}, innermost + * first. A point can sit inside several entities at once (a tracked + * change inside a comment highlight, for example); every match is + * surfaced, not just the topmost. Empty array when the point isn't + * over any painted entity, when called outside a browser, or when no + * editor is mounted. + * + * Scoped to the controller's own editor: hits are only returned when + * the point lands inside this editor's painted host. A page mounting + * two SuperDoc instances therefore can't have one controller return + * ids from the other's DOM, and post-destroy calls return `[]` + * rather than stale ids from cached painted nodes. + * + * Today the supported entity types are `comment` and `trackedChange`. + * `link`, `image`, and `tableCell` are reserved for follow-ups; + * adding them is purely additive (new union members), so callers can + * `switch` on `hit.type` and the default branch remains forward + * compatible. + */ + entityAt(input: ViewportEntityAtInput): ViewportEntityHit[]; +} + +/** + * Input shape for {@link ViewportHandle.entityAt}. Coordinates are + * viewport-relative (the same space `MouseEvent.clientX` / + * `clientY` produce, and the same space {@link ViewportRect} reports + * back), so a `contextmenu` handler can pass `event.clientX` / + * `event.clientY` directly. + */ +export interface ViewportEntityAtInput { + x: number; + y: number; } + +/** + * One hit returned by {@link ViewportHandle.entityAt}. + * + * The union is intentionally narrow today (`comment` / + * `trackedChange`); other entity types land via additive union + * members so a `switch` on `hit.type` with a default branch stays + * forward compatible. + */ +export type ViewportEntityHit = { type: 'comment'; id: string } | { type: 'trackedChange'; id: string }; diff --git a/packages/super-editor/src/ui/viewport.test.ts b/packages/super-editor/src/ui/viewport.test.ts index 0dcef561e2..2d71ff3a34 100644 --- a/packages/super-editor/src/ui/viewport.test.ts +++ b/packages/super-editor/src/ui/viewport.test.ts @@ -310,3 +310,55 @@ describe('ui.viewport.scrollIntoView', () => { ui.destroy(); }); }); + +describe('ui.viewport.entityAt — host scoping', () => { + it('returns [] for invalid input (missing or non-numeric coordinates)', () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + expect(ui.viewport.entityAt({} as never)).toEqual([]); + expect(ui.viewport.entityAt({ x: 'a', y: 0 } as never)).toEqual([]); + + ui.destroy(); + }); + + it('returns [] when no editor is mounted (no presentationEditor.visibleHost)', () => { + const { superdoc } = makeStubs(); + // Stub editor has no `visibleHost` on its presentationEditor — + // simulating SSR / non-paginated mounts and post-destroy state. + const ui = createSuperDocUI({ superdoc }); + + expect(ui.viewport.entityAt({ x: 10, y: 10 })).toEqual([]); + + ui.destroy(); + }); + + it('returns [] when the hit element is outside the controller`s painted host', () => { + const { superdoc } = makeStubs(); + // Mount a fake host on the stub presentation editor and put the + // "hit" element OUTSIDE that host — the equivalent of a second + // SuperDoc instance painting the cursor target. + const host = document.createElement('div'); + document.body.appendChild(host); + ( + superdoc.activeEditor as unknown as { presentationEditor: { visibleHost: HTMLElement } } + ).presentationEditor.visibleHost = host; + + const outside = document.createElement('span'); + outside.dataset.commentIds = 'c-foreign'; + document.body.appendChild(outside); + + const docAny = document as unknown as { elementFromPoint?: (x: number, y: number) => Element | null }; + const original = docAny.elementFromPoint; + docAny.elementFromPoint = () => outside; + + const ui = createSuperDocUI({ superdoc }); + expect(ui.viewport.entityAt({ x: 0, y: 0 })).toEqual([]); + + if (original) docAny.elementFromPoint = original; + else delete docAny.elementFromPoint; + outside.remove(); + host.remove(); + ui.destroy(); + }); +}); diff --git a/packages/superdoc/src/ui.d.ts b/packages/superdoc/src/ui.d.ts index 518d9d23cd..f64426a6ec 100644 --- a/packages/superdoc/src/ui.d.ts +++ b/packages/superdoc/src/ui.d.ts @@ -10,6 +10,9 @@ export { type CommentsListQuery, type CommentsListResult, type CommentsSlice, + type ContextMenuContribution, + type ContextMenuItem, + type ContextMenuWhenInput, type CustomCommandHandle, type CustomCommandHandleState, type CustomCommandRegistration, @@ -23,10 +26,12 @@ export { type Receipt, type ScrollIntoViewInput, type ScrollIntoViewOutput, + type SelectionAnchorRectOptions, type SelectionCapture, type SelectionHandle, type SelectionInfo, type SelectionPoint, + type SelectionRestoreResult, type SelectionSlice, type SelectionTarget, type SelectorFn, @@ -50,6 +55,8 @@ export { type TrackChangesSlice, type TrackedChangeAddress, type UIToolbarCommandState, + type ViewportEntityAtInput, + type ViewportEntityHit, type ViewportGetRectInput, type ViewportHandle, type ViewportRect,