@lexical/markdown 0.44.1-nightly.20260519.0 → 0.45.1-dev.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/{LexicalMarkdown.dev.js → dist/LexicalMarkdown.dev.js} +1 -1
- package/{LexicalMarkdown.dev.mjs → dist/LexicalMarkdown.dev.mjs} +1 -1
- package/dist/LexicalMarkdown.prod.js +9 -0
- package/dist/LexicalMarkdown.prod.mjs +9 -0
- package/package.json +37 -22
- package/src/MarkdownExport.ts +627 -0
- package/src/MarkdownImport.ts +363 -0
- package/src/MarkdownShortcuts.ts +677 -0
- package/src/MarkdownTransformers.ts +962 -0
- package/src/importTextFormatTransformer.ts +389 -0
- package/src/importTextMatchTransformer.ts +110 -0
- package/src/importTextTransformers.ts +141 -0
- package/src/index.ts +138 -0
- package/src/utils.ts +472 -0
- package/LexicalMarkdown.prod.js +0 -9
- package/LexicalMarkdown.prod.mjs +0 -9
- /package/{LexicalMarkdown.js → dist/LexicalMarkdown.js} +0 -0
- /package/{LexicalMarkdown.js.flow → dist/LexicalMarkdown.js.flow} +0 -0
- /package/{LexicalMarkdown.mjs → dist/LexicalMarkdown.mjs} +0 -0
- /package/{LexicalMarkdown.node.mjs → dist/LexicalMarkdown.node.mjs} +0 -0
- /package/{MarkdownExport.d.ts → dist/MarkdownExport.d.ts} +0 -0
- /package/{MarkdownImport.d.ts → dist/MarkdownImport.d.ts} +0 -0
- /package/{MarkdownShortcuts.d.ts → dist/MarkdownShortcuts.d.ts} +0 -0
- /package/{MarkdownTransformers.d.ts → dist/MarkdownTransformers.d.ts} +0 -0
- /package/{importTextFormatTransformer.d.ts → dist/importTextFormatTransformer.d.ts} +0 -0
- /package/{importTextMatchTransformer.d.ts → dist/importTextMatchTransformer.d.ts} +0 -0
- /package/{importTextTransformers.d.ts → dist/importTextTransformers.d.ts} +0 -0
- /package/{index.d.ts → dist/index.d.ts} +0 -0
- /package/{utils.d.ts → dist/utils.d.ts} +0 -0
|
@@ -0,0 +1,677 @@
|
|
|
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 {
|
|
10
|
+
ElementTransformer,
|
|
11
|
+
MultilineElementTransformer,
|
|
12
|
+
TextFormatTransformer,
|
|
13
|
+
TextMatchTransformer,
|
|
14
|
+
Transformer,
|
|
15
|
+
} from './MarkdownTransformers';
|
|
16
|
+
import type {ElementNode, LexicalEditor, TextNode} from 'lexical';
|
|
17
|
+
|
|
18
|
+
import {$isCodeNode} from '@lexical/code-core';
|
|
19
|
+
import invariant from '@lexical/internal/invariant';
|
|
20
|
+
import {
|
|
21
|
+
$addUpdateTag,
|
|
22
|
+
$createRangeSelection,
|
|
23
|
+
$getSelection,
|
|
24
|
+
$isLineBreakNode,
|
|
25
|
+
$isRangeSelection,
|
|
26
|
+
$isRootOrShadowRoot,
|
|
27
|
+
$isTextNode,
|
|
28
|
+
$setSelection,
|
|
29
|
+
COLLABORATION_TAG,
|
|
30
|
+
COMMAND_PRIORITY_LOW,
|
|
31
|
+
COMPOSITION_END_TAG,
|
|
32
|
+
HISTORIC_TAG,
|
|
33
|
+
HISTORY_PUSH_TAG,
|
|
34
|
+
KEY_ENTER_COMMAND,
|
|
35
|
+
mergeRegister,
|
|
36
|
+
} from 'lexical';
|
|
37
|
+
|
|
38
|
+
import {canContainTransformableMarkdown} from './importTextTransformers';
|
|
39
|
+
import {TRANSFORMERS} from './MarkdownTransformers';
|
|
40
|
+
import {indexBy, PUNCTUATION_OR_SPACE, transformersByType} from './utils';
|
|
41
|
+
|
|
42
|
+
function runElementTransformers(
|
|
43
|
+
parentNode: ElementNode,
|
|
44
|
+
anchorNode: TextNode,
|
|
45
|
+
anchorOffset: number,
|
|
46
|
+
elementTransformers: ReadonlyArray<ElementTransformer>,
|
|
47
|
+
triggerOnEnter?: boolean,
|
|
48
|
+
): boolean {
|
|
49
|
+
const grandParentNode = parentNode.getParent();
|
|
50
|
+
|
|
51
|
+
if (
|
|
52
|
+
!$isRootOrShadowRoot(grandParentNode) ||
|
|
53
|
+
parentNode.getFirstChild() !== anchorNode
|
|
54
|
+
) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const textContent = anchorNode.getTextContent();
|
|
59
|
+
|
|
60
|
+
// Checking for anchorOffset position to prevent any checks for cases when caret is too far
|
|
61
|
+
// from a line start to be a part of block-level markdown trigger.
|
|
62
|
+
//
|
|
63
|
+
// TODO:
|
|
64
|
+
// Can have a quick check if caret is close enough to the beginning of the string (e.g. offset less than 10-20)
|
|
65
|
+
// since otherwise it won't be a markdown shortcut, but tables are exception
|
|
66
|
+
if (!triggerOnEnter) {
|
|
67
|
+
if (textContent[anchorOffset - 1] !== ' ') {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const {regExp, replace} of elementTransformers) {
|
|
73
|
+
const match = textContent.match(regExp);
|
|
74
|
+
|
|
75
|
+
const expectedMatchLength =
|
|
76
|
+
triggerOnEnter || (match && match[0].endsWith(' '))
|
|
77
|
+
? anchorOffset
|
|
78
|
+
: anchorOffset - 1;
|
|
79
|
+
|
|
80
|
+
if (match && match[0].length === expectedMatchLength) {
|
|
81
|
+
const nextSiblings = anchorNode.getNextSiblings();
|
|
82
|
+
const [leadingNode, remainderNode] = anchorNode.splitText(anchorOffset);
|
|
83
|
+
const siblings = remainderNode
|
|
84
|
+
? [remainderNode, ...nextSiblings]
|
|
85
|
+
: nextSiblings;
|
|
86
|
+
if (replace(parentNode, siblings, match, false) !== false) {
|
|
87
|
+
leadingNode.remove();
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function runMultilineElementTransformers(
|
|
97
|
+
parentNode: ElementNode,
|
|
98
|
+
anchorNode: TextNode,
|
|
99
|
+
anchorOffset: number,
|
|
100
|
+
elementTransformers: ReadonlyArray<MultilineElementTransformer>,
|
|
101
|
+
triggerOnEnter?: boolean,
|
|
102
|
+
): boolean {
|
|
103
|
+
const grandParentNode = parentNode.getParent();
|
|
104
|
+
|
|
105
|
+
if (
|
|
106
|
+
!$isRootOrShadowRoot(grandParentNode) ||
|
|
107
|
+
parentNode.getFirstChild() !== anchorNode
|
|
108
|
+
) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const textContent = anchorNode.getTextContent();
|
|
113
|
+
|
|
114
|
+
if (!triggerOnEnter) {
|
|
115
|
+
// Checking for anchorOffset position to prevent any checks for cases when caret is too far
|
|
116
|
+
// from a line start to be a part of block-level markdown trigger.
|
|
117
|
+
//
|
|
118
|
+
// TODO:
|
|
119
|
+
// Can have a quick check if caret is close enough to the beginning of the string (e.g. offset less than 10-20)
|
|
120
|
+
// since otherwise it won't be a markdown shortcut, but tables are exception
|
|
121
|
+
if (textContent[anchorOffset - 1] !== ' ') {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const {regExpStart, replace, regExpEnd} of elementTransformers) {
|
|
127
|
+
if (
|
|
128
|
+
(regExpEnd && !('optional' in regExpEnd)) ||
|
|
129
|
+
(regExpEnd && 'optional' in regExpEnd && !regExpEnd.optional)
|
|
130
|
+
) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const match = textContent.match(regExpStart);
|
|
135
|
+
|
|
136
|
+
if (match) {
|
|
137
|
+
const matchLength =
|
|
138
|
+
triggerOnEnter || match[0].endsWith(' ')
|
|
139
|
+
? anchorOffset
|
|
140
|
+
: anchorOffset - 1;
|
|
141
|
+
|
|
142
|
+
if (match[0].length !== matchLength) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const nextSiblings = anchorNode.getNextSiblings();
|
|
147
|
+
const [leadingNode, remainderNode] = anchorNode.splitText(anchorOffset);
|
|
148
|
+
const siblings = remainderNode
|
|
149
|
+
? [remainderNode, ...nextSiblings]
|
|
150
|
+
: nextSiblings;
|
|
151
|
+
|
|
152
|
+
if (replace(parentNode, siblings, match, null, null, false) !== false) {
|
|
153
|
+
leadingNode.remove();
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function runTextMatchTransformers(
|
|
163
|
+
anchorNode: TextNode,
|
|
164
|
+
anchorOffset: number,
|
|
165
|
+
transformersByTrigger: Readonly<Record<string, Array<TextMatchTransformer>>>,
|
|
166
|
+
): boolean {
|
|
167
|
+
let textContent = anchorNode.getTextContent();
|
|
168
|
+
const lastChar = textContent[anchorOffset - 1];
|
|
169
|
+
const transformers = transformersByTrigger[lastChar];
|
|
170
|
+
|
|
171
|
+
if (transformers == null) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// If typing in the middle of content, remove the tail to do
|
|
176
|
+
// reg exp match up to a string end (caret position)
|
|
177
|
+
if (anchorOffset < textContent.length) {
|
|
178
|
+
textContent = textContent.slice(0, anchorOffset);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
for (const transformer of transformers) {
|
|
182
|
+
if (!transformer.replace || !transformer.regExp) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const match = textContent.match(transformer.regExp);
|
|
186
|
+
|
|
187
|
+
if (match === null) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const startIndex = match.index || 0;
|
|
192
|
+
const endIndex = startIndex + match[0].length;
|
|
193
|
+
let replaceNode;
|
|
194
|
+
|
|
195
|
+
if (startIndex === 0) {
|
|
196
|
+
[replaceNode] = anchorNode.splitText(endIndex);
|
|
197
|
+
} else {
|
|
198
|
+
[, replaceNode] = anchorNode.splitText(startIndex, endIndex);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
replaceNode.selectNext(0, 0);
|
|
202
|
+
transformer.replace(replaceNode, match);
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function $runTextFormatTransformers(
|
|
210
|
+
anchorNode: TextNode,
|
|
211
|
+
anchorOffset: number,
|
|
212
|
+
textFormatTransformers: Readonly<
|
|
213
|
+
Record<string, ReadonlyArray<TextFormatTransformer>>
|
|
214
|
+
>,
|
|
215
|
+
): boolean {
|
|
216
|
+
const textContent = anchorNode.getTextContent();
|
|
217
|
+
const closeTagEndIndex = anchorOffset - 1;
|
|
218
|
+
const closeChar = textContent[closeTagEndIndex];
|
|
219
|
+
// Quick check if we're possibly at the end of inline markdown style
|
|
220
|
+
const matchers = textFormatTransformers[closeChar];
|
|
221
|
+
|
|
222
|
+
if (!matchers) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
for (const matcher of matchers) {
|
|
227
|
+
const {tag} = matcher;
|
|
228
|
+
const tagLength = tag.length;
|
|
229
|
+
const closeTagStartIndex = closeTagEndIndex - tagLength + 1;
|
|
230
|
+
|
|
231
|
+
// If tag is not single char check if rest of it matches with text content
|
|
232
|
+
if (tagLength > 1) {
|
|
233
|
+
if (
|
|
234
|
+
!isEqualSubString(textContent, closeTagStartIndex, tag, 0, tagLength)
|
|
235
|
+
) {
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Space before closing tag cancels inline markdown
|
|
241
|
+
if (textContent[closeTagStartIndex - 1] === ' ') {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Some tags can not be used within words, hence should have newline/space/punctuation after it
|
|
246
|
+
const afterCloseTagChar = textContent[closeTagEndIndex + 1];
|
|
247
|
+
|
|
248
|
+
if (
|
|
249
|
+
matcher.intraword === false &&
|
|
250
|
+
afterCloseTagChar &&
|
|
251
|
+
!PUNCTUATION_OR_SPACE.test(afterCloseTagChar)
|
|
252
|
+
) {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const closeNode = anchorNode;
|
|
257
|
+
let openNode = closeNode;
|
|
258
|
+
let openTagStartIndex = getOpenTagStartIndex(
|
|
259
|
+
textContent,
|
|
260
|
+
closeTagStartIndex,
|
|
261
|
+
tag,
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
// Go through text node siblings and search for opening tag
|
|
265
|
+
// if haven't found it within the same text node as closing tag
|
|
266
|
+
let sibling: TextNode | null = openNode;
|
|
267
|
+
|
|
268
|
+
while (
|
|
269
|
+
openTagStartIndex < 0 &&
|
|
270
|
+
(sibling = sibling.getPreviousSibling<TextNode>())
|
|
271
|
+
) {
|
|
272
|
+
if ($isLineBreakNode(sibling)) {
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if ($isTextNode(sibling)) {
|
|
277
|
+
if (sibling.hasFormat('code')) {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const siblingTextContent = sibling.getTextContent();
|
|
281
|
+
openNode = sibling;
|
|
282
|
+
openTagStartIndex = getOpenTagStartIndex(
|
|
283
|
+
siblingTextContent,
|
|
284
|
+
siblingTextContent.length,
|
|
285
|
+
tag,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Opening tag is not found
|
|
291
|
+
if (openTagStartIndex < 0) {
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// No content between opening and closing tag
|
|
296
|
+
if (
|
|
297
|
+
openNode === closeNode &&
|
|
298
|
+
openTagStartIndex + tagLength === closeTagStartIndex
|
|
299
|
+
) {
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Checking longer tags for repeating chars (e.g. *** vs **)
|
|
304
|
+
const prevOpenNodeText = openNode.getTextContent();
|
|
305
|
+
|
|
306
|
+
if (
|
|
307
|
+
openTagStartIndex > 0 &&
|
|
308
|
+
prevOpenNodeText[openTagStartIndex - 1] === closeChar
|
|
309
|
+
) {
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Some tags can not be used within words, hence should have newline/space/punctuation before it
|
|
314
|
+
const beforeOpenTagChar = prevOpenNodeText[openTagStartIndex - 1];
|
|
315
|
+
|
|
316
|
+
if (
|
|
317
|
+
matcher.intraword === false &&
|
|
318
|
+
beforeOpenTagChar &&
|
|
319
|
+
!PUNCTUATION_OR_SPACE.test(beforeOpenTagChar)
|
|
320
|
+
) {
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Per CommonMark, code spans take precedence over other inline formatting
|
|
325
|
+
if (
|
|
326
|
+
!matcher.format.includes('code') &&
|
|
327
|
+
$isInsideUnclosedCodeSpan(openNode, openTagStartIndex)
|
|
328
|
+
) {
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Clean text from opening and closing tags (starting from closing tag
|
|
333
|
+
// to prevent any offset shifts if we start from opening one)
|
|
334
|
+
const prevCloseNodeText = closeNode.getTextContent();
|
|
335
|
+
const closeNodeText =
|
|
336
|
+
prevCloseNodeText.slice(0, closeTagStartIndex) +
|
|
337
|
+
prevCloseNodeText.slice(closeTagEndIndex + 1);
|
|
338
|
+
closeNode.setTextContent(closeNodeText);
|
|
339
|
+
const openNodeText =
|
|
340
|
+
openNode === closeNode ? closeNodeText : prevOpenNodeText;
|
|
341
|
+
openNode.setTextContent(
|
|
342
|
+
openNodeText.slice(0, openTagStartIndex) +
|
|
343
|
+
openNodeText.slice(openTagStartIndex + tagLength),
|
|
344
|
+
);
|
|
345
|
+
const selection = $getSelection();
|
|
346
|
+
const nextSelection = $createRangeSelection();
|
|
347
|
+
$setSelection(nextSelection);
|
|
348
|
+
// Adjust offset based on deleted chars
|
|
349
|
+
const newOffset =
|
|
350
|
+
closeTagEndIndex - tagLength * (openNode === closeNode ? 2 : 1) + 1;
|
|
351
|
+
nextSelection.anchor.set(openNode.__key, openTagStartIndex, 'text');
|
|
352
|
+
nextSelection.focus.set(closeNode.__key, newOffset, 'text');
|
|
353
|
+
|
|
354
|
+
// Apply formatting to selected text
|
|
355
|
+
for (const format of matcher.format) {
|
|
356
|
+
if (!nextSelection.hasFormat(format)) {
|
|
357
|
+
nextSelection.formatText(format);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Collapse selection up to the focus point
|
|
362
|
+
nextSelection.anchor.set(
|
|
363
|
+
nextSelection.focus.key,
|
|
364
|
+
nextSelection.focus.offset,
|
|
365
|
+
nextSelection.focus.type,
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
// Remove formatting from collapsed selection
|
|
369
|
+
for (const format of matcher.format) {
|
|
370
|
+
if (nextSelection.hasFormat(format)) {
|
|
371
|
+
nextSelection.toggleFormat(format);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if ($isRangeSelection(selection)) {
|
|
376
|
+
nextSelection.format = selection.format;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Per CommonMark spec, code spans take precedence over other inline
|
|
386
|
+
// formatting. Returns true if there is an unclosed backtick (code span
|
|
387
|
+
// opener) in the text preceding the given offset, which means the offset
|
|
388
|
+
// is inside a code span that hasn't been closed yet.
|
|
389
|
+
function $isInsideUnclosedCodeSpan(node: TextNode, offset: number): boolean {
|
|
390
|
+
let backtickCount = 0;
|
|
391
|
+
|
|
392
|
+
const text = node.getTextContent();
|
|
393
|
+
for (let i = 0; i < offset; i++) {
|
|
394
|
+
if (text[i] === '`') {
|
|
395
|
+
backtickCount++;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return backtickCount % 2 !== 0;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function getOpenTagStartIndex(
|
|
403
|
+
string: string,
|
|
404
|
+
maxIndex: number,
|
|
405
|
+
tag: string,
|
|
406
|
+
): number {
|
|
407
|
+
const tagLength = tag.length;
|
|
408
|
+
|
|
409
|
+
for (let i = maxIndex; i >= tagLength; i--) {
|
|
410
|
+
const startIndex = i - tagLength;
|
|
411
|
+
|
|
412
|
+
if (
|
|
413
|
+
isEqualSubString(string, startIndex, tag, 0, tagLength) && // Space after opening tag cancels transformation
|
|
414
|
+
string[startIndex + tagLength] !== ' '
|
|
415
|
+
) {
|
|
416
|
+
return startIndex;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return -1;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function isEqualSubString(
|
|
424
|
+
stringA: string,
|
|
425
|
+
aStart: number,
|
|
426
|
+
stringB: string,
|
|
427
|
+
bStart: number,
|
|
428
|
+
length: number,
|
|
429
|
+
): boolean {
|
|
430
|
+
for (let i = 0; i < length; i++) {
|
|
431
|
+
if (stringA[aStart + i] !== stringB[bStart + i]) {
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export function registerMarkdownShortcuts(
|
|
440
|
+
editor: LexicalEditor,
|
|
441
|
+
transformers: Array<Transformer> = TRANSFORMERS,
|
|
442
|
+
): () => void {
|
|
443
|
+
const byType = transformersByType(transformers);
|
|
444
|
+
const elementTransformersForEnter = byType.element.filter(
|
|
445
|
+
t => t.triggerOnEnter,
|
|
446
|
+
);
|
|
447
|
+
const textFormatTransformersByTrigger = indexBy(
|
|
448
|
+
byType.textFormat,
|
|
449
|
+
({tag}) => tag[tag.length - 1],
|
|
450
|
+
);
|
|
451
|
+
const textMatchTransformersByTrigger = indexBy(
|
|
452
|
+
byType.textMatch,
|
|
453
|
+
({trigger}) => trigger,
|
|
454
|
+
);
|
|
455
|
+
// Composition end fires per IME commit (every CJK syllable, German dead-key
|
|
456
|
+
// resolve, etc.). Most of those have nothing to do with markdown, so only
|
|
457
|
+
// enter the transformer pass when the just-committed character can plausibly
|
|
458
|
+
// close a trigger. Space covers element/multilineElement triggers (`# `,
|
|
459
|
+
// `- `, ...) and text-match triggers carry their own single-character triggers.
|
|
460
|
+
const compositionEndTriggerChars = new Set<string>([' ']);
|
|
461
|
+
for (const t of byType.textFormat) {
|
|
462
|
+
compositionEndTriggerChars.add(t.tag.slice(-1));
|
|
463
|
+
}
|
|
464
|
+
for (const t of byType.textMatch) {
|
|
465
|
+
if (t.trigger !== undefined) {
|
|
466
|
+
compositionEndTriggerChars.add(t.trigger);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
for (const transformer of transformers) {
|
|
471
|
+
const type = transformer.type;
|
|
472
|
+
if (
|
|
473
|
+
type === 'element' ||
|
|
474
|
+
type === 'text-match' ||
|
|
475
|
+
type === 'multiline-element'
|
|
476
|
+
) {
|
|
477
|
+
const dependencies = transformer.dependencies;
|
|
478
|
+
for (const node of dependencies) {
|
|
479
|
+
if (!editor.hasNode(node)) {
|
|
480
|
+
invariant(
|
|
481
|
+
false,
|
|
482
|
+
'MarkdownShortcuts: missing dependency %s for transformer. Ensure node dependency is included in editor initial config.',
|
|
483
|
+
node.getType(),
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const $transform = (
|
|
491
|
+
parentNode: ElementNode,
|
|
492
|
+
anchorNode: TextNode,
|
|
493
|
+
anchorOffset: number,
|
|
494
|
+
): boolean => {
|
|
495
|
+
if (
|
|
496
|
+
runElementTransformers(
|
|
497
|
+
parentNode,
|
|
498
|
+
anchorNode,
|
|
499
|
+
anchorOffset,
|
|
500
|
+
byType.element,
|
|
501
|
+
)
|
|
502
|
+
) {
|
|
503
|
+
return true;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (
|
|
507
|
+
runMultilineElementTransformers(
|
|
508
|
+
parentNode,
|
|
509
|
+
anchorNode,
|
|
510
|
+
anchorOffset,
|
|
511
|
+
byType.multilineElement,
|
|
512
|
+
)
|
|
513
|
+
) {
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (
|
|
518
|
+
runTextMatchTransformers(
|
|
519
|
+
anchorNode,
|
|
520
|
+
anchorOffset,
|
|
521
|
+
textMatchTransformersByTrigger,
|
|
522
|
+
)
|
|
523
|
+
) {
|
|
524
|
+
return true;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (
|
|
528
|
+
$runTextFormatTransformers(
|
|
529
|
+
anchorNode,
|
|
530
|
+
anchorOffset,
|
|
531
|
+
textFormatTransformersByTrigger,
|
|
532
|
+
)
|
|
533
|
+
) {
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return false;
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
return mergeRegister(
|
|
541
|
+
editor.registerUpdateListener(
|
|
542
|
+
({tags, dirtyLeaves, editorState, prevEditorState}) => {
|
|
543
|
+
// Ignore updates from collaboration and undo/redo (as changes already calculated)
|
|
544
|
+
if (tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG)) {
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// If editor is still composing (i.e. backticks) we must wait before the user confirms the key
|
|
549
|
+
if (editor.isComposing()) {
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// composition end commits the composed text without moving the
|
|
554
|
+
// selection (compositionupdate already did) and may jump the anchor by
|
|
555
|
+
// more than one when multi-character commits land at once. Skip the
|
|
556
|
+
// typed-character heuristics so a trailing trigger character can still
|
|
557
|
+
// fire its transform.
|
|
558
|
+
const isCompositionEnd = tags.has(COMPOSITION_END_TAG);
|
|
559
|
+
|
|
560
|
+
const selection = editorState.read($getSelection);
|
|
561
|
+
const prevSelection = prevEditorState.read($getSelection);
|
|
562
|
+
|
|
563
|
+
// We expect selection to be a collapsed range and not match previous one (as we want
|
|
564
|
+
// to trigger transforms only as user types)
|
|
565
|
+
if (
|
|
566
|
+
!$isRangeSelection(prevSelection) ||
|
|
567
|
+
!$isRangeSelection(selection) ||
|
|
568
|
+
!selection.isCollapsed() ||
|
|
569
|
+
(selection.is(prevSelection) && !isCompositionEnd)
|
|
570
|
+
) {
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const anchorKey = selection.anchor.key;
|
|
575
|
+
const anchorOffset = selection.anchor.offset;
|
|
576
|
+
|
|
577
|
+
const anchorNode = editorState._nodeMap.get(anchorKey);
|
|
578
|
+
|
|
579
|
+
if (
|
|
580
|
+
!$isTextNode(anchorNode) ||
|
|
581
|
+
!dirtyLeaves.has(anchorKey) ||
|
|
582
|
+
(!isCompositionEnd &&
|
|
583
|
+
anchorOffset !== 1 &&
|
|
584
|
+
anchorOffset > prevSelection.anchor.offset + 1)
|
|
585
|
+
) {
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (isCompositionEnd) {
|
|
590
|
+
const closeChar = editorState.read(() => anchorNode.getTextContent())[
|
|
591
|
+
anchorOffset - 1
|
|
592
|
+
];
|
|
593
|
+
if (!compositionEndTriggerChars.has(closeChar)) {
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
editor.update(() => {
|
|
599
|
+
if (!canContainTransformableMarkdown(anchorNode)) {
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const parentNode = anchorNode.getParent();
|
|
604
|
+
|
|
605
|
+
if (parentNode === null || $isCodeNode(parentNode)) {
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if ($transform(parentNode, anchorNode, selection.anchor.offset)) {
|
|
610
|
+
$addUpdateTag(HISTORY_PUSH_TAG);
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
},
|
|
614
|
+
),
|
|
615
|
+
editor.registerCommand(
|
|
616
|
+
KEY_ENTER_COMMAND,
|
|
617
|
+
event => {
|
|
618
|
+
if (event !== null && event.shiftKey) {
|
|
619
|
+
return false;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const selection = $getSelection();
|
|
623
|
+
|
|
624
|
+
if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const anchorOffset = selection.anchor.offset;
|
|
629
|
+
const anchorNode = selection.anchor.getNode();
|
|
630
|
+
|
|
631
|
+
if (
|
|
632
|
+
!$isTextNode(anchorNode) ||
|
|
633
|
+
!canContainTransformableMarkdown(anchorNode)
|
|
634
|
+
) {
|
|
635
|
+
return false;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const parentNode = anchorNode.getParent();
|
|
639
|
+
|
|
640
|
+
if (parentNode === null || $isCodeNode(parentNode)) {
|
|
641
|
+
return false;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const textContent = anchorNode.getTextContent();
|
|
645
|
+
|
|
646
|
+
if (anchorOffset !== textContent.length) {
|
|
647
|
+
return false;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (
|
|
651
|
+
runMultilineElementTransformers(
|
|
652
|
+
parentNode,
|
|
653
|
+
anchorNode,
|
|
654
|
+
anchorOffset,
|
|
655
|
+
byType.multilineElement,
|
|
656
|
+
true,
|
|
657
|
+
) ||
|
|
658
|
+
runElementTransformers(
|
|
659
|
+
parentNode,
|
|
660
|
+
anchorNode,
|
|
661
|
+
anchorOffset,
|
|
662
|
+
elementTransformersForEnter,
|
|
663
|
+
true,
|
|
664
|
+
)
|
|
665
|
+
) {
|
|
666
|
+
if (event !== null) {
|
|
667
|
+
event.preventDefault();
|
|
668
|
+
}
|
|
669
|
+
return true;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
return false;
|
|
673
|
+
},
|
|
674
|
+
COMMAND_PRIORITY_LOW,
|
|
675
|
+
),
|
|
676
|
+
);
|
|
677
|
+
}
|