Skip to content

Commit 959d82a

Browse files
committed
fix: address review comments
1 parent 352dbbf commit 959d82a

2 files changed

Lines changed: 119 additions & 11 deletions

File tree

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

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -380,16 +380,24 @@ export const applyLinkedStyleToTransaction = (tr, editor, style) => {
380380
};
381381
};
382382

383-
// Function to clear formatting marks from text content
384-
const clearFormattingMarks = (startPos, endPos) => {
385-
tr.doc.nodesBetween(startPos, endPos, (node, pos) => {
386-
if (node.isText && node.marks.length > 0) {
387-
node.marks.forEach((mark) => {
388-
if (FORMATTING_MARK_NAMES.has(mark.type.name)) {
389-
tr.removeMark(pos, pos + node.nodeSize, mark);
390-
}
391-
});
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;
392389
}
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+
});
393401
return true;
394402
});
395403
};
@@ -447,7 +455,8 @@ export const applyLinkedStyleToTransaction = (tr, editor, style) => {
447455
if (startPara && endPara && startPara.pos === endPara.pos) {
448456
const bounds = getParagraphTextBounds(tr.doc, startPara.pos, startPara.node);
449457
const coversFullParagraphText = bounds && from <= bounds.from && to >= bounds.to;
450-
if (!coversFullParagraphText) {
458+
// No text (empty / image-only): cannot do linked character range apply; use paragraph path below.
459+
if (bounds && !coversFullParagraphText) {
451460
clearFormattingMarks(from, to);
452461
applyCharacterStyleMarkToRange(tr, textStyleType, from, to, linkedCharStyleId);
453462
clearStoredFormattingMarks();
@@ -467,6 +476,18 @@ export const applyLinkedStyleToTransaction = (tr, editor, style) => {
467476
return true;
468477
});
469478

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

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

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
2-
import { NodeSelection, TextSelection } from 'prosemirror-state';
2+
import { EditorState, NodeSelection, TextSelection } from 'prosemirror-state';
33
import { initTestEditor, loadTestDataForEditorTests } from '../../tests/helpers/helpers.js';
44

55
const findParagraphInfo = (doc, paragraphIndex) => {
@@ -178,6 +178,93 @@ describe('LinkedStyles Extension', () => {
178178
});
179179
expect(sawEmphasisInSelection).toBe(true);
180180
});
181+
182+
it('partial linked character apply clears formatting only inside the selection', () => {
183+
const minimalDoc = editor.schema.nodeFromJSON({
184+
type: 'doc',
185+
content: [
186+
{
187+
type: 'paragraph',
188+
content: [
189+
{
190+
type: 'run',
191+
content: [{ type: 'text', text: 'Hello world' }],
192+
},
193+
],
194+
},
195+
],
196+
});
197+
editor.setState(EditorState.create({ schema: editor.schema, doc: minimalDoc }));
198+
199+
let lineFrom;
200+
let lineTo;
201+
editor.state.doc.descendants((node, pos) => {
202+
if (node.isText && node.text === 'Hello world') {
203+
lineFrom = pos;
204+
lineTo = pos + node.nodeSize;
205+
return false;
206+
}
207+
return true;
208+
});
209+
expect(lineFrom).toBeDefined();
210+
211+
const { bold } = editor.schema.marks;
212+
editor.view.dispatch(editor.view.state.tr.addMark(lineFrom, lineTo, bold.create()));
213+
214+
const world = 'world';
215+
const selFrom = lineFrom + 'Hello world'.indexOf(world);
216+
const selTo = selFrom + world.length;
217+
218+
const styleWithLink = {
219+
...headingStyle,
220+
type: 'paragraph',
221+
definition: {
222+
...headingStyle.definition,
223+
attrs: { ...headingStyle.definition.attrs, link: 'Emphasis' },
224+
},
225+
};
226+
editor.view.dispatch(
227+
editor.view.state.tr.setSelection(TextSelection.create(editor.view.state.doc, selFrom, selTo)),
228+
);
229+
expect(editor.commands.setLinkedStyle(styleWithLink)).toBe(true);
230+
231+
const insidePrefix = selFrom - 1;
232+
expect(insidePrefix).toBeGreaterThanOrEqual(lineFrom);
233+
expect(
234+
editor.state.doc
235+
.resolve(insidePrefix)
236+
.marks()
237+
.some((m) => m.type.name === 'bold'),
238+
).toBe(true);
239+
});
240+
241+
it('applies paragraph style when the paragraph has no text (e.g. break-only)', () => {
242+
const minimalDoc = editor.schema.nodeFromJSON({
243+
type: 'doc',
244+
content: [
245+
{
246+
type: 'paragraph',
247+
content: [
248+
{
249+
type: 'run',
250+
content: [{ type: 'hardBreak', attrs: {} }],
251+
},
252+
],
253+
},
254+
],
255+
});
256+
editor.setState(EditorState.create({ schema: editor.schema, doc: minimalDoc }));
257+
258+
const info = findParagraphInfo(editor.state.doc, 0);
259+
expect(info).toBeTruthy();
260+
const innerFrom = info.pos + 1;
261+
const innerTo = info.pos + info.node.nodeSize - 1;
262+
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, innerFrom, innerTo)));
263+
264+
expect(editor.commands.setLinkedStyle(headingStyle)).toBe(true);
265+
const para = findParagraphInfo(editor.state.doc, 0);
266+
expect(getParagraphProps(para.node).styleId).toBe('Heading1');
267+
});
181268
});
182269

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

0 commit comments

Comments
 (0)