@lexical/mdast 0.0.0-bootstrap.0 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +207 -2
- package/dist/LexicalMdast.dev.js +2620 -0
- package/dist/LexicalMdast.dev.mjs +2594 -0
- package/dist/LexicalMdast.js +11 -0
- package/dist/LexicalMdast.js.flow +228 -0
- package/dist/LexicalMdast.mjs +36 -0
- package/dist/LexicalMdast.node.mjs +34 -0
- package/dist/LexicalMdast.prod.js +11 -0
- package/dist/LexicalMdast.prod.mjs +11 -0
- package/dist/MdastExport.d.ts +20 -0
- package/dist/MdastExportExtension.d.ts +80 -0
- package/dist/MdastExtension.d.ts +21 -0
- package/dist/MdastGfmExtension.d.ts +20 -0
- package/dist/MdastImport.d.ts +48 -0
- package/dist/MdastImportExtension.d.ts +264 -0
- package/dist/MdastShortcuts.d.ts +27 -0
- package/dist/MdastStream.d.ts +70 -0
- package/dist/MdastTableExtension.d.ts +29 -0
- package/dist/compile.d.ts +18 -0
- package/dist/handlers.d.ts +84 -0
- package/dist/index.d.ts +15 -0
- package/dist/state.d.ts +58 -0
- package/dist/types.d.ts +165 -0
- package/dist/typescript-too-old.d.ts +18 -0
- package/package.json +90 -6
- package/src/MdastExport.ts +545 -0
- package/src/MdastExportExtension.ts +122 -0
- package/src/MdastExtension.ts +30 -0
- package/src/MdastGfmExtension.ts +38 -0
- package/src/MdastImport.ts +236 -0
- package/src/MdastImportExtension.ts +635 -0
- package/src/MdastShortcuts.ts +453 -0
- package/src/MdastStream.ts +187 -0
- package/src/MdastTableExtension.ts +144 -0
- package/src/compile.ts +44 -0
- package/src/handlers.ts +648 -0
- package/src/index.ts +69 -0
- package/src/state.ts +124 -0
- package/src/types.ts +197 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {MdastBlockMatch} from './MdastStream';
|
|
10
|
+
import type {CompiledMdast} from './types';
|
|
11
|
+
import type {ElementNode, LexicalEditor, LexicalNode, TextNode} from 'lexical';
|
|
12
|
+
|
|
13
|
+
import {$createCodeNode, $isCodeNode} from '@lexical/code-core';
|
|
14
|
+
import {
|
|
15
|
+
$createListItemNode,
|
|
16
|
+
$createListNode,
|
|
17
|
+
$isListItemNode,
|
|
18
|
+
$isListNode,
|
|
19
|
+
} from '@lexical/list';
|
|
20
|
+
import {$createHeadingNode, $createQuoteNode} from '@lexical/rich-text';
|
|
21
|
+
import {
|
|
22
|
+
$addUpdateTag,
|
|
23
|
+
$getNodeByKey,
|
|
24
|
+
$getSelection,
|
|
25
|
+
$isParagraphNode,
|
|
26
|
+
$isRangeSelection,
|
|
27
|
+
$isRootOrShadowRoot,
|
|
28
|
+
$isTextNode,
|
|
29
|
+
$setState,
|
|
30
|
+
COLLABORATION_TAG,
|
|
31
|
+
COMMAND_PRIORITY_BEFORE_EDITOR,
|
|
32
|
+
COMPOSITION_END_TAG,
|
|
33
|
+
HISTORIC_TAG,
|
|
34
|
+
HISTORY_PUSH_TAG,
|
|
35
|
+
KEY_ENTER_COMMAND,
|
|
36
|
+
mergeRegister,
|
|
37
|
+
} from 'lexical';
|
|
38
|
+
|
|
39
|
+
import {$append, $listTypeFromMdast} from './handlers';
|
|
40
|
+
import {MarkdownStreamScanner} from './MdastStream';
|
|
41
|
+
import {codeFenceState, codeMetaState} from './state';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Block markers are at most a few characters (`'###### '`, `' 999. '`,
|
|
45
|
+
* `'- [x] '`); when the caret is past this column a space cannot complete a
|
|
46
|
+
* block marker, so the micromark scan is skipped entirely.
|
|
47
|
+
*/
|
|
48
|
+
const MAX_BLOCK_MARKER_LENGTH = 24;
|
|
49
|
+
|
|
50
|
+
/** Removes the first `n` characters from the leading text nodes of `element`. */
|
|
51
|
+
function $stripLeading(element: ElementNode, n: number): void {
|
|
52
|
+
let remaining = n;
|
|
53
|
+
let child = element.getFirstChild();
|
|
54
|
+
while (remaining > 0 && child) {
|
|
55
|
+
if (!$isTextNode(child)) {
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
const text = child.getTextContent();
|
|
59
|
+
if (text.length <= remaining) {
|
|
60
|
+
const next = child.getNextSibling();
|
|
61
|
+
remaining -= text.length;
|
|
62
|
+
child.remove();
|
|
63
|
+
child = next;
|
|
64
|
+
} else {
|
|
65
|
+
child.setTextContent(text.slice(remaining));
|
|
66
|
+
remaining = 0;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Replaces `paragraph` with the block construct described by `match`, keeping
|
|
73
|
+
* the content that followed the marker.
|
|
74
|
+
*/
|
|
75
|
+
function $applyBlock(paragraph: ElementNode, match: MdastBlockMatch): boolean {
|
|
76
|
+
// Capture the fence before the marker is stripped from the paragraph.
|
|
77
|
+
const fence =
|
|
78
|
+
match.kind === 'code'
|
|
79
|
+
? paragraph.getTextContent().match(/^[ \t]*(`{3,}|~{3,})/)
|
|
80
|
+
: null;
|
|
81
|
+
$stripLeading(paragraph, match.markerLength);
|
|
82
|
+
const remaining = paragraph.getChildren();
|
|
83
|
+
|
|
84
|
+
if (match.kind === 'code') {
|
|
85
|
+
const code = $createCodeNode(match.node.lang || undefined);
|
|
86
|
+
// Keep the typed fence and info-string tail so export reproduces them.
|
|
87
|
+
if (fence) {
|
|
88
|
+
$setState(code, codeFenceState, fence[1]);
|
|
89
|
+
}
|
|
90
|
+
if (match.node.meta) {
|
|
91
|
+
$setState(code, codeMetaState, match.node.meta);
|
|
92
|
+
}
|
|
93
|
+
$append(code, remaining);
|
|
94
|
+
paragraph.replace(code);
|
|
95
|
+
code.selectStart();
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let target: ElementNode;
|
|
100
|
+
let selectInto: ElementNode;
|
|
101
|
+
if (match.kind === 'heading') {
|
|
102
|
+
const heading = $append(
|
|
103
|
+
$createHeadingNode(`h${match.node.depth}`),
|
|
104
|
+
remaining,
|
|
105
|
+
);
|
|
106
|
+
target = heading;
|
|
107
|
+
selectInto = heading;
|
|
108
|
+
} else if (match.kind === 'blockquote') {
|
|
109
|
+
const quote = $append($createQuoteNode(), remaining);
|
|
110
|
+
target = quote;
|
|
111
|
+
selectInto = quote;
|
|
112
|
+
} else {
|
|
113
|
+
const listNode = match.node;
|
|
114
|
+
const listType = $listTypeFromMdast(listNode);
|
|
115
|
+
const start =
|
|
116
|
+
listNode.ordered && listNode.start != null ? listNode.start : 1;
|
|
117
|
+
const list = $createListNode(listType, start);
|
|
118
|
+
const firstItem = listNode.children[0];
|
|
119
|
+
const checked =
|
|
120
|
+
firstItem &&
|
|
121
|
+
firstItem.type === 'listItem' &&
|
|
122
|
+
typeof firstItem.checked === 'boolean'
|
|
123
|
+
? firstItem.checked
|
|
124
|
+
: undefined;
|
|
125
|
+
const item = $append($createListItemNode(checked), remaining);
|
|
126
|
+
$append(list, [item]);
|
|
127
|
+
target = list;
|
|
128
|
+
selectInto = item;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
paragraph.replace(target);
|
|
132
|
+
selectInto.selectStart();
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Cheap check that `text` (up to the caret) could plausibly contain a closed
|
|
138
|
+
* inline construct ending in `closeChar`, before paying for a micromark parse.
|
|
139
|
+
* A closing `)` needs a `](` link infix; any other delimiter needs an earlier
|
|
140
|
+
* occurrence of itself to act as the opener.
|
|
141
|
+
*/
|
|
142
|
+
function mayCloseInlineConstruct(text: string, closeChar: string): boolean {
|
|
143
|
+
return closeChar === ')'
|
|
144
|
+
? text.lastIndexOf('](') > 0
|
|
145
|
+
: text.lastIndexOf(closeChar, text.length - 2) !== -1;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Materializes the inline construct ending at `anchorOffset` inside
|
|
150
|
+
* `anchorNode`, replacing the raw markdown span with formatted Lexical nodes.
|
|
151
|
+
*/
|
|
152
|
+
function $applyInline(
|
|
153
|
+
anchorNode: TextNode,
|
|
154
|
+
anchorOffset: number,
|
|
155
|
+
scanner: MarkdownStreamScanner,
|
|
156
|
+
): boolean {
|
|
157
|
+
const upTo = anchorNode.getTextContent().slice(0, anchorOffset);
|
|
158
|
+
const inlineNode = scanner.scanInline(upTo);
|
|
159
|
+
if (!inlineNode || !inlineNode.position) {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
const start = inlineNode.position.start.offset ?? 0;
|
|
163
|
+
const end = anchorOffset;
|
|
164
|
+
|
|
165
|
+
let target: TextNode;
|
|
166
|
+
if (start <= 0) {
|
|
167
|
+
[target] = anchorNode.splitText(end);
|
|
168
|
+
} else {
|
|
169
|
+
const parts = anchorNode.splitText(start, end);
|
|
170
|
+
target = parts.length === 3 ? parts[1] : parts[parts.length - 1];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const lexicalNodes = scanner.importInline(inlineNode);
|
|
174
|
+
if (lexicalNodes.length === 0) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let prev: LexicalNode = lexicalNodes[0];
|
|
179
|
+
target.replace(prev);
|
|
180
|
+
for (let i = 1; i < lexicalNodes.length; i++) {
|
|
181
|
+
prev.insertAfter(lexicalNodes[i]);
|
|
182
|
+
prev = lexicalNodes[i];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if ($isTextNode(prev)) {
|
|
186
|
+
const size = prev.getTextContentSize();
|
|
187
|
+
prev.select(size, size);
|
|
188
|
+
} else {
|
|
189
|
+
prev.selectNext(0, 0);
|
|
190
|
+
}
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Matches a GFM task-list checkbox marker (`[ ] `, `[x] `) typed at line
|
|
196
|
+
* start. Exactly one character is required between the brackets, matching
|
|
197
|
+
* micromark's gfm-task-list-item grammar — `[]` is not a checkbox.
|
|
198
|
+
*/
|
|
199
|
+
const CHECKBOX_REGEX = /^\[([ xX])\]\s$/;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Handles a checkbox marker typed at the start of a line. micromark only
|
|
203
|
+
* recognizes a task-list item once it already has a list-item prefix, so by
|
|
204
|
+
* the time the user types `[ ] ` the paragraph is usually already a bullet
|
|
205
|
+
* list item (from the earlier `- ` shortcut). This promotes that item — or a
|
|
206
|
+
* bare paragraph — to a Lexical check list.
|
|
207
|
+
*/
|
|
208
|
+
function $tryCheckbox(
|
|
209
|
+
anchorNode: TextNode,
|
|
210
|
+
parent: ElementNode,
|
|
211
|
+
anchorOffset: number,
|
|
212
|
+
): boolean {
|
|
213
|
+
if (parent.getFirstChild() !== anchorNode) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
const prefix = parent.getTextContent().slice(0, anchorOffset);
|
|
217
|
+
const match = prefix.match(CHECKBOX_REGEX);
|
|
218
|
+
if (!match) {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
const checked = match[1].toLowerCase() === 'x';
|
|
222
|
+
|
|
223
|
+
if ($isListItemNode(parent)) {
|
|
224
|
+
const list = parent.getParent();
|
|
225
|
+
if (!$isListNode(list) || list.getListType() === 'number') {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
$stripLeading(parent, match[0].length);
|
|
229
|
+
list.setListType('check');
|
|
230
|
+
parent.setChecked(checked);
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if ($isShortcutParagraph(parent, anchorNode)) {
|
|
235
|
+
$stripLeading(parent, match[0].length);
|
|
236
|
+
const remaining = parent.getChildren();
|
|
237
|
+
const list = $createListNode('check');
|
|
238
|
+
const item = $append($createListItemNode(checked), remaining);
|
|
239
|
+
$append(list, [item]);
|
|
240
|
+
parent.replace(list);
|
|
241
|
+
item.selectStart();
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function $isShortcutParagraph(
|
|
249
|
+
node: LexicalNode,
|
|
250
|
+
anchorNode: TextNode,
|
|
251
|
+
): boolean {
|
|
252
|
+
return (
|
|
253
|
+
$isParagraphNode(node) &&
|
|
254
|
+
$isRootOrShadowRoot(node.getParent()) &&
|
|
255
|
+
node.getFirstChild() === anchorNode
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Registers streaming Markdown shortcuts on `editor` from the
|
|
261
|
+
* {@link CompiledMdast} registry. As the user types, the current line/inline
|
|
262
|
+
* buffer is fed back through micromark (the same parser as full-document
|
|
263
|
+
* import) and recognized constructs are transformed in place:
|
|
264
|
+
*
|
|
265
|
+
* - Block markers (`# `, `> `, `- `, `1. `, `- [ ] `) convert the paragraph
|
|
266
|
+
* into the matching Lexical block as soon as the trailing space is typed.
|
|
267
|
+
* - A marker-only line (`` ```lang ``, `## `, `- `) converts on
|
|
268
|
+
* <kbd>Enter</kbd>.
|
|
269
|
+
* - Inline constructs (`*em*`, `**strong**`, `` `code` ``, `~~del~~`,
|
|
270
|
+
* `[text](url)`, plus registered `inlineShortcutTypes`) convert when their
|
|
271
|
+
* closing delimiter is typed.
|
|
272
|
+
*
|
|
273
|
+
* Wired up by {@link MdastShortcutsExtension}; this is an internal helper, not
|
|
274
|
+
* part of the package's public API.
|
|
275
|
+
*/
|
|
276
|
+
export function registerMarkdownShortcuts(
|
|
277
|
+
editor: LexicalEditor,
|
|
278
|
+
compiled: CompiledMdast,
|
|
279
|
+
): () => void {
|
|
280
|
+
const scanner = new MarkdownStreamScanner(compiled);
|
|
281
|
+
const inlineTriggers = scanner.inlineTriggers;
|
|
282
|
+
// Composition end fires per IME commit (every CJK syllable, dead-key
|
|
283
|
+
// resolve, ...). Only enter the transformer pass when the just-committed
|
|
284
|
+
// character can plausibly close a trigger.
|
|
285
|
+
const compositionEndTriggers = new Set<string>([' ', ...inlineTriggers]);
|
|
286
|
+
|
|
287
|
+
return mergeRegister(
|
|
288
|
+
editor.registerUpdateListener(
|
|
289
|
+
({tags, dirtyLeaves, editorState, prevEditorState}) => {
|
|
290
|
+
// Ignore updates from collaboration and undo/redo (changes already
|
|
291
|
+
// calculated), and anything that dirtied no leaves (pure selection
|
|
292
|
+
// moves) before paying for any editor-state reads.
|
|
293
|
+
if (
|
|
294
|
+
dirtyLeaves.size === 0 ||
|
|
295
|
+
tags.has(COLLABORATION_TAG) ||
|
|
296
|
+
tags.has(HISTORIC_TAG)
|
|
297
|
+
) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
// If the editor is still composing we must wait for the commit.
|
|
301
|
+
if (editor.isComposing()) {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
// A composition commit lands without moving the selection (and may
|
|
305
|
+
// commit several characters at once), so it bypasses the typed-one-
|
|
306
|
+
// character heuristics below.
|
|
307
|
+
const isCompositionEnd = tags.has(COMPOSITION_END_TAG);
|
|
308
|
+
|
|
309
|
+
const selection = editorState.read($getSelection);
|
|
310
|
+
const prevSelection = prevEditorState.read($getSelection);
|
|
311
|
+
if (
|
|
312
|
+
!$isRangeSelection(selection) ||
|
|
313
|
+
!$isRangeSelection(prevSelection) ||
|
|
314
|
+
!selection.isCollapsed() ||
|
|
315
|
+
(selection.is(prevSelection) && !isCompositionEnd)
|
|
316
|
+
) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const anchorKey = selection.anchor.key;
|
|
320
|
+
const anchorOffset = selection.anchor.offset;
|
|
321
|
+
const anchorNode = editorState._nodeMap.get(anchorKey);
|
|
322
|
+
if (!$isTextNode(anchorNode) || !dirtyLeaves.has(anchorKey)) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
// Only react to a single typed character: the caret must have
|
|
326
|
+
// advanced exactly one position (or sit right after the first
|
|
327
|
+
// character of a fresh node). This keeps paste, drag-drop, and
|
|
328
|
+
// deletions — which can leave the caret after a delimiter — from
|
|
329
|
+
// firing destructive transforms.
|
|
330
|
+
if (
|
|
331
|
+
!isCompositionEnd &&
|
|
332
|
+
anchorOffset !== 1 &&
|
|
333
|
+
!(
|
|
334
|
+
prevSelection.anchor.key === anchorKey &&
|
|
335
|
+
anchorOffset === prevSelection.anchor.offset + 1
|
|
336
|
+
)
|
|
337
|
+
) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const textContent = editorState.read(() => anchorNode.getTextContent());
|
|
341
|
+
const typedChar = textContent[anchorOffset - 1];
|
|
342
|
+
if (isCompositionEnd && !compositionEndTriggers.has(typedChar)) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (typedChar !== ' ' && !inlineTriggers.has(typedChar)) {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
// Cheap prefilter: an inline construct needs an opener earlier in the
|
|
349
|
+
// text; skip the micromark parse when there is none.
|
|
350
|
+
if (
|
|
351
|
+
typedChar !== ' ' &&
|
|
352
|
+
!mayCloseInlineConstruct(
|
|
353
|
+
textContent.slice(0, anchorOffset),
|
|
354
|
+
typedChar,
|
|
355
|
+
)
|
|
356
|
+
) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
editor.update(() => {
|
|
361
|
+
const node = $getNodeByKey(anchorKey);
|
|
362
|
+
if (!$isTextNode(node) || node.hasFormat('code')) {
|
|
363
|
+
// Per CommonMark, code spans take precedence over any other
|
|
364
|
+
// inline construct; never transform inside one.
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const parent = node.getParent();
|
|
368
|
+
if (parent === null || $isCodeNode(parent)) {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
let transformed = false;
|
|
373
|
+
if (typedChar === ' ') {
|
|
374
|
+
if (
|
|
375
|
+
scanner.supportsTaskListItems &&
|
|
376
|
+
$tryCheckbox(node, parent, anchorOffset)
|
|
377
|
+
) {
|
|
378
|
+
transformed = true;
|
|
379
|
+
} else if (
|
|
380
|
+
anchorOffset <= MAX_BLOCK_MARKER_LENGTH &&
|
|
381
|
+
$isShortcutParagraph(parent, node)
|
|
382
|
+
) {
|
|
383
|
+
// The marker must end exactly at the caret, so scanning the
|
|
384
|
+
// prefix is sufficient (and cheaper than the whole line).
|
|
385
|
+
const match = scanner.scanBlock(
|
|
386
|
+
node.getTextContent().slice(0, anchorOffset),
|
|
387
|
+
);
|
|
388
|
+
if (
|
|
389
|
+
match &&
|
|
390
|
+
match.kind !== 'code' &&
|
|
391
|
+
match.markerLength === anchorOffset
|
|
392
|
+
) {
|
|
393
|
+
transformed = $applyBlock(parent, match);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
} else {
|
|
397
|
+
transformed = $applyInline(node, anchorOffset, scanner);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (transformed) {
|
|
401
|
+
$addUpdateTag(HISTORY_PUSH_TAG);
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
},
|
|
405
|
+
),
|
|
406
|
+
editor.registerCommand(
|
|
407
|
+
KEY_ENTER_COMMAND,
|
|
408
|
+
event => {
|
|
409
|
+
if (event !== null && event.shiftKey) {
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
const selection = $getSelection();
|
|
413
|
+
if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
const anchorNode = selection.anchor.getNode();
|
|
417
|
+
if (!$isTextNode(anchorNode) || anchorNode.hasFormat('code')) {
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
const parent = anchorNode.getParent();
|
|
421
|
+
const anchorOffset = selection.anchor.offset;
|
|
422
|
+
if (
|
|
423
|
+
parent === null ||
|
|
424
|
+
$isCodeNode(parent) ||
|
|
425
|
+
!$isShortcutParagraph(parent, anchorNode) ||
|
|
426
|
+
anchorOffset !== anchorNode.getTextContentSize()
|
|
427
|
+
) {
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
const match = scanner.scanBlock(parent.getTextContent());
|
|
431
|
+
// Only convert when the whole line is the marker (`## `, `- `,
|
|
432
|
+
// '```lang title=x'). A line with content after the marker was either
|
|
433
|
+
// already converted at the trailing space, or deliberately reverted
|
|
434
|
+
// with undo — Enter must not re-convert it.
|
|
435
|
+
if (
|
|
436
|
+
match &&
|
|
437
|
+
match.markerLength === anchorOffset &&
|
|
438
|
+
$applyBlock(parent, match)
|
|
439
|
+
) {
|
|
440
|
+
if (event !== null) {
|
|
441
|
+
event.preventDefault();
|
|
442
|
+
}
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
return false;
|
|
446
|
+
},
|
|
447
|
+
// The lowest priority that still pre-empts the default rich-text Enter
|
|
448
|
+
// handler: prepended to the editor-priority queue, so every listener at
|
|
449
|
+
// LOW and above (and none of the defaults) runs first.
|
|
450
|
+
COMMAND_PRIORITY_BEFORE_EDITOR,
|
|
451
|
+
),
|
|
452
|
+
);
|
|
453
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
3
|
+
*
|
|
4
|
+
* This source code is licensed under the MIT license found in the
|
|
5
|
+
* LICENSE file in the root directory of this source tree.
|
|
6
|
+
*
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {CompiledMdast, MdastNode} from './types';
|
|
10
|
+
import type {LexicalNode} from 'lexical';
|
|
11
|
+
import type {Blockquote, Code, Heading, List, PhrasingContent} from 'mdast';
|
|
12
|
+
|
|
13
|
+
import {fromMarkdown} from 'mdast-util-from-markdown';
|
|
14
|
+
|
|
15
|
+
import {createNodeImporter} from './MdastImport';
|
|
16
|
+
|
|
17
|
+
export type MdastBlockMatch =
|
|
18
|
+
| {kind: 'heading'; node: Heading; markerLength: number}
|
|
19
|
+
| {kind: 'blockquote'; node: Blockquote; markerLength: number}
|
|
20
|
+
| {kind: 'list'; node: List; markerLength: number}
|
|
21
|
+
| {kind: 'code'; node: Code; markerLength: number};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Returns the offset of the first content character inside a block construct,
|
|
25
|
+
* i.e. the length of the leading block marker (`"## "`, `"- "`, `"> "`, ...).
|
|
26
|
+
* When the construct has no content yet (the user has only typed the marker)
|
|
27
|
+
* the whole `line` is the marker.
|
|
28
|
+
*
|
|
29
|
+
* The walk descends through *container* levels only (blockquote -> paragraph,
|
|
30
|
+
* list -> listItem -> paragraph) and stops at the first inline child of a
|
|
31
|
+
* paragraph or heading — descending further would treat inline delimiters
|
|
32
|
+
* (`**`, `[`, `` ` ``) as part of the block marker.
|
|
33
|
+
*/
|
|
34
|
+
function contentStartOffset(node: MdastNode, line: string): number {
|
|
35
|
+
let current: MdastNode = node;
|
|
36
|
+
for (;;) {
|
|
37
|
+
if (current.type === 'heading' || current.type === 'paragraph') {
|
|
38
|
+
const first = current.children[0];
|
|
39
|
+
return first && first.position && first.position.start.offset != null
|
|
40
|
+
? first.position.start.offset
|
|
41
|
+
: line.length;
|
|
42
|
+
}
|
|
43
|
+
if (!('children' in current) || current.children.length === 0) {
|
|
44
|
+
return line.length;
|
|
45
|
+
}
|
|
46
|
+
current = current.children[0];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `MarkdownStreamScanner` is the streaming heart of the shortcut engine. Each
|
|
52
|
+
* keystroke feeds the growing line/inline buffer back through micromark (via
|
|
53
|
+
* `mdast-util-from-markdown`), so shortcut recognition uses the *exact* same
|
|
54
|
+
* grammar — and the same enabled extensions — as full-document import. There
|
|
55
|
+
* is no second, divergent set of regular expressions to keep in sync.
|
|
56
|
+
*
|
|
57
|
+
* It is constructed from the {@link CompiledMdast} registry assembled by
|
|
58
|
+
* {@link MdastImportExtension}, so it stays in lock-step with whatever feature
|
|
59
|
+
* extensions are enabled — including the inline construct types and trigger
|
|
60
|
+
* characters contributed via `inlineShortcutTypes` / `inlineShortcutTriggers`.
|
|
61
|
+
*/
|
|
62
|
+
export class MarkdownStreamScanner {
|
|
63
|
+
private readonly compiled: CompiledMdast;
|
|
64
|
+
private readonly importNode: (
|
|
65
|
+
node: MdastNode,
|
|
66
|
+
format: number,
|
|
67
|
+
) => LexicalNode[];
|
|
68
|
+
/**
|
|
69
|
+
* Whether the registry's grammar recognizes GFM task-list items (i.e.
|
|
70
|
+
* `MdastTaskListExtension` contributed `gfmTaskListItem`). Probed by
|
|
71
|
+
* parsing rather than configured, so it can never drift from the grammar.
|
|
72
|
+
*/
|
|
73
|
+
readonly supportsTaskListItems: boolean;
|
|
74
|
+
|
|
75
|
+
constructor(compiled: CompiledMdast) {
|
|
76
|
+
this.compiled = compiled;
|
|
77
|
+
this.importNode = createNodeImporter(compiled, '').$importNode;
|
|
78
|
+
const probe = this.parse('- [x] a').children[0];
|
|
79
|
+
this.supportsTaskListItems =
|
|
80
|
+
probe != null &&
|
|
81
|
+
probe.type === 'list' &&
|
|
82
|
+
probe.children[0].checked === true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Characters that can close an inline construct for this registry. */
|
|
86
|
+
get inlineTriggers(): ReadonlySet<string> {
|
|
87
|
+
return this.compiled.inlineShortcutTriggers;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private parse(value: string) {
|
|
91
|
+
return fromMarkdown(value, {
|
|
92
|
+
extensions: this.compiled.micromarkExtensions,
|
|
93
|
+
mdastExtensions: this.compiled.mdastExtensions,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Materializes an mdast inline node into Lexical nodes using the same
|
|
99
|
+
* import handlers as the full-document importer.
|
|
100
|
+
*/
|
|
101
|
+
importInline(node: MdastNode): LexicalNode[] {
|
|
102
|
+
return this.importNode(node, 0);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Recognizes a block-level construct at the start of `line`. Returns the
|
|
107
|
+
* matched construct together with the marker length, or `null`.
|
|
108
|
+
*/
|
|
109
|
+
scanBlock(line: string): MdastBlockMatch | null {
|
|
110
|
+
if (line.trim() === '') {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
const first = this.parse(line).children[0];
|
|
114
|
+
// Only offer shortcuts for constructs the registry can actually import:
|
|
115
|
+
// in a granular setup (e.g. no blockquote extension) the syntax should
|
|
116
|
+
// stay literal rather than materialize an unregistered node.
|
|
117
|
+
if (!first || !this.compiled.importHandlers.has(first.type)) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
switch (first.type) {
|
|
121
|
+
case 'heading':
|
|
122
|
+
return {
|
|
123
|
+
kind: 'heading',
|
|
124
|
+
markerLength: contentStartOffset(first, line),
|
|
125
|
+
node: first,
|
|
126
|
+
};
|
|
127
|
+
case 'blockquote':
|
|
128
|
+
return {
|
|
129
|
+
kind: 'blockquote',
|
|
130
|
+
markerLength: contentStartOffset(first, line),
|
|
131
|
+
node: first,
|
|
132
|
+
};
|
|
133
|
+
case 'list':
|
|
134
|
+
return {
|
|
135
|
+
kind: 'list',
|
|
136
|
+
markerLength: contentStartOffset(first, line),
|
|
137
|
+
node: first,
|
|
138
|
+
};
|
|
139
|
+
case 'code':
|
|
140
|
+
// A code construct recognized from a single line is just the opening
|
|
141
|
+
// fence (possibly with a language); the entire line is the marker.
|
|
142
|
+
return {kind: 'code', markerLength: line.length, node: first};
|
|
143
|
+
default:
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Recognizes an inline construct (emphasis, strong, strikethrough, inline
|
|
150
|
+
* code, link, plus any registered `inlineShortcutTypes`) whose closing
|
|
151
|
+
* delimiter falls exactly at the end of `value` (the text up to the caret).
|
|
152
|
+
* Returns the mdast node, or `null`.
|
|
153
|
+
*/
|
|
154
|
+
scanInline(value: string): PhrasingContent | null {
|
|
155
|
+
if (value.length === 0) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
const tree = this.parse(value);
|
|
159
|
+
const lastBlock = tree.children[tree.children.length - 1];
|
|
160
|
+
if (!lastBlock || lastBlock.type !== 'paragraph') {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
const last = lastBlock.children[lastBlock.children.length - 1];
|
|
164
|
+
if (
|
|
165
|
+
!last ||
|
|
166
|
+
!last.position ||
|
|
167
|
+
last.position.end.offset !== value.length ||
|
|
168
|
+
!this.compiled.inlineShortcutTypes.has(last.type)
|
|
169
|
+
) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
// Guard against firing mid-delimiter: while typing `**bold*` the parser
|
|
173
|
+
// briefly sees emphasis (`*bold*`) closing at the caret with a stray `*`
|
|
174
|
+
// in front. If the opening delimiter is immediately preceded by the same
|
|
175
|
+
// delimiter character, defer until the user finishes the longer run.
|
|
176
|
+
const start = last.position.start.offset ?? 0;
|
|
177
|
+
const openChar = value[start];
|
|
178
|
+
if (
|
|
179
|
+
start > 0 &&
|
|
180
|
+
value[start - 1] === openChar &&
|
|
181
|
+
this.compiled.inlineShortcutTriggers.has(openChar)
|
|
182
|
+
) {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
return last;
|
|
186
|
+
}
|
|
187
|
+
}
|