Skip to content

Commit 915dbbe

Browse files
authored
Merge pull request #3509 from superdoc-dev/artem/SD-3232
feat: expose public sdt events
2 parents 27869f8 + 5309deb commit 915dbbe

20 files changed

Lines changed: 762 additions & 61 deletions

File tree

apps/docs/docs.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@
104104
"editor/custom-ui/custom-commands",
105105
"editor/custom-ui/comments",
106106
"editor/custom-ui/track-changes",
107+
"editor/custom-ui/content-controls",
107108
"editor/custom-ui/context-menu",
108109
"editor/custom-ui/selection-and-viewport",
109110
"editor/custom-ui/document-control",
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
title: 'Content controls'
3+
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().'
4+
---
5+
6+
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.
7+
8+
## A minimal field chip
9+
10+
```ts
11+
import { SuperDoc } from 'superdoc';
12+
import { createSuperDocUI } from 'superdoc/ui';
13+
14+
new SuperDoc({
15+
selector: '#editor',
16+
document: '/contract.docx',
17+
// Turn off the built-in labels, borders, and hover/selection chrome.
18+
modules: { contentControls: { chrome: 'none' } },
19+
onReady: ({ superdoc }) => {
20+
const ui = createSuperDocUI({ superdoc });
21+
22+
superdoc.on('content-control:active-change', ({ active }) => {
23+
if (!active) return chip.hide(); // `chip` is your own element
24+
const rect = ui.contentControls.getRect({ id: active.id });
25+
if (rect.success) chip.showAt(rect.rect, active.alias ?? active.tag);
26+
});
27+
},
28+
});
29+
```
30+
31+
The event tells you *what* is active; `getRect` tells you *where* to draw. `active` is an `SdtRef` with `id`, `tag`, `alias`, `controlType`, and `scope`.
32+
33+
## Pick the right surface
34+
35+
| Goal | API |
36+
| --- | --- |
37+
| Active control (enter, switch, leave) | `superdoc.on('content-control:active-change')` |
38+
| Click inside a control | `superdoc.on('content-control:click')` |
39+
| Full live list and active stack | `ui.contentControls.observe()` / `getSnapshot()` |
40+
| Read one control | `ui.contentControls.get({ id })` |
41+
| Position your UI | `ui.contentControls.getRect({ id })` |
42+
| Hover and right-click hit-testing | `ui.viewport.entityAt()` / `contextAt()` |
43+
| Change content, tags, or locks | `editor.doc.contentControls.*` |
44+
45+
`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.
46+
47+
## Current limits
48+
49+
- No built-in scroll-to-control. Read the position with `getRect()` and scroll your container.
50+
- No geometry-change subscription. Re-read `getRect()` on scroll, resize, and the `pagination-update` / `zoomChange` events.
51+
- No focus-by-id helper. Clicking a control in the document still drives selection.
52+
53+
## See also
54+
55+
- [Contract templates demo](https://github.com/superdoc-dev/superdoc/tree/main/demos/contract-templates) - a working field chip built on these APIs.
56+
- [Configuration](/editor/superdoc/configuration) - the `modules.contentControls.chrome` option.
57+
- [Document API: content controls](/document-api/features/content-controls) - read and change controls.

apps/docs/editor/superdoc/events.mdx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,55 @@ superdoc.on('fonts-resolved', ({ documentFonts, unsupportedFonts }) => {
243243
```
244244
</CodeGroup>
245245

246+
## Content controls (SDT fields)
247+
248+
### `content-control:active-change`
249+
250+
Fires when selection enters a content control, switches between controls, or leaves all controls.
251+
252+
```javascript
253+
superdoc.on('content-control:active-change', ({ active, previous, activePath, source }) => {
254+
// source: 'keyboard' | 'pointer'
255+
// active / previous: SdtRef | null
256+
// activePath: SdtRef[] - full active stack, innermost first ([] when none)
257+
});
258+
```
259+
260+
`SdtRef` shape:
261+
262+
```ts
263+
type SdtRef = {
264+
id: string;
265+
tag?: string;
266+
alias?: string;
267+
controlType: string;
268+
scope: 'inline' | 'block';
269+
};
270+
```
271+
272+
`active` is the deepest control (`activePath[0]`); `activePath` holds the full stack, innermost first. Controls without an `id` do not emit these events.
273+
274+
How to interpret:
275+
276+
1. Focus (`null -> A`): `previous === null && active !== null`
277+
2. Switch (`A -> B`): `previous !== null && active !== null && previous.id !== active.id`
278+
3. Blur (`A -> null`): `previous !== null && active === null`
279+
280+
### `content-control:click`
281+
282+
Fires on pointer click inside a content control.
283+
284+
```javascript
285+
superdoc.on('content-control:click', ({ target, source }) => {
286+
// source is always 'pointer'
287+
// target: SdtRef
288+
});
289+
```
290+
291+
Both are also available as config callbacks: `onContentControlActiveChange` and `onContentControlClick`.
292+
293+
To build your own content-control UI on these events, see [Custom UI > Content controls](/editor/custom-ui/content-controls).
294+
246295
## Comments events
247296

248297
### `comments-update`

demos/contract-templates/src/field-chip.ts

Lines changed: 39 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,30 @@
11
/**
2-
* Contextual smart-field chip — SD-3157 demo.
2+
* Contextual smart-field chip — SD-3157 / SD-3232 demo.
33
*
44
* Shows a small chip anchored above the active smart-field content
5-
* control with the field's label and current value. Wired against the
6-
* public `superdoc/ui` controller (no framework — this demo is plain
7-
* TypeScript), using:
5+
* control with the field's label and current value. Plain TypeScript
6+
* (no framework), wired against two public SuperDoc APIs:
87
*
9-
* - `ui.contentControls.observe(...)` to react to the active control
10-
* - `ui.contentControls.getRect({ id })` to anchor the chip
8+
* - `superdoc.on('content-control:active-change', ...)` to know *which*
9+
* control is active (SD-3232 events). The payload's `SdtRef` carries
10+
* the tag/alias/scope directly, so no extra lookup is needed.
11+
* - `ui.contentControls.getRect({ id })` to know *where* to draw the chip.
12+
*
13+
* That pairing is the intended model: events tell you what is active;
14+
* `getRect()` tells you where to place your own UI.
1115
*
1216
* Narrow on purpose: only renders for `kind: 'smartField'` controls so
13-
* the chip doesn't collide with the existing block-clause review UI in
14-
* the Clauses tab. Linked-occurrence highlights, field-details popovers,
15-
* and clause badges are deliberate follow-ups (SD-3155 umbrella).
17+
* the chip doesn't collide with the block-clause review UI in the Clauses
18+
* tab. Linked-occurrence highlights, field-details popovers, and clause
19+
* badges are deliberate follow-ups (SD-3155 umbrella).
1620
*
17-
* This demo runs with SuperDoc's built-in SDT chrome turned off
21+
* The demo runs with SuperDoc's built-in SDT chrome turned off
1822
* (`modules.contentControls.chrome: 'none'`, SD-3159), so the chip is the
1923
* smart field's active-state UI rather than an addition on top of the
2024
* built-in blue label/border. The wrappers and data-sdt-* datasets are
21-
* still emitted, which is what `observe`/`getRect`/`get` rely on.
25+
* still emitted, which is what `getRect` relies on.
2226
*/
27+
import type { SuperDoc, ContentControlActiveChangePayload } from 'superdoc';
2328
import type { SuperDocUI } from 'superdoc/ui';
2429

2530
export type SmartFieldLookup = {
@@ -33,11 +38,12 @@ const CHIP_CLASS = 'sd-field-chip';
3338
const CHIP_OFFSET_PX = 6;
3439

3540
/**
36-
* Wire the chip to the controller. Returns a teardown function that
41+
* Wire the chip. `superdoc` supplies the active-change events; `ui`
42+
* supplies `getRect` for positioning. Returns a teardown function that
3743
* detaches listeners and removes the chip element. Safe to call after
3844
* `initialize()` has populated the field-value cache.
3945
*/
40-
export function attachFieldChip(ui: SuperDocUI, lookup: SmartFieldLookup): () => void {
46+
export function attachFieldChip(superdoc: SuperDoc, ui: SuperDocUI, lookup: SmartFieldLookup): () => void {
4147
const chipEl = document.createElement('div');
4248
chipEl.className = CHIP_CLASS;
4349
chipEl.style.position = 'fixed';
@@ -50,12 +56,11 @@ export function attachFieldChip(ui: SuperDocUI, lookup: SmartFieldLookup): () =>
5056
let currentKey: string | null = null;
5157

5258
/**
53-
* Clear the active control entirely. Use ONLY when the controller
54-
* tells us "no active SDT" — i.e. the observe callback fires with
55-
* `activeId: null` or the active control isn't a smart field. Do
56-
* NOT call this from the positioning loop on a transient rect miss
57-
* (a reflow can drop the rect for one tick; clearing here would
58-
* leave the chip hidden until the user clicks away and back).
59+
* Clear the active control entirely. Use ONLY when active-change tells
60+
* us "no active smart field" (active is null, or not a smart field). Do
61+
* NOT call this from the positioning loop on a transient rect miss (a
62+
* reflow can drop the rect for one tick; clearing here would leave the
63+
* chip hidden until the user clicks away and back).
5964
*/
6065
const clearActive = () => {
6166
chipEl.style.visibility = 'hidden';
@@ -72,9 +77,8 @@ export function attachFieldChip(ui: SuperDocUI, lookup: SmartFieldLookup): () =>
7277
if (!currentId) return;
7378
const rect = ui.contentControls.getRect({ id: currentId });
7479
if (!rect.success) {
75-
// Transient miss — keep the active state so the next scroll /
76-
// resize / observe tick can re-anchor without requiring the
77-
// user to click away.
80+
// Transient miss — keep the active state so the next scroll / resize
81+
// tick can re-anchor without requiring the user to click away.
7882
hideVisually();
7983
return;
8084
}
@@ -113,17 +117,18 @@ export function attachFieldChip(ui: SuperDocUI, lookup: SmartFieldLookup): () =>
113117

114118
const onScrollOrResize = () => positionChip();
115119

116-
const unsubscribe = ui.contentControls.observe((snapshot) => {
117-
// Narrow to smart-field SDTs only. Block-level reusable clauses
118-
// have their own review surface in the Clauses tab; rendering a
119-
// chip on them would compete with that flow.
120-
const activeId = snapshot.activeId;
121-
if (!activeId) {
120+
// SD-3232: the active control comes from the public SuperDoc event. The
121+
// payload includes the SdtRef (id + tag), so we can narrow to smart
122+
// fields and anchor by id without a separate lookup.
123+
const onActiveChange = ({ active }: ContentControlActiveChangePayload) => {
124+
if (!active) {
122125
clearActive();
123126
return;
124127
}
125-
const info = ui.contentControls.get({ id: activeId });
126-
const tagStr = info?.properties?.tag;
128+
// Narrow to smart-field SDTs only. Block-level reusable clauses have
129+
// their own review surface in the Clauses tab; a chip on them would
130+
// compete with that flow.
131+
const tagStr = active.tag;
127132
if (!tagStr) {
128133
clearActive();
129134
return;
@@ -139,16 +144,17 @@ export function attachFieldChip(ui: SuperDocUI, lookup: SmartFieldLookup): () =>
139144
clearActive();
140145
return;
141146
}
142-
currentId = activeId;
147+
currentId = active.id;
143148
currentKey = parsed.key;
144149
update();
145-
});
150+
};
146151

152+
superdoc.on('content-control:active-change', onActiveChange);
147153
window.addEventListener('scroll', onScrollOrResize, true);
148154
window.addEventListener('resize', onScrollOrResize);
149155

150156
return () => {
151-
unsubscribe();
157+
superdoc.off('content-control:active-change', onActiveChange);
152158
window.removeEventListener('scroll', onScrollOrResize, true);
153159
window.removeEventListener('resize', onScrollOrResize);
154160
chipEl.remove();

demos/contract-templates/src/main.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,9 @@ async function initialize(instance: DemoSuperDoc): Promise<void> {
279279
// leave the previous controller's scroll/resize listeners attached
280280
// to `window` and the previous chip element in the DOM.
281281
state.ui = createSuperDocUI({ superdoc: instance });
282-
state.fieldChipTeardown = attachFieldChip(state.ui, {
282+
// Active control comes from the SuperDoc event (SD-3232); placement from
283+
// the UI controller's getRect (SD-3157).
284+
state.fieldChipTeardown = attachFieldChip(instance, state.ui, {
283285
labelFor: (key) => FIELDS.find((f) => f.key === (key as FieldKey))?.label ?? key,
284286
valueFor: (key) => state.values[key as FieldKey],
285287
});

demos/contract-templates/src/style.css

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -264,11 +264,12 @@ input:focus {
264264
}
265265

266266
/*
267-
* Contextual smart-field chip (SD-3157). Floats over the active smart-
268-
* field SDT showing field label + live value. Wired in field-chip.ts
269-
* against `ui.contentControls.observe` + `getRect`. With built-in chrome
270-
* off (SD-3159), the chip is the smart field's active-state affordance:
271-
* custom UI anchored to the SDT via the public geometry API.
267+
* Contextual smart-field chip (SD-3157 / SD-3232). Floats over the active
268+
* smart-field SDT showing field label + live value. Wired in field-chip.ts
269+
* against the public `content-control:active-change` event (what's active)
270+
* + `ui.contentControls.getRect` (where to draw). With built-in chrome off
271+
* (SD-3159), the chip is the smart field's active-state affordance: custom
272+
* UI anchored to the SDT via public events and the geometry API.
272273
*/
273274
.sd-field-chip {
274275
display: inline-flex;

0 commit comments

Comments
 (0)