diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/common.test.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/common.test.ts index 9683832940..f773dfaea1 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/common.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/common.test.ts @@ -79,4 +79,42 @@ describe('applyInlineRunProperties', () => { expect(result).not.toBe(baseRun); expect(baseRun.italic).toBeUndefined(); }); + + it('preserves mark-derived bold when runProperties does not specify bold (SD-2011)', () => { + const runWithBold: TextRun = { + ...baseRun, + bold: true, + }; + // Empty runProperties — bold is undefined in computeRunAttrs result + const runProperties: RunProperties = {}; + + const result = applyInlineRunProperties(runWithBold, runProperties); + + // bold should be preserved from the run (mark-derived), not overwritten by undefined + expect(result.bold).toBe(true); + }); + + it('preserves mark-derived italic when runProperties does not specify italic (SD-2011)', () => { + const runWithItalic: TextRun = { + ...baseRun, + italic: true, + }; + const runProperties: RunProperties = {}; + + const result = applyInlineRunProperties(runWithItalic, runProperties); + + expect(result.italic).toBe(true); + }); + + it('overwrites bold when runProperties explicitly sets bold to false', () => { + const runWithBold: TextRun = { + ...baseRun, + bold: true, + }; + const runProperties: RunProperties = { bold: false }; + + const result = applyInlineRunProperties(runWithBold, runProperties); + + expect(result.bold).toBe(false); + }); }); diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/common.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/common.ts index 87864791b5..002611fd3d 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/common.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/common.ts @@ -80,11 +80,13 @@ export const applyInlineRunProperties = ( return run; } const runAttrs = computeRunAttrs(runProperties, converterContext); - const merged = { ...run, ...runAttrs }; - // Preserve existing run color when runProperties doesn't specify one. - // Object spread with undefined values overwrites the original, so we restore it. - if (runAttrs.color === undefined && run.color !== undefined) { - merged.color = run.color; + // Merge runAttrs onto run, but skip undefined values to avoid overwriting + // mark-derived properties (e.g., bold from a mark) with absent runProperties fields. + const merged = { ...run }; + for (const key of Object.keys(runAttrs) as Array) { + if (runAttrs[key] !== undefined) { + (merged as Record)[key] = runAttrs[key]; + } } return merged; }; diff --git a/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.js b/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.js index 52a57c9662..4f96f604fd 100644 --- a/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.js +++ b/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.js @@ -97,7 +97,7 @@ const buildWrapTransaction = (state, ranges, runType, editor, markDefsFromMeta = ranges.forEach(({ from, to }) => { state.doc.nodesBetween(from, to, (node, pos, parent, index) => { - if (!node.isText || !parent || parent.type === runType || parent.type?.name === 'structuredContent') return; + if (!node.isText || !parent || parent.type === runType) return; const match = parent.contentMatchAt ? parent.contentMatchAt(index) : null; if (match && !match.matchType(runType)) return; @@ -107,8 +107,10 @@ const buildWrapTransaction = (state, ranges, runType, editor, markDefsFromMeta = let textNode = node; // For the first node in a paragraph, inherit run properties from previous paragraph - // and merge marks (this preserves existing marks like italic while adding inherited ones like bold) - if (index === 0) { + // and merge marks (this preserves existing marks like italic while adding inherited ones like bold). + // Only apply when the text is a direct child of the paragraph — not when it is + // first inside an inline wrapper like structuredContent (SDT). + if (index === 0 && parent.type.name === 'paragraph') { ({ runProperties, textNode } = copyRunPropertiesFromPreviousParagraph(state, pos, textNode, runType, editor)); } diff --git a/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.test.js b/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.test.js index 4cfdffd93e..6c03b2ea8f 100644 --- a/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.test.js +++ b/packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.test.js @@ -4,29 +4,46 @@ import { EditorState, TextSelection } from 'prosemirror-state'; import { EditorView } from 'prosemirror-view'; import { wrapTextInRunsPlugin } from './wrapTextInRunsPlugin.js'; -const makeSchema = () => - new Schema({ - nodes: { - doc: { content: 'block+' }, - paragraph: { - group: 'block', - content: 'inline*', - toDOM: () => ['p', 0], - attrs: { - paragraphProperties: { default: null }, - }, +const makeSchema = ({ includeStructuredContent = false } = {}) => { + const nodes = { + doc: { content: 'block+' }, + paragraph: { + group: 'block', + content: 'inline*', + toDOM: () => ['p', 0], + attrs: { + paragraphProperties: { default: null }, }, - run: { - inline: true, - group: 'inline', - content: 'inline*', - toDOM: () => ['span', { 'data-run': '1' }, 0], - attrs: { - runProperties: { default: null }, - }, + }, + run: { + inline: true, + group: 'inline', + content: 'inline*', + toDOM: () => ['span', { 'data-run': '1' }, 0], + attrs: { + runProperties: { default: null }, }, - text: { group: 'inline' }, }, + text: { group: 'inline' }, + }; + + if (includeStructuredContent) { + nodes.structuredContent = { + inline: true, + group: 'inline', + content: 'inline*', + isolating: true, + toDOM: () => ['span', { 'data-structured-content': '' }, 0], + attrs: { + id: { default: null }, + tag: { default: null }, + alias: { default: null }, + }, + }; + } + + return new Schema({ + nodes, marks: { bold: { toDOM: () => ['strong', 0], @@ -52,6 +69,7 @@ const makeSchema = () => }, }, }); +}; const paragraphDoc = (schema) => schema.node('doc', null, [schema.node('paragraph')]); @@ -540,4 +558,97 @@ describe('wrapTextInRunsPlugin', () => { expect(run.firstChild.marks.some((mark) => mark.type.name === 'bold')).toBe(false); }); }); + + describe('structuredContent wrapping (SD-2011)', () => { + it('wraps text when inserting SDT with bare text content via transaction', () => { + const schema = makeSchema({ includeStructuredContent: true }); + const doc = schema.node('doc', null, [schema.node('paragraph')]); + const view = createView(schema, doc); + + // Insert SDT with bare text content (simulates template builder insertion) + const sdtNode = schema.nodes.structuredContent.create({ id: '123', alias: 'Field' }, schema.text('John Doe')); + const tr = view.state.tr.insert(1, sdtNode); + view.dispatch(tr); + + const paragraph = view.state.doc.firstChild; + // Find the structuredContent node (may be wrapped in a run by the plugin) + let sdt = null; + paragraph.descendants((node) => { + if (node.type.name === 'structuredContent') sdt = node; + }); + expect(sdt).not.toBeNull(); + // The text inside SDT should be wrapped in a run + expect(sdt.firstChild.type.name).toBe('run'); + expect(sdt.textContent).toBe('John Doe'); + }); + + it('wraps text replaced inside structuredContent via transaction', () => { + const schema = makeSchema({ includeStructuredContent: true }); + const sdtNode = schema.nodes.structuredContent.create( + { id: '456', alias: 'Name' }, + schema.nodes.run.create(null, schema.text('Old')), + ); + const runNode = schema.nodes.run.create(null, sdtNode); + const doc = schema.node('doc', null, [schema.node('paragraph', null, [runNode])]); + const view = createView(schema, doc); + + // Structure: paragraph(0) > run(1) > sdt(2) > run(3) > text(4..6="Old") + // Replace "Old" with bare text — simulates typing inside the SDT + const tr = view.state.tr.replaceWith(4, 7, schema.text('New Value')); + view.dispatch(tr); + + let updatedSdt = null; + view.state.doc.firstChild.descendants((node) => { + if (node.type.name === 'structuredContent') updatedSdt = node; + }); + expect(updatedSdt).not.toBeNull(); + // Text should still be inside a run within the SDT + expect(updatedSdt.firstChild.type.name).toBe('run'); + expect(updatedSdt.textContent).toBe('New Value'); + }); + + it('does not inherit trailing paragraph run styles when replacing first SDT inner text node', () => { + const schema = makeSchema({ includeStructuredContent: true }); + + const leadingRun = schema.nodes.run.create({ runProperties: {} }, schema.text('Lead ')); + const sdtNode = schema.nodes.structuredContent.create({ id: '789', alias: 'Field' }, schema.text('Old')); + const trailingRun = schema.nodes.run.create({ runProperties: { bold: true } }, schema.text(' Tail')); + const doc = schema.node('doc', null, [schema.node('paragraph', null, [leadingRun, sdtNode, trailingRun])]); + const view = createView(schema, doc); + + let oldTextFrom = null; + view.state.doc.descendants((node, pos) => { + if (oldTextFrom !== null) return false; + if (node.isText && node.text === 'Old') { + oldTextFrom = pos; + return false; + } + return true; + }); + + expect(oldTextFrom).not.toBeNull(); + const oldTextTo = oldTextFrom + 'Old'.length; + + // Replace SDT inner text with bare text (simulates transactional replacement in inline SDT). + const tr = view.state.tr.replaceWith(oldTextFrom, oldTextTo, schema.text('New')); + view.dispatch(tr); + + let updatedSdt = null; + view.state.doc.firstChild.descendants((node) => { + if (node.type.name === 'structuredContent') updatedSdt = node; + }); + + expect(updatedSdt).not.toBeNull(); + expect(updatedSdt.firstChild.type.name).toBe('run'); + expect(updatedSdt.textContent).toBe('New'); + + const innerRun = updatedSdt.firstChild; + const innerText = innerRun.firstChild; + + // Regression guard: replacing text inside inline SDT must not pull styles + // from the paragraph's last run. + expect(innerRun.attrs.runProperties?.bold).not.toBe(true); + expect(innerText.marks.some((mark) => mark.type.name === 'bold')).toBe(false); + }); + }); });