Skip to content

Commit aa93637

Browse files
authored
Merge pull request #3679 from udecode/feat/deserialize-md
preserve line breaks during markdown deserialization
2 parents 7c84511 + 564769a commit aa93637

9 files changed

+290
-12
lines changed

.changeset/soft-keys-march.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@udecode/plate-markdown": patch
3+
---
4+
5+
Split line breaks into separate paragraphs during Markdown deserialization

packages/markdown/src/lib/MarkdownPlugin.ts

+10
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ export type MarkdownConfig = PluginConfig<
2626
/** Override element rules. */
2727
elementRules?: RemarkElementRules;
2828
indentList?: boolean;
29+
/**
30+
* When the text contains \n, split the text into a separate paragraph.
31+
*
32+
* Line breaks between paragraphs will also be converted into separate
33+
* paragraphs.
34+
*
35+
* @default false
36+
*/
37+
splitLineBreaks?: boolean;
2938

3039
/** Override text rules. */
3140
textRules?: RemarkTextRules;
@@ -43,6 +52,7 @@ export const MarkdownPlugin = createTSlatePlugin<MarkdownConfig>({
4352
options: {
4453
elementRules: remarkDefaultElementRules,
4554
indentList: false,
55+
splitLineBreaks: false,
4656
textRules: remarkDefaultTextRules,
4757
},
4858
})

packages/markdown/src/lib/deserializer/utils/deserializeMd.spec.tsx

+129
Original file line numberDiff line numberDiff line change
@@ -605,3 +605,132 @@ describe('deserializeMdIndentList', () => {
605605
expect(deserializeMd(editor, input)).toEqual(output);
606606
});
607607
});
608+
609+
describe('when splitLineBreaks is enabled', () => {
610+
const editor = createSlateEditor({
611+
plugins: [MarkdownPlugin.configure({ options: { splitLineBreaks: true } })],
612+
});
613+
614+
it('should deserialize paragraphs and keep in separate paragraphs with line breaks', () => {
615+
const input =
616+
'Paragraph 1 line 1\nParagraph 1 line 2\n\nParagraph 2 line 1';
617+
618+
const output = (
619+
<fragment>
620+
<hp>Paragraph 1 line 1</hp>
621+
<hp>Paragraph 1 line 2</hp>
622+
<hp>
623+
<htext />
624+
</hp>
625+
<hp>Paragraph 2 line 1</hp>
626+
</fragment>
627+
);
628+
629+
expect(deserializeMd(editor, input)).toEqual(output);
630+
});
631+
632+
it('should deserialize line break tags and keep in separate paragraphs', () => {
633+
const input = 'Line 1<br>Line 2';
634+
const output = (
635+
<fragment>
636+
<hp>Line 1</hp>
637+
<hp>Line 2</hp>
638+
</fragment>
639+
);
640+
641+
expect(deserializeMd(editor, input)).toEqual(output);
642+
});
643+
644+
it('splits N consecutive line breaks into N paragraph breaks', () => {
645+
const input = '\n\nLine 1\n\nLine 2\n\n\nLine 3\n\n';
646+
647+
const output = (
648+
<fragment>
649+
<hp>
650+
<htext />
651+
</hp>
652+
<hp>
653+
<htext />
654+
</hp>
655+
<hp>Line 1</hp>
656+
<hp>
657+
<htext />
658+
</hp>
659+
<hp>Line 2</hp>
660+
<hp>
661+
<htext />
662+
</hp>
663+
<hp>
664+
<htext />
665+
</hp>
666+
<hp>Line 3</hp>
667+
<hp>
668+
<htext />
669+
</hp>
670+
</fragment>
671+
);
672+
673+
expect(deserializeMd(editor, input)).toEqual(output);
674+
});
675+
676+
it('splits N consecutive line break tags into N paragraph breaks', () => {
677+
const input = '<br><br>Line 1<br><br>Line 2<br><br><br>Line 3<br><br>';
678+
679+
const output = (
680+
<fragment>
681+
<hp>
682+
<htext />
683+
</hp>
684+
<hp>
685+
<htext />
686+
</hp>
687+
<hp>Line 1</hp>
688+
<hp>
689+
<htext />
690+
</hp>
691+
<hp>Line 2</hp>
692+
<hp>
693+
<htext />
694+
</hp>
695+
<hp>
696+
<htext />
697+
</hp>
698+
<hp>Line 3</hp>
699+
<hp>
700+
<htext />
701+
</hp>
702+
</fragment>
703+
);
704+
705+
expect(deserializeMd(editor, input)).toEqual(output);
706+
});
707+
708+
it('allows mixing line breaks and line break tags', () => {
709+
const input = '<br>Line 1\n<br>Line 2<br>\n<br>Line 3\n<br>';
710+
711+
const output = (
712+
<fragment>
713+
<hp>
714+
<htext />
715+
</hp>
716+
<hp>Line 1</hp>
717+
<hp>
718+
<htext />
719+
</hp>
720+
<hp>Line 2</hp>
721+
<hp>
722+
<htext />
723+
</hp>
724+
<hp>
725+
<htext />
726+
</hp>
727+
<hp>Line 3</hp>
728+
<hp>
729+
<htext />
730+
</hp>
731+
</fragment>
732+
);
733+
734+
expect(deserializeMd(editor, input)).toEqual(output);
735+
});
736+
});

packages/markdown/src/lib/remark-slate/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
export * from './remarkDefaultElementRules';
66
export * from './remarkDefaultTextRules';
7+
export * from './remarkSplitLineBreaksCompiler';
78
export * from './remarkPlugin';
89
export * from './remarkTextTypes';
910
export * from './remarkTransformElement';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { TDescendant } from '@udecode/plate-common';
2+
3+
import type { MdastNode, RemarkPluginOptions } from './types';
4+
5+
import { remarkTransformNode } from './remarkTransformNode';
6+
7+
export const remarkDefaultCompiler = (
8+
node: MdastNode,
9+
options: RemarkPluginOptions
10+
): TDescendant[] => {
11+
return (node.children || []).flatMap((child) =>
12+
remarkTransformNode(child, options)
13+
);
14+
};

packages/markdown/src/lib/remark-slate/remarkDefaultElementRules.ts

+50-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { TDescendant, TElement, TText } from '@udecode/plate-common';
22

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

5+
import { MarkdownPlugin } from '../MarkdownPlugin';
56
import { remarkTransformElementChildren } from './remarkTransformElementChildren';
67
import { remarkTransformNode } from './remarkTransformNode';
78

@@ -79,7 +80,16 @@ export const remarkDefaultElementRules: RemarkElementRules = {
7980
indent = 1
8081
) => {
8182
_node.children?.forEach((listItem) => {
82-
const [paragraph, ...subLists] = listItem.children!;
83+
if (!listItem.children) {
84+
listItems.push({
85+
children: remarkTransformElementChildren(listItem, options),
86+
type: options.editor.getType({ key: 'p' }),
87+
});
88+
89+
return listItems;
90+
}
91+
92+
const [paragraph, ...subLists] = listItem.children;
8393

8494
listItems.push({
8595
children: remarkTransformElementChildren(
@@ -139,6 +149,9 @@ export const remarkDefaultElementRules: RemarkElementRules = {
139149
},
140150
paragraph: {
141151
transform: (node, options) => {
152+
const isKeepLineBreak =
153+
options.editor.getOptions(MarkdownPlugin).splitLineBreaks;
154+
142155
const children = remarkTransformElementChildren(node, options);
143156

144157
const paragraphType = options.editor.getType({ key: 'p' });
@@ -164,6 +177,42 @@ export const remarkDefaultElementRules: RemarkElementRules = {
164177
if (type && splitBlockTypes.has(type as string)) {
165178
flushInlineNodes();
166179
elements.push(child as TElement);
180+
} else if (
181+
isKeepLineBreak &&
182+
'text' in child &&
183+
typeof child.text === 'string'
184+
) {
185+
// Handle line break generated by <br>
186+
const isSingleLineBreak =
187+
child.text === '\n' && inlineNodes.length === 0;
188+
189+
if (isSingleLineBreak) {
190+
inlineNodes.push({ ...child, text: '' });
191+
flushInlineNodes();
192+
193+
return;
194+
}
195+
196+
// Handle text containing line breaks
197+
const textParts = child.text.split('\n');
198+
199+
textParts.forEach((part, index, array) => {
200+
const isNotFirstPart = index > 0;
201+
const isNotLastPart = index < array.length - 1;
202+
203+
// Create new paragraph for non-first parts
204+
if (isNotFirstPart) {
205+
flushInlineNodes();
206+
}
207+
// Only add non-empty text
208+
if (part) {
209+
inlineNodes.push({ ...child, text: part });
210+
}
211+
// Create paragraph break for non-last parts
212+
if (isNotLastPart) {
213+
flushInlineNodes();
214+
}
215+
});
167216
} else {
168217
inlineNodes.push(child);
169218
}
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,27 @@
11
/* eslint-disable @typescript-eslint/ban-ts-comment */
22

3+
import type { Processor } from 'unified';
4+
35
import type { MdastNode, RemarkPluginOptions } from './types';
46

5-
import { remarkTransformNode } from './remarkTransformNode';
7+
import { MarkdownPlugin } from '../MarkdownPlugin';
8+
import { remarkDefaultCompiler } from './remarkDefaultCompiler';
9+
import { remarkSplitLineBreaksCompiler } from './remarkSplitLineBreaksCompiler';
10+
11+
export function remarkPlugin(
12+
this: Processor<undefined, undefined, undefined, MdastNode, any>,
13+
options: RemarkPluginOptions
14+
) {
15+
const shouldSplitLineBreaks =
16+
options.editor.getOptions(MarkdownPlugin).splitLineBreaks;
17+
18+
const compiler = (node: MdastNode) => {
19+
if (shouldSplitLineBreaks) {
20+
return remarkSplitLineBreaksCompiler(node, options);
21+
}
622

7-
export function remarkPlugin(options: RemarkPluginOptions) {
8-
const compiler = (node: { children: MdastNode[] }) => {
9-
return node.children.flatMap((child) =>
10-
remarkTransformNode(child, options)
11-
);
23+
return remarkDefaultCompiler(node, options);
1224
};
1325

14-
// @ts-ignore
15-
this.Compiler = compiler;
26+
this.compiler = compiler;
1627
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { TDescendant, TText } from '@udecode/plate-common';
2+
3+
import type { MdastNode, RemarkPluginOptions } from './types';
4+
5+
import { remarkTransformNode } from './remarkTransformNode';
6+
7+
export const remarkSplitLineBreaksCompiler = (
8+
node: MdastNode,
9+
options: RemarkPluginOptions
10+
): TDescendant[] => {
11+
const results: TDescendant[] = [];
12+
let startLine = node.position!.start.line;
13+
14+
const addEmptyParagraphs = (count: number) => {
15+
if (count > 0) {
16+
results.push(
17+
...Array.from({ length: count }).map(() => {
18+
return {
19+
children: [{ text: '' } as TText],
20+
type: options.editor.getType({ key: 'p' }),
21+
};
22+
})
23+
);
24+
}
25+
};
26+
27+
node?.children?.forEach((child, index) => {
28+
const isFirstChild = index === 0;
29+
const isLastChild = index === node.children!.length - 1;
30+
31+
const emptyLinesBefore =
32+
child.position!.start.line - (isFirstChild ? startLine : startLine + 1);
33+
addEmptyParagraphs(emptyLinesBefore);
34+
35+
const transformValue = remarkTransformNode(child, options);
36+
results.push(
37+
...(Array.isArray(transformValue) ? transformValue : [transformValue])
38+
);
39+
40+
if (isLastChild) {
41+
const emptyLinesAfter =
42+
node.position!.end.line - child.position!.end.line - 1;
43+
addEmptyParagraphs(emptyLinesAfter);
44+
}
45+
46+
startLine = child.position!.end.line;
47+
});
48+
49+
return results;
50+
};

packages/markdown/src/lib/remark-slate/types.ts

+12-3
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,28 @@ export type MdastTextType =
2222

2323
export type MdastNodeType = MdastElementType | MdastTextType;
2424

25+
export interface TextPosition {
26+
column: number;
27+
line: number;
28+
offset?: number;
29+
}
30+
2531
export interface MdastNode {
32+
type: MdastNodeType;
33+
// mdast metadata
34+
position?: {
35+
end: TextPosition;
36+
start: TextPosition;
37+
};
2638
alt?: string;
2739
checked?: any;
2840
children?: MdastNode[];
2941
depth?: 1 | 2 | 3 | 4 | 5 | 6;
3042
indent?: any;
3143
lang?: string;
3244
ordered?: boolean;
33-
// mdast metadata
34-
position?: any;
3545
spread?: any;
3646
text?: string;
37-
type?: MdastNodeType;
3847
url?: string;
3948
value?: string;
4049
}

0 commit comments

Comments
 (0)