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();