Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
98 changes: 98 additions & 0 deletions demos/__tests__/contract-templates-smart-tags.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { test, expect } from '@playwright/test';

/**
* Smart-tags authoring: clicking a tag chip in the sidebar inserts a matching
* inline SDT at the caret (dogfoods ui.selection.capture + create.contentControl
* + ui.contentControls.focus). The inserted field carries the field's tag and
* the token text, and paints with the same .superdoc-structured-content-inline
* wrapper the chips are styled to match.
*
* Runs only for the contract-templates demo (the shared suite runs once per DEMO).
*/

test('clicking a Smart-tags chip inserts a matching inline SDT at the caret', 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 },
);
await page.waitForSelector('[data-tag-key]');

// Place a caret in the document body so capture() has an insertion point.
await page.evaluate(() => {
(window as any).__demo.superdoc.activeEditor.commands?.setTextSelection?.({ from: 6, to: 6 });
});

const key = await page.getAttribute('[data-tag-key]', 'data-tag-key');
expect(key).toBeTruthy();

// Count existing controls with this tag, then click the chip and expect one more.
const tag = JSON.stringify({ kind: 'smartField', key });
const token = key!.replace(/([A-Z])/g, '_$1').toUpperCase();

const textsForTag = () =>
page.evaluate((t) => {
const ed = (window as any).__demo.superdoc.activeEditor;
const out: string[] = [];
ed.state.doc.descendants((node: any) => {
if (node.type.name === 'structuredContent' && node.attrs?.tag === t) out.push(node.textContent);
return true;
});
return out;
}, tag);

const before = await textsForTag();
await page.click(`[data-tag-key="${key}"]`);

// A new inline SDT carrying this tag + token text should appear.
await expect
.poll(async () => (await textsForTag()).filter((x) => x === token).length, { timeout: 6_000 })
.toBeGreaterThan(before.filter((x) => x === token).length);
});

test('clicking an in-editor smart-field token highlights its sidebar chip', 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 },
);
await page.waitForSelector('[data-tag-key]');

const sel = '.superdoc-structured-content-inline[data-sdt-tag*="smartField"]';
await page.waitForSelector(sel);
// The key of the first painted inline smart-field token in the document.
const key = await page.evaluate((s) => {
const el = document.querySelector(s);
try {
return JSON.parse(el?.getAttribute('data-sdt-tag') ?? '{}').key ?? null;
} catch {
return null;
}
}, sel);
expect(key).toBeTruthy();

// Click the token in the document; its sidebar chip should become active.
await page.locator(sel).first().click();
await expect
.poll(
async () =>
page.evaluate(
(k) => document.querySelector(`.smart-tag[data-tag-key="${k}"]`)?.classList.contains('is-active') ?? false,
key,
),
{ timeout: 5_000 },
)
.toBe(true);
});
15 changes: 8 additions & 7 deletions demos/contract-templates/README.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
# Contract templates

Runtime contract template management built on Word content controls. A Mutual NDA opens with tagged smart fields and six versioned clauses. The app detects stale clauses against a library, updates them in place, and exports either a raw template DOCX or a clean final DOCX. Single-page, no backend, no framework.
A demo of building **your own UI for Word content controls (SDT fields)** on top of SuperDoc. It turns off SuperDoc's built-in field chrome (`modules: { contentControls: { chrome: 'none' } }`) and renders its own: smart-field tokens as pills in the document, a "Smart tags" palette in the sidebar using the *same* pill look, and click-to-insert / locate / focus interactions — all on standard, Word-compatible SDTs that round-trip to `.docx`. A Mutual NDA opens with tagged smart fields and six versioned clauses; the app fills fields live, detects and replaces stale clauses, and exports a raw template or a clean final DOCX. Single-page, no backend, no framework.

This is a demo: it composes multiple content-control patterns into a product workflow. For the smallest copy-pasteable primitive, see the [tagged inline text example](../../examples/document-api/content-controls/tagged-inline-text).
The point: SuperDoc owns *how content is painted*, but with `chrome: 'none'` you own *the field's look and the surrounding UI*. You style the painted SDT wrapper, react to public events, position overlays with `getRect`, and mutate through `editor.doc.contentControls.*` — so fields in the editor can look exactly like your app. For the smallest copy-pasteable primitive, see the [tagged inline text example](../../examples/document-api/content-controls/tagged-inline-text).

## What this shows

The starting document is a Mutual NDA at `public/nda-template.docx` with thirteen content controls already in place: seven inline plain-text controls (smart fields) and six block rich-text controls (reusable clauses). Receiving party and Purpose each appear twice — once in the header sentence and once nested inside the Permitted Use clause. Each control carries a `w:tag` with a JSON payload. On boot, SuperDoc imports the DOCX, parses the SDTs, and the demo reads field values and clause versions straight from the parsed controls.

Three flows of the same primitive, composed into one app:
The flows, composed into one app:

1. **Smart fields.** Seven inline plain-text content controls across five field keys share a `tag` shape (`{ kind: 'smartField', key: 'disclosingParty' }`) per occurrence. They were authored as Word "Plain Text Content Controls" (`ContentControls.Add(1, range)`), so SuperDoc resolves them as `controlType: 'text'`. Edit a value in the Fields tab; every occurrence of that field updates live via `selectByTag` + per-occurrence `text.setValue`. Receiving party and Purpose appear twice (header sentence and nested inside the Permitted Use clause), so a single edit fans across both locations.
2. **Versioned reusable clauses.** Six block rich-text content controls carry `{ kind: 'reusableSection', sectionId, version }` in their tags. They were authored as Word "Rich Text Content Controls" (`ContentControls.Add(0, range)`), which produces typeless sdtPr; SuperDoc resolves them as `controlType: 'richText'` per ECMA-376 §17.5.2.26. The app reads each live version from `contentControls.list`, compares against the clause library, and surfaces a Review CTA when they diverge. Review expands a card with the current clause text alongside the library clause text plus a Replace with library clause action that calls `replaceContent` + `patch`.
3. **Export.** `superdoc.export({ exportedName, isFinalDoc, triggerDownload })` has two buttons: **Export raw DOCX** uses `isFinalDoc: false` to preserve content controls and tags for future template/library updates; **Export clean DOCX** uses `isFinalDoc: true` to flatten controls so the filled values are in place.
1. **Custom field look + Smart-tags authoring.** Built-in chrome is off, so the demo styles the painted SDT wrapper itself: inline smart fields render as amber token pills via CSS on `.superdoc-structured-content-inline[data-sdt-tag*='smartField']`. The sidebar "Smart tags" palette uses the same `--tag-*` token style, so a palette chip and the field it inserts look identical. Clicking a chip captures the caret (`ui.selection.capture()`), inserts an inline SDT there (`editor.doc.create.contentControl({ at, content, tag })`), then focuses it (`ui.contentControls.focus`). Clicking a token in the document highlights its chip (`content-control:click`) — the two-way loop. Each field and clause also has Locate (`ui.contentControls.scrollIntoView`) and Focus (`ui.contentControls.focus`) to jump to it, and the active field gets a contextual chip overlay positioned with `getRect` + kept anchored with `ui.viewport.observe`.
2. **Smart fields (fill).** Seven inline plain-text content controls across five field keys share a `tag` shape (`{ kind: 'smartField', key: 'disclosingParty' }`) per occurrence. They were authored as Word "Plain Text Content Controls" (`ContentControls.Add(1, range)`), so SuperDoc resolves them as `controlType: 'text'`. Edit a value in the Fields tab; every occurrence of that field updates live via `selectByTag` + per-occurrence `text.setValue`. Receiving party and Purpose appear twice (header sentence and nested inside the Permitted Use clause), so a single edit fans across both locations.
3. **Versioned reusable clauses.** Six block rich-text content controls carry `{ kind: 'reusableSection', sectionId, version }` in their tags. They were authored as Word "Rich Text Content Controls" (`ContentControls.Add(0, range)`), which produces typeless sdtPr; SuperDoc resolves them as `controlType: 'richText'` per ECMA-376 §17.5.2.26. The app reads each live version from `contentControls.list`, compares against the clause library, and surfaces a Review CTA when they diverge. Review expands a card with the current clause text alongside the library clause text plus a Replace with library clause action that calls `replaceContent` + `patch`.
4. **Export.** `superdoc.export({ exportedName, isFinalDoc, triggerDownload })` has two buttons: **Export raw DOCX** uses `isFinalDoc: false` to preserve content controls and tags for future template/library updates; **Export clean DOCX** uses `isFinalDoc: true` to flatten controls so the filled values are in place.

Every mutation goes through `editor.doc.*`. The same operation set runs headless via the Node SDK and CLI.

Expand All @@ -23,7 +24,7 @@ pnpm install
pnpm dev
```

The seeded NDA ships with three clauses behind their latest versions (Confidentiality, Governing Law, Limitation of Liability). The Clauses tab shows a Review CTA on each; expanding a card lets you compare the in-document clause with the library version and replace it in place. Edit a value in the Fields tab and watch it fan to every occurrence in the document (header and nested locations). Export raw DOCX when you want to keep the template controls, or export clean DOCX when you want a final document with the values in place.
In the Fields tab, click a chip in the Smart tags palette to insert that field as a styled token at the cursor — it appears in the document with the same pill look as the chip. Click a token in the document and its chip highlights. Use Locate / Focus on a field or clause to jump to it (Focus also drops the cursor inside). Edit a value and it fans to every occurrence (header and nested locations). The seeded NDA ships with three clauses behind their latest versions (Confidentiality, Governing Law, Limitation of Liability); the Clauses tab shows a Review CTA on each, and expanding a card compares the in-document clause with the library version and replaces it in place. Export raw DOCX to keep the template controls, or clean DOCX for a final document with values in place.

## Related work

Expand Down
130 changes: 130 additions & 0 deletions demos/contract-templates/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ import { attachFieldChip } from './field-chip.js';
type NodeKind = 'block' | 'inline';
type LockMode = 'unlocked' | 'sdtLocked' | 'contentLocked' | 'sdtContentLocked';
type ContentControlTarget = { kind: NodeKind; nodeType: 'sdt'; nodeId: string };
// Minimal shapes for inserting at the caret (see `editor.doc.create.contentControl`).
type SelectionPoint = { kind: 'text'; blockId: string; offset: number };
type SelectionTarget = { kind: 'selection'; start: SelectionPoint; end: SelectionPoint };

type ContentControlInfo = {
target: ContentControlTarget;
Expand All @@ -60,6 +63,16 @@ type MutationResult =
| { success: false; failure: { code: string; message: string } };

type DocumentApi = {
create: {
contentControl(input: {
kind: NodeKind;
controlType?: 'text' | 'richText';
at?: SelectionTarget;
content?: string;
tag?: string;
alias?: string;
}): MutationResult;
};
contentControls: {
list(input?: Record<string, unknown>): { items: ContentControlInfo[]; total: number };
selectByTag(input: { tag: string }): { items: ContentControlInfo[]; total: number };
Expand Down Expand Up @@ -199,10 +212,14 @@ const state = {
values: {} as Record<FieldKey, string>,
versions: {} as Record<ClauseId, string>,
expandedClause: null as ClauseId | null,
/** Smart-tag chip mirrored as active when the caret is in a matching field. */
activeTagKey: null as FieldKey | null,
/** UI controller; created in `initialize`, disposed by `teardown`. */
ui: null as ReturnType<typeof createSuperDocUI> | null,
/** Field-chip detach handle; created in `initialize`, called by `teardown`. */
fieldChipTeardown: null as (() => void) | null,
/** Detaches the document -> palette highlight listeners. */
smartTagSyncTeardown: null as (() => void) | null,
};

const statusEl = qs<HTMLElement>('#status');
Expand Down Expand Up @@ -286,6 +303,25 @@ async function initialize(instance: DemoSuperDoc): Promise<void> {
valueFor: (key) => state.values[key as FieldKey],
});

// Document -> palette: clicking a smart-field token in the editor highlights
// its chip in the sidebar (dogfoods content-control:click). Cleared on blur.
const onTokenClick = ({ target }: { target: { tag?: string } }) => {
const parsed = target?.tag ? parseTag(target.tag) : null;
state.activeTagKey = parsed?.kind === 'smartField' ? (parsed.key as FieldKey) : null;
highlightActiveTag();
};
const onActiveChange = ({ active }: { active: { tag?: string } | null }) => {
if (active) return;
state.activeTagKey = null;
highlightActiveTag();
};
instance.on('content-control:click', onTokenClick);
instance.on('content-control:active-change', onActiveChange);
state.smartTagSyncTeardown = () => {
instance.off('content-control:click', onTokenClick);
instance.off('content-control:active-change', onActiveChange);
};

setStatus('Ready');
setBusy(false);
}
Expand Down Expand Up @@ -318,6 +354,41 @@ function applyField(key: FieldKey, value: string): void {
}
}

/** The token text shown inside an unfilled field (e.g. `disclosingParty` -> `DISCLOSING_PARTY`). */
const tokenFor = (key: FieldKey): string => key.replace(/([A-Z])/g, '_$1').toUpperCase();

/**
* Insert a smart-tag field at the caret (authoring). Dogfoods the verified
* recipe: capture the caret as a TextTarget, bridge it to a collapsed
* SelectionTarget, then `create.contentControl` with the token as initial
* content. The new inline SDT paints with the same `.superdoc-structured-content-inline`
* wrapper the palette chips are styled to match. Then focus it so the user can type.
*/
function insertTagAtCursor(key: FieldKey, label: string): void {
const ui = state.ui;
const editor = state.editor;
if (!ui || !editor?.doc) return;
const seg = ui.selection.capture()?.target?.segments?.[0];
if (!seg) {
// No caret to insert at — tell the user instead of silently no-op'ing.
setStatus('Place the cursor in the document, then click a tag to insert it.');
return;
}
const point: SelectionPoint = { kind: 'text', blockId: seg.blockId, offset: seg.range.start };
const result = editor.doc.create.contentControl({
kind: 'inline',
controlType: 'text',
at: { kind: 'selection', start: point, end: point },
content: tokenFor(key),
tag: fieldTag(key),
alias: label,
});
if (result.success) {
state.values[key] = state.values[key] ?? '';
void ui.contentControls.focus({ id: result.contentControl.nodeId });
}
}

async function applyClauseVersion(clauseId: ClauseId, toVersion: string, body: string): Promise<void> {
const doc = getDoc();
const clause = CLAUSE_LIBRARY.find((c) => c.id === clauseId);
Expand Down Expand Up @@ -396,8 +467,61 @@ function renderPanels(): void {
renderClausesPanel();
}

/**
* Smart-tags palette: a searchable list of variable chips. Clicking a chip
* inserts that field as an inline SDT at the caret (authoring). The chips use
* the same token look (.smart-tag / --tag-*) as the painted in-editor field, so
* the sidebar tag and the inserted field are visually identical.
*/
function renderSmartTagsPalette(): void {
const section = document.createElement('div');
section.className = 'smart-tags';
section.innerHTML = `
<p class="smart-tags-hint">Click a tag to insert it at the cursor.</p>
<input class="smart-tags-search" type="search" placeholder="Search tags…" aria-label="Search smart tags" />
<div class="smart-tags-group">Offer</div>
<div class="smart-tags-list">
${FIELDS.map(
(f) =>
`<button class="smart-tag" type="button" data-tag-key="${escapeAttr(f.key)}" title="Insert ${escapeAttr(f.label)} at the cursor">${escapeHtml(tokenFor(f.key))}</button>`,
).join('')}
</div>
`;
fieldsPanelEl.appendChild(section);

section.querySelectorAll<HTMLButtonElement>('.smart-tag').forEach((btn) => {
btn.addEventListener('click', () => {
const field = FIELDS.find((f) => f.key === (btn.dataset.tagKey as FieldKey));
if (field) insertTagAtCursor(field.key, field.label);
});
});

const search = section.querySelector<HTMLInputElement>('.smart-tags-search');
search?.addEventListener('input', () => {
const q = search.value.trim().toUpperCase();
section.querySelectorAll<HTMLButtonElement>('.smart-tag').forEach((btn) => {
btn.style.display = !q || (btn.textContent ?? '').includes(q) ? '' : 'none';
});
});

highlightActiveTag();
}

/**
* Mirror the active field in the palette: the chip whose key matches
* `state.activeTagKey` gets `.is-active`. Driven by `content-control:click`
* (and cleared on blur via `content-control:active-change`) — the document ->
* sidebar half of the two-way loop.
*/
function highlightActiveTag(): void {
fieldsPanelEl.querySelectorAll<HTMLButtonElement>('.smart-tag').forEach((btn) => {
btn.classList.toggle('is-active', btn.dataset.tagKey === state.activeTagKey);
});
}

function renderFieldsPanel(): void {
fieldsPanelEl.innerHTML = '';
renderSmartTagsPalette();
for (const field of FIELDS) {
// A <div> wrapper (not <label>): a <label> may not contain interactive
// content, so the Locate <button> must be a sibling of the input, with a
Expand Down Expand Up @@ -602,6 +726,12 @@ const teardown = () => {
/* best-effort teardown */
}
state.fieldChipTeardown = null;
try {
state.smartTagSyncTeardown?.();
} catch {
/* best-effort teardown */
}
state.smartTagSyncTeardown = null;
try {
state.ui?.destroy();
} catch {
Expand Down
Loading
Loading