Skip to content

Commit 8001511

Browse files
fix: newline style inheritance after clearing inline formatting (SD-2228) (#2559)
* fix(editor): treat empty storedMarks array as explicit no-formatting override When storedMarks is set to an empty array ([]), it represents an intentional user action to clear all formatting. Previously, the `|| null` coercion and falsy checks on storedMarks caused empty arrays to be treated the same as absent storedMarks, falling through to paragraph/style inheritance and re-applying formatting the user had explicitly removed. Switch from `||` to `??` and introduce a `hasStoredMarks` flag using strict null checks so that an empty array is respected as "no marks". * fix(editor): preserve explicit stored marks across paragraph splits When a user applies formatting (e.g., bold) and then presses Enter to create a new paragraph, the storedMarks set by the formatting action were being overwritten by applyStyleMarks, which resolved style marks from the document's style hierarchy. This caused the user's explicit formatting choice to be lost on the new line. Capture storedMarks before style resolution and, when present, restore or retain them instead of replacing with style-resolved marks. * fix(editor): preserve storedMarks through run-management plugins The run-management append-only plugins (calculateInlineRunProperties, cleanupEmptyRuns, wrapTextInRuns) emit follow-up transactions that rebuild run nodes and normalize selections. These transactions inadvertently clear storedMarks, discarding formatting the user had explicitly toggled before typing. Propagate the original storedMarks through each plugin's transaction so that explicit formatting state survives run restructuring. * fix(editor): clear inherited paragraph runProperties on split when run has no formatting When pressing Enter at the end of a run that has no inline formatting, the new paragraph was inheriting the parent paragraph's runProperties (e.g., bold). This caused the new line to appear formatted even though the cursor was in an unformatted run. Extract a shared syncSplitParagraphRunProperties helper that either copies the current run's properties or explicitly removes them from the new paragraph attrs, ensuring the split paragraph only inherits formatting that the run actually carries. * test(behavior): add e2e test for new-line style inheritance (SD-2228) Verify that pressing Enter at the end of a line where bold+italic was explicitly removed does not carry those marks into the new paragraph. Covers the full user flow: type formatted text, remove formatting from the end of the line, press Enter, and assert the new line is unformatted. * fix(super-editor): preserve empty paragraph run properties on split * fix(super-editor): honor cleared stored marks in formatting state * fix(super-editor): preserve cleared formatting when splitting paragraphs * fix(super-editor): preserve enclosing run properties on split-run fallback * refactor(super-editor): share normalizeRunProperties helper
1 parent e623a33 commit 8001511

13 files changed

Lines changed: 411 additions & 45 deletions

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

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NodeSelection, TextSelection } from 'prosemirror-state';
22
import { canSplit } from 'prosemirror-transform';
33
import { defaultBlockAt } from '../helpers/defaultBlockAt.js';
4+
import { getSplitRunProperties, syncSplitParagraphRunProperties } from '../helpers/splitParagraphRunProperties.js';
45
import { Attribute } from '../Attribute.js';
56
import { clearInheritedLinkedStyleId } from './linkedStyleSplitHelpers.js';
67

@@ -29,21 +30,6 @@ const ensureMarks = (state, splittableMarks) => {
2930
}
3031
};
3132

32-
/**
33-
* Extracts runProperties from the run node at the cursor position.
34-
* When the cursor is directly inside a paragraph (not inside a run), it
35-
* looks at the node just before the cursor (which is typically a run node).
36-
* @param {import('prosemirror-model').ResolvedPos} $from
37-
* @returns {Record<string, unknown> | null}
38-
*/
39-
const getRunPropertiesAtCursor = ($from) => {
40-
const runNode = $from.nodeBefore;
41-
if (runNode?.type.name === 'run' && runNode.attrs.runProperties) {
42-
return { ...runNode.attrs.runProperties };
43-
}
44-
return null;
45-
};
46-
4733
/**
4834
* Will split the current node into two nodes. If the selection is not
4935
* splittable, the command will be ignored.
@@ -87,16 +73,8 @@ export const splitBlock =
8773
// current run's runProperties on the new paragraph so the toolbar and
8874
// wrapTextInRunsPlugin know which inline formatting to inherit.
8975
if (atEnd) {
90-
const runProperties = getRunPropertiesAtCursor($from);
91-
if (runProperties) {
92-
newAttrs = {
93-
...newAttrs,
94-
paragraphProperties: {
95-
...(newAttrs.paragraphProperties || {}),
96-
runProperties,
97-
},
98-
};
99-
}
76+
const runProperties = getSplitRunProperties(state, $from);
77+
newAttrs = syncSplitParagraphRunProperties(newAttrs, runProperties);
10078
}
10179
if (selection instanceof TextSelection) tr.deleteSelection();
10280
const deflt = $from.depth === 0 ? null : defaultBlockAt($from.node(-1).contentMatchAt($from.indexAfter(-1)));

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

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { TextSelection } from 'prosemirror-state';
23
import { splitBlock } from './splitBlock.js';
34

45
vi.mock('../Attribute.js', () => ({
@@ -11,6 +12,21 @@ vi.mock('prosemirror-transform', () => ({
1112
canSplit: vi.fn(() => true),
1213
}));
1314

15+
vi.mock('@converter/styles.js', () => ({
16+
decodeRPrFromMarks: vi.fn((marks) => {
17+
const runProperties = {};
18+
for (const mark of marks || []) {
19+
if (mark?.type?.name === 'bold' && mark.attrs?.value !== '0' && mark.attrs?.value !== false) {
20+
runProperties.bold = true;
21+
}
22+
if (mark?.type?.name === 'textStyle') {
23+
Object.assign(runProperties, mark.attrs || {});
24+
}
25+
}
26+
return runProperties;
27+
}),
28+
}));
29+
1430
/**
1531
* Create a mock resolved position ($from/$to) compatible with ProseMirror
1632
*/
@@ -209,6 +225,104 @@ describe('splitBlock', () => {
209225
});
210226

211227
describe('edge cases', () => {
228+
it('prefers explicit storedMarks over the previous run when splitting at paragraph end', () => {
229+
const paragraphType = { name: 'paragraph', isTextblock: true, hasRequiredAttrs: vi.fn(() => false) };
230+
const parentNode = {
231+
contentMatchAt: vi.fn(() => ({
232+
edgeCount: 1,
233+
edge: vi.fn(() => ({ type: paragraphType })),
234+
})),
235+
canReplaceWith: vi.fn(() => true),
236+
};
237+
const textStyleMark = {
238+
type: { name: 'textStyle' },
239+
attrs: { fontFamily: 'Arial, sans-serif', fontSize: '12pt' },
240+
};
241+
const paragraphAttrs = { paragraphProperties: { runProperties: { bold: true } } };
242+
const $from = createMockResolvedPos({
243+
depth: 1,
244+
parent: {
245+
isBlock: true,
246+
content: { size: 5 },
247+
type: { name: 'paragraph' },
248+
inlineContent: true,
249+
attrs: paragraphAttrs,
250+
},
251+
parentOffset: 5,
252+
node: vi.fn((depth) => {
253+
if (depth === -1) return parentNode;
254+
return { type: { name: 'paragraph' }, attrs: paragraphAttrs };
255+
}),
256+
nodeBefore: {
257+
type: { name: 'run' },
258+
attrs: { runProperties: { bold: true } },
259+
},
260+
indexAfter: vi.fn(() => 0),
261+
});
262+
const $to = createMockResolvedPos({
263+
parentOffset: 5,
264+
parent: { content: { size: 5 } },
265+
});
266+
267+
mockState.storedMarks = [textStyleMark];
268+
mockTr.selection = { $from, $to };
269+
mockState.selection = mockTr.selection;
270+
mockTr.doc = {
271+
resolve: vi.fn((pos) => (pos === 1 ? $from : $to)),
272+
};
273+
274+
const command = splitBlock();
275+
command({ tr: mockTr, state: mockState, dispatch: () => {}, editor: mockEditor });
276+
277+
const [, , types] = mockTr.split.mock.calls[0];
278+
expect(types[0].attrs.paragraphProperties.runProperties).toEqual(textStyleMark.attrs);
279+
});
280+
281+
it('preserves paragraph runProperties at paragraph end for an empty paragraph', () => {
282+
const paragraphType = { name: 'paragraph', isTextblock: true, hasRequiredAttrs: vi.fn(() => false) };
283+
const parentNode = {
284+
contentMatchAt: vi.fn(() => ({
285+
edgeCount: 1,
286+
edge: vi.fn(() => ({ type: paragraphType })),
287+
})),
288+
canReplaceWith: vi.fn(() => true),
289+
};
290+
const paragraphAttrs = { paragraphProperties: { runProperties: { bold: true } } };
291+
const $from = createMockResolvedPos({
292+
depth: 1,
293+
parent: {
294+
isBlock: true,
295+
content: { size: 0 },
296+
type: { name: 'paragraph' },
297+
inlineContent: true,
298+
attrs: paragraphAttrs,
299+
},
300+
parentOffset: 0,
301+
node: vi.fn((depth) => {
302+
if (depth === -1) return parentNode;
303+
return { type: { name: 'paragraph' }, attrs: paragraphAttrs };
304+
}),
305+
nodeBefore: null,
306+
indexAfter: vi.fn(() => 0),
307+
});
308+
const $to = createMockResolvedPos({
309+
parentOffset: 0,
310+
parent: { content: { size: 0 } },
311+
});
312+
313+
mockTr.selection = { $from, $to };
314+
mockState.selection = mockTr.selection;
315+
mockTr.doc = {
316+
resolve: vi.fn((pos) => (pos === 1 ? $from : $to)),
317+
};
318+
319+
const command = splitBlock();
320+
command({ tr: mockTr, state: mockState, dispatch: () => {}, editor: mockEditor });
321+
322+
const [, , types] = mockTr.split.mock.calls[0];
323+
expect(types[0].attrs.paragraphProperties.runProperties).toEqual({ bold: true });
324+
});
325+
212326
it('does not call ensureMarks when keepMarks is false', () => {
213327
const $from = createMockResolvedPos({
214328
marks: [{ type: { name: 'bold' }, attrs: { value: true } }],

packages/super-editor/src/core/helpers/getMarksFromSelection.js

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { calculateResolvedParagraphProperties } from '@extensions/paragraph/reso
33
import { decodeRPrFromMarks, encodeMarksFromRPr } from '@converter/styles.js';
44

55
import { resolveRunProperties } from '@superdoc/style-engine/ooxml';
6+
import { normalizeRunProperties } from './normalizeRunProperties.js';
67

78
export function getMarksFromSelection(state, editor) {
89
return getSelectionFormattingState(state, editor).resolvedMarks;
@@ -13,7 +14,7 @@ export function getSelectionFormattingState(state, editor) {
1314

1415
if (empty) {
1516
return getFormattingStateAtPos(state, state.selection.$head.pos, editor, {
16-
storedMarks: state.storedMarks || null,
17+
storedMarks: state.storedMarks ?? null,
1718
includeCursorMarksWithStoredMarks: true,
1819
});
1920
}
@@ -31,14 +32,16 @@ export function getFormattingStateAtPos(state, pos, editor, options = {}) {
3132
const context = getParagraphRunContext($pos, editor);
3233
const currentRunProperties = context?.runProperties || null;
3334
const cursorMarks = $pos.marks();
35+
const hasStoredMarks = storedMarks !== null;
36+
const hasExplicitEmptyStoredMarks = hasStoredMarks && storedMarks.length === 0;
3437
const resolvedMarks = [];
3538
const inlineMarks = [];
3639

3740
let inlineRunProperties = null;
3841
if (preferParagraphRunProperties) {
3942
inlineRunProperties = context?.paragraphAttrs?.paragraphProperties?.runProperties || null;
4043
inlineMarks.push(...createMarksFromRunProperties(state, inlineRunProperties, editor));
41-
} else if (storedMarks) {
44+
} else if (hasStoredMarks) {
4245
inlineMarks.push(...storedMarks);
4346
inlineRunProperties = decodeRPrFromMarks(storedMarks);
4447
} else if (context?.isEmpty) {
@@ -52,18 +55,28 @@ export function getFormattingStateAtPos(state, pos, editor, options = {}) {
5255
inlineRunProperties = decodeRPrFromMarks(inlineMarks);
5356
}
5457

58+
if (hasExplicitEmptyStoredMarks) {
59+
return {
60+
resolvedMarks: [],
61+
inlineMarks: [],
62+
resolvedRunProperties: {},
63+
inlineRunProperties: {},
64+
styleRunProperties: {},
65+
};
66+
}
67+
5568
const resolvedFromSelection = getInheritedRunProperties(
5669
$pos,
5770
editor,
58-
preferParagraphRunProperties || (!storedMarks && context?.isEmpty)
71+
preferParagraphRunProperties || (!hasStoredMarks && context?.isEmpty)
5972
? context?.paragraphAttrs?.paragraphProperties?.runProperties || null
6073
: inlineRunProperties,
6174
);
6275
const resolvedRunProperties = resolvedFromSelection?.resolvedRunProperties ?? inlineRunProperties;
6376
const styleRunProperties = resolvedFromSelection?.styleRunProperties ?? null;
6477
const resolvedMarksFromProperties = createMarksFromRunProperties(state, resolvedRunProperties, editor);
6578
resolvedMarks.push(...mergeResolvedMarksWithInlineFallback(resolvedMarksFromProperties, inlineMarks));
66-
if (storedMarks && includeCursorMarksWithStoredMarks) {
79+
if (hasStoredMarks && includeCursorMarksWithStoredMarks) {
6780
resolvedMarks.push(...cursorMarks);
6881
}
6982

@@ -240,11 +253,6 @@ function getParagraphRunContext($pos, editor) {
240253
};
241254
}
242255

243-
function normalizeRunProperties(runProperties) {
244-
if (!runProperties || typeof runProperties !== 'object') return null;
245-
return Object.keys(runProperties).length > 0 ? runProperties : null;
246-
}
247-
248256
function getSafeResolutionContext(editor, node, $pos, paragraphAttrs) {
249257
const fallback = {
250258
params: {

packages/super-editor/src/core/helpers/getMarksFromSelection.test.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,24 @@ describe('getMarksFromSelection', () => {
104104
expect(result.some((mark) => mark.type.name === 'bold')).toBe(false);
105105
});
106106

107+
it('treats empty storedMarks as an explicit no-formatting override', () => {
108+
const testDoc = customSchema.node('doc', null, [
109+
customSchema.node('paragraph', { paragraphProperties: { runProperties: { bold: true } } }),
110+
]);
111+
const baseState = EditorState.create({ schema: customSchema, doc: testDoc });
112+
const tr = baseState.tr.setSelection(TextSelection.create(testDoc, 1));
113+
tr.setStoredMarks([]);
114+
const state = baseState.apply(tr);
115+
116+
const result = getSelectionFormattingState(state, mockEditor);
117+
118+
expect(result.inlineMarks).toEqual([]);
119+
expect(result.inlineRunProperties).toEqual({});
120+
expect(result.resolvedMarks).toEqual([]);
121+
expect(result.resolvedRunProperties).toEqual({});
122+
expect(result.styleRunProperties).toEqual({});
123+
});
124+
107125
it('does not return inherited marks when paragraph has text content', () => {
108126
const testDoc = customSchema.node('doc', null, [
109127
customSchema.node('paragraph', { paragraphProperties: { runProperties: { bold: true } } }, [
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Normalizes runProperties objects so empty or invalid values are treated as null.
3+
*
4+
* @param {Record<string, unknown> | null | undefined} runProperties
5+
* @returns {Record<string, unknown> | null}
6+
*/
7+
export function normalizeRunProperties(runProperties) {
8+
if (!runProperties || typeof runProperties !== 'object') return null;
9+
return Object.keys(runProperties).length > 0 ? runProperties : null;
10+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { decodeRPrFromMarks } from '@converter/styles.js';
2+
import { normalizeRunProperties } from './normalizeRunProperties.js';
3+
4+
/**
5+
* Extracts runProperties from the cursor context.
6+
* When the cursor is directly inside a paragraph (not inside a run), it
7+
* looks at the node just before the cursor. For empty paragraphs, it falls
8+
* back to `paragraphProperties.runProperties`.
9+
*
10+
* @param {import('prosemirror-model').ResolvedPos} $from
11+
* @returns {Record<string, unknown> | null}
12+
*/
13+
export function getRunPropertiesAtCursor($from) {
14+
if ($from.parent?.type.name === 'run' && $from.parent.attrs?.runProperties) {
15+
return { ...$from.parent.attrs.runProperties };
16+
}
17+
18+
const runNode = $from.nodeBefore;
19+
if (runNode?.type.name === 'run' && runNode.attrs.runProperties) {
20+
return { ...runNode.attrs.runProperties };
21+
}
22+
23+
if ($from.parent?.type.name === 'paragraph' && $from.parent.content.size === 0) {
24+
const paragraphRunProperties = $from.parent.attrs?.paragraphProperties?.runProperties;
25+
if (paragraphRunProperties && typeof paragraphRunProperties === 'object') {
26+
return { ...paragraphRunProperties };
27+
}
28+
}
29+
30+
return null;
31+
}
32+
33+
/**
34+
* Resolves the inline formatting source to carry onto a newly split paragraph.
35+
* Explicit stored marks take precedence over inherited run-node formatting.
36+
*
37+
* @param {import('prosemirror-state').EditorState} state
38+
* @param {import('prosemirror-model').ResolvedPos} $from
39+
* @returns {Record<string, unknown> | null}
40+
*/
41+
export function getSplitRunProperties(state, $from) {
42+
if (state.storedMarks !== null) {
43+
return normalizeRunProperties(decodeRPrFromMarks(state.storedMarks));
44+
}
45+
46+
return getRunPropertiesAtCursor($from);
47+
}
48+
49+
/**
50+
* Sync paragraph-level runProperties for a newly split paragraph.
51+
*
52+
* @param {Record<string, any>} attrs
53+
* @param {Record<string, unknown> | null} runProperties
54+
* @returns {Record<string, any>}
55+
*/
56+
export function syncSplitParagraphRunProperties(attrs, runProperties) {
57+
const nextParagraphProperties = { ...(attrs.paragraphProperties || {}) };
58+
if (runProperties) {
59+
nextParagraphProperties.runProperties = { ...runProperties };
60+
} else {
61+
delete nextParagraphProperties.runProperties;
62+
}
63+
64+
return {
65+
...attrs,
66+
paragraphProperties: nextParagraphProperties,
67+
};
68+
}

packages/super-editor/src/extensions/run/calculateInlineRunPropertiesPlugin.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ export const calculateInlineRunPropertiesPlugin = (editor) =>
7878

7979
if (!runPositions.size) return null;
8080

81-
const selectionPreserver = createSelectionPreserver(tr, newState.selection);
81+
const selectionPreserver = createSelectionPreserver(tr, newState.selection, newState.storedMarks);
8282
const sortedRunPositions = Array.from(runPositions).sort((a, b) => b - a);
8383

8484
sortedRunPositions.forEach((pos) => {
@@ -457,7 +457,7 @@ function stableStringifyInlineProps(inlineProps) {
457457
* @param {import('prosemirror-state').Selection} originalSelection
458458
* @returns {{ mapReplacement: (startPos: number, nodeSize: number, replacement: Fragment) => void, finalize: () => void }|null}
459459
*/
460-
function createSelectionPreserver(tr, originalSelection) {
460+
function createSelectionPreserver(tr, originalSelection, originalStoredMarks = null) {
461461
if (!originalSelection) return null;
462462

463463
const isTextSelection = originalSelection instanceof TextSelection;
@@ -534,6 +534,9 @@ function createSelectionPreserver(tr, originalSelection) {
534534
if (!tr.docChanged) return;
535535
if (isTextSelection && preservedAnchor != null && preservedHead != null) {
536536
tr.setSelection(TextSelection.create(tr.doc, preservedAnchor, preservedHead));
537+
if (preservedAnchor === preservedHead && originalStoredMarks !== null) {
538+
tr.setStoredMarks(originalStoredMarks);
539+
}
537540
return;
538541
}
539542
tr.setSelection(originalSelection.map(tr.doc, tr.mapping));

0 commit comments

Comments
 (0)