diff --git a/packages/document-api/src/content-controls/content-controls.types.ts b/packages/document-api/src/content-controls/content-controls.types.ts index c1bf6570fc..a8bb6f1c37 100644 --- a/packages/document-api/src/content-controls/content-controls.types.ts +++ b/packages/document-api/src/content-controls/content-controls.types.ts @@ -124,10 +124,39 @@ export interface RepeatingSectionControlProperties { export interface ContentControlProperties { tag?: string; alias?: string; + /** + * Visual chrome behavior (``). + * + * Returned verbatim from the imported XML. When the source omits + * the element, this field is `undefined` — NOT silently set to + * `boundingBox`. Word's effective default when the element is + * absent is `boundingBox`, but consumers building UI on top of + * appearance (e.g. deciding whether to draw chrome) must apply + * that default themselves; the API does not fabricate it. + * + * Contract: + * - `'boundingBox'` → explicit; show chrome + * - `'tags'` → explicit; show tag markers + * - `'hidden'` → explicit; render transparently + * - `undefined` → source XML omitted the element; treat as + * Word's effective default (`'boundingBox'`). + */ appearance?: ContentControlAppearance; color?: string; placeholder?: string; showingPlaceholder?: boolean; + /** + * `` toggle (ECMA-376 §17.5.2.43). + * + * When enabled, Word treats the content control as temporary and may + * remove the SDT wrapper after the user edits/fills the control. + * + * Returned verbatim from the imported XML: + * - `true` → element present (`` or `w:val="true"`/`"1"`) + * - `false` → element present with `w:val="false"`/`"0"` + * - `undefined` → element absent in source; treat as Word's + * effective default (`false`). + */ temporary?: boolean; tabIndex?: number; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.js index a0154806cf..c3d0232104 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.js @@ -1,4 +1,5 @@ import { parseAnnotationMarks } from './handle-annotation-node'; +import { parseStrictStOnOff } from '../../../utils.js'; /** * Detect the semantic control type from sdtPr child elements. @@ -56,6 +57,24 @@ function extractPlaceholder(sdtPr) { return docPart?.attributes?.['w:val'] ?? null; } +/** + * Extract the `` toggle from sdtPr (ECMA-376 §17.5.2.43). + * + * Delegates to `parseStrictStOnOff` so token recognition matches the + * project's shared ST_OnOff convention (`true`/`1`/`on` → true; + * `false`/`0`/`off` → false). Returns `undefined` when the element is + * absent or carries an invalid token, preserving the "absent vs explicit + * false" distinction at the Document API surface. + * + * @param {Object|null} sdtPr + * @returns {boolean|undefined} + */ +function extractTemporary(sdtPr) { + const el = sdtPr?.elements?.find((e) => e.name === 'w:temporary'); + if (!el) return undefined; + return parseStrictStOnOff(el.attributes?.['w:val'], 'temporary', 'w:temporary'); +} + /** * @param {Object} params * @returns {Object|null} @@ -84,9 +103,10 @@ export function handleStructuredContentNode(params) { // Control type detection from sdtPr children const controlType = detectControlType(sdtPr); - // Appearance and placeholder + // Appearance, placeholder, and temporary toggle const appearance = extractAppearance(sdtPr); const placeholder = extractPlaceholder(sdtPr); + const temporary = extractTemporary(sdtPr); if (!sdtContent) { return null; @@ -117,6 +137,10 @@ export function handleStructuredContentNode(params) { type: controlType, appearance, placeholder, + // `temporary` is only set when the XML carries ``; + // omitted attrs stay undefined so consumers can distinguish + // "absent from source" from explicit false. + ...(temporary !== undefined ? { temporary } : {}), sdtPr, }, }; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.test.js index e2bad14387..5f32162789 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.test.js @@ -225,6 +225,64 @@ describe('handleStructuredContentNode', () => { }); }); + describe('w:temporary parsing (SD-3111)', () => { + const parseTemporary = (sdtPrElements) => { + const node = createNode(sdtPrElements, [{ name: 'w:r', text: 'content' }]); + const params = { nodes: [node], nodeListHandler: mockNodeListHandler }; + parseAnnotationMarks.mockReturnValue({ marks: [] }); + return handleStructuredContentNode(params).attrs.temporary; + }; + + it('reads as true (empty toggle)', () => { + expect(parseTemporary([{ name: 'w:temporary' }])).toBe(true); + }); + + it('reads as true', () => { + expect(parseTemporary([{ name: 'w:temporary', attributes: { 'w:val': 'true' } }])).toBe(true); + }); + + it('reads as true', () => { + expect(parseTemporary([{ name: 'w:temporary', attributes: { 'w:val': '1' } }])).toBe(true); + }); + + it('reads as false', () => { + expect(parseTemporary([{ name: 'w:temporary', attributes: { 'w:val': 'false' } }])).toBe(false); + }); + + it('reads as false', () => { + expect(parseTemporary([{ name: 'w:temporary', attributes: { 'w:val': '0' } }])).toBe(false); + }); + + it('reads as true (ST_OnOff alias)', () => { + expect(parseTemporary([{ name: 'w:temporary', attributes: { 'w:val': 'on' } }])).toBe(true); + }); + + it('reads as false (ST_OnOff alias)', () => { + // Without going through the shared ST_OnOff set this would + // incorrectly fall through to true. See utils.js parseStrictStOnOff. + expect(parseTemporary([{ name: 'w:temporary', attributes: { 'w:val': 'off' } }])).toBe(false); + }); + + it('returns undefined for invalid w:val tokens (parser rejects unknown tokens)', () => { + expect(parseTemporary([{ name: 'w:temporary', attributes: { 'w:val': 'banana' } }])).toBeUndefined(); + }); + + it('returns undefined (not false) when is absent', () => { + // Spec contract: absent in source XML stays undefined so consumers + // can distinguish "Word's effective default" from "explicit false". + expect(parseTemporary([])).toBeUndefined(); + expect(parseTemporary([{ name: 'w:tag', attributes: { 'w:val': 'unrelated' } }])).toBeUndefined(); + }); + + it('does not stamp temporary on attrs when absent (preserves "undefined" semantics)', () => { + const node = createNode([], [{ name: 'w:r', text: 'content' }]); + const params = { nodes: [node], nodeListHandler: mockNodeListHandler }; + parseAnnotationMarks.mockReturnValue({ marks: [] }); + const result = handleStructuredContentNode(params); + expect('temporary' in result.attrs).toBe(false); + }); + }); + describe('controlType detection', () => { const detectFrom = (sdtPrElements) => { const node = createNode(sdtPrElements, [{ name: 'w:r', text: 'content' }]);