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