diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index c0215cdc626..b3d23a32cb4 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -22,12 +22,14 @@ import { $isTextNode, } from 'lexical'; -import {transformersByType} from './utils'; +import {isEmptyParagraph, transformersByType} from './utils'; export function createMarkdownExport( transformers: Array, + shouldPreserveNewLines: boolean = false, ): (node?: ElementNode) => string { const byType = transformersByType(transformers); + const isNewlineDelimited = !shouldPreserveNewLines; // Export only uses text formats that are responsible for single format // e.g. it will filter out *** (bold, italic) and instead use separate ** and * @@ -39,7 +41,8 @@ export function createMarkdownExport( const output = []; const children = (node || $getRoot()).getChildren(); - for (const child of children) { + for (let i = 0; i < children.length; i++) { + const child = children[i]; const result = exportTopLevelElements( child, byType.element, @@ -48,11 +51,20 @@ export function createMarkdownExport( ); if (result != null) { - output.push(result); + output.push( + // seperate consecutive group of texts with a line break: eg. ["hello", "world"] -> ["hello", "/nworld"] + isNewlineDelimited && + i > 0 && + !isEmptyParagraph(child) && + !isEmptyParagraph(children[i - 1]) + ? '\n'.concat(result) + : result, + ); } } - - return output.join('\n\n'); + // Ensure consecutive groups of texts are atleast \n\n apart while each empty paragraph render as a newline. + // Eg. ["hello", "", "", "hi", "\nworld"] -> "hello\n\n\nhi\n\nworld" + return output.join('\n'); }; } @@ -116,6 +128,7 @@ function exportChildren( exportTextFormat(child, child.getTextContent(), textTransformersIndex), ); } else if ($isElementNode(child)) { + // empty paragraph returns "" output.push( exportChildren(child, textTransformersIndex, textMatchTransformers), ); diff --git a/packages/lexical-markdown/src/MarkdownImport.ts b/packages/lexical-markdown/src/MarkdownImport.ts index 76e40e2ca5e..dbeb689f63f 100644 --- a/packages/lexical-markdown/src/MarkdownImport.ts +++ b/packages/lexical-markdown/src/MarkdownImport.ts @@ -13,7 +13,7 @@ import type { TextMatchTransformer, Transformer, } from '@lexical/markdown'; -import type {LexicalNode, TextNode} from 'lexical'; +import type {TextNode} from 'lexical'; import {$createCodeNode} from '@lexical/code'; import {$isListItemNode, $isListNode, ListItemNode} from '@lexical/list'; @@ -26,14 +26,16 @@ import { $getRoot, $getSelection, $isParagraphNode, - $isTextNode, ElementNode, } from 'lexical'; import {IS_APPLE_WEBKIT, IS_IOS, IS_SAFARI} from 'shared/environment'; -import {PUNCTUATION_OR_SPACE, transformersByType} from './utils'; +import { + isEmptyParagraph, + PUNCTUATION_OR_SPACE, + transformersByType, +} from './utils'; -const MARKDOWN_EMPTY_LINE_REG_EXP = /^\s{0,3}$/; const CODE_BLOCK_REG_EXP = /^[ \t]*```(\w{1,10})?\s?$/; type TextFormatTransformersIndex = Readonly<{ fullMatchRegExpByTag: Readonly>; @@ -43,6 +45,7 @@ type TextFormatTransformersIndex = Readonly<{ export function createMarkdownImport( transformers: Array, + shouldPreserveNewLines = false, ): (markdownString: string, node?: ElementNode) => void { const byType = transformersByType(transformers); const textFormatTransformersIndex = createTextFormatTransformersIndex( @@ -77,11 +80,16 @@ export function createMarkdownImport( ); } - // Removing empty paragraphs as md does not really - // allow empty lines and uses them as delimiter + // By default, removing empty paragraphs as md does not really + // allow empty lines and uses them as delimiter. + // If you need empty lines set shouldPreserveNewLines = true. const children = root.getChildren(); for (const child of children) { - if (isEmptyParagraph(child) && root.getChildrenSize() > 1) { + if ( + !shouldPreserveNewLines && + isEmptyParagraph(child) && + root.getChildrenSize() > 1 + ) { child.remove(); } } @@ -92,20 +100,6 @@ export function createMarkdownImport( }; } -function isEmptyParagraph(node: LexicalNode): boolean { - if (!$isParagraphNode(node)) { - return false; - } - - const firstChild = node.getFirstChild(); - return ( - firstChild == null || - (node.getChildrenSize() === 1 && - $isTextNode(firstChild) && - MARKDOWN_EMPTY_LINE_REG_EXP.test(firstChild.getTextContent())) - ); -} - function $importBlocks( lineText: string, rootNode: ElementNode, diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index fcc3f20f075..e5eb74bda6e 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -28,6 +28,7 @@ describe('Markdown', () => { md: string; skipExport?: true; skipImport?: true; + shouldPreserveNewLines?: true; }>; const URL = 'https://lexical.dev'; @@ -147,6 +148,16 @@ describe('Markdown', () => { html: '

Hello world!

', md: '*Hello **world**!*', }, + { + html: '

Hello




world!

', + md: '# Hello\n\n\n\n**world**!', + shouldPreserveNewLines: true, + }, + { + html: '

Hello

hi


world


hi

hello
hello


hi


hi

', + md: '# Hello\nhi\n\n**world**\n\nhi\n> hello\n> hello\n\n# hi\n\nhi', + shouldPreserveNewLines: true, + }, { // Import only: export will use * instead of _ due to registered transformers order html: '

Hello world

', @@ -221,7 +232,12 @@ describe('Markdown', () => { }, }; - for (const {html, md, skipImport} of IMPORT_AND_EXPORT) { + for (const { + html, + md, + skipImport, + shouldPreserveNewLines, + } of IMPORT_AND_EXPORT) { if (skipImport) { continue; } @@ -240,10 +256,12 @@ describe('Markdown', () => { editor.update( () => - $convertFromMarkdownString(md, [ - ...TRANSFORMERS, - HIGHLIGHT_TEXT_MATCH_IMPORT, - ]), + $convertFromMarkdownString( + md, + [...TRANSFORMERS, HIGHLIGHT_TEXT_MATCH_IMPORT], + undefined, + shouldPreserveNewLines, + ), { discrete: true, }, @@ -255,7 +273,12 @@ describe('Markdown', () => { }); } - for (const {html, md, skipExport} of IMPORT_AND_EXPORT) { + for (const { + html, + md, + skipExport, + shouldPreserveNewLines, + } of IMPORT_AND_EXPORT) { if (skipExport) { continue; } @@ -288,7 +311,13 @@ describe('Markdown', () => { expect( editor .getEditorState() - .read(() => $convertToMarkdownString(TRANSFORMERS)), + .read(() => + $convertToMarkdownString( + TRANSFORMERS, + undefined, + shouldPreserveNewLines, + ), + ), ).toBe(md); }); } diff --git a/packages/lexical-markdown/src/index.ts b/packages/lexical-markdown/src/index.ts index 7c44b5ae516..24d19291cbd 100644 --- a/packages/lexical-markdown/src/index.ts +++ b/packages/lexical-markdown/src/index.ts @@ -72,16 +72,24 @@ function $convertFromMarkdownString( markdown: string, transformers: Array = TRANSFORMERS, node?: ElementNode, + shouldPreserveNewLines = false, ): void { - const importMarkdown = createMarkdownImport(transformers); + const importMarkdown = createMarkdownImport( + transformers, + shouldPreserveNewLines, + ); return importMarkdown(markdown, node); } function $convertToMarkdownString( transformers: Array = TRANSFORMERS, node?: ElementNode, + shouldPreserveNewLines: boolean = false, ): string { - const exportMarkdown = createMarkdownExport(transformers); + const exportMarkdown = createMarkdownExport( + transformers, + shouldPreserveNewLines, + ); return exportMarkdown(node); } diff --git a/packages/lexical-markdown/src/utils.ts b/packages/lexical-markdown/src/utils.ts index 4d99a2acf39..ce8f1e3657f 100644 --- a/packages/lexical-markdown/src/utils.ts +++ b/packages/lexical-markdown/src/utils.ts @@ -13,11 +13,17 @@ import type { TextMatchTransformer, Transformer, } from '@lexical/markdown'; -import type {ElementNode, LexicalNode, TextFormatType} from 'lexical'; import {$isCodeNode} from '@lexical/code'; import {$isListItemNode, $isListNode} from '@lexical/list'; import {$isHeadingNode, $isQuoteNode} from '@lexical/rich-text'; +import { + $isParagraphNode, + $isTextNode, + type ElementNode, + type LexicalNode, + type TextFormatType, +} from 'lexical'; type MarkdownFormatKind = | 'noTransformation' @@ -429,3 +435,19 @@ export function transformersByType(transformers: Array): Readonly<{ } export const PUNCTUATION_OR_SPACE = /[!-/:-@[-`{-~\s]/; + +const MARKDOWN_EMPTY_LINE_REG_EXP = /^\s{0,3}$/; + +export function isEmptyParagraph(node: LexicalNode): boolean { + if (!$isParagraphNode(node)) { + return false; + } + + const firstChild = node.getFirstChild(); + return ( + firstChild == null || + (node.getChildrenSize() === 1 && + $isTextNode(firstChild) && + MARKDOWN_EMPTY_LINE_REG_EXP.test(firstChild.getTextContent())) + ); +} diff --git a/packages/lexical-playground/src/Editor.tsx b/packages/lexical-playground/src/Editor.tsx index 9981098e6a5..096770f184e 100644 --- a/packages/lexical-playground/src/Editor.tsx +++ b/packages/lexical-playground/src/Editor.tsx @@ -88,6 +88,7 @@ export default function Editor(): JSX.Element { showTreeView, showTableOfContents, shouldUseLexicalContextMenu, + shouldPreserveNewLinesInMarkdown, tableCellMerge, tableCellBackgroundColor, }, @@ -239,7 +240,10 @@ export default function Editor(): JSX.Element { {isAutocomplete && }
{showTableOfContents && }
{shouldUseLexicalContextMenu && } - + {showTreeView && } diff --git a/packages/lexical-playground/src/Settings.tsx b/packages/lexical-playground/src/Settings.tsx index abfd6005c0e..a8e622dc6b0 100644 --- a/packages/lexical-playground/src/Settings.tsx +++ b/packages/lexical-playground/src/Settings.tsx @@ -30,6 +30,7 @@ export default function Settings(): JSX.Element { disableBeforeInput, showTableOfContents, shouldUseLexicalContextMenu, + shouldPreserveNewLinesInMarkdown, }, } = useSettings(); useEffect(() => { @@ -150,6 +151,16 @@ export default function Settings(): JSX.Element { checked={shouldUseLexicalContextMenu} text="Use Lexical Context Menu" /> + { + setOption( + 'shouldPreserveNewLinesInMarkdown', + !shouldPreserveNewLinesInMarkdown, + ); + }} + checked={shouldPreserveNewLinesInMarkdown} + text="Preserve newlines in Markdown" + /> ) : null} diff --git a/packages/lexical-playground/src/appSettings.ts b/packages/lexical-playground/src/appSettings.ts index 97a34db486f..61892c2946e 100644 --- a/packages/lexical-playground/src/appSettings.ts +++ b/packages/lexical-playground/src/appSettings.ts @@ -21,6 +21,7 @@ export const DEFAULT_SETTINGS = { isMaxLength: false, isRichText: true, measureTypingPerf: false, + shouldPreserveNewLinesInMarkdown: false, shouldUseLexicalContextMenu: false, showNestedEditorTreeView: false, showTableOfContents: false, diff --git a/packages/lexical-playground/src/plugins/ActionsPlugin/index.tsx b/packages/lexical-playground/src/plugins/ActionsPlugin/index.tsx index 9b06dd8f25c..5fee067b33c 100644 --- a/packages/lexical-playground/src/plugins/ActionsPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/ActionsPlugin/index.tsx @@ -93,8 +93,10 @@ async function shareDoc(doc: SerializedDocument): Promise { export default function ActionsPlugin({ isRichText, + shouldPreserveNewLinesInMarkdown, }: { isRichText: boolean; + shouldPreserveNewLinesInMarkdown: boolean; }): JSX.Element { const [editor] = useLexicalComposerContext(); const [isEditable, setIsEditable] = useState(() => editor.isEditable()); @@ -172,9 +174,15 @@ export default function ActionsPlugin({ $convertFromMarkdownString( firstChild.getTextContent(), PLAYGROUND_TRANSFORMERS, + undefined, // node + shouldPreserveNewLinesInMarkdown, ); } else { - const markdown = $convertToMarkdownString(PLAYGROUND_TRANSFORMERS); + const markdown = $convertToMarkdownString( + PLAYGROUND_TRANSFORMERS, + undefined, //node + shouldPreserveNewLinesInMarkdown, + ); root .clear() .append( @@ -183,7 +191,7 @@ export default function ActionsPlugin({ } root.selectEnd(); }); - }, [editor]); + }, [editor, shouldPreserveNewLinesInMarkdown]); return (