Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
57 changes: 57 additions & 0 deletions packages/lexical-markdown/src/MarkdownTransformers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
$createTextNode,
$findMatchingParent,
$getState,
$isParagraphNode,
$setState,
createState,
ElementNode,
Expand Down Expand Up @@ -414,6 +415,62 @@ export const QUOTE: ElementTransformer = {
type: 'element',
};

export function nestHeadingInBlockquote(
transformers: Array<Transformer> = TRANSFORMERS,
): Array<Transformer> {
const BLOCKQUOTE_HEADING_REGEX = /^(?:>\s)?(#{1,6})\s/;

const headingTransformer: ElementTransformer = {
dependencies: [HeadingNode, QuoteNode],
export: HEADING.export,
regExp: BLOCKQUOTE_HEADING_REGEX,
replace: (parentNode, children, match, isImport) => {
const tag = ('h' + match[1].length) as HeadingTagType;
const headingNode = $createHeadingNode(tag);
headingNode.append(...children);

const hasBlockquotePrefix = match[0].startsWith('>');

if (hasBlockquotePrefix) {
const quoteNode = $createQuoteNode();
quoteNode.append(headingNode);
parentNode.replace(quoteNode);
} else if (!isImport && !$isParagraphNode(parentNode)) {
parentNode.append(headingNode);
} else {
parentNode.replace(headingNode);
}

if (!isImport) {
headingNode.select(0, 0);
}
},
type: 'element',
};

const quoteTransformer: ElementTransformer = {
...QUOTE,
export: (node, exportChildren) => {
if (!$isQuoteNode(node)) {
return null;
}

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

return QUOTE.export!(node, exportChildren);
},
};

return transformers.map((t) =>
t === HEADING ? headingTransformer : t === QUOTE ? quoteTransformer : t,
);
}

export const CODE: MultilineElementTransformer = {
dependencies: [CodeNode],

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,27 @@
*/
import {CodeExtension} from '@lexical/code-core';
import {buildEditorFromExtensions} from '@lexical/extension';
import {createHeadlessEditor} from '@lexical/headless';
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,
nestHeadingInBlockquote,
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 @@ -156,3 +172,172 @@ describe('LINK', () => {
});
});
});

describe('BLOCK QUOTE + HEADING', () => {
const nestableTransformers = nestHeadingInBlockquote(TRANSFORMERS);

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 nestHeadingInBlockquote is used (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');
});
});
2 changes: 2 additions & 0 deletions packages/lexical-markdown/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
ITALIC_UNDERSCORE,
LINK,
MULTILINE_ELEMENT_TRANSFORMERS,
nestHeadingInBlockquote,
normalizeMarkdown,
ORDERED_LIST,
QUOTE,
Expand Down Expand Up @@ -100,6 +101,7 @@ export {
LINK,
MULTILINE_ELEMENT_TRANSFORMERS,
type MultilineElementTransformer,
nestHeadingInBlockquote,
ORDERED_LIST,
QUOTE,
registerMarkdownShortcuts,
Expand Down
Loading