From edc92320c5cf48d2e60fa432c62d67e226e53bfb Mon Sep 17 00:00:00 2001 From: conghuydever <346811832+conghuydever@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:30:25 +0700 Subject: [PATCH 1/3] [lexical-mdast] Feature: Add a horizontal rule shortcut for ---, *** and ___ MdastShortcutsExtension had typing shortcuts for headings, quotes, lists and code blocks, but none for a horizontal rule. A line of ---, *** or ___ stayed text and was exported escaped, as \---. Now ---, *** or ___ alone on a line, followed by Space or Enter, becomes a HorizontalRuleNode, with the caret in a new paragraph under it. The rule keeps the typed marker, so export writes the same one. Like the other block shortcuts, it needs MdastHorizontalRuleExtension in the editor. At the start of a quote the rule goes into the quote. With text after the marker, or in a table cell, the line stays text. Co-Authored-By: Claude Opus 5.5 --- packages/lexical-mdast/src/MdastShortcuts.ts | 48 ++++++- packages/lexical-mdast/src/MdastStream.ts | 15 +- .../src/__tests__/unit/MdastShortcuts.test.ts | 133 ++++++++++++++++++ 3 files changed, 191 insertions(+), 5 deletions(-) diff --git a/packages/lexical-mdast/src/MdastShortcuts.ts b/packages/lexical-mdast/src/MdastShortcuts.ts index e0d963afbb3..438ec9be32a 100644 --- a/packages/lexical-mdast/src/MdastShortcuts.ts +++ b/packages/lexical-mdast/src/MdastShortcuts.ts @@ -11,6 +11,7 @@ import type {CompiledMdast} from './types'; import type {ElementNode, LexicalEditor, LexicalNode, TextNode} from 'lexical'; import {$createCodeNode, $isCodeNode} from '@lexical/code-core'; +import {$createHorizontalRuleNode} from '@lexical/extension'; import { $createListItemNode, $createListNode, @@ -23,8 +24,11 @@ import { $isQuoteNode, type QuoteNode, } from '@lexical/rich-text'; +import {$isTableCellNode} from '@lexical/table'; import { $addUpdateTag, + $createParagraphNode, + $findMatchingParent, $getNodeByKey, $getSelection, $isParagraphNode, @@ -43,7 +47,7 @@ import { import {$append, $listTypeFromMdast} from './handlers'; import {MarkdownStreamScanner} from './MdastStream'; -import {codeFenceState, codeMetaState} from './state'; +import {codeFenceState, codeMetaState, hrMarkerState} from './state'; /** * Block markers are at most a few characters (`'###### '`, `' 999. '`, @@ -84,7 +88,7 @@ function $stripLeading(element: ElementNode, n: number): void { * it legal, which is not the tree {@link MdastShadowRootQuoteExtension} * imports for `> ## H2`. */ -function $placeBlock(container: ElementNode, block: ElementNode): void { +function $placeBlock(container: ElementNode, block: LexicalNode): void { if ($isQuoteNode(container)) { container.setIsShadowRoot(true).append(block); } else { @@ -92,6 +96,38 @@ function $placeBlock(container: ElementNode, block: ElementNode): void { } } +/** + * Replaces `container` with a horizontal rule, and puts the caret in a new + * empty paragraph under it: the new line that the typed space or Enter asks + * for. The marker must be the whole line. `--- text` is a paragraph in + * Markdown, so a space typed between `---` and text converts nothing. + * + * A table cell has no Markdown for a rule (the GFM table export writes one + * after the table), so `---` typed in a cell stays text. + */ +function $applyThematicBreak( + container: ElementNode, + markerLength: number, +): boolean { + const line = container.getTextContent(); + if ( + line.length !== markerLength || + !container.getChildren().every($isTextNode) || + $findMatchingParent(container, $isTableCellNode) + ) { + return false; + } + $stripLeading(container, markerLength); + const rule = $createHorizontalRuleNode(); + // Keep the typed marker (`-`, `*` or `_`) so export writes the same rule. + $setState(rule, hrMarkerState, line.trimStart()[0]); + const next = $createParagraphNode(); + $placeBlock(container, rule); + rule.insertAfter(next); + next.select(); + return true; +} + /** * Replaces `paragraph` with the block construct described by `match`, keeping * the content that followed the marker. `paragraph` is the shortcut container: @@ -99,6 +135,9 @@ function $placeBlock(container: ElementNode, block: ElementNode): void { * rather than being replaced by it. */ function $applyBlock(paragraph: ElementNode, match: MdastBlockMatch): boolean { + if (match.kind === 'thematicBreak') { + return $applyThematicBreak(paragraph, match.markerLength); + } // Capture the fence before the marker is stripped from the paragraph. const fence = match.kind === 'code' @@ -329,6 +368,9 @@ function $isShortcutContainer( * matching what import produces for `> ## H2`. * - A marker-only line (`` ```lang ``, `## `, `- `) converts on * Enter. + * - A thematic break (`---`, `***`, `___`) alone on its line converts to a + * horizontal rule on space or Enter, with the caret in a new + * paragraph under it. * - Inline constructs (`*em*`, `**strong**`, `` `code` ``, `~~del~~`, * `[text](url)`, plus registered `inlineShortcutTypes`) convert when their * closing delimiter is typed. @@ -491,7 +533,7 @@ export function registerMarkdownShortcuts( return false; } const match = scanner.scanBlock(parent.getTextContent()); - // Only convert when the whole line is the marker (`## `, `- `, + // Only convert when the whole line is the marker (`## `, `- `, `---`, // '```lang title=x'). A line with content after the marker was either // already converted at the trailing space, or deliberately reverted // with undo — Enter must not re-convert it. diff --git a/packages/lexical-mdast/src/MdastStream.ts b/packages/lexical-mdast/src/MdastStream.ts index 5fcfd2f6d5f..6b3430c7c2b 100644 --- a/packages/lexical-mdast/src/MdastStream.ts +++ b/packages/lexical-mdast/src/MdastStream.ts @@ -8,7 +8,14 @@ import type {CompiledMdast, MdastNode} from './types'; import type {LexicalNode} from 'lexical'; -import type {Blockquote, Code, Heading, List, PhrasingContent} from 'mdast'; +import type { + Blockquote, + Code, + Heading, + List, + PhrasingContent, + ThematicBreak, +} from 'mdast'; import {fromMarkdown} from 'mdast-util-from-markdown'; @@ -18,7 +25,8 @@ export type MdastBlockMatch = | {kind: 'heading'; node: Heading; markerLength: number} | {kind: 'blockquote'; node: Blockquote; markerLength: number} | {kind: 'list'; node: List; markerLength: number} - | {kind: 'code'; node: Code; markerLength: number}; + | {kind: 'code'; node: Code; markerLength: number} + | {kind: 'thematicBreak'; node: ThematicBreak; markerLength: number}; /** * Returns the offset of the first content character inside a block construct, @@ -140,6 +148,9 @@ export class MarkdownStreamScanner { // A code construct recognized from a single line is just the opening // fence (possibly with a language); the entire line is the marker. return {kind: 'code', markerLength: line.length, node: first}; + case 'thematicBreak': + // A thematic break has no content: the entire line is the marker. + return {kind: 'thematicBreak', markerLength: line.length, node: first}; default: return null; } diff --git a/packages/lexical-mdast/src/__tests__/unit/MdastShortcuts.test.ts b/packages/lexical-mdast/src/__tests__/unit/MdastShortcuts.test.ts index 787ce267527..bfbb42b9b5b 100644 --- a/packages/lexical-mdast/src/__tests__/unit/MdastShortcuts.test.ts +++ b/packages/lexical-mdast/src/__tests__/unit/MdastShortcuts.test.ts @@ -10,12 +10,21 @@ import {$isCodeNode} from '@lexical/code-core'; import { + $isHorizontalRuleNode, buildEditorFromExtensions, type LexicalEditorWithDispose, } from '@lexical/extension'; import {$isLinkNode} from '@lexical/link'; import {$isListItemNode, $isListNode} from '@lexical/list'; import {$isHeadingNode, $isQuoteNode} from '@lexical/rich-text'; +import { + $createTableCellNode, + $createTableNode, + $createTableRowNode, + $isTableCellNode, + $isTableNode, + $isTableRowNode, +} from '@lexical/table'; import { $createParagraphNode, $createTextNode, @@ -35,6 +44,7 @@ import {$assertNodeType} from 'lexical/src/__tests__/utils'; import {describe, expect, it} from 'vitest'; import { + $convertToMarkdownString, MdastCommonMarkExtension, MdastGfmExtension, MdastShortcutsExtension, @@ -222,6 +232,21 @@ describe('@lexical/mdast streaming shortcuts', () => { }); }); + it('--- -> horizontal rule nested in the quote', () => { + using editor = createEditor(); + type(editor, ['>', ' ', '-', '-', '-', ' ', 'after']); + editor.read(() => { + const quote = $assertNodeType($getRoot().getFirstChild(), $isQuoteNode); + expect(quote.isShadowRoot()).toBe(true); + const [rule, next, ...rest] = quote.getChildren(); + $assertNodeType(rule, $isHorizontalRuleNode); + const paragraph = $assertNodeType(next, $isParagraphNode); + expect(paragraph.getTextContent()).toBe('after'); + expect(rest).toEqual([]); + expect($convertToMarkdownString()).toBe('> ---\n>\n> after'); + }); + }); + it('leaves the quote alone once it holds a block', () => { using editor = createEditor(); type(editor, ['>', ' ', '#', ' ', 'A']); @@ -343,6 +368,114 @@ describe('@lexical/mdast streaming shortcuts', () => { }); }); + describe('horizontal rule (on space or Enter)', () => { + /** + * Asserts the root holds a rule and then the paragraph the caret moved + * to, and that the rule is exported with the marker it was typed with. + */ + function expectRuleThenLine(editor: LexicalEditor, marker: string): void { + editor.read(() => { + const [rule, next, ...rest] = $getRoot().getChildren(); + $assertNodeType(rule, $isHorizontalRuleNode); + const paragraph = $assertNodeType(next, $isParagraphNode); + expect(paragraph.getTextContent()).toBe('after'); + expect(rest).toEqual([]); + expect($convertToMarkdownString()).toBe(`${marker}\n\nafter`); + }); + } + + describe.each(['---', '***', '___'])('%s', marker => { + it('+ space -> horizontal rule, caret on a new line under it', () => { + using editor = createEditor(); + type(editor, [...marker, ' ', 'after']); + expectRuleThenLine(editor, marker); + }); + + it('+ Enter -> horizontal rule, caret on a new line under it', () => { + using editor = createEditor(); + type(editor, [...marker]); + editor.dispatchCommand(KEY_ENTER_COMMAND, null); + type(editor, ['after']); + expectRuleThenLine(editor, marker); + }); + }); + + it('-- + Enter stays text', () => { + using editor = createEditor(); + type(editor, ['-', '-']); + editor.dispatchCommand(KEY_ENTER_COMMAND, null); + editor.read(() => { + const paragraph = $assertNodeType( + $getRoot().getFirstChild(), + $isParagraphNode, + ); + expect(paragraph.getTextContent()).toBe('--'); + expect($getRoot().getChildren().some($isHorizontalRuleNode)).toBe( + false, + ); + }); + }); + + it('a space typed between --- and text converts nothing', () => { + using editor = createEditor(); + // `--- after` is a paragraph in Markdown, not a rule followed by text, + // even though the text up to the caret (`--- `) is a rule. + editor.update( + () => { + const paragraph = $createParagraphNode(); + const text = $createTextNode('---after'); + paragraph.append(text); + $getRoot().clear().append(paragraph); + text.select(3, 3); + }, + {discrete: true}, + ); + type(editor, [' ']); + $expectFirstChild(editor, $isParagraphNode); + editor.read(() => { + expect($getRoot().getTextContent()).toBe('--- after'); + }); + }); + + it.each([ + ['space', ' '], + ['Enter', null], + ])('--- + %s in a table cell stays text', (_key, key) => { + using editor = createEditor(); + // A cell has no Markdown for a rule: the table export would write it + // after the table. + editor.update( + () => { + const paragraph = $createParagraphNode(); + $getRoot() + .clear() + .append( + $createTableNode().append( + $createTableRowNode().append( + $createTableCellNode().append(paragraph), + ), + ), + ); + paragraph.select(); + }, + {discrete: true}, + ); + type(editor, ['-', '-', '-']); + if (key === null) { + editor.dispatchCommand(KEY_ENTER_COMMAND, null); + } else { + type(editor, [key]); + } + editor.read(() => { + const table = $assertNodeType($getRoot().getFirstChild(), $isTableNode); + const row = $assertNodeType(table.getFirstChild(), $isTableRowNode); + const cell = $assertNodeType(row.getFirstChild(), $isTableCellNode); + expect(cell.getChildren().some($isHorizontalRuleNode)).toBe(false); + expect(cell.getTextContent()).toContain('---'); + }); + }); + }); + describe('does not fire destructively', () => { it('Enter does not convert a line with content after the marker', () => { using editor = createEditor(); From 5c6917a22ccde7f79873494c7d25eef7dabda627 Mon Sep 17 00:00:00 2001 From: conghuydever <346811832+conghuydever@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:36:47 +0700 Subject: [PATCH 2/3] [lexical-mdast] Feature: Keep @lexical/table out of the horizontal rule shortcut Convert a typed rule only at the top level or inside a quote, the places Markdown can write one, instead of checking for a table cell. This drops the @lexical/table import, so editors without tables no longer ship it. Let a space convert a rule longer than 24 characters, as Enter already does. Add tests for undo, a code block, inline code and Shift+Enter, and a changeset. Co-Authored-By: Claude Opus 5.5 --- .changeset/mdast-thematic-break-shortcut.md | 7 ++ packages/lexical-mdast/src/MdastShortcuts.ts | 38 +++++-- .../src/__tests__/unit/MdastShortcuts.test.ts | 103 +++++++++++++++++- 3 files changed, 135 insertions(+), 13 deletions(-) create mode 100644 .changeset/mdast-thematic-break-shortcut.md diff --git a/.changeset/mdast-thematic-break-shortcut.md b/.changeset/mdast-thematic-break-shortcut.md new file mode 100644 index 00000000000..a272c1fd948 --- /dev/null +++ b/.changeset/mdast-thematic-break-shortcut.md @@ -0,0 +1,7 @@ +--- +'@lexical/mdast': patch +--- + +[feature] Type `---`, `***` or `___` on its own line, then Space or Enter, to insert a horizontal rule with `MdastShortcutsExtension` + +Verify: with `MdastHorizontalRuleExtension` (part of `MdastCommonMarkExtension`), a line that is only `---`, `***` or `___` becomes a horizontal rule on Space or Enter, with the caret on a new line below; a line with text after the marker, or in a table cell, stays text. diff --git a/packages/lexical-mdast/src/MdastShortcuts.ts b/packages/lexical-mdast/src/MdastShortcuts.ts index 438ec9be32a..5be69dafed6 100644 --- a/packages/lexical-mdast/src/MdastShortcuts.ts +++ b/packages/lexical-mdast/src/MdastShortcuts.ts @@ -24,7 +24,6 @@ import { $isQuoteNode, type QuoteNode, } from '@lexical/rich-text'; -import {$isTableCellNode} from '@lexical/table'; import { $addUpdateTag, $createParagraphNode, @@ -52,10 +51,19 @@ import {codeFenceState, codeMetaState, hrMarkerState} from './state'; /** * Block markers are at most a few characters (`'###### '`, `' 999. '`, * `'- [x] '`); when the caret is past this column a space cannot complete a - * block marker, so the micromark scan is skipped entirely. + * block marker, so the micromark scan is skipped entirely. A thematic break + * is the one exception, see {@link THEMATIC_BREAK_LINE}. */ const MAX_BLOCK_MARKER_LENGTH = 24; +/** + * A line that is only a thematic break: three or more of the same `-`, `*` + * or `_`, with spaces or tabs between them allowed (CommonMark 4.1). A rule + * has no length limit, so a space typed after a long one is still scanned + * past {@link MAX_BLOCK_MARKER_LENGTH}, as Enter already does. + */ +const THEMATIC_BREAK_LINE = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/; + /** Removes the first `n` characters from the leading text nodes of `element`. */ function $stripLeading(element: ElementNode, n: number): void { let remaining = n; @@ -102,8 +110,11 @@ function $placeBlock(container: ElementNode, block: LexicalNode): void { * for. The marker must be the whole line. `--- text` is a paragraph in * Markdown, so a space typed between `---` and text converts nothing. * - * A table cell has no Markdown for a rule (the GFM table export writes one - * after the table), so `---` typed in a cell stays text. + * A rule converts only where Markdown can write one: at the top level or + * inside a quote. A table cell, for example, has no Markdown for a rule (the + * GFM table export writes one after the table), so `---` typed in a cell + * stays text. Checking the ancestors this way, rather than for a table cell, + * keeps `@lexical/table` out of editors that don't use tables. */ function $applyThematicBreak( container: ElementNode, @@ -113,7 +124,10 @@ function $applyThematicBreak( if ( line.length !== markerLength || !container.getChildren().every($isTextNode) || - $findMatchingParent(container, $isTableCellNode) + $findMatchingParent( + container.getParentOrThrow(), + node => !$isQuoteNode(node), + ) ) { return false; } @@ -481,15 +495,15 @@ export function registerMarkdownShortcuts( $tryCheckbox(node, parent, anchorOffset) ) { transformed = true; - } else if ( - anchorOffset <= MAX_BLOCK_MARKER_LENGTH && - $isShortcutContainer(parent, node) - ) { + } else if ($isShortcutContainer(parent, node)) { // The marker must end exactly at the caret, so scanning the // prefix is sufficient (and cheaper than the whole line). - const match = scanner.scanBlock( - node.getTextContent().slice(0, anchorOffset), - ); + const prefix = node.getTextContent().slice(0, anchorOffset); + const match = + anchorOffset <= MAX_BLOCK_MARKER_LENGTH || + THEMATIC_BREAK_LINE.test(prefix) + ? scanner.scanBlock(prefix) + : null; if ( match && match.kind !== 'code' && diff --git a/packages/lexical-mdast/src/__tests__/unit/MdastShortcuts.test.ts b/packages/lexical-mdast/src/__tests__/unit/MdastShortcuts.test.ts index bfbb42b9b5b..bd2b1159180 100644 --- a/packages/lexical-mdast/src/__tests__/unit/MdastShortcuts.test.ts +++ b/packages/lexical-mdast/src/__tests__/unit/MdastShortcuts.test.ts @@ -11,9 +11,11 @@ import {$isCodeNode} from '@lexical/code-core'; import { $isHorizontalRuleNode, + type AnyLexicalExtensionArgument, buildEditorFromExtensions, type LexicalEditorWithDispose, } from '@lexical/extension'; +import {HistoryExtension} from '@lexical/history'; import {$isLinkNode} from '@lexical/link'; import {$isListItemNode, $isListNode} from '@lexical/list'; import {$isHeadingNode, $isQuoteNode} from '@lexical/rich-text'; @@ -35,10 +37,12 @@ import { $isParagraphNode, $isRangeSelection, $isTextNode, + configExtension, defineExtension, KEY_ENTER_COMMAND, type LexicalEditor, type LexicalNode, + UNDO_COMMAND, } from 'lexical'; import {$assertNodeType} from 'lexical/src/__tests__/utils'; import {describe, expect, it} from 'vitest'; @@ -51,13 +55,16 @@ import { } from '../../index'; import {codeFenceState, codeMetaState} from '../../state'; -function createEditor(): LexicalEditorWithDispose { +function createEditor( + ...extensions: AnyLexicalExtensionArgument[] +): LexicalEditorWithDispose { const editor = buildEditorFromExtensions( defineExtension({ dependencies: [ MdastCommonMarkExtension, MdastGfmExtension, MdastShortcutsExtension, + ...extensions, ], name: '[root]', }), @@ -474,6 +481,100 @@ describe('@lexical/mdast streaming shortcuts', () => { expect(cell.getTextContent()).toContain('---'); }); }); + + /** Presses space (`' '`) or Enter (`null`). */ + function press(editor: LexicalEditor, key: ' ' | null): void { + if (key === null) { + editor.dispatchCommand(KEY_ENTER_COMMAND, null); + } else { + type(editor, [key]); + } + } + + function $expectNoRule(): void { + expect($getRoot().getChildren().some($isHorizontalRuleNode)).toBe(false); + } + + it.each([ + ['space', ' '], + ['Enter', null], + ] as const)('a rule longer than 24 characters converts on %s', (_, key) => { + using editor = createEditor(); + type(editor, [...'-'.repeat(30)]); + press(editor, key); + type(editor, ['after']); + expectRuleThenLine(editor, '---'); + }); + + it.each([ + ['space', ' ', '--- '], + ['Enter', null, '---'], + ] as const)('undo after %s gives back the typed marker', (_, key, text) => { + using editor = createEditor( + // History is off by default where there is no window. + configExtension(HistoryExtension, {disabled: false}), + ); + type(editor, ['-', '-', '-']); + press(editor, key); + $expectFirstChild(editor, $isHorizontalRuleNode); + editor.dispatchCommand(UNDO_COMMAND, undefined); + editor.read(() => { + const [paragraph, ...rest] = $getRoot().getChildren(); + expect( + $assertNodeType(paragraph, $isParagraphNode).getTextContent(), + ).toBe(text); + expect(rest).toEqual([]); + }); + }); + + it.each([ + ['space', ' '], + ['Enter', null], + ] as const)('--- + %s in a code block stays code', (_, key) => { + using editor = createEditor(); + type(editor, ['```']); + editor.dispatchCommand(KEY_ENTER_COMMAND, null); + type(editor, ['-', '-', '-']); + press(editor, key); + editor.read(() => { + const code = $assertNodeType($getRoot().getFirstChild(), $isCodeNode); + expect(code.getTextContent()).toMatch(/^---/); + $expectNoRule(); + }); + }); + + it.each([ + ['space', ' '], + ['Enter', null], + ] as const)('--- + %s in inline code stays text', (_, key) => { + using editor = createEditor(); + editor.update( + () => { + const text = $createTextNode('---').toggleFormat('code'); + $getRoot().clear().append($createParagraphNode().append(text)); + text.selectEnd(); + }, + {discrete: true}, + ); + press(editor, key); + editor.read(() => { + expect($getRoot().getTextContent()).toMatch(/^---/); + $expectNoRule(); + }); + }); + + it('--- + Shift+Enter stays text', () => { + using editor = createEditor(); + type(editor, ['-', '-', '-']); + editor.dispatchCommand(KEY_ENTER_COMMAND, { + preventDefault() {}, + shiftKey: true, + } as KeyboardEvent); + editor.read(() => { + expect($getRoot().getTextContent()).toMatch(/^---/); + $expectNoRule(); + }); + }); }); describe('does not fire destructively', () => { From f785646319672ecf139fe4e7cc079c6c4be97899 Mon Sep 17 00:00:00 2001 From: conghuydever <346811832+conghuydever@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:50:51 +0700 Subject: [PATCH 3/3] [lexical-mdast] Feature: Leave a typed rule as text when the editor has no HorizontalRuleNode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description An editor can import thematicBreak as its own node, without HorizontalRuleExtension. Typing --- and then Space or Enter there reported "Node HorizontalRuleNode has not been registered" to onError, while on main the line stays text. The horizontal rule shortcut now checks that the editor has HorizontalRuleNode first, and leaves the line as text when it doesn't. ## Test plan ### Before $ pnpm exec vitest run packages/lexical-mdast/src/__tests__/unit/MdastShortcuts.test.ts × --- + space stays text when the editor has no HorizontalRuleNode × --- + Enter stays text when the editor has no HorizontalRuleNode Tests 2 failed | 47 passed (49) ### After $ pnpm exec vitest run packages/lexical-mdast/src/__tests__/unit/MdastShortcuts.test.ts Tests 49 passed (49) Co-Authored-By: Claude Opus 5.5 --- packages/lexical-mdast/src/MdastShortcuts.ts | 11 ++++- .../src/__tests__/unit/MdastShortcuts.test.ts | 48 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/lexical-mdast/src/MdastShortcuts.ts b/packages/lexical-mdast/src/MdastShortcuts.ts index 5be69dafed6..11768cccbfd 100644 --- a/packages/lexical-mdast/src/MdastShortcuts.ts +++ b/packages/lexical-mdast/src/MdastShortcuts.ts @@ -11,7 +11,10 @@ import type {CompiledMdast} from './types'; import type {ElementNode, LexicalEditor, LexicalNode, TextNode} from 'lexical'; import {$createCodeNode, $isCodeNode} from '@lexical/code-core'; -import {$createHorizontalRuleNode} from '@lexical/extension'; +import { + $createHorizontalRuleNode, + HorizontalRuleNode, +} from '@lexical/extension'; import { $createListItemNode, $createListNode, @@ -28,6 +31,7 @@ import { $addUpdateTag, $createParagraphNode, $findMatchingParent, + $getEditor, $getNodeByKey, $getSelection, $isParagraphNode, @@ -115,6 +119,10 @@ function $placeBlock(container: ElementNode, block: LexicalNode): void { * GFM table export writes one after the table), so `---` typed in a cell * stays text. Checking the ancestors this way, rather than for a table cell, * keeps `@lexical/table` out of editors that don't use tables. + * + * An editor can import `thematicBreak` as its own node, without + * `HorizontalRuleExtension`. There the line stays text too, rather than + * fail on a node the editor doesn't have. */ function $applyThematicBreak( container: ElementNode, @@ -122,6 +130,7 @@ function $applyThematicBreak( ): boolean { const line = container.getTextContent(); if ( + !$getEditor().hasNode(HorizontalRuleNode) || line.length !== markerLength || !container.getChildren().every($isTextNode) || $findMatchingParent( diff --git a/packages/lexical-mdast/src/__tests__/unit/MdastShortcuts.test.ts b/packages/lexical-mdast/src/__tests__/unit/MdastShortcuts.test.ts index bd2b1159180..07810daeb01 100644 --- a/packages/lexical-mdast/src/__tests__/unit/MdastShortcuts.test.ts +++ b/packages/lexical-mdast/src/__tests__/unit/MdastShortcuts.test.ts @@ -50,7 +50,9 @@ import {describe, expect, it} from 'vitest'; import { $convertToMarkdownString, MdastCommonMarkExtension, + MdastExtension, MdastGfmExtension, + MdastRichTextExtension, MdastShortcutsExtension, } from '../../index'; import {codeFenceState, codeMetaState} from '../../state'; @@ -575,6 +577,52 @@ describe('@lexical/mdast streaming shortcuts', () => { $expectNoRule(); }); }); + + it.each([ + ['space', ' '], + ['Enter', null], + ] as const)( + '--- + %s stays text when the editor has no HorizontalRuleNode', + (_, key) => { + const errors: Error[] = []; + // An editor that imports `thematicBreak` as its own node, without + // HorizontalRuleExtension. + using editor = buildEditorFromExtensions( + defineExtension({ + dependencies: [ + MdastRichTextExtension, + configExtension(MdastExtension, { + importRules: [ + { + $import: () => $createParagraphNode(), + type: 'thematicBreak', + }, + ], + }), + MdastShortcutsExtension, + ], + name: '[no horizontal rule]', + onError: error => { + errors.push(error); + }, + }), + ); + editor.update( + () => { + const paragraph = $createParagraphNode(); + $getRoot().clear().append(paragraph); + paragraph.select(); + }, + {discrete: true}, + ); + type(editor, ['-', '-', '-']); + press(editor, key); + expect(errors).toEqual([]); + editor.read(() => { + expect($getRoot().getTextContent()).toMatch(/^---/); + }); + }, + ); }); describe('does not fire destructively', () => {