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

feat: split line breaks into separate paragraphs during Markdown deserialization #3679

Merged
merged 13 commits into from
Nov 18, 2024
12 changes: 12 additions & 0 deletions packages/markdown/src/lib/MarkdownPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,17 @@ 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 are also preserved.
natamox marked this conversation as resolved.
Show resolved Hide resolved
*
* This means that if the text contains \n, the \n and the text before it
* are split into separate paragraphs.
natamox marked this conversation as resolved.
Show resolved Hide resolved
*
* @default false
*/
keepLineBreak?: boolean;

/** Override text rules. */
textRules?: RemarkTextRules;
Expand All @@ -43,6 +54,7 @@ export const MarkdownPlugin = createTSlatePlugin<MarkdownConfig>({
options: {
elementRules: remarkDefaultElementRules,
indentList: false,
keepLineBreak: false,
natamox marked this conversation as resolved.
Show resolved Hide resolved
natamox marked this conversation as resolved.
Show resolved Hide resolved
textRules: remarkDefaultTextRules,
},
})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/** @jsx jsx */

import { createSlateEditor } from '@udecode/plate-common';
import { jsx } from '@udecode/plate-test-utils';

import { MarkdownPlugin } from '../../MarkdownPlugin';
import { deserializeMd } from './deserializeMd';

jsx;

describe('deserializeMd', () => {
natamox marked this conversation as resolved.
Show resolved Hide resolved
const editor = createSlateEditor({
plugins: [MarkdownPlugin.configure({ options: { keepLineBreak: 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>
<htext>Line 1</htext>
natamox marked this conversation as resolved.
Show resolved Hide resolved
</hp>
<hp>
<htext>Line 2</htext>
</hp>
</fragment>
);

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

it('multiple line breaks in the middle and the leading and trailing replacement lines should be preserved', () => {
natamox marked this conversation as resolved.
Show resolved Hide resolved
const input = '\n\nLine 1\n\nLine 2\n\n\nLine 3\n\n';

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

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

it('multiple line break tags in the middle and the leading and trailing replacement lines should be preserved', () => {
natamox marked this conversation as resolved.
Show resolved Hide resolved
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>
<htext>Line 1</htext>
</hp>
<hp>
<htext />
</hp>
<hp>
<htext>Line 2</htext>
</hp>
<hp>
<htext />
</hp>
<hp>
<htext />
</hp>
<hp>
<htext>Line 3</htext>
</hp>
<hp>
<htext />
</hp>
</fragment>
);

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

it('a string containing <br> and \n should be parsed as separate paragraphs', () => {
natamox marked this conversation as resolved.
Show resolved Hide resolved
const input = '<br>Line 1\n<br>Line 2<br>\n<br>Line 3\n<br>';

const output = (
<fragment>
<hp>
<htext />
</hp>
<hp>
<htext>Line 1</htext>
</hp>
<hp>
<htext />
</hp>
<hp>
<htext>Line 2</htext>
</hp>
<hp>
<htext />
</hp>
<hp>
<htext />
</hp>
<hp>
<htext>Line 3</htext>
</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 './remarkLineBreakCompiler';
export * from './remarkPlugin';
export * from './remarkTextTypes';
export * from './remarkTransformElement';
Expand Down
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).keepLineBreak;

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
50 changes: 50 additions & 0 deletions packages/markdown/src/lib/remark-slate/remarkLineBreakCompiler.ts
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 remarkLineBreakCompiler = (
node: MdastNode,
options: RemarkPluginOptions
) => {
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;
};
6 changes: 5 additions & 1 deletion packages/markdown/src/lib/remark-slate/remarkPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

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

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

export function remarkPlugin(options: RemarkPluginOptions) {
Expand All @@ -12,5 +14,7 @@ export function remarkPlugin(options: RemarkPluginOptions) {
};

// @ts-ignore
this.Compiler = compiler;
this.Compiler = options.editor.getOptions(MarkdownPlugin).keepLineBreak
? (node: MdastNode) => remarkLineBreakCompiler(node, options)
: compiler;
natamox marked this conversation as resolved.
Show resolved Hide resolved
}
13 changes: 11 additions & 2 deletions packages/markdown/src/lib/remark-slate/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,25 @@ export type MdastTextType =

export type MdastNodeType = MdastElementType | MdastTextType;

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

export interface MdastNode {
// 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;
Expand Down