From 4b1ffb146aa1e4e56771d194c279919bb71d22d1 Mon Sep 17 00:00:00 2001 From: Artem Nistuley Date: Fri, 22 May 2026 15:46:49 +0300 Subject: [PATCH 1/7] feat: expose public sdt events --- apps/docs/editor/superdoc/events.mdx | 42 +++++++ .../src/editors/v1/core/Editor.ts | 112 +++++++++++++++++- .../pointer-events/EditorInputManager.ts | 2 +- ...InputManager.headerFooterBodyClick.test.ts | 1 + ...itorInputManager.structuredContent.test.ts | 3 +- .../src/editors/v1/core/types/EditorEvents.ts | 34 ++++++ packages/superdoc/src/SuperDoc.test.js | 66 +++++++++++ packages/superdoc/src/SuperDoc.vue | 15 +++ packages/superdoc/src/core/SuperDoc.ts | 29 +++++ packages/superdoc/src/core/types/index.ts | 23 ++++ 10 files changed, 323 insertions(+), 4 deletions(-) diff --git a/apps/docs/editor/superdoc/events.mdx b/apps/docs/editor/superdoc/events.mdx index f4038e0cd7..86bb91c12d 100644 --- a/apps/docs/editor/superdoc/events.mdx +++ b/apps/docs/editor/superdoc/events.mdx @@ -243,6 +243,48 @@ 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, source }) => { + // source: 'keyboard' | 'pointer' + // active / previous: SdtRef | null +}); +``` + +`SdtRef` shape: + +```ts +type SdtRef = { + id: string; + tag?: string; + alias?: string; + controlType: string; + scope: 'inline' | 'block'; +}; +``` + +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 +}); +``` + ## Comments events ### `comments-update` diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index 3a70991350..160f922bd4 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,7 @@ 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)); }; +const CONTENT_CONTROL_POINTER_WINDOW_MS = 800; type ExtensionInstanceLike = { type?: string; @@ -679,6 +680,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,6 +1090,9 @@ 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.on('pointerDown', () => { + this.#lastPointerDownAt = Date.now(); + }); this.on('pointerUp', this.options.onPointerUp!); this.on('rightClick', this.options.onRightClick!); } @@ -3145,6 +3151,11 @@ export class Editor extends EventEmitter { transaction: transactionToApply, }); } + this.#emitContentControlEvents({ + transaction: transactionToApply, + nextState, + selectionHasChanged, + }); const focus = transactionToApply.getMeta('focus'); if (focus) { @@ -3182,6 +3193,103 @@ 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'; + const activeContentControl = this.#resolveActiveContentControlRef(nextState); + + if (selectionHasChanged) { + const previous = this.#lastActiveContentControlRef; + const activeChanged = previous?.id !== activeContentControl?.id; + if (activeChanged) { + if (activeContentControl) { + this.emit('contentControlFocus', { + active: activeContentControl, + previous, + source, + }); + } else if (previous) { + this.emit('contentControlBlur', { + active: null, + previous, + source, + }); + } + this.#lastActiveContentControlRef = activeContentControl; + } + } + + if (uiEvent === 'click' && activeContentControl) { + this.emit('contentControlClick', { + target: activeContentControl, + source: 'pointer', + }); + } + } + + #resolveActiveContentControlRef(state: EditorState): SdtRef | null { + const selection = state.selection; + if (!selection) return null; + + const refs = this.#collectActiveSdtRefs(selection); + return refs[0] ?? null; + } + + #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..01e40f2035 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,31 @@ 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; + source: 'keyboard' | 'pointer'; +} + +export interface ContentControlBlurPayload { + active: null; + previous: SdtRef; + source: 'keyboard' | 'pointer'; +} + +export interface ContentControlClickPayload { + target: SdtRef; + source: 'pointer'; +} + /** * Event map for the Editor class */ @@ -206,6 +231,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..f726913738 100644 --- a/packages/superdoc/src/core/SuperDoc.ts +++ b/packages/superdoc/src/core/SuperDoc.ts @@ -119,6 +119,29 @@ interface SuperDocContentErrorPayload { error: unknown; editor: Editor; } +interface SuperDocEditorUpdatePayload { + editor?: Editor; + sourceEditor?: Editor; + surface: string; + headerId: string | null; + sectionType: string | null; +} +interface SuperDocSdtRef { + id: string; + tag?: string; + alias?: string; + controlType: string; + scope: 'inline' | 'block'; +} +interface SuperDocContentControlActiveChangePayload { + active: SuperDocSdtRef | null; + previous: SuperDocSdtRef | null; + source: 'keyboard' | 'pointer'; +} +interface SuperDocContentControlClickPayload { + target: SuperDocSdtRef; + source: 'pointer'; +} /** * SuperDoc lifecycle event registry. Keys are event names emitted via @@ -142,6 +165,8 @@ interface SuperDocEventMap { 'pagination-update': [SuperDocPaginationPayload]; 'list-definitions-change': [ListDefinitionsPayload]; 'comments-update': [SuperDocCommentsUpdatePayload]; + 'content-control:active-change': [SuperDocContentControlActiveChangePayload]; + 'content-control:click': [SuperDocContentControlClickPayload]; 'collaboration-ready': [SuperDocEditorPayload]; 'awareness-update': [SuperDocAwarenessUpdatePayload]; locked: [SuperDocLockedPayload]; @@ -412,6 +437,8 @@ export class SuperDoc extends EventEmitter { onContentError: () => null, onReady: () => null, onCommentsUpdate: () => null, + onContentControlActiveChange: () => null, + onContentControlClick: () => null, onAwarenessUpdate: () => null, onLocked: () => null, onPdfDocumentReady: () => null, @@ -841,6 +868,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..9ef950bc9e 100644 --- a/packages/superdoc/src/core/types/index.ts +++ b/packages/superdoc/src/core/types/index.ts @@ -1435,6 +1435,25 @@ 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; + source: 'keyboard' | 'pointer'; +} + +export interface ContentControlClickPayload { + target: SdtRef; + source: 'pointer'; +} + export interface SuperDocLayoutEngineOptions { /** * Layout engine flow mode. @@ -1649,6 +1668,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. */ From d7ee037845c9d2dafad1e4c85bf640c472489d79 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 28 May 2026 20:57:32 -0300 Subject: [PATCH 2/7] fix(sdt-events): reset state on unload, dedupe + export payload types, add core tests Address review feedback on the public content-control events: - Editor: clear #lastActiveContentControlRef / #lastPointerDownAt in #unloadDocument so a close/reopen on the same instance does not leak a stale `previous` into the next document's first event (or skip the first focus when ids collide). Document the 800ms pointer-source window as advisory. - types: drop the duplicate SdtRef / payload interfaces in core/SuperDoc (the event map now uses the public ContentControl*Payload from core/types), remove the dead SuperDocEditorUpdatePayload, and export SdtRef + ContentControlActiveChangePayload + ContentControlClickPayload from the public root so consumers can import them by name. - consumer-typecheck: assert the onContentControl* callback params equal the exported payload types. - tests: add real-editor behavior coverage (focus, switch, blur, click, source detection, and a negative programmatic-mutation case) over actual structuredContent nodes, not mocked payloads. - docs: explain events vs ui.contentControls.observe() / getRect(), the config-callback form, and the limitations (events require an SDT id; `active` is the deepest control - use observe() for the full stack). --- apps/docs/editor/superdoc/events.mdx | 24 +++ .../core/Editor.contentControlEvents.test.ts | 168 ++++++++++++++++++ .../src/editors/v1/core/Editor.ts | 14 ++ packages/superdoc/src/core/SuperDoc.ts | 29 +-- packages/superdoc/src/public/index.ts | 3 + .../src/config-callback-payloads.ts | 11 ++ 6 files changed, 224 insertions(+), 25 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/Editor.contentControlEvents.test.ts diff --git a/apps/docs/editor/superdoc/events.mdx b/apps/docs/editor/superdoc/events.mdx index 86bb91c12d..b67f204874 100644 --- a/apps/docs/editor/superdoc/events.mdx +++ b/apps/docs/editor/superdoc/events.mdx @@ -285,6 +285,30 @@ superdoc.on('content-control:click', ({ target, source }) => { }); ``` +Both are also available as config callbacks: `onContentControlActiveChange` +and `onContentControlClick`. + +### Building custom content-control UI + +Use these events (or `ui.contentControls.observe()`) to know **what** is +active, and `ui.contentControls.getRect({ id })` to know **where** to draw your +own UI. A typical custom field affordance subscribes to +`content-control:active-change` for the active control, then calls `getRect` +to anchor an element over it. Disable the built-in chrome with +`modules.contentControls.chrome: 'none'` when you want your UI to fully replace +it. See the `contract-templates` demo for a working example. + +### Requirements and limitations + +- **An `id` is required.** Payloads key off the control's `id`. Controls + imported from a `.docx` that has no `w:id` resolve to a null id and do **not** + emit these events. Controls created through SuperDoc (commands / document API) + get an id automatically, and Word-authored controls normally carry `w:id`. +- **`active` is the primary (deepest) control.** When controls are nested (for + example an inline field inside a block clause), `active` is the innermost + control. Use `ui.contentControls.observe()` (which exposes the full active + stack) when you need the surrounding controls as well. + ## Comments events ### `comments-update` 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..43cd4151d5 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/Editor.contentControlEvents.test.ts @@ -0,0 +1,168 @@ +/* @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[1][0]).toMatchObject({ active: { id: b.id }, previous: { id: a.id }, source: 'keyboard' }); + expect(blur).toHaveBeenCalledTimes(1); + expect(blur.mock.calls[0][0]).toMatchObject({ active: null, previous: { id: b.id }, source: 'keyboard' }); + + 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(); + }); + +}); diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index 160f922bd4..74648cc8fe 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -402,6 +402,13 @@ 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 = { @@ -1420,6 +1427,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; } /** diff --git a/packages/superdoc/src/core/SuperDoc.ts b/packages/superdoc/src/core/SuperDoc.ts index f726913738..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, @@ -119,29 +121,6 @@ interface SuperDocContentErrorPayload { error: unknown; editor: Editor; } -interface SuperDocEditorUpdatePayload { - editor?: Editor; - sourceEditor?: Editor; - surface: string; - headerId: string | null; - sectionType: string | null; -} -interface SuperDocSdtRef { - id: string; - tag?: string; - alias?: string; - controlType: string; - scope: 'inline' | 'block'; -} -interface SuperDocContentControlActiveChangePayload { - active: SuperDocSdtRef | null; - previous: SuperDocSdtRef | null; - source: 'keyboard' | 'pointer'; -} -interface SuperDocContentControlClickPayload { - target: SuperDocSdtRef; - source: 'pointer'; -} /** * SuperDoc lifecycle event registry. Keys are event names emitted via @@ -165,8 +144,8 @@ interface SuperDocEventMap { 'pagination-update': [SuperDocPaginationPayload]; 'list-definitions-change': [ListDefinitionsPayload]; 'comments-update': [SuperDocCommentsUpdatePayload]; - 'content-control:active-change': [SuperDocContentControlActiveChangePayload]; - 'content-control:click': [SuperDocContentControlClickPayload]; + 'content-control:active-change': [ContentControlActiveChangePayload]; + 'content-control:click': [ContentControlClickPayload]; 'collaboration-ready': [SuperDocEditorPayload]; 'awareness-update': [SuperDocAwarenessUpdatePayload]; locked: [SuperDocLockedPayload]; 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/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; From 5854a25a23832db673a338442742ffd46495a0ef Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 29 May 2026 06:38:13 -0300 Subject: [PATCH 3/7] fix(sdt-events): wire pointer-source tracking on all init paths; update export snapshot - Editor: the #lastPointerDownAt listener was only registered in #registerEventListeners() (the deferDocumentLoad path). The default PresentationEditor path uses #init / #initRichText, so pointer-driven selection changes without uiEvent:'click' were reported as source:'keyboard'. Extract a #trackContentControlPointer() helper and call it on all three init paths. - tests: add a regression test (on the default #init path) that a non-click selection after a pointerDown reports source:'pointer', and a block-scope (structuredContentBlock) focus case. - consumer-typecheck: regenerate the root-exports snapshot for the three newly exported payload types so check:public:superdoc passes. --- .../core/Editor.contentControlEvents.test.ts | 51 +++++++++++++++++++ .../src/editors/v1/core/Editor.ts | 17 ++++++- .../snapshots/superdoc-root-exports.json | 17 +++++-- .../snapshots/superdoc-root-exports.md | 34 ++++++++----- 4 files changed, 99 insertions(+), 20 deletions(-) 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 index 43cd4151d5..06ef6468b3 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.contentControlEvents.test.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.contentControlEvents.test.ts @@ -165,4 +165,55 @@ describe('Editor content-control events', () => { 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' }); + + 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 74648cc8fe..9ba954e266 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -1097,11 +1097,22 @@ 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(); }); - this.on('pointerUp', this.options.onPointerUp!); - this.on('rightClick', this.options.onRightClick!); } /** @@ -1518,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!); @@ -1597,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!); 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 | | From 13d8845a1604d223fdf03740ce161f1602fb8ff7 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 29 May 2026 06:59:01 -0300 Subject: [PATCH 4/7] demo(contract-templates): drive the field chip from content-control:active-change Switch the contextual smart-field chip from ui.contentControls.observe to the public content-control:active-change event (SD-3232) for "which control is active", keeping ui.contentControls.getRect for placement. attachFieldChip now takes the SuperDoc instance plus the UI controller. Built-in chrome stays off (chrome:'none'); the chip comments now describe the events + getRect pairing. --- demos/contract-templates/src/field-chip.ts | 72 ++++++++++++---------- demos/contract-templates/src/main.ts | 4 +- demos/contract-templates/src/style.css | 11 ++-- 3 files changed, 48 insertions(+), 39 deletions(-) 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; From 9e0aae18bbe3bdd6261952358827ee312dd3097e Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 29 May 2026 07:36:13 -0300 Subject: [PATCH 5/7] feat(sdt-events): add activePath (full active stack) to content-control:active-change The event exposed only the deepest active control, while ui.contentControls.observe has the full activeIds stack - so nested-aware custom UI had to combine the two APIs. Add activePath: SdtRef[] (innermost first; active === activePath[0]; empty on blur) to the focus/blur payloads and the public ContentControlActiveChangePayload. The stack was already computed in #collectActiveSdtRefs; drop the now-unused #resolveActiveContentControlRef. Tests cover single inline/block, switch, blur, and a nested inline-in-block case (activePath = [inner, outer]). --- apps/docs/editor/superdoc/events.mdx | 8 +++- .../core/Editor.contentControlEvents.test.ts | 44 +++++++++++++++++++ .../src/editors/v1/core/Editor.ts | 15 +++---- .../src/editors/v1/core/types/EditorEvents.ts | 4 ++ packages/superdoc/src/core/types/index.ts | 7 +++ 5 files changed, 69 insertions(+), 9 deletions(-) diff --git a/apps/docs/editor/superdoc/events.mdx b/apps/docs/editor/superdoc/events.mdx index b67f204874..0ca90a91b7 100644 --- a/apps/docs/editor/superdoc/events.mdx +++ b/apps/docs/editor/superdoc/events.mdx @@ -250,9 +250,10 @@ superdoc.on('fonts-resolved', ({ documentFonts, unsupportedFonts }) => { Fires when selection enters a content control, switches between controls, or leaves all controls. ```javascript -superdoc.on('content-control:active-change', ({ active, previous, source }) => { +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) }); ``` @@ -268,6 +269,11 @@ type SdtRef = { }; ``` +`active` is the deepest control (`activePath[0]`). For **nested** controls (an +inline field inside a block clause), `activePath` carries the whole stack +innermost-first, so you can update nested custom UI without also reading +`ui.contentControls.observe()`. `activePath` is empty on blur. + How to interpret: 1. Focus (`null -> A`): `previous === null && active !== null` 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 index 06ef6468b3..767b1e9e01 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.contentControlEvents.test.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.contentControlEvents.test.ts @@ -113,9 +113,12 @@ describe('Editor content-control events', () => { 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(); }); @@ -213,6 +216,47 @@ describe('Editor content-control events', () => { 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 9ba954e266..54f35848b8 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -3232,7 +3232,11 @@ export class Editor extends EventEmitter { 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'; - const activeContentControl = this.#resolveActiveContentControlRef(nextState); + // 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; @@ -3242,12 +3246,14 @@ export class Editor extends EventEmitter { this.emit('contentControlFocus', { active: activeContentControl, previous, + activePath, source, }); } else if (previous) { this.emit('contentControlBlur', { active: null, previous, + activePath: [], source, }); } @@ -3263,13 +3269,6 @@ export class Editor extends EventEmitter { } } - #resolveActiveContentControlRef(state: EditorState): SdtRef | null { - const selection = state.selection; - if (!selection) return null; - - const refs = this.#collectActiveSdtRefs(selection); - return refs[0] ?? null; - } #collectActiveSdtRefs(selection: EditorState['selection']): SdtRef[] { const refs: SdtRef[] = []; 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 01e40f2035..034b42607b 100644 --- a/packages/super-editor/src/editors/v1/core/types/EditorEvents.ts +++ b/packages/super-editor/src/editors/v1/core/types/EditorEvents.ts @@ -145,12 +145,16 @@ export interface SdtRef { 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'; } diff --git a/packages/superdoc/src/core/types/index.ts b/packages/superdoc/src/core/types/index.ts index 9ef950bc9e..1d085c6ff1 100644 --- a/packages/superdoc/src/core/types/index.ts +++ b/packages/superdoc/src/core/types/index.ts @@ -1446,6 +1446,13 @@ export interface SdtRef { 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'; } From afc36b0f7888ebf97bd36242679508c52a63d825 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 29 May 2026 07:56:57 -0300 Subject: [PATCH 6/7] docs(events): reconcile activePath vs observe guidance for nested controls The activePath paragraph said you can read the nested stack without ui.contentControls.observe(), but the limitations note still told readers to use observe() for the full stack. Reword the limitation: activePath carries the stack on the event; observe() is for ongoing list/value reactivity, not just the active nesting. --- apps/docs/editor/superdoc/events.mdx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/docs/editor/superdoc/events.mdx b/apps/docs/editor/superdoc/events.mdx index 0ca90a91b7..56824dcae5 100644 --- a/apps/docs/editor/superdoc/events.mdx +++ b/apps/docs/editor/superdoc/events.mdx @@ -312,8 +312,9 @@ it. See the `contract-templates` demo for a working example. get an id automatically, and Word-authored controls normally carry `w:id`. - **`active` is the primary (deepest) control.** When controls are nested (for example an inline field inside a block clause), `active` is the innermost - control. Use `ui.contentControls.observe()` (which exposes the full active - stack) when you need the surrounding controls as well. + control and `activePath` carries the full stack. Use + `ui.contentControls.observe()` when you need ongoing list/value reactivity, + not just the active nesting for a single event. ## Comments events From 5309deb828b243f813639e2e27171ff6385d0821 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 29 May 2026 08:13:02 -0300 Subject: [PATCH 7/7] docs(content-controls): move the custom-UI guide out of the event registry events.mdx should list events, not teach a workflow. Trim its content-control section to the active-change/click reference plus a link, and add editor/custom-ui/content-controls with the actual how-to: turn off built-in chrome, react to the active control (and activePath), position with getRect, hit-test via the viewport, and mutate through the document API. Notes current limits (no scroll-to-control, geometry subscription, or focus-by-id helper yet). Supersedes the in-place events.mdx reconcile. --- apps/docs/docs.json | 1 + .../editor/custom-ui/content-controls.mdx | 57 +++++++++++++++++++ apps/docs/editor/superdoc/events.mdx | 30 +--------- 3 files changed, 61 insertions(+), 27 deletions(-) create mode 100644 apps/docs/editor/custom-ui/content-controls.mdx 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 56824dcae5..3cffc4de66 100644 --- a/apps/docs/editor/superdoc/events.mdx +++ b/apps/docs/editor/superdoc/events.mdx @@ -269,10 +269,7 @@ type SdtRef = { }; ``` -`active` is the deepest control (`activePath[0]`). For **nested** controls (an -inline field inside a block clause), `activePath` carries the whole stack -innermost-first, so you can update nested custom UI without also reading -`ui.contentControls.observe()`. `activePath` is empty on blur. +`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: @@ -291,30 +288,9 @@ superdoc.on('content-control:click', ({ target, source }) => { }); ``` -Both are also available as config callbacks: `onContentControlActiveChange` -and `onContentControlClick`. +Both are also available as config callbacks: `onContentControlActiveChange` and `onContentControlClick`. -### Building custom content-control UI - -Use these events (or `ui.contentControls.observe()`) to know **what** is -active, and `ui.contentControls.getRect({ id })` to know **where** to draw your -own UI. A typical custom field affordance subscribes to -`content-control:active-change` for the active control, then calls `getRect` -to anchor an element over it. Disable the built-in chrome with -`modules.contentControls.chrome: 'none'` when you want your UI to fully replace -it. See the `contract-templates` demo for a working example. - -### Requirements and limitations - -- **An `id` is required.** Payloads key off the control's `id`. Controls - imported from a `.docx` that has no `w:id` resolve to a null id and do **not** - emit these events. Controls created through SuperDoc (commands / document API) - get an id automatically, and Word-authored controls normally carry `w:id`. -- **`active` is the primary (deepest) control.** When controls are nested (for - example an inline field inside a block clause), `active` is the innermost - control and `activePath` carries the full stack. Use - `ui.contentControls.observe()` when you need ongoing list/value reactivity, - not just the active nesting for a single event. +To build your own content-control UI on these events, see [Custom UI > Content controls](/editor/custom-ui/content-controls). ## Comments events