Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

preserve line breaks during markdown deserialization #3679

Merged
merged 13 commits into from
Nov 18, 2024
5 changes: 5 additions & 0 deletions .changeset/soft-keys-march.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@udecode/plate-markdown": patch
---

Split line breaks into separate paragraphs during Markdown deserialization
10 changes: 10 additions & 0 deletions packages/markdown/src/lib/MarkdownPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ export type MarkdownConfig = PluginConfig<
/** Override element rules. */
elementRules?: RemarkElementRules;
indentList?: boolean;
/**
* When the text contains \n, split the text into a separate paragraph.
*
* Line breaks between paragraphs will also be converted into separate
* paragraphs.
*
* @default false
*/
splitLineBreaks?: boolean;

/** Override text rules. */
textRules?: RemarkTextRules;
Expand All @@ -43,6 +52,7 @@ export const MarkdownPlugin = createTSlatePlugin<MarkdownConfig>({
options: {
elementRules: remarkDefaultElementRules,
indentList: false,
splitLineBreaks: false,
textRules: remarkDefaultTextRules,
},
})
Expand Down
129 changes: 129 additions & 0 deletions packages/markdown/src/lib/deserializer/utils/deserializeMd.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -605,3 +605,132 @@ describe('deserializeMdIndentList', () => {
expect(deserializeMd(editor, input)).toEqual(output);
});
});

describe('when splitLineBreaks is enabled', () => {
const editor = createSlateEditor({
plugins: [MarkdownPlugin.configure({ options: { splitLineBreaks: true } })],
});

it('should deserialize paragraphs and keep in separate paragraphs with line breaks', () => {
const input =
'Paragraph 1 line 1\nParagraph 1 line 2\n\nParagraph 2 line 1';

const output = (
<fragment>
<hp>Paragraph 1 line 1</hp>
<hp>Paragraph 1 line 2</hp>
<hp>
<htext />
</hp>
<hp>Paragraph 2 line 1</hp>
</fragment>
);

expect(deserializeMd(editor, input)).toEqual(output);
});

it('should deserialize line break tags and keep in separate paragraphs', () => {
const input = 'Line 1<br>Line 2';
const output = (
<fragment>
<hp>Line 1</hp>
<hp>Line 2</hp>
</fragment>
);

expect(deserializeMd(editor, input)).toEqual(output);
});

it('splits N consecutive line breaks into N paragraph breaks', () => {
const input = '\n\nLine 1\n\nLine 2\n\n\nLine 3\n\n';

const output = (
<fragment>
<hp>
<htext />
</hp>
<hp>
<htext />
</hp>
<hp>Line 1</hp>
<hp>
<htext />
</hp>
<hp>Line 2</hp>
<hp>
<htext />
</hp>
<hp>
<htext />
</hp>
<hp>Line 3</hp>
<hp>
<htext />
</hp>
</fragment>
);

expect(deserializeMd(editor, input)).toEqual(output);
});

it('splits N consecutive line break tags into N paragraph breaks', () => {
const input = '<br><br>Line 1<br><br>Line 2<br><br><br>Line 3<br><br>';

const output = (
<fragment>
<hp>
<htext />
</hp>
<hp>
<htext />
</hp>
<hp>Line 1</hp>
<hp>
<htext />
</hp>
<hp>Line 2</hp>
<hp>
<htext />
</hp>
<hp>
<htext />
</hp>
<hp>Line 3</hp>
<hp>
<htext />
</hp>
</fragment>
);

expect(deserializeMd(editor, input)).toEqual(output);
});

it('allows mixing line breaks and line break tags', () => {
const input = '<br>Line 1\n<br>Line 2<br>\n<br>Line 3\n<br>';

const output = (
<fragment>
<hp>
<htext />
</hp>
<hp>Line 1</hp>
<hp>
<htext />
</hp>
<hp>Line 2</hp>
<hp>
<htext />
</hp>
<hp>
<htext />
</hp>
<hp>Line 3</hp>
<hp>
<htext />
</hp>
</fragment>
);

expect(deserializeMd(editor, input)).toEqual(output);
});
});
1 change: 1 addition & 0 deletions packages/markdown/src/lib/remark-slate/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

export * from './remarkDefaultElementRules';
export * from './remarkDefaultTextRules';
export * from './remarkSplitLineBreaksCompiler';
export * from './remarkPlugin';
export * from './remarkTextTypes';
export * from './remarkTransformElement';
Expand Down
14 changes: 14 additions & 0 deletions packages/markdown/src/lib/remark-slate/remarkDefaultCompiler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { TDescendant } from '@udecode/plate-common';

import type { MdastNode, RemarkPluginOptions } from './types';

import { remarkTransformNode } from './remarkTransformNode';

export const remarkDefaultCompiler = (
node: MdastNode,
options: RemarkPluginOptions
): TDescendant[] => {
return (node.children || []).flatMap((child) =>
remarkTransformNode(child, options)
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { TDescendant, TElement, TText } from '@udecode/plate-common';

import type { MdastNode, RemarkElementRules } from './types';

import { MarkdownPlugin } from '../MarkdownPlugin';
import { remarkTransformElementChildren } from './remarkTransformElementChildren';
import { remarkTransformNode } from './remarkTransformNode';

Expand Down Expand Up @@ -79,7 +80,16 @@ export const remarkDefaultElementRules: RemarkElementRules = {
indent = 1
) => {
_node.children?.forEach((listItem) => {
const [paragraph, ...subLists] = listItem.children!;
if (!listItem.children) {
listItems.push({
children: remarkTransformElementChildren(listItem, options),
type: options.editor.getType({ key: 'p' }),
});

return listItems;
}

const [paragraph, ...subLists] = listItem.children;

listItems.push({
children: remarkTransformElementChildren(
Expand Down Expand Up @@ -139,6 +149,9 @@ export const remarkDefaultElementRules: RemarkElementRules = {
},
paragraph: {
transform: (node, options) => {
const isKeepLineBreak =
options.editor.getOptions(MarkdownPlugin).splitLineBreaks;

const children = remarkTransformElementChildren(node, options);

const paragraphType = options.editor.getType({ key: 'p' });
Expand All @@ -164,6 +177,42 @@ export const remarkDefaultElementRules: RemarkElementRules = {
if (type && splitBlockTypes.has(type as string)) {
flushInlineNodes();
elements.push(child as TElement);
} else if (
isKeepLineBreak &&
'text' in child &&
typeof child.text === 'string'
) {
// Handle line break generated by <br>
const isSingleLineBreak =
child.text === '\n' && inlineNodes.length === 0;

if (isSingleLineBreak) {
inlineNodes.push({ ...child, text: '' });
flushInlineNodes();

return;
}

// Handle text containing line breaks
const textParts = child.text.split('\n');

textParts.forEach((part, index, array) => {
const isNotFirstPart = index > 0;
const isNotLastPart = index < array.length - 1;

// Create new paragraph for non-first parts
if (isNotFirstPart) {
flushInlineNodes();
}
// Only add non-empty text
if (part) {
inlineNodes.push({ ...child, text: part });
}
// Create paragraph break for non-last parts
if (isNotLastPart) {
flushInlineNodes();
}
});
} else {
inlineNodes.push(child);
}
Expand Down
27 changes: 19 additions & 8 deletions packages/markdown/src/lib/remark-slate/remarkPlugin.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */

import type { Processor } from 'unified';

import type { MdastNode, RemarkPluginOptions } from './types';

import { remarkTransformNode } from './remarkTransformNode';
import { MarkdownPlugin } from '../MarkdownPlugin';
import { remarkDefaultCompiler } from './remarkDefaultCompiler';
import { remarkSplitLineBreaksCompiler } from './remarkSplitLineBreaksCompiler';

export function remarkPlugin(
this: Processor<undefined, undefined, undefined, MdastNode, any>,
options: RemarkPluginOptions
) {
const shouldSplitLineBreaks =
options.editor.getOptions(MarkdownPlugin).splitLineBreaks;

const compiler = (node: MdastNode) => {
if (shouldSplitLineBreaks) {
return remarkSplitLineBreaksCompiler(node, options);
}

export function remarkPlugin(options: RemarkPluginOptions) {
const compiler = (node: { children: MdastNode[] }) => {
return node.children.flatMap((child) =>
remarkTransformNode(child, options)
);
return remarkDefaultCompiler(node, options);
};

// @ts-ignore
this.Compiler = compiler;
this.compiler = compiler;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { TDescendant, TText } from '@udecode/plate-common';

import type { MdastNode, RemarkPluginOptions } from './types';

import { remarkTransformNode } from './remarkTransformNode';

export const remarkSplitLineBreaksCompiler = (
node: MdastNode,
options: RemarkPluginOptions
): TDescendant[] => {
const results: TDescendant[] = [];
let startLine = node.position!.start.line;

const addEmptyParagraphs = (count: number) => {
if (count > 0) {
results.push(
...Array.from({ length: count }).map(() => {
return {
children: [{ text: '' } as TText],
type: options.editor.getType({ key: 'p' }),
};
})
);
}
};

node?.children?.forEach((child, index) => {
const isFirstChild = index === 0;
const isLastChild = index === node.children!.length - 1;

const emptyLinesBefore =
child.position!.start.line - (isFirstChild ? startLine : startLine + 1);
addEmptyParagraphs(emptyLinesBefore);

const transformValue = remarkTransformNode(child, options);
results.push(
...(Array.isArray(transformValue) ? transformValue : [transformValue])
);

if (isLastChild) {
const emptyLinesAfter =
node.position!.end.line - child.position!.end.line - 1;
addEmptyParagraphs(emptyLinesAfter);
}

startLine = child.position!.end.line;
});

return results;
};
15 changes: 12 additions & 3 deletions packages/markdown/src/lib/remark-slate/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,28 @@ export type MdastTextType =

export type MdastNodeType = MdastElementType | MdastTextType;

export interface TextPosition {
column: number;
line: number;
offset?: number;
}

export interface MdastNode {
type: MdastNodeType;
// mdast metadata
position?: {
end: TextPosition;
start: TextPosition;
};
alt?: string;
checked?: any;
children?: MdastNode[];
depth?: 1 | 2 | 3 | 4 | 5 | 6;
indent?: any;
lang?: string;
ordered?: boolean;
// mdast metadata
position?: any;
spread?: any;
text?: string;
type?: MdastNodeType;
url?: string;
value?: string;
}
Expand Down