Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions .changeset/mdast-thematic-break-shortcut.md
Original file line number Diff line number Diff line change
@@ -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.
87 changes: 76 additions & 11 deletions packages/lexical-mdast/src/MdastShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -25,6 +29,9 @@ import {
} from '@lexical/rich-text';
import {
$addUpdateTag,
$createParagraphNode,
$findMatchingParent,
$getEditor,
$getNodeByKey,
$getSelection,
$isBlockContainer,
Expand All @@ -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;
Expand Down Expand Up @@ -84,21 +100,67 @@ 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 {
container.replace(block);
}
}

/**
* 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) ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional, non-blocking: this is the one guard in $applyThematicBreak that no test pins; removing it leaves all 49 tests in MdastShortcuts.test.ts passing. It keeps --- followed by an inline node with no text (an inline decorator) from converting and dropping that node along with the paragraph, so a Space case and an Enter case with such a node would keep it covered.

$findMatchingParent(
container.getParentOrThrow(),
node => !$isQuoteNode(node),
)
) {
return false;
}
$stripLeading(container, markerLength);
const rule = $createHorizontalRuleNode();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional, non-blocking: scanBlock offers this match whenever some import rule handles thematicBreak, so an editor that maps it to its own node without HorizontalRuleExtension gets Node HorizontalRuleNode has not been registered in onError when --- is typed. Adding !$getEditor().hasNode(HorizontalRuleNode) to the checks above would let the shortcut decline quietly. The heading shortcut behaves the same way today, so this can wait.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in f785646. $applyThematicBreak now returns early when !$getEditor().hasNode(HorizontalRuleNode), so the line stays text.

I reproduced it first: an editor with MdastRichTextExtension, MdastExtension configured to import thematicBreak as a paragraph, and MdastShortcutsExtension, but no HorizontalRuleExtension, reported Node HorizontalRuleNode has not been registered. Ensure node has been passed to createEditor. to onError when --- was typed. A new test does this with Space and with Enter and checks that onError gets nothing; both cases fail without the check. I left the heading shortcut as it is.

// 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:
* a ParagraphNode, or an inline-content QuoteNode that adopts the new block
* 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'
Expand Down Expand Up @@ -329,6 +391,9 @@ function $isShortcutContainer(
* matching what import produces for `> ## H2`.
* - A marker-only line (`` ```lang ``, `## `, `- `) converts on
* <kbd>Enter</kbd>.
* - A thematic break (`---`, `***`, `___`) alone on its line converts to a
* horizontal rule on space or <kbd>Enter</kbd>, 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.
Expand Down Expand Up @@ -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' &&
Expand Down Expand Up @@ -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.
Expand Down
15 changes: 13 additions & 2 deletions packages/lexical-mdast/src/MdastStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down
Loading
Loading