Skip to content

Commit 9714ffb

Browse files
authored
fix: clear linked style for the next paragraph (#2344)
* fix: clear linked style for the next paragraph * fix: address comment/preserve marks for splitted text * fix: address review comments
1 parent 45b4452 commit 9714ffb

10 files changed

Lines changed: 689 additions & 15 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { readTranslatedLinkedStyles } from '@core/parts/adapters/styles-read.js';
2+
3+
export const isLinkedParagraphStyleId = (editor, styleId) => {
4+
if (!styleId) return false;
5+
6+
const translatedStyles = readTranslatedLinkedStyles(editor)?.styles;
7+
const styleDefinition = translatedStyles?.[styleId];
8+
return Boolean(styleDefinition?.type === 'paragraph' && styleDefinition?.link);
9+
};
10+
11+
export const clearInheritedLinkedStyleId = (attrs, editor, { emptyParagraph = false } = {}) => {
12+
if (!emptyParagraph) return attrs;
13+
if (!attrs || typeof attrs !== 'object') return attrs;
14+
const paragraphProperties = attrs.paragraphProperties;
15+
const styleId = paragraphProperties?.styleId;
16+
if (!isLinkedParagraphStyleId(editor, styleId)) return attrs;
17+
18+
return {
19+
...attrs,
20+
paragraphProperties: {
21+
...paragraphProperties,
22+
styleId: null,
23+
},
24+
};
25+
};
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { clearInheritedLinkedStyleId, isLinkedParagraphStyleId } from './linkedStyleSplitHelpers.js';
3+
4+
describe('linkedStyleSplitHelpers', () => {
5+
describe('isLinkedParagraphStyleId', () => {
6+
it('returns true for linked paragraph styles from the converter', () => {
7+
const editor = {
8+
converter: {
9+
translatedLinkedStyles: {
10+
styles: {
11+
Heading1: { styleId: 'Heading1', type: 'paragraph', link: 'Heading1Char' },
12+
Emphasis: { styleId: 'Emphasis', type: 'character', link: 'EmphasisPara' },
13+
},
14+
},
15+
},
16+
};
17+
18+
expect(isLinkedParagraphStyleId(editor, 'Heading1')).toBe(true);
19+
});
20+
21+
it('returns false for missing style ids, missing converter data, non-paragraph styles, and ordinary paragraph styles', () => {
22+
expect(isLinkedParagraphStyleId({}, 'Heading1')).toBe(false);
23+
expect(
24+
isLinkedParagraphStyleId(
25+
{
26+
converter: {
27+
translatedLinkedStyles: {
28+
styles: {
29+
Emphasis: { styleId: 'Emphasis', type: 'character', link: 'EmphasisPara' },
30+
},
31+
},
32+
},
33+
},
34+
'Emphasis',
35+
),
36+
).toBe(false);
37+
expect(
38+
isLinkedParagraphStyleId(
39+
{
40+
converter: {
41+
translatedLinkedStyles: {
42+
styles: {
43+
BodyText: { styleId: 'BodyText', type: 'paragraph' },
44+
},
45+
},
46+
},
47+
},
48+
'BodyText',
49+
),
50+
).toBe(false);
51+
expect(
52+
isLinkedParagraphStyleId(
53+
{
54+
converter: {
55+
translatedLinkedStyles: {
56+
styles: {
57+
Heading1: { styleId: 'Heading1', type: 'paragraph', link: 'Heading1Char' },
58+
},
59+
},
60+
},
61+
},
62+
null,
63+
),
64+
).toBe(false);
65+
});
66+
});
67+
68+
describe('clearInheritedLinkedStyleId', () => {
69+
it('removes styleId when it belongs to a linked paragraph style', () => {
70+
const editor = {
71+
converter: {
72+
translatedLinkedStyles: {
73+
styles: {
74+
Heading1: { styleId: 'Heading1', type: 'paragraph', link: 'Heading1Char' },
75+
},
76+
},
77+
},
78+
};
79+
const attrs = {
80+
paragraphProperties: { styleId: 'Heading1', keep: true },
81+
preserve: true,
82+
};
83+
84+
const result = clearInheritedLinkedStyleId(attrs, editor, { emptyParagraph: true });
85+
86+
expect(result).toEqual({
87+
paragraphProperties: { styleId: null, keep: true },
88+
preserve: true,
89+
});
90+
expect(attrs).toEqual({
91+
paragraphProperties: { styleId: 'Heading1', keep: true },
92+
preserve: true,
93+
});
94+
});
95+
96+
it('preserves linked paragraph styleId when the new paragraph is not empty', () => {
97+
const editor = {
98+
converter: {
99+
translatedLinkedStyles: {
100+
styles: {
101+
Heading1: { styleId: 'Heading1', type: 'paragraph', link: 'Heading1Char' },
102+
},
103+
},
104+
},
105+
};
106+
const attrs = {
107+
paragraphProperties: { styleId: 'Heading1', keep: true },
108+
};
109+
110+
expect(clearInheritedLinkedStyleId(attrs, editor, { emptyParagraph: false })).toBe(attrs);
111+
});
112+
113+
it('leaves attrs unchanged for non-linked styles or missing paragraphProperties', () => {
114+
const editor = {
115+
converter: {
116+
translatedLinkedStyles: {
117+
styles: {
118+
Heading1: { styleId: 'Heading1', type: 'paragraph', link: 'Heading1Char' },
119+
BodyText: { styleId: 'BodyText', type: 'paragraph' },
120+
},
121+
},
122+
},
123+
};
124+
const attrs = {
125+
paragraphProperties: { styleId: 'BodyText', keep: true },
126+
};
127+
128+
expect(clearInheritedLinkedStyleId(attrs, editor, { emptyParagraph: false })).toBe(attrs);
129+
expect(clearInheritedLinkedStyleId(attrs, editor, { emptyParagraph: true })).toBe(attrs);
130+
expect(clearInheritedLinkedStyleId({ preserve: true }, editor, { emptyParagraph: true })).toEqual({
131+
preserve: true,
132+
});
133+
expect(clearInheritedLinkedStyleId(null, editor, { emptyParagraph: true })).toBe(null);
134+
});
135+
});
136+
});

packages/super-editor/src/core/commands/splitBlock.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { NodeSelection, TextSelection } from 'prosemirror-state';
22
import { canSplit } from 'prosemirror-transform';
33
import { defaultBlockAt } from '../helpers/defaultBlockAt.js';
44
import { Attribute } from '../Attribute.js';
5+
import { clearInheritedLinkedStyleId } from './linkedStyleSplitHelpers.js';
56

67
const isHeadingStyleId = (styleId) => typeof styleId === 'string' && /^heading\s*[1-6]$/i.test(styleId.trim());
78

@@ -65,6 +66,7 @@ export const splitBlock =
6566

6667
if (dispatch) {
6768
const atEnd = $to.parentOffset === $to.parent.content.size;
69+
newAttrs = clearInheritedLinkedStyleId(newAttrs, editor, { emptyParagraph: atEnd });
6870
if (selection instanceof TextSelection) tr.deleteSelection();
6971
const deflt = $from.depth === 0 ? null : defaultBlockAt($from.node(-1).contentMatchAt($from.indexAfter(-1)));
7072

packages/super-editor/src/core/commands/splitBlock.test.js

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,167 @@ describe('splitBlock', () => {
280280
expect(attrs.paragraphProperties?.styleId).toBeUndefined();
281281
});
282282

283+
it('does not inherit linked paragraph styles onto the newly created paragraph', () => {
284+
mockEditor.converter = {
285+
translatedLinkedStyles: {
286+
styles: {
287+
Heading2: { styleId: 'Heading2', type: 'paragraph', link: 'Heading2Char' },
288+
},
289+
},
290+
};
291+
const paragraphType = { name: 'paragraph', isTextblock: true, hasRequiredAttrs: vi.fn(() => false) };
292+
const parentNode = {
293+
contentMatchAt: vi.fn(() => ({
294+
edgeCount: 1,
295+
edge: vi.fn(() => ({ type: paragraphType })),
296+
})),
297+
};
298+
299+
const sourceAttrs = {
300+
paragraphProperties: { styleId: 'Heading2', keep: true },
301+
};
302+
const $from = createMockResolvedPos({
303+
depth: 1,
304+
parent: {
305+
isBlock: true,
306+
content: { size: 10 },
307+
type: { name: 'paragraph' },
308+
inlineContent: true,
309+
attrs: sourceAttrs,
310+
},
311+
parentOffset: 5,
312+
node: vi.fn((depth) => {
313+
if (depth === -1) return parentNode;
314+
return { type: { name: 'paragraph' }, attrs: sourceAttrs };
315+
}),
316+
});
317+
const $to = createMockResolvedPos({
318+
pos: 5,
319+
parent: { isBlock: true, content: { size: 10 }, type: { name: 'paragraph' }, inlineContent: true },
320+
parentOffset: 10,
321+
});
322+
323+
mockTr.selection = { $from, $to };
324+
mockState.selection = mockTr.selection;
325+
mockTr.doc = {
326+
resolve: vi.fn(() => $from),
327+
};
328+
329+
const command = splitBlock();
330+
command({ tr: mockTr, state: mockState, dispatch: () => {}, editor: mockEditor });
331+
332+
const splitTypes = mockTr.split.mock.calls[0][2];
333+
expect(splitTypes?.[0]?.attrs?.paragraphProperties?.styleId).toBeNull();
334+
expect(splitTypes?.[0]?.attrs?.paragraphProperties?.keep).toBe(true);
335+
expect(sourceAttrs.paragraphProperties.styleId).toBe('Heading2');
336+
});
337+
338+
it('preserves linked paragraph styles when the split creates a non-empty following paragraph', () => {
339+
mockEditor.converter = {
340+
translatedLinkedStyles: {
341+
styles: {
342+
Heading2: { styleId: 'Heading2', type: 'paragraph', link: 'Heading2Char' },
343+
},
344+
},
345+
};
346+
const paragraphType = { name: 'paragraph', isTextblock: true, hasRequiredAttrs: vi.fn(() => false) };
347+
const parentNode = {
348+
contentMatchAt: vi.fn(() => ({
349+
edgeCount: 1,
350+
edge: vi.fn(() => ({ type: paragraphType })),
351+
})),
352+
};
353+
354+
const sourceAttrs = {
355+
paragraphProperties: { styleId: 'Heading2', keep: true },
356+
};
357+
const $from = createMockResolvedPos({
358+
depth: 1,
359+
parent: {
360+
isBlock: true,
361+
content: { size: 10 },
362+
type: { name: 'paragraph' },
363+
inlineContent: true,
364+
attrs: sourceAttrs,
365+
},
366+
parentOffset: 5,
367+
node: vi.fn((depth) => {
368+
if (depth === -1) return parentNode;
369+
return { type: { name: 'paragraph' }, attrs: sourceAttrs };
370+
}),
371+
});
372+
const $to = createMockResolvedPos({
373+
pos: 5,
374+
parent: { isBlock: true, content: { size: 10 }, type: { name: 'paragraph' }, inlineContent: true },
375+
parentOffset: 5,
376+
});
377+
378+
mockTr.selection = { $from, $to };
379+
mockState.selection = mockTr.selection;
380+
mockTr.doc = {
381+
resolve: vi.fn(() => $from),
382+
};
383+
384+
const command = splitBlock();
385+
command({ tr: mockTr, state: mockState, dispatch: () => {}, editor: mockEditor });
386+
387+
const splitTypes = mockTr.split.mock.calls[0][2];
388+
expect(splitTypes).toBeUndefined();
389+
});
390+
391+
it('preserves ordinary paragraph styles on the newly created paragraph', () => {
392+
mockEditor.converter = {
393+
translatedLinkedStyles: {
394+
styles: {
395+
BodyText: { styleId: 'BodyText', type: 'paragraph' },
396+
},
397+
},
398+
};
399+
const paragraphType = { name: 'paragraph', isTextblock: true, hasRequiredAttrs: vi.fn(() => false) };
400+
const parentNode = {
401+
contentMatchAt: vi.fn(() => ({
402+
edgeCount: 1,
403+
edge: vi.fn(() => ({ type: paragraphType })),
404+
})),
405+
};
406+
407+
const sourceAttrs = {
408+
paragraphProperties: { styleId: 'BodyText', keep: true },
409+
};
410+
const $from = createMockResolvedPos({
411+
depth: 1,
412+
parent: {
413+
isBlock: true,
414+
content: { size: 10 },
415+
type: { name: 'paragraph' },
416+
inlineContent: true,
417+
attrs: sourceAttrs,
418+
},
419+
parentOffset: 5,
420+
node: vi.fn((depth) => {
421+
if (depth === -1) return parentNode;
422+
return { type: { name: 'paragraph' }, attrs: sourceAttrs };
423+
}),
424+
});
425+
const $to = createMockResolvedPos({
426+
pos: 5,
427+
parent: { isBlock: true, content: { size: 10 }, type: { name: 'paragraph' }, inlineContent: true },
428+
parentOffset: 10,
429+
});
430+
431+
mockTr.selection = { $from, $to };
432+
mockState.selection = mockTr.selection;
433+
mockTr.doc = {
434+
resolve: vi.fn(() => $from),
435+
};
436+
437+
const command = splitBlock();
438+
command({ tr: mockTr, state: mockState, dispatch: () => {}, editor: mockEditor });
439+
440+
const splitTypes = mockTr.split.mock.calls[0][2];
441+
expect(splitTypes?.[0]?.attrs?.paragraphProperties?.styleId).toBe('BodyText');
442+
});
443+
283444
it('does not mutate source attrs when removing nested override attributes', () => {
284445
const paragraphType = { name: 'paragraph', isTextblock: true, hasRequiredAttrs: vi.fn(() => false) };
285446
const parentNode = {

0 commit comments

Comments
 (0)