diff --git a/.changeset/soft-keys-march.md b/.changeset/soft-keys-march.md new file mode 100644 index 0000000000..18071fedc9 --- /dev/null +++ b/.changeset/soft-keys-march.md @@ -0,0 +1,5 @@ +--- +"@udecode/plate-markdown": patch +--- + +Split line breaks into separate paragraphs during Markdown deserialization diff --git a/packages/markdown/src/lib/MarkdownPlugin.ts b/packages/markdown/src/lib/MarkdownPlugin.ts index ad915978a4..1e9ff29e44 100644 --- a/packages/markdown/src/lib/MarkdownPlugin.ts +++ b/packages/markdown/src/lib/MarkdownPlugin.ts @@ -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; @@ -43,6 +52,7 @@ export const MarkdownPlugin = createTSlatePlugin({ options: { elementRules: remarkDefaultElementRules, indentList: false, + splitLineBreaks: false, textRules: remarkDefaultTextRules, }, }) diff --git a/packages/markdown/src/lib/deserializer/utils/deserializeMd.spec.tsx b/packages/markdown/src/lib/deserializer/utils/deserializeMd.spec.tsx index bcab8f144f..9716518543 100644 --- a/packages/markdown/src/lib/deserializer/utils/deserializeMd.spec.tsx +++ b/packages/markdown/src/lib/deserializer/utils/deserializeMd.spec.tsx @@ -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 = ( + + Paragraph 1 line 1 + Paragraph 1 line 2 + + + + Paragraph 2 line 1 + + ); + + expect(deserializeMd(editor, input)).toEqual(output); + }); + + it('should deserialize line break tags and keep in separate paragraphs', () => { + const input = 'Line 1
Line 2'; + const output = ( + + Line 1 + Line 2 + + ); + + 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 = ( + + + + + + + + Line 1 + + + + Line 2 + + + + + + + Line 3 + + + + + ); + + expect(deserializeMd(editor, input)).toEqual(output); + }); + + it('splits N consecutive line break tags into N paragraph breaks', () => { + const input = '

Line 1

Line 2


Line 3

'; + + const output = ( + + + + + + + + Line 1 + + + + Line 2 + + + + + + + Line 3 + + + + + ); + + expect(deserializeMd(editor, input)).toEqual(output); + }); + + it('allows mixing line breaks and line break tags', () => { + const input = '
Line 1\n
Line 2
\n
Line 3\n
'; + + const output = ( + + + + + Line 1 + + + + Line 2 + + + + + + + Line 3 + + + + + ); + + expect(deserializeMd(editor, input)).toEqual(output); + }); +}); diff --git a/packages/markdown/src/lib/remark-slate/index.ts b/packages/markdown/src/lib/remark-slate/index.ts index 7fde5b2e0f..6271256236 100644 --- a/packages/markdown/src/lib/remark-slate/index.ts +++ b/packages/markdown/src/lib/remark-slate/index.ts @@ -4,6 +4,7 @@ export * from './remarkDefaultElementRules'; export * from './remarkDefaultTextRules'; +export * from './remarkSplitLineBreaksCompiler'; export * from './remarkPlugin'; export * from './remarkTextTypes'; export * from './remarkTransformElement'; diff --git a/packages/markdown/src/lib/remark-slate/remarkDefaultCompiler.ts b/packages/markdown/src/lib/remark-slate/remarkDefaultCompiler.ts new file mode 100644 index 0000000000..4678f4cd7b --- /dev/null +++ b/packages/markdown/src/lib/remark-slate/remarkDefaultCompiler.ts @@ -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) + ); +}; diff --git a/packages/markdown/src/lib/remark-slate/remarkDefaultElementRules.ts b/packages/markdown/src/lib/remark-slate/remarkDefaultElementRules.ts index 4a8fd90efa..21eac86b12 100644 --- a/packages/markdown/src/lib/remark-slate/remarkDefaultElementRules.ts +++ b/packages/markdown/src/lib/remark-slate/remarkDefaultElementRules.ts @@ -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'; @@ -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( @@ -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' }); @@ -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
+ 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); } diff --git a/packages/markdown/src/lib/remark-slate/remarkPlugin.ts b/packages/markdown/src/lib/remark-slate/remarkPlugin.ts index 25de59afdf..e93fa22d46 100644 --- a/packages/markdown/src/lib/remark-slate/remarkPlugin.ts +++ b/packages/markdown/src/lib/remark-slate/remarkPlugin.ts @@ -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, + 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; } diff --git a/packages/markdown/src/lib/remark-slate/remarkSplitLineBreaksCompiler.ts b/packages/markdown/src/lib/remark-slate/remarkSplitLineBreaksCompiler.ts new file mode 100644 index 0000000000..bb8e4cc661 --- /dev/null +++ b/packages/markdown/src/lib/remark-slate/remarkSplitLineBreaksCompiler.ts @@ -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; +}; diff --git a/packages/markdown/src/lib/remark-slate/types.ts b/packages/markdown/src/lib/remark-slate/types.ts index 0d269d9cca..10075866db 100644 --- a/packages/markdown/src/lib/remark-slate/types.ts +++ b/packages/markdown/src/lib/remark-slate/types.ts @@ -22,7 +22,19 @@ 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[]; @@ -30,11 +42,8 @@ export interface MdastNode { indent?: any; lang?: string; ordered?: boolean; - // mdast metadata - position?: any; spread?: any; text?: string; - type?: MdastNodeType; url?: string; value?: string; }