Skip to content

Commit 484ee8a

Browse files
fix: apply linked style to selected range only (#2704)
* fix: apply linked style to selected range only * fix: address review comments * test: add coverage for character style branch and cross-paragraph linked style fallthrough * test: add behavior tests for linked style partial selection (SD-2425) * refactor: extract isLinkedCharacterStyleId helper and simplify boolean condition * fix: handle character style with collapsed cursor via stored marks instead of paragraph path --------- Co-authored-by: Caio Pizzol <caio@harbourshare.com> Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com>
1 parent 1768159 commit 484ee8a

File tree

9 files changed

+656
-16
lines changed

9 files changed

+656
-16
lines changed

packages/super-editor/src/editors/v1/core/commands/linkedStyleSplitHelpers.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ export const isLinkedParagraphStyleId = (editor, styleId) => {
88
return Boolean(styleDefinition?.type === 'paragraph' && styleDefinition?.link);
99
};
1010

11+
export const isLinkedCharacterStyleId = (editor, styleId) => {
12+
if (!styleId) return false;
13+
14+
const translatedStyles = readTranslatedLinkedStyles(editor)?.styles;
15+
if (!translatedStyles) return false;
16+
return Object.values(translatedStyles).some((def) => def?.type === 'paragraph' && def?.link === styleId);
17+
};
18+
1119
export const clearInheritedLinkedStyleId = (attrs, editor, { emptyParagraph = false } = {}) => {
1220
if (!emptyParagraph) return attrs;
1321
if (!attrs || typeof attrs !== 'object') return attrs;

packages/super-editor/src/editors/v1/core/commands/linkedStyleSplitHelpers.test.js

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, expect, it } from 'vitest';
2-
import { clearInheritedLinkedStyleId, isLinkedParagraphStyleId } from './linkedStyleSplitHelpers.js';
2+
import {
3+
clearInheritedLinkedStyleId,
4+
isLinkedCharacterStyleId,
5+
isLinkedParagraphStyleId,
6+
} from './linkedStyleSplitHelpers.js';
37

48
describe('linkedStyleSplitHelpers', () => {
59
describe('isLinkedParagraphStyleId', () => {
@@ -65,6 +69,40 @@ describe('linkedStyleSplitHelpers', () => {
6569
});
6670
});
6771

72+
describe('isLinkedCharacterStyleId', () => {
73+
it('returns true when the styleId is the linked character counterpart of a paragraph style', () => {
74+
const editor = {
75+
converter: {
76+
translatedLinkedStyles: {
77+
styles: {
78+
Heading1: { styleId: 'Heading1', type: 'paragraph', link: 'Heading1Char' },
79+
},
80+
},
81+
},
82+
};
83+
84+
expect(isLinkedCharacterStyleId(editor, 'Heading1Char')).toBe(true);
85+
});
86+
87+
it('returns false for paragraph style ids, unknown ids, and missing data', () => {
88+
const editor = {
89+
converter: {
90+
translatedLinkedStyles: {
91+
styles: {
92+
Heading1: { styleId: 'Heading1', type: 'paragraph', link: 'Heading1Char' },
93+
BodyText: { styleId: 'BodyText', type: 'paragraph' },
94+
},
95+
},
96+
},
97+
};
98+
99+
expect(isLinkedCharacterStyleId(editor, 'Heading1')).toBe(false);
100+
expect(isLinkedCharacterStyleId(editor, 'Unknown')).toBe(false);
101+
expect(isLinkedCharacterStyleId(editor, null)).toBe(false);
102+
expect(isLinkedCharacterStyleId({}, 'Heading1Char')).toBe(false);
103+
});
104+
});
105+
68106
describe('clearInheritedLinkedStyleId', () => {
69107
it('removes styleId when it belongs to a linked paragraph style', () => {
70108
const editor = {

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: 98 additions & 11 deletions
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);
@@ -351,16 +380,24 @@ export const applyLinkedStyleToTransaction = (tr, editor, style) => {
351380
};
352381
};
353382

354-
// Function to clear formatting marks from text content
355-
const clearFormattingMarks = (startPos, endPos) => {
356-
tr.doc.nodesBetween(startPos, endPos, (node, pos) => {
357-
if (node.isText && node.marks.length > 0) {
358-
node.marks.forEach((mark) => {
359-
if (FORMATTING_MARK_NAMES.has(mark.type.name)) {
360-
tr.removeMark(pos, pos + node.nodeSize, mark);
361-
}
362-
});
383+
// Clear FORMATTING_MARK_NAMES only inside [rangeFrom, rangeTo), not across whole text nodes
384+
// (selection can split mid-node; removeMark must use the intersection with each text slice).
385+
const clearFormattingMarks = (rangeFrom, rangeTo) => {
386+
tr.doc.nodesBetween(rangeFrom, rangeTo, (node, pos) => {
387+
if (!node.isText || node.marks.length === 0) {
388+
return true;
363389
}
390+
const nodeEnd = pos + node.nodeSize;
391+
const clearFrom = Math.max(pos, rangeFrom);
392+
const clearTo = Math.min(nodeEnd, rangeTo);
393+
if (clearFrom >= clearTo) {
394+
return true;
395+
}
396+
node.marks.forEach((mark) => {
397+
if (FORMATTING_MARK_NAMES.has(mark.type.name)) {
398+
tr.removeMark(clearFrom, clearTo, mark);
399+
}
400+
});
364401
return true;
365402
});
366403
};
@@ -377,7 +414,23 @@ export const applyLinkedStyleToTransaction = (tr, editor, style) => {
377414
}
378415
};
379416

380-
// Handle cursor position (no selection)
417+
// Character styles: only affect the selected range (or stored marks for collapsed cursor)
418+
if (style.type === 'character') {
419+
if (!textStyleType) return false;
420+
if (from === to) {
421+
// Collapsed cursor: set stored marks for subsequent typing
422+
const sourceMarks = tr.storedMarks ?? state.storedMarks ?? selection.$from.marks();
423+
const filtered = sourceMarks.filter((mark) => mark.type !== textStyleType);
424+
tr.setStoredMarks([...filtered, textStyleType.create({ styleId: style.id })]);
425+
return true;
426+
}
427+
clearFormattingMarks(from, to);
428+
applyCharacterStyleMarkToRange(tr, textStyleType, from, to, style.id);
429+
clearStoredFormattingMarks();
430+
return true;
431+
}
432+
433+
// Handle cursor position (no selection) — paragraph styles only
381434
if (from === to) {
382435
let pos = from;
383436
let paragraphNode = tr.doc.nodeAt(from);
@@ -398,7 +451,29 @@ export const applyLinkedStyleToTransaction = (tr, editor, style) => {
398451
return true;
399452
}
400453

401-
// Handle selection spanning multiple nodes
454+
// Paragraph style + partial selection in a single paragraph: apply linked character style to range only (Word behavior)
455+
if (style.type === 'paragraph' && textStyleType) {
456+
const linkedCharStyleId = style.definition?.attrs?.link;
457+
if (linkedCharStyleId) {
458+
const $fromPos = tr.doc.resolve(from);
459+
const $toPos = tr.doc.resolve(to);
460+
const startPara = findParentNodeClosestToPos($fromPos, (n) => n.type.name === 'paragraph');
461+
const endPara = findParentNodeClosestToPos($toPos, (n) => n.type.name === 'paragraph');
462+
if (startPara && endPara && startPara.pos === endPara.pos) {
463+
const bounds = getParagraphTextBounds(tr.doc, startPara.pos, startPara.node);
464+
const coversFullParagraphText = bounds && from <= bounds.from && to >= bounds.to;
465+
// No text (empty / image-only): cannot do linked character range apply; use paragraph path below.
466+
if (bounds && !coversFullParagraphText) {
467+
clearFormattingMarks(from, to);
468+
applyCharacterStyleMarkToRange(tr, textStyleType, from, to, linkedCharStyleId);
469+
clearStoredFormattingMarks();
470+
return true;
471+
}
472+
}
473+
}
474+
}
475+
476+
// Handle selection spanning multiple nodes / full paragraph(s)
402477
const paragraphPositions = [];
403478

404479
tr.doc.nodesBetween(from, to, (node, pos) => {
@@ -408,6 +483,18 @@ export const applyLinkedStyleToTransaction = (tr, editor, style) => {
408483
return true;
409484
});
410485

486+
// nodesBetween often skips block parents when the range only covers inline content (e.g. image-only).
487+
if (paragraphPositions.length === 0 && from !== to) {
488+
const seen = new Set();
489+
const pushParagraph = (info) => {
490+
if (!info || seen.has(info.pos)) return;
491+
seen.add(info.pos);
492+
paragraphPositions.push({ node: info.node, pos: info.pos });
493+
};
494+
pushParagraph(findParentNodeClosestToPos(tr.doc.resolve(from), (n) => n.type.name === 'paragraph'));
495+
pushParagraph(findParentNodeClosestToPos(tr.doc.resolve(to), (n) => n.type.name === 'paragraph'));
496+
}
497+
411498
// Apply style to all paragraphs in selection (with clean attributes and cleared marks)
412499
paragraphPositions.forEach(({ node, pos }) => {
413500
// Clear formatting marks within this paragraph

0 commit comments

Comments
 (0)