Skip to content

Commit 352dbbf

Browse files
committed
fix: apply linked style to selected range only
1 parent 8dfbf95 commit 352dbbf

6 files changed

Lines changed: 209 additions & 4 deletions

File tree

packages/super-editor/src/editors/v1/core/super-converter/docx-helpers/get-default-style-definition.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ export const getDefaultStyleDefinition = (defaultStyleId, docx) => {
9999
.find((el) => el.elements.some((inner) => inner.name === 'w:basedOn'))
100100
?.elements.find((inner) => inner.name === 'w:basedOn')?.attributes['w:val'];
101101

102+
const linkToCharacterStyle = firstMatch.elements.find((el) => el.name === 'w:link')?.attributes?.['w:val'] ?? null;
103+
102104
const parsedAttrs = {
103105
name,
104106
qFormat: qFormat ? true : false,
@@ -108,6 +110,8 @@ export const getDefaultStyleDefinition = (defaultStyleId, docx) => {
108110
pageBreakBefore: pageBreakBeforeVal ? true : false,
109111
pageBreakAfter: pageBreakAfterVal ? true : false,
110112
basedOn: basedOn ?? null,
113+
/** Linked character style id (w:link); used when applying paragraph style to a partial selection */
114+
link: linkToCharacterStyle,
111115
};
112116

113117
// rPr

packages/super-editor/src/editors/v1/core/super-converter/docx-helpers/get-default-style-definition.test.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ describe('getDefaultStyleDefinition', () => {
106106
pageBreakBefore: true,
107107
pageBreakAfter: false,
108108
basedOn: 'Base',
109+
link: null,
109110
});
110111

111112
// styles -> spacing and indent converted, textAlign propagated from justify when indent present
@@ -160,6 +161,31 @@ describe('getDefaultStyleDefinition', () => {
160161
]);
161162
});
162163

164+
it('parses w:link to linked character style id', () => {
165+
const docx = {
166+
'word/styles.xml': {
167+
elements: [
168+
{
169+
elements: [
170+
{
171+
name: 'w:style',
172+
attributes: { 'w:styleId': 'Heading1', 'w:type': 'paragraph' },
173+
elements: [
174+
{ name: 'w:name', attributes: { 'w:val': 'Heading 1' } },
175+
{ name: 'w:link', attributes: { 'w:val': 'Heading1Char' } },
176+
{ name: 'w:rPr', elements: [] },
177+
],
178+
},
179+
],
180+
},
181+
],
182+
},
183+
};
184+
185+
const res = getDefaultStyleDefinition('Heading1', docx);
186+
expect(res.attrs.link).toBe('Heading1Char');
187+
});
188+
163189
it('handles w:tabs element with no children gracefully', () => {
164190
const docx = {
165191
'word/styles.xml': {

packages/super-editor/src/editors/v1/extensions/linked-styles/helpers.js

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { CustomSelectionPluginKey } from '@core/selection-state.js';
33
import { getLineHeightValueString } from '@core/super-converter/helpers.js';
44
import { findParentNode } from '../../core/helpers/findParentNode.js';
5+
import { findParentNodeClosestToPos } from '../../core/helpers/findParentNodeClosestToPos.js';
56
import { kebabCase } from '@superdoc/common';
67
import { getUnderlineCssString } from './index.js';
78
import { twipsToLines, twipsToPixels, halfPointToPixels } from '@converter/helpers.js';
@@ -308,6 +309,33 @@ export const generateLinkedStyleString = (linkedStyle, basedOnStyle, node, paren
308309
return final;
309310
};
310311

312+
/**
313+
* @param {import('prosemirror-model').Node} doc
314+
* @param {number} paragraphPos
315+
* @param {import('prosemirror-model').Node} paragraphNode
316+
* @returns {{ from: number; to: number } | null} Half-span [from, to) covering all text in the paragraph
317+
*/
318+
const getParagraphTextBounds = (doc, paragraphPos, paragraphNode) => {
319+
let minPos = null;
320+
let maxPos = null;
321+
const innerStart = paragraphPos + 1;
322+
const innerEnd = paragraphPos + paragraphNode.nodeSize - 1;
323+
doc.nodesBetween(innerStart, innerEnd, (node, pos) => {
324+
if (node.isText) {
325+
if (minPos === null || pos < minPos) minPos = pos;
326+
maxPos = pos + node.nodeSize;
327+
}
328+
return true;
329+
});
330+
if (minPos === null || maxPos === null) return null;
331+
return { from: minPos, to: maxPos };
332+
};
333+
334+
const applyCharacterStyleMarkToRange = (tr, textStyleType, from, to, styleId) => {
335+
tr.removeMark(from, to, textStyleType);
336+
tr.addMark(from, to, textStyleType.create({ styleId }));
337+
};
338+
311339
/**
312340
* Apply a linked style to a transaction
313341
* @category Helper
@@ -325,6 +353,7 @@ export const applyLinkedStyleToTransaction = (tr, editor, style) => {
325353

326354
let selection = tr.selection;
327355
const state = editor.state;
356+
const textStyleType = editor.schema.marks.textStyle;
328357

329358
// Check for preserved selection from custom selection plugin
330359
const focusState = CustomSelectionPluginKey.getState(state);
@@ -398,7 +427,37 @@ export const applyLinkedStyleToTransaction = (tr, editor, style) => {
398427
return true;
399428
}
400429

401-
// Handle selection spanning multiple nodes
430+
// Character styles: only affect the selected range (including across paragraphs)
431+
if (style.type === 'character') {
432+
if (!textStyleType) return false;
433+
clearFormattingMarks(from, to);
434+
applyCharacterStyleMarkToRange(tr, textStyleType, from, to, style.id);
435+
clearStoredFormattingMarks();
436+
return true;
437+
}
438+
439+
// Paragraph style + partial selection in a single paragraph: apply linked character style to range only (Word behavior)
440+
if (style.type === 'paragraph' && textStyleType) {
441+
const linkedCharStyleId = style.definition?.attrs?.link;
442+
if (linkedCharStyleId) {
443+
const $fromPos = tr.doc.resolve(from);
444+
const $toPos = tr.doc.resolve(to);
445+
const startPara = findParentNodeClosestToPos($fromPos, (n) => n.type.name === 'paragraph');
446+
const endPara = findParentNodeClosestToPos($toPos, (n) => n.type.name === 'paragraph');
447+
if (startPara && endPara && startPara.pos === endPara.pos) {
448+
const bounds = getParagraphTextBounds(tr.doc, startPara.pos, startPara.node);
449+
const coversFullParagraphText = bounds && from <= bounds.from && to >= bounds.to;
450+
if (!coversFullParagraphText) {
451+
clearFormattingMarks(from, to);
452+
applyCharacterStyleMarkToRange(tr, textStyleType, from, to, linkedCharStyleId);
453+
clearStoredFormattingMarks();
454+
return true;
455+
}
456+
}
457+
}
458+
}
459+
460+
// Handle selection spanning multiple nodes / full paragraph(s)
402461
const paragraphPositions = [];
403462

404463
tr.doc.nodesBetween(from, to, (node, pos) => {

packages/super-editor/src/editors/v1/extensions/linked-styles/linked-styles.test.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,52 @@ describe('LinkedStyles Extension', () => {
132132
});
133133
expect(boldTextNodes).toHaveLength(0);
134134
});
135+
136+
it('applies linked character style to a partial selection without restyling the whole paragraph', () => {
137+
const firstParagraph = findParagraphInfo(editor.state.doc, 0);
138+
let innerFrom;
139+
let innerTo;
140+
editor.state.doc.nodesBetween(
141+
firstParagraph.pos,
142+
firstParagraph.pos + firstParagraph.node.nodeSize,
143+
(node, pos) => {
144+
if (node.isText && node.text.length >= 4) {
145+
innerFrom = pos + 1;
146+
innerTo = pos + node.text.length - 1;
147+
return false;
148+
}
149+
return true;
150+
},
151+
);
152+
expect(innerFrom).toBeDefined();
153+
expect(innerTo).toBeGreaterThan(innerFrom);
154+
155+
const prevParaStyleId = getParagraphProps(firstParagraph.node).styleId;
156+
157+
const styleWithLink = {
158+
...headingStyle,
159+
type: 'paragraph',
160+
definition: {
161+
...headingStyle.definition,
162+
attrs: { ...headingStyle.definition.attrs, link: 'Emphasis' },
163+
},
164+
};
165+
166+
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, innerFrom, innerTo)));
167+
const result = editor.commands.setLinkedStyle(styleWithLink);
168+
expect(result).toBe(true);
169+
170+
const paraAfter = findParagraphInfo(editor.state.doc, 0);
171+
expect(getParagraphProps(paraAfter.node).styleId).toBe(prevParaStyleId);
172+
173+
let sawEmphasisInSelection = false;
174+
editor.state.doc.nodesBetween(innerFrom, innerTo, (node) => {
175+
if (!node.isText) return;
176+
const ts = node.marks.find((m) => m.type.name === 'textStyle');
177+
if (ts?.attrs?.styleId === 'Emphasis') sawEmphasisInSelection = true;
178+
});
179+
expect(sawEmphasisInSelection).toBe(true);
180+
});
135181
});
136182

137183
describe('toggleLinkedStyle', () => {

packages/super-editor/src/editors/v1/extensions/run/commands/split-run.js

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { NodeSelection, TextSelection, AllSelection } from 'prosemirror-state';
33
import { canSplit } from 'prosemirror-transform';
44
import { defaultBlockAt } from '@core/helpers/defaultBlockAt.js';
55
import { getSplitRunProperties, syncSplitParagraphRunProperties } from '@core/helpers/splitParagraphRunProperties.js';
6-
import { clearInheritedLinkedStyleId } from '@core/commands/linkedStyleSplitHelpers.js';
6+
import { clearInheritedLinkedStyleId, isLinkedParagraphStyleId } from '@core/commands/linkedStyleSplitHelpers.js';
77
import { resolveRunProperties, encodeMarksFromRPr } from '@core/super-converter/styles.js';
88
import { extractTableInfo } from '../calculateInlineRunPropertiesPlugin.js';
99

@@ -89,6 +89,7 @@ export function splitBlockPatch(state, dispatch, editor) {
8989
atEnd = $from.end(d) == $from.pos + ($from.depth - d);
9090
atStart = $from.start(d) == $from.pos - ($from.depth - d);
9191
deflt = defaultBlockAt($from.node(d - 1).contentMatchAt($from.indexAfter(d - 1)));
92+
const sourceParagraphStyleId = node.attrs?.paragraphProperties?.styleId;
9293
paragraphAttrs = /** @type {Record<string, unknown>} */ ({
9394
...node.attrs,
9495
// Ensure newly created block gets a fresh ID (block-node plugin assigns one)
@@ -104,7 +105,25 @@ export function splitBlockPatch(state, dispatch, editor) {
104105
// current run's runProperties on the new paragraph so the toolbar and
105106
// wrapTextInRunsPlugin know which inline formatting to inherit.
106107
if (atEnd && $from.parent.type.name === 'run') {
107-
paragraphAttrs = syncSplitParagraphRunProperties(paragraphAttrs, getSplitRunProperties(state, $from));
108+
if (!isLinkedParagraphStyleId(editor, sourceParagraphStyleId)) {
109+
paragraphAttrs = syncSplitParagraphRunProperties(paragraphAttrs, getSplitRunProperties(state, $from));
110+
}
111+
const pp = paragraphAttrs?.paragraphProperties;
112+
const styles = editor?.converter?.translatedLinkedStyles?.styles;
113+
const rp = pp?.runProperties;
114+
const runStyleIsLinkedCharacter =
115+
rp?.styleId &&
116+
styles &&
117+
Object.values(styles).some((def) => def?.type === 'paragraph' && def?.link === rp.styleId);
118+
if (
119+
pp &&
120+
(isLinkedParagraphStyleId(editor, sourceParagraphStyleId) ||
121+
(runStyleIsLinkedCharacter && !isLinkedParagraphStyleId(editor, sourceParagraphStyleId)))
122+
) {
123+
const nextPp = { ...pp };
124+
delete nextPp.runProperties;
125+
paragraphAttrs = { ...paragraphAttrs, paragraphProperties: nextPp };
126+
}
108127
}
109128
types.unshift({ type: deflt || node.type, attrs: paragraphAttrs });
110129
splitDepth = d;
@@ -234,7 +253,10 @@ function applyStyleMarks(state, tr, editor, paragraphAttrs, tableInfo) {
234253
)
235254
: [];
236255

237-
const selectionMarks = state.selection?.$from?.marks ? state.selection.$from.marks() : [];
256+
// Use the post-split selection on `tr`. `state.selection` still points at the pre-split
257+
// cursor (e.g. end of styled text), which would incorrectly re-apply marks like textStyle
258+
// onto a new empty paragraph after Enter.
259+
const selectionMarks = tr.selection?.$from?.marks ? tr.selection.$from.marks() : [];
238260
const selectionMarkDefs = selectionMarks.map((mark) => ({ type: mark.type.name, attrs: mark.attrs }));
239261

240262
/** @type {Array<{type: string, attrs: Record<string, unknown>}>} */

packages/super-editor/src/editors/v1/extensions/run/commands/split-run.test.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,22 @@ describe('splitRunToParagraph with style marks', () => {
220220
],
221221
};
222222

223+
/** Paragraph without a linked paragraph style — simulates applying only the linked character part to a selection. */
224+
const NORMAL_BODY_PARAGRAPH_DOC = {
225+
type: 'doc',
226+
content: [
227+
{
228+
type: 'paragraph',
229+
content: [
230+
{
231+
type: 'run',
232+
content: [{ type: 'text', text: 'Hello world' }],
233+
},
234+
],
235+
},
236+
],
237+
};
238+
223239
const STYLED_TABLE_DOC = {
224240
type: 'doc',
225241
content: [
@@ -513,6 +529,38 @@ describe('splitRunToParagraph with style marks', () => {
513529
expect(markTypes).not.toContain('textStyle');
514530
});
515531

532+
it('does not carry linked character style to a new line when only a selection had the linked char style', () => {
533+
const linkedStyleConverter = createHeadingLinkedStyleConverter();
534+
editor.converter = linkedStyleConverter;
535+
loadDoc(NORMAL_BODY_PARAGRAPH_DOC);
536+
537+
const start = findTextPos('Hello world');
538+
expect(start).not.toBeNull();
539+
const textStyle = editor.schema.marks.textStyle;
540+
const textStart = start ?? 0;
541+
editor.view.dispatch(
542+
editor.view.state.tr.addMark(textStart + 6, textStart + 11, textStyle.create({ styleId: 'Heading1Char' })),
543+
);
544+
545+
updateSelection(textStart + 'Hello world'.length);
546+
expect(editor.commands.splitRunToParagraph()).toBe(true);
547+
548+
editor.commands.insertContent('X');
549+
550+
let insertedTextNode = null;
551+
editor.view.state.doc.descendants((node) => {
552+
if (node.type.name === 'text' && node.text === 'X') {
553+
insertedTextNode = node;
554+
return false;
555+
}
556+
return true;
557+
});
558+
559+
expect(insertedTextNode).toBeTruthy();
560+
const markTypes = (insertedTextNode?.marks || []).map((mark) => mark.type?.name);
561+
expect(markTypes).not.toContain('textStyle');
562+
});
563+
516564
it('does not carry linked style marks into an empty paragraph created from a previously split linked-style paragraph', () => {
517565
const linkedStyleConverter = createHeadingLinkedStyleConverter({
518566
runProperties: {

0 commit comments

Comments
 (0)