@lexical/markdown 0.2.3 → 0.2.6

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.
@@ -6,12 +6,11 @@
6
6
  */
7
7
  'use strict';
8
8
 
9
- var code = require('@lexical/code');
10
- var list = require('@lexical/list');
11
9
  var lexical = require('lexical');
10
+ var code = require('@lexical/code');
12
11
  var link = require('@lexical/link');
12
+ var list = require('@lexical/list');
13
13
  var richText = require('@lexical/rich-text');
14
- var text = require('@lexical/text');
15
14
 
16
15
  /**
17
16
  * Copyright (c) Meta Platforms, Inc. and affiliates.
@@ -21,834 +20,390 @@ var text = require('@lexical/text');
21
20
  *
22
21
  *
23
22
  */
24
- /*
25
- How to add a new syntax to capture and transform.
26
- 1. Create a new enumeration by adding to MarkdownFormatKind.
27
- 2. Add a new criteria with a regEx pattern. See markdownStrikethrough as an example.
28
- 3. Add your block criteria (e.g. '# ') to allMarkdownCriteria or
29
- your text criteria (e.g. *MyItalic*) to allMarkdownCriteriaForTextNodes.
30
- 4. Add your Lexical block specific transforming code here: transformTextNodeForText.
31
- Add your Lexical text specific transforming code here: transformTextNodeForText.
32
- */
33
- // The trigger state helps to capture EditorState information
34
- // from the prior and current EditorState.
35
- // This is then used to determined if an auto format has been triggered.
36
-
37
- // Eventually we need to support multiple trigger string's including newlines.
38
- const SEPARATOR_BETWEEN_TEXT_AND_NON_TEXT_NODES = '\u0004'; // Select an unused unicode character to separate text and non-text nodes.
39
-
40
- const SEPARATOR_LENGTH = SEPARATOR_BETWEEN_TEXT_AND_NON_TEXT_NODES.length;
41
- const spaceTrigger = {
42
- triggerKind: 'space_trigger',
43
- triggerString: '\u0020'
44
- }; // Future todo: add support for ``` + carriage return either inside or not inside code block. Should toggle between.
45
- // const codeBlockTrigger : AutoFormatTrigger = {
46
- // triggerKind: 'codeBlock_trigger',
47
- // triggerString: '```', // + new paragraph element or new code block element.
48
- // };
49
-
50
- const triggers = [spaceTrigger
51
- /*, codeBlockTrigger*/
52
- ]; // Future Todo: speed up performance by having non-capture group variations of the regex.
53
-
54
- const autoFormatBase = {
55
- markdownFormatKind: null,
56
- regEx: /(?:)/,
57
- regExForAutoFormatting: /(?:)/,
58
- requiresParagraphStart: false
59
- };
60
- const paragraphStartBase = { ...autoFormatBase,
61
- requiresParagraphStart: true
62
- };
63
- const markdownHeader1 = { ...paragraphStartBase,
64
- markdownFormatKind: 'paragraphH1',
65
- regEx: /^(?:# )/,
66
- regExForAutoFormatting: /^(?:# )/
67
- };
68
- const markdownHeader2 = { ...paragraphStartBase,
69
- markdownFormatKind: 'paragraphH2',
70
- regEx: /^(?:## )/,
71
- regExForAutoFormatting: /^(?:## )/
72
- };
73
- const markdownHeader3 = { ...paragraphStartBase,
74
- markdownFormatKind: 'paragraphH3',
75
- regEx: /^(?:### )/,
76
- regExForAutoFormatting: /^(?:### )/
77
- };
78
- const markdownHeader4 = { ...paragraphStartBase,
79
- markdownFormatKind: 'paragraphH4',
80
- regEx: /^(?:#### )/,
81
- regExForAutoFormatting: /^(?:#### )/
82
- };
83
- const markdownHeader5 = { ...paragraphStartBase,
84
- markdownFormatKind: 'paragraphH5',
85
- regEx: /^(?:##### )/,
86
- regExForAutoFormatting: /^(?:##### )/
87
- };
88
- const markdownBlockQuote = { ...paragraphStartBase,
89
- markdownFormatKind: 'paragraphBlockQuote',
90
- regEx: /^(?:> )/,
91
- regExForAutoFormatting: /^(?:> )/
92
- };
93
- const markdownUnorderedListDash = { ...paragraphStartBase,
94
- markdownFormatKind: 'paragraphUnorderedList',
95
- regEx: /^(\s{0,10})(?:- )/,
96
- regExForAutoFormatting: /^(\s{0,10})(?:- )/
97
- };
98
- const markdownUnorderedListAsterisk = { ...paragraphStartBase,
99
- markdownFormatKind: 'paragraphUnorderedList',
100
- regEx: /^(\s{0,10})(?:\* )/,
101
- regExForAutoFormatting: /^(\s{0,10})(?:\* )/
102
- };
103
- const markdownCodeBlock = { ...paragraphStartBase,
104
- markdownFormatKind: 'paragraphCodeBlock',
105
- regEx: /^(```)$/,
106
- regExForAutoFormatting: /^(```)([a-z]*)( )/
107
- };
108
- const markdownOrderedList = { ...paragraphStartBase,
109
- markdownFormatKind: 'paragraphOrderedList',
110
- regEx: /^(\s{0,10})(\d+)\.\s/,
111
- regExForAutoFormatting: /^(\s{0,10})(\d+)\.\s/
112
- };
113
- const markdownHorizontalRule = { ...paragraphStartBase,
114
- markdownFormatKind: 'horizontalRule',
115
- regEx: /^(?:\*\*\*)$/,
116
- regExForAutoFormatting: /^(?:\*\*\* )/
117
- };
118
- const markdownHorizontalRuleUsingDashes = { ...paragraphStartBase,
119
- markdownFormatKind: 'horizontalRule',
120
- regEx: /^(?:---)$/,
121
- regExForAutoFormatting: /^(?:--- )/
122
- };
123
- const markdownInlineCode = { ...autoFormatBase,
124
- markdownFormatKind: 'code',
125
- regEx: /(`)([^`]*)(`)/,
126
- regExForAutoFormatting: /(`)(\s*\b)([^`]*)(\b\s*)(`)(\s)$/
127
- };
128
- const markdownBold = { ...autoFormatBase,
129
- markdownFormatKind: 'bold',
130
- regEx: /(\*\*)(\s*)([^\*\*]*)(\s*)(\*\*)()/,
131
- regExForAutoFormatting: /(\*\*)(\s*\b)([^\*\*]*)(\b\s*)(\*\*)(\s)$/
132
- };
133
- const markdownItalic = { ...autoFormatBase,
134
- markdownFormatKind: 'italic',
135
- regEx: /(\*)(\s*)([^\*]*)(\s*)(\*)()/,
136
- regExForAutoFormatting: /(\*)(\s*\b)([^\*]*)(\b\s*)(\*)(\s)$/
137
- };
138
- const markdownBold2 = { ...autoFormatBase,
139
- markdownFormatKind: 'bold',
140
- regEx: /(__)(\s*)([^__]*)(\s*)(__)()/,
141
- regExForAutoFormatting: /(__)(\s*)([^__]*)(\s*)(__)(\s)$/
142
- };
143
- const markdownItalic2 = { ...autoFormatBase,
144
- markdownFormatKind: 'italic',
145
- regEx: /(_)()([^_]*)()(_)()/,
146
- regExForAutoFormatting: /(_)()([^_]*)()(_)(\s)$/ // Maintain 7 groups.
147
-
148
- }; // Markdown does not support underline, but we can allow folks to use
149
- // the HTML tags for underline.
150
-
151
- const fakeMarkdownUnderline = { ...autoFormatBase,
152
- markdownFormatKind: 'underline',
153
- regEx: /(\<u\>)(\s*)([^\<]*)(\s*)(\<\/u\>)()/,
154
- regExForAutoFormatting: /(\<u\>)(\s*\b)([^\<]*)(\b\s*)(\<\/u\>)(\s)$/
155
- };
156
- const markdownStrikethrough = { ...autoFormatBase,
157
- markdownFormatKind: 'strikethrough',
158
- regEx: /(~~)(\s*)([^~~]*)(\s*)(~~)()/,
159
- regExForAutoFormatting: /(~~)(\s*\b)([^~~]*)(\b\s*)(~~)(\s)$/
160
- };
161
- const markdownStrikethroughItalicBold = { ...autoFormatBase,
162
- markdownFormatKind: 'strikethrough_italic_bold',
163
- regEx: /(~~_\*\*)(\s*\b)([^~~_\*\*][^\*\*_~~]*)(\b\s*)(\*\*_~~)()/,
164
- regExForAutoFormatting: /(~~_\*\*)(\s*\b)([^~~_\*\*][^\*\*_~~]*)(\b\s*)(\*\*_~~)(\s)$/
165
- };
166
- const markdownItalicbold = { ...autoFormatBase,
167
- markdownFormatKind: 'italic_bold',
168
- regEx: /(_\*\*)(\s*\b)([^_\*\*][^\*\*_]*)(\b\s*)(\*\*_)/,
169
- regExForAutoFormatting: /(_\*\*)(\s*\b)([^_\*\*][^\*\*_]*)(\b\s*)(\*\*_)(\s)$/
170
- };
171
- const markdownStrikethroughItalic = { ...autoFormatBase,
172
- markdownFormatKind: 'strikethrough_italic',
173
- regEx: /(~~_)(\s*)([^~~_][^_~~]*)(\s*)(_~~)/,
174
- regExForAutoFormatting: /(~~_)(\s*)([^~~_][^_~~]*)(\s*)(_~~)(\s)$/
175
- };
176
- const markdownStrikethroughBold = { ...autoFormatBase,
177
- markdownFormatKind: 'strikethrough_bold',
178
- regEx: /(~~\*\*)(\s*\b)([^~~\*\*][^\*\*~~]*)(\b\s*)(\*\*~~)/,
179
- regExForAutoFormatting: /(~~\*\*)(\s*\b)([^~~\*\*][^\*\*~~]*)(\b\s*)(\*\*~~)(\s)$/
180
- };
181
- const markdownLink = { ...autoFormatBase,
182
- markdownFormatKind: 'link',
183
- regEx: /(\[)([^\]]*)(\]\()([^)]*)(\)*)()/,
184
- regExForAutoFormatting: /(\[)([^\]]*)(\]\()([^)]*)(\)*)(\s)$/
185
- };
186
- const allMarkdownCriteriaForTextNodes = [// Place the combination formats ahead of the individual formats.
187
- // Combos
188
- markdownStrikethroughItalicBold, markdownItalicbold, markdownStrikethroughItalic, markdownStrikethroughBold, // Individuals
189
- markdownInlineCode, markdownBold, markdownItalic, // Must appear after markdownBold
190
- markdownBold2, markdownItalic2, // Must appear after markdownBold2.
191
- fakeMarkdownUnderline, markdownStrikethrough, markdownLink];
192
- const allMarkdownCriteriaForParagraphs = [markdownHeader1, markdownHeader2, markdownHeader3, markdownHeader4, markdownHeader5, markdownBlockQuote, markdownUnorderedListDash, markdownUnorderedListAsterisk, markdownOrderedList, markdownCodeBlock, markdownHorizontalRule, markdownHorizontalRuleUsingDashes];
193
- const allMarkdownCriteria = [...allMarkdownCriteriaForParagraphs, ...allMarkdownCriteriaForTextNodes];
194
- function getAllTriggers() {
195
- return triggers;
196
- }
197
- function getAllMarkdownCriteriaForParagraphs() {
198
- return allMarkdownCriteriaForParagraphs;
199
- }
200
- function getAllMarkdownCriteriaForTextNodes() {
201
- return allMarkdownCriteriaForTextNodes;
202
- }
203
- function getAllMarkdownCriteria() {
204
- return allMarkdownCriteria;
23
+ function indexBy(list, callback) {
24
+ const index = {};
25
+
26
+ for (const item of list) {
27
+ const key = callback(item);
28
+
29
+ if (index[key]) {
30
+ index[key].push(item);
31
+ } else {
32
+ index[key] = [item];
33
+ }
34
+ }
35
+
36
+ return index;
205
37
  }
206
- function getInitialScanningContext(editor, isAutoFormatting, textNodeWithOffset, triggerState) {
38
+ function transformersByType(transformers) {
39
+ const byType = indexBy(transformers, t => t.type);
207
40
  return {
208
- currentElementNode: null,
209
- editor,
210
- isAutoFormatting,
211
- isWithinCodeBlock: false,
212
- joinedText: null,
213
- markdownCriteria: {
214
- markdownFormatKind: 'noTransformation',
215
- regEx: /(?:)/,
216
- // Empty reg ex.
217
- regExForAutoFormatting: /(?:)/,
218
- // Empty reg ex.
219
- requiresParagraphStart: null
220
- },
221
- patternMatchResults: {
222
- regExCaptureGroups: []
223
- },
224
- textNodeWithOffset,
225
- triggerState
226
- };
227
- }
228
- function resetScanningContext(scanningContext) {
229
- scanningContext.joinedText = null;
230
- scanningContext.markdownCriteria = {
231
- markdownFormatKind: 'noTransformation',
232
- regEx: /(?:)/,
233
- // Empty reg ex.
234
- regExForAutoFormatting: /(?:)/,
235
- // Empty reg ex.
236
- requiresParagraphStart: null
41
+ // $FlowFixMe
42
+ element: byType.element,
43
+ // $FlowFixMe
44
+ textFormat: byType['text-format'],
45
+ // $FlowFixMe
46
+ textMatch: byType['text-match']
237
47
  };
238
- scanningContext.patternMatchResults = {
239
- regExCaptureGroups: []
240
- };
241
- scanningContext.triggerState = null;
242
- scanningContext.textNodeWithOffset = null;
243
- return scanningContext;
244
- }
245
- function getCodeBlockCriteria() {
246
- return markdownCodeBlock;
247
48
  }
248
- function getPatternMatchResultsForCriteria(markdownCriteria, scanningContext, parentElementNode) {
249
- if (markdownCriteria.requiresParagraphStart === true) {
250
- return getPatternMatchResultsForParagraphs(markdownCriteria, scanningContext);
251
- }
252
49
 
253
- return getPatternMatchResultsForText(markdownCriteria, scanningContext, parentElementNode);
254
- }
255
- function getPatternMatchResultsForCodeBlock(scanningContext, text) {
256
- const markdownCriteria = getCodeBlockCriteria();
257
- return getPatternMatchResultsWithRegEx(text, true, false, scanningContext.isAutoFormatting ? markdownCriteria.regExForAutoFormatting : markdownCriteria.regEx);
258
- }
50
+ /**
51
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
52
+ *
53
+ * This source code is licensed under the MIT license found in the
54
+ * LICENSE file in the root directory of this source tree.
55
+ *
56
+ *
57
+ */
58
+ function createMarkdownExport(transformers) {
59
+ const byType = transformersByType(transformers); // Export only uses text formats that are responsible for single format
60
+ // e.g. it will filter out *** (bold, italic) and instead use separate ** and *
259
61
 
260
- function getPatternMatchResultsWithRegEx(textToSearch, matchMustAppearAtStartOfString, matchMustAppearAtEndOfString, regEx) {
261
- const patternMatchResults = {
262
- regExCaptureGroups: []
263
- };
264
- const regExMatches = textToSearch.match(regEx);
265
-
266
- if (regExMatches !== null && regExMatches.length > 0 && (matchMustAppearAtStartOfString === false || regExMatches.index === 0) && (matchMustAppearAtEndOfString === false || regExMatches.index + regExMatches[0].length === textToSearch.length)) {
267
- const captureGroupsCount = regExMatches.length;
268
- let runningLength = regExMatches.index;
269
-
270
- for (let captureGroupIndex = 0; captureGroupIndex < captureGroupsCount; captureGroupIndex++) {
271
- const textContent = regExMatches[captureGroupIndex];
272
- patternMatchResults.regExCaptureGroups.push({
273
- offsetInParent: runningLength,
274
- text: textContent
275
- }); // The 0th capture group is special in that it's text contents is
276
- // a join of all subsequent capture groups. So, skip this group
277
- // when calculating the runningLength.
278
-
279
- if (captureGroupIndex > 0) {
280
- runningLength += textContent.length;
62
+ const textFormatTransformers = byType.textFormat.filter(transformer => transformer.format.length === 1);
63
+ return () => {
64
+ const output = [];
65
+ const children = lexical.$getRoot().getChildren();
66
+
67
+ for (const child of children) {
68
+ const result = exportTopLevelElements(child, byType.element, textFormatTransformers, byType.textMatch);
69
+
70
+ if (result != null) {
71
+ output.push(result);
281
72
  }
282
73
  }
283
74
 
284
- return patternMatchResults;
285
- }
286
-
287
- return null;
75
+ return output.join('\n');
76
+ };
288
77
  }
289
78
 
290
- function hasPatternMatchResults(scanningContext) {
291
- return scanningContext.patternMatchResults.regExCaptureGroups.length > 0;
292
- }
293
- function getTextNodeWithOffsetOrThrow(scanningContext) {
294
- const textNodeWithOffset = scanningContext.textNodeWithOffset;
79
+ function exportTopLevelElements(node, elementTransformers, textTransformersIndex, textMatchTransformers) {
80
+ for (const transformer of elementTransformers) {
81
+ const result = transformer.export(node, _node => exportChildren(_node, textTransformersIndex, textMatchTransformers));
295
82
 
296
- if (textNodeWithOffset == null) {
297
- {
298
- throw Error(`Expect to have a text node with offset.`);
83
+ if (result != null) {
84
+ return result;
299
85
  }
300
86
  }
301
87
 
302
- return textNodeWithOffset;
88
+ return lexical.$isElementNode(node) ? exportChildren(node, textTransformersIndex, textMatchTransformers) : null;
303
89
  }
304
90
 
305
- function getPatternMatchResultsForParagraphs(markdownCriteria, scanningContext) {
306
- const textNodeWithOffset = getTextNodeWithOffsetOrThrow(scanningContext); // At start of paragraph.
307
-
308
- if (textNodeWithOffset.node.getPreviousSibling() === null) {
309
- const textToSearch = textNodeWithOffset.node.getTextContent();
310
- return getPatternMatchResultsWithRegEx(textToSearch, true, false, scanningContext.isAutoFormatting ? markdownCriteria.regExForAutoFormatting : markdownCriteria.regEx);
311
- }
91
+ function exportChildren(node, textTransformersIndex, textMatchTransformers) {
92
+ const output = [];
93
+ const children = node.getChildren();
312
94
 
313
- return null;
314
- }
95
+ mainLoop: for (const child of children) {
96
+ if (lexical.$isLineBreakNode(child)) {
97
+ output.push('\n');
98
+ } else if (lexical.$isTextNode(child)) {
99
+ output.push(exportTextFormat(child, child.getTextContent(), textTransformersIndex));
100
+ } else {
101
+ for (const transformer of textMatchTransformers) {
102
+ const result = transformer.export(child, parentNode => exportChildren(parentNode, textTransformersIndex, textMatchTransformers), (textNode, textContent) => exportTextFormat(textNode, textContent, textTransformersIndex));
315
103
 
316
- function getPatternMatchResultsForText(markdownCriteria, scanningContext, parentElementNode) {
317
- if (scanningContext.joinedText == null) {
318
- if (lexical.$isElementNode(parentElementNode)) {
319
- if (scanningContext.joinedText == null) {
320
- // Lazy calculate the text to search.
321
- scanningContext.joinedText = text.$joinTextNodesInElementNode(parentElementNode, SEPARATOR_BETWEEN_TEXT_AND_NON_TEXT_NODES, getTextNodeWithOffsetOrThrow(scanningContext));
104
+ if (result != null) {
105
+ output.push(result);
106
+ continue mainLoop;
107
+ }
322
108
  }
323
- } else {
324
- {
325
- throw Error(`Expected node ${parentElementNode.__key} to to be a ElementNode.`);
109
+
110
+ if (lexical.$isElementNode(child)) {
111
+ output.push(exportChildren(child, textTransformersIndex, textMatchTransformers));
326
112
  }
327
113
  }
328
114
  }
329
115
 
330
- const matchMustAppearAtEndOfString = markdownCriteria.regExForAutoFormatting === true;
331
- return getPatternMatchResultsWithRegEx(scanningContext.joinedText, false, matchMustAppearAtEndOfString, scanningContext.isAutoFormatting ? markdownCriteria.regExForAutoFormatting : markdownCriteria.regEx);
116
+ return output.join('');
332
117
  }
333
118
 
334
- function getNewNodeForCriteria(scanningContext, element, createHorizontalRuleNode) {
335
- let newNode = null;
336
- const shouldDelete = false;
337
- const children = element.getChildren();
338
- const markdownCriteria = scanningContext.markdownCriteria;
339
- const patternMatchResults = scanningContext.patternMatchResults;
340
-
341
- if (markdownCriteria.markdownFormatKind != null) {
342
- switch (markdownCriteria.markdownFormatKind) {
343
- case 'paragraphH1':
344
- {
345
- newNode = richText.$createHeadingNode('h1');
346
- newNode.append(...children);
347
- return {
348
- newNode,
349
- shouldDelete
350
- };
351
- }
119
+ function exportTextFormat(node, textContent, textTransformers) {
120
+ let output = textContent;
121
+ const applied = new Set();
352
122
 
353
- case 'paragraphH2':
354
- {
355
- newNode = richText.$createHeadingNode('h2');
356
- newNode.append(...children);
357
- return {
358
- newNode,
359
- shouldDelete
360
- };
361
- }
123
+ for (const transformer of textTransformers) {
124
+ const format = transformer.format[0];
125
+ const tag = transformer.tag;
362
126
 
363
- case 'paragraphH3':
364
- {
365
- newNode = richText.$createHeadingNode('h3');
366
- newNode.append(...children);
367
- return {
368
- newNode,
369
- shouldDelete
370
- };
371
- }
127
+ if (hasFormat(node, format) && !applied.has(format)) {
128
+ // Multiple tags might be used for the same format (*, _)
129
+ applied.add(format); // Prevent adding opening tag is already opened by the previous sibling
372
130
 
373
- case 'paragraphH4':
374
- {
375
- newNode = richText.$createHeadingNode('h4');
376
- newNode.append(...children);
377
- return {
378
- newNode,
379
- shouldDelete
380
- };
381
- }
131
+ const previousNode = getTextSibling(node, true);
382
132
 
383
- case 'paragraphH5':
384
- {
385
- newNode = richText.$createHeadingNode('h5');
386
- newNode.append(...children);
387
- return {
388
- newNode,
389
- shouldDelete
390
- };
391
- }
133
+ if (!hasFormat(previousNode, format)) {
134
+ output = tag + output;
135
+ } // Prevent adding closing tag if next sibling will do it
392
136
 
393
- case 'paragraphBlockQuote':
394
- {
395
- newNode = richText.$createQuoteNode();
396
- newNode.append(...children);
397
- return {
398
- newNode,
399
- shouldDelete
400
- };
401
- }
402
137
 
403
- case 'paragraphUnorderedList':
404
- {
405
- createListOrMergeWithPrevious(element, children, patternMatchResults, 'ul');
406
- return {
407
- newNode: null,
408
- shouldDelete: false
409
- };
410
- }
138
+ const nextNode = getTextSibling(node, false);
411
139
 
412
- case 'paragraphOrderedList':
413
- {
414
- const startAsString = patternMatchResults.regExCaptureGroups.length > 1 ? patternMatchResults.regExCaptureGroups[patternMatchResults.regExCaptureGroups.length - 1].text : '1'; // For conversion, don't use start number.
415
- // For short-cuts aka autoFormatting, use start number.
416
- // Later, this should be surface dependent and externalized.
417
-
418
- const start = scanningContext.isAutoFormatting ? parseInt(startAsString, 10) : undefined;
419
- createListOrMergeWithPrevious(element, children, patternMatchResults, 'ol', start);
420
- return {
421
- newNode: null,
422
- shouldDelete: false
423
- };
424
- }
140
+ if (!hasFormat(nextNode, format)) {
141
+ output += tag;
142
+ }
143
+ }
144
+ }
425
145
 
426
- case 'paragraphCodeBlock':
427
- {
428
- // Toggle code and paragraph nodes.
429
- if (scanningContext.isAutoFormatting === false) {
430
- const shouldToggle = hasPatternMatchResults(scanningContext);
431
-
432
- if (shouldToggle) {
433
- scanningContext.isWithinCodeBlock = scanningContext.isWithinCodeBlock !== true; // When toggling, always clear the code block element node.
434
-
435
- scanningContext.currentElementNode = null;
436
- return {
437
- newNode: null,
438
- shouldDelete: true
439
- };
440
- }
441
-
442
- if (scanningContext.isWithinCodeBlock) {
443
- // Create the code block and return it to the caller.
444
- if (scanningContext.currentElementNode == null) {
445
- const newCodeBlockNode = code.$createCodeNode();
446
- newCodeBlockNode.append(...children);
447
- scanningContext.currentElementNode = newCodeBlockNode;
448
- return {
449
- newNode: newCodeBlockNode,
450
- shouldDelete: false
451
- };
452
- } // Build up the code block with a line break and the children.
453
-
454
-
455
- if (scanningContext.currentElementNode != null) {
456
- const codeBlockNode = scanningContext.currentElementNode;
457
- const lineBreakNode = lexical.$createLineBreakNode();
458
- codeBlockNode.append(lineBreakNode);
459
-
460
- if (children.length) {
461
- codeBlockNode.append(lineBreakNode);
462
- }
463
-
464
- codeBlockNode.append(...children);
465
- }
466
- }
467
-
468
- return {
469
- newNode: null,
470
- shouldDelete: true
471
- };
472
- }
473
-
474
- if (scanningContext.triggerState != null && scanningContext.triggerState.isCodeBlock) {
475
- newNode = lexical.$createParagraphNode();
476
- } else {
477
- newNode = code.$createCodeNode();
478
- const codingLanguage = patternMatchResults.regExCaptureGroups.length >= 3 ? patternMatchResults.regExCaptureGroups[2].text : null;
479
-
480
- if (codingLanguage != null && codingLanguage.length > 0) {
481
- newNode.setLanguage(codingLanguage);
482
- }
483
- }
484
-
485
- newNode.append(...children);
486
- return {
487
- newNode,
488
- shouldDelete
489
- };
490
- }
146
+ return output;
147
+ } // Get next or previous text sibling a text node, including cases
148
+ // when it's a child of inline element (e.g. link)
491
149
 
492
- case 'horizontalRule':
493
- {
494
- if (createHorizontalRuleNode != null) {
495
- // return null for newNode. Insert the HR here.
496
- const horizontalRuleNode = createHorizontalRuleNode();
497
- element.insertBefore(horizontalRuleNode);
498
- }
499
150
 
500
- break;
501
- }
151
+ function getTextSibling(node, backward) {
152
+ let sibling = backward ? node.getPreviousSibling() : node.getNextSibling();
153
+
154
+ if (!sibling) {
155
+ const parent = node.getParentOrThrow();
156
+
157
+ if (parent.isInline()) {
158
+ sibling = backward ? parent.getPreviousSibling() : parent.getNextSibling();
502
159
  }
503
160
  }
504
161
 
505
- return {
506
- newNode,
507
- shouldDelete
508
- };
509
- }
162
+ while (sibling) {
163
+ if (lexical.$isElementNode(sibling)) {
164
+ if (!sibling.isInline()) {
165
+ break;
166
+ }
510
167
 
511
- function createListOrMergeWithPrevious(element, children, patternMatchResults, tag, start) {
512
- const listItem = list.$createListItemNode();
513
- const indentMatch = patternMatchResults.regExCaptureGroups[0].text.match(/^\s*/);
514
- const indent = indentMatch ? Math.floor(indentMatch[0].length / 4) : 0;
515
- listItem.append(...children); // Checking if previous element is a list, and if so append
516
- // new list item inside instead of creating new list
168
+ const descendant = backward ? sibling.getLastDescendant() : sibling.getFirstDescendant();
517
169
 
518
- const prevElement = element.getPreviousSibling();
170
+ if (lexical.$isTextNode(descendant)) {
171
+ return descendant;
172
+ } else {
173
+ sibling = backward ? sibling.getPreviousSibling() : sibling.getNextSibling();
174
+ }
175
+ }
519
176
 
520
- if (list.$isListNode(prevElement) && prevElement.getTag() === tag) {
521
- prevElement.append(listItem);
522
- element.remove();
523
- } else {
524
- const list$1 = list.$createListNode(tag, start);
525
- list$1.append(listItem);
526
- element.replace(list$1);
177
+ if (lexical.$isTextNode(sibling)) {
178
+ return sibling;
179
+ }
527
180
  }
528
181
 
529
- if (indent) {
530
- listItem.setIndent(indent);
531
- }
182
+ return null;
532
183
  }
533
184
 
534
- function transformTextNodeForMarkdownCriteria(scanningContext, elementNode, createHorizontalRuleNode) {
535
- if (scanningContext.markdownCriteria.requiresParagraphStart === true) {
536
- transformTextNodeForElementNode(elementNode, scanningContext, createHorizontalRuleNode);
537
- } else {
538
- transformTextNodeForText(scanningContext, elementNode);
539
- }
185
+ function hasFormat(node, format) {
186
+ return lexical.$isTextNode(node) && node.hasFormat(format);
540
187
  }
541
188
 
542
- function transformTextNodeForElementNode(elementNode, scanningContext, createHorizontalRuleNode) {
543
- if (scanningContext.textNodeWithOffset != null) {
544
- const textNodeWithOffset = getTextNodeWithOffsetOrThrow(scanningContext);
189
+ /**
190
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
191
+ *
192
+ * This source code is licensed under the MIT license found in the
193
+ * LICENSE file in the root directory of this source tree.
194
+ *
195
+ *
196
+ */
197
+ const CODE_BLOCK_REG_EXP = /^```(\w{1,10})?\s?$/;
198
+ function createMarkdownImport(transformers) {
199
+ const byType = transformersByType(transformers);
200
+ const textFormatTransformersIndex = createTextFormatTransformersIndex(byType.textFormat);
201
+ return markdownString => {
202
+ const lines = markdownString.split('\n');
203
+ const linesLength = lines.length;
204
+ const root = lexical.$getRoot();
205
+ root.clear();
545
206
 
546
- if (hasPatternMatchResults(scanningContext)) {
547
- const text = scanningContext.patternMatchResults.regExCaptureGroups[0].text; // Remove the text which we matched.
207
+ for (let i = 0; i < linesLength; i++) {
208
+ const lineText = lines[i]; // Codeblocks are processed first as anything inside such block
209
+ // is ignored for further processing
210
+ // TODO:
211
+ // Abstract it to be dynamic as other transformers (add multiline match option)
548
212
 
549
- const textNode = textNodeWithOffset.node.spliceText(0, text.length, '', true);
213
+ const [codeBlockNode, shiftedIndex] = importCodeBlock(lines, i, root);
550
214
 
551
- if (textNode.getTextContent() === '') {
552
- textNode.selectPrevious();
553
- textNode.remove();
215
+ if (codeBlockNode != null) {
216
+ i = shiftedIndex;
217
+ continue;
554
218
  }
555
- }
556
- } // Transform the current element kind to the new element kind.
557
219
 
220
+ importBlocks(lineText, root, byType.element, textFormatTransformersIndex, byType.textMatch);
221
+ }
558
222
 
559
- const {
560
- newNode,
561
- shouldDelete
562
- } = getNewNodeForCriteria(scanningContext, elementNode, createHorizontalRuleNode);
563
-
564
- if (shouldDelete) {
565
- elementNode.remove();
566
- } else if (newNode !== null) {
567
- elementNode.replace(newNode);
568
- }
223
+ root.selectEnd();
224
+ };
569
225
  }
570
226
 
571
- function transformTextNodeForText(scanningContext, parentElementNode) {
572
- const markdownCriteria = scanningContext.markdownCriteria;
227
+ function importBlocks(lineText, rootNode, elementTransformers, textFormatTransformersIndex, textMatchTransformers) {
228
+ const textNode = lexical.$createTextNode(lineText);
229
+ const elementNode = lexical.$createParagraphNode();
230
+ elementNode.append(textNode);
231
+ rootNode.append(elementNode);
573
232
 
574
- if (markdownCriteria.markdownFormatKind != null) {
575
- const formatting = getTextFormatType(markdownCriteria.markdownFormatKind);
233
+ for (const {
234
+ regExp,
235
+ replace
236
+ } of elementTransformers) {
237
+ const match = lineText.match(regExp);
576
238
 
577
- if (formatting != null) {
578
- transformTextNodeWithFormatting(formatting, scanningContext, parentElementNode);
579
- return;
580
- }
581
-
582
- if (markdownCriteria.markdownFormatKind === 'link') {
583
- transformTextNodeWithLink(scanningContext, parentElementNode);
239
+ if (match) {
240
+ textNode.setTextContent(lineText.slice(match[0].length));
241
+ replace(elementNode, [textNode], match, true);
242
+ break;
584
243
  }
585
244
  }
586
- }
587
245
 
588
- function transformTextNodeWithFormatting(formatting, scanningContext, parentElementNode) {
589
- const patternMatchResults = scanningContext.patternMatchResults;
590
- const groupCount = patternMatchResults.regExCaptureGroups.length;
591
-
592
- if (groupCount !== 7) {
593
- // For BIUS and similar formats which have a pattern + text + pattern:
594
- // given '*italic* ' below are the capture groups by index:
595
- // 0. '*italic* '
596
- // 1. '*'
597
- // 2. whitespace // typically this is "".
598
- // 3. 'italic'
599
- // 4. whitespace // typicallly this is "".
600
- // 5. '*'
601
- // 6. ' '
602
- return;
603
- } // Remove unwanted text in reg ex pattern.
604
- // Remove group 5.
605
-
606
-
607
- removeTextByCaptureGroups(5, 5, scanningContext, parentElementNode); // Remove group 1.
246
+ importTextFormatTransformers(textNode, textFormatTransformersIndex, textMatchTransformers);
247
+ }
608
248
 
609
- removeTextByCaptureGroups(1, 1, scanningContext, parentElementNode); // Apply the formatting.
249
+ function importCodeBlock(lines, startLineIndex, rootNode) {
250
+ const openMatch = lines[startLineIndex].match(CODE_BLOCK_REG_EXP);
610
251
 
611
- formatTextInCaptureGroupIndex(formatting, 3, scanningContext, parentElementNode); // Place caret at end of final capture group.
252
+ if (openMatch) {
253
+ let endLineIndex = startLineIndex;
254
+ const linesLength = lines.length;
612
255
 
613
- selectAfterFinalCaptureGroup(scanningContext, parentElementNode);
614
- }
256
+ while (++endLineIndex < linesLength) {
257
+ const closeMatch = lines[endLineIndex].match(CODE_BLOCK_REG_EXP);
615
258
 
616
- function transformTextNodeWithLink(scanningContext, parentElementNode) {
617
- const patternMatchResults = scanningContext.patternMatchResults;
618
- const regExCaptureGroups = patternMatchResults.regExCaptureGroups;
619
- const groupCount = regExCaptureGroups.length;
620
-
621
- if (groupCount !== 7) {
622
- // For links and similar formats which have: pattern + text + pattern + pattern2 text2 + pattern2:
623
- // Given '[title](url) ', below are the capture groups by index:
624
- // 0. '[title](url) '
625
- // 1. '['
626
- // 2. 'title'
627
- // 3. ']('
628
- // 4. 'url'
629
- // 5. ')'
630
- // 6. ' '
631
- return;
259
+ if (closeMatch) {
260
+ const codeBlockNode = code.$createCodeNode(openMatch[1]);
261
+ const textNode = lexical.$createTextNode(lines.slice(startLineIndex + 1, endLineIndex).join('\n'));
262
+ codeBlockNode.append(textNode);
263
+ rootNode.append(codeBlockNode);
264
+ return [codeBlockNode, endLineIndex];
265
+ }
266
+ }
632
267
  }
633
268
 
634
- const title = regExCaptureGroups[2].text;
635
- const url = regExCaptureGroups[4].text;
636
-
637
- if (title.length === 0 || url.length === 0) {
638
- return;
639
- } // Remove the initial pattern through to the final pattern.
269
+ return [null, startLineIndex];
270
+ } // Processing text content and replaces text format tags.
271
+ // It takes outermost tag match and its content, creates text node with
272
+ // format based on tag and then recursively executed over node's content
273
+ //
274
+ // E.g. for "*Hello **world**!*" string it will create text node with
275
+ // "Hello **world**!" content and italic format and run recursively over
276
+ // its content to transform "**world**" part
640
277
 
641
278
 
642
- removeTextByCaptureGroups(1, 5, scanningContext, parentElementNode);
643
- insertTextPriorToCaptureGroup(1, // Insert at the beginning of the meaningful capture groups, namely index 1. Index 0 refers to the whole matched string.
644
- title, scanningContext, parentElementNode);
645
- const newSelectionForLink = createSelectionWithCaptureGroups(1, 1, false, true, scanningContext, parentElementNode);
279
+ function importTextFormatTransformers(textNode, textFormatTransformersIndex, textMatchTransformers) {
280
+ const textContent = textNode.getTextContent();
281
+ const match = findOutermostMatch(textContent, textFormatTransformersIndex);
646
282
 
647
- if (newSelectionForLink == null) {
283
+ if (!match) {
284
+ // Once text format processing is done run text match transformers, as it
285
+ // only can span within single text node (unline formats that can cover multiple nodes)
286
+ importTextMatchTransformers(textNode, textMatchTransformers);
648
287
  return;
649
288
  }
650
289
 
651
- lexical.$setSelection(newSelectionForLink);
652
- scanningContext.editor.dispatchCommand(link.TOGGLE_LINK_COMMAND, url); // Place caret at end of final capture group.
290
+ let currentNode, remainderNode; // If matching full content there's no need to run splitText and can reuse existing textNode
291
+ // to update its content and apply format. E.g. for **_Hello_** string after applying bold
292
+ // format (**) it will reuse the same text node to apply italic (_)
653
293
 
654
- selectAfterFinalCaptureGroup(scanningContext, parentElementNode);
655
- } // Below are lower level helper functions.
656
-
657
-
658
- function getParentElementNodeOrThrow(scanningContext) {
659
- return getTextNodeWithOffsetOrThrow(scanningContext).node.getParentOrThrow();
660
- }
661
-
662
- function getJoinedTextLength(patternMatchResults) {
663
- const groupCount = patternMatchResults.regExCaptureGroups.length;
294
+ if (match[0] === textContent) {
295
+ currentNode = textNode;
296
+ } else {
297
+ const startIndex = match.index;
298
+ const endIndex = startIndex + match[0].length;
664
299
 
665
- if (groupCount < 2) {
666
- // Ignore capture group 0, as regEx defaults the 0th one to the entire matched string.
667
- return 0;
300
+ if (startIndex === 0) {
301
+ [currentNode, remainderNode] = textNode.splitText(endIndex);
302
+ } else {
303
+ [, currentNode, remainderNode] = textNode.splitText(startIndex, endIndex);
304
+ }
668
305
  }
669
306
 
670
- const lastGroupIndex = groupCount - 1;
671
- return patternMatchResults.regExCaptureGroups[lastGroupIndex].offsetInParent + patternMatchResults.regExCaptureGroups[lastGroupIndex].text.length;
672
- }
307
+ currentNode.setTextContent(match[2]);
308
+ const transformer = textFormatTransformersIndex.transformersByTag[match[1]];
673
309
 
674
- function getTextFormatType(markdownFormatKind) {
675
- switch (markdownFormatKind) {
676
- case 'italic':
677
- case 'bold':
678
- case 'underline':
679
- case 'strikethrough':
680
- case 'code':
681
- return [markdownFormatKind];
682
-
683
- case 'strikethrough_italic_bold':
684
- {
685
- return ['strikethrough', 'italic', 'bold'];
310
+ if (transformer) {
311
+ for (const format of transformer.format) {
312
+ if (!currentNode.hasFormat(format)) {
313
+ currentNode.toggleFormat(format);
686
314
  }
315
+ }
316
+ } // Recursively run over inner text if it's not inline code
687
317
 
688
- case 'italic_bold':
689
- {
690
- return ['italic', 'bold'];
691
- }
692
-
693
- case 'strikethrough_italic':
694
- {
695
- return ['strikethrough', 'italic'];
696
- }
697
-
698
- case 'strikethrough_bold':
699
- {
700
- return ['strikethrough', 'bold'];
701
- }
702
- }
703
-
704
- return null;
705
- }
706
318
 
707
- function createSelectionWithCaptureGroups(anchorCaptureGroupIndex, focusCaptureGroupIndex, startAtEndOfAnchor, finishAtEndOfFocus, scanningContext, parentElementNode) {
708
- const patternMatchResults = scanningContext.patternMatchResults;
709
- const regExCaptureGroups = patternMatchResults.regExCaptureGroups;
710
- const regExCaptureGroupsCount = regExCaptureGroups.length;
319
+ if (!currentNode.hasFormat('code')) {
320
+ importTextFormatTransformers(currentNode, textFormatTransformersIndex, textMatchTransformers);
321
+ } // Run over remaining text if any
711
322
 
712
- if (anchorCaptureGroupIndex >= regExCaptureGroupsCount || focusCaptureGroupIndex >= regExCaptureGroupsCount) {
713
- return null;
714
- }
715
323
 
716
- const joinedTextLength = getJoinedTextLength(patternMatchResults);
717
- const anchorCaptureGroupDetail = regExCaptureGroups[anchorCaptureGroupIndex];
718
- const focusCaptureGroupDetail = regExCaptureGroups[focusCaptureGroupIndex];
719
- const anchorLocation = startAtEndOfAnchor ? anchorCaptureGroupDetail.offsetInParent + anchorCaptureGroupDetail.text.length : anchorCaptureGroupDetail.offsetInParent;
720
- const focusLocation = finishAtEndOfFocus ? focusCaptureGroupDetail.offsetInParent + focusCaptureGroupDetail.text.length : focusCaptureGroupDetail.offsetInParent;
721
- const anchorTextNodeWithOffset = text.$findNodeWithOffsetFromJoinedText(anchorLocation, joinedTextLength, SEPARATOR_LENGTH, parentElementNode);
722
- const focusTextNodeWithOffset = text.$findNodeWithOffsetFromJoinedText(focusLocation, joinedTextLength, SEPARATOR_LENGTH, parentElementNode);
723
-
724
- if (anchorTextNodeWithOffset == null && focusTextNodeWithOffset == null && parentElementNode.getChildren().length === 0) {
725
- const emptyElementSelection = lexical.$createRangeSelection();
726
- emptyElementSelection.anchor.set(parentElementNode.getKey(), 0, 'element');
727
- emptyElementSelection.focus.set(parentElementNode.getKey(), 0, 'element');
728
- return emptyElementSelection;
324
+ if (remainderNode) {
325
+ importTextFormatTransformers(remainderNode, textFormatTransformersIndex, textMatchTransformers);
729
326
  }
730
-
731
- if (anchorTextNodeWithOffset == null || focusTextNodeWithOffset == null) {
732
- return null;
733
- }
734
-
735
- const selection = lexical.$createRangeSelection();
736
- selection.anchor.set(anchorTextNodeWithOffset.node.getKey(), anchorTextNodeWithOffset.offset, 'text');
737
- selection.focus.set(focusTextNodeWithOffset.node.getKey(), focusTextNodeWithOffset.offset, 'text');
738
- return selection;
739
327
  }
740
328
 
741
- function removeTextByCaptureGroups(anchorCaptureGroupIndex, focusCaptureGroupIndex, scanningContext, parentElementNode) {
742
- const patternMatchResults = scanningContext.patternMatchResults;
743
- const regExCaptureGroups = patternMatchResults.regExCaptureGroups;
744
- const newSelection = createSelectionWithCaptureGroups(anchorCaptureGroupIndex, focusCaptureGroupIndex, false, true, scanningContext, parentElementNode);
745
-
746
- if (newSelection != null) {
747
- lexical.$setSelection(newSelection);
748
- const currentSelection = lexical.$getSelection();
749
-
750
- if (currentSelection != null && lexical.$isRangeSelection(currentSelection) && currentSelection.isCollapsed() === false) {
751
- currentSelection.removeText(); // Shift all group offsets and clear out group text.
329
+ function importTextMatchTransformers(textNode_, textMatchTransformers) {
330
+ let textNode = textNode_;
752
331
 
753
- let runningLength = 0;
754
- const groupCount = regExCaptureGroups.length;
332
+ mainLoop: while (textNode) {
333
+ for (const transformer of textMatchTransformers) {
334
+ const match = textNode.getTextContent().match(transformer.importRegExp);
755
335
 
756
- for (let i = anchorCaptureGroupIndex; i < groupCount; i++) {
757
- const captureGroupDetail = regExCaptureGroups[i];
336
+ if (!match) {
337
+ continue;
338
+ }
758
339
 
759
- if (i > anchorCaptureGroupIndex) {
760
- captureGroupDetail.offsetInParent -= runningLength;
761
- }
340
+ const startIndex = match.index;
341
+ const endIndex = startIndex + match[0].length;
342
+ let replaceNode;
762
343
 
763
- if (i <= focusCaptureGroupIndex) {
764
- runningLength += captureGroupDetail.text.length;
765
- captureGroupDetail.text = '';
766
- }
344
+ if (startIndex === 0) {
345
+ [replaceNode, textNode] = textNode.splitText(endIndex);
346
+ } else {
347
+ [, replaceNode, textNode] = textNode.splitText(startIndex, endIndex);
767
348
  }
768
- }
769
- }
770
- }
771
349
 
772
- function insertTextPriorToCaptureGroup(captureGroupIndex, text, scanningContext, parentElementNode) {
773
- const patternMatchResults = scanningContext.patternMatchResults;
774
- const regExCaptureGroups = patternMatchResults.regExCaptureGroups;
775
- const regExCaptureGroupsCount = regExCaptureGroups.length;
350
+ transformer.replace(replaceNode, match);
351
+ continue mainLoop;
352
+ }
776
353
 
777
- if (captureGroupIndex >= regExCaptureGroupsCount) {
778
- return;
354
+ break;
779
355
  }
356
+ } // Finds first "<tag>content<tag>" match that is not nested into another tag
780
357
 
781
- const captureGroupDetail = regExCaptureGroups[captureGroupIndex];
782
- const newCaptureGroupDetail = {
783
- offsetInParent: captureGroupDetail.offsetInParent,
784
- text
785
- };
786
- const newSelection = createSelectionWithCaptureGroups(captureGroupIndex, captureGroupIndex, false, false, scanningContext, parentElementNode);
787
-
788
- if (newSelection != null) {
789
- lexical.$setSelection(newSelection);
790
- const currentSelection = lexical.$getSelection();
791
-
792
- if (currentSelection != null && lexical.$isRangeSelection(currentSelection) && currentSelection.isCollapsed()) {
793
- currentSelection.insertText(newCaptureGroupDetail.text); // Update the capture groups.
794
-
795
- regExCaptureGroups.splice(captureGroupIndex, 0, newCaptureGroupDetail);
796
- const textLength = newCaptureGroupDetail.text.length;
797
- const newGroupCount = regExCaptureGroups.length;
798
-
799
- for (let i = captureGroupIndex + 1; i < newGroupCount; i++) {
800
- const currentCaptureGroupDetail = regExCaptureGroups[i];
801
- currentCaptureGroupDetail.offsetInParent += textLength;
802
- }
803
- }
804
- }
805
- }
806
358
 
807
- function formatTextInCaptureGroupIndex(formatTypes, captureGroupIndex, scanningContext, parentElementNode) {
808
- const patternMatchResults = scanningContext.patternMatchResults;
809
- const regExCaptureGroups = patternMatchResults.regExCaptureGroups;
810
- const regExCaptureGroupsCount = regExCaptureGroups.length;
359
+ function findOutermostMatch(textContent, textTransformersIndex) {
360
+ const openTagsMatch = textContent.match(textTransformersIndex.openTagsRegExp);
811
361
 
812
- if (!(captureGroupIndex < regExCaptureGroupsCount)) {
813
- throw Error(`The capture group count in the RegEx does match the actual capture group count.`);
362
+ if (openTagsMatch == null) {
363
+ return null;
814
364
  }
815
365
 
816
- const captureGroupDetail = regExCaptureGroups[captureGroupIndex];
817
-
818
- if (captureGroupDetail.text.length === 0) {
819
- return;
820
- }
366
+ for (const match of openTagsMatch) {
367
+ // Open tags reg exp might capture leading space so removing it
368
+ // before using match to find transformer
369
+ const fullMatchRegExp = textTransformersIndex.fullMatchRegExpByTag[match.replace(/^\s/, '')];
821
370
 
822
- const newSelection = createSelectionWithCaptureGroups(captureGroupIndex, captureGroupIndex, false, true, scanningContext, parentElementNode);
371
+ if (fullMatchRegExp == null) {
372
+ continue;
373
+ }
823
374
 
824
- if (newSelection != null) {
825
- lexical.$setSelection(newSelection);
826
- const currentSelection = lexical.$getSelection();
375
+ const fullMatch = textContent.match(fullMatchRegExp);
827
376
 
828
- if (lexical.$isRangeSelection(currentSelection)) {
829
- for (let i = 0; i < formatTypes.length; i++) {
830
- currentSelection.formatText(formatTypes[i]);
831
- }
377
+ if (fullMatch != null) {
378
+ return fullMatch;
832
379
  }
833
380
  }
834
- } // Place caret at end of final capture group.
835
381
 
382
+ return null;
383
+ }
836
384
 
837
- function selectAfterFinalCaptureGroup(scanningContext, parentElementNode) {
838
- const patternMatchResults = scanningContext.patternMatchResults;
839
- const groupCount = patternMatchResults.regExCaptureGroups.length;
385
+ function createTextFormatTransformersIndex(textTransformers) {
386
+ const transformersByTag = {};
387
+ const fullMatchRegExpByTag = {};
388
+ const openTagsRegExp = [];
840
389
 
841
- if (groupCount < 2) {
842
- // Ignore capture group 0, as regEx defaults the 0th one to the entire matched string.
843
- return;
390
+ for (const transformer of textTransformers) {
391
+ const {
392
+ tag
393
+ } = transformer;
394
+ transformersByTag[tag] = transformer;
395
+ const tagRegExp = tag.replace(/(\*|\^)/g, '\\$1');
396
+ openTagsRegExp.push(tagRegExp);
397
+ fullMatchRegExpByTag[tag] = new RegExp(`(${tagRegExp})(?![${tagRegExp}\\s])(.*?[^${tagRegExp}\\s])${tagRegExp}(?!${tagRegExp})`);
844
398
  }
845
399
 
846
- const lastGroupIndex = groupCount - 1;
847
- const newSelection = createSelectionWithCaptureGroups(lastGroupIndex, lastGroupIndex, true, true, scanningContext, parentElementNode);
848
-
849
- if (newSelection != null) {
850
- lexical.$setSelection(newSelection);
851
- }
400
+ return {
401
+ // Reg exp to find open tag + content + close tag
402
+ fullMatchRegExpByTag,
403
+ // Reg exp to find opening tags
404
+ openTagsRegExp: new RegExp('(' + openTagsRegExp.join('|') + ')', 'g'),
405
+ transformersByTag
406
+ };
852
407
  }
853
408
 
854
409
  /**
@@ -860,385 +415,483 @@ function selectAfterFinalCaptureGroup(scanningContext, parentElementNode) {
860
415
  *
861
416
  */
862
417
 
863
- function getTextNodeForAutoFormatting(selection) {
864
- if (!lexical.$isRangeSelection(selection)) {
865
- return null;
418
+ function runElementTransformers(parentNode, anchorNode, anchorOffset, elementTransformers) {
419
+ const grandParentNode = parentNode.getParent();
420
+
421
+ if (!lexical.$isRootNode(grandParentNode) || parentNode.getFirstChild() !== anchorNode) {
422
+ return false;
866
423
  }
867
424
 
868
- const node = selection.anchor.getNode();
425
+ const textContent = anchorNode.getTextContent(); // Checking for anchorOffset position to prevent any checks for cases when caret is too far
426
+ // from a line start to be a part of block-level markdown trigger.
427
+ //
428
+ // TODO:
429
+ // Can have a quick check if caret is close enough to the beginning of the string (e.g. offset less than 10-20)
430
+ // since otherwise it won't be a markdown shortcut, but tables are exception
869
431
 
870
- if (!lexical.$isTextNode(node)) {
871
- return null;
432
+ if (textContent[anchorOffset - 1] !== ' ') {
433
+ return false;
872
434
  }
873
435
 
874
- return {
875
- node,
876
- offset: selection.anchor.offset
877
- };
878
- }
436
+ for (const {
437
+ regExp,
438
+ replace
439
+ } of elementTransformers) {
440
+ const match = textContent.match(regExp);
879
441
 
880
- function updateAutoFormatting(editor, scanningContext, createHorizontalRuleNode) {
881
- editor.update(() => {
882
- const elementNode = getTextNodeWithOffsetOrThrow(scanningContext).node.getParentOrThrow();
883
- transformTextNodeForMarkdownCriteria(scanningContext, elementNode, createHorizontalRuleNode);
884
- }, {
885
- tag: 'history-push'
886
- });
442
+ if (match && match[0].length === anchorOffset) {
443
+ const nextSiblings = anchorNode.getNextSiblings();
444
+ const [leadingNode, remainderNode] = anchorNode.splitText(anchorOffset);
445
+ leadingNode.remove();
446
+ const siblings = remainderNode ? [remainderNode, ...nextSiblings] : nextSiblings;
447
+ replace(parentNode, siblings, match, false);
448
+ return true;
449
+ }
450
+ }
451
+
452
+ return false;
887
453
  }
888
454
 
889
- function getCriteriaWithPatternMatchResults(markdownCriteriaArray, scanningContext) {
890
- const currentTriggerState = scanningContext.triggerState;
891
- const count = markdownCriteriaArray.length;
455
+ function runTextMatchTransformers(anchorNode, anchorOffset, transformersByTrigger) {
456
+ let textContent = anchorNode.getTextContent();
457
+ const lastChar = textContent[anchorOffset - 1];
458
+ const transformers = transformersByTrigger[lastChar];
892
459
 
893
- for (let i = 0; i < count; i++) {
894
- const markdownCriteria = markdownCriteriaArray[i]; // Skip code block nodes, unless the autoFormatKind calls for toggling the code block.
460
+ if (transformers == null) {
461
+ return false;
462
+ } // If typing in the middle of content, remove the tail to do
463
+ // reg exp match up to a string end (caret position)
895
464
 
896
- if (currentTriggerState != null && currentTriggerState.isCodeBlock === false || markdownCriteria.markdownFormatKind === 'paragraphCodeBlock') {
897
- const patternMatchResults = getPatternMatchResultsForCriteria(markdownCriteria, scanningContext, getParentElementNodeOrThrow(scanningContext));
898
465
 
899
- if (patternMatchResults != null) {
900
- return {
901
- markdownCriteria,
902
- patternMatchResults
903
- };
904
- }
905
- }
466
+ if (anchorOffset < textContent.length) {
467
+ textContent = textContent.slice(0, anchorOffset);
906
468
  }
907
469
 
908
- return {
909
- markdownCriteria: null,
910
- patternMatchResults: null
911
- };
912
- }
913
-
914
- function findScanningContextWithValidMatch(editor, currentTriggerState) {
915
- let scanningContext = null;
916
- editor.getEditorState().read(() => {
917
- const textNodeWithOffset = getTextNodeForAutoFormatting(lexical.$getSelection());
918
-
919
- if (textNodeWithOffset === null) {
920
- return;
921
- } // Please see the declaration of ScanningContext for a detailed explanation.
470
+ for (const transformer of transformers) {
471
+ const match = textContent.match(transformer.regExp);
922
472
 
473
+ if (match === null) {
474
+ continue;
475
+ }
923
476
 
924
- const initialScanningContext = getInitialScanningContext(editor, true, textNodeWithOffset, currentTriggerState);
925
- const criteriaWithPatternMatchResults = getCriteriaWithPatternMatchResults( // Do not apply paragraph node changes like blockQuote or H1 to listNodes. Also, do not attempt to transform a list into a list using * or -.
926
- currentTriggerState.isParentAListItemNode === false ? getAllMarkdownCriteria() : getAllMarkdownCriteriaForTextNodes(), initialScanningContext);
477
+ const startIndex = match.index;
478
+ const endIndex = startIndex + match[0].length;
479
+ let replaceNode;
927
480
 
928
- if (criteriaWithPatternMatchResults.markdownCriteria === null || criteriaWithPatternMatchResults.patternMatchResults === null) {
929
- return;
481
+ if (startIndex === 0) {
482
+ [replaceNode] = anchorNode.splitText(endIndex);
483
+ } else {
484
+ [, replaceNode] = anchorNode.splitText(startIndex, endIndex);
930
485
  }
931
486
 
932
- scanningContext = initialScanningContext; // Lazy fill-in the particular format criteria and any matching result information.
487
+ replaceNode.selectNext();
488
+ transformer.replace(replaceNode, match);
489
+ return true;
490
+ }
933
491
 
934
- scanningContext.markdownCriteria = criteriaWithPatternMatchResults.markdownCriteria;
935
- scanningContext.patternMatchResults = criteriaWithPatternMatchResults.patternMatchResults;
936
- });
937
- return scanningContext;
492
+ return false;
938
493
  }
939
494
 
940
- function getTriggerState(editorState) {
941
- let criteria = null;
942
- editorState.read(() => {
943
- const selection = lexical.$getSelection();
495
+ function runTextFormatTransformers(editor, anchorNode, anchorOffset, textFormatTransformers) {
496
+ const textContent = anchorNode.getTextContent();
497
+ const closeTagEndIndex = anchorOffset - 1;
498
+ const closeChar = textContent[closeTagEndIndex]; // Quick check if we're possibly at the end of inline markdown style
944
499
 
945
- if (!lexical.$isRangeSelection(selection) || !selection.isCollapsed()) {
946
- return;
947
- }
500
+ const matchers = textFormatTransformers[closeChar];
948
501
 
949
- const node = selection.anchor.getNode();
950
- const parentNode = node.getParent();
951
- const isParentAListItemNode = list.$isListItemNode(parentNode);
952
- const hasParentNode = parentNode !== null;
953
- criteria = {
954
- anchorOffset: selection.anchor.offset,
955
- hasParentNode,
956
- isCodeBlock: code.$isCodeNode(node),
957
- isParentAListItemNode,
958
- isSelectionCollapsed: true,
959
- isSimpleText: lexical.$isTextNode(node) && node.isSimpleText(),
960
- nodeKey: node.getKey(),
961
- textContent: node.getTextContent()
962
- };
963
- });
964
- return criteria;
965
- }
966
- function findScanningContext(editor, currentTriggerState, priorTriggerState) {
967
- if (currentTriggerState == null || priorTriggerState == null) {
968
- return null;
502
+ if (!matchers) {
503
+ return false;
969
504
  }
970
505
 
971
- const triggerArray = getAllTriggers();
972
- const triggerCount = triggers.length;
506
+ for (const matcher of matchers) {
507
+ const {
508
+ tag
509
+ } = matcher;
510
+ const tagLength = tag.length;
511
+ const closeTagStartIndex = closeTagEndIndex - tagLength + 1; // If tag is not single char check if rest of it matches with text content
973
512
 
974
- for (let ti = 0; ti < triggerCount; ti++) {
975
- const triggerString = triggerArray[ti].triggerString; // The below checks needs to execute relativey quickly, so perform the light-weight ones first.
976
- // The substr check is a quick way to avoid autoformat parsing in that it looks for the autoformat
977
- // trigger which is the trigger string (" ").
513
+ if (tagLength > 1) {
514
+ if (!isEqualSubString(textContent, closeTagStartIndex, tag, 0, tagLength)) {
515
+ continue;
516
+ }
517
+ } // Space before closing tag cancels inline markdown
978
518
 
979
- const triggerStringLength = triggerString.length;
980
- const currentTextContentLength = currentTriggerState.textContent.length;
981
- const triggerOffset = currentTriggerState.anchorOffset - triggerStringLength; // Todo: these checks help w/ performance, yet we can do more.
982
- // We might consider looking for ** + space or __ + space and so on to boost performance
983
- // even further. Make sure the patter is driven from the trigger state type.
984
519
 
985
- if ((currentTriggerState.hasParentNode === true && currentTriggerState.isSimpleText && currentTriggerState.isSelectionCollapsed && currentTriggerState.anchorOffset !== priorTriggerState.anchorOffset && triggerOffset >= 0 && triggerOffset + triggerStringLength <= currentTextContentLength && currentTriggerState.textContent.substr(triggerOffset, triggerStringLength) === triggerString && // Some code differentiation needed if trigger kind is not a simple space character.
986
- currentTriggerState.textContent !== priorTriggerState.textContent) === false) {
987
- return null;
520
+ if (textContent[closeTagStartIndex - 1] === ' ') {
521
+ continue;
988
522
  }
989
- }
990
523
 
991
- return findScanningContextWithValidMatch(editor, currentTriggerState);
992
- }
524
+ const closeNode = anchorNode;
525
+ let openNode = closeNode;
526
+ let openTagStartIndex = getOpenTagStartIndex(textContent, closeTagStartIndex, tag); // Go through text node siblings and search for opening tag
527
+ // if haven't found it within the same text node as closing tag
993
528
 
994
- /**
995
- * Copyright (c) Meta Platforms, Inc. and affiliates.
996
- *
997
- * This source code is licensed under the MIT license found in the
998
- * LICENSE file in the root directory of this source tree.
999
- *
1000
- *
1001
- */
1002
- function convertStringToLexical(text, editor) {
1003
- if (!text.length) {
1004
- return null;
1005
- }
529
+ let sibling = openNode;
1006
530
 
1007
- const nodes = [];
1008
- const splitLines = text.split('\n');
1009
- const splitLinesCount = splitLines.length;
531
+ while (openTagStartIndex < 0 && (sibling = sibling.getPreviousSibling())) {
532
+ if (lexical.$isLineBreakNode(sibling)) {
533
+ break;
534
+ }
1010
535
 
1011
- for (let i = 0; i < splitLinesCount; i++) {
1012
- if (splitLines[i].length > 0) {
1013
- nodes.push(lexical.$createParagraphNode().append(lexical.$createTextNode(splitLines[i])));
1014
- } else {
1015
- nodes.push(lexical.$createParagraphNode());
1016
- }
1017
- }
536
+ if (lexical.$isTextNode(sibling)) {
537
+ const siblingTextContent = sibling.getTextContent();
538
+ openNode = sibling;
539
+ openTagStartIndex = getOpenTagStartIndex(siblingTextContent, siblingTextContent.length, tag);
540
+ }
541
+ } // Opening tag is not found
1018
542
 
1019
- if (nodes.length) {
1020
- const root = lexical.$getRoot();
1021
- root.clear();
1022
- root.append(...nodes);
1023
- return root;
1024
- }
1025
543
 
1026
- return null;
1027
- }
1028
- function convertMarkdownForElementNodes(editor, createHorizontalRuleNode) {
1029
- // Please see the declaration of ScanningContext for a detailed explanation.
1030
- const scanningContext = getInitialScanningContext(editor, false, null, null);
1031
- const root = lexical.$getRoot();
1032
- let done = false;
1033
- let startIndex = 0; // Handle the paragraph level markdown.
544
+ if (openTagStartIndex < 0) {
545
+ continue;
546
+ } // No content between opening and closing tag
1034
547
 
1035
- while (!done) {
1036
- done = true;
1037
- const elementNodes = root.getChildren();
1038
- const countOfElementNodes = elementNodes.length;
1039
548
 
1040
- for (let i = startIndex; i < countOfElementNodes; i++) {
1041
- const elementNode = elementNodes[i];
549
+ if (openNode === closeNode && openTagStartIndex + tagLength === closeTagStartIndex) {
550
+ continue;
551
+ } // Checking longer tags for repeating chars (e.g. *** vs **)
1042
552
 
1043
- if (lexical.$isElementNode(elementNode)) {
1044
- convertParagraphLevelMarkdown(scanningContext, elementNode, createHorizontalRuleNode);
1045
- } // Reset the scanning information that relates to the particular element node.
1046
553
 
554
+ const prevOpenNodeText = openNode.getTextContent();
1047
555
 
1048
- resetScanningContext(scanningContext);
556
+ if (openTagStartIndex > 0 && prevOpenNodeText[openTagStartIndex - 1] === closeChar) {
557
+ continue;
558
+ } // Clean text from opening and closing tags (starting from closing tag
559
+ // to prevent any offset shifts if we start from opening one)
1049
560
 
1050
- if (root.getChildren().length !== countOfElementNodes) {
1051
- // The conversion added or removed an from root's children.
1052
- startIndex = i;
1053
- done = false;
1054
- break;
561
+
562
+ const prevCloseNodeText = closeNode.getTextContent();
563
+ const closeNodeText = prevCloseNodeText.slice(0, closeTagStartIndex) + prevCloseNodeText.slice(closeTagEndIndex + 1);
564
+ closeNode.setTextContent(closeNodeText);
565
+ const openNodeText = openNode === closeNode ? closeNodeText : prevOpenNodeText;
566
+ openNode.setTextContent(openNodeText.slice(0, openTagStartIndex) + openNodeText.slice(openTagStartIndex + tagLength));
567
+ const nextSelection = lexical.$createRangeSelection();
568
+ lexical.$setSelection(nextSelection); // Adjust offset based on deleted chars
569
+
570
+ const newOffset = closeTagEndIndex - tagLength * (openNode === closeNode ? 2 : 1) + 1;
571
+ nextSelection.anchor.set(openNode.__key, openTagStartIndex, 'text');
572
+ nextSelection.focus.set(closeNode.__key, newOffset, 'text'); // Apply formatting to selected text
573
+
574
+ for (const format of matcher.format) {
575
+ if (!nextSelection.hasFormat(format)) {
576
+ nextSelection.formatText(format);
1055
577
  }
1056
- }
1057
- } // while
578
+ } // Collapse selection up to the focus point
1058
579
 
1059
580
 
1060
- done = false;
1061
- startIndex = 0; // Handle the text level markdown.
581
+ nextSelection.anchor.set(nextSelection.focus.key, nextSelection.focus.offset, nextSelection.focus.type); // Remove formatting from collapsed selection
1062
582
 
1063
- while (!done) {
1064
- done = true;
1065
- const elementNodes = root.getChildren();
1066
- const countOfElementNodes = elementNodes.length;
583
+ for (const format of matcher.format) {
584
+ if (nextSelection.hasFormat(format)) {
585
+ nextSelection.toggleFormat(format);
586
+ }
587
+ }
1067
588
 
1068
- for (let i = startIndex; i < countOfElementNodes; i++) {
1069
- const elementNode = elementNodes[i];
589
+ return true;
590
+ }
1070
591
 
1071
- if (lexical.$isElementNode(elementNode)) {
1072
- convertTextLevelMarkdown(scanningContext, elementNode, createHorizontalRuleNode);
1073
- } // Reset the scanning information that relates to the particular element node.
592
+ return false;
593
+ }
1074
594
 
595
+ function getOpenTagStartIndex(string, maxIndex, tag) {
596
+ const tagLength = tag.length;
1075
597
 
1076
- resetScanningContext(scanningContext);
598
+ for (let i = maxIndex; i >= tagLength; i--) {
599
+ const startIndex = i - tagLength;
600
+
601
+ if (isEqualSubString(string, startIndex, tag, 0, tagLength) && // Space after opening tag cancels transformation
602
+ string[startIndex + tagLength] !== ' ') {
603
+ return startIndex;
1077
604
  }
1078
- } // while
605
+ }
1079
606
 
607
+ return -1;
1080
608
  }
1081
609
 
1082
- function convertParagraphLevelMarkdown(scanningContext, elementNode, createHorizontalRuleNode) {
1083
- const textContent = elementNode.getTextContent(); // Handle paragraph nodes below.
1084
-
1085
- if (lexical.$isParagraphNode(elementNode)) {
1086
- const paragraphNode = elementNode;
1087
- const firstChild = paragraphNode.getFirstChild();
1088
- const firstChildIsTextNode = lexical.$isTextNode(firstChild); // Handle conversion to code block.
1089
-
1090
- if (scanningContext.isWithinCodeBlock === true) {
1091
- if (firstChild != null && firstChildIsTextNode) {
1092
- // Test if we encounter ending code block.
1093
- scanningContext.textNodeWithOffset = {
1094
- node: firstChild,
1095
- offset: 0
1096
- };
1097
- const patternMatchResults = getPatternMatchResultsForCodeBlock(scanningContext, textContent);
1098
-
1099
- if (patternMatchResults != null) {
1100
- // Toggle transform to or from code block.
1101
- scanningContext.patternMatchResults = patternMatchResults;
1102
- }
1103
- }
610
+ function isEqualSubString(stringA, aStart, stringB, bStart, length) {
611
+ for (let i = 0; i < length; i++) {
612
+ if (stringA[aStart + i] !== stringB[bStart + i]) {
613
+ return false;
614
+ }
615
+ }
1104
616
 
1105
- scanningContext.markdownCriteria = getCodeBlockCriteria(); // Perform text transformation here.
617
+ return true;
618
+ }
1106
619
 
1107
- transformTextNodeForMarkdownCriteria(scanningContext, elementNode, createHorizontalRuleNode);
620
+ function registerMarkdownShortcuts(editor, transformers) {
621
+ const byType = transformersByType(transformers);
622
+ const textFormatTransformersIndex = indexBy(byType.textFormat, ({
623
+ tag
624
+ }) => tag[tag.length - 1]);
625
+ const textMatchTransformersIndex = indexBy(byType.textMatch, ({
626
+ trigger
627
+ }) => trigger);
628
+
629
+ const transform = (parentNode, anchorNode, anchorOffset) => {
630
+ if (runElementTransformers(parentNode, anchorNode, anchorOffset, byType.element)) {
1108
631
  return;
1109
632
  }
1110
633
 
1111
- if (elementNode.getChildren().length) {
1112
- const allCriteria = getAllMarkdownCriteriaForParagraphs();
1113
- const count = allCriteria.length;
1114
- scanningContext.joinedText = paragraphNode.getTextContent();
634
+ if (runTextMatchTransformers(anchorNode, anchorOffset, textMatchTransformersIndex)) {
635
+ return;
636
+ }
1115
637
 
1116
- if (!(firstChild != null && firstChildIsTextNode)) {
1117
- throw Error(`Expect paragraph containing only text nodes.`);
1118
- }
638
+ runTextFormatTransformers(editor, anchorNode, anchorOffset, textFormatTransformersIndex);
639
+ };
1119
640
 
1120
- scanningContext.textNodeWithOffset = {
1121
- node: firstChild,
1122
- offset: 0
1123
- };
641
+ return editor.registerUpdateListener(({
642
+ tags,
643
+ dirtyLeaves,
644
+ editorState,
645
+ prevEditorState
646
+ }) => {
647
+ // Ignore updates from undo/redo (as changes already calculated)
648
+ if (tags.has('historic')) {
649
+ return;
650
+ }
1124
651
 
1125
- for (let i = 0; i < count; i++) {
1126
- const criteria = allCriteria[i];
652
+ const selection = editorState.read(lexical.$getSelection);
653
+ const prevSelection = prevEditorState.read(lexical.$getSelection);
1127
654
 
1128
- if (criteria.requiresParagraphStart === false) {
1129
- return;
1130
- }
655
+ if (!lexical.$isRangeSelection(prevSelection) || !lexical.$isRangeSelection(selection) || !selection.isCollapsed()) {
656
+ return;
657
+ }
1131
658
 
1132
- const patternMatchResults = getPatternMatchResultsForCriteria(criteria, scanningContext, getParentElementNodeOrThrow(scanningContext));
659
+ const anchorKey = selection.anchor.key;
660
+ const anchorOffset = selection.anchor.offset;
1133
661
 
1134
- if (patternMatchResults != null) {
1135
- scanningContext.markdownCriteria = criteria;
1136
- scanningContext.patternMatchResults = patternMatchResults; // Perform text transformation here.
662
+ const anchorNode = editorState._nodeMap.get(anchorKey);
1137
663
 
1138
- transformTextNodeForMarkdownCriteria(scanningContext, elementNode, createHorizontalRuleNode);
1139
- return;
1140
- }
1141
- }
664
+ if (!lexical.$isTextNode(anchorNode) || !dirtyLeaves.has(anchorKey) || anchorOffset !== 1 && anchorOffset !== prevSelection.anchor.offset + 1) {
665
+ return;
1142
666
  }
1143
- }
1144
- }
1145
-
1146
- function convertTextLevelMarkdown(scanningContext, elementNode, createHorizontalRuleNode) {
1147
- const firstChild = elementNode.getFirstChild();
1148
-
1149
- if (lexical.$isTextNode(firstChild)) {
1150
- // This function will convert all text nodes within the elementNode.
1151
- convertMarkdownForTextCriteria(scanningContext, elementNode, createHorizontalRuleNode);
1152
- return;
1153
- } // Handle the case where the elementNode has child elementNodes like lists.
1154
- // Since we started at a text import, we don't need to worry about anything but textNodes.
1155
667
 
668
+ editor.update(() => {
669
+ // Markdown is not available inside code
670
+ if (anchorNode.hasFormat('code')) {
671
+ return;
672
+ }
1156
673
 
1157
- const children = elementNode.getChildren();
1158
- const countOfChildren = children.length;
674
+ const parentNode = anchorNode.getParent();
1159
675
 
1160
- for (let i = 0; i < countOfChildren; i++) {
1161
- const node = children[i];
676
+ if (parentNode === null || code.$isCodeNode(parentNode)) {
677
+ return;
678
+ }
1162
679
 
1163
- if (lexical.$isElementNode(node)) {
1164
- // Recurse down until we find a text node.
1165
- convertTextLevelMarkdown(scanningContext, node, createHorizontalRuleNode);
1166
- }
1167
- }
680
+ transform(parentNode, anchorNode, selection.anchor.offset);
681
+ });
682
+ });
1168
683
  }
1169
684
 
1170
- function convertMarkdownForTextCriteria(scanningContext, elementNode, createHorizontalRuleNode) {
1171
- resetScanningContext(scanningContext); // Cycle through all the criteria and convert all text patterns in the parent element.
685
+ /**
686
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
687
+ *
688
+ * This source code is licensed under the MIT license found in the
689
+ * LICENSE file in the root directory of this source tree.
690
+ *
691
+ *
692
+ */
1172
693
 
1173
- const allCriteria = getAllMarkdownCriteriaForTextNodes();
1174
- const count = allCriteria.length;
1175
- let textContent = elementNode.getTextContent();
1176
- let done = textContent.length === 0;
1177
- let startIndex = 0;
694
+ const replaceWithBlock = createNode => {
695
+ return (parentNode, children, match) => {
696
+ const node = createNode(match);
697
+ node.append(...children);
698
+ parentNode.replace(node);
699
+ node.select(0, 0);
700
+ };
701
+ }; // Amount of spaces that define indentation level
702
+ // TODO: should be an option
1178
703
 
1179
- while (!done) {
1180
- done = true;
1181
704
 
1182
- for (let i = startIndex; i < count; i++) {
1183
- const criteria = allCriteria[i];
705
+ const LIST_INDENT_SIZE = 4;
1184
706
 
1185
- if (scanningContext.textNodeWithOffset == null) {
1186
- // Need to search through the very last text node in the element.
1187
- const lastTextNode = getLastTextNodeInElementNode(elementNode);
707
+ const listReplace = listType => {
708
+ return (parentNode, children, match) => {
709
+ const previousNode = parentNode.getPreviousSibling();
710
+ const listItem = list.$createListItemNode(listType === 'check' ? match[3] === 'x' : undefined);
1188
711
 
1189
- if (lastTextNode == null) {
1190
- // If we have no more text nodes, then there's nothing to search and transform.
1191
- return;
1192
- }
712
+ if (list.$isListNode(previousNode) && previousNode.getListType() === listType) {
713
+ previousNode.append(listItem);
714
+ parentNode.remove();
715
+ } else {
716
+ const list$1 = list.$createListNode(listType, listType === 'number' ? Number(match[2]) : undefined);
717
+ list$1.append(listItem);
718
+ parentNode.replace(list$1);
719
+ }
1193
720
 
1194
- scanningContext.textNodeWithOffset = {
1195
- node: lastTextNode,
1196
- offset: lastTextNode.getTextContent().length
1197
- };
1198
- }
721
+ listItem.append(...children);
722
+ listItem.select(0, 0);
723
+ const indent = Math.floor(match[1].length / LIST_INDENT_SIZE);
1199
724
 
1200
- const patternMatchResults = getPatternMatchResultsForCriteria(criteria, scanningContext, elementNode);
725
+ if (indent) {
726
+ listItem.setIndent(indent);
727
+ }
728
+ };
729
+ };
1201
730
 
1202
- if (patternMatchResults != null) {
1203
- scanningContext.markdownCriteria = criteria;
1204
- scanningContext.patternMatchResults = patternMatchResults; // Perform text transformation here.
731
+ const listExport = (listNode, exportChildren, depth) => {
732
+ const output = [];
733
+ const children = listNode.getChildren();
734
+ let index = 0;
1205
735
 
1206
- transformTextNodeForMarkdownCriteria(scanningContext, elementNode, createHorizontalRuleNode);
1207
- resetScanningContext(scanningContext);
1208
- const currentTextContent = elementNode.getTextContent();
736
+ for (const listItemNode of children) {
737
+ if (list.$isListItemNode(listItemNode)) {
738
+ if (listItemNode.getChildrenSize() === 1) {
739
+ const firstChild = listItemNode.getFirstChild();
1209
740
 
1210
- if (currentTextContent.length === 0) {
1211
- // Nothing left to convert.
1212
- return;
741
+ if (list.$isListNode(firstChild)) {
742
+ output.push(listExport(firstChild, exportChildren, depth + 1));
743
+ continue;
1213
744
  }
745
+ }
1214
746
 
1215
- if (currentTextContent === textContent) {
1216
- // Nothing was changed by this transformation, so move on to the next crieteria.
1217
- continue;
1218
- } // The text was changed. Perhaps there is another hit for the same criteria.
747
+ const indent = ' '.repeat(depth * LIST_INDENT_SIZE);
748
+ const listType = listNode.getListType();
749
+ const prefix = listType === 'number' ? `${listNode.getStart() + index}. ` : listType === 'check' ? `- [${listItemNode.getChecked() ? 'x' : ' '}] ` : '- ';
750
+ output.push(indent + prefix + exportChildren(listItemNode));
751
+ index++;
752
+ }
753
+ }
1219
754
 
755
+ return output.join('\n');
756
+ };
1220
757
 
1221
- textContent = currentTextContent;
1222
- startIndex = i;
1223
- done = false;
1224
- break;
1225
- }
758
+ const HEADING = {
759
+ export: (node, exportChildren) => {
760
+ if (!richText.$isHeadingNode(node)) {
761
+ return null;
1226
762
  }
1227
- }
1228
- }
1229
763
 
1230
- function getLastTextNodeInElementNode(elementNode) {
1231
- const children = elementNode.getChildren();
1232
- const countOfChildren = children.length;
764
+ const level = Number(node.getTag().slice(1));
765
+ return '#'.repeat(level) + ' ' + exportChildren(node);
766
+ },
767
+ regExp: /^(#{1,6})\s/,
768
+ replace: replaceWithBlock(match => {
769
+ // $FlowFixMe[incompatible-cast]
770
+ const tag = 'h' + match[1].length;
771
+ return richText.$createHeadingNode(tag);
772
+ }),
773
+ type: 'element'
774
+ };
775
+ const QUOTE = {
776
+ export: (node, exportChildren) => {
777
+ return richText.$isQuoteNode(node) ? '> ' + exportChildren(node) : null;
778
+ },
779
+ regExp: /^>\s/,
780
+ replace: replaceWithBlock(() => richText.$createQuoteNode()),
781
+ type: 'element'
782
+ };
783
+ const CODE = {
784
+ export: node => {
785
+ if (!code.$isCodeNode(node)) {
786
+ return null;
787
+ }
1233
788
 
1234
- for (let i = countOfChildren - 1; i >= 0; i--) {
1235
- if (lexical.$isTextNode(children[i])) {
1236
- return children[i];
789
+ const textContent = node.getTextContent();
790
+ return '```' + (node.getLanguage() || '') + (textContent ? '\n' + textContent : '') + '\n' + '```';
791
+ },
792
+ regExp: /^```(\w{1,10})?\s/,
793
+ replace: replaceWithBlock(match => {
794
+ return code.$createCodeNode(match ? match[1] : undefined);
795
+ }),
796
+ type: 'element'
797
+ };
798
+ const UNORDERED_LIST = {
799
+ export: (node, exportChildren) => {
800
+ return list.$isListNode(node) ? listExport(node, exportChildren, 0) : null;
801
+ },
802
+ regExp: /^(\s*)[-*+]\s/,
803
+ replace: listReplace('bullet'),
804
+ type: 'element'
805
+ };
806
+ const CHECK_LIST = {
807
+ export: (node, exportChildren) => {
808
+ return list.$isListNode(node) ? listExport(node, exportChildren, 0) : null;
809
+ },
810
+ regExp: /^(\s*)(?:-\s)?\s?(\[(\s|x)?\])\s/i,
811
+ replace: listReplace('check'),
812
+ type: 'element'
813
+ };
814
+ const ORDERED_LIST = {
815
+ export: (node, exportChildren) => {
816
+ return list.$isListNode(node) ? listExport(node, exportChildren, 0) : null;
817
+ },
818
+ regExp: /^(\s*)(\d{1,})\.\s/,
819
+ replace: listReplace('number'),
820
+ type: 'element'
821
+ };
822
+ const INLINE_CODE = {
823
+ format: ['code'],
824
+ tag: '`',
825
+ type: 'text-format'
826
+ };
827
+ const BOLD_ITALIC_STAR = {
828
+ format: ['bold', 'italic'],
829
+ tag: '***',
830
+ type: 'text-format'
831
+ };
832
+ const BOLD_ITALIC_UNDERSCORE = {
833
+ format: ['bold', 'italic'],
834
+ tag: '___',
835
+ type: 'text-format'
836
+ };
837
+ const BOLD_STAR = {
838
+ format: ['bold'],
839
+ tag: '**',
840
+ type: 'text-format'
841
+ };
842
+ const BOLD_UNDERSCORE = {
843
+ format: ['bold'],
844
+ tag: '__',
845
+ type: 'text-format'
846
+ };
847
+ const STRIKETHROUGH = {
848
+ format: ['strikethrough'],
849
+ tag: '~~',
850
+ type: 'text-format'
851
+ };
852
+ const ITALIC_STAR = {
853
+ format: ['italic'],
854
+ tag: '*',
855
+ type: 'text-format'
856
+ };
857
+ const ITALIC_UNDERSCORE = {
858
+ format: ['italic'],
859
+ tag: '_',
860
+ type: 'text-format'
861
+ }; // Order of text transformers matters:
862
+ //
863
+ // - code should go first as it prevents any transformations inside
864
+ // - then longer tags match (e.g. ** or __ should go before * or _)
865
+
866
+ const LINK = {
867
+ export: (node, exportChildren, exportFormat) => {
868
+ if (!link.$isLinkNode(node)) {
869
+ return null;
1237
870
  }
1238
- }
1239
871
 
1240
- return null;
1241
- }
872
+ const linkContent = `[${node.getTextContent()}](${node.getURL()})`;
873
+ const firstChild = node.getFirstChild(); // Add text styles only if link has single text node inside. If it's more
874
+ // then one we ignore it as markdown does not support nested styles for links
875
+
876
+ if (node.getChildrenSize() === 1 && lexical.$isTextNode(firstChild)) {
877
+ return exportFormat(firstChild, linkContent);
878
+ } else {
879
+ return linkContent;
880
+ }
881
+ },
882
+ importRegExp: /(?:\[([^[]+)\])(?:\(([^(]+)\))/,
883
+ regExp: /(?:\[([^[]+)\])(?:\(([^(]+)\))$/,
884
+ replace: (textNode, match) => {
885
+ const [, linkText, linkUrl] = match;
886
+ const linkNode = link.$createLinkNode(linkUrl);
887
+ const linkTextNode = lexical.$createTextNode(linkText);
888
+ linkTextNode.setFormat(textNode.getFormat());
889
+ linkNode.append(linkTextNode);
890
+ textNode.replace(linkNode);
891
+ },
892
+ trigger: ')',
893
+ type: 'text-match'
894
+ };
1242
895
 
1243
896
  /**
1244
897
  * Copyright (c) Meta Platforms, Inc. and affiliates.
@@ -1248,35 +901,44 @@ function getLastTextNodeInElementNode(elementNode) {
1248
901
  *
1249
902
  *
1250
903
  */
1251
- function registerMarkdownShortcuts(editor, createHorizontalRuleNode) {
1252
- // The priorTriggerState is compared against the currentTriggerState to determine
1253
- // if the user has performed some typing event that warrants an auto format.
1254
- // For example, typing "#" and then " ", shoud trigger an format.
1255
- // However, given "#A B", where the user delets "A" should not.
1256
- let priorTriggerState = null;
1257
- return editor.registerUpdateListener(({
1258
- tags
1259
- }) => {
1260
- // Examine historic so that we are not running autoformatting within markdown.
1261
- if (tags.has('historic') === false) {
1262
- const currentTriggerState = getTriggerState(editor.getEditorState());
1263
- const scanningContext = currentTriggerState == null ? null : findScanningContext(editor, currentTriggerState, priorTriggerState);
904
+ const ELEMENT_TRANSFORMERS = [HEADING, QUOTE, CODE, UNORDERED_LIST, ORDERED_LIST]; // Order of text format transformers matters:
905
+ //
906
+ // - code should go first as it prevents any transformations inside
907
+ // - then longer tags match (e.g. ** or __ should go before * or _)
1264
908
 
1265
- if (scanningContext != null) {
1266
- updateAutoFormatting(editor, scanningContext, createHorizontalRuleNode);
1267
- }
909
+ const TEXT_FORMAT_TRANSFORMERS = [INLINE_CODE, BOLD_ITALIC_STAR, BOLD_ITALIC_UNDERSCORE, BOLD_STAR, BOLD_UNDERSCORE, ITALIC_STAR, ITALIC_UNDERSCORE, STRIKETHROUGH];
910
+ const TEXT_MATCH_TRANSFORMERS = [LINK];
911
+ const TRANSFORMERS = [...ELEMENT_TRANSFORMERS, ...TEXT_FORMAT_TRANSFORMERS, ...TEXT_MATCH_TRANSFORMERS];
1268
912
 
1269
- priorTriggerState = currentTriggerState;
1270
- } else {
1271
- priorTriggerState = null;
1272
- }
1273
- });
913
+ function $convertFromMarkdownString(markdown, transformers = TRANSFORMERS) {
914
+ const importMarkdown = createMarkdownImport(transformers);
915
+ return importMarkdown(markdown);
1274
916
  }
1275
- function $convertFromMarkdownString(markdownString, editor, createHorizontalRuleNode) {
1276
- if (convertStringToLexical(markdownString) != null) {
1277
- convertMarkdownForElementNodes(editor, createHorizontalRuleNode);
1278
- }
917
+
918
+ function $convertToMarkdownString(transformers = TRANSFORMERS) {
919
+ const exportMarkdown = createMarkdownExport(transformers);
920
+ return exportMarkdown();
1279
921
  }
1280
922
 
1281
923
  exports.$convertFromMarkdownString = $convertFromMarkdownString;
924
+ exports.$convertToMarkdownString = $convertToMarkdownString;
925
+ exports.BOLD_ITALIC_STAR = BOLD_ITALIC_STAR;
926
+ exports.BOLD_ITALIC_UNDERSCORE = BOLD_ITALIC_UNDERSCORE;
927
+ exports.BOLD_STAR = BOLD_STAR;
928
+ exports.BOLD_UNDERSCORE = BOLD_UNDERSCORE;
929
+ exports.CHECK_LIST = CHECK_LIST;
930
+ exports.CODE = CODE;
931
+ exports.ELEMENT_TRANSFORMERS = ELEMENT_TRANSFORMERS;
932
+ exports.HEADING = HEADING;
933
+ exports.INLINE_CODE = INLINE_CODE;
934
+ exports.ITALIC_STAR = ITALIC_STAR;
935
+ exports.ITALIC_UNDERSCORE = ITALIC_UNDERSCORE;
936
+ exports.LINK = LINK;
937
+ exports.ORDERED_LIST = ORDERED_LIST;
938
+ exports.QUOTE = QUOTE;
939
+ exports.STRIKETHROUGH = STRIKETHROUGH;
940
+ exports.TEXT_FORMAT_TRANSFORMERS = TEXT_FORMAT_TRANSFORMERS;
941
+ exports.TEXT_MATCH_TRANSFORMERS = TEXT_MATCH_TRANSFORMERS;
942
+ exports.TRANSFORMERS = TRANSFORMERS;
943
+ exports.UNORDERED_LIST = UNORDERED_LIST;
1282
944
  exports.registerMarkdownShortcuts = registerMarkdownShortcuts;