Repository navigation
[lexical-mdast] Feature: Add a horizontal rule shortcut for ---, *** and ___ #9484
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
edc9232
334581b
46a4033
5c6917a
f785646
bc560e0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,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) || | ||
| $findMatchingParent( | ||
| container.getParentOrThrow(), | ||
| node => !$isQuoteNode(node), | ||
| ) | ||
| ) { | ||
| return false; | ||
| } | ||
| $stripLeading(container, markerLength); | ||
| const rule = $createHorizontalRuleNode(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Optional, non-blocking:
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in f785646. I reproduced it first: an editor with |
||
| // 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' | ||
|
|
@@ -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. | ||
|
|
@@ -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. | ||
|
|
||
There was a problem hiding this comment.
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
$applyThematicBreakthat no test pins; removing it leaves all 49 tests inMdastShortcuts.test.tspassing. 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.