Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
165 changes: 114 additions & 51 deletions packages/lexical-markdown/src/MarkdownTransformers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,62 +523,125 @@ const $listExport = (
return output.join('\n');
};

export const HEADING: ElementTransformer = {
dependencies: [HeadingNode],
export: (node, exportChildren) => {
if (!$isHeadingNode(node)) {
return null;
}
const level = Number(node.getTag().slice(1));
return '#'.repeat(level) + ' ' + exportChildren(node);
},
regExp: HEADING_REGEX,
replace: createBlockNode(match => {
const tag = ('h' + match[1].length) as HeadingTagType;
return $createHeadingNode(tag);
}),
triggerOnEnter: true,
type: 'element',
};
export function $createHeadingTransformer(options?: {
nestInBlockquote?: boolean;
}): ElementTransformer {
const nestInBlockquote =
(options != null && options.nestInBlockquote) || false;
return {
dependencies: nestInBlockquote ? [HeadingNode, QuoteNode] : [HeadingNode],
export: (node, exportChildren) => {
if (!$isHeadingNode(node)) {
return null;
}
const level = Number(node.getTag().slice(1));
return '#'.repeat(level) + ' ' + exportChildren(node);
},
regExp: HEADING_REGEX,
replace: (parentNode, children, match, isImport) => {
const tag = ('h' + match[1].length) as HeadingTagType;
const headingNode = $createHeadingNode(tag);
headingNode.append(...children);

if (nestInBlockquote && $isQuoteNode(parentNode)) {
parentNode.append(headingNode);
} else {
parentNode.replace(headingNode);
}

export const QUOTE: ElementTransformer = {
dependencies: [QuoteNode],
export: (node, exportChildren) => {
if (!$isQuoteNode(node)) {
return null;
}
if (!isImport) {
headingNode.select(0, 0);
}
},
triggerOnEnter: true,
type: 'element',
};
}

const lines = exportChildren(node).split('\n');
const output = [];
for (const line of lines) {
output.push('> ' + line);
}
return output.join('\n');
},
regExp: QUOTE_REGEX,
replace: (parentNode, children, _match, isImport) => {
if (isImport) {
const previousNode = parentNode.getPreviousSibling();
if ($isQuoteNode(previousNode)) {
previousNode.splice(previousNode.getChildrenSize(), 0, [
$createMarkdownLineBreakNode(previousNode),
...children,
]);
parentNode.remove();
export const HEADING: ElementTransformer = $createHeadingTransformer();

const QUOTE_WITH_HEADING_REGEX = /^>\s(?:(#{1,6})\s)?/;

export function $createQuoteTransformer(options?: {
handleNestedHeadings?: boolean;
}): ElementTransformer {
const handleNestedHeadings =
(options != null && options.handleNestedHeadings) || false;
return {
dependencies: handleNestedHeadings ? [QuoteNode, HeadingNode] : [QuoteNode],
export: (node, exportChildren) => {
if (!$isQuoteNode(node)) {
return null;
}

if (handleNestedHeadings) {
const firstChild = node.getFirstChild();
if ($isHeadingNode(firstChild) && node.getChildrenSize() === 1) {
const level = Number(firstChild.getTag().slice(1));
return '> ' + '#'.repeat(level) + ' ' + exportChildren(firstChild);
}
}

const lines = exportChildren(node).split('\n');
const output = [];
for (const line of lines) {
output.push('> ' + line);
}
return output.join('\n');
},
regExp: handleNestedHeadings ? QUOTE_WITH_HEADING_REGEX : QUOTE_REGEX,
replace: (parentNode, children, match, isImport) => {
if (handleNestedHeadings && match[1]) {
const tag = ('h' + match[1].length) as HeadingTagType;
const headingNode = $createHeadingNode(tag);
headingNode.append(...children);

if (isImport) {
const previousNode = parentNode.getPreviousSibling();
if ($isQuoteNode(previousNode)) {
previousNode.splice(previousNode.getChildrenSize(), 0, [
$createMarkdownLineBreakNode(previousNode),
headingNode,
]);
parentNode.remove();
return;
}
}

const quoteNode = $createQuoteNode();
quoteNode.append(headingNode);
parentNode.replace(quoteNode);
if (!isImport) {
headingNode.select(0, 0);
}
return;
}
}

const node = $createQuoteNode();
node.append(...children);
parentNode.replace(node);
if (!isImport) {
node.select(0, 0);
}
},
triggerOnEnter: true,
type: 'element',
};
if (isImport) {
const previousNode = parentNode.getPreviousSibling();
if ($isQuoteNode(previousNode)) {
previousNode.splice(previousNode.getChildrenSize(), 0, [
$createMarkdownLineBreakNode(previousNode),
...children,
]);
parentNode.remove();
return;
}
}

const node = $createQuoteNode();
node.append(...children);
parentNode.replace(node);
if (!isImport) {
node.select(0, 0);
}
},
triggerOnEnter: true,
type: 'element',
};
}

export const QUOTE: ElementTransformer = $createQuoteTransformer();

export const CODE: MultilineElementTransformer = {
dependencies: [CodeNode],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,31 @@
*/
import {CodeExtension} from '@lexical/code-core';
import {buildEditorFromExtensions} from '@lexical/extension';
import {createHeadlessEditor} from '@lexical/headless';
import {HistoryExtension} from '@lexical/history';
import {$createLinkNode, $isLinkNode, LinkExtension} from '@lexical/link';
import {ListExtension} from '@lexical/list';
import {registerMarkdownShortcuts} from '@lexical/markdown';
import {RichTextExtension} from '@lexical/rich-text';
import {
$convertFromMarkdownString,
$convertToMarkdownString,
$createHeadingTransformer,
$createQuoteTransformer,
HEADING,
QUOTE,
registerMarkdownShortcuts,
TRANSFORMERS,
} from '@lexical/markdown';
import {
$createHeadingNode,
$createQuoteNode,
$isHeadingNode,
$isQuoteNode,
HeadingNode,
QuoteNode,
RichTextExtension,
} from '@lexical/rich-text';
import {
$createLineBreakNode,
$createParagraphNode,
$createTextNode,
$getRoot,
Expand Down Expand Up @@ -161,6 +180,183 @@ describe('LINK', () => {
});
});

describe('BLOCK QUOTE + HEADING', () => {
const headingWithNesting = $createHeadingTransformer({
nestInBlockquote: true,
});
const quoteWithNesting = $createQuoteTransformer({
handleNestedHeadings: true,
});
const nestableTransformers = TRANSFORMERS.map(t =>
t === HEADING ? headingWithNesting : t === QUOTE ? quoteWithNesting : t,
);

const NestableHeadingExtension = defineExtension({
dependencies: [
LinkExtension,
RichTextExtension,
ListExtension,
CodeExtension,
],
name: 'NestableHeadingTest',
register: editor_ =>
registerMarkdownShortcuts(editor_, nestableTransformers),
});

test('typing "> # SOME HEADER" creates a heading inside a quote when nestInBlockquote is enabled (issue #7407)', () => {
const editor = buildEditorFromExtensions([NestableHeadingExtension]);
typeMarkdown(editor, '> # SOME HEADER');
editor.read(() => {
const root = $getRoot();
const firstChild = root.getFirstChildOrThrow();
assert($isQuoteNode(firstChild), 'Root child must be a QuoteNode');
const quoteChild = firstChild.getFirstChildOrThrow();
assert($isHeadingNode(quoteChild), 'Quote child must be a HeadingNode');
expect(quoteChild.getTag()).toBe('h1');
expect(quoteChild.getTextContent()).toBe('SOME HEADER');
});
});

test('typing "> # SOME HEADER" replaces the quote with a heading by default', () => {
const editor = buildEditorFromExtensions([MarkdownShortcutTestExtension]);
typeMarkdown(editor, '> # SOME HEADER');
editor.read(() => {
const root = $getRoot();
const firstChild = root.getFirstChildOrThrow();
assert(
$isHeadingNode(firstChild),
'Root child must be a HeadingNode (default behavior)',
);
expect(firstChild.getTag()).toBe('h1');
expect(firstChild.getTextContent()).toBe('SOME HEADER');
});
});

test('import: "> # heading" produces a HeadingNode inside a QuoteNode', () => {
const editor = createHeadlessEditor({
nodes: [HeadingNode, QuoteNode],
});

editor.update(
() => {
$convertFromMarkdownString('> # SOME HEADER', nestableTransformers);
},
{discrete: true},
);

editor.read(() => {
const root = $getRoot();
const firstChild = root.getFirstChildOrThrow();
assert($isQuoteNode(firstChild), 'Root child must be a QuoteNode');
const quoteChild = firstChild.getFirstChildOrThrow();
assert($isHeadingNode(quoteChild), 'Quote child must be a HeadingNode');
expect(quoteChild.getTag()).toBe('h1');
expect(quoteChild.getTextContent()).toBe('SOME HEADER');
});
});

test('export: HeadingNode inside QuoteNode produces "> # heading"', () => {
const editor = createHeadlessEditor({
nodes: [HeadingNode, QuoteNode],
});

editor.update(
() => {
const heading = $createHeadingNode('h2').append(
$createTextNode('SOME HEADER'),
);
const quote = $createQuoteNode().append(heading);
$getRoot().clear().append(quote);
},
{discrete: true},
);

const markdown = editor
.getEditorState()
.read(() => $convertToMarkdownString(nestableTransformers));

expect(markdown).toBe('> ## SOME HEADER');
});

test('round-trip: import then export preserves "> # heading"', () => {
const editor = createHeadlessEditor({
nodes: [HeadingNode, QuoteNode],
});

const input = '> ### SOME HEADER';

editor.update(
() => {
$convertFromMarkdownString(input, nestableTransformers);
},
{discrete: true},
);

const output = editor
.getEditorState()
.read(() => $convertToMarkdownString(nestableTransformers));

expect(output).toBe(input);
});

test('import: "> # heading" followed by "> text" produces correct structure', () => {
const editor = createHeadlessEditor({
nodes: [HeadingNode, QuoteNode],
});

editor.update(
() => {
$convertFromMarkdownString(
'> # HEADING\n> some text',
nestableTransformers,
);
},
{discrete: true},
);

editor.read(() => {
const root = $getRoot();
const firstChild = root.getFirstChildOrThrow();
assert($isQuoteNode(firstChild), 'First child must be a QuoteNode');
const quoteChild = firstChild.getFirstChildOrThrow();
assert(
$isHeadingNode(quoteChild),
'First quote child must be a HeadingNode',
);
expect(quoteChild.getTag()).toBe('h1');
expect(quoteChild.getTextContent()).toBe('HEADING');
expect(firstChild.getTextContent()).toContain('some text');
});
});

test('export: QuoteNode with heading and text falls back to default quote export', () => {
const editor = createHeadlessEditor({
nodes: [HeadingNode, QuoteNode],
});

editor.update(
() => {
const heading = $createHeadingNode('h1').append(
$createTextNode('HEADING'),
);
const quote = $createQuoteNode().append(
heading,
$createLineBreakNode(),
$createTextNode('some text'),
);
$getRoot().clear().append(quote);
},
{discrete: true},
);

const markdown = editor
.getEditorState()
.read(() => $convertToMarkdownString(nestableTransformers));

expect(markdown).toBe('> HEADING\n> some text');
});
});

describe('CODE_SPAN_PRECEDENCE', () => {
test('__bold__ inside backticks is not formatted as bold', () => {
using editor = buildEditorFromExtensions([MarkdownShortcutTestExtension]);
Expand Down
Loading
Loading