Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 53 additions & 14 deletions packages/super-editor/src/core/commands/splitBlock.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,23 @@ import { canSplit } from 'prosemirror-transform';
import { defaultBlockAt } from '../helpers/defaultBlockAt.js';
import { Attribute } from '../Attribute.js';

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

const clearHeadingStyleId = (attrs) => {
if (!attrs || typeof attrs !== 'object') return attrs;
const paragraphProperties = attrs.paragraphProperties;
const styleId = paragraphProperties?.styleId;
if (!isHeadingStyleId(styleId)) return attrs;

const nextParagraphProperties = { ...paragraphProperties };
delete nextParagraphProperties.styleId;

return {
...attrs,
paragraphProperties: nextParagraphProperties,
};
};

const ensureMarks = (state, splittableMarks) => {
const marks = state.storedMarks || (state.selection.$to.parentOffset && state.selection.$from.marks());
if (marks) {
Expand Down Expand Up @@ -62,11 +79,18 @@ export const splitBlock =
if (can) {
tr.split(tr.mapping.map($from.pos), 1, types);

if (deflt && !atEnd && !$from.parentOffset && $from.parent.type !== deflt) {
if (deflt && !atEnd && !$from.parentOffset) {
const first = tr.mapping.map($from.before());
const $first = tr.doc.resolve(first);
if ($from.node(-1).canReplaceWith($first.index(), $first.index() + 1, deflt)) {
tr.setNodeMarkup(tr.mapping.map($from.before()), deflt);
const shouldChangeType = $from.parent.type !== deflt;
const normalizedAttrs = clearHeadingStyleId($from.parent.attrs);
const shouldNormalizeAttrs = normalizedAttrs !== $from.parent.attrs;

if (
$from.node(-1).canReplaceWith($first.index(), $first.index() + 1, deflt) &&
(shouldChangeType || shouldNormalizeAttrs)
) {
tr.setNodeMarkup(first, deflt, normalizedAttrs);
}
}
}
Expand All @@ -79,19 +103,34 @@ export const splitBlock =
};

function deleteAttributes(attrs, attrsToRemove) {
const newAttrs = { ...attrs };
attrsToRemove.forEach((attrName) => {
let nextAttrs = { ...attrs };
for (const attrName of attrsToRemove) {
const parts = attrName.split('.');
if (parts.length === 1) {
delete newAttrs[attrName];
} else {
let current = newAttrs;
for (let i = 0; i < parts.length - 1; i++) {
if (current[parts[i]] == null) return;
current = current[parts[i]];
delete nextAttrs[attrName];
continue;
}

let source = nextAttrs;
for (let i = 0; i < parts.length - 1; i += 1) {
if (source == null || typeof source !== 'object') {
source = null;
break;
}
delete current[parts[parts.length - 1]];
source = source[parts[i]];
}
});
return newAttrs;

if (source == null || typeof source !== 'object') continue;

let target = nextAttrs;
for (let i = 0; i < parts.length - 1; i += 1) {
const key = parts[i];
const value = target[key];
target[key] = { ...value };
target = target[key];
}

delete target[parts[parts.length - 1]];
}
return nextAttrs;
}
94 changes: 94 additions & 0 deletions packages/super-editor/src/core/commands/splitBlock.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -233,5 +233,99 @@ describe('splitBlock', () => {
// ensureMarks should NOT be called
expect(mockTr.ensureMarks).not.toHaveBeenCalled();
});

it('clears heading style on leading block when splitting at start of heading paragraph', () => {
const canReplaceWith = vi.fn(() => true);
const paragraphType = { name: 'paragraph', isTextblock: true, hasRequiredAttrs: vi.fn(() => false) };
const parentNode = {
contentMatchAt: vi.fn(() => ({
edgeCount: 1,
edge: vi.fn(() => ({ type: paragraphType })),
})),
canReplaceWith,
};
const headingAttrs = { paragraphProperties: { styleId: 'Heading2' } };
const $from = createMockResolvedPos({
depth: 1,
parent: {
isBlock: true,
content: { size: 10 },
type: { name: 'paragraph' },
inlineContent: true,
attrs: headingAttrs,
},
parentOffset: 0,
node: vi.fn((depth) => {
if (depth === -1) return parentNode;
return { type: { name: 'paragraph' }, attrs: headingAttrs };
}),
});
const $to = createMockResolvedPos({
pos: 5,
parent: { isBlock: true, content: { size: 10 }, type: { name: 'paragraph' }, inlineContent: true },
parentOffset: 0,
});

mockTr.selection = { $from, $to };
mockState.selection = mockTr.selection;
mockTr.doc = {
resolve: vi.fn(() => ({ index: vi.fn(() => 0) })),
};

const command = splitBlock();
command({ tr: mockTr, state: mockState, dispatch: () => {}, editor: mockEditor });

expect(mockTr.setNodeMarkup).toHaveBeenCalled();
const attrs = mockTr.setNodeMarkup.mock.calls[0][2];
expect(attrs.paragraphProperties?.styleId).toBeUndefined();
});

it('does not mutate source attrs when removing nested override attributes', () => {
const paragraphType = { name: 'paragraph', isTextblock: true, hasRequiredAttrs: vi.fn(() => false) };
const parentNode = {
contentMatchAt: vi.fn(() => ({
edgeCount: 1,
edge: vi.fn(() => ({ type: paragraphType })),
})),
};
const sourceAttrs = {
paragraphProperties: { styleId: 'Heading2', keep: true },
preserve: true,
};
const $from = createMockResolvedPos({
depth: 1,
parent: {
isBlock: true,
content: { size: 10 },
type: { name: 'paragraph' },
inlineContent: true,
attrs: sourceAttrs,
},
parentOffset: 0,
node: vi.fn((depth) => {
if (depth === -1) return parentNode;
return { type: { name: 'paragraph' }, attrs: sourceAttrs };
}),
});
const $to = createMockResolvedPos({
pos: 5,
parent: { isBlock: true, content: { size: 10 }, type: { name: 'paragraph' }, inlineContent: true },
parentOffset: 10,
});

mockTr.selection = { $from, $to };
mockState.selection = mockTr.selection;
mockTr.doc = {
resolve: vi.fn(() => ({ index: vi.fn(() => 0) })),
};

const command = splitBlock({ attrsToRemoveOverride: ['paragraphProperties.styleId'] });
command({ tr: mockTr, state: mockState, dispatch: () => {}, editor: mockEditor });

const splitTypes = mockTr.split.mock.calls[0][2];
expect(splitTypes?.[0]?.attrs?.paragraphProperties?.styleId).toBeUndefined();
expect(splitTypes?.[0]?.attrs?.paragraphProperties?.keep).toBe(true);
expect(sourceAttrs.paragraphProperties.styleId).toBe('Heading2');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,23 @@ function paragraphByText(paragraphs, expectedText) {
}

describe('markdown to DOCX integration', () => {
it('retains blank lines between root blocks as empty paragraphs', () => {
const markdown = `First paragraph.


Second paragraph.



Third paragraph.`;

const doc = createDocFromMarkdown(markdown, editor);
const paragraphs = collectTopLevelParagraphs(doc);
const texts = paragraphs.map((node) => node.textContent);

expect(texts).toEqual(['First paragraph.', '', '', 'Second paragraph.', '', '', '', 'Third paragraph.']);
});

it('converts complete markdown document with headings and lists', () => {
const markdown = `# Main Title

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import type { MdastConversionContext, MarkdownDiagnostic } from './types.js';
* suitable for constructing a full doc or a fragment.
*/
export function convertMdastToBlocks(root: Root, ctx: MdastConversionContext): JsonNode[] {
return flatMapChildren(root, ctx);
return flatMapRootChildrenPreserveBlankLines(root, ctx);
}

// ---------------------------------------------------------------------------
Expand All @@ -68,6 +68,26 @@ interface JsonMark {
// Block-level converters
// ---------------------------------------------------------------------------

function flatMapRootChildrenPreserveBlankLines(root: Root, ctx: MdastConversionContext): JsonNode[] {
const children = root.children ?? [];
const blocks: JsonNode[] = [];

for (let i = 0; i < children.length; i += 1) {
const child = children[i];
blocks.push(...convertBlockNode(child, ctx));

const next = children[i + 1];
if (!next) continue;

const blankLines = countBlankLinesBetweenSiblings(child, next);
for (let j = 0; j < blankLines; j += 1) {
blocks.push(makeParagraph([]));
Comment on lines +82 to +84
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Skip structural separator line when preserving blanks

This loop inserts one empty paragraph for every line gap between root blocks, but in Markdown a single blank line is the normal structural separator between blocks. With blankLines === 1, standard input like a heading followed by a paragraph now imports with an extra empty paragraph between them, which changes document structure and pagination for ordinary Markdown files. Only extra blank lines beyond the required separator should be materialized (for example, blankLines - 1) or this behavior should be optional.

Useful? React with 👍 / 👎.

}
}

return blocks;
}

function flatMapChildren(parent: MdastNode & { children?: MdastNode[] }, ctx: MdastConversionContext): JsonNode[] {
if (!parent.children) return [];
const blocks: JsonNode[] = [];
Expand All @@ -77,6 +97,20 @@ function flatMapChildren(parent: MdastNode & { children?: MdastNode[] }, ctx: Md
return blocks;
}

function countBlankLinesBetweenSiblings(previous: MdastNode, next: MdastNode): number {
const previousEndLine = previous.position?.end?.line;
const nextStartLine = next.position?.start?.line;

if (typeof previousEndLine !== 'number' || typeof nextStartLine !== 'number') {
return 0;
}

// mdast line numbers are 1-based and inclusive:
// previous ends on line A, next starts on line B.
// Lines strictly between them are explicit blank lines in the source.
return Math.max(0, nextStartLine - previousEndLine - 1);
}

function convertBlockNode(node: MdastNode, ctx: MdastConversionContext): JsonNode[] {
switch (node.type) {
case 'paragraph':
Expand Down
43 changes: 38 additions & 5 deletions packages/super-editor/src/extensions/run/commands/split-run.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@ import { defaultBlockAt } from '@core/helpers/defaultBlockAt.js';
import { resolveRunProperties, encodeMarksFromRPr } from '@core/super-converter/styles.js';
import { extractTableInfo } from '../calculateInlineRunPropertiesPlugin.js';

function isHeadingStyleId(styleId) {
return typeof styleId === 'string' && /^heading\s*[1-6]$/i.test(styleId.trim());
}

function clearHeadingStyleId(attrs) {
if (!attrs || typeof attrs !== 'object') return attrs;
const paragraphProperties = attrs.paragraphProperties;
const styleId = paragraphProperties?.styleId;
if (!isHeadingStyleId(styleId)) return attrs;

const nextParagraphProperties = { ...paragraphProperties };
delete nextParagraphProperties.styleId;

return {
...attrs,
paragraphProperties: nextParagraphProperties,
};
}

/**
* Splits a run node at the current selection into two paragraphs.
* @returns {import('@core/commands/types').Command}
Expand Down Expand Up @@ -99,11 +118,21 @@ export function splitBlockPatch(state, dispatch, editor) {
}
if (!can) return false;
tr.split(splitPos, types.length, types);
if (!atEnd && atStart && $from.node(splitDepth).type != deflt) {
let first = tr.mapping.map($from.before(splitDepth)),
$first = tr.doc.resolve(first);
if (deflt && $from.node(splitDepth - 1).canReplaceWith($first.index(), $first.index() + 1, deflt))
tr.setNodeMarkup(tr.mapping.map($from.before(splitDepth)), deflt);
if (!atEnd && atStart) {
const first = tr.mapping.map($from.before(splitDepth));
const $first = tr.doc.resolve(first);
const sourceNode = $from.node(splitDepth);
const shouldChangeType = sourceNode.type != deflt;
const normalizedAttrs = clearHeadingStyleId(sourceNode.attrs);
const shouldNormalizeAttrs = normalizedAttrs !== sourceNode.attrs;

if (
deflt &&
$from.node(splitDepth - 1).canReplaceWith($first.index(), $first.index() + 1, deflt) &&
(shouldChangeType || shouldNormalizeAttrs)
) {
tr.setNodeMarkup(first, deflt, normalizedAttrs);
}
}

applyStyleMarks(state, tr, editor, paragraphAttrs, tableInfo);
Expand Down Expand Up @@ -195,6 +224,10 @@ function applyStyleMarks(state, tr, editor, paragraphAttrs, tableInfo) {
}
}

/**
* Splits the current run node into two sibling runs at the cursor position.
* @returns {import('@core/commands/types').Command}
*/
export const splitRunAtCursor = () => (props) => {
let { state, dispatch, tr } = props;
const sel = state.selection;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,15 @@ describe('splitRunToParagraph command', () => {

expect(handled).toBe(false);
});

it('returns false for splitRunAtCursor when cursor is not in a run node', () => {
loadDoc(PLAIN_PARAGRAPH_DOC);
updateSelection(1);

const handled = editor.commands.splitRunAtCursor();

expect(handled).toBe(false);
});
});

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

it('clears heading style on the leading empty paragraph when splitting at heading start', () => {
const mockConverter = {
convertedXml: {},
numbering: {},
translatedNumbering: {},
translatedLinkedStyles: {},
documentGuid: 'test-guid-123',
promoteToGuid: vi.fn(),
};

editor.converter = mockConverter;
loadDoc(STYLED_PARAGRAPH_DOC);

const start = findTextPos('Heading Text');
expect(start).not.toBeNull();
updateSelection(start ?? 0);

const handled = editor.commands.splitRunToParagraph();
expect(handled).toBe(true);

const paragraphs = [];
editor.view.state.doc.descendants((node) => {
if (node.type.name === 'paragraph') paragraphs.push(node);
});

expect(paragraphs).toHaveLength(2);
expect(paragraphs[0].textContent).toBe('');
expect(paragraphs[0].attrs?.paragraphProperties?.styleId).toBeUndefined();
expect(paragraphs[1].attrs?.paragraphProperties?.styleId).toBe('Heading1');
});

it('handles missing converter gracefully during split', () => {
const mockConverter = {
convertedXml: {},
Expand Down
Loading
Loading