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 @@ -46,10 +46,6 @@ describe('Paragraph Node', () => {
expect(textNode).toBeDefined();
const textContent = textNode.elements?.find((child) => child.type === 'text');
expect(textContent?.text).toBe('Test Heading');

// Run properties should include font size from the Heading1 style
const rPr = run.elements.find((el) => el.name === 'w:rPr');
expect(rPr).toBeDefined();
});

it('inserting plain text creates a simple paragraph', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
calculateResolvedParagraphProperties,
getResolvedParagraphProperties,
} from '@extensions/paragraph/resolvedPropertiesCache.js';
import { carbonCopy } from '@core/utilities/carbonCopy';

/**
* ProseMirror plugin that recalculates inline `runProperties` whenever marks change on run nodes,
Expand Down Expand Up @@ -83,6 +84,18 @@ export const calculateInlineRunPropertiesPlugin = (editor) =>
const inlineRunProperties = getInlineRunProperties(runPropertiesFromMarks, runPropertiesFromStyles);
const runProperties = Object.keys(inlineRunProperties).length ? inlineRunProperties : null;

const isFirstInParagraph = $pos.parent.firstChild === runNode;

if (isFirstInParagraph) {
// Keep paragraph's default runProperties in sync for the first run
const inlineParagraphProperties = carbonCopy(paragraphNode.attrs.paragraphProperties) || {};
inlineParagraphProperties.runProperties = runProperties;
tr.setNodeMarkup($pos.before(), paragraphNode.type, {
...paragraphNode.attrs,
paragraphProperties: inlineParagraphProperties,
});
}

if (JSON.stringify(runProperties) === JSON.stringify(runNode.attrs.runProperties)) return;
tr.setNodeMarkup(pos, runNode.type, { ...runNode.attrs, runProperties }, runNode.marks);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,13 @@ const makeSchema = () =>
new Schema({
nodes: {
doc: { content: 'block+' },
paragraph: { group: 'block', content: 'inline*' },
paragraph: {
group: 'block',
content: 'inline*',
attrs: {
paragraphProperties: { default: null },
},
},
run: {
inline: true,
group: 'inline',
Expand Down Expand Up @@ -65,6 +71,17 @@ const runPos = (doc) => {
return pos;
};

const runPositions = (doc) => {
const positions = [];
doc.descendants((node, nodePos) => {
if (node.type.name === 'run') {
positions.push(nodePos);
}
return true;
});
return positions;
};

const runTextRange = (doc, startIndex, endIndex) => {
const base = runPos(doc);
if (base == null) throw new Error('Run not found');
Expand All @@ -81,6 +98,10 @@ const createState = (schema, doc) =>
describe('calculateInlineRunPropertiesPlugin', () => {
beforeEach(() => {
vi.clearAllMocks();
decodeRPrFromMarksMock.mockImplementation((marks) => ({ bold: marks.some((mark) => mark.type.name === 'bold') }));
resolveRunPropertiesMock.mockImplementation(() => ({ bold: false }));
calculateResolvedParagraphPropertiesMock.mockImplementation(() => ({ paragraph: 'calculated' }));
getResolvedParagraphPropertiesMock.mockImplementation(() => null);
});

it('stores inline run properties when marks differ from paragraph styles', () => {
Expand Down Expand Up @@ -138,4 +159,39 @@ describe('calculateInlineRunPropertiesPlugin', () => {
expect(getResolvedParagraphPropertiesMock).toHaveBeenCalled();
expect(calculateResolvedParagraphPropertiesMock).not.toHaveBeenCalled();
});

it('keeps paragraph runProperties in sync with the first run', () => {
const schema = makeSchema();
const doc = paragraphDoc(schema);
const state = createState(schema, doc);
const { from, to } = runTextRange(state.doc, 0, 2);

const tr = state.tr.addMark(from, to, schema.marks.bold.create());
const { state: nextState } = state.applyTransaction(tr);

const paragraph = nextState.doc.firstChild;
expect(paragraph.attrs.paragraphProperties).toEqual({ runProperties: { bold: true } });
});

it('does not update paragraph runProperties when a non-first run changes', () => {
const schema = makeSchema();
const doc = schema.node('doc', null, [
schema.node('paragraph', null, [
schema.node('run', null, schema.text('First')),
schema.node('run', null, schema.text('Second')),
]),
]);
const state = createState(schema, doc);
const [firstRunPos, secondRunPos] = runPositions(state.doc);
const from = secondRunPos + 1;
const to = secondRunPos + 3;

const tr = state.tr.addMark(from, to, schema.marks.bold.create());
const { state: nextState } = state.applyTransaction(tr);

const paragraph = nextState.doc.firstChild;
expect(paragraph.attrs.paragraphProperties).toBeNull();
const firstRun = nextState.doc.nodeAt(firstRunPos);
expect(firstRun?.attrs.runProperties).toBeNull();
});
});
129 changes: 44 additions & 85 deletions packages/super-editor/src/extensions/run/wrapTextInRunsPlugin.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Plugin, TextSelection } from 'prosemirror-state';
import { decodeRPrFromMarks, encodeMarksFromRPr, resolveRunProperties } from '@converter/styles.js';
import { decodeRPrFromMarks, encodeMarksFromRPr } from '@converter/styles.js';
import { carbonCopy } from '@core/utilities/carbonCopy';

const mergeRanges = (ranges, docSize) => {
if (!ranges.length) return [];
Expand Down Expand Up @@ -68,54 +69,6 @@ const getParagraphAtPos = (doc, pos) => {
return null;
};

/**
* Resolves run properties from a paragraph's style definition.
* Extracts character-level formatting (fonts, sizes, bold, italic, etc.) that should
* apply to text within the paragraph based on the paragraph's styleId.
*
* @param {Object | null} paragraphNode - The ProseMirror paragraph node containing style information.
* @param {Object} paragraphNode.attrs - Node attributes.
* @param {Object} [paragraphNode.attrs.paragraphProperties] - Paragraph properties object.
* @param {string} [paragraphNode.attrs.paragraphProperties.styleId] - The paragraph style ID to resolve.
* @param {Object} editor - The editor instance containing the converter.
* @param {Object} editor.converter - The DOCX converter instance with style data.
* @param {Object} editor.converter.convertedXml - The parsed DOCX XML structure for theme/font lookups.
* @param {Object} editor.converter.numbering - The numbering definitions from DOCX.
* @returns {{runProperties: Object, markDefs: Array<Object>}} Resolved run properties and mark definitions.
*
* @remarks
* Error handling: Returns empty objects on any failure to prevent crashes during typing.
* This allows the plugin to gracefully degrade when converter data is unavailable.
*/
const resolveRunPropertiesFromParagraphStyle = (paragraphNode, editor) => {
if (!paragraphNode || !editor?.converter) return { runProperties: {}, markDefs: [] };

const styleId = paragraphNode.attrs?.paragraphProperties?.styleId;
if (!styleId) return { runProperties: {}, markDefs: [] };

try {
const params = {
translatedNumbering: editor.converter.translatedNumbering,
translatedLinkedStyles: editor.converter.translatedLinkedStyles,
};
const resolvedPpr = { styleId };
const runProperties = resolveRunProperties(params, {}, resolvedPpr, null, false, false);
const markDefs = encodeMarksFromRPr(runProperties, editor.converter.convertedXml);

return { runProperties, markDefs: Array.isArray(markDefs) ? markDefs : [] };
} catch (_e) {
return { runProperties: {}, markDefs: [] };
}
};

const createMarksFromDefs = (schema, markDefs = []) =>
markDefs
.map((def) => {
const markType = schema.marks[def.type];
return markType ? markType.create(def.attrs) : null;
})
.filter(Boolean);

// Keep collapsed selections inside run nodes so caret geometry maps to text positions.
const normalizeSelectionIntoRun = (tr, runType) => {
const selection = tr.selection;
Expand All @@ -142,11 +95,41 @@ const normalizeSelectionIntoRun = (tr, runType) => {
}
};

const buildWrapTransaction = (state, ranges, runType, editor, markDefsFromMeta = []) => {
/**
* Copies run properties from the previous paragraph's last run and applies its marks to a text node.
* @param {import('prosemirror-state').EditorState} state
* @param {number} pos
* @param {import('prosemirror-model').Node} textNode
* @param {import('prosemirror-model').NodeType} runType
* @param {Object} editor
* @returns {{ runProperties: Record<string, unknown> | undefined, textNode: import('prosemirror-model').Node }}
*/
const copyRunPropertiesFromPreviousParagraph = (state, pos, textNode, runType, editor) => {
let runProperties;
let updatedTextNode = textNode;
const paragraphNode = getParagraphAtPos(state.doc, pos - 2);
if (paragraphNode && paragraphNode.content.size > 0) {
const lastChild = paragraphNode.child(paragraphNode.childCount - 1);
if (lastChild.type === runType && lastChild.attrs.runProperties) {
runProperties = carbonCopy(lastChild.attrs.runProperties);
}
// Copy marks and apply them to the text node being wrapped.
if (runProperties) {
const markDefs = encodeMarksFromRPr(runProperties, editor?.converter?.convertedXml ?? {});
const markInstances = markDefs.map((def) => state.schema.marks[def.type]?.create(def.attrs)).filter(Boolean);
if (markInstances.length) {
const mergedMarks = markInstances.reduce((set, mark) => mark.addToSet(set), updatedTextNode.marks);
updatedTextNode = updatedTextNode.mark(mergedMarks);
}
}
}
return { runProperties, textNode: updatedTextNode };
};

const buildWrapTransaction = (state, ranges, runType, editor) => {
if (!ranges.length) return null;

const replacements = [];
const metaStyleMarks = createMarksFromDefs(state.schema, markDefsFromMeta);

ranges.forEach(({ from, to }) => {
state.doc.nodesBetween(from, to, (node, pos, parent, index) => {
Expand All @@ -156,29 +139,16 @@ const buildWrapTransaction = (state, ranges, runType, editor, markDefsFromMeta =
if (match && !match.matchType(runType)) return;
if (!match && !parent.type.contentMatch.matchType(runType)) return;

let runProperties = decodeRPrFromMarks(node.marks);
let runProperties;
let textNode = node;

if ((!node.marks || node.marks.length === 0) && editor?.converter) {
const paragraphNode = getParagraphAtPos(state.doc, pos);
const { runProperties: styleRunProps, markDefs: styleMarkDefs } = resolveRunPropertiesFromParagraphStyle(
paragraphNode,
editor,
);
if (Object.keys(styleRunProps).length > 0) {
runProperties = styleRunProps;
// Use metaStyleMarks if available, otherwise create marks from resolved OOXML run props
const markDefs = metaStyleMarks.length ? markDefsFromMeta : styleMarkDefs;
const styleMarks = metaStyleMarks.length ? metaStyleMarks : createMarksFromDefs(state.schema, markDefs);
if (styleMarks.length && typeof state.schema.text === 'function') {
const textNode = state.schema.text(node.text || '', styleMarks);
if (textNode) {
node = textNode;
}
}
}
if (index === 0) {
// First node in parent. Copy run properties from the preceding paragraph's last run, if any.
({ runProperties, textNode } = copyRunPropertiesFromPreviousParagraph(state, pos, textNode, runType, editor));
} else {
runProperties = decodeRPrFromMarks(node.marks);
}

const runNode = runType.create({ runProperties }, node);
const runNode = runType.create({ runProperties }, textNode);
replacements.push({ from: pos, to: pos + node.nodeSize, runNode });
});
});
Expand All @@ -195,7 +165,6 @@ const buildWrapTransaction = (state, ranges, runType, editor, markDefsFromMeta =
export const wrapTextInRunsPlugin = (editor) => {
let view = null;
let pendingRanges = [];
let lastStyleMarksMeta = [];

const flush = () => {
if (!view) return;
Expand All @@ -204,7 +173,7 @@ export const wrapTextInRunsPlugin = (editor) => {
pendingRanges = [];
return;
}
const tr = buildWrapTransaction(view.state, pendingRanges, runType, editor, lastStyleMarksMeta);
const tr = buildWrapTransaction(view.state, pendingRanges, runType, editor);
pendingRanges = [];
if (tr) {
view.dispatch(tr);
Expand All @@ -225,7 +194,6 @@ export const wrapTextInRunsPlugin = (editor) => {
editorView.dom.removeEventListener('compositionend', onCompositionEnd);
view = null;
pendingRanges = [];
lastStyleMarksMeta = [];
},
};
},
Expand All @@ -243,16 +211,7 @@ export const wrapTextInRunsPlugin = (editor) => {
return null;
}

const latestStyleMarksMeta =
[...transactions]
.reverse()
.find((tr) => tr.getMeta && tr.getMeta('sdStyleMarks'))
?.getMeta('sdStyleMarks') || lastStyleMarksMeta;
if (latestStyleMarksMeta && latestStyleMarksMeta.length) {
lastStyleMarksMeta = latestStyleMarksMeta;
}

const tr = buildWrapTransaction(newState, pendingRanges, runType, editor, latestStyleMarksMeta);
const tr = buildWrapTransaction(newState, pendingRanges, runType, editor);
pendingRanges = [];
return tr;
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,42 @@ describe('wrapTextInRunsPlugin', () => {
expect(paragraph.textContent).toBe('あ');
});

it('copies run properties from previous paragraph and applies marks to wrapped text', () => {
const schema = makeSchema();
const prevRun = schema.node('run', { runProperties: { bold: true } }, [schema.text('Prev')]);
const doc = schema.node('doc', null, [schema.node('paragraph', null, [prevRun]), schema.node('paragraph')]);
const view = createView(schema, doc);

const secondParagraphPos = view.state.doc.child(0).nodeSize + 1;
const tr = view.state.tr.setSelection(TextSelection.create(view.state.doc, secondParagraphPos)).insertText('Next');
view.dispatch(tr);

const secondParagraph = view.state.doc.child(1);
const run = secondParagraph.firstChild;
expect(run.type.name).toBe('run');
expect(run.attrs.runProperties).toEqual({ bold: true });
expect(run.firstChild.marks.some((mark) => mark.type.name === 'bold')).toBe(true);
});

it('merges previous paragraph marks with existing text marks', () => {
const schema = makeSchema();
const prevRun = schema.node('run', { runProperties: { bold: true } }, [schema.text('Prev')]);
const doc = schema.node('doc', null, [schema.node('paragraph', null, [prevRun]), schema.node('paragraph')]);
const view = createView(schema, doc);

const secondParagraphPos = view.state.doc.child(0).nodeSize + 1;
const tr = view.state.tr.setSelection(TextSelection.create(view.state.doc, secondParagraphPos));
tr.addStoredMark(schema.marks.italic.create());
tr.insertText('X');
view.dispatch(tr);

const secondParagraph = view.state.doc.child(1);
const run = secondParagraph.firstChild;
const markNames = run.firstChild.marks.map((mark) => mark.type.name);
expect(markNames).toContain('bold');
expect(markNames).toContain('italic');
});

describe('resolveRunPropertiesFromParagraphStyle', () => {
it('resolves run properties from paragraph styleId', () => {
const schema = makeSchema();
Expand Down
Loading