@lexical/markdown 0.2.5 → 0.2.8

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,394 @@ 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
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)
542
149
 
543
- const prevElement = element.getPreviousSibling();
544
150
 
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
- }
151
+ function getTextSibling(node, backward) {
152
+ let sibling = backward ? node.getPreviousSibling() : node.getNextSibling();
553
153
 
554
- if (indent) {
555
- listItem.setIndent(indent);
556
- }
557
- }
154
+ if (!sibling) {
155
+ const parent = node.getParentOrThrow();
558
156
 
559
- function transformTextNodeForMarkdownCriteria(scanningContext, elementNode, createHorizontalRuleNode) {
560
- if (scanningContext.markdownCriteria.requiresParagraphStart === true) {
561
- transformTextNodeForElementNode(elementNode, scanningContext, createHorizontalRuleNode);
562
- } else {
563
- transformTextNodeForText(scanningContext, elementNode);
157
+ if (parent.isInline()) {
158
+ sibling = backward ? parent.getPreviousSibling() : parent.getNextSibling();
159
+ }
564
160
  }
565
- }
566
161
 
567
- function transformTextNodeForElementNode(elementNode, scanningContext, createHorizontalRuleNode) {
568
- if (scanningContext.textNodeWithOffset != null) {
569
- const textNodeWithOffset = getTextNodeWithOffsetOrThrow(scanningContext);
570
-
571
- if (hasPatternMatchResults(scanningContext)) {
572
- const text = scanningContext.patternMatchResults.regExCaptureGroups[0].text; // Remove the text which we matched.
162
+ while (sibling) {
163
+ if (lexical.$isElementNode(sibling)) {
164
+ if (!sibling.isInline()) {
165
+ break;
166
+ }
573
167
 
574
- const textNode = textNodeWithOffset.node.spliceText(0, text.length, '', true);
168
+ const descendant = backward ? sibling.getLastDescendant() : sibling.getFirstDescendant();
575
169
 
576
- if (textNode.getTextContent() === '') {
577
- textNode.selectPrevious();
578
- textNode.remove();
170
+ if (lexical.$isTextNode(descendant)) {
171
+ return descendant;
172
+ } else {
173
+ sibling = backward ? sibling.getPreviousSibling() : sibling.getNextSibling();
579
174
  }
580
175
  }
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
- }
594
- }
595
-
596
- function transformTextNodeForText(scanningContext, parentElementNode) {
597
- const markdownCriteria = scanningContext.markdownCriteria;
598
176
 
599
- if (markdownCriteria.markdownFormatKind != null) {
600
- const formatting = getTextFormatType(markdownCriteria.markdownFormatKind);
601
-
602
- if (formatting != null) {
603
- transformTextNodeWithFormatting(formatting, scanningContext, parentElementNode);
604
- return;
177
+ if (lexical.$isTextNode(sibling)) {
178
+ return sibling;
605
179
  }
606
180
 
607
- if (markdownCriteria.markdownFormatKind === 'link') {
608
- transformTextNodeWithLink(scanningContext, parentElementNode);
181
+ if (!lexical.$isElementNode(sibling)) {
182
+ return null;
609
183
  }
610
184
  }
611
- }
612
185
 
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);
186
+ return null;
639
187
  }
640
188
 
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();
189
+ function hasFormat(node, format) {
190
+ return lexical.$isTextNode(node) && node.hasFormat(format);
685
191
  }
686
192
 
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
- }
193
+ /**
194
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
195
+ *
196
+ * This source code is licensed under the MIT license found in the
197
+ * LICENSE file in the root directory of this source tree.
198
+ *
199
+ *
200
+ */
201
+ const CODE_BLOCK_REG_EXP = /^```(\w{1,10})?\s?$/;
202
+ function createMarkdownImport(transformers) {
203
+ const byType = transformersByType(transformers);
204
+ const textFormatTransformersIndex = createTextFormatTransformersIndex(byType.textFormat);
205
+ return markdownString => {
206
+ const lines = markdownString.split('\n');
207
+ const linesLength = lines.length;
208
+ const root = lexical.$getRoot();
209
+ root.clear();
698
210
 
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
- }
211
+ for (let i = 0; i < linesLength; i++) {
212
+ const lineText = lines[i]; // Codeblocks are processed first as anything inside such block
213
+ // is ignored for further processing
214
+ // TODO:
215
+ // Abstract it to be dynamic as other transformers (add multiline match option)
712
216
 
713
- case 'italic_bold':
714
- {
715
- return ['italic', 'bold'];
716
- }
217
+ const [codeBlockNode, shiftedIndex] = importCodeBlock(lines, i, root);
717
218
 
718
- case 'strikethrough_italic':
719
- {
720
- return ['strikethrough', 'italic'];
219
+ if (codeBlockNode != null) {
220
+ i = shiftedIndex;
221
+ continue;
721
222
  }
722
223
 
723
- case 'strikethrough_bold':
724
- {
725
- return ['strikethrough', 'bold'];
726
- }
727
- }
224
+ importBlocks(lineText, root, byType.element, textFormatTransformersIndex, byType.textMatch);
225
+ }
728
226
 
729
- return null;
227
+ root.selectEnd();
228
+ };
730
229
  }
731
230
 
732
- function createSelectionWithCaptureGroups(anchorCaptureGroupIndex, focusCaptureGroupIndex, startAtEndOfAnchor, finishAtEndOfFocus, scanningContext, parentElementNode) {
733
- const patternMatchResults = scanningContext.patternMatchResults;
734
- const regExCaptureGroups = patternMatchResults.regExCaptureGroups;
735
- const regExCaptureGroupsCount = regExCaptureGroups.length;
231
+ function importBlocks(lineText, rootNode, elementTransformers, textFormatTransformersIndex, textMatchTransformers) {
232
+ const textNode = lexical.$createTextNode(lineText);
233
+ const elementNode = lexical.$createParagraphNode();
234
+ elementNode.append(textNode);
235
+ rootNode.append(elementNode);
736
236
 
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
- }
237
+ for (const {
238
+ regExp,
239
+ replace
240
+ } of elementTransformers) {
241
+ const match = lineText.match(regExp);
755
242
 
756
- if (anchorTextNodeWithOffset == null || focusTextNodeWithOffset == null) {
757
- return null;
243
+ if (match) {
244
+ textNode.setTextContent(lineText.slice(match[0].length));
245
+ replace(elementNode, [textNode], match, true);
246
+ break;
247
+ }
758
248
  }
759
249
 
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;
250
+ importTextFormatTransformers(textNode, textFormatTransformersIndex, textMatchTransformers);
764
251
  }
765
252
 
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();
253
+ function importCodeBlock(lines, startLineIndex, rootNode) {
254
+ const openMatch = lines[startLineIndex].match(CODE_BLOCK_REG_EXP);
774
255
 
775
- if (currentSelection != null && lexical.$isRangeSelection(currentSelection) && currentSelection.isCollapsed() === false) {
776
- currentSelection.removeText(); // Shift all group offsets and clear out group text.
256
+ if (openMatch) {
257
+ let endLineIndex = startLineIndex;
258
+ const linesLength = lines.length;
777
259
 
778
- let runningLength = 0;
779
- const groupCount = regExCaptureGroups.length;
260
+ while (++endLineIndex < linesLength) {
261
+ const closeMatch = lines[endLineIndex].match(CODE_BLOCK_REG_EXP);
780
262
 
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
- }
263
+ if (closeMatch) {
264
+ const codeBlockNode = code.$createCodeNode(openMatch[1]);
265
+ const textNode = lexical.$createTextNode(lines.slice(startLineIndex + 1, endLineIndex).join('\n'));
266
+ codeBlockNode.append(textNode);
267
+ rootNode.append(codeBlockNode);
268
+ return [codeBlockNode, endLineIndex];
792
269
  }
793
270
  }
794
271
  }
795
- }
796
272
 
797
- function insertTextPriorToCaptureGroup(captureGroupIndex, text, scanningContext, parentElementNode) {
798
- const patternMatchResults = scanningContext.patternMatchResults;
799
- const regExCaptureGroups = patternMatchResults.regExCaptureGroups;
800
- const regExCaptureGroupsCount = regExCaptureGroups.length;
273
+ return [null, startLineIndex];
274
+ } // Processing text content and replaces text format tags.
275
+ // It takes outermost tag match and its content, creates text node with
276
+ // format based on tag and then recursively executed over node's content
277
+ //
278
+ // E.g. for "*Hello **world**!*" string it will create text node with
279
+ // "Hello **world**!" content and italic format and run recursively over
280
+ // its content to transform "**world**" part
281
+
801
282
 
802
- if (captureGroupIndex >= regExCaptureGroupsCount) {
283
+ function importTextFormatTransformers(textNode, textFormatTransformersIndex, textMatchTransformers) {
284
+ const textContent = textNode.getTextContent();
285
+ const match = findOutermostMatch(textContent, textFormatTransformersIndex);
286
+
287
+ if (!match) {
288
+ // Once text format processing is done run text match transformers, as it
289
+ // only can span within single text node (unline formats that can cover multiple nodes)
290
+ importTextMatchTransformers(textNode, textMatchTransformers);
803
291
  return;
804
292
  }
805
293
 
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);
294
+ let currentNode, remainderNode; // If matching full content there's no need to run splitText and can reuse existing textNode
295
+ // to update its content and apply format. E.g. for **_Hello_** string after applying bold
296
+ // format (**) it will reuse the same text node to apply italic (_)
812
297
 
813
- if (newSelection != null) {
814
- lexical.$setSelection(newSelection);
815
- const currentSelection = lexical.$getSelection();
298
+ if (match[0] === textContent) {
299
+ currentNode = textNode;
300
+ } else {
301
+ const startIndex = match.index;
302
+ const endIndex = startIndex + match[0].length;
816
303
 
817
- if (currentSelection != null && lexical.$isRangeSelection(currentSelection) && currentSelection.isCollapsed()) {
818
- currentSelection.insertText(newCaptureGroupDetail.text); // Update the capture groups.
304
+ if (startIndex === 0) {
305
+ [currentNode, remainderNode] = textNode.splitText(endIndex);
306
+ } else {
307
+ [, currentNode, remainderNode] = textNode.splitText(startIndex, endIndex);
308
+ }
309
+ }
819
310
 
820
- regExCaptureGroups.splice(captureGroupIndex, 0, newCaptureGroupDetail);
821
- const textLength = newCaptureGroupDetail.text.length;
822
- const newGroupCount = regExCaptureGroups.length;
311
+ currentNode.setTextContent(match[2]);
312
+ const transformer = textFormatTransformersIndex.transformersByTag[match[1]];
823
313
 
824
- for (let i = captureGroupIndex + 1; i < newGroupCount; i++) {
825
- const currentCaptureGroupDetail = regExCaptureGroups[i];
826
- currentCaptureGroupDetail.offsetInParent += textLength;
314
+ if (transformer) {
315
+ for (const format of transformer.format) {
316
+ if (!currentNode.hasFormat(format)) {
317
+ currentNode.toggleFormat(format);
827
318
  }
828
319
  }
829
- }
830
- }
320
+ } // Recursively run over inner text if it's not inline code
831
321
 
832
- function formatTextInCaptureGroupIndex(formatTypes, captureGroupIndex, scanningContext, parentElementNode) {
833
- const patternMatchResults = scanningContext.patternMatchResults;
834
- const regExCaptureGroups = patternMatchResults.regExCaptureGroups;
835
- const regExCaptureGroupsCount = regExCaptureGroups.length;
836
322
 
837
- if (!(captureGroupIndex < regExCaptureGroupsCount)) {
838
- throw Error(`The capture group count in the RegEx does match the actual capture group count.`);
839
- }
323
+ if (!currentNode.hasFormat('code')) {
324
+ importTextFormatTransformers(currentNode, textFormatTransformersIndex, textMatchTransformers);
325
+ } // Run over remaining text if any
840
326
 
841
- const captureGroupDetail = regExCaptureGroups[captureGroupIndex];
842
327
 
843
- if (captureGroupDetail.text.length === 0) {
844
- return;
328
+ if (remainderNode) {
329
+ importTextFormatTransformers(remainderNode, textFormatTransformersIndex, textMatchTransformers);
845
330
  }
331
+ }
846
332
 
847
- const newSelection = createSelectionWithCaptureGroups(captureGroupIndex, captureGroupIndex, false, true, scanningContext, parentElementNode);
333
+ function importTextMatchTransformers(textNode_, textMatchTransformers) {
334
+ let textNode = textNode_;
848
335
 
849
- if (newSelection != null) {
850
- lexical.$setSelection(newSelection);
851
- const currentSelection = lexical.$getSelection();
336
+ mainLoop: while (textNode) {
337
+ for (const transformer of textMatchTransformers) {
338
+ const match = textNode.getTextContent().match(transformer.importRegExp);
852
339
 
853
- if (lexical.$isRangeSelection(currentSelection)) {
854
- for (let i = 0; i < formatTypes.length; i++) {
855
- currentSelection.formatText(formatTypes[i]);
340
+ if (!match) {
341
+ continue;
856
342
  }
857
- }
858
- }
859
- } // Place caret at end of final capture group.
860
343
 
344
+ const startIndex = match.index;
345
+ const endIndex = startIndex + match[0].length;
346
+ let replaceNode;
861
347
 
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
- }
348
+ if (startIndex === 0) {
349
+ [replaceNode, textNode] = textNode.splitText(endIndex);
350
+ } else {
351
+ [, replaceNode, textNode] = textNode.splitText(startIndex, endIndex);
352
+ }
870
353
 
871
- const lastGroupIndex = groupCount - 1;
872
- const newSelection = createSelectionWithCaptureGroups(lastGroupIndex, lastGroupIndex, true, true, scanningContext, parentElementNode);
354
+ transformer.replace(replaceNode, match);
355
+ continue mainLoop;
356
+ }
873
357
 
874
- if (newSelection != null) {
875
- lexical.$setSelection(newSelection);
358
+ break;
876
359
  }
877
- }
360
+ } // Finds first "<tag>content<tag>" match that is not nested into another tag
878
361
 
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
362
 
363
+ function findOutermostMatch(textContent, textTransformersIndex) {
364
+ const openTagsMatch = textContent.match(textTransformersIndex.openTagsRegExp);
889
365
 
890
- const LIST_INDENT_SIZE = 4;
366
+ if (openTagsMatch == null) {
367
+ return null;
368
+ }
891
369
 
892
- function processNestedLists(listNode, exportChildren, depth) {
893
- const output = [];
894
- const children = listNode.getChildren();
895
- let index = 0;
370
+ for (const match of openTagsMatch) {
371
+ // Open tags reg exp might capture leading space so removing it
372
+ // before using match to find transformer
373
+ const fullMatchRegExp = textTransformersIndex.fullMatchRegExpByTag[match.replace(/^\s/, '')];
896
374
 
897
- for (const listItemNode of children) {
898
- if (list.$isListItemNode(listItemNode)) {
899
- if (listItemNode.getChildrenSize() === 1) {
900
- const firstChild = listItemNode.getFirstChild();
375
+ if (fullMatchRegExp == null) {
376
+ continue;
377
+ }
901
378
 
902
- if (list.$isListNode(firstChild)) {
903
- output.push(processNestedLists(firstChild, exportChildren, depth + 1));
904
- continue;
905
- }
906
- }
379
+ const fullMatch = textContent.match(fullMatchRegExp);
907
380
 
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++;
381
+ if (fullMatch != null) {
382
+ return fullMatch;
912
383
  }
913
384
  }
914
385
 
915
- return output.join('\n');
386
+ return null;
916
387
  }
917
388
 
918
- function blockQuoteExport(node, exportChildren) {
919
- return richText.$isQuoteNode(node) ? '> ' + exportChildren(node) : null;
920
- }
389
+ function createTextFormatTransformersIndex(textTransformers) {
390
+ const transformersByTag = {};
391
+ const fullMatchRegExpByTag = {};
392
+ const openTagsRegExp = [];
921
393
 
922
- function codeBlockExport(node, exportChildren) {
923
- if (!code.$isCodeNode(node)) {
924
- return null;
394
+ for (const transformer of textTransformers) {
395
+ const {
396
+ tag
397
+ } = transformer;
398
+ transformersByTag[tag] = transformer;
399
+ const tagRegExp = tag.replace(/(\*|\^)/g, '\\$1');
400
+ openTagsRegExp.push(tagRegExp);
401
+ fullMatchRegExpByTag[tag] = new RegExp(`(${tagRegExp})(?![${tagRegExp}\\s])(.*?[^${tagRegExp}\\s])${tagRegExp}(?!${tagRegExp})`);
925
402
  }
926
403
 
927
- const textContent = node.getTextContent();
928
- return '```' + (node.getLanguage() || '') + (textContent ? '\n' + textContent : '') + '\n' + '```';
404
+ return {
405
+ // Reg exp to find open tag + content + close tag
406
+ fullMatchRegExpByTag,
407
+ // Reg exp to find opening tags
408
+ openTagsRegExp: new RegExp('(' + openTagsRegExp.join('|') + ')', 'g'),
409
+ transformersByTag
410
+ };
929
411
  }
930
412
 
931
413
  /**
@@ -936,385 +418,287 @@ function codeBlockExport(node, exportChildren) {
936
418
  *
937
419
  *
938
420
  */
421
+ const PUNCTUATION_OR_SPACE = /[!-/:-@[-`{-~\s]/;
939
422
 
940
- function getTextNodeForAutoFormatting(selection) {
941
- if (!lexical.$isRangeSelection(selection)) {
942
- return null;
943
- }
944
-
945
- const node = selection.anchor.getNode();
423
+ function runElementTransformers(parentNode, anchorNode, anchorOffset, elementTransformers) {
424
+ const grandParentNode = parentNode.getParent();
946
425
 
947
- if (!lexical.$isTextNode(node)) {
948
- return null;
426
+ if (!lexical.$isRootNode(grandParentNode) || parentNode.getFirstChild() !== anchorNode) {
427
+ return false;
949
428
  }
950
429
 
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;
430
+ const textContent = anchorNode.getTextContent(); // Checking for anchorOffset position to prevent any checks for cases when caret is too far
431
+ // from a line start to be a part of block-level markdown trigger.
432
+ //
433
+ // TODO:
434
+ // Can have a quick check if caret is close enough to the beginning of the string (e.g. offset less than 10-20)
435
+ // since otherwise it won't be a markdown shortcut, but tables are exception
969
436
 
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.
437
+ if (textContent[anchorOffset - 1] !== ' ') {
438
+ return false;
439
+ }
972
440
 
973
- if (currentTriggerState != null && currentTriggerState.isCodeBlock === false || markdownCriteria.markdownFormatKind === 'paragraphCodeBlock') {
974
- const patternMatchResults = getPatternMatchResultsForCriteria(markdownCriteria, scanningContext, getParentElementNodeOrThrow(scanningContext));
441
+ for (const {
442
+ regExp,
443
+ replace
444
+ } of elementTransformers) {
445
+ const match = textContent.match(regExp);
975
446
 
976
- if (patternMatchResults != null) {
977
- return {
978
- markdownCriteria,
979
- patternMatchResults
980
- };
981
- }
447
+ if (match && match[0].length === anchorOffset) {
448
+ const nextSiblings = anchorNode.getNextSiblings();
449
+ const [leadingNode, remainderNode] = anchorNode.splitText(anchorOffset);
450
+ leadingNode.remove();
451
+ const siblings = remainderNode ? [remainderNode, ...nextSiblings] : nextSiblings;
452
+ replace(parentNode, siblings, match, false);
453
+ return true;
982
454
  }
983
455
  }
984
456
 
985
- return {
986
- markdownCriteria: null,
987
- patternMatchResults: null
988
- };
457
+ return false;
989
458
  }
990
459
 
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.
460
+ function runTextMatchTransformers(anchorNode, anchorOffset, transformersByTrigger) {
461
+ let textContent = anchorNode.getTextContent();
462
+ const lastChar = textContent[anchorOffset - 1];
463
+ const transformers = transformersByTrigger[lastChar];
999
464
 
465
+ if (transformers == null) {
466
+ return false;
467
+ } // If typing in the middle of content, remove the tail to do
468
+ // reg exp match up to a string end (caret position)
1000
469
 
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);
1004
470
 
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
-
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;
471
+ if (anchorOffset < textContent.length) {
472
+ textContent = textContent.slice(0, anchorOffset);
1046
473
  }
1047
474
 
1048
- const triggerArray = getAllTriggers();
1049
- const triggerCount = triggers.length;
475
+ for (const transformer of transformers) {
476
+ const match = textContent.match(transformer.regExp);
1050
477
 
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 (" ").
478
+ if (match === null) {
479
+ continue;
480
+ }
1055
481
 
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.
482
+ const startIndex = match.index;
483
+ const endIndex = startIndex + match[0].length;
484
+ let replaceNode;
1061
485
 
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;
486
+ if (startIndex === 0) {
487
+ [replaceNode] = anchorNode.splitText(endIndex);
488
+ } else {
489
+ [, replaceNode] = anchorNode.splitText(startIndex, endIndex);
1065
490
  }
491
+
492
+ replaceNode.selectNext();
493
+ transformer.replace(replaceNode, match);
494
+ return true;
1066
495
  }
1067
496
 
1068
- return findScanningContextWithValidMatch(editor, currentTriggerState);
497
+ return false;
1069
498
  }
1070
499
 
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
- }
500
+ function runTextFormatTransformers(editor, anchorNode, anchorOffset, textFormatTransformers) {
501
+ const textContent = anchorNode.getTextContent();
502
+ const closeTagEndIndex = anchorOffset - 1;
503
+ const closeChar = textContent[closeTagEndIndex]; // Quick check if we're possibly at the end of inline markdown style
1083
504
 
1084
- const nodes = [];
1085
- const splitLines = text.split('\n');
1086
- const splitLinesCount = splitLines.length;
505
+ const matchers = textFormatTransformers[closeChar];
1087
506
 
1088
- for (let i = 0; i < splitLinesCount; i++) {
1089
- if (splitLines[i].length > 0) {
1090
- nodes.push(lexical.$createParagraphNode().append(lexical.$createTextNode(splitLines[i])));
1091
- } else {
1092
- nodes.push(lexical.$createParagraphNode());
1093
- }
507
+ if (!matchers) {
508
+ return false;
1094
509
  }
1095
510
 
1096
- if (nodes.length) {
1097
- const root = lexical.$getRoot();
1098
- root.clear();
1099
- root.append(...nodes);
1100
- return root;
1101
- }
511
+ for (const matcher of matchers) {
512
+ const {
513
+ tag
514
+ } = matcher;
515
+ const tagLength = tag.length;
516
+ const closeTagStartIndex = closeTagEndIndex - tagLength + 1; // If tag is not single char check if rest of it matches with text content
517
+
518
+ if (tagLength > 1) {
519
+ if (!isEqualSubString(textContent, closeTagStartIndex, tag, 0, tagLength)) {
520
+ continue;
521
+ }
522
+ } // Space before closing tag cancels inline markdown
1102
523
 
1103
- return null;
1104
- }
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
524
 
1112
- while (!done) {
1113
- done = true;
1114
- const elementNodes = root.getChildren();
1115
- const countOfElementNodes = elementNodes.length;
525
+ if (textContent[closeTagStartIndex - 1] === ' ') {
526
+ continue;
527
+ } // Some tags can not be used within words, hence should have newline/space/punctuation after it
1116
528
 
1117
- for (let i = startIndex; i < countOfElementNodes; i++) {
1118
- const elementNode = elementNodes[i];
1119
529
 
1120
- if (lexical.$isElementNode(elementNode)) {
1121
- convertParagraphLevelMarkdown(scanningContext, elementNode, createHorizontalRuleNode);
1122
- } // Reset the scanning information that relates to the particular element node.
530
+ const afterCloseTagChar = textContent[closeTagEndIndex + 1];
1123
531
 
532
+ if (matcher.intraword === false && afterCloseTagChar && !PUNCTUATION_OR_SPACE.test(afterCloseTagChar)) {
533
+ continue;
534
+ }
1124
535
 
1125
- resetScanningContext(scanningContext);
536
+ const closeNode = anchorNode;
537
+ let openNode = closeNode;
538
+ let openTagStartIndex = getOpenTagStartIndex(textContent, closeTagStartIndex, tag); // Go through text node siblings and search for opening tag
539
+ // if haven't found it within the same text node as closing tag
1126
540
 
1127
- if (root.getChildren().length !== countOfElementNodes) {
1128
- // The conversion added or removed an from root's children.
1129
- startIndex = i;
1130
- done = false;
541
+ let sibling = openNode;
542
+
543
+ while (openTagStartIndex < 0 && (sibling = sibling.getPreviousSibling())) {
544
+ if (lexical.$isLineBreakNode(sibling)) {
1131
545
  break;
1132
546
  }
1133
- }
1134
- } // while
1135
547
 
548
+ if (lexical.$isTextNode(sibling)) {
549
+ const siblingTextContent = sibling.getTextContent();
550
+ openNode = sibling;
551
+ openTagStartIndex = getOpenTagStartIndex(siblingTextContent, siblingTextContent.length, tag);
552
+ }
553
+ } // Opening tag is not found
1136
554
 
1137
- done = false;
1138
- startIndex = 0; // Handle the text level markdown.
1139
555
 
1140
- while (!done) {
1141
- done = true;
1142
- const elementNodes = root.getChildren();
1143
- const countOfElementNodes = elementNodes.length;
556
+ if (openTagStartIndex < 0) {
557
+ continue;
558
+ } // No content between opening and closing tag
1144
559
 
1145
- for (let i = startIndex; i < countOfElementNodes; i++) {
1146
- const elementNode = elementNodes[i];
1147
560
 
1148
- if (lexical.$isElementNode(elementNode)) {
1149
- convertTextLevelMarkdown(scanningContext, elementNode, createHorizontalRuleNode);
1150
- } // Reset the scanning information that relates to the particular element node.
561
+ if (openNode === closeNode && openTagStartIndex + tagLength === closeTagStartIndex) {
562
+ continue;
563
+ } // Checking longer tags for repeating chars (e.g. *** vs **)
1151
564
 
1152
565
 
1153
- resetScanningContext(scanningContext);
1154
- }
1155
- } // while
566
+ const prevOpenNodeText = openNode.getTextContent();
1156
567
 
1157
- }
1158
-
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
- }
568
+ if (openTagStartIndex > 0 && prevOpenNodeText[openTagStartIndex - 1] === closeChar) {
569
+ continue;
570
+ } // Some tags can not be used within words, hence should have newline/space/punctuation before it
1181
571
 
1182
- scanningContext.markdownCriteria = getCodeBlockCriteria(); // Perform text transformation here.
1183
572
 
1184
- transformTextNodeForMarkdownCriteria(scanningContext, elementNode, createHorizontalRuleNode);
1185
- return;
1186
- }
573
+ const beforeOpenTagChar = prevOpenNodeText[openTagStartIndex - 1];
1187
574
 
1188
- if (elementNode.getChildren().length) {
1189
- const allCriteria = getAllMarkdownCriteriaForParagraphs();
1190
- const count = allCriteria.length;
1191
- scanningContext.joinedText = paragraphNode.getTextContent();
575
+ if (matcher.intraword === false && beforeOpenTagChar && !PUNCTUATION_OR_SPACE.test(beforeOpenTagChar)) {
576
+ continue;
577
+ } // Clean text from opening and closing tags (starting from closing tag
578
+ // to prevent any offset shifts if we start from opening one)
1192
579
 
1193
- if (!(firstChild != null && firstChildIsTextNode)) {
1194
- throw Error(`Expect paragraph containing only text nodes.`);
1195
- }
1196
580
 
1197
- scanningContext.textNodeWithOffset = {
1198
- node: firstChild,
1199
- offset: 0
1200
- };
581
+ const prevCloseNodeText = closeNode.getTextContent();
582
+ const closeNodeText = prevCloseNodeText.slice(0, closeTagStartIndex) + prevCloseNodeText.slice(closeTagEndIndex + 1);
583
+ closeNode.setTextContent(closeNodeText);
584
+ const openNodeText = openNode === closeNode ? closeNodeText : prevOpenNodeText;
585
+ openNode.setTextContent(openNodeText.slice(0, openTagStartIndex) + openNodeText.slice(openTagStartIndex + tagLength));
586
+ const nextSelection = lexical.$createRangeSelection();
587
+ lexical.$setSelection(nextSelection); // Adjust offset based on deleted chars
1201
588
 
1202
- for (let i = 0; i < count; i++) {
1203
- const criteria = allCriteria[i];
589
+ const newOffset = closeTagEndIndex - tagLength * (openNode === closeNode ? 2 : 1) + 1;
590
+ nextSelection.anchor.set(openNode.__key, openTagStartIndex, 'text');
591
+ nextSelection.focus.set(closeNode.__key, newOffset, 'text'); // Apply formatting to selected text
1204
592
 
1205
- if (criteria.requiresParagraphStart === false) {
1206
- return;
1207
- }
593
+ for (const format of matcher.format) {
594
+ if (!nextSelection.hasFormat(format)) {
595
+ nextSelection.formatText(format);
596
+ }
597
+ } // Collapse selection up to the focus point
1208
598
 
1209
- const patternMatchResults = getPatternMatchResultsForCriteria(criteria, scanningContext, getParentElementNodeOrThrow(scanningContext));
1210
599
 
1211
- if (patternMatchResults != null) {
1212
- scanningContext.markdownCriteria = criteria;
1213
- scanningContext.patternMatchResults = patternMatchResults; // Perform text transformation here.
600
+ nextSelection.anchor.set(nextSelection.focus.key, nextSelection.focus.offset, nextSelection.focus.type); // Remove formatting from collapsed selection
1214
601
 
1215
- transformTextNodeForMarkdownCriteria(scanningContext, elementNode, createHorizontalRuleNode);
1216
- return;
1217
- }
602
+ for (const format of matcher.format) {
603
+ if (nextSelection.hasFormat(format)) {
604
+ nextSelection.toggleFormat(format);
1218
605
  }
1219
606
  }
1220
- }
1221
- }
1222
-
1223
- function convertTextLevelMarkdown(scanningContext, elementNode, createHorizontalRuleNode) {
1224
- const firstChild = elementNode.getFirstChild();
1225
607
 
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.
608
+ return true;
609
+ }
1232
610
 
611
+ return false;
612
+ }
1233
613
 
1234
- const children = elementNode.getChildren();
1235
- const countOfChildren = children.length;
614
+ function getOpenTagStartIndex(string, maxIndex, tag) {
615
+ const tagLength = tag.length;
1236
616
 
1237
- for (let i = 0; i < countOfChildren; i++) {
1238
- const node = children[i];
617
+ for (let i = maxIndex; i >= tagLength; i--) {
618
+ const startIndex = i - tagLength;
1239
619
 
1240
- if (lexical.$isElementNode(node)) {
1241
- // Recurse down until we find a text node.
1242
- convertTextLevelMarkdown(scanningContext, node, createHorizontalRuleNode);
620
+ if (isEqualSubString(string, startIndex, tag, 0, tagLength) && // Space after opening tag cancels transformation
621
+ string[startIndex + tagLength] !== ' ') {
622
+ return startIndex;
1243
623
  }
1244
624
  }
1245
- }
1246
625
 
1247
- function convertMarkdownForTextCriteria(scanningContext, elementNode, createHorizontalRuleNode) {
1248
- resetScanningContext(scanningContext); // Cycle through all the criteria and convert all text patterns in the parent element.
626
+ return -1;
627
+ }
1249
628
 
1250
- const allCriteria = getAllMarkdownCriteriaForTextNodes();
1251
- const count = allCriteria.length;
1252
- let textContent = elementNode.getTextContent();
1253
- let done = textContent.length === 0;
1254
- let startIndex = 0;
629
+ function isEqualSubString(stringA, aStart, stringB, bStart, length) {
630
+ for (let i = 0; i < length; i++) {
631
+ if (stringA[aStart + i] !== stringB[bStart + i]) {
632
+ return false;
633
+ }
634
+ }
1255
635
 
1256
- while (!done) {
1257
- done = true;
636
+ return true;
637
+ }
1258
638
 
1259
- for (let i = startIndex; i < count; i++) {
1260
- const criteria = allCriteria[i];
639
+ function registerMarkdownShortcuts(editor, transformers = TRANSFORMERS) {
640
+ const byType = transformersByType(transformers);
641
+ const textFormatTransformersIndex = indexBy(byType.textFormat, ({
642
+ tag
643
+ }) => tag[tag.length - 1]);
644
+ const textMatchTransformersIndex = indexBy(byType.textMatch, ({
645
+ trigger
646
+ }) => trigger);
1261
647
 
1262
- if (scanningContext.textNodeWithOffset == null) {
1263
- // Need to search through the very last text node in the element.
1264
- const lastTextNode = getLastTextNodeInElementNode(elementNode);
648
+ const transform = (parentNode, anchorNode, anchorOffset) => {
649
+ if (runElementTransformers(parentNode, anchorNode, anchorOffset, byType.element)) {
650
+ return;
651
+ }
1265
652
 
1266
- if (lastTextNode == null) {
1267
- // If we have no more text nodes, then there's nothing to search and transform.
1268
- return;
1269
- }
653
+ if (runTextMatchTransformers(anchorNode, anchorOffset, textMatchTransformersIndex)) {
654
+ return;
655
+ }
1270
656
 
1271
- scanningContext.textNodeWithOffset = {
1272
- node: lastTextNode,
1273
- offset: lastTextNode.getTextContent().length
1274
- };
1275
- }
657
+ runTextFormatTransformers(editor, anchorNode, anchorOffset, textFormatTransformersIndex);
658
+ };
1276
659
 
1277
- const patternMatchResults = getPatternMatchResultsForCriteria(criteria, scanningContext, elementNode);
660
+ return editor.registerUpdateListener(({
661
+ tags,
662
+ dirtyLeaves,
663
+ editorState,
664
+ prevEditorState
665
+ }) => {
666
+ // Ignore updates from undo/redo (as changes already calculated)
667
+ if (tags.has('historic')) {
668
+ return;
669
+ }
1278
670
 
1279
- if (patternMatchResults != null) {
1280
- scanningContext.markdownCriteria = criteria;
1281
- scanningContext.patternMatchResults = patternMatchResults; // Perform text transformation here.
671
+ const selection = editorState.read(lexical.$getSelection);
672
+ const prevSelection = prevEditorState.read(lexical.$getSelection);
1282
673
 
1283
- transformTextNodeForMarkdownCriteria(scanningContext, elementNode, createHorizontalRuleNode);
1284
- resetScanningContext(scanningContext);
1285
- const currentTextContent = elementNode.getTextContent();
674
+ if (!lexical.$isRangeSelection(prevSelection) || !lexical.$isRangeSelection(selection) || !selection.isCollapsed()) {
675
+ return;
676
+ }
1286
677
 
1287
- if (currentTextContent.length === 0) {
1288
- // Nothing left to convert.
1289
- return;
1290
- }
678
+ const anchorKey = selection.anchor.key;
679
+ const anchorOffset = selection.anchor.offset;
1291
680
 
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.
681
+ const anchorNode = editorState._nodeMap.get(anchorKey);
1296
682
 
683
+ if (!lexical.$isTextNode(anchorNode) || !dirtyLeaves.has(anchorKey) || anchorOffset !== 1 && anchorOffset !== prevSelection.anchor.offset + 1) {
684
+ return;
685
+ }
1297
686
 
1298
- textContent = currentTextContent;
1299
- startIndex = i;
1300
- done = false;
1301
- break;
687
+ editor.update(() => {
688
+ // Markdown is not available inside code
689
+ if (anchorNode.hasFormat('code')) {
690
+ return;
1302
691
  }
1303
- }
1304
- }
1305
- }
1306
692
 
1307
- function getLastTextNodeInElementNode(elementNode) {
1308
- const children = elementNode.getChildren();
1309
- const countOfChildren = children.length;
693
+ const parentNode = anchorNode.getParent();
1310
694
 
1311
- for (let i = countOfChildren - 1; i >= 0; i--) {
1312
- if (lexical.$isTextNode(children[i])) {
1313
- return children[i];
1314
- }
1315
- }
695
+ if (parentNode === null || code.$isCodeNode(parentNode)) {
696
+ return;
697
+ }
1316
698
 
1317
- return null;
699
+ transform(parentNode, anchorNode, selection.anchor.offset);
700
+ });
701
+ });
1318
702
  }
1319
703
 
1320
704
  /**
@@ -1325,138 +709,211 @@ function getLastTextNodeInElementNode(elementNode) {
1325
709
  *
1326
710
  *
1327
711
  */
1328
- function $convertToMarkdownString() {
1329
- const output = [];
1330
- const children = lexical.$getRoot().getChildren();
1331
-
1332
- for (const child of children) {
1333
- const result = exportTopLevelElementOrDecorator(child);
1334
712
 
1335
- if (result != null) {
1336
- output.push(result);
1337
- }
1338
- }
713
+ const replaceWithBlock = createNode => {
714
+ return (parentNode, children, match) => {
715
+ const node = createNode(match);
716
+ node.append(...children);
717
+ parentNode.replace(node);
718
+ node.select(0, 0);
719
+ };
720
+ }; // Amount of spaces that define indentation level
721
+ // TODO: should be an option
1339
722
 
1340
- return output.join('\n');
1341
- }
1342
723
 
1343
- function exportTopLevelElementOrDecorator(node) {
1344
- const blockTransformers = getAllMarkdownCriteriaForParagraphs();
724
+ const LIST_INDENT_SIZE = 4;
1345
725
 
1346
- for (const transformer of blockTransformers) {
1347
- if (transformer.export != null) {
1348
- const result = transformer.export(node, _node => exportChildren(_node));
726
+ const listReplace = listType => {
727
+ return (parentNode, children, match) => {
728
+ const previousNode = parentNode.getPreviousSibling();
729
+ const listItem = list.$createListItemNode(listType === 'check' ? match[3] === 'x' : undefined);
1349
730
 
1350
- if (result != null) {
1351
- return result;
1352
- }
731
+ if (list.$isListNode(previousNode) && previousNode.getListType() === listType) {
732
+ previousNode.append(listItem);
733
+ parentNode.remove();
734
+ } else {
735
+ const list$1 = list.$createListNode(listType, listType === 'number' ? Number(match[2]) : undefined);
736
+ list$1.append(listItem);
737
+ parentNode.replace(list$1);
1353
738
  }
1354
- }
1355
-
1356
- return lexical.$isElementNode(node) ? exportChildren(node) : null;
1357
- }
1358
739
 
1359
- function exportChildren(node) {
1360
- const output = [];
1361
- const children = node.getChildren();
740
+ listItem.append(...children);
741
+ listItem.select(0, 0);
742
+ const indent = Math.floor(match[1].length / LIST_INDENT_SIZE);
1362
743
 
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));
744
+ if (indent) {
745
+ listItem.setIndent(indent);
1382
746
  }
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;
747
+ };
748
+ };
1399
749
 
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)
750
+ const listExport = (listNode, exportChildren, depth) => {
751
+ const output = [];
752
+ const children = listNode.getChildren();
753
+ let index = 0;
1404
754
 
1405
- const previousNode = getTextSibling(node, true);
755
+ for (const listItemNode of children) {
756
+ if (list.$isListItemNode(listItemNode)) {
757
+ if (listItemNode.getChildrenSize() === 1) {
758
+ const firstChild = listItemNode.getFirstChild();
1406
759
 
1407
- if (!hasFormat(previousNode, format)) {
1408
- output = tag + output;
760
+ if (list.$isListNode(firstChild)) {
761
+ output.push(listExport(firstChild, exportChildren, depth + 1));
762
+ continue;
763
+ }
1409
764
  }
1410
765
 
1411
- const nextNode = getTextSibling(node, false);
1412
-
1413
- if (!hasFormat(nextNode, format)) {
1414
- output += tagClose;
1415
- }
766
+ const indent = ' '.repeat(depth * LIST_INDENT_SIZE);
767
+ const listType = listNode.getListType();
768
+ const prefix = listType === 'number' ? `${listNode.getStart() + index}. ` : listType === 'check' ? `- [${listItemNode.getChecked() ? 'x' : ' '}] ` : '- ';
769
+ output.push(indent + prefix + exportChildren(listItemNode));
770
+ index++;
1416
771
  }
1417
772
  }
1418
773
 
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();
774
+ return output.join('\n');
775
+ };
1428
776
 
1429
- if (parent.isInline()) {
1430
- sibling = backward ? parent.getPreviousSibling() : parent.getNextSibling();
777
+ const HEADING = {
778
+ export: (node, exportChildren) => {
779
+ if (!richText.$isHeadingNode(node)) {
780
+ return null;
1431
781
  }
1432
- }
1433
782
 
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
-
1442
- if (lexical.$isTextNode(descendant)) {
1443
- return descendant;
1444
- } else {
1445
- sibling = backward ? sibling.getPreviousSibling() : sibling.getNextSibling();
1446
- }
783
+ const level = Number(node.getTag().slice(1));
784
+ return '#'.repeat(level) + ' ' + exportChildren(node);
785
+ },
786
+ regExp: /^(#{1,6})\s/,
787
+ replace: replaceWithBlock(match => {
788
+ // $FlowFixMe[incompatible-cast]
789
+ const tag = 'h' + match[1].length;
790
+ return richText.$createHeadingNode(tag);
791
+ }),
792
+ type: 'element'
793
+ };
794
+ const QUOTE = {
795
+ export: (node, exportChildren) => {
796
+ return richText.$isQuoteNode(node) ? '> ' + exportChildren(node) : null;
797
+ },
798
+ regExp: /^>\s/,
799
+ replace: replaceWithBlock(() => richText.$createQuoteNode()),
800
+ type: 'element'
801
+ };
802
+ const CODE = {
803
+ export: node => {
804
+ if (!code.$isCodeNode(node)) {
805
+ return null;
1447
806
  }
1448
807
 
1449
- if (lexical.$isTextNode(sibling)) {
1450
- return sibling;
808
+ const textContent = node.getTextContent();
809
+ return '```' + (node.getLanguage() || '') + (textContent ? '\n' + textContent : '') + '\n' + '```';
810
+ },
811
+ regExp: /^```(\w{1,10})?\s/,
812
+ replace: replaceWithBlock(match => {
813
+ return code.$createCodeNode(match ? match[1] : undefined);
814
+ }),
815
+ type: 'element'
816
+ };
817
+ const UNORDERED_LIST = {
818
+ export: (node, exportChildren) => {
819
+ return list.$isListNode(node) ? listExport(node, exportChildren, 0) : null;
820
+ },
821
+ regExp: /^(\s*)[-*+]\s/,
822
+ replace: listReplace('bullet'),
823
+ type: 'element'
824
+ };
825
+ const CHECK_LIST = {
826
+ export: (node, exportChildren) => {
827
+ return list.$isListNode(node) ? listExport(node, exportChildren, 0) : null;
828
+ },
829
+ regExp: /^(\s*)(?:-\s)?\s?(\[(\s|x)?\])\s/i,
830
+ replace: listReplace('check'),
831
+ type: 'element'
832
+ };
833
+ const ORDERED_LIST = {
834
+ export: (node, exportChildren) => {
835
+ return list.$isListNode(node) ? listExport(node, exportChildren, 0) : null;
836
+ },
837
+ regExp: /^(\s*)(\d{1,})\.\s/,
838
+ replace: listReplace('number'),
839
+ type: 'element'
840
+ };
841
+ const INLINE_CODE = {
842
+ format: ['code'],
843
+ tag: '`',
844
+ type: 'text-format'
845
+ };
846
+ const BOLD_ITALIC_STAR = {
847
+ format: ['bold', 'italic'],
848
+ tag: '***',
849
+ type: 'text-format'
850
+ };
851
+ const BOLD_ITALIC_UNDERSCORE = {
852
+ format: ['bold', 'italic'],
853
+ intraword: false,
854
+ tag: '___',
855
+ type: 'text-format'
856
+ };
857
+ const BOLD_STAR = {
858
+ format: ['bold'],
859
+ tag: '**',
860
+ type: 'text-format'
861
+ };
862
+ const BOLD_UNDERSCORE = {
863
+ format: ['bold'],
864
+ intraword: false,
865
+ tag: '__',
866
+ type: 'text-format'
867
+ };
868
+ const STRIKETHROUGH = {
869
+ format: ['strikethrough'],
870
+ tag: '~~',
871
+ type: 'text-format'
872
+ };
873
+ const ITALIC_STAR = {
874
+ format: ['italic'],
875
+ tag: '*',
876
+ type: 'text-format'
877
+ };
878
+ const ITALIC_UNDERSCORE = {
879
+ format: ['italic'],
880
+ intraword: false,
881
+ tag: '_',
882
+ type: 'text-format'
883
+ }; // Order of text transformers matters:
884
+ //
885
+ // - code should go first as it prevents any transformations inside
886
+ // - then longer tags match (e.g. ** or __ should go before * or _)
887
+
888
+ const LINK = {
889
+ export: (node, exportChildren, exportFormat) => {
890
+ if (!link.$isLinkNode(node)) {
891
+ return null;
1451
892
  }
1452
- }
1453
893
 
1454
- return null;
1455
- }
894
+ const linkContent = `[${node.getTextContent()}](${node.getURL()})`;
895
+ const firstChild = node.getFirstChild(); // Add text styles only if link has single text node inside. If it's more
896
+ // then one we ignore it as markdown does not support nested styles for links
1456
897
 
1457
- function hasFormat(node, format) {
1458
- return lexical.$isTextNode(node) && node.hasFormat(format);
1459
- }
898
+ if (node.getChildrenSize() === 1 && lexical.$isTextNode(firstChild)) {
899
+ return exportFormat(firstChild, linkContent);
900
+ } else {
901
+ return linkContent;
902
+ }
903
+ },
904
+ importRegExp: /(?:\[([^[]+)\])(?:\(([^(]+)\))/,
905
+ regExp: /(?:\[([^[]+)\])(?:\(([^(]+)\))$/,
906
+ replace: (textNode, match) => {
907
+ const [, linkText, linkUrl] = match;
908
+ const linkNode = link.$createLinkNode(linkUrl);
909
+ const linkTextNode = lexical.$createTextNode(linkText);
910
+ linkTextNode.setFormat(textNode.getFormat());
911
+ linkNode.append(linkTextNode);
912
+ textNode.replace(linkNode);
913
+ },
914
+ trigger: ')',
915
+ type: 'text-match'
916
+ };
1460
917
 
1461
918
  /**
1462
919
  * Copyright (c) Meta Platforms, Inc. and affiliates.
@@ -1466,36 +923,44 @@ function hasFormat(node, format) {
1466
923
  *
1467
924
  *
1468
925
  */
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);
926
+ const ELEMENT_TRANSFORMERS = [HEADING, QUOTE, CODE, UNORDERED_LIST, ORDERED_LIST]; // Order of text format transformers matters:
927
+ //
928
+ // - code should go first as it prevents any transformations inside
929
+ // - then longer tags match (e.g. ** or __ should go before * or _)
1482
930
 
1483
- if (scanningContext != null) {
1484
- updateAutoFormatting(editor, scanningContext, createHorizontalRuleNode);
1485
- }
931
+ const TEXT_FORMAT_TRANSFORMERS = [INLINE_CODE, BOLD_ITALIC_STAR, BOLD_ITALIC_UNDERSCORE, BOLD_STAR, BOLD_UNDERSCORE, ITALIC_STAR, ITALIC_UNDERSCORE, STRIKETHROUGH];
932
+ const TEXT_MATCH_TRANSFORMERS = [LINK];
933
+ const TRANSFORMERS = [...ELEMENT_TRANSFORMERS, ...TEXT_FORMAT_TRANSFORMERS, ...TEXT_MATCH_TRANSFORMERS];
1486
934
 
1487
- priorTriggerState = currentTriggerState;
1488
- } else {
1489
- priorTriggerState = null;
1490
- }
1491
- });
935
+ function $convertFromMarkdownString(markdown, transformers = TRANSFORMERS) {
936
+ const importMarkdown = createMarkdownImport(transformers);
937
+ return importMarkdown(markdown);
1492
938
  }
1493
- function $convertFromMarkdownString(markdownString, editor, createHorizontalRuleNode) {
1494
- if (convertStringToLexical(markdownString) != null) {
1495
- convertMarkdownForElementNodes(editor, createHorizontalRuleNode);
1496
- }
939
+
940
+ function $convertToMarkdownString(transformers = TRANSFORMERS) {
941
+ const exportMarkdown = createMarkdownExport(transformers);
942
+ return exportMarkdown();
1497
943
  }
1498
944
 
1499
945
  exports.$convertFromMarkdownString = $convertFromMarkdownString;
1500
946
  exports.$convertToMarkdownString = $convertToMarkdownString;
947
+ exports.BOLD_ITALIC_STAR = BOLD_ITALIC_STAR;
948
+ exports.BOLD_ITALIC_UNDERSCORE = BOLD_ITALIC_UNDERSCORE;
949
+ exports.BOLD_STAR = BOLD_STAR;
950
+ exports.BOLD_UNDERSCORE = BOLD_UNDERSCORE;
951
+ exports.CHECK_LIST = CHECK_LIST;
952
+ exports.CODE = CODE;
953
+ exports.ELEMENT_TRANSFORMERS = ELEMENT_TRANSFORMERS;
954
+ exports.HEADING = HEADING;
955
+ exports.INLINE_CODE = INLINE_CODE;
956
+ exports.ITALIC_STAR = ITALIC_STAR;
957
+ exports.ITALIC_UNDERSCORE = ITALIC_UNDERSCORE;
958
+ exports.LINK = LINK;
959
+ exports.ORDERED_LIST = ORDERED_LIST;
960
+ exports.QUOTE = QUOTE;
961
+ exports.STRIKETHROUGH = STRIKETHROUGH;
962
+ exports.TEXT_FORMAT_TRANSFORMERS = TEXT_FORMAT_TRANSFORMERS;
963
+ exports.TEXT_MATCH_TRANSFORMERS = TEXT_MATCH_TRANSFORMERS;
964
+ exports.TRANSFORMERS = TRANSFORMERS;
965
+ exports.UNORDERED_LIST = UNORDERED_LIST;
1501
966
  exports.registerMarkdownShortcuts = registerMarkdownShortcuts;