Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 46 additions & 62 deletions packages/lexical-markdown/src/MarkdownImport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@ import {
$createTabNode,
$createTextNode,
$findMatchingParent,
$getRoot,
$getSelection,
$isElementNode,
$isParagraphNode,
ElementNode,
Expand All @@ -40,77 +38,63 @@ export type TextFormatTransformersIndex = Readonly<{
}>;

/**
* Renders markdown from a string. The selection is moved to the start after the operation.
* Parses a markdown string and appends the resulting nodes to `container`.
* Does not clear the container or touch the selection — callers handle that.
*/
export function createMarkdownImport(
export function $importMarkdownNodes(
markdownString: string,
container: ElementNode,
transformers: Transformer[],
shouldPreserveNewLines = false,
): (markdownString: string, node?: ElementNode) => void {
): void {
const byType = transformersByType(transformers);
const textFormatTransformersIndex = createTextFormatTransformersIndex(
byType.textFormat,
);
const lines = markdownString.split('\n');
const linesLength = lines.length;

for (let i = 0; i < linesLength; i++) {
const lineText = lines[i];

const [imported, shiftedIndex] = $importMultiline(
lines,
i,
byType.multilineElement,
container,
);

if (imported) {
i = shiftedIndex;
continue;
}

return (markdownString, node) => {
const lines = markdownString.split('\n');
const linesLength = lines.length;
const root = node || $getRoot();
root.clear();

for (let i = 0; i < linesLength; i++) {
const lineText = lines[i];

const [imported, shiftedIndex] = $importMultiline(
lines,
i,
byType.multilineElement,
root,
);

if (imported) {
// If a multiline markdown element was imported, we don't want to process the lines that were part of it anymore.
// There could be other sub-markdown elements (both multiline and normal ones) matching within this matched multiline element's children.
// However, it would be the responsibility of the matched multiline transformer to decide how it wants to handle them.
// We cannot handle those, as there is no way for us to know how to maintain the correct order of generated lexical nodes for possible children.
i = shiftedIndex; // Next loop will start from the line after the last line of the multiline element
continue;
}
$importBlocks(
lineText,
container,
byType.element,
textFormatTransformersIndex,
byType.textMatch,
shouldPreserveNewLines,
);
}

$importBlocks(
lineText,
root,
byType.element,
textFormatTransformersIndex,
byType.textMatch,
shouldPreserveNewLines,
);
const children = container.getChildren();
for (const child of children) {
if (
!shouldPreserveNewLines &&
isEmptyParagraph(child) &&
container.getChildrenSize() > 1
) {
child.remove();
continue;
}

const children = root.getChildren();
for (const child of children) {
// 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.
if (
!shouldPreserveNewLines &&
isEmptyParagraph(child) &&
root.getChildrenSize() > 1
) {
child.remove();
continue;
if ($isElementNode(child)) {
for (const textNode of child.getAllTextNodes()) {
$normalizeMarkdownTextNode(textNode);
}
// Convert all '\t' into TabNode.
if ($isElementNode(child)) {
for (const textNode of child.getAllTextNodes()) {
$normalizeMarkdownTextNode(textNode);
}
}
}

if ($getSelection() !== null) {
root.selectStart();
}
};
}
}

/**
Expand Down Expand Up @@ -262,7 +246,7 @@ function $importBlocks(
// If no transformer found and we left with original paragraph node
// can check if its content can be appended to the previous node
// if it's a paragraph, quote or list
if (elementNode.isAttached() && lineText.length > 0) {
if (elementNode.getParent() !== null && lineText.length > 0) {
const previousNode = elementNode.getPreviousSibling();
if (
!shouldPreserveNewLines && // Only append if we're not preserving newlines
Expand Down
131 changes: 131 additions & 0 deletions packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
$convertFromMarkdownString,
$convertSelectionToMarkdownString,
$convertToMarkdownString,
$generateNodesFromMarkdownString,
CHECK_LIST,
CODE,
ElementTransformer,
Expand Down Expand Up @@ -2868,3 +2869,133 @@ describe('Ordered list start adjustment (#8677)', () => {
);
});
});

describe('$generateNodesFromMarkdownString', () => {
function createTestEditor() {
return createHeadlessEditor({
nodes: [
HeadingNode,
ListNode,
ListItemNode,
QuoteNode,
CodeNode,
LinkNode,
],
});
}

it('returns nodes without modifying the root', () => {
const editor = createTestEditor();

editor.update(
() => {
$getRoot()
.clear()
.append($createParagraphNode().append($createTextNode('existing')));
},
{discrete: true},
);

let nodes: ReturnType<typeof $generateNodesFromMarkdownString> = [];
editor.update(
() => {
nodes = $generateNodesFromMarkdownString(
'# Heading\n\nParagraph',
TRANSFORMERS,
);
},
{discrete: true},
);

expect(nodes).toHaveLength(2);
expect(nodes[0].getType()).toBe('heading');
expect(nodes[1].getType()).toBe('paragraph');

expect(editor.read(() => $getRoot().getTextContent())).toBe('existing');
});

it('produces the same nodes as $convertFromMarkdownString', () => {
const md = '# Title\n\n- item 1\n- item 2\n\n> quote\n\n```\ncode\n```';

const convertEditor = createTestEditor();
convertEditor.update(() => $convertFromMarkdownString(md, TRANSFORMERS), {
discrete: true,
});
const convertHtml = convertEditor.read(() =>
$generateHtmlFromNodes(convertEditor),
);

const generateEditor = createTestEditor();
generateEditor.update(
() => {
const nodes = $generateNodesFromMarkdownString(md, TRANSFORMERS);
$getRoot()
.clear()
.append(...nodes);
},
{discrete: true},
);
const generateHtml = generateEditor.read(() =>
$generateHtmlFromNodes(generateEditor),
);

expect(generateHtml).toBe(convertHtml);
});

it('returned nodes can be inserted at selection', () => {
const editor = createTestEditor();

editor.update(
() => {
$getRoot()
.clear()
.append(
$createParagraphNode().append($createTextNode('before')),
$createParagraphNode().append($createTextNode('after')),
);
},
{discrete: true},
);

editor.update(
() => {
const root = $getRoot();
root.select(1, 1);
const nodes = $generateNodesFromMarkdownString(
'**bold**',
TRANSFORMERS,
);
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.insertNodes(nodes);
}
},
{discrete: true},
);

const html = editor.read(() => $generateHtmlFromNodes(editor));
expect(html).toContain('before');
expect(html).toContain('<strong');
expect(html).toContain('after');
});

it('handles adjacent line merging (commonmark)', () => {
const editor = createTestEditor();

let nodes: ReturnType<typeof $generateNodesFromMarkdownString> = [];
editor.update(
() => {
nodes = $generateNodesFromMarkdownString(
'line 1\nline 2',
TRANSFORMERS,
false,
true,
);
},
{discrete: true},
);

expect(nodes).toHaveLength(1);
expect(nodes[0].getType()).toBe('paragraph');
});
});
49 changes: 44 additions & 5 deletions packages/lexical-markdown/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,20 @@ import type {
TextMatchTransformer,
Transformer,
} from './MarkdownTransformers';
import type {BaseSelection, ElementNode} from 'lexical';
import type {BaseSelection, ElementNode, LexicalNode} from 'lexical';

import {$isRangeSelection} from 'lexical';
import {
$getRoot,
$getSelection,
$isRangeSelection,
ArtificialNode__DO_NOT_USE,
} from 'lexical';

import {
createMarkdownExport,
createSelectionMarkdownExport,
} from './MarkdownExport';
import {createMarkdownImport} from './MarkdownImport';
import {$importMarkdownNodes} from './MarkdownImport';
import {registerMarkdownShortcuts} from './MarkdownShortcuts';
import {
BOLD_ITALIC_STAR,
Expand Down Expand Up @@ -65,11 +70,44 @@ function $convertFromMarkdownString(
const sanitizedMarkdown = shouldPreserveNewLines
? markdown
: normalizeMarkdown(markdown, shouldMergeAdjacentLines);
const importMarkdown = createMarkdownImport(
const root = node || $getRoot();
root.clear();
$importMarkdownNodes(
sanitizedMarkdown,
root,
transformers,
shouldPreserveNewLines,
);
if ($getSelection() !== null) {
root.selectStart();
}
}

/**
* Parses a markdown string and returns the resulting nodes as an array,
* without modifying the document tree or selection. The returned nodes can be
* inserted at an arbitrary position via `selection.insertNodes()`.
*
* @param {boolean} [shouldPreserveNewLines] By setting this to true, new lines will be preserved between conversions
* @param {boolean} [shouldMergeAdjacentLines] By setting this to true, adjacent non empty lines will be merged according to commonmark spec: https://spec.commonmark.org/0.24/#example-177. Not applicable if shouldPreserveNewLines = true.
*/
function $generateNodesFromMarkdownString(
markdown: string,
transformers: Transformer[] = TRANSFORMERS,
shouldPreserveNewLines = false,
shouldMergeAdjacentLines = false,
): LexicalNode[] {
const sanitizedMarkdown = shouldPreserveNewLines
? markdown
: normalizeMarkdown(markdown, shouldMergeAdjacentLines);
const container = new ArtificialNode__DO_NOT_USE();
$importMarkdownNodes(
sanitizedMarkdown,
container,
transformers,
shouldPreserveNewLines,
);
return importMarkdown(sanitizedMarkdown, node);
return container.getChildren();
}

/**
Expand Down Expand Up @@ -109,6 +147,7 @@ export {
$convertFromMarkdownString,
$convertSelectionToMarkdownString,
$convertToMarkdownString,
$generateNodesFromMarkdownString,
BOLD_ITALIC_STAR,
BOLD_ITALIC_UNDERSCORE,
BOLD_STAR,
Expand Down
Loading
Loading