Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<keyof typeof runAttrs>) {
Comment thread
harbournick marked this conversation as resolved.
if (runAttrs[key] !== undefined) {
(merged as Record<string, unknown>)[key] = runAttrs[key];
}
}
return merged;
};
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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));
}

Expand Down
151 changes: 131 additions & 20 deletions packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -52,6 +69,7 @@ const makeSchema = () =>
},
},
});
};

const paragraphDoc = (schema) => schema.node('doc', null, [schema.node('paragraph')]);

Expand Down Expand Up @@ -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);
});
});
});
Loading