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
10 changes: 7 additions & 3 deletions apps/docs/document-api/features/content-controls.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ for (const control of items) {
}
```

Composed runtime: [`demos/contract-templates`](https://github.com/superdoc-dev/superdoc/tree/main/demos/contract-templates).
Or keep clauses **single-use and governed**: a clause is either in the contract or available to add from a library, and it appears once. Track inclusion by querying `contentControls.list` for the `sectionId` instead of comparing versions, and lock each placed clause (`contentLocked`) so its prose is fixed. A clause can also carry nested smart fields - inline controls inside the block - that fill from one place.

The [`demos/contract-templates`](https://github.com/superdoc-dev/superdoc/tree/main/demos/contract-templates) runtime composes the single-use approach: a clause library that inserts locked block clauses (some with nested fields), each filled by tag from a form.

## Why `tag`, not `nodeId`

Expand Down Expand Up @@ -134,10 +136,12 @@ Set `lockMode` when you create a control to govern which changes are allowed.
|---|---|
| `unlocked` | Content and properties can be updated through the Document API. |
| `sdtLocked` | The wrapper is preserved through user edits. |
| `contentLocked` | The content can't be modified through the editor surface. |
| `contentLocked` | The user can't edit the content, **and** content writes through the Document API (`text.setValue`, `replaceContent`) are rejected too - they return a `LOCK_VIOLATION`. |
| `sdtContentLocked` | Both wrapper and content are preserved. |

For controls your app drives with `text.setValue`, `replaceContent`, or `patch`, use `lockMode: 'unlocked'`.
For controls your app drives freely with `text.setValue` or `replaceContent`, use `lockMode: 'unlocked'`.

For a **locked template** - controls the user can't touch, but your app still updates - keep them `contentLocked` and unlock around each write: `setLockMode({ lockMode: 'unlocked' })`, write, then `setLockMode({ lockMode: 'contentLocked' })`. Use `try`/`finally` so a failed write never leaves a control unlocked. `setLockMode` and `patch` are not blocked by `contentLocked`, so only the content write needs the unlock window. A smart field nested inside a locked block control needs the **parent** unlocked for the write too, since the parent's content lock vetoes writes to anything inside it.

## Data binding

Expand Down
13 changes: 12 additions & 1 deletion apps/docs/editor/custom-ui/content-controls.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,19 @@ This is the path for `chrome: 'none'`. To theme the **built-in** chrome instead

You build your UI *over* the control, not inside it. SuperDoc owns how the control's content is painted in the document; you turn off its built-in chrome and draw your own (chips, badges, panels) anchored with `getRect`, react with the events, and change content through `editor.doc.contentControls.*`. Custom field types are expressed as a `tag` - for example `{ kind: 'smartField', key: 'party_name' }`, interpreted by your own UI - the underlying control stays a standard Word SDT so it round-trips to `.docx`.

## Build a custom field system

Putting it together into a fillable template, the way the contract-templates demo does:

1. **Define a tag schema.** Give each control a JSON `tag` your app owns - e.g. `{ kind: 'smartField', key }` for inline variables and `{ kind: 'reusableSection', sectionId }` for clauses.
2. **Insert from a palette.** Drop a control at a point with `editor.doc.create.contentControl({ kind, at, content, tag, lockMode })`, resolving the drop point with `ui.viewport.positionAt({ x, y })`. A clause can wrap its `{ field }` slots as nested inline controls.
3. **Style it.** Set the `--sd-content-controls-custom-*` variables on a `data-sdt-tag` selector (see [Style the controls in place](#style-the-controls-in-place)). The sidebar chips can reuse the same tokens, so palette and document match.
4. **React.** Highlight the active control from `content-control:active-change` / `:click`, and anchor overlays with `getRect` + `ui.viewport.observe`.
5. **Fill by tag.** A form edits a value and fans it to every occurrence: `editor.doc.contentControls.selectByTag({ tag })`, then `text.setValue` per occurrence.
6. **Govern with locks.** Keep controls `contentLocked` so users can't edit them, and have the form unlock → write → relock (see [Lock modes](/document-api/features/content-controls#lock-modes)). For a field nested in a locked clause, unlock the parent for the write.

## See also

- [Contract templates demo](https://github.com/superdoc-dev/superdoc/tree/main/demos/contract-templates) - a working field chip built on these APIs.
- [Contract templates demo](https://github.com/superdoc-dev/superdoc/tree/main/demos/contract-templates) - a full custom contract-template UI: a field + clause library, custom SDT styling, locks, form-driven values, events, insert, and export.
- [Configuration](/editor/superdoc/configuration) - the `modules.contentControls.chrome` option.
- [Document API: content controls](/document-api/features/content-controls) - read and change controls.
45 changes: 45 additions & 0 deletions demos/__tests__/contract-templates-smart-tags.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,3 +397,48 @@ test('adding the Return of Materials clause nests a real smart field that fills
.poll(async () => (await receivingPartyControls()).filter((t) => t === 'Beacon Bio').length, { timeout: 6_000 })
.toBe(before + 1);
});

test('the public custom SDT variables drive the painted fields across states (no !important)', async ({ page }) => {
test.skip(process.env.DEMO !== 'contract-templates', 'contract-templates demo only');

await page.route('**/ingest.superdoc.dev/**', (r) =>
r.fulfill({ status: 204, contentType: 'application/json', body: '{}' }),
);
await page.goto('/');
await page.waitForFunction(
() => (window as any).__demo?.state?.ui?.contentControls?.getSnapshot()?.items?.length > 0,
null,
{ timeout: 30_000 },
);
const sel = ".superdoc-structured-content-inline[data-sdt-tag*='smartField']";
await page.waitForSelector(sel);
const field = page.locator(sel).first();

const bg = () => field.evaluate((el) => getComputedStyle(el).backgroundColor);
const borderTop = () =>
field.evaluate((el) => `${getComputedStyle(el).borderTopWidth} ${getComputedStyle(el).borderTopColor}`);

const restBg = await bg();
const restBorder = await borderTop();
await field.hover();
await page.waitForTimeout(250);
const hoverBg = await bg();
const hoverBorder = await borderTop();

// The custom hover background applies (the fill changes)...
expect(hoverBg).not.toBe(restBg);
// ...and it is NOT the built-in lock-hover tint. Fields carry data-lock-mode,
// which matches SuperDoc's lock-hover path; the custom variable must win.
expect(hoverBg).not.toBe('rgba(98, 155, 231, 0.08)');
// The border is constant across states (no jitter) - achieved with variables
// alone: the demo CSS has no !important and no .ProseMirror-selectednode /
// .sdt-group-hover state selectors.
expect(hoverBorder).toBe(restBorder);
expect(restBorder.startsWith('1px ')).toBe(true);

// No built-in label / chrome leaks under chrome:'none'.
const leakedLabels = await page
.locator('.superdoc-structured-content__label, .superdoc-structured-content-inline__label')
.count();
expect(leakedLabels).toBe(0);
});
2 changes: 2 additions & 0 deletions demos/contract-templates/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ The starting document is `public/nda-template.docx`: inline plain-text fields an
- Smart-field chips wear the same blue token look as the in-document field (CSS on `.superdoc-structured-content-inline[data-sdt-tag*='smartField']`). Drag a chip onto the document, or click to insert it at the cursor. An unfilled field shows its field-name token (e.g. `DISCLOSING_PARTY`) as a stand-in placeholder. That token is literal text content, not a native SDT placeholder.
- Clause cards wear the same blue block look as the in-document clause and carry metadata (category, jurisdiction, version) and a status. A clause is single-use, like an inclusion checklist: a card already in the contract reads **In contract** and clicking it reveals the existing clause; an available card reads **Add clause** and drags or clicks in. The catalog includes clauses that aren't in the document yet (e.g. Indemnification, Return of Materials).

**Custom styling.** With chrome off, the field and clause look is set entirely through SuperDoc's public `--sd-content-controls-custom-*` CSS variables, on a `data-sdt-tag` selector. SuperDoc applies them across rest, hover, selected, and locked-hover, so the demo's CSS has no `!important` and no internal state classes (`.ProseMirror-selectednode`, `.sdt-group-hover`) - copy these rules to style your own SDTs. See [Custom UI > Content controls](https://docs.superdoc.dev/editor/custom-ui/content-controls).

Inserts resolve the drop point with `ui.viewport.positionAt({ x, y })` and create the control with `editor.doc.create.contentControl({ kind, at, content, tag, lockMode })`. A field inserts inline at the exact caret; a clause snaps to a block boundary so it lands as a clean section instead of splitting a paragraph. Clicking a control in the document highlights its chip or card (`content-control:click`).

A clause is assembled from structured `parts`: prose plus `{ field }` slots. Inserting "Permitted Use" creates the block and then wraps each slot as a nested, locked inline smart field, so the inserted clause carries real Receiving party and Purpose fields, just like the seeded one. Filling those fields in the Values tab updates the clause and the header sentence together.
Expand Down
83 changes: 32 additions & 51 deletions demos/contract-templates/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -263,12 +263,12 @@ input:focus {
/* -----------------------------------------------------------------------
Host-owned SDT styling.
This demo turns off SuperDoc's built-in content-control chrome
(`modules.contentControls.chrome: 'none'` in main.ts) and paints its
own. The painter adds `.superdoc-cc-chrome-none` to the mount and resets
border/padding/radius/background on the SDT wrappers; scoping under that
class keeps these rules above the reset in specificity and cascade, and
restores the box properties the reset strips. No painter label element
exists under chrome-none, so there is nothing to style for it.
(`modules.contentControls.chrome: 'none'` in main.ts) and paints its own,
driving it entirely through SuperDoc's public --sd-content-controls-custom-*
variables. We set those variables per tag (on the data-sdt-tag selector) and
the painter applies them across rest, hover, selected, and locked-hover. So
there is no !important, and no .ProseMirror-selectednode / .sdt-group-hover
state selectors - this is the copy-pasteable pattern for styling custom SDTs.
----------------------------------------------------------------------- */

/* Smart-tag token look, shared by the in-editor inline SDT and the Smart-tags
Expand All @@ -290,36 +290,23 @@ input:focus {
--tag-block-bg-hover: color-mix(in srgb, var(--tag-color) 8%, var(--demo-bg));
--tag-radius: 6px;
}
/* Inline smart fields: token pill (painted SDT wrapper under chrome:'none').
The box (border width + padding) is identical in every state so clicking /
hovering a field never shifts layout. Hover changes only the fill. */
/* Inline smart fields: token pill, painted by SuperDoc under chrome:'none'. We
set the public --sd-content-controls-custom-inline-* variables, and SuperDoc
applies them across rest, hover, selected, and locked-hover. No !important, no
.ProseMirror-selectednode / .sdt-group-hover state selectors, and the box
(border + padding) stays identical in every state, so a field never shifts on
hover or click. Only the background changes; the border is constant. */
.superdoc-cc-chrome-none .superdoc-structured-content-inline[data-sdt-tag*='smartField'] {
padding: 1px 6px;
border: 1px solid var(--tag-border);
border-radius: var(--tag-radius);
background-color: var(--tag-bg);
--sd-content-controls-custom-inline-border: 1px solid var(--tag-border);
--sd-content-controls-custom-inline-radius: var(--tag-radius);
--sd-content-controls-custom-inline-padding: 1px 6px;
--sd-content-controls-custom-inline-bg: var(--tag-bg);
--sd-content-controls-custom-inline-hover-bg: var(--tag-bg-hover);
--sd-content-controls-custom-inline-selected-bg: var(--tag-bg-hover);
/* Text colour is not part of the custom border/fill layer and chrome:'none'
does not reset it, so it can be set directly and stays stable across states. */
color: var(--tag-fg);
}
.superdoc-cc-chrome-none .superdoc-structured-content-inline[data-sdt-tag*='smartField']:hover {
/* Under chrome:'none' SuperDoc resets the field's border + fill (including on
hover) so the consumer owns the look. We re-assert the box so hover
never moves or recolors the field. The !important wins over that reset
without coupling to SuperDoc's selector specificity — a custom-UI styling
rough edge today (no first-class per-control styling hook yet). */
border: 1px solid var(--tag-border) !important;
background-color: var(--tag-bg-hover) !important;
}
/* Selecting a field is a ProseMirror NodeSelection (.ProseMirror-selectednode).
Under chrome:'none' SuperDoc resets the border + fill to transparent in that
state too; without re-asserting, the field loses its fill and the box can
shift (~2px) on click. Keep the same box and a controlled blue "selected"
fill so hover/click/selected stay on-brand and never move the field. */
.superdoc-cc-chrome-none .superdoc-structured-content-inline[data-sdt-tag*='smartField'].ProseMirror-selectednode {
/* !important to win over the chrome-none reset; same rough edge as hover. */
border: 1px solid var(--tag-border) !important;
background-color: var(--tag-bg-hover) !important;
color: var(--tag-fg) !important;
}

/* Smart-tags palette (sidebar). Chips reuse the --tag-* token look above, so a
palette chip and the field it inserts are visually identical. */
Expand Down Expand Up @@ -364,23 +351,17 @@ input:focus {
.smart-tag.is-active { box-shadow: 0 0 0 2px var(--demo-accent); }
.smart-tag:focus-visible { outline: 1px solid var(--demo-accent); outline-offset: 1px; }

/* Block clauses: a quiet card with a blue left rail, same field language as
the inline pills, but a region not a token: soft border, faint fill, a 4px
blue spine. */
/* Block clauses: a quiet card with a blue left rail, same field language as the
inline pills but a region not a token (soft border, faint fill, a 4px blue
spine). Set the public --sd-content-controls-custom-block-* variables; SuperDoc
applies them across rest, hover, selected, and locked-hover - so, like the
inline fields, there's no !important and no .sdt-group-hover /
.ProseMirror-selectednode state selectors, and the box stays constant. */
.superdoc-cc-chrome-none .superdoc-structured-content-block[data-sdt-tag*='reusableSection'] {
border: 1px solid var(--tag-block-border);
border-left: 4px solid var(--tag-color);
border-radius: var(--tag-radius);
background-color: var(--tag-block-bg);
}
/* Under chrome:'none' SuperDoc resets the block's border + fill on hover
(.sdt-group-hover) and select (.ProseMirror-selectednode) — via ::before/
::after pseudo-elements, different mechanics than inline. Re-assert the exact
box (no jitter) and lift the fill slightly to show activity. !important wins
over the reset; same custom-UI rough edge as the inline rules above. */
.superdoc-cc-chrome-none .superdoc-structured-content-block[data-sdt-tag*='reusableSection'].sdt-group-hover,
.superdoc-cc-chrome-none .superdoc-structured-content-block[data-sdt-tag*='reusableSection'].ProseMirror-selectednode {
border: 1px solid var(--tag-block-border) !important;
border-left: 4px solid var(--tag-color) !important;
background-color: var(--tag-block-bg-hover) !important;
--sd-content-controls-custom-block-border: 1px solid var(--tag-block-border);
--sd-content-controls-custom-block-border-left: 4px solid var(--tag-color);
--sd-content-controls-custom-block-radius: var(--tag-radius);
--sd-content-controls-custom-block-bg: var(--tag-block-bg);
--sd-content-controls-custom-block-hover-bg: var(--tag-block-bg-hover);
--sd-content-controls-custom-block-selected-bg: var(--tag-block-bg-hover);
}
Loading