Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
57 changes: 57 additions & 0 deletions apps/docs/editor/custom-ui/content-controls.mdx
Original file line number Diff line number Diff line change
@@ -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.
49 changes: 49 additions & 0 deletions apps/docs/editor/superdoc/events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,55 @@ superdoc.on('fonts-resolved', ({ documentFonts, unsupportedFonts }) => {
```
</CodeGroup>

## 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`
Expand Down
72 changes: 39 additions & 33 deletions demos/contract-templates/src/field-chip.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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';
Expand All @@ -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';
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand Down
4 changes: 3 additions & 1 deletion demos/contract-templates/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,9 @@ async function initialize(instance: DemoSuperDoc): Promise<void> {
// 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],
});
Expand Down
11 changes: 6 additions & 5 deletions demos/contract-templates/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading