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

[lexical-markdown][lexical-playground] Feature: Option to include blanklines in markdown render #6020

Merged
merged 13 commits into from
May 17, 2024
23 changes: 18 additions & 5 deletions packages/lexical-markdown/src/MarkdownExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@ import {
$isTextNode,
} from 'lexical';

import {transformersByType} from './utils';
import {isEmptyParagraph, transformersByType} from './utils';

export function createMarkdownExport(
transformers: Array<Transformer>,
shouldPreserveNewLines: boolean = false,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think the preserveNewLines comment we discussed applies here?

Copy link
Contributor Author

@potatowagon potatowagon May 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i hope i understood u correctly, its been applied in 89a7b30 ,

isNewlineDelimited replaced with shouldPreserveNewLines,

preserveNewLines is named as shouldPreserveNewLines to be consistent with boolean naming conventions

): (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 *
Expand All @@ -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,
Expand All @@ -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');
};
}

Expand Down Expand Up @@ -116,6 +128,7 @@ function exportChildren(
exportTextFormat(child, child.getTextContent(), textTransformersIndex),
);
} else if ($isElementNode(child)) {
// empty paragraph returns ""
output.push(
exportChildren(child, textTransformersIndex, textMatchTransformers),
);
Expand Down
36 changes: 15 additions & 21 deletions packages/lexical-markdown/src/MarkdownImport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<Record<string, RegExp>>;
Expand All @@ -43,6 +45,7 @@ type TextFormatTransformersIndex = Readonly<{

export function createMarkdownImport(
transformers: Array<Transformer>,
shouldPreserveNewLines = false,
): (markdownString: string, node?: ElementNode) => void {
const byType = transformersByType(transformers);
const textFormatTransformersIndex = createTextFormatTransformersIndex(
Expand Down Expand Up @@ -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();
}
}
Expand All @@ -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()))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this intentional? I believe this regex is particularly important on top of the default Node isEmpty

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i moved this to packages/lexical-markdown/src/utils.ts for reuse

);
}

function $importBlocks(
lineText: string,
rootNode: ElementNode,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ describe('Markdown', () => {
md: string;
skipExport?: true;
skipImport?: true;
shouldPreserveNewLines?: true;
}>;

const URL = 'https://lexical.dev';
Expand Down Expand Up @@ -147,6 +148,16 @@ describe('Markdown', () => {
html: '<p><i><em style="white-space: pre-wrap;">Hello </em></i><i><b><strong style="white-space: pre-wrap;">world</strong></b></i><i><em style="white-space: pre-wrap;">!</em></i></p>',
md: '*Hello **world**!*',
},
{
html: '<h1><span style="white-space: pre-wrap;">Hello</span></h1><p><br></p><p><br></p><p><br></p><p><b><strong style="white-space: pre-wrap;">world</strong></b><span style="white-space: pre-wrap;">!</span></p>',
md: '# Hello\n\n\n\n**world**!',
shouldPreserveNewLines: true,
},
{
html: '<h1><span style="white-space: pre-wrap;">Hello</span></h1><p><span style="white-space: pre-wrap;">hi</span></p><p><br></p><p><b><strong style="white-space: pre-wrap;">world</strong></b></p><p><br></p><p><span style="white-space: pre-wrap;">hi</span></p><blockquote><span style="white-space: pre-wrap;">hello</span><br><span style="white-space: pre-wrap;">hello</span></blockquote><p><br></p><h1><span style="white-space: pre-wrap;">hi</span></h1><p><br></p><p><span style="white-space: pre-wrap;">hi</span></p>',
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: '<p><i><em style="white-space: pre-wrap;">Hello</em></i><span style="white-space: pre-wrap;"> world</span></p>',
Expand Down Expand Up @@ -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;
}
Expand All @@ -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,
},
Expand All @@ -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;
}
Expand Down Expand Up @@ -288,7 +311,13 @@ describe('Markdown', () => {
expect(
editor
.getEditorState()
.read(() => $convertToMarkdownString(TRANSFORMERS)),
.read(() =>
$convertToMarkdownString(
TRANSFORMERS,
undefined,
shouldPreserveNewLines,
),
),
).toBe(md);
});
}
Expand Down
12 changes: 10 additions & 2 deletions packages/lexical-markdown/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,16 +72,24 @@ function $convertFromMarkdownString(
markdown: string,
transformers: Array<Transformer> = TRANSFORMERS,
node?: ElementNode,
shouldPreserveNewLines = false,
): void {
const importMarkdown = createMarkdownImport(transformers);
const importMarkdown = createMarkdownImport(
transformers,
shouldPreserveNewLines,
);
return importMarkdown(markdown, node);
}

function $convertToMarkdownString(
transformers: Array<Transformer> = TRANSFORMERS,
node?: ElementNode,
shouldPreserveNewLines: boolean = false,
): string {
const exportMarkdown = createMarkdownExport(transformers);
const exportMarkdown = createMarkdownExport(
transformers,
shouldPreserveNewLines,
);
return exportMarkdown(node);
}

Expand Down
24 changes: 23 additions & 1 deletion packages/lexical-markdown/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -429,3 +435,19 @@ export function transformersByType(transformers: Array<Transformer>): 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()))
);
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@zurfyx here it is

6 changes: 5 additions & 1 deletion packages/lexical-playground/src/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export default function Editor(): JSX.Element {
showTreeView,
showTableOfContents,
shouldUseLexicalContextMenu,
shouldPreserveNewLinesInMarkdown,
tableCellMerge,
tableCellBackgroundColor,
},
Expand Down Expand Up @@ -239,7 +240,10 @@ export default function Editor(): JSX.Element {
{isAutocomplete && <AutocompletePlugin />}
<div>{showTableOfContents && <TableOfContentsPlugin />}</div>
{shouldUseLexicalContextMenu && <ContextMenuPlugin />}
<ActionsPlugin isRichText={isRichText} />
<ActionsPlugin
isRichText={isRichText}
shouldPreserveNewLinesInMarkdown={shouldPreserveNewLinesInMarkdown}
/>
</div>
{showTreeView && <TreeViewPlugin />}
</>
Expand Down
11 changes: 11 additions & 0 deletions packages/lexical-playground/src/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export default function Settings(): JSX.Element {
disableBeforeInput,
showTableOfContents,
shouldUseLexicalContextMenu,
shouldPreserveNewLinesInMarkdown,
},
} = useSettings();
useEffect(() => {
Expand Down Expand Up @@ -150,6 +151,16 @@ export default function Settings(): JSX.Element {
checked={shouldUseLexicalContextMenu}
text="Use Lexical Context Menu"
/>
<Switch
onClick={() => {
setOption(
'shouldPreserveNewLinesInMarkdown',
!shouldPreserveNewLinesInMarkdown,
);
}}
checked={shouldPreserveNewLinesInMarkdown}
text="Preserve newlines in Markdown"
/>
</div>
) : null}
</>
Expand Down
1 change: 1 addition & 0 deletions packages/lexical-playground/src/appSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const DEFAULT_SETTINGS = {
isMaxLength: false,
isRichText: true,
measureTypingPerf: false,
shouldPreserveNewLinesInMarkdown: false,
shouldUseLexicalContextMenu: false,
showNestedEditorTreeView: false,
showTableOfContents: false,
Expand Down
12 changes: 10 additions & 2 deletions packages/lexical-playground/src/plugins/ActionsPlugin/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,10 @@ async function shareDoc(doc: SerializedDocument): Promise<void> {

export default function ActionsPlugin({
isRichText,
shouldPreserveNewLinesInMarkdown,
}: {
isRichText: boolean;
shouldPreserveNewLinesInMarkdown: boolean;
}): JSX.Element {
const [editor] = useLexicalComposerContext();
const [isEditable, setIsEditable] = useState(() => editor.isEditable());
Expand Down Expand Up @@ -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(
Expand All @@ -183,7 +191,7 @@ export default function ActionsPlugin({
}
root.selectEnd();
});
}, [editor]);
}, [editor, shouldPreserveNewLinesInMarkdown]);

return (
<div className="actions">
Expand Down
Loading