diff --git a/packages/super-editor/src/editors/v1/core/commands/insertContentAt.js b/packages/super-editor/src/editors/v1/core/commands/insertContentAt.js index fecd1618b4..3e7210f098 100644 --- a/packages/super-editor/src/editors/v1/core/commands/insertContentAt.js +++ b/packages/super-editor/src/editors/v1/core/commands/insertContentAt.js @@ -17,14 +17,15 @@ const isFragment = (nodeOrFragment) => { /** * Checks if a string looks like it contains HTML tags. - * Matches complete tag pairs (e.g.,
...
) or self-closing tags (e.g.,
, ). + * Detects a tag (tag pairs or self-closing tags) anywhere in the string. + * * @param {string} str * @returns {boolean} */ const looksLikeHTML = (str) => - /^\s*<[a-zA-Z][^>]*>.*<\/[a-zA-Z][^>]*>\s*$/s.test(str) || // Complete tag pair - /^\s*<[a-zA-Z][^>]*\/>\s*$/.test(str) || // Self-closing tag - /^\s*<(br|hr|img|input|meta|link|area|base|col|embed|param|source|track|wbr)\b[^>]*>\s*$/i.test(str); // Void elements + /<[a-zA-Z][^>]*>[\s\S]*?<\/[a-zA-Z][^>]*>/.test(str) || // Tag pair somewhere (incl. inline formatting inside text) + /<[a-zA-Z][^>]*\/>/.test(str) || // Self-closing tag somewhere + /<(br|hr|img|input|meta|link|area|base|col|embed|param|source|track|wbr)\b[^>]*>/i.test(str); // Void element somewhere /** * Inserts content at the specified position. diff --git a/packages/super-editor/src/editors/v1/core/commands/insertContentAt.test.js b/packages/super-editor/src/editors/v1/core/commands/insertContentAt.test.js index c68c84a788..8f7e3ad0c2 100644 --- a/packages/super-editor/src/editors/v1/core/commands/insertContentAt.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/insertContentAt.test.js @@ -240,4 +240,40 @@ describe('insertContentAt', () => { // empty textblock wrapper replacement [from-1, to+1] expect(tr.replaceWith).toHaveBeenCalledWith(9, 11, blockNode); }); + + it('parses inline HTML mixed with text via the HTML path (not literal insertText)', () => { + const value = '☒ yes ☐ no'; + + createNodeFromContent.mockImplementation(() => [ + { isText: true, isBlock: false, marks: [], check: vi.fn() }, + { isText: true, isBlock: false, marks: [{ type: 'bold' }], check: vi.fn() }, + { isText: true, isBlock: false, marks: [], check: vi.fn() }, + ]); + + const tr = makeTr(); + const editor = makeEditor(); + + const cmd = insertContentAt(5, value, { updateSelection: true }); + const result = cmd({ tr, dispatch: true, editor }); + + expect(result).toBe(true); + expect(createNodeFromContent).toHaveBeenCalled(); // took the HTML path + expect(tr.replaceWith).toHaveBeenCalled(); + expect(tr.insertText).not.toHaveBeenCalledWith(value, 5, 5); // not inserted verbatim + }); + + it('treats prose with stray angle brackets as plain text', () => { + const value = 'For all x, 5 < 10 and 20 > 3'; + + const tr = makeTr(); + const editor = makeEditor(); + + const cmd = insertContentAt(5, value, { updateSelection: true }); + const result = cmd({ tr, dispatch: true, editor }); + + expect(result).toBe(true); + expect(createNodeFromContent).not.toHaveBeenCalled(); // fast path, no HTML parsing + expect(tr.insertText).toHaveBeenCalledWith(value, 5, 5); + expect(tr.replaceWith).not.toHaveBeenCalled(); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/hyperlink-preprocessor.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/hyperlink-preprocessor.js index c7d9943867..6b9155e973 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/hyperlink-preprocessor.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/hyperlink-preprocessor.js @@ -64,8 +64,84 @@ export function resolveHyperlinkAttributes(instruction, docx) { } /** - * Processes a HYPERLINK instruction and creates a `w:hyperlink` node. - * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The nodes to combine. + * Slips a `` *inside* one paragraph, wrapping just its visible runs. + * + * @param {import('../../v2/types/index.js').OpenXmlNode} paragraph + * @param {Record} linkAttributes + * @returns {import('../../v2/types/index.js').OpenXmlNode} A copy of the paragraph with its runs wrapped. + */ +function wrapParagraphRunsInHyperlink(paragraph, linkAttributes) { + const children = Array.isArray(paragraph.elements) ? paragraph.elements : []; + const wrappedChildren = []; + let pendingRuns = null; + + const flushPendingRuns = () => { + if (!pendingRuns || pendingRuns.length === 0) { + pendingRuns = null; + return; + } + wrappedChildren.push({ + name: 'w:hyperlink', + type: 'element', + attributes: { ...linkAttributes }, + elements: pendingRuns, + }); + pendingRuns = null; + }; + + for (const child of children) { + if (child?.name === 'w:r') { + if (!pendingRuns) pendingRuns = []; + pendingRuns.push(child); + continue; + } + // Paragraph properties (w:pPr) and any other structural child must stay a + // direct child of the paragraph, outside the inline hyperlink. + flushPendingRuns(); + wrappedChildren.push(child); + } + flushPendingRuns(); + + return { ...paragraph, elements: wrappedChildren }; +} + +/** + * Turns a HYPERLINK field code into real `` tag(s). + * + * Old Word docs don't store a link as one tidy tag. They store "plumbing": a + * `begin` marker, the URL instruction, a `separate` marker, the visible text, + * then an `end` marker. By the time we get here that plumbing has been stripped + * and `nodesToCombine` holds just the visible content the link should cover. Our + * job is to wrap that content in a ``. + * + * Common case — the whole link lives on one line, so `nodesToCombine` is just + * inline runs. One `` around all of them is correct: + * + * CSP - 1 + * + * Cross-paragraph case — the plumbing started on one line and finished on the + * next (very common when a link fills a table cell), so `nodesToCombine` holds + * whole `` blocks, not loose runs. A `` is *inline* (like + * ``): it lives inside a line of text and cannot contain a ``. Wrapping + * the paragraphs in one hyperlink... + * + * <-- paragraphs can't live inside an inline tag + * CSP - 1 + * Data in transit + * + * + * ...makes the importer throw the paragraphs away (it only reads runs out of a + * hyperlink), which empties the table cell. An empty cell breaks the tableCell + * `block+` schema and aborts the whole document load. + * + * So for the cross-paragraph case we keep each paragraph and drop a *separate* + * `` inside each one, all pointing at the same target — exactly how + * Word itself writes a link that spans more than one line: + * + * CSP - 1 + * Data in transit + * + * @param {import('../../v2/types/index.js').OpenXmlNode[]} nodesToCombine The visible content the link should cover. * @param {string} instruction The instruction text. * @param {{ docx?: import('../../v2/docxHelper').ParsedDocx }} [options] * @returns {import('../../v2/types/index.js').OpenXmlNode[]} @@ -75,12 +151,30 @@ export function preProcessHyperlinkInstruction(nodesToCombine, instruction, opti const docx = options.docx; const linkAttributes = resolveHyperlinkAttributes(instruction, docx) ?? {}; - return [ - { + // A `` in the gathered content means the link spans a paragraph break, so + // we can't use the simple "one hyperlink around everything" shape. + const spansParagraphBoundary = nodesToCombine.some((node) => node?.name === 'w:p'); + if (!spansParagraphBoundary) { + return [ + { + name: 'w:hyperlink', + type: 'element', + attributes: linkAttributes, + elements: nodesToCombine, + }, + ]; + } + + return nodesToCombine.map((node) => { + if (node?.name === 'w:p') { + return wrapParagraphRunsInHyperlink(node, linkAttributes); + } + // A loose run sitting between paragraphs still gets its own inline link. + return { name: 'w:hyperlink', type: 'element', - attributes: linkAttributes, - elements: nodesToCombine, - }, - ]; + attributes: { ...linkAttributes }, + elements: [node], + }; + }); } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/hyperlink-preprocessor.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/hyperlink-preprocessor.test.js index 44bbc626e7..eba58852b2 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/hyperlink-preprocessor.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/fld-preprocessors/hyperlink-preprocessor.test.js @@ -181,4 +181,97 @@ describe('preProcessHyperlinkInstruction', () => { }, ]); }); + + it('wraps runs per paragraph when the field spans a paragraph boundary', () => { + // A field whose collected content crosses a paragraph break yields whole + // blocks. Because is inline and cannot wrap a or + // span the break, Word emits one per paragraph sharing the + // same target. The paragraphs must survive as blocks so a table cell keeps + // its required block content. + const instruction = 'HYPERLINK "http://example.com"'; + const mockDocx = { + 'word/_rels/document.xml.rels': { + elements: [{ name: 'Relationships', elements: [] }], + }, + }; + + const crossParagraphNodes = [ + { + name: 'w:p', + elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'first ' }] }] }], + }, + { + name: 'w:p', + elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'second' }] }] }], + }, + ]; + + const result = preProcessHyperlinkInstruction(crossParagraphNodes, instruction, { docx: mockDocx }); + + expect(result).toEqual([ + { + name: 'w:p', + elements: [ + { + name: 'w:hyperlink', + type: 'element', + attributes: { 'r:id': 'rIdabc12345' }, + elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'first ' }] }] }], + }, + ], + }, + { + name: 'w:p', + elements: [ + { + name: 'w:hyperlink', + type: 'element', + attributes: { 'r:id': 'rIdabc12345' }, + elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'second' }] }] }], + }, + ], + }, + ]); + }); + + it('keeps paragraph properties outside the per-paragraph hyperlink', () => { + // is a paragraph-level property, not visible content, so it must stay + // a direct child of the paragraph rather than being pulled into the inline + // alongside the runs. + const instruction = 'HYPERLINK "http://example.com"'; + const mockDocx = { + 'word/_rels/document.xml.rels': { + elements: [{ name: 'Relationships', elements: [] }], + }, + }; + + const nodes = [ + { + name: 'w:p', + elements: [ + { name: 'w:pPr', elements: [{ name: 'w:jc', attributes: { 'w:val': 'center' } }] }, + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'only' }] }] }, + ], + }, + { + name: 'w:p', + elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'tail' }] }] }], + }, + ]; + + const result = preProcessHyperlinkInstruction(nodes, instruction, { docx: mockDocx }); + + expect(result[0]).toEqual({ + name: 'w:p', + elements: [ + { name: 'w:pPr', elements: [{ name: 'w:jc', attributes: { 'w:val': 'center' } }] }, + { + name: 'w:hyperlink', + type: 'element', + attributes: { 'r:id': 'rIdabc12345' }, + elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'only' }] }] }], + }, + ], + }); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js index 309a19adbc..377a778bea 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/field-references/preProcessNodesForFldChar.test.js @@ -487,14 +487,17 @@ describe('preProcessNodesForFldChar', () => { const { processedNodes } = preProcessNodesForFldChar(nodes, mockDocx); + // The field envelope crosses into a paragraph, so the collected content is a + // . A is inline and cannot wrap a , so the link goes + // inside the paragraph around its runs — never around the paragraph itself. expect(processedNodes).toEqual([ { - name: 'w:hyperlink', - type: 'element', - attributes: { 'r:id': 'rIdabc12345' }, + name: 'w:p', elements: [ { - name: 'w:p', + name: 'w:hyperlink', + type: 'element', + attributes: { 'r:id': 'rIdabc12345' }, elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'link text' }] }] }], }, ], @@ -531,24 +534,22 @@ describe('preProcessNodesForFldChar', () => { const { processedNodes } = preProcessNodesForFldChar(nodes, mockDocx); + // The collected content is a , so the paragraph is preserved as a block + // rather than being wrapped in an inline (which would drop it). + // The visible run lives inside a wrapper, not directly under the + // paragraph, so it is kept intact; the link is applied only to runs that are + // direct paragraph children. expect(processedNodes).toEqual([ { - name: 'w:hyperlink', - type: 'element', - attributes: { 'r:id': 'rIdabc12345' }, + name: 'w:p', elements: [ { - name: 'w:p', + name: 'w:sdt', elements: [ { - name: 'w:sdt', + name: 'w:sdtContent', elements: [ - { - name: 'w:sdtContent', - elements: [ - { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'link text' }] }] }, - ], - }, + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'link text' }] }] }, ], }, ], diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/checkbox-set-state-visible-glyph.integration.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/checkbox-set-state-visible-glyph.integration.test.ts new file mode 100644 index 0000000000..a72f4b76df --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/checkbox-set-state-visible-glyph.integration.test.ts @@ -0,0 +1,56 @@ +/* @vitest-environment jsdom */ + +/** + * End-to-end coverage for `checkbox.setState`: it must update a block-scope + * checkbox's VISIBLE glyph, not just the stored `w14:checked` value. + * + * A block-scope checkbox is an SDT whose `sdtContent` wraps a `` carrying + * the glyph (e.g. stacked Yes/No checkboxes in a table cell). The inline path + * swaps the glyph via `updateStructuredContentById`, but that builds inline text + * JSON the block schema rejects — so for block SDTs the glyph must be rewritten + * through the content range directly. The unit test in + * `content-controls-wrappers.test.ts` pins the wrapper-level behavior with a + * mock; this test drives the real import → mutate → read pipeline against the + * `block_checkbox_control.docx` fixture to confirm the glyph swaps end-to-end + * and to guard against regressions. + */ + +import { describe, expect, it } from 'vitest'; +import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; + +const UNCHECKED = '☐'; // U+2610 +const CHECKED = '☒'; // U+2612 + +describe('checkbox.setState updates the visible glyph (block scope)', () => { + it('swaps the rendered glyph ☐ -> ☒ for a block-scope checkbox', async () => { + const docData = await loadTestDataForEditorTests('block_checkbox_control.docx'); + const { editor } = initTestEditor({ + content: docData.docx, + media: docData.media, + mediaFiles: docData.mediaFiles, + fonts: docData.fonts, + isHeadless: true, + user: { name: 'Test', email: 'test@example.com' }, + }); + + // The fixture starts unchecked. + const before = await Promise.resolve(editor.doc.contentControls.list()); + const checkboxBefore = before.items.find((item) => item.controlType === 'checkbox'); + expect(checkboxBefore).toBeDefined(); + expect(checkboxBefore?.text).toContain(UNCHECKED); + + const result = await Promise.resolve( + editor.doc.contentControls.checkbox.setState( + { target: checkboxBefore!.target, checked: true }, + { changeMode: 'direct' }, + ), + ); + expect(result.success).toBe(true); + + // Re-read: the rendered glyph should now be checked, not the empty box. + const after = await Promise.resolve(editor.doc.contentControls.list()); + const checkboxAfter = after.items.find((item) => item.controlType === 'checkbox'); + expect(checkboxAfter?.text).toContain(CHECKED); + expect(checkboxAfter?.text).not.toContain(UNCHECKED); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/choice-list-set-selected-visible-text.integration.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/choice-list-set-selected-visible-text.integration.test.ts new file mode 100644 index 0000000000..35c4bb2591 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/choice-list-set-selected-visible-text.integration.test.ts @@ -0,0 +1,50 @@ +/* @vitest-environment jsdom */ + +/** + * End-to-end coverage for `choiceList.setSelected`: it must update a block-scope + * dropdown's VISIBLE text, not just the stored `w:lastValue`. + * + * A block-scope dropdown is an SDT whose `sdtContent` wraps a `` carrying + * the currently displayed option (e.g. a "Yes/No" dropdown sitting on its own + * line). + */ + +import { describe, expect, it } from 'vitest'; +import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; + +const PLACEHOLDER = 'Select an item.'; +const SELECTED = 'Yes'; + +describe('choiceList.setSelected updates the visible text (block scope)', () => { + it('replaces the placeholder text with the selected option display text', async () => { + const docData = await loadTestDataForEditorTests('block_dropdown_control.docx'); + const { editor } = initTestEditor({ + content: docData.docx, + media: docData.media, + mediaFiles: docData.mediaFiles, + fonts: docData.fonts, + isHeadless: true, + user: { name: 'Test', email: 'test@example.com' }, + }); + + // The fixture starts on the placeholder option. + const before = await Promise.resolve(editor.doc.contentControls.list()); + const dropdownBefore = before.items.find((item) => item.controlType === 'dropDownList'); + expect(dropdownBefore).toBeDefined(); + expect(dropdownBefore?.text).toContain(PLACEHOLDER); + + const result = await Promise.resolve( + editor.doc.contentControls.choiceList.setSelected( + { target: dropdownBefore!.target, value: SELECTED }, + { changeMode: 'direct' }, + ), + ); + expect(result.success).toBe(true); + + // Re-read: the rendered text should now be the selected option, not the placeholder. + const after = await Promise.resolve(editor.doc.contentControls.list()); + const dropdownAfter = after.items.find((item) => item.controlType === 'dropDownList'); + expect(dropdownAfter?.text).toContain(SELECTED); + expect(dropdownAfter?.text).not.toContain(PLACEHOLDER); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.test.ts index a9fadb6833..6050513551 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.test.ts @@ -1119,9 +1119,9 @@ describe('buildContentControlInfoFromAttrs completeness', () => { }); }); -describe('choiceList.setSelected visual text sync', () => { +describe('choiceList.setSelected visual text sync (inline scope)', () => { it('updates visible content text to the selected item displayText', () => { - const editor = makeSdtEditor({ + const editor = makeInlineSdtEditor({ controlType: 'dropDownList', type: 'dropDownList', sdtPr: { @@ -1140,7 +1140,10 @@ describe('choiceList.setSelected visual text sync', () => { }); const adapter = createContentControlsAdapter(editor); - const result = adapter.choiceList.setSelected({ target: SDT_TARGET, value: 'acme' }, { changeMode: 'direct' }); + const result = adapter.choiceList.setSelected( + { target: INLINE_SDT_TARGET, value: 'acme' }, + { changeMode: 'direct' }, + ); expect(result.success).toBe(true); const updateCmd = editor.commands!.updateStructuredContentById as ReturnType; @@ -1149,7 +1152,7 @@ describe('choiceList.setSelected visual text sync', () => { }); it('falls back to the selected value when no matching item is found', () => { - const editor = makeSdtEditor({ + const editor = makeInlineSdtEditor({ controlType: 'dropDownList', type: 'dropDownList', sdtPr: { @@ -1167,7 +1170,10 @@ describe('choiceList.setSelected visual text sync', () => { }); const adapter = createContentControlsAdapter(editor); - const result = adapter.choiceList.setSelected({ target: SDT_TARGET, value: 'unknown' }, { changeMode: 'direct' }); + const result = adapter.choiceList.setSelected( + { target: INLINE_SDT_TARGET, value: 'unknown' }, + { changeMode: 'direct' }, + ); expect(result.success).toBe(true); const updateCmd = editor.commands!.updateStructuredContentById as ReturnType; @@ -1176,6 +1182,181 @@ describe('choiceList.setSelected visual text sync', () => { }); }); +describe('choiceList.setSelected visual text sync (block scope)', () => { + // Build a block-scope dropdown control (sdtContent wraps a paragraph carrying + // the displayed option), as produced by a "Yes/No" dropdown on its own line. + function makeBlockDropdownEditor() { + return makeSdtEditor( + { + controlType: 'dropDownList', + type: 'dropDownList', + sdtPr: { + name: 'w:sdtPr', + elements: [ + { + name: 'w:dropDownList', + type: 'element', + elements: [ + { name: 'w:listItem', type: 'element', attributes: { 'w:displayText': 'Yes', 'w:value': 'Yes' } }, + { name: 'w:listItem', type: 'element', attributes: { 'w:displayText': 'No', 'w:value': 'No' } }, + ], + }, + ], + }, + }, + [createParagraphNode('Select an item.')], + ); + } + + // setSelected must rewrite the SDT's visible text for block-scope dropdowns, + // not only w:lastValue; otherwise the option never changes on screen. The + // block path can't use updateStructuredContentById (it builds inline text JSON + // the block schema rejects, rolling back the whole transaction incl. the + // w:lastValue write), so the rewrite surfaces as a tr.replaceWith of the inner + // range. Mirror the checkbox block-scope tests. + it('rewrites the visible text for block-scope dropdowns, not just w:lastValue', () => { + const editor = makeBlockDropdownEditor(); + const adapter = createContentControlsAdapter(editor); + + const result = adapter.choiceList.setSelected({ target: SDT_TARGET, value: 'Yes' }, { changeMode: 'direct' }); + + expect(result.success).toBe(true); + expect((editor.state.tr as any).replaceWith).toHaveBeenCalledTimes(1); + }); + + it('still writes w:lastValue to the dropdown sdtPr child', () => { + const editor = makeBlockDropdownEditor(); + const adapter = createContentControlsAdapter(editor); + + adapter.choiceList.setSelected({ target: SDT_TARGET, value: 'Yes' }, { changeMode: 'direct' }); + + const setAttr = (editor.state.tr as any).setNodeAttribute as ReturnType; + const sdtPrCall = setAttr.mock.calls.find((call: any[]) => call[1] === 'sdtPr'); + expect(sdtPrCall).toBeDefined(); + const writtenSdtPr = sdtPrCall?.[2] as { elements?: Array<{ name: string; attributes?: Record }> }; + const dropdownEl = writtenSdtPr?.elements?.find((el) => el.name === 'w:dropDownList'); + expect(dropdownEl?.attributes?.['w:lastValue']).toBe('Yes'); + }); +}); + +describe('date.setValue visual text sync', () => { + // Build a block date control whose visible content is the Word placeholder + // ("Click or tap to enter a date."), mirroring the date_control.docx fixture. + function makeDateControlEditor() { + return makeSdtEditor( + { + controlType: 'date', + type: 'date', + sdtPr: { + name: 'w:sdtPr', + elements: [ + { + name: 'w:date', + type: 'element', + attributes: {}, + elements: [{ name: 'w:dateFormat', type: 'element', attributes: { 'w:val': 'dd/MM/yyyy' } }], + }, + ], + }, + }, + [createParagraphNode('Click or tap to enter a date.')], + ); + } + + // setValue must rewrite the SDT's visible content range, not only w:fullDate + // (the stored value); otherwise the control keeps showing its placeholder. + // Surfaced here as a tr.replaceWith. + it('rewrites the visible content range so the rendered date updates, not just w:fullDate', () => { + const editor = makeDateControlEditor(); + const adapter = createContentControlsAdapter(editor); + + const result = adapter.date.setValue({ target: SDT_TARGET, value: '2026-05-24' }, { changeMode: 'direct' }); + + expect(result.success).toBe(true); + expect((editor.state.tr as any).replaceWith).toHaveBeenCalledTimes(1); + }); + + it('still writes w:fullDate to the w:date sdtPr child (stored value)', () => { + const editor = makeDateControlEditor(); + const adapter = createContentControlsAdapter(editor); + + adapter.date.setValue({ target: SDT_TARGET, value: '2026-05-24' }, { changeMode: 'direct' }); + + // Metadata writes flow through tr.setNodeAttribute (AttrStep) as a full sdtPr replace. + const setAttr = (editor.state.tr as any).setNodeAttribute as ReturnType; + const sdtPrCall = setAttr.mock.calls.find((call: any[]) => call[1] === 'sdtPr'); + expect(sdtPrCall).toBeDefined(); + const writtenSdtPr = sdtPrCall?.[2] as { elements?: Array<{ name: string; attributes?: Record }> }; + const dateEl = writtenSdtPr?.elements?.find((el) => el.name === 'w:date'); + expect(dateEl?.attributes?.['w:fullDate']).toBe('2026-05-24'); + }); +}); + +describe('checkbox.setState visual glyph sync (block scope)', () => { + // Build a block-scope checkbox control (sdtContent wraps a paragraph carrying + // the glyph), as produced by stacked Yes/No checkboxes inside a table cell. + function makeBlockCheckboxEditor() { + return makeSdtEditor( + { + controlType: 'checkbox', + type: 'checkbox', + sdtPr: { + name: 'w:sdtPr', + elements: [ + { + name: 'w14:checkbox', + type: 'element', + elements: [ + { name: 'w14:checked', type: 'element', attributes: { 'w14:val': '0' } }, + { + name: 'w14:checkedState', + type: 'element', + attributes: { 'w14:val': '2612', 'w14:font': 'MS Gothic' }, + }, + { + name: 'w14:uncheckedState', + type: 'element', + attributes: { 'w14:val': '2610', 'w14:font': 'MS Gothic' }, + }, + ], + }, + ], + }, + }, + [createParagraphNode('☐')], + ); + } + + // setState must rewrite the SDT's visible glyph for block-scope checkboxes, not + // only w14:checked; otherwise the box never swaps ☐ -> ☒. The block path can't + // use updateStructuredContentById (it builds inline text JSON the block schema + // rejects), so the rewrite surfaces as a tr.replaceWith of the inner range. + it('rewrites the visible glyph for block-scope checkboxes, not just w14:checked', () => { + const editor = makeBlockCheckboxEditor(); + const adapter = createContentControlsAdapter(editor); + + const result = adapter.checkbox.setState({ target: SDT_TARGET, checked: true }, { changeMode: 'direct' }); + + expect(result.success).toBe(true); + expect((editor.state.tr as any).replaceWith).toHaveBeenCalledTimes(1); + }); + + it('still writes w14:checked to the checkbox sdtPr child', () => { + const editor = makeBlockCheckboxEditor(); + const adapter = createContentControlsAdapter(editor); + + adapter.checkbox.setState({ target: SDT_TARGET, checked: true }, { changeMode: 'direct' }); + + const setAttr = (editor.state.tr as any).setNodeAttribute as ReturnType; + const sdtPrCall = setAttr.mock.calls.find((call: any[]) => call[1] === 'sdtPr'); + expect(sdtPrCall).toBeDefined(); + const writtenSdtPr = sdtPrCall?.[2] as { elements?: Array<{ name: string; elements?: any[] }> }; + const checkboxEl = writtenSdtPr?.elements?.find((el) => el.name === 'w14:checkbox'); + const checkedEl = checkboxEl?.elements?.find((el: any) => el.name === 'w14:checked'); + expect(checkedEl?.attributes?.['w14:val']).toBe('1'); + }); +}); + describe('create.contentControl default sdtPr seeding', () => { it('seeds checkbox controls with checked state + symbol pair defaults', () => { const editor = makeSdtEditor(); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.ts index 4deabd771e..670b6ce30e 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.ts @@ -1333,12 +1333,19 @@ function dateSetValueWrapper( // w:fullDate is an attribute on w:date itself, not a sub-element return executeSdtMutation(editor, target, options, () => { - return updateSdtPrChild(editor, input.target, 'w:date', (existing) => ({ + // The stored value lives in w:sdtPr/w:date/@w:fullDate. + const metadataUpdated = updateSdtPrChild(editor, input.target, 'w:date', (existing) => ({ name: 'w:date', type: 'element', ...existing, attributes: { ...(existing?.attributes ?? {}), 'w:fullDate': input.value }, })); + // ...but updating w:fullDate alone leaves the SDT showing its placeholder + // ("Click or tap to enter a date.") forever. Mirror textSetValueWrapper and + // also rewrite the visible content so the rendered date actually changes. + const contentUpdated = replaceSdtTextContent(editor, input.target, input.value); + // Either step landing a change means the mutation succeeded. + return metadataUpdated || contentUpdated; }); } @@ -1445,6 +1452,13 @@ function checkboxSetStateWrapper( Boolean(updateCmd(input.target.nodeId, { text: symbol.char, keepTextNodeStyles: true })); return visualUpdated || checkboxUpdated; } + } else if (sdt.kind === 'block') { + // Block-scope checkboxes can't reuse the inline branch above: it feeds + // updateStructuredContentById a bare text node, which a block SDT's schema + // rejects (block content must be wrapped in a paragraph). + // Instead use replaceSdtTextContent to swap the glyph. + const visualUpdated = replaceSdtTextContent(editor, input.target, symbol.char); + return visualUpdated || checkboxUpdated; } return checkboxUpdated; @@ -1577,11 +1591,23 @@ function choiceListSetSelectedWrapper( if (!selectedUpdated) return false; // Keep the SDT body text in sync so the selected option is visible in-editor and after export. - const updateCmd = editor.commands?.updateStructuredContentById; - if (typeof updateCmd === 'function') { - const visualUpdated = Boolean( - updateCmd(input.target.nodeId, { text: selectedDisplayText, keepTextNodeStyles: true }), - ); + if (sdt.kind === 'inline') { + const updateCmd = editor.commands?.updateStructuredContentById; + if (typeof updateCmd === 'function') { + const visualUpdated = Boolean( + updateCmd(input.target.nodeId, { text: selectedDisplayText, keepTextNodeStyles: true }), + ); + return visualUpdated || selectedUpdated; + } + } else if (sdt.kind === 'block') { + // Block-scope dropdowns can't reuse the inline branch above: it feeds + // updateStructuredContentById a bare text node, which a block SDT's schema + // (block content must be wrapped in a paragraph) rejects. PM's content + // check then rolls back the whole transaction — including the w:lastValue + // update above — so the selection fails silently. Mirror + // checkboxSetStateWrapper and use replaceSdtTextContent, which wraps the + // display text in a paragraph. + const visualUpdated = replaceSdtTextContent(editor, input.target, selectedDisplayText); return visualUpdated || selectedUpdated; } diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/date-set-value-visible-text.integration.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/date-set-value-visible-text.integration.test.ts new file mode 100644 index 0000000000..bc3ae6b2b3 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/date-set-value-visible-text.integration.test.ts @@ -0,0 +1,54 @@ +/* @vitest-environment jsdom */ + +/** + * End-to-end coverage for `date.setValue`: it must update the SDT's VISIBLE + * text, not just the stored `w:fullDate` value. + * + * Writing `w:sdtPr/w:date/@w:fullDate` alone leaves the rendered content + * untouched, so a date control keeps showing its placeholder + * ("Click or tap to enter a date.") even though the OOXML value is correct. + * The unit test in `content-controls-wrappers.test.ts` pins the wrapper-level + * behavior with a mock; this test drives the real import → mutate → read + * pipeline against the `date_control.docx` fixture to confirm the behavior + * holds end-to-end and to guard against regressions. + */ + +import { describe, expect, it } from 'vitest'; +import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; + +const PLACEHOLDER = 'Click or tap to enter a date.'; +const NEW_DATE = '2026-05-24'; + +describe('date.setValue updates visible text', () => { + it('replaces the placeholder with the new value in the rendered date control', async () => { + const docData = await loadTestDataForEditorTests('date_control.docx'); + const { editor } = initTestEditor({ + content: docData.docx, + media: docData.media, + mediaFiles: docData.mediaFiles, + fonts: docData.fonts, + isHeadless: true, + user: { name: 'Test', email: 'test@example.com' }, + }); + + // Sanity check: the fixture starts out showing Word's date placeholder. + const before = await Promise.resolve(editor.doc.contentControls.list()); + const dateBefore = before.items.find((item) => item.controlType === 'date'); + expect(dateBefore).toBeDefined(); + expect(dateBefore?.text).toContain(PLACEHOLDER); + + const result = await Promise.resolve( + editor.doc.contentControls.date.setValue( + { target: dateBefore!.target, value: NEW_DATE }, + { changeMode: 'direct' }, + ), + ); + expect(result.success).toBe(true); + + // Re-read: the visible text should now be the date value, not the placeholder. + const after = await Promise.resolve(editor.doc.contentControls.list()); + const dateAfter = after.items.find((item) => item.controlType === 'date'); + expect(dateAfter?.text).toContain(NEW_DATE); + expect(dateAfter?.text).not.toContain(PLACEHOLDER); + }); +}); diff --git a/packages/super-editor/src/editors/v1/tests/data/block_checkbox_control.docx b/packages/super-editor/src/editors/v1/tests/data/block_checkbox_control.docx new file mode 100644 index 0000000000..d92152d40a Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/block_checkbox_control.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/block_dropdown_control.docx b/packages/super-editor/src/editors/v1/tests/data/block_dropdown_control.docx new file mode 100644 index 0000000000..2e71f606a6 Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/block_dropdown_control.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/date_control.docx b/packages/super-editor/src/editors/v1/tests/data/date_control.docx new file mode 100644 index 0000000000..9f2c7dbdbd Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/date_control.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/hyperlink-fldchar-across-paragraphs-in-cell.docx b/packages/super-editor/src/editors/v1/tests/data/hyperlink-fldchar-across-paragraphs-in-cell.docx new file mode 100644 index 0000000000..5f9e37e76f Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/hyperlink-fldchar-across-paragraphs-in-cell.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/regression/hyperlink-fldchar-across-paragraphs.test.js b/packages/super-editor/src/editors/v1/tests/regression/hyperlink-fldchar-across-paragraphs.test.js new file mode 100644 index 0000000000..6c39246d66 --- /dev/null +++ b/packages/super-editor/src/editors/v1/tests/regression/hyperlink-fldchar-across-paragraphs.test.js @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { loadTestDataForEditorTests, initTestEditor } from '@tests/helpers/helpers.js'; + +/** + * A legacy HYPERLINK field code (w:fldChar / w:instrText) can span a paragraph + * boundary: the field begins (begin -> instrText -> separate) in the first + * paragraph of a table cell and closes (end) in the second paragraph. A + * is inline and cannot wrap a , so the importer must emit one + * per paragraph rather than hoisting a single hyperlink that wraps + * both paragraphs. + */ +describe('HYPERLINK field code spanning a paragraph boundary inside a table cell', () => { + it('imports without a schema error and keeps the link on both paragraphs', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests( + 'hyperlink-fldchar-across-paragraphs-in-cell.docx', + ); + const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }); + + const json = editor.getJSON(); + const table = json.content.find((node) => node.type === 'table'); + expect(table).toBeDefined(); + + const cell = table.content[0].content[0]; + expect(cell.type).toBe('tableCell'); + + // The cross-paragraph field must survive as two separate paragraphs; the cell + // must never collapse to zero block children. + const paragraphs = cell.content.filter((node) => node.type === 'paragraph'); + expect(paragraphs).toHaveLength(2); + + // Every visible run from the field should carry the link mark pointing at the + // field's target URL. + const linkedText = []; + editor.state.doc.descendants((node) => { + if (!node.isText) return; + const linkMark = node.marks.find((mark) => mark.type.name === 'link'); + if (linkMark) { + linkedText.push(node.text); + expect(linkMark.attrs.href).toBe('https://example.com/data-in-transit'); + } + }); + + expect(linkedText.join('')).toContain('CSP - 1'); + expect(linkedText.join('')).toContain('Data in transit protection'); + + editor.destroy(); + }); +});