Skip to content

Commit ed407d7

Browse files
authored
feat(content-controls): support richText type and regenerate NDA demo fixture (SD-3131) (#3275)
* feat(content-controls): support richText type and resolve typeless SDTs per ECMA-376 (SD-3131) Adds 'richText' to ContentControlType so SuperDoc resolves typeless w:sdt SDTs to 'richText' per ECMA-376 §17.5.2.26 ("If no type element is specified, then the nearest ancestor structured document tag shall be of type richText"). Before, typeless SDTs - which Word emits for ContentControls.Add(0, range) and the default Add($null, range) - fell through to 'unknown', blocking customers from inspecting Word-authored controls by type. - Detection: typeless sdtPr and explicit <w:richText/> both resolve to 'richText'. Unmodeled type children (w:equation, w:picture, w:citation, w:bibliography, w:docPartList) still return null so resolveControlType yields 'unknown' - keeping 'unknown' as "unsupported or unrecognized", not "typeless rich-text control". - Export/create maps: richText: 'w:richText' in both CONTROL_TYPE_SDT_PR_ELEMENTS (wrappers) and CONTROL_TYPE_ELEMENT_MAP (converter), plus a 'richText' case in buildDefaultTypeSdtPrElement so create.contentControl and setType produce valid OOXML. - text.setValue stays restricted to actual w:text controls. Tests: 22 in handle-structured-content-node.test.js (10 new), 39 in content-controls-wrappers, 1189 in conformance - all pass. * feat(demos): regenerate NDA fixture with plain-text inline fields (SD-3131) The contract-templates demo fixture had inline smart fields authored as default rich-text controls (typeless sdtPr), which is why none of them detected as 'text' - they fell through to 'unknown' before SD-3131 and resolve as 'richText' after. Regenerated nda-template.docx with ContentControls.Add(1, range) for the 7 inline smart fields (now <w:text/> in sdtPr, controlType: 'text') while leaving the 6 block clauses as typeless (controlType: 'richText'). Verified unzip: 13 SDTs total, 7 <w:text/>, 0 explicit <w:richText/>. Switched per-occurrence field update from replaceContent to text.setValue to exercise the typed API. Clause replacement stays on replaceContent because rich-text controls don't have a typed setter. README/JSDoc updated: "seven inline plain-text + six block rich-text" (was "thirteen plain-text"). Honest limits section updated to reflect the API split. Browser smoke verified: receivingParty fan-out updates 2/2 occurrences (header + nested), clause replacement decrements "updates available" correctly, summary line accurate.
1 parent 3c549c9 commit ed407d7

9 files changed

Lines changed: 114 additions & 20 deletions

File tree

demos/contract-templates/README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@ This is a demo: it composes multiple content-control patterns into a product wor
66

77
## What this shows
88

9-
The starting document is a Mutual NDA at `public/nda-template.docx` with thirteen plain-text content controls already in place: seven inline smart fields across five field keys (Receiving party and Purpose each appear twice — once in the header sentence and once nested inside the Permitted Use clause) plus six block clauses, each with a `w:tag` carrying 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.
9+
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.
1010

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

13-
1. **Smart fields.** Seven inline content controls across five field keys share a `tag` shape (`{ kind: 'smartField', key: 'disclosingParty' }`) per occurrence. Edit a value in the Fields tab; every occurrence of that field updates live via `selectByTag` + `replaceContent`. Receiving party and Purpose appear twice (header sentence and nested inside the Permitted Use clause), so a single edit fans across both locations.
14-
2. **Versioned reusable clauses.** Six block content controls carry `{ kind: 'reusableSection', sectionId, version }` in their tags. 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`.
13+
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.
14+
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`.
1515
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.
1616

1717
Every mutation goes through `editor.doc.*`. The same operation set runs headless via the Node SDK and CLI.
@@ -32,8 +32,8 @@ If you need a **ready-made React component for authoring templates** with conten
3232
## Honest limits
3333

3434
- All content controls in the fixture are `unlocked`. Locked controls (`sdtLocked`, `sdtContentLocked`) are not driven programmatically here.
35-
- Field values are updated through `contentControls.replaceContent` rather than `text.setValue`. `replaceContent` works regardless of how the control's type is detected on import.
36-
- Clause bodies are plain text. Rich-content clauses (formatting, tables, lists) need a different path: use `doc.insert` with the fragment, then `create.contentControl({ at: range })` to wrap the inserted range with a tag.
35+
- Smart field values are pushed through `text.setValue` (the typed API for plain-text controls). Clause bodies are pushed through `replaceContent` because rich-text controls don't have a typed setter.
36+
- Clause bodies in the seeded fixture are single-paragraph plain prose; the rich-text wrapper supports formatting/lists/tables when authored that way, but the demo doesn't exercise those.
3737

3838
## See also
3939

6 Bytes
Binary file not shown.

demos/contract-templates/src/main.ts

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,27 @@
33
*
44
* The document is a Mutual NDA (`public/nda-template.docx`)
55
* with content controls already in place:
6-
* - Seven inline plain-text SDTs across five field keys (disclosing
7-
* party, receiving party, effective date, purpose, term length).
6+
* - Seven inline plain-text content controls across five field keys
7+
* (disclosing party, receiving party, effective date, purpose, term
8+
* length). Authored via Word's `ContentControls.Add(1, range)`, so their
9+
* `w:sdtPr` carries `<w:text/>` and they resolve as `controlType: 'text'`.
810
* Receiving party and Purpose each appear twice: once in the header
911
* sentence and once nested inside the Permitted Use block clause.
10-
* - Six block plain-text SDTs (Preamble, Confidentiality, Permitted Use,
11-
* Term and Termination, Governing Law, Limitation of Liability). Each
12-
* block carries `{ kind: 'reusableSection', sectionId, version }` in
13-
* its tag.
12+
* - Six block rich-text content controls (Preamble, Confidentiality,
13+
* Permitted Use, Term and Termination, Governing Law, Limitation of
14+
* Liability). Authored via `ContentControls.Add(0, range)`, which
15+
* produces typeless sdtPr that resolves as `controlType: 'richText'`
16+
* per ECMA-376 §17.5.2.26. Each block carries
17+
* `{ kind: 'reusableSection', sectionId, version }` in its tag.
1418
*
1519
* The app:
1620
* 1. Loads the fixture as its starting document.
1721
* 2. Reads each field's text and each clause's version from the parsed SDTs.
1822
* 3. Compares clause versions against the local library and surfaces a
1923
* Review CTA on every stale clause with a one-line summary of the change.
2024
* 4. Field inputs are reactive: typing in a value debounces by ~250ms and
21-
* fans the new text to every occurrence via `selectByTag` + `replaceContent`.
25+
* fans the new text to every occurrence via `selectByTag` + per-occurrence
26+
* `text.setValue` (the typed API path for plain-text controls).
2227
* 5. Review expands a card showing the in-document clause alongside the
2328
* library version. Replace with library clause swaps body via
2429
* `replaceContent` and bumps the tag version via `patch`.
@@ -58,6 +63,9 @@ type DocumentApi = {
5863
selectByTag(input: { tag: string }): { items: ContentControlInfo[]; total: number };
5964
patch(input: { target: ContentControlTarget; tag?: string; alias?: string }): MutationResult;
6065
replaceContent(input: { target: ContentControlTarget; content: string; format?: 'text' }): MutationResult;
66+
text: {
67+
setValue(input: { target: ContentControlTarget; value: string }): MutationResult;
68+
};
6169
};
6270
};
6371

@@ -276,11 +284,7 @@ function applyField(key: FieldKey, value: string): void {
276284
state.values[key] = value;
277285
const { items } = state.editor.doc.contentControls.selectByTag({ tag: fieldTag(key) });
278286
for (const ctrl of items) {
279-
state.editor.doc.contentControls.replaceContent({
280-
target: ctrl.target,
281-
content: value,
282-
format: 'text',
283-
});
287+
state.editor.doc.contentControls.text.setValue({ target: ctrl.target, value });
284288
}
285289
}
286290

packages/document-api/src/content-controls/content-controls.types.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,16 @@ import type { ReceiptFailure } from '../types/receipt.js';
1313
// Enums and constants
1414
// ---------------------------------------------------------------------------
1515

16-
/** Semantic SDT subtype derived from `w:sdtPr` children. */
16+
/**
17+
* Semantic SDT subtype derived from `w:sdtPr` children.
18+
*
19+
* `richText` covers both explicit `<w:richText/>` and the OOXML default for
20+
* sdtPr with no type child (ECMA-376 §17.5.2.26: typeless SDT shall be of
21+
* type richText). `unknown` means an unsupported or unrecognized type child.
22+
*/
1723
export type ContentControlType =
1824
| 'text'
25+
| 'richText'
1926
| 'date'
2027
| 'checkbox'
2128
| 'comboBox'
@@ -27,6 +34,7 @@ export type ContentControlType =
2734

2835
export const CONTENT_CONTROL_TYPES = [
2936
'text',
37+
'richText',
3038
'date',
3139
'checkbox',
3240
'comboBox',

packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ import { parseAnnotationMarks } from './handle-annotation-node';
77
* @returns {string|null}
88
*/
99
function detectControlType(sdtPr) {
10-
if (!sdtPr?.elements) return null;
10+
// ECMA-376 §17.5.2.26: an sdtPr with no type child shall be of type richText.
11+
if (!sdtPr?.elements) return 'richText';
1112
const names = sdtPr.elements.map((el) => el.name);
1213

1314
if (names.includes('w:text')) return 'text';
15+
if (names.includes('w:richText')) return 'richText';
1416
if (names.includes('w:date')) return 'date';
1517
if (names.includes('w14:checkbox') || names.includes('w:checkbox')) return 'checkbox';
1618
if (names.includes('w:comboBox')) return 'comboBox';
@@ -19,7 +21,16 @@ function detectControlType(sdtPr) {
1921
if (names.includes('w15:repeatingSectionItem') || names.includes('w:repeatingSectionItem'))
2022
return 'repeatingSectionItem';
2123
if (names.includes('w:group')) return 'group';
22-
return null;
24+
25+
// Type-marker children that we don't (yet) model — equation, picture, citation,
26+
// bibliography, docPartList. Fall through so resolveControlType yields 'unknown'.
27+
const TYPE_CHILD_NAMES = new Set(['w:equation', 'w:picture', 'w:citation', 'w:bibliography', 'w:docPartList']);
28+
if (names.some((n) => TYPE_CHILD_NAMES.has(n))) return null;
29+
30+
// No recognized type child and no unrecognized type child either — sdtPr has
31+
// only property children (alias/tag/id/lock/placeholder/...). Per the spec,
32+
// that's a richText SDT.
33+
return 'richText';
2334
}
2435

2536
/**

packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.test.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,4 +224,70 @@ describe('handleStructuredContentNode', () => {
224224
expect(result.attrs.lockMode).toBe('unlocked');
225225
});
226226
});
227+
228+
describe('controlType detection', () => {
229+
const detectFrom = (sdtPrElements) => {
230+
const node = createNode(sdtPrElements, [{ name: 'w:r', text: 'content' }]);
231+
const params = { nodes: [node], nodeListHandler: mockNodeListHandler };
232+
parseAnnotationMarks.mockReturnValue({ marks: [] });
233+
return handleStructuredContentNode(params).attrs.controlType;
234+
};
235+
236+
it('detects explicit <w:text/> as "text"', () => {
237+
expect(detectFrom([{ name: 'w:text' }])).toBe('text');
238+
});
239+
240+
it('detects explicit <w:richText/> as "richText"', () => {
241+
expect(detectFrom([{ name: 'w:richText' }])).toBe('richText');
242+
});
243+
244+
it('resolves typeless sdtPr (only property children) as "richText" per ECMA-376 §17.5.2.26', () => {
245+
// Real-world case: Word emits this for ContentControls.Add(0, range) and
246+
// ContentControls.Add($null, range). The sdtPr carries only properties
247+
// (alias/tag/id/placeholder), no type-axis child.
248+
const propsOnly = [
249+
{ name: 'w:alias', attributes: { 'w:val': 'Field' } },
250+
{ name: 'w:tag', attributes: { 'w:val': 'x' } },
251+
{ name: 'w:id', attributes: { 'w:val': '123' } },
252+
{ name: 'w:placeholder', elements: [{ name: 'w:docPart', attributes: { 'w:val': 'default' } }] },
253+
];
254+
expect(detectFrom(propsOnly)).toBe('richText');
255+
});
256+
257+
it('detects <w:date/> as "date"', () => {
258+
expect(detectFrom([{ name: 'w:date' }])).toBe('date');
259+
});
260+
261+
it('detects <w14:checkbox/> as "checkbox"', () => {
262+
expect(detectFrom([{ name: 'w14:checkbox' }])).toBe('checkbox');
263+
});
264+
265+
it('detects <w:comboBox/> as "comboBox"', () => {
266+
expect(detectFrom([{ name: 'w:comboBox' }])).toBe('comboBox');
267+
});
268+
269+
it('detects <w:dropDownList/> as "dropDownList"', () => {
270+
expect(detectFrom([{ name: 'w:dropDownList' }])).toBe('dropDownList');
271+
});
272+
273+
it('detects <w15:repeatingSection/> as "repeatingSection"', () => {
274+
expect(detectFrom([{ name: 'w15:repeatingSection' }])).toBe('repeatingSection');
275+
});
276+
277+
it('detects <w:group/> as "group"', () => {
278+
expect(detectFrom([{ name: 'w:group' }])).toBe('group');
279+
});
280+
281+
it('returns null for unmodeled type children so resolveControlType coerces to "unknown"', () => {
282+
// detectControlType returns null for unmodeled type elements; resolveControlType
283+
// (in sdt-info-builder.ts) coerces null → 'unknown' downstream. Verifies that
284+
// 'unknown' keeps its semantics: "unsupported or unrecognized type child",
285+
// not "typeless rich-text control".
286+
expect(detectFrom([{ name: 'w:bibliography' }])).toBeNull();
287+
expect(detectFrom([{ name: 'w:citation' }])).toBeNull();
288+
expect(detectFrom([{ name: 'w:equation' }])).toBeNull();
289+
expect(detectFrom([{ name: 'w:picture' }])).toBeNull();
290+
expect(detectFrom([{ name: 'w:docPartList' }])).toBeNull();
291+
});
292+
});
227293
});

packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/translate-structured-content.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export function translateStructuredContent(params) {
4040
/** Maps control types to their sdtPr element names for OOXML export. */
4141
const CONTROL_TYPE_ELEMENT_MAP = {
4242
text: 'w:text',
43+
richText: 'w:richText',
4344
date: 'w:date',
4445
checkbox: 'w14:checkbox',
4546
comboBox: 'w:comboBox',

packages/super-editor/src/editors/v1/document-api-adapters/helpers/content-controls/sdt-info-builder.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { findSdtPrChild, getSdtPrChildAttrs, type SdtPrElement } from './sdt-pro
2323

2424
const VALID_CONTROL_TYPES: readonly string[] = [
2525
'text',
26+
'richText',
2627
'date',
2728
'checkbox',
2829
'comboBox',

packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -626,6 +626,7 @@ function setLockModeWrapper(
626626
/** Maps control types to their sdtPr element name. Types not listed have no element. */
627627
const CONTROL_TYPE_SDT_PR_ELEMENTS: Record<string, string> = {
628628
text: 'w:text',
629+
richText: 'w:richText',
629630
date: 'w:date',
630631
checkbox: 'w14:checkbox',
631632
comboBox: 'w:comboBox',
@@ -785,6 +786,8 @@ function buildDefaultTypeSdtPrElement(controlType: string | undefined): SdtPrEle
785786
switch (controlType) {
786787
case 'text':
787788
return { name: 'w:text', type: 'element' };
789+
case 'richText':
790+
return { name: 'w:richText', type: 'element' };
788791
case 'date':
789792
return {
790793
name: 'w:date',

0 commit comments

Comments
 (0)