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 239ad43d876..6895c47deee 100644
--- a/packages/lexical-mdast/src/MdastShortcuts.ts
+++ b/packages/lexical-mdast/src/MdastShortcuts.ts
@@ -11,6 +11,10 @@ import type {CompiledMdast} from './types';
import type {ElementNode, LexicalEditor, LexicalNode, TextNode} from 'lexical';
import {$createCodeNode, $isCodeNode} from '@lexical/code-core';
+import {
+ $createHorizontalRuleNode,
+ HorizontalRuleNode,
+} from '@lexical/extension';
import {
$createListItemNode,
$createListNode,
@@ -25,6 +29,9 @@ import {
} from '@lexical/rich-text';
import {
$addUpdateTag,
+ $createParagraphNode,
+ $findMatchingParent,
+ $getEditor,
$getNodeByKey,
$getSelection,
$isBlockContainer,
@@ -43,15 +50,24 @@ 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. '`,
* `'- [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;
@@ -84,7 +100,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 +108,49 @@ 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 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.
+ *
+ * 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,
+ markerLength: number,
+): boolean {
+ const line = container.getTextContent();
+ if (
+ !$getEditor().hasNode(HorizontalRuleNode) ||
+ line.length !== markerLength ||
+ !container.getChildren().every($isTextNode) ||
+ $findMatchingParent(
+ container.getParentOrThrow(),
+ node => !$isQuoteNode(node),
+ )
+ ) {
+ 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 +158,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 +391,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.
@@ -439,15 +504,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' &&
@@ -491,7 +556,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..07810daeb01 100644
--- a/packages/lexical-mdast/src/__tests__/unit/MdastShortcuts.test.ts
+++ b/packages/lexical-mdast/src/__tests__/unit/MdastShortcuts.test.ts
@@ -10,12 +10,23 @@
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';
+import {
+ $createTableCellNode,
+ $createTableNode,
+ $createTableRowNode,
+ $isTableCellNode,
+ $isTableNode,
+ $isTableRowNode,
+} from '@lexical/table';
import {
$createParagraphNode,
$createTextNode,
@@ -26,28 +37,36 @@ 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';
import {
+ $convertToMarkdownString,
MdastCommonMarkExtension,
+ MdastExtension,
MdastGfmExtension,
+ MdastRichTextExtension,
MdastShortcutsExtension,
} 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]',
}),
@@ -222,6 +241,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 +377,254 @@ 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('---');
+ });
+ });
+
+ /** 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();
+ });
+ });
+
+ 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', () => {
it('Enter does not convert a line with content after the marker', () => {
using editor = createEditor();