Skip to content

Commit e1965fc

Browse files
authored
fix: splitting run with header adds empty row (#2229)
1 parent 4b36780 commit e1965fc

9 files changed

Lines changed: 462 additions & 22 deletions

File tree

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

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,23 @@ import { canSplit } from 'prosemirror-transform';
33
import { defaultBlockAt } from '../helpers/defaultBlockAt.js';
44
import { Attribute } from '../Attribute.js';
55

6+
const isHeadingStyleId = (styleId) => typeof styleId === 'string' && /^heading\s*[1-6]$/i.test(styleId.trim());
7+
8+
const clearHeadingStyleId = (attrs) => {
9+
if (!attrs || typeof attrs !== 'object') return attrs;
10+
const paragraphProperties = attrs.paragraphProperties;
11+
const styleId = paragraphProperties?.styleId;
12+
if (!isHeadingStyleId(styleId)) return attrs;
13+
14+
const nextParagraphProperties = { ...paragraphProperties };
15+
delete nextParagraphProperties.styleId;
16+
17+
return {
18+
...attrs,
19+
paragraphProperties: nextParagraphProperties,
20+
};
21+
};
22+
623
const ensureMarks = (state, splittableMarks) => {
724
const marks = state.storedMarks || (state.selection.$to.parentOffset && state.selection.$from.marks());
825
if (marks) {
@@ -62,11 +79,18 @@ export const splitBlock =
6279
if (can) {
6380
tr.split(tr.mapping.map($from.pos), 1, types);
6481

65-
if (deflt && !atEnd && !$from.parentOffset && $from.parent.type !== deflt) {
82+
if (deflt && !atEnd && !$from.parentOffset) {
6683
const first = tr.mapping.map($from.before());
6784
const $first = tr.doc.resolve(first);
68-
if ($from.node(-1).canReplaceWith($first.index(), $first.index() + 1, deflt)) {
69-
tr.setNodeMarkup(tr.mapping.map($from.before()), deflt);
85+
const shouldChangeType = $from.parent.type !== deflt;
86+
const normalizedAttrs = clearHeadingStyleId($from.parent.attrs);
87+
const shouldNormalizeAttrs = normalizedAttrs !== $from.parent.attrs;
88+
89+
if (
90+
$from.node(-1).canReplaceWith($first.index(), $first.index() + 1, deflt) &&
91+
(shouldChangeType || shouldNormalizeAttrs)
92+
) {
93+
tr.setNodeMarkup(first, deflt, normalizedAttrs);
7094
}
7195
}
7296
}
@@ -79,19 +103,34 @@ export const splitBlock =
79103
};
80104

81105
function deleteAttributes(attrs, attrsToRemove) {
82-
const newAttrs = { ...attrs };
83-
attrsToRemove.forEach((attrName) => {
106+
let nextAttrs = { ...attrs };
107+
for (const attrName of attrsToRemove) {
84108
const parts = attrName.split('.');
85109
if (parts.length === 1) {
86-
delete newAttrs[attrName];
87-
} else {
88-
let current = newAttrs;
89-
for (let i = 0; i < parts.length - 1; i++) {
90-
if (current[parts[i]] == null) return;
91-
current = current[parts[i]];
110+
delete nextAttrs[attrName];
111+
continue;
112+
}
113+
114+
let source = nextAttrs;
115+
for (let i = 0; i < parts.length - 1; i += 1) {
116+
if (source == null || typeof source !== 'object') {
117+
source = null;
118+
break;
92119
}
93-
delete current[parts[parts.length - 1]];
120+
source = source[parts[i]];
94121
}
95-
});
96-
return newAttrs;
122+
123+
if (source == null || typeof source !== 'object') continue;
124+
125+
let target = nextAttrs;
126+
for (let i = 0; i < parts.length - 1; i += 1) {
127+
const key = parts[i];
128+
const value = target[key];
129+
target[key] = { ...value };
130+
target = target[key];
131+
}
132+
133+
delete target[parts[parts.length - 1]];
134+
}
135+
return nextAttrs;
97136
}

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

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,5 +233,99 @@ describe('splitBlock', () => {
233233
// ensureMarks should NOT be called
234234
expect(mockTr.ensureMarks).not.toHaveBeenCalled();
235235
});
236+
237+
it('clears heading style on leading block when splitting at start of heading paragraph', () => {
238+
const canReplaceWith = vi.fn(() => true);
239+
const paragraphType = { name: 'paragraph', isTextblock: true, hasRequiredAttrs: vi.fn(() => false) };
240+
const parentNode = {
241+
contentMatchAt: vi.fn(() => ({
242+
edgeCount: 1,
243+
edge: vi.fn(() => ({ type: paragraphType })),
244+
})),
245+
canReplaceWith,
246+
};
247+
const headingAttrs = { paragraphProperties: { styleId: 'Heading2' } };
248+
const $from = createMockResolvedPos({
249+
depth: 1,
250+
parent: {
251+
isBlock: true,
252+
content: { size: 10 },
253+
type: { name: 'paragraph' },
254+
inlineContent: true,
255+
attrs: headingAttrs,
256+
},
257+
parentOffset: 0,
258+
node: vi.fn((depth) => {
259+
if (depth === -1) return parentNode;
260+
return { type: { name: 'paragraph' }, attrs: headingAttrs };
261+
}),
262+
});
263+
const $to = createMockResolvedPos({
264+
pos: 5,
265+
parent: { isBlock: true, content: { size: 10 }, type: { name: 'paragraph' }, inlineContent: true },
266+
parentOffset: 0,
267+
});
268+
269+
mockTr.selection = { $from, $to };
270+
mockState.selection = mockTr.selection;
271+
mockTr.doc = {
272+
resolve: vi.fn(() => ({ index: vi.fn(() => 0) })),
273+
};
274+
275+
const command = splitBlock();
276+
command({ tr: mockTr, state: mockState, dispatch: () => {}, editor: mockEditor });
277+
278+
expect(mockTr.setNodeMarkup).toHaveBeenCalled();
279+
const attrs = mockTr.setNodeMarkup.mock.calls[0][2];
280+
expect(attrs.paragraphProperties?.styleId).toBeUndefined();
281+
});
282+
283+
it('does not mutate source attrs when removing nested override attributes', () => {
284+
const paragraphType = { name: 'paragraph', isTextblock: true, hasRequiredAttrs: vi.fn(() => false) };
285+
const parentNode = {
286+
contentMatchAt: vi.fn(() => ({
287+
edgeCount: 1,
288+
edge: vi.fn(() => ({ type: paragraphType })),
289+
})),
290+
};
291+
const sourceAttrs = {
292+
paragraphProperties: { styleId: 'Heading2', keep: true },
293+
preserve: true,
294+
};
295+
const $from = createMockResolvedPos({
296+
depth: 1,
297+
parent: {
298+
isBlock: true,
299+
content: { size: 10 },
300+
type: { name: 'paragraph' },
301+
inlineContent: true,
302+
attrs: sourceAttrs,
303+
},
304+
parentOffset: 0,
305+
node: vi.fn((depth) => {
306+
if (depth === -1) return parentNode;
307+
return { type: { name: 'paragraph' }, attrs: sourceAttrs };
308+
}),
309+
});
310+
const $to = createMockResolvedPos({
311+
pos: 5,
312+
parent: { isBlock: true, content: { size: 10 }, type: { name: 'paragraph' }, inlineContent: true },
313+
parentOffset: 10,
314+
});
315+
316+
mockTr.selection = { $from, $to };
317+
mockState.selection = mockTr.selection;
318+
mockTr.doc = {
319+
resolve: vi.fn(() => ({ index: vi.fn(() => 0) })),
320+
};
321+
322+
const command = splitBlock({ attrsToRemoveOverride: ['paragraphProperties.styleId'] });
323+
command({ tr: mockTr, state: mockState, dispatch: () => {}, editor: mockEditor });
324+
325+
const splitTypes = mockTr.split.mock.calls[0][2];
326+
expect(splitTypes?.[0]?.attrs?.paragraphProperties?.styleId).toBeUndefined();
327+
expect(splitTypes?.[0]?.attrs?.paragraphProperties?.keep).toBe(true);
328+
expect(sourceAttrs.paragraphProperties.styleId).toBe('Heading2');
329+
});
236330
});
237331
});

packages/super-editor/src/core/helpers/importMarkdown.integration.test.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,23 @@ function paragraphByText(paragraphs, expectedText) {
5252
}
5353

5454
describe('markdown to DOCX integration', () => {
55+
it('retains blank lines between root blocks as empty paragraphs', () => {
56+
const markdown = `First paragraph.
57+
58+
59+
Second paragraph.
60+
61+
62+
63+
Third paragraph.`;
64+
65+
const doc = createDocFromMarkdown(markdown, editor);
66+
const paragraphs = collectTopLevelParagraphs(doc);
67+
const texts = paragraphs.map((node) => node.textContent);
68+
69+
expect(texts).toEqual(['First paragraph.', '', '', 'Second paragraph.', '', '', '', 'Third paragraph.']);
70+
});
71+
5572
it('converts complete markdown document with headings and lists', () => {
5673
const markdown = `# Main Title
5774

packages/super-editor/src/core/helpers/markdown/mdastToProseMirror.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ import type { MdastConversionContext, MarkdownDiagnostic } from './types.js';
4444
* suitable for constructing a full doc or a fragment.
4545
*/
4646
export function convertMdastToBlocks(root: Root, ctx: MdastConversionContext): JsonNode[] {
47-
return flatMapChildren(root, ctx);
47+
return flatMapRootChildrenPreserveBlankLines(root, ctx);
4848
}
4949

5050
// ---------------------------------------------------------------------------
@@ -68,6 +68,26 @@ interface JsonMark {
6868
// Block-level converters
6969
// ---------------------------------------------------------------------------
7070

71+
function flatMapRootChildrenPreserveBlankLines(root: Root, ctx: MdastConversionContext): JsonNode[] {
72+
const children = root.children ?? [];
73+
const blocks: JsonNode[] = [];
74+
75+
for (let i = 0; i < children.length; i += 1) {
76+
const child = children[i];
77+
blocks.push(...convertBlockNode(child, ctx));
78+
79+
const next = children[i + 1];
80+
if (!next) continue;
81+
82+
const blankLines = countBlankLinesBetweenSiblings(child, next);
83+
for (let j = 0; j < blankLines; j += 1) {
84+
blocks.push(makeParagraph([]));
85+
}
86+
}
87+
88+
return blocks;
89+
}
90+
7191
function flatMapChildren(parent: MdastNode & { children?: MdastNode[] }, ctx: MdastConversionContext): JsonNode[] {
7292
if (!parent.children) return [];
7393
const blocks: JsonNode[] = [];
@@ -77,6 +97,20 @@ function flatMapChildren(parent: MdastNode & { children?: MdastNode[] }, ctx: Md
7797
return blocks;
7898
}
7999

100+
function countBlankLinesBetweenSiblings(previous: MdastNode, next: MdastNode): number {
101+
const previousEndLine = previous.position?.end?.line;
102+
const nextStartLine = next.position?.start?.line;
103+
104+
if (typeof previousEndLine !== 'number' || typeof nextStartLine !== 'number') {
105+
return 0;
106+
}
107+
108+
// mdast line numbers are 1-based and inclusive:
109+
// previous ends on line A, next starts on line B.
110+
// Lines strictly between them are explicit blank lines in the source.
111+
return Math.max(0, nextStartLine - previousEndLine - 1);
112+
}
113+
80114
function convertBlockNode(node: MdastNode, ctx: MdastConversionContext): JsonNode[] {
81115
switch (node.type) {
82116
case 'paragraph':

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

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,25 @@ import { defaultBlockAt } from '@core/helpers/defaultBlockAt.js';
55
import { resolveRunProperties, encodeMarksFromRPr } from '@core/super-converter/styles.js';
66
import { extractTableInfo } from '../calculateInlineRunPropertiesPlugin.js';
77

8+
function isHeadingStyleId(styleId) {
9+
return typeof styleId === 'string' && /^heading\s*[1-6]$/i.test(styleId.trim());
10+
}
11+
12+
function clearHeadingStyleId(attrs) {
13+
if (!attrs || typeof attrs !== 'object') return attrs;
14+
const paragraphProperties = attrs.paragraphProperties;
15+
const styleId = paragraphProperties?.styleId;
16+
if (!isHeadingStyleId(styleId)) return attrs;
17+
18+
const nextParagraphProperties = { ...paragraphProperties };
19+
delete nextParagraphProperties.styleId;
20+
21+
return {
22+
...attrs,
23+
paragraphProperties: nextParagraphProperties,
24+
};
25+
}
26+
827
/**
928
* Splits a run node at the current selection into two paragraphs.
1029
* @returns {import('@core/commands/types').Command}
@@ -99,11 +118,21 @@ export function splitBlockPatch(state, dispatch, editor) {
99118
}
100119
if (!can) return false;
101120
tr.split(splitPos, types.length, types);
102-
if (!atEnd && atStart && $from.node(splitDepth).type != deflt) {
103-
let first = tr.mapping.map($from.before(splitDepth)),
104-
$first = tr.doc.resolve(first);
105-
if (deflt && $from.node(splitDepth - 1).canReplaceWith($first.index(), $first.index() + 1, deflt))
106-
tr.setNodeMarkup(tr.mapping.map($from.before(splitDepth)), deflt);
121+
if (!atEnd && atStart) {
122+
const first = tr.mapping.map($from.before(splitDepth));
123+
const $first = tr.doc.resolve(first);
124+
const sourceNode = $from.node(splitDepth);
125+
const shouldChangeType = sourceNode.type != deflt;
126+
const normalizedAttrs = clearHeadingStyleId(sourceNode.attrs);
127+
const shouldNormalizeAttrs = normalizedAttrs !== sourceNode.attrs;
128+
129+
if (
130+
deflt &&
131+
$from.node(splitDepth - 1).canReplaceWith($first.index(), $first.index() + 1, deflt) &&
132+
(shouldChangeType || shouldNormalizeAttrs)
133+
) {
134+
tr.setNodeMarkup(first, deflt, normalizedAttrs);
135+
}
107136
}
108137

109138
applyStyleMarks(state, tr, editor, paragraphAttrs, tableInfo);
@@ -195,6 +224,10 @@ function applyStyleMarks(state, tr, editor, paragraphAttrs, tableInfo) {
195224
}
196225
}
197226

227+
/**
228+
* Splits the current run node into two sibling runs at the cursor position.
229+
* @returns {import('@core/commands/types').Command}
230+
*/
198231
export const splitRunAtCursor = () => (props) => {
199232
let { state, dispatch, tr } = props;
200233
const sel = state.selection;

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,15 @@ describe('splitRunToParagraph command', () => {
170170

171171
expect(handled).toBe(false);
172172
});
173+
174+
it('returns false for splitRunAtCursor when cursor is not in a run node', () => {
175+
loadDoc(PLAIN_PARAGRAPH_DOC);
176+
updateSelection(1);
177+
178+
const handled = editor.commands.splitRunAtCursor();
179+
180+
expect(handled).toBe(false);
181+
});
173182
});
174183

175184
describe('splitRunToParagraph with style marks', () => {
@@ -312,6 +321,37 @@ describe('splitRunToParagraph with style marks', () => {
312321
expect(paragraphTexts).toEqual(['Heading', ' Text']);
313322
});
314323

324+
it('clears heading style on the leading empty paragraph when splitting at heading start', () => {
325+
const mockConverter = {
326+
convertedXml: {},
327+
numbering: {},
328+
translatedNumbering: {},
329+
translatedLinkedStyles: {},
330+
documentGuid: 'test-guid-123',
331+
promoteToGuid: vi.fn(),
332+
};
333+
334+
editor.converter = mockConverter;
335+
loadDoc(STYLED_PARAGRAPH_DOC);
336+
337+
const start = findTextPos('Heading Text');
338+
expect(start).not.toBeNull();
339+
updateSelection(start ?? 0);
340+
341+
const handled = editor.commands.splitRunToParagraph();
342+
expect(handled).toBe(true);
343+
344+
const paragraphs = [];
345+
editor.view.state.doc.descendants((node) => {
346+
if (node.type.name === 'paragraph') paragraphs.push(node);
347+
});
348+
349+
expect(paragraphs).toHaveLength(2);
350+
expect(paragraphs[0].textContent).toBe('');
351+
expect(paragraphs[0].attrs?.paragraphProperties?.styleId).toBeUndefined();
352+
expect(paragraphs[1].attrs?.paragraphProperties?.styleId).toBe('Heading1');
353+
});
354+
315355
it('handles missing converter gracefully during split', () => {
316356
const mockConverter = {
317357
convertedXml: {},

0 commit comments

Comments
 (0)