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();
+ });
+});