From 7c4853e4f904fe44362568803c5b5d76fb10331c Mon Sep 17 00:00:00 2001 From: Adrian Khoo Date: Thu, 11 Jun 2026 23:21:38 -0700 Subject: [PATCH 1/5] fix(plan-engine): date.setValue updates visible text --- .../content-controls-wrappers.test.ts | 53 +++++++++++++++++ .../plan-engine/content-controls-wrappers.ts | 9 ++- ...set-value-visible-text.integration.test.ts | 54 ++++++++++++++++++ .../editors/v1/tests/data/date_control.docx | Bin 0 -> 20076 bytes 4 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/date-set-value-visible-text.integration.test.ts create mode 100644 packages/super-editor/src/editors/v1/tests/data/date_control.docx 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..6324454a35 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 @@ -1176,6 +1176,59 @@ describe('choiceList.setSelected visual text sync', () => { }); }); +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('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..f5f91424e7 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; }); } 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/date_control.docx b/packages/super-editor/src/editors/v1/tests/data/date_control.docx new file mode 100644 index 0000000000000000000000000000000000000000..9f2c7dbdbd21e3b53d1d522bc5afd05aeffc5eb4 GIT binary patch literal 20076 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 Date: Wed, 17 Jun 2026 07:07:29 -0700 Subject: [PATCH 2/5] fix(plan-engine): checkbox.setState updates block-scope checkbox glyph --- ...et-state-visible-glyph.integration.test.ts | 56 +++++++++++++++ .../content-controls-wrappers.test.ts | 65 ++++++++++++++++++ .../plan-engine/content-controls-wrappers.ts | 7 ++ .../v1/tests/data/block_checkbox_control.docx | Bin 0 -> 2623 bytes 4 files changed, 128 insertions(+) create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/checkbox-set-state-visible-glyph.integration.test.ts create mode 100644 packages/super-editor/src/editors/v1/tests/data/block_checkbox_control.docx 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/content-controls-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/content-controls-wrappers.test.ts index 6324454a35..59d67a0744 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 @@ -1229,6 +1229,71 @@ describe('date.setValue visual text sync', () => { }); }); +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 f5f91424e7..9875b2d1cf 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 @@ -1452,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; 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 0000000000000000000000000000000000000000..d92152d40a0bd04d16880563991db3e8b6040eda GIT binary patch literal 2623 zcmZ{m2{crTAIE1J*_W|}Y)>f3zC|7~)}di+DZ7|ZmNCe9W3naGsLa^PmaWMa(kqXU zEb;7w5K)rQ7`v?htM}fK`M-DXx%Zx#bH2ZGexLjO{(cu}3Zdfxfk2F)^vX7ekJ{pV zB47|m4gvyk1OM9V`S}K-d;^`WL(r%IM>(vIcZp@IS%U(!X6=-Wb5Uai1SSRNkTIIh z4h=_{bFug>R_1TxjQi1hX0mb&2TidFlC-7@ zgvu>W*N+Ietq2EVe3Yv{a47}BT@orr;xwC89hyDkiTBav3mc}$1rbXkrmMbQKbWwrk?~Nvs0k%(=QP1KTURu$v}x!4 z$Z6yOoP6hN>%RgRZ4ZG!EWpzlgYpiLm)pCS5IX>jtl{&xTPw^bUBpf~{?VzjHyCi!uPoBZl*}Ha%T4rch#q97N zBvMLl_NM>_^Q8lUH~}KTei(OocR#lvAHZdM_KLP1@l98N;=61wT<^kgBE+9T4U&4v z#tF~2L8St(q*sw6)oIJ8Hr~z8rUWXuMo1Jbx=ghTW&QE4tOhqAtQi@^jHo9e+6DOI zl9Ukjbz!*5+RyDFuF^f(5s`J_pMpR4uO`|`aU3Oh@Z715^Q{~_>{B_0 zTh44p7Z_w&qnz2WLhF3J*(5uJr#605rB);$ICvMW$U zqVpfV$$UN3@~qHnn{{nkYepa#DW4-7%j{jRLk17TUI(W|aZuA$Rs5e^JNqd{{X$<> z&KAQ#b405GV`-Ve&i?qZU^&g1?Y`={4LbHS_uQE*YIt|8WVi@ zJ>M_|1WfG~9tUxf!BW*(m)M;9Umk&&Gykf2s=S?r?&8K4~ymf>d62|11Co!krtygjbLpT`1?kp^WuY> ziz~$>9-H)+%9@!OqoX?`7ySp~&QX~uFL)WhRd);ONUdXa#16Zmj2N zSeb`tl?VQ{d|Cl2b*z`!Rz>H{xN=;9?hL_#dSf8;IHaAxV1Ak)gV~s97AT3Vk|Q|8 zPlj!-#~kVRNtPUasaXBlbkbD+oVoJBJQJnt_mNX8<_90HnQH0eAq3@k0m5yZK>Idzeu7I`yfsi5>>_2Rwnq_vB2NRssB>(*0y5#O(&l)t4G=%eIR^#dupI<6;Cus0LQ8ZV&lluaD=Ngl-Ot9mNlu9L{-e6uS z^tT`ruhjvxR{w|O;lTvrZV9oDVH-$0_z(}|zrQwsR(>4ezzhG^Zx7l6wEOihIS3RE zHU~=lv~6jHv}&q~NYleWQ4oj$IBNj9-i&*{{s$}@L-+sy literal 0 HcmV?d00001 From abcaa117f8f88aaded052a6bd5bb50fa88fda942 Mon Sep 17 00:00:00 2001 From: Adrian Khoo Date: Wed, 15 Jul 2026 18:11:15 -0700 Subject: [PATCH 3/5] fix(commands): detect inline HTML anywhere in insertContentAt value looksLikeHTML anchored its tag-detection regexes to the start and end of the string, so content that mixes text with inline elements (e.g. a checkbox glyph followed by "yes") was misclassified as plain text and its tags were inserted as literal characters. Match a tag pair, self-closing tag, or void element anywhere in the string instead, while still rejecting prose with stray angle brackets ("5 < 10"). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../v1/core/commands/insertContentAt.js | 9 ++--- .../v1/core/commands/insertContentAt.test.js | 36 +++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) 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(); + }); }); From 77fce105cce6ef08523e152572f98aacfc50dd59 Mon Sep 17 00:00:00 2001 From: Adrian Khoo Date: Thu, 16 Jul 2026 23:41:37 -0700 Subject: [PATCH 4/5] fix(plan-engine): choiceList.setSelected updates block dropdown text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit choiceListSetSelectedWrapper synced the SDT's visible text via updateStructuredContentById for every control, but that command feeds a bare inline text node into the SDT. For a block-scope dropdown (structuredContentBlock, schema block*) PM's content check rejects the inline content and rolls back the whole transaction — including the w:lastValue write made just before — so the selection failed silently while still reporting success. Branch on sdt.kind like checkboxSetStateWrapper: inline keeps updateStructuredContentById; block uses replaceSdtTextContent, which wraps the display text in a paragraph the block schema accepts. Adds a block_dropdown_control.docx fixture and an end-to-end spec that drives import -> setSelected -> read, and splits the wrapper unit tests into inline and block scopes. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-selected-visible-text.integration.test.ts | 50 ++++++++++++ .../content-controls-wrappers.test.ts | 73 ++++++++++++++++-- .../plan-engine/content-controls-wrappers.ts | 22 ++++-- .../v1/tests/data/block_dropdown_control.docx | Bin 0 -> 2899 bytes 4 files changed, 135 insertions(+), 10 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/choice-list-set-selected-visible-text.integration.test.ts create mode 100644 packages/super-editor/src/editors/v1/tests/data/block_dropdown_control.docx 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 59d67a0744..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,63 @@ 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. 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 9875b2d1cf..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 @@ -1591,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/tests/data/block_dropdown_control.docx b/packages/super-editor/src/editors/v1/tests/data/block_dropdown_control.docx new file mode 100644 index 0000000000000000000000000000000000000000..2e71f606a642ccd239a9da27993d9bf8a218949f GIT binary patch literal 2899 zcmaJ@2{@E(7ak1;W3q3bkgce(CTpd!&j>S?ref?9^;yPNYWPsdGWMm#mTXNZ(pP?K zB435b`ZJakg)}BnDE#lMEV=sr_qt}@XXZWUJ?FX4IrsfwO`**E5D0_~a+%Dqdviou zNSp}*QG`Mue89i9`rcmtL@$2_>kA~JA3-tD)1%b#xp|{9ckS2xNQdI4FsPm!nhR+> zksB0BwBUt%&sG*JllLI_+5g5e8-FOxzJ9ShAl-9!E_>am5b>!IUb9c?`Kopun3uxLOOd&++62g2 zGem+EXD1qHA};e{{=S|Esz-QL1JI616|`ub=7V<4?s3W2N#&oGOtGKDEt7rQ8yXBU z`NCqYB&7qj-&!phNMnqkwl7j83Z!oZBG7QL?t!Gr@57TOw2lCy29$2qQ-1#4ozqzg zTu~{_?d`WOU2tPst$#N5O&^-9WEEx7V#fHkQ=(Y3ho!*Sq(4&vLl0Tepx|>+64Cdb zt_;#NumGGK0C12sIM)_GzgU60gD=s;PYJZygk0!&UBe{s48H>cfp6F-f|jMpZ2;7q zp)=%|XI~EvC35SToky8eOR9-K#tf?8TEsa!lF3NsC`ncw62&EC#p6G;>Bf?5s&&q- zFq^JRx7<@c`=~L>stNXCcPOt$Go?f{u@PqV6ZA7i={K|&?b41qA5>O(|7U4&mYk!f zWucs|{ywIY*~D&z6CPcyR87|J%fH8cF#vPsOE2kg&q(?}UsCdFX>vVm8TM=W zr=TfyQ$Y4!z|`?<-Gz&{FA>}ZF|qCJS`%F0cuRicdPPMqSx>$i!AcW9nv&MrR32AK znfv%YQqf~tZnCQ=hhFybco6PbbG5y?jRK3-+(4zDlJ0O$%U*fj%UzDOdG_}5&l|D+ z!l&kv?DM$z`?0(y$1G!WuL`~E*q6r8JCMJRPf^HN!tM30<_eBbzD)Gt?)khM(J1KU zD)yE#(QyLR2OlXrV0IIiRB(OQ-X80?WPM44qHn>tE}EkBFmXY4^|V@kLZ8FL>Zx+s)jIbD*3B-61gHqW zV&h@~R_DEaUAK%5Xi}_L0Zj#PlLXib#BR+5=#~IafU-dBf-M`oXv&-bS1}KXY8gEe zFq#%u=M*kOKs&|GStFYTcSqmIZ)&UiD>V~oj%$Ts5`tuMj~oV6OIfmmA-G`JRjkqD5_RDBCDo4Gr@F zQ8ogLiVoK(k>HV5o)li1cG6MSELFdR@$ZjxHXXg)xwwG4Xp%>Cwem|X9Dn~}-%Y%x zmSPNtmff53rWx#5_T}_zat(`13p0K9|xs<$7Cwv#JP~w#? zXgB*_wyb~0ozNSwj3q`$4~-n|B(#Qmt0rpX?p#(q(_OMtE{`vC9ZSRWO?TOd3= zSLkY2JKK;5rLm%n%EhPj6hkgP%wg8ka4JaemdI|mfFL)6BzycDm zEuh%ceBEDCYjtH8cg&Qvz-?ZkCZ;2f=)tCBhuIsEv-2e>{5F|22XwNthK5#XCw+RO zk1;stj|A90R(FW%$}I%y4j;aBlTv3aT63KAiuv2Qii|&A2o<9lAr@`Ve@M^mM zygm?3MNLs~jPTx|Jfia7D1kGhKG5Mx}b(bj;M? zn5GQH<@%r!rp^&n!6?8pPCa~toj$A`Y<9o1CG%vfP;Gkm%8t!Z06_w9*|ZsXV~8{Ar7)f|1F>winBdSvuMlN3J@uGi zGSnU!$k62cjBx0w5rkTB9Maie-U?^FKQLb*COcf!LXUck>$ewiL%+dL%(86os{0BN z78PzL+%3FC!pms3UOt5wzxU4zPm`Q*Do>R;?#&V6WWE^saF1q$1+bPi=+R6$JsqU{ zi;F@SUc#r%+=?XN{R?jmNp@zIq2>`ux0)wlX1>2; Date: Fri, 17 Jul 2026 11:57:38 -0700 Subject: [PATCH 5/5] fix(super-converter): keep field-code hyperlinks that span a paragraph boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A legacy HYPERLINK field code (w:fldChar/w:instrText) can begin in one paragraph and close in a later one — common when a link fills a table cell. The field preprocessor wrapped all of the collected content, whole blocks included, in a single inline . Because cannot contain a paragraph, the importer dropped the paragraphs and left the cell with zero block children, violating the tableCell `block+` schema and aborting the whole document load with "Invalid content for node type tableCell". Detect when the collected content spans a paragraph boundary and, instead of hoisting one hyperlink around the paragraphs, keep each paragraph and wrap only its runs in their own sharing the same target — the representation Word uses for a link that covers more than one line. Adds a minimal fixture that reproduces the crash through the full import path, plus unit coverage for the per-paragraph wrapping. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../hyperlink-preprocessor.js | 110 ++++++++++++++++-- .../hyperlink-preprocessor.test.js | 93 +++++++++++++++ .../preProcessNodesForFldChar.test.js | 31 ++--- ...ink-fldchar-across-paragraphs-in-cell.docx | Bin 0 -> 11242 bytes ...yperlink-fldchar-across-paragraphs.test.js | 48 ++++++++ 5 files changed, 259 insertions(+), 23 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/tests/data/hyperlink-fldchar-across-paragraphs-in-cell.docx create mode 100644 packages/super-editor/src/editors/v1/tests/regression/hyperlink-fldchar-across-paragraphs.test.js 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/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 0000000000000000000000000000000000000000..5f9e37e76fefbd0ed0f7d31b2f52501b4bfeb142 GIT binary patch literal 11242 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 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(); + }); +});