diff --git a/apps/docs/docs.json b/apps/docs/docs.json index d67934546d..c81cdc87ee 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -104,6 +104,7 @@ "editor/custom-ui/custom-commands", "editor/custom-ui/comments", "editor/custom-ui/track-changes", + "editor/custom-ui/content-controls", "editor/custom-ui/context-menu", "editor/custom-ui/selection-and-viewport", "editor/custom-ui/document-control", diff --git a/apps/docs/editor/custom-ui/content-controls.mdx b/apps/docs/editor/custom-ui/content-controls.mdx new file mode 100644 index 0000000000..af8c1f1588 --- /dev/null +++ b/apps/docs/editor/custom-ui/content-controls.mdx @@ -0,0 +1,57 @@ +--- +title: 'Content controls' +description: 'Build your own UI for Word content controls (SDT fields): turn off the built-in chrome, react to the active control, and anchor your UI with getRect().' +--- + +Turn off SuperDoc's built-in chrome, listen for the active control, and anchor your own UI over it. The control wrappers and `data-sdt-*` attributes stay in the DOM, so your UI has something to attach to. + +## A minimal field chip + +```ts +import { SuperDoc } from 'superdoc'; +import { createSuperDocUI } from 'superdoc/ui'; + +new SuperDoc({ + selector: '#editor', + document: '/contract.docx', + // Turn off the built-in labels, borders, and hover/selection chrome. + modules: { contentControls: { chrome: 'none' } }, + onReady: ({ superdoc }) => { + const ui = createSuperDocUI({ superdoc }); + + superdoc.on('content-control:active-change', ({ active }) => { + if (!active) return chip.hide(); // `chip` is your own element + const rect = ui.contentControls.getRect({ id: active.id }); + if (rect.success) chip.showAt(rect.rect, active.alias ?? active.tag); + }); + }, +}); +``` + +The event tells you *what* is active; `getRect` tells you *where* to draw. `active` is an `SdtRef` with `id`, `tag`, `alias`, `controlType`, and `scope`. + +## Pick the right surface + +| Goal | API | +| --- | --- | +| Active control (enter, switch, leave) | `superdoc.on('content-control:active-change')` | +| Click inside a control | `superdoc.on('content-control:click')` | +| Full live list and active stack | `ui.contentControls.observe()` / `getSnapshot()` | +| Read one control | `ui.contentControls.get({ id })` | +| Position your UI | `ui.contentControls.getRect({ id })` | +| Hover and right-click hit-testing | `ui.viewport.entityAt()` / `contextAt()` | +| Change content, tags, or locks | `editor.doc.contentControls.*` | + +`active` is the innermost control. For nested controls (an inline field inside a block clause), `activePath` carries the full stack, innermost first, so you don't also need `observe()` just to read the nesting. + +## Current limits + +- No built-in scroll-to-control. Read the position with `getRect()` and scroll your container. +- No geometry-change subscription. Re-read `getRect()` on scroll, resize, and the `pagination-update` / `zoomChange` events. +- No focus-by-id helper. Clicking a control in the document still drives selection. + +## See also + +- [Contract templates demo](https://github.com/superdoc-dev/superdoc/tree/main/demos/contract-templates) - a working field chip built on these APIs. +- [Configuration](/editor/superdoc/configuration) - the `modules.contentControls.chrome` option. +- [Document API: content controls](/document-api/features/content-controls) - read and change controls. diff --git a/apps/docs/editor/superdoc/events.mdx b/apps/docs/editor/superdoc/events.mdx index f4038e0cd7..3cffc4de66 100644 --- a/apps/docs/editor/superdoc/events.mdx +++ b/apps/docs/editor/superdoc/events.mdx @@ -243,6 +243,55 @@ superdoc.on('fonts-resolved', ({ documentFonts, unsupportedFonts }) => { ``` +## Content controls (SDT fields) + +### `content-control:active-change` + +Fires when selection enters a content control, switches between controls, or leaves all controls. + +```javascript +superdoc.on('content-control:active-change', ({ active, previous, activePath, source }) => { + // source: 'keyboard' | 'pointer' + // active / previous: SdtRef | null + // activePath: SdtRef[] - full active stack, innermost first ([] when none) +}); +``` + +`SdtRef` shape: + +```ts +type SdtRef = { + id: string; + tag?: string; + alias?: string; + controlType: string; + scope: 'inline' | 'block'; +}; +``` + +`active` is the deepest control (`activePath[0]`); `activePath` holds the full stack, innermost first. Controls without an `id` do not emit these events. + +How to interpret: + +1. Focus (`null -> A`): `previous === null && active !== null` +2. Switch (`A -> B`): `previous !== null && active !== null && previous.id !== active.id` +3. Blur (`A -> null`): `previous !== null && active === null` + +### `content-control:click` + +Fires on pointer click inside a content control. + +```javascript +superdoc.on('content-control:click', ({ target, source }) => { + // source is always 'pointer' + // target: SdtRef +}); +``` + +Both are also available as config callbacks: `onContentControlActiveChange` and `onContentControlClick`. + +To build your own content-control UI on these events, see [Custom UI > Content controls](/editor/custom-ui/content-controls). + ## Comments events ### `comments-update` diff --git a/demos/contract-templates/src/field-chip.ts b/demos/contract-templates/src/field-chip.ts index 965167d295..53b328e5e6 100644 --- a/demos/contract-templates/src/field-chip.ts +++ b/demos/contract-templates/src/field-chip.ts @@ -1,25 +1,30 @@ /** - * Contextual smart-field chip — SD-3157 demo. + * Contextual smart-field chip — SD-3157 / SD-3232 demo. * * Shows a small chip anchored above the active smart-field content - * control with the field's label and current value. Wired against the - * public `superdoc/ui` controller (no framework — this demo is plain - * TypeScript), using: + * control with the field's label and current value. Plain TypeScript + * (no framework), wired against two public SuperDoc APIs: * - * - `ui.contentControls.observe(...)` to react to the active control - * - `ui.contentControls.getRect({ id })` to anchor the chip + * - `superdoc.on('content-control:active-change', ...)` to know *which* + * control is active (SD-3232 events). The payload's `SdtRef` carries + * the tag/alias/scope directly, so no extra lookup is needed. + * - `ui.contentControls.getRect({ id })` to know *where* to draw the chip. + * + * That pairing is the intended model: events tell you what is active; + * `getRect()` tells you where to place your own UI. * * Narrow on purpose: only renders for `kind: 'smartField'` controls so - * the chip doesn't collide with the existing block-clause review UI in - * the Clauses tab. Linked-occurrence highlights, field-details popovers, - * and clause badges are deliberate follow-ups (SD-3155 umbrella). + * the chip doesn't collide with the block-clause review UI in the Clauses + * tab. Linked-occurrence highlights, field-details popovers, and clause + * badges are deliberate follow-ups (SD-3155 umbrella). * - * This demo runs with SuperDoc's built-in SDT chrome turned off + * The demo runs with SuperDoc's built-in SDT chrome turned off * (`modules.contentControls.chrome: 'none'`, SD-3159), so the chip is the * smart field's active-state UI rather than an addition on top of the * built-in blue label/border. The wrappers and data-sdt-* datasets are - * still emitted, which is what `observe`/`getRect`/`get` rely on. + * still emitted, which is what `getRect` relies on. */ +import type { SuperDoc, ContentControlActiveChangePayload } from 'superdoc'; import type { SuperDocUI } from 'superdoc/ui'; export type SmartFieldLookup = { @@ -33,11 +38,12 @@ const CHIP_CLASS = 'sd-field-chip'; const CHIP_OFFSET_PX = 6; /** - * Wire the chip to the controller. Returns a teardown function that + * Wire the chip. `superdoc` supplies the active-change events; `ui` + * supplies `getRect` for positioning. Returns a teardown function that * detaches listeners and removes the chip element. Safe to call after * `initialize()` has populated the field-value cache. */ -export function attachFieldChip(ui: SuperDocUI, lookup: SmartFieldLookup): () => void { +export function attachFieldChip(superdoc: SuperDoc, ui: SuperDocUI, lookup: SmartFieldLookup): () => void { const chipEl = document.createElement('div'); chipEl.className = CHIP_CLASS; chipEl.style.position = 'fixed'; @@ -50,12 +56,11 @@ export function attachFieldChip(ui: SuperDocUI, lookup: SmartFieldLookup): () => let currentKey: string | null = null; /** - * Clear the active control entirely. Use ONLY when the controller - * tells us "no active SDT" — i.e. the observe callback fires with - * `activeId: null` or the active control isn't a smart field. Do - * NOT call this from the positioning loop on a transient rect miss - * (a reflow can drop the rect for one tick; clearing here would - * leave the chip hidden until the user clicks away and back). + * Clear the active control entirely. Use ONLY when active-change tells + * us "no active smart field" (active is null, or not a smart field). Do + * NOT call this from the positioning loop on a transient rect miss (a + * reflow can drop the rect for one tick; clearing here would leave the + * chip hidden until the user clicks away and back). */ const clearActive = () => { chipEl.style.visibility = 'hidden'; @@ -72,9 +77,8 @@ export function attachFieldChip(ui: SuperDocUI, lookup: SmartFieldLookup): () => if (!currentId) return; const rect = ui.contentControls.getRect({ id: currentId }); if (!rect.success) { - // Transient miss — keep the active state so the next scroll / - // resize / observe tick can re-anchor without requiring the - // user to click away. + // Transient miss — keep the active state so the next scroll / resize + // tick can re-anchor without requiring the user to click away. hideVisually(); return; } @@ -113,17 +117,18 @@ export function attachFieldChip(ui: SuperDocUI, lookup: SmartFieldLookup): () => const onScrollOrResize = () => positionChip(); - const unsubscribe = ui.contentControls.observe((snapshot) => { - // Narrow to smart-field SDTs only. Block-level reusable clauses - // have their own review surface in the Clauses tab; rendering a - // chip on them would compete with that flow. - const activeId = snapshot.activeId; - if (!activeId) { + // SD-3232: the active control comes from the public SuperDoc event. The + // payload includes the SdtRef (id + tag), so we can narrow to smart + // fields and anchor by id without a separate lookup. + const onActiveChange = ({ active }: ContentControlActiveChangePayload) => { + if (!active) { clearActive(); return; } - const info = ui.contentControls.get({ id: activeId }); - const tagStr = info?.properties?.tag; + // Narrow to smart-field SDTs only. Block-level reusable clauses have + // their own review surface in the Clauses tab; a chip on them would + // compete with that flow. + const tagStr = active.tag; if (!tagStr) { clearActive(); return; @@ -139,16 +144,17 @@ export function attachFieldChip(ui: SuperDocUI, lookup: SmartFieldLookup): () => clearActive(); return; } - currentId = activeId; + currentId = active.id; currentKey = parsed.key; update(); - }); + }; + superdoc.on('content-control:active-change', onActiveChange); window.addEventListener('scroll', onScrollOrResize, true); window.addEventListener('resize', onScrollOrResize); return () => { - unsubscribe(); + superdoc.off('content-control:active-change', onActiveChange); window.removeEventListener('scroll', onScrollOrResize, true); window.removeEventListener('resize', onScrollOrResize); chipEl.remove(); diff --git a/demos/contract-templates/src/main.ts b/demos/contract-templates/src/main.ts index 090deed3eb..83f34f54ec 100644 --- a/demos/contract-templates/src/main.ts +++ b/demos/contract-templates/src/main.ts @@ -279,7 +279,9 @@ async function initialize(instance: DemoSuperDoc): Promise { // leave the previous controller's scroll/resize listeners attached // to `window` and the previous chip element in the DOM. state.ui = createSuperDocUI({ superdoc: instance }); - state.fieldChipTeardown = attachFieldChip(state.ui, { + // Active control comes from the SuperDoc event (SD-3232); placement from + // the UI controller's getRect (SD-3157). + state.fieldChipTeardown = attachFieldChip(instance, state.ui, { labelFor: (key) => FIELDS.find((f) => f.key === (key as FieldKey))?.label ?? key, valueFor: (key) => state.values[key as FieldKey], }); diff --git a/demos/contract-templates/src/style.css b/demos/contract-templates/src/style.css index 410759e4bd..e867ad1785 100644 --- a/demos/contract-templates/src/style.css +++ b/demos/contract-templates/src/style.css @@ -264,11 +264,12 @@ input:focus { } /* - * Contextual smart-field chip (SD-3157). Floats over the active smart- - * field SDT showing field label + live value. Wired in field-chip.ts - * against `ui.contentControls.observe` + `getRect`. With built-in chrome - * off (SD-3159), the chip is the smart field's active-state affordance: - * custom UI anchored to the SDT via the public geometry API. + * Contextual smart-field chip (SD-3157 / SD-3232). Floats over the active + * smart-field SDT showing field label + live value. Wired in field-chip.ts + * against the public `content-control:active-change` event (what's active) + * + `ui.contentControls.getRect` (where to draw). With built-in chrome off + * (SD-3159), the chip is the smart field's active-state affordance: custom + * UI anchored to the SDT via public events and the geometry API. */ .sd-field-chip { display: inline-flex; diff --git a/packages/super-editor/src/editors/v1/core/Editor.contentControlEvents.test.ts b/packages/super-editor/src/editors/v1/core/Editor.contentControlEvents.test.ts new file mode 100644 index 0000000000..767b1e9e01 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/Editor.contentControlEvents.test.ts @@ -0,0 +1,263 @@ +/* @vitest-environment jsdom */ + +/** + * Behavior coverage for the public content-control interaction events + * (`contentControlFocus` / `contentControlBlur` / `contentControlClick`). + * + * Unlike the SuperDoc.vue bridge tests (which replay mocked payloads), these + * drive a real headless editor with real `structuredContent` / + * `structuredContentBlock` nodes and exercise the actual extraction + * (`#collectActiveSdtRefs` / `#toSdtRef`), the focus/switch/blur state + * machine, the click path, and source detection. + */ + +import { describe, it, expect, beforeAll, vi } from 'vitest'; +import { TextSelection } from 'prosemirror-state'; +import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; + +// SDT phrases sit between non-SDT padding so there are positions outside every +// control (for the blur/baseline cases). +const HOST = 'Start Alpha gap Charlie end'; + +function resolveBlockId(receipt) { + if (!receipt || typeof receipt !== 'object') return null; + const v = receipt; + if (typeof v.target?.blockId === 'string' && v.target.blockId) return v.target.blockId; + if (typeof v.resolution?.target?.blockId === 'string' && v.resolution.target.blockId) return v.resolution.target.blockId; + return null; +} + +describe('Editor content-control events', () => { + let docData; + beforeAll(async () => { + docData = await loadTestDataForEditorTests('blank-doc.docx'); + }); + + function makeEditor() { + const { editor } = initTestEditor({ + content: docData.docx, + media: docData.media, + mediaFiles: docData.mediaFiles, + fonts: docData.fonts, + isHeadless: true, + useImmediateSetTimeout: false, + user: { name: 'Test', email: 'test@example.com' }, + }); + return editor; + } + + // Seed HOST and wrap two inline SDTs. Wrap the later phrase first so the + // earlier phrase's offsets stay valid as wraps accumulate. + async function seedTwoInlineSdts(editor) { + const seed = await Promise.resolve(editor.doc.insert({ value: HOST })); + const blockId = resolveBlockId(seed); + if (!blockId) throw new Error('no blockId'); + const wrap = async (phrase, tag, alias) => { + const start = HOST.indexOf(phrase); + const end = start + phrase.length; + const r = await Promise.resolve( + editor.doc.create.contentControl({ + kind: 'inline', + controlType: 'text', + at: { kind: 'selection', start: { kind: 'text', blockId, offset: start }, end: { kind: 'text', blockId, offset: end } }, + tag, + alias, + }), + ); + expect(r.success).toBe(true); + }; + await wrap('Charlie', 'cc-b', 'Control B'); + await wrap('Alpha', 'cc-a', 'Control A'); + } + + // Collect inline SDTs in document order with a position inside each. + function findInlineSdts(editor) { + const sdts = []; + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'structuredContent') { + sdts.push({ id: node.attrs.id, tag: node.attrs.tag, alias: node.attrs.alias, pos, inside: pos + 1 }); + } + return true; + }); + sdts.sort((a, b) => a.pos - b.pos); + return sdts; + } + + const selectAt = (editor, pos, meta) => { + let tr = editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos)); + if (meta) tr = tr.setMeta('uiEvent', meta); + editor.dispatch(tr); + }; + + // A position guaranteed to be outside every SDT (inside the trailing " end"). + const outsidePos = (editor) => Math.max(1, editor.state.doc.content.size - 2); + + it('emits focus, switch, and blur with correct refs and keyboard source', async () => { + const editor = makeEditor(); + await seedTwoInlineSdts(editor); + const sdts = findInlineSdts(editor); + expect(sdts).toHaveLength(2); + const [a, b] = sdts; + + // Baseline to a non-SDT position so the first tracked focus has previous=null. + selectAt(editor, outsidePos(editor)); + + const focus = vi.fn(); + const blur = vi.fn(); + editor.on('contentControlFocus', focus); + editor.on('contentControlBlur', blur); + + selectAt(editor, a.inside); // null -> A + selectAt(editor, b.inside); // A -> B (switch) + selectAt(editor, outsidePos(editor)); // B -> null (blur) + + expect(focus).toHaveBeenCalledTimes(2); + expect(focus.mock.calls[0][0]).toMatchObject({ active: { id: a.id, scope: 'inline' }, previous: null, source: 'keyboard' }); + expect(focus.mock.calls[0][0].activePath.map((r) => r.id)).toEqual([a.id]); + expect(focus.mock.calls[1][0]).toMatchObject({ active: { id: b.id }, previous: { id: a.id }, source: 'keyboard' }); + expect(focus.mock.calls[1][0].activePath.map((r) => r.id)).toEqual([b.id]); + expect(blur).toHaveBeenCalledTimes(1); + expect(blur.mock.calls[0][0]).toMatchObject({ active: null, previous: { id: b.id }, source: 'keyboard' }); + expect(blur.mock.calls[0][0].activePath).toEqual([]); + + editor.destroy(); + }); + + it('emits click with pointer source when the selection transaction carries uiEvent:click', async () => { + const editor = makeEditor(); + await seedTwoInlineSdts(editor); + const sdts = findInlineSdts(editor); + selectAt(editor, outsidePos(editor)); + + const click = vi.fn(); + const focus = vi.fn(); + editor.on('contentControlClick', click); + editor.on('contentControlFocus', focus); + + selectAt(editor, sdts[0].inside, 'click'); + + expect(click).toHaveBeenCalledTimes(1); + expect(click.mock.calls[0][0]).toMatchObject({ target: { id: sdts[0].id }, source: 'pointer' }); + // The same click also enters the control, so focus fires with pointer source. + expect(focus).toHaveBeenCalledTimes(1); + expect(focus.mock.calls[0][0]).toMatchObject({ active: { id: sdts[0].id }, source: 'pointer' }); + + editor.destroy(); + }); + + it('does not emit content-control events for a mutation that does not change the active control', async () => { + const editor = makeEditor(); + await seedTwoInlineSdts(editor); + const sdts = findInlineSdts(editor); + selectAt(editor, sdts[0].inside); // enter A + + const focus = vi.fn(); + const blur = vi.fn(); + const click = vi.fn(); + editor.on('contentControlFocus', focus); + editor.on('contentControlBlur', blur); + editor.on('contentControlClick', click); + + // Mutate text content while selection stays inside the same control. + editor.dispatch(editor.state.tr.insertText('X', sdts[0].inside)); + + expect(focus).not.toHaveBeenCalled(); + expect(blur).not.toHaveBeenCalled(); + expect(click).not.toHaveBeenCalled(); + + editor.destroy(); + }); + + it('reports pointer source for a non-click selection following a recent pointerDown', async () => { + // initTestEditor builds the editor without deferDocumentLoad, i.e. the + // default #init path PresentationEditor uses. This guards that the pointer + // timestamp listener is wired there (not only on #registerEventListeners), + // so source is 'pointer' even when the selection carries no uiEvent:'click'. + const editor = makeEditor(); + await seedTwoInlineSdts(editor); + const sdts = findInlineSdts(editor); + selectAt(editor, outsidePos(editor)); // baseline + + const focus = vi.fn(); + editor.on('contentControlFocus', focus); + + // Simulate a pointer interaction, then a selection change with NO click meta. + editor.emit('pointerDown', { editor, event: {} as PointerEvent }); + selectAt(editor, sdts[0].inside); + + expect(focus).toHaveBeenCalledTimes(1); + expect(focus.mock.calls[0][0]).toMatchObject({ active: { id: sdts[0].id }, source: 'pointer' }); + + editor.destroy(); + }); + + it('emits focus for a block-scope SDT (structuredContentBlock)', async () => { + const editor = makeEditor(); + await Promise.resolve(editor.doc.insert({ value: 'Block clause body text' })); + // kind: 'block' wraps the block containing the current selection. + const r = await Promise.resolve( + editor.doc.create.contentControl({ kind: 'block', controlType: 'richText', tag: 'cc-block', alias: 'Block Control' }), + ); + expect(r.success).toBe(true); + + let block = null; + editor.state.doc.descendants((node, pos) => { + if (!block && node.type.name === 'structuredContentBlock') block = { id: node.attrs.id, inside: pos + 2 }; + return true; + }); + expect(block).toBeTruthy(); + if (!block) return; + + const focus = vi.fn(); + editor.on('contentControlFocus', focus); + selectAt(editor, 1); // out + selectAt(editor, block.inside); // into the block control + + const blockFocus = focus.mock.calls.map((c) => c[0]).find((p) => p.active?.id === block.id); + expect(blockFocus).toBeTruthy(); + expect(blockFocus.active).toMatchObject({ id: block.id, scope: 'block', controlType: 'richText' }); + expect(blockFocus.activePath.map((r) => r.id)).toEqual([block.id]); + + editor.destroy(); + }); + + it('exposes the full activePath (innermost first) for a nested inline-in-block control', () => { + const editor = makeEditor(); + const schema = editor.state.schema; + // Build a deterministic nested doc: block control > paragraph > [text, inline control, text]. + const inner = schema.nodes.structuredContent.create( + { id: 'cc-inner', tag: 'cc-inner', controlType: 'text' }, + schema.text('nested'), + ); + const para = schema.nodes.paragraph.create(null, [schema.text('a '), inner, schema.text(' b')]); + const outer = schema.nodes.structuredContentBlock.create( + { id: 'cc-outer', tag: 'cc-outer', controlType: 'richText' }, + para, + ); + editor.dispatch(editor.state.tr.replaceWith(0, editor.state.doc.content.size, outer)); + + let innerInside = null; + editor.state.doc.descendants((node, pos) => { + if (innerInside === null && node.type.name === 'structuredContent') innerInside = pos + 1; + return true; + }); + expect(innerInside).not.toBeNull(); + + // Baseline inside the outer block but before the inline (active = outer only). + selectAt(editor, 2); + const focus = vi.fn(); + editor.on('contentControlFocus', focus); + selectAt(editor, innerInside); // caret inside the nested inline control + + const ev = focus.mock.calls.map((c) => c[0]).find((p) => p.active?.id === 'cc-inner'); + expect(ev).toBeTruthy(); + if (!ev) return; + // `active` is the deepest control; activePath is the full stack innermost-first. + expect(ev.active.id).toBe('cc-inner'); + expect(ev.active.scope).toBe('inline'); + expect(ev.activePath.map((r) => r.id)).toEqual(['cc-inner', 'cc-outer']); + expect(ev.activePath.map((r) => r.scope)).toEqual(['inline', 'block']); + + editor.destroy(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index 3a70991350..54f35848b8 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -6,10 +6,10 @@ import type { Doc as YDoc, XmlFragment as YXmlFragment } from 'yjs'; import type { EditorOptions, User, FieldValue, DocxFileEntry } from './types/EditorConfig.js'; import type { EditorHelpers, ExtensionStorage, ProseMirrorJSON, PageStyles, Toolbar } from './types/EditorTypes.js'; import type { ChainableCommandObject, CanObject, EditorCommands } from './types/ChainedCommands.js'; -import type { EditorEventMap, FontsResolvedPayload, Comment } from './types/EditorEvents.js'; +import type { EditorEventMap, FontsResolvedPayload, Comment, SdtRef } from './types/EditorEvents.js'; import type { SchemaSummaryJSON } from './types/EditorSchema.js'; -import { EditorState as PmEditorState } from 'prosemirror-state'; +import { EditorState as PmEditorState, NodeSelection as PmNodeSelection } from 'prosemirror-state'; import { DOMSerializer as PmDOMSerializer } from 'prosemirror-model'; import { yXmlFragmentToProseMirrorRootNode } from 'y-prosemirror'; import * as helpers from './helpers/index.js'; @@ -402,6 +402,14 @@ const transactionTouchesTrackedReviewState = (state: EditorState, tr: Transactio const docs = (tr as unknown as { docs?: PmNode[] }).docs ?? []; return tr.steps.some((step, index) => stepTouchesTrackedReviewState(step, docs[index] ?? state.doc)); }; +// Best-effort heuristic for labeling a content-control event's `source`. A +// click sets `uiEvent: 'click'` on its selection transaction (the precise +// signal); this window is the fallback for selection changes that don't carry +// that meta but follow a recent pointerDown. It is deliberately generous (a +// click can be followed by async selection settling); the trade-off is that a +// keyboard move within this window of a click is labeled 'pointer'. Source is +// advisory metadata, not a correctness guarantee. +const CONTENT_CONTROL_POINTER_WINDOW_MS = 800; type ExtensionInstanceLike = { type?: string; @@ -679,6 +687,8 @@ export class Editor extends EventEmitter { * Guard flag to prevent double-tracking document open */ #documentOpenTracked = false; + #lastActiveContentControlRef: SdtRef | null = null; + #lastPointerDownAt = 0; /** * Fragment configured at construction time. This is reusable default config, @@ -1087,10 +1097,24 @@ export class Editor extends EventEmitter { this.on('fonts-resolved', this.options.onFontsResolved!); this.on('exception', this.options.onException!); this.on('pointerDown', this.options.onPointerDown!); + this.#trackContentControlPointer(); this.on('pointerUp', this.options.onPointerUp!); this.on('rightClick', this.options.onRightClick!); } + /** + * Record the most recent pointerDown timestamp for content-control event + * source detection. Registered on every init path (#registerEventListeners, + * #init, #initRichText) so source detection works regardless of + * deferDocumentLoad - the default PresentationEditor path uses #init / + * #initRichText, not #registerEventListeners. + */ + #trackContentControlPointer(): void { + this.on('pointerDown', () => { + this.#lastPointerDownAt = Date.now(); + }); + } + /** * Load a document into the editor from various source types. * @@ -1414,6 +1438,13 @@ export class Editor extends EventEmitter { // Reset internal state this._state = undefined!; + + // Reset content-control event tracking. These track the active SDT and the + // last pointer-down for the *current* document; leaving them set would leak + // a stale `previous` into the first content-control event of the next + // document opened on this instance (or skip the first focus if ids collide). + this.#lastActiveContentControlRef = null; + this.#lastPointerDownAt = 0; } /** @@ -1498,6 +1529,7 @@ export class Editor extends EventEmitter { this.on('fonts-resolved', this.options.onFontsResolved!); this.on('exception', this.options.onException!); this.on('pointerDown', this.options.onPointerDown!); + this.#trackContentControlPointer(); this.on('pointerUp', this.options.onPointerUp!); this.on('rightClick', this.options.onRightClick!); @@ -1577,6 +1609,7 @@ export class Editor extends EventEmitter { this.on('locked', this.options.onDocumentLocked!); this.on('list-definitions-change', this.options.onListDefinitionsChange!); this.on('pointerDown', this.options.onPointerDown!); + this.#trackContentControlPointer(); this.on('pointerUp', this.options.onPointerUp!); this.on('rightClick', this.options.onRightClick!); @@ -3145,6 +3178,11 @@ export class Editor extends EventEmitter { transaction: transactionToApply, }); } + this.#emitContentControlEvents({ + transaction: transactionToApply, + nextState, + selectionHasChanged, + }); const focus = transactionToApply.getMeta('focus'); if (focus) { @@ -3182,6 +3220,102 @@ export class Editor extends EventEmitter { } } + #emitContentControlEvents({ + transaction, + nextState, + selectionHasChanged, + }: { + transaction: Transaction; + nextState: EditorState; + selectionHasChanged: boolean; + }): void { + const uiEvent = transaction.getMeta('uiEvent'); + const recentPointerDown = Date.now() - this.#lastPointerDownAt <= CONTENT_CONTROL_POINTER_WINDOW_MS; + const source: 'keyboard' | 'pointer' = uiEvent === 'click' || recentPointerDown ? 'pointer' : 'keyboard'; + // Full active stack (innermost first), matching ui.contentControls + // activeIds. `active` is the deepest control; `activePath` lets nested-aware + // custom UI read the surrounding controls without combining with observe(). + const activePath = nextState.selection ? this.#collectActiveSdtRefs(nextState.selection) : []; + const activeContentControl = activePath[0] ?? null; + + if (selectionHasChanged) { + const previous = this.#lastActiveContentControlRef; + const activeChanged = previous?.id !== activeContentControl?.id; + if (activeChanged) { + if (activeContentControl) { + this.emit('contentControlFocus', { + active: activeContentControl, + previous, + activePath, + source, + }); + } else if (previous) { + this.emit('contentControlBlur', { + active: null, + previous, + activePath: [], + source, + }); + } + this.#lastActiveContentControlRef = activeContentControl; + } + } + + if (uiEvent === 'click' && activeContentControl) { + this.emit('contentControlClick', { + target: activeContentControl, + source: 'pointer', + }); + } + } + + + #collectActiveSdtRefs(selection: EditorState['selection']): SdtRef[] { + const refs: SdtRef[] = []; + const seenIds = new Set(); + const selectedNode = selection instanceof PmNodeSelection ? selection.node : null; + if ( + selectedNode && + (selectedNode.type?.name === 'structuredContent' || selectedNode.type?.name === 'structuredContentBlock') + ) { + const ref = this.#toSdtRef(selectedNode); + if (ref) { + refs.push(ref); + seenIds.add(ref.id); + } + } + + const anchor = selection.$anchor; + if (anchor && typeof anchor.depth === 'number' && typeof anchor.node === 'function') { + for (let depth = anchor.depth; depth >= 0; depth -= 1) { + const node = anchor.node(depth) as PmNode | null | undefined; + if (!node) continue; + const typeName = node?.type?.name; + if (typeName !== 'structuredContent' && typeName !== 'structuredContentBlock') continue; + const ref = this.#toSdtRef(node); + if (!ref) continue; + if (seenIds.has(ref.id)) continue; + refs.push(ref); + seenIds.add(ref.id); + } + } + return refs; + } + + #toSdtRef(node: PmNode): SdtRef | null { + const attrs = node.attrs as { id?: unknown; tag?: unknown; alias?: unknown; controlType?: unknown }; + const id = attrs?.id; + if (typeof id !== 'string' || id.length === 0) return null; + const scope = node.type.name === 'structuredContent' ? 'inline' : 'block'; + return { + id, + tag: typeof attrs.tag === 'string' ? attrs.tag : undefined, + alias: typeof attrs.alias === 'string' ? attrs.alias : undefined, + controlType: typeof attrs.controlType === 'string' ? attrs.controlType : 'unknown', + scope, + }; + } + /** * Public dispatch method for transaction dispatching. * diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts index 3f928bc84a..b9f15a31f0 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -1699,7 +1699,7 @@ export class EditorInputManager { if (!nextSelection.$from.parent.inlineContent) { nextSelection = Selection.near(doc.resolve(hit.pos), 1); } - let tr = editor.state.tr.setSelection(nextSelection); + let tr = editor.state.tr.setSelection(nextSelection).setMeta('uiEvent', 'click'); if (inlineSdtBoundaryPos != null && inlineSdtBoundaryDirection) { tr = applyEditableSlotAtInlineBoundary(tr, inlineSdtBoundaryPos, inlineSdtBoundaryDirection); nextSelection = tr.selection; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.headerFooterBodyClick.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.headerFooterBodyClick.test.ts index d7f25fadcc..d1220d18d2 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.headerFooterBodyClick.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.headerFooterBodyClick.test.ts @@ -65,6 +65,7 @@ describe('EditorInputManager - body click during header/footer session', () => { }, tr: { setSelection: vi.fn().mockReturnThis(), + setMeta: vi.fn().mockReturnThis(), setStoredMarks: vi.fn().mockReturnThis(), }, selection: { $anchor: null }, diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.structuredContent.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.structuredContent.test.ts index f7363addee..804757a5be 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.structuredContent.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.structuredContent.test.ts @@ -172,7 +172,7 @@ describe('EditorInputManager structured content clicks', () => { isEditable: boolean; state: { doc: ReturnType; - tr: { setSelection: Mock; setStoredMarks: Mock }; + tr: { setSelection: Mock; setStoredMarks: Mock; setMeta: Mock }; selection: { $anchor: null }; storedMarks: null; }; @@ -229,6 +229,7 @@ describe('EditorInputManager structured content clicks', () => { doc: createMockDoc('plainSdt'), tr: { setSelection: vi.fn().mockReturnThis(), + setMeta: vi.fn().mockReturnThis(), setStoredMarks: vi.fn().mockReturnThis(), }, selection: { $anchor: null }, diff --git a/packages/super-editor/src/editors/v1/core/types/EditorEvents.ts b/packages/super-editor/src/editors/v1/core/types/EditorEvents.ts index 41161bf6ec..034b42607b 100644 --- a/packages/super-editor/src/editors/v1/core/types/EditorEvents.ts +++ b/packages/super-editor/src/editors/v1/core/types/EditorEvents.ts @@ -134,6 +134,35 @@ export interface TrackedChangesChangedPayload { source?: string; } +export interface SdtRef { + id: string; + tag?: string; + alias?: string; + controlType: string; + scope: 'inline' | 'block'; +} + +export interface ContentControlFocusPayload { + active: SdtRef; + previous: SdtRef | null; + /** Active control stack, innermost first (matches ui.contentControls activeIds). */ + activePath: SdtRef[]; + source: 'keyboard' | 'pointer'; +} + +export interface ContentControlBlurPayload { + active: null; + previous: SdtRef; + /** Empty on blur: selection left all controls. */ + activePath: SdtRef[]; + source: 'keyboard' | 'pointer'; +} + +export interface ContentControlClickPayload { + target: SdtRef; + source: 'pointer'; +} + /** * Event map for the Editor class */ @@ -206,6 +235,15 @@ export interface EditorEventMap extends DefaultEventMap { /** Called when all fonts used in the document are determined */ 'fonts-resolved': [FontsResolvedPayload]; + /** Called when active content control changes to a new control (or A -> B). */ + contentControlFocus: [ContentControlFocusPayload]; + + /** Called when selection leaves content controls (A -> null). */ + contentControlBlur: [ContentControlBlurPayload]; + + /** Called on pointer click inside an active content control. */ + contentControlClick: [ContentControlClickPayload]; + // Document Lifecycle Events /** Called when a document is opened via editor.open() */ diff --git a/packages/superdoc/src/SuperDoc.test.js b/packages/superdoc/src/SuperDoc.test.js index 89c551408d..1b855715b8 100644 --- a/packages/superdoc/src/SuperDoc.test.js +++ b/packages/superdoc/src/SuperDoc.test.js @@ -581,6 +581,72 @@ describe('SuperDoc.vue', () => { }); }); + it('bridges content-control editor events to superdoc public events', async () => { + const superdocStub = createSuperdocStub(); + const wrapper = await mountComponent(superdocStub); + await nextTick(); + + const options = wrapper.findComponent(SuperEditorStub).props('options'); + const listeners = {}; + const editorMock = { + options: { documentId: 'doc-1' }, + on: vi.fn((event, handler) => { + listeners[event] = handler; + }), + }; + + options.onCreate({ editor: editorMock }); + + const activePayload = { + active: { id: 'cc-2', controlType: 'text', scope: 'inline', tag: 'tag-2', alias: 'Alias 2' }, + previous: { id: 'cc-1', controlType: 'text', scope: 'inline', tag: 'tag-1', alias: 'Alias 1' }, + source: 'pointer', + }; + const blurPayload = { + active: null, + previous: { id: 'cc-2', controlType: 'text', scope: 'inline', tag: 'tag-2', alias: 'Alias 2' }, + source: 'keyboard', + }; + const clickPayload = { + target: { id: 'cc-2', controlType: 'text', scope: 'inline', tag: 'tag-2', alias: 'Alias 2' }, + source: 'pointer', + }; + + listeners.contentControlFocus?.(activePayload); + listeners.contentControlBlur?.(blurPayload); + listeners.contentControlClick?.(clickPayload); + + expect(superdocStub.emit).toHaveBeenCalledWith('content-control:active-change', activePayload); + expect(superdocStub.emit).toHaveBeenCalledWith('content-control:active-change', blurPayload); + expect(superdocStub.emit).toHaveBeenCalledWith('content-control:click', clickPayload); + }); + + it('bridges content-control blur payload (active=null) as active-change event', async () => { + const superdocStub = createSuperdocStub(); + const wrapper = await mountComponent(superdocStub); + await nextTick(); + + const options = wrapper.findComponent(SuperEditorStub).props('options'); + const listeners = {}; + const editorMock = { + options: { documentId: 'doc-1' }, + on: vi.fn((event, handler) => { + listeners[event] = handler; + }), + }; + + options.onCreate({ editor: editorMock }); + + const blurPayload = { + active: null, + previous: { id: 'cc-prev', controlType: 'text', scope: 'inline', tag: 'tag-prev', alias: 'Prev' }, + source: 'keyboard', + }; + listeners.contentControlBlur?.(blurPayload); + + expect(superdocStub.emit).toHaveBeenCalledWith('content-control:active-change', blurPayload); + }); + it('does not emit public exception events for recoverable password prompt errors by default', async () => { const superdocStub = createSuperdocStub(); const surfaceManager = { diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index 3a1460cd4a..c881a12cd9 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -400,11 +400,26 @@ const onEditorBeforeCreate = ({ editor }) => { proxy.$superdoc?.broadcastEditorBeforeCreate(editor); }; +const onEditorContentControlFocus = (payload) => { + proxy.$superdoc.emit('content-control:active-change', payload); +}; + +const onEditorContentControlBlur = (payload) => { + proxy.$superdoc.emit('content-control:active-change', payload); +}; + +const onEditorContentControlClick = (payload) => { + proxy.$superdoc.emit('content-control:click', payload); +}; + const onEditorCreate = ({ editor }) => { const { documentId } = editor.options; const doc = getDocument(documentId); doc.setEditor(editor); proxy.$superdoc.setActiveEditor(editor); + editor.on?.('contentControlFocus', onEditorContentControlFocus); + editor.on?.('contentControlBlur', onEditorContentControlBlur); + editor.on?.('contentControlClick', onEditorContentControlClick); proxy.$superdoc.broadcastEditorCreate(editor); // Initialize the ai layer initAiLayer(true); diff --git a/packages/superdoc/src/core/SuperDoc.ts b/packages/superdoc/src/core/SuperDoc.ts index c3e3d4c349..680cb0f3dc 100644 --- a/packages/superdoc/src/core/SuperDoc.ts +++ b/packages/superdoc/src/core/SuperDoc.ts @@ -68,6 +68,8 @@ import type { CanPerformPermissionParams, CollaborationProvider, Config, + ContentControlActiveChangePayload, + ContentControlClickPayload, DocumentMode, Editor, EditorUpdateEvent, @@ -142,6 +144,8 @@ interface SuperDocEventMap { 'pagination-update': [SuperDocPaginationPayload]; 'list-definitions-change': [ListDefinitionsPayload]; 'comments-update': [SuperDocCommentsUpdatePayload]; + 'content-control:active-change': [ContentControlActiveChangePayload]; + 'content-control:click': [ContentControlClickPayload]; 'collaboration-ready': [SuperDocEditorPayload]; 'awareness-update': [SuperDocAwarenessUpdatePayload]; locked: [SuperDocLockedPayload]; @@ -412,6 +416,8 @@ export class SuperDoc extends EventEmitter { onContentError: () => null, onReady: () => null, onCommentsUpdate: () => null, + onContentControlActiveChange: () => null, + onContentControlClick: () => null, onAwarenessUpdate: () => null, onLocked: () => null, onPdfDocumentReady: () => null, @@ -841,6 +847,8 @@ export class SuperDoc extends EventEmitter { this.#onConfig('editorDestroy', this.config.onEditorDestroy); this.#onConfig('ready', this.config.onReady); this.#onConfig('comments-update', this.config.onCommentsUpdate); + this.#onConfig('content-control:active-change', this.config.onContentControlActiveChange); + this.#onConfig('content-control:click', this.config.onContentControlClick); this.#onConfig('awareness-update', this.config.onAwarenessUpdate); this.#onConfig('locked', this.config.onLocked); this.#onConfig('pdf:document-ready', this.config.onPdfDocumentReady); diff --git a/packages/superdoc/src/core/types/index.ts b/packages/superdoc/src/core/types/index.ts index bb5a2d7234..1d085c6ff1 100644 --- a/packages/superdoc/src/core/types/index.ts +++ b/packages/superdoc/src/core/types/index.ts @@ -1435,6 +1435,32 @@ export interface EditorTransactionEvent { sectionType?: string | null; } +export interface SdtRef { + id: string; + tag?: string; + alias?: string; + controlType: string; + scope: 'inline' | 'block'; +} + +export interface ContentControlActiveChangePayload { + active: SdtRef | null; + previous: SdtRef | null; + /** + * Active content-control stack for the new selection, innermost first + * (matches `ui.contentControls` activeIds). `active` is `activePath[0]`. + * Empty when the selection is not inside any control. Lets nested-aware + * custom UI read the surrounding controls without combining with observe(). + */ + activePath: SdtRef[]; + source: 'keyboard' | 'pointer'; +} + +export interface ContentControlClickPayload { + target: SdtRef; + source: 'pointer'; +} + export interface SuperDocLayoutEngineOptions { /** * Layout engine flow mode. @@ -1649,6 +1675,10 @@ export interface Config { onReady?: (params: SuperDocReadyPayload) => void; /** Callback when comments are updated. */ onCommentsUpdate?: (params: SuperDocCommentsUpdatePayload) => void; + /** Callback when active content control changes. */ + onContentControlActiveChange?: (params: ContentControlActiveChangePayload) => void; + /** Callback when user clicks inside a content control. */ + onContentControlClick?: (params: ContentControlClickPayload) => void; /** Callback when awareness is updated. */ onAwarenessUpdate?: (params: SuperDocAwarenessUpdatePayload) => void; /** Callback when the SuperDoc is locked or unlocked. */ diff --git a/packages/superdoc/src/public/index.ts b/packages/superdoc/src/public/index.ts index 1c8699774c..7d708db728 100644 --- a/packages/superdoc/src/public/index.ts +++ b/packages/superdoc/src/public/index.ts @@ -93,6 +93,9 @@ export type { PasswordPromptResolution } from '../core/types/index.js'; export type { PermissionResolverParams } from '../core/types/index.js'; export type { ResolvedFindReplaceTexts } from '../core/types/index.js'; export type { ResolvedPasswordPromptTexts } from '../core/types/index.js'; +export type { ContentControlActiveChangePayload } from '../core/types/index.js'; +export type { ContentControlClickPayload } from '../core/types/index.js'; +export type { SdtRef } from '../core/types/index.js'; export type { SearchMatch } from '../core/types/index.js'; export type { StoryLocator } from '../core/types/index.js'; export type { SuperDocAwarenessUpdatePayload } from '../core/types/index.js'; diff --git a/tests/consumer-typecheck/snapshots/superdoc-root-exports.json b/tests/consumer-typecheck/snapshots/superdoc-root-exports.json index f900446ee9..74b64e4557 100644 --- a/tests/consumer-typecheck/snapshots/superdoc-root-exports.json +++ b/tests/consumer-typecheck/snapshots/superdoc-root-exports.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-05-26T11:53:56.060Z", + "generatedAt": "2026-05-29T09:44:23.077Z", "ticket": "SD-3212 PR A0", "package": "superdoc", "rootExport": { @@ -42,6 +42,8 @@ "CommentsPluginKey", "CommentsType", "Config", + "ContentControlActiveChangePayload", + "ContentControlClickPayload", "ContextMenu", "ContextMenuConfig", "ContextMenuContext", @@ -154,6 +156,7 @@ "Schema", "ScrollIntoViewInput", "ScrollIntoViewOutput", + "SdtRef", "SearchMatch", "SectionHelpers", "SectionMetadata", @@ -262,6 +265,8 @@ "CommentsPluginKey", "CommentsType", "Config", + "ContentControlActiveChangePayload", + "ContentControlClickPayload", "ContextMenu", "ContextMenuConfig", "ContextMenuContext", @@ -374,6 +379,7 @@ "Schema", "ScrollIntoViewInput", "ScrollIntoViewOutput", + "SdtRef", "SearchMatch", "SectionHelpers", "SectionMetadata", @@ -547,11 +553,11 @@ } }, "counts": { - "types.import": 214, - "types.require": 214, + "types.import": 217, + "types.require": 217, "import": 41, "require": 41, - "union": 214 + "union": 217 }, "divergences": { "typesImportVsRequire": { @@ -588,6 +594,8 @@ "CommentsPayload", "CommentsType", "Config", + "ContentControlActiveChangePayload", + "ContentControlClickPayload", "ContextMenuConfig", "ContextMenuContext", "ContextMenuItem", @@ -692,6 +700,7 @@ "Schema", "ScrollIntoViewInput", "ScrollIntoViewOutput", + "SdtRef", "SearchMatch", "SectionMetadata", "SelectionApi", diff --git a/tests/consumer-typecheck/snapshots/superdoc-root-exports.md b/tests/consumer-typecheck/snapshots/superdoc-root-exports.md index 49225b0f75..a8bbdc3491 100644 --- a/tests/consumer-typecheck/snapshots/superdoc-root-exports.md +++ b/tests/consumer-typecheck/snapshots/superdoc-root-exports.md @@ -1,17 +1,17 @@ # superdoc root export inventory (SD-3212 PR A0) -Generated: 2026-05-26T11:53:56.060Z +Generated: 2026-05-29T09:44:23.077Z Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` ## Counts | Source | Path | Count | |---|---|---| -| types.import | `./dist/superdoc/src/public/index.d.ts` | 214 | -| types.require | `./dist/superdoc/src/public/index.d.cts` | 214 | +| types.import | `./dist/superdoc/src/public/index.d.ts` | 217 | +| types.require | `./dist/superdoc/src/public/index.d.cts` | 217 | | import | `./dist/superdoc.es.js` | 41 | | require | `./dist/superdoc.cjs` | 41 | -| **union** | | **214** | +| **union** | | **217** | ## Divergences @@ -19,7 +19,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` - types.require only (not in types.import): 0 - ESM only (not in CJS): 0 - CJS only (not in ESM): 0 -- typed but no runtime export (phantom risk): 173 +- typed but no runtime export (phantom risk): 176 - runtime export but not typed (silent shadow on root): 0 ### Type-only names (no runtime) @@ -48,6 +48,8 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` - `CommentsPayload` - `CommentsType` - `Config` +- `ContentControlActiveChangePayload` +- `ContentControlClickPayload` - `ContextMenuConfig` - `ContextMenuContext` - `ContextMenuItem` @@ -152,6 +154,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` - `Schema` - `ScrollIntoViewInput` - `ScrollIntoViewOutput` +- `SdtRef` - `SearchMatch` - `SectionMetadata` - `SelectionApi` @@ -221,7 +224,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `CollaborationProvider` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `Command` | ✓ | ✓ | | | 3 | ✓ | 78 | 0 | 8 | ✓ | | `CommandProps` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | ✓ | -| `Comment` | ✓ | ✓ | | | 5 | ✓ | 28 | 3 | 45 | | +| `Comment` | ✓ | ✓ | | | 5 | ✓ | 29 | 3 | 45 | | | `CommentAddress` | ✓ | ✓ | | | 1 | ✓ | 4 | 0 | 3 | | | `CommentConfig` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `CommentElement` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | @@ -230,6 +233,8 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `CommentsPluginKey` | ✓ | ✓ | ✓ | ✓ | 2 | | 0 | 0 | 1 | ✓ | | `CommentsType` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `Config` | ✓ | ✓ | | | 8 | ✓ | 2 | 1 | 2 | ✓ | +| `ContentControlActiveChangePayload` | ✓ | ✓ | | | 1 | | 0 | 0 | 0 | | +| `ContentControlClickPayload` | ✓ | ✓ | | | 1 | | 0 | 0 | 0 | | | `ContextMenu` | ✓ | ✓ | ✓ | ✓ | 1 | | 7 | 0 | 31 | | | `ContextMenuConfig` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `ContextMenuContext` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | @@ -239,13 +244,13 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `DOCX` | ✓ | ✓ | ✓ | ✓ | 2 | | 149 | 24 | 59 | ✓ | | `DirectSurfaceRequest` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `DocRange` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | -| `Document` | ✓ | ✓ | | | 2 | | 287 | 52 | 111 | ✓ | -| `DocumentApi` | ✓ | ✓ | | | 3 | ✓ | 0 | 8 | 4 | ✓ | +| `Document` | ✓ | ✓ | | | 2 | | 288 | 56 | 111 | ✓ | +| `DocumentApi` | ✓ | ✓ | | | 3 | ✓ | 0 | 11 | 4 | ✓ | | `DocumentMode` | ✓ | ✓ | | | 3 | ✓ | 2 | 16 | 3 | | | `DocumentProtectionState` | ✓ | ✓ | | | 1 | ✓ | 1 | 0 | 1 | | | `DocxFileEntry` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `DocxZipper` | ✓ | ✓ | ✓ | ✓ | 2 | | 0 | 0 | 1 | ✓ | -| `Editor` | ✓ | ✓ | ✓ | ✓ | 8 | | 194 | 19 | 69 | ✓ | +| `Editor` | ✓ | ✓ | ✓ | ✓ | 8 | | 195 | 20 | 69 | ✓ | | `EditorCommands` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | ✓ | | `EditorEventMap` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `EditorExtension` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | @@ -342,6 +347,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `Schema` | ✓ | ✓ | | | 4 | ✓ | 5 | 0 | 4 | ✓ | | `ScrollIntoViewInput` | ✓ | ✓ | | | 2 | ✓ | 1 | 0 | 0 | | | `ScrollIntoViewOutput` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | +| `SdtRef` | ✓ | ✓ | | | 0 | | 4 | 0 | 0 | | | `SearchMatch` | ✓ | ✓ | | | 2 | ✓ | 3 | 0 | 0 | | | `SectionHelpers` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 0 | 1 | | | `SectionMetadata` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | @@ -351,9 +357,9 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `SelectionHandle` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `SelectionInfo` | ✓ | ✓ | | | 2 | ✓ | 6 | 0 | 1 | | | `SlashMenu` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 0 | 1 | | -| `StoryLocator` | ✓ | ✓ | | | 1 | ✓ | 116 | 0 | 3 | | +| `StoryLocator` | ✓ | ✓ | | | 1 | ✓ | 123 | 0 | 3 | | | `SuperConverter` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 0 | 3 | ✓ | -| `SuperDoc` | ✓ | ✓ | ✓ | ✓ | 21 | | 1014 | 180 | 244 | ✓ | +| `SuperDoc` | ✓ | ✓ | ✓ | ✓ | 21 | | 1021 | 187 | 245 | ✓ | | `SuperDocAwarenessUpdatePayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `SuperDocCommentsUpdatePayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `SuperDocEditorPayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | @@ -363,7 +369,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `SuperDocExceptionStorePayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `SuperDocLayoutEngineOptions` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `SuperDocLockedPayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | -| `SuperDocReadyPayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | +| `SuperDocReadyPayload` | ✓ | ✓ | | | 2 | | 2 | 0 | 0 | | | `SuperDocState` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `SuperDocTelemetryConfig` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `SuperEditor` | ✓ | ✓ | ✓ | ✓ | 1 | | 16 | 0 | 5 | | @@ -381,7 +387,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `TelemetryEvent` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `TextAddress` | ✓ | ✓ | | | 3 | ✓ | 404 | 0 | 7 | | | `TextSegment` | ✓ | ✓ | | | 3 | ✓ | 8 | 0 | 4 | | -| `TextTarget` | ✓ | ✓ | | | 3 | ✓ | 41 | 0 | 9 | | +| `TextTarget` | ✓ | ✓ | | | 3 | ✓ | 45 | 0 | 9 | | | `Toolbar` | ✓ | ✓ | ✓ | ✓ | 1 | | 35 | 7 | 15 | | | `TrackChangesBasePluginKey` | ✓ | ✓ | ✓ | ✓ | 2 | | 0 | 0 | 1 | ✓ | | `TrackChangesModuleConfig` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | @@ -391,7 +397,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `Transaction` | ✓ | ✓ | | | 3 | ✓ | 5 | 0 | 0 | ✓ | | `UnsupportedContentItem` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `UpgradeToCollaborationOptions` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | -| `User` | ✓ | ✓ | | | 7 | ✓ | 51 | 8 | 30 | | +| `User` | ✓ | ✓ | | | 7 | ✓ | 52 | 8 | 30 | | | `ViewLayout` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `ViewOptions` | ✓ | ✓ | | | 1 | ✓ | 2 | 0 | 0 | | | `ViewingVisibilityConfig` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | diff --git a/tests/consumer-typecheck/src/config-callback-payloads.ts b/tests/consumer-typecheck/src/config-callback-payloads.ts index 9f681c770c..fc0b555498 100644 --- a/tests/consumer-typecheck/src/config-callback-payloads.ts +++ b/tests/consumer-typecheck/src/config-callback-payloads.ts @@ -37,6 +37,8 @@ */ import type { Config, + ContentControlActiveChangePayload, + ContentControlClickPayload, EditorUpdateEvent, ListDefinitionsPayload, SuperDocAwarenessUpdatePayload, @@ -72,6 +74,15 @@ const _onLockedOk: AssertEqual, SuperDocLockedPayloa // ─── onCommentsUpdate ─────────────────────────────────────────────── const _onCommentsUpdateOk: AssertEqual, SuperDocCommentsUpdatePayload> = true; +// ─── onContentControlActiveChange / onContentControlClick ─────────── +// The public payload types must match what the Config callbacks receive +// (and be importable by name from 'superdoc'). +const _onContentControlActiveChangeOk: AssertEqual< + ParamOf, + ContentControlActiveChangePayload +> = true; +const _onContentControlClickOk: AssertEqual, ContentControlClickPayload> = true; + // ─── onAwarenessUpdate ────────────────────────────────────────────── // Field set is `{ states, added, removed, superdoc }` - NOT `context`. const _onAwarenessUpdateOk: AssertEqual, SuperDocAwarenessUpdatePayload> = true;