@lexical/markdown 0.49.1-nightly.20260902.0 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -280,6 +280,112 @@ function getIndent(whitespaces) {
280
280
  }
281
281
  return indent;
282
282
  }
283
+
284
+ /**
285
+ * The column the text after `whitespaces` starts at, expanding a tab to the
286
+ * next multiple of `LIST_INDENT_SIZE` the way CommonMark does.
287
+ */
288
+ function getColumn(whitespaces) {
289
+ let column = 0;
290
+ for (const char of whitespaces) {
291
+ column += char === '\t' ? LIST_INDENT_SIZE - column % LIST_INDENT_SIZE : 1;
292
+ }
293
+ return column;
294
+ }
295
+
296
+ /**
297
+ * The content columns of the list levels the lines placed so far have left
298
+ * open, outermost first, or null outside a markdown import.
299
+ *
300
+ * A sublist is measured against the column where its parent item's content
301
+ * begins rather than against a fixed number of spaces: that is what lets
302
+ * `1. a` take a three-space sublist while `- a` takes a two-space one, and
303
+ * what keeps two items written at the same column siblings even when that
304
+ * column is deep enough to have opened a level.
305
+ *
306
+ * Only the line that opened a level knows the column it was written at, and
307
+ * nothing in the tree records it afterwards, so the columns are carried from
308
+ * line to line for the length of one import. A shortcut typed into an editor
309
+ * is a line on its own with no such run behind it and keeps reading its indent
310
+ * as a fixed `LIST_INDENT_SIZE` per level, which is the step `$listExport`
311
+ * writes and the one the toolbar and Tab indent by.
312
+ */
313
+ let importListColumns = null;
314
+ let importJoinsLooseLists = false;
315
+
316
+ /**
317
+ * Run `fn` with the columns of a single markdown import tracked across the
318
+ * lines it places. Restores whatever was being tracked around it, so an import
319
+ * that runs inside another one does not disturb it.
320
+ *
321
+ * `joinLooseLists` reads a blank line between list lines as making the list
322
+ * loose rather than ending it. An import that preserves new lines keeps blank
323
+ * paragraphs as content, so there a blank line closes the list like any other
324
+ * block.
325
+ *
326
+ * @internal
327
+ */
328
+ function withListIndentColumns(joinLooseLists, fn) {
329
+ const previousColumns = importListColumns;
330
+ const previousJoins = importJoinsLooseLists;
331
+ importListColumns = [];
332
+ importJoinsLooseLists = joinLooseLists;
333
+ try {
334
+ return fn();
335
+ } finally {
336
+ importListColumns = previousColumns;
337
+ importJoinsLooseLists = previousJoins;
338
+ }
339
+ }
340
+
341
+ /**
342
+ * The indent `whitespaces` names given the levels open above it: the innermost
343
+ * level whose content column it reaches, or 0 when it reaches none of them.
344
+ */
345
+ function getColumnIndent(columns, whitespaces) {
346
+ const column = getColumn(whitespaces);
347
+ for (let i = columns.length - 1; i >= 0; i--) {
348
+ if (column >= columns[i]) {
349
+ return i + 1;
350
+ }
351
+ }
352
+ return 0;
353
+ }
354
+
355
+ /**
356
+ * The column this line opens its content at: where the matched marker ends,
357
+ * with tabs expanded, so `-\ta` opens at the tab stop and not two columns in.
358
+ * A check list item is marked by its bullet — the `[ ]` is content — so
359
+ * `- [ ] a` opens where `- a` does. Capped one `LIST_INDENT_SIZE` past the
360
+ * marker so that the indent `$listExport` writes always nests: a sublist of
361
+ * `100. ` is exported with four spaces, short of that marker's real content
362
+ * column.
363
+ */
364
+ function getContentColumn(match, listType) {
365
+ let prefix = match[0];
366
+ if (listType === 'check') {
367
+ const bullet = prefix.slice(match[1].length).match(/^[-*+]\s/);
368
+ if (bullet) {
369
+ prefix = match[1] + bullet[0];
370
+ }
371
+ }
372
+ return Math.min(getColumn(prefix), getColumn(match[1]) + LIST_INDENT_SIZE);
373
+ }
374
+
375
+ /**
376
+ * Record the content column that this line leaves open for the lines below it,
377
+ * closing the levels it stepped back out of.
378
+ */
379
+ function setOpenColumn(columns, indent, match, listType) {
380
+ // An indent read from `getIndent` rather than from the columns can name a
381
+ // level no line of this import opened, so any gap below it is filled with
382
+ // the fixed step that reading assumed.
383
+ for (let i = columns.length; i < indent; i++) {
384
+ columns[i] = (i + 1) * LIST_INDENT_SIZE;
385
+ }
386
+ columns.length = indent;
387
+ columns[indent] = getContentColumn(match, listType);
388
+ }
283
389
  const listReplace = listType => {
284
390
  return (parentNode, children, match, isImport) => {
285
391
  if (richText.$isHeadingNode(parentNode) || !isImport && $isUnreplaceableBlock(parentNode)) {
@@ -287,13 +393,34 @@ const listReplace = listType => {
287
393
  }
288
394
  const previousNode = parentNode.getPreviousSibling();
289
395
  const nextNode = parentNode.getNextSibling();
290
- const listItem = list.$createListItemNode(listType === 'check' ? match[3] === 'x' : undefined);
396
+ const listItem = list.$createListItemNode(
397
+ // CHECK_LIST_REGEX matches case-insensitively, so `[X]` is checked too.
398
+ listType === 'check' ? /^x$/i.test(match[3] || '') : undefined);
291
399
  const firstMatchChar = match[0].trim()[0];
292
400
  const listMarker = (listType === 'bullet' || listType === 'check') && firstMatchChar === listMarkerState.parse(firstMatchChar) ? firstMatchChar : undefined;
293
- if (list.$isListNode(nextNode) && nextNode.getListType() === listType) {
294
- if (listMarker) {
295
- lexical.$setState(nextNode, listMarkerState, listMarker);
401
+ // A block of another kind closes every level above this line. Blank lines
402
+ // do not: they only make the list loose, and a blank line must not decide
403
+ // how the line after it is read, or the same sublist would be read one way
404
+ // written tightly and another written with a blank line before it. So the
405
+ // line is measured against, and placed into, the list it follows across
406
+ // any blank lines, and the levels close only when the nearest block that
407
+ // is not one is no longer the list itself. Outside such an import — a
408
+ // typed shortcut, or an import that keeps its blank lines as content — a
409
+ // blank line above the line is a block like any other.
410
+ const columns = importListColumns;
411
+ let precedingBlock = previousNode;
412
+ if (columns !== null) {
413
+ if (importJoinsLooseLists) {
414
+ while (precedingBlock !== null && isEmptyParagraph(precedingBlock)) {
415
+ precedingBlock = precedingBlock.getPreviousSibling();
416
+ }
296
417
  }
418
+ if (!list.$isListNode(precedingBlock)) {
419
+ columns.length = 0;
420
+ }
421
+ }
422
+ const indent = columns === null || columns.length === 0 ? getIndent(match[1]) : getColumnIndent(columns, match[1]);
423
+ if (list.$isListNode(nextNode) && nextNode.getListType() === listType) {
297
424
  const firstChild = nextNode.getFirstChild();
298
425
  if (firstChild !== null) {
299
426
  firstChild.insertBefore(listItem);
@@ -302,24 +429,24 @@ const listReplace = listType => {
302
429
  nextNode.append(listItem);
303
430
  }
304
431
  // The new list item lands at index 0, so the typed number becomes the
305
- // list's starting value. #8677.
306
- if (listType === 'number') {
432
+ // list's starting value. #8677. An indented item only passes through —
433
+ // `setIndent` below moves it into a sublist — so its number belongs to
434
+ // that sublist, not to the list it leaves.
435
+ if (listType === 'number' && indent === 0) {
307
436
  nextNode.setStart(Number(match[2]));
308
437
  }
309
438
  parentNode.remove();
310
- } else if (list.$isListNode(previousNode) && previousNode.getListType() === listType) {
311
- if (listMarker) {
312
- lexical.$setState(previousNode, listMarkerState, listMarker);
313
- }
314
- // The new item is appended at the end and inherits the existing
315
- // sequence, so the typed number is intentionally ignored here.
316
- previousNode.append(listItem);
439
+ } else if (list.$isListNode(precedingBlock) && (precedingBlock.getListType() === listType || indent > 0)) {
440
+ // An item of the same type continues the list, and inherits the
441
+ // existing sequence — the typed number is intentionally ignored. An
442
+ // indented item of another type belongs inside it too: it is appended
443
+ // at the top level here, `setIndent` below moves it to its level, and a
444
+ // list of the right type is spliced in once the level it lands in is
445
+ // known.
446
+ precedingBlock.append(listItem);
317
447
  parentNode.remove();
318
448
  } else {
319
449
  const list$1 = list.$createListNode(listType, listType === 'number' ? Number(match[2]) : undefined);
320
- if (listMarker) {
321
- lexical.$setState(list$1, listMarkerState, listMarker);
322
- }
323
450
  list$1.append(listItem);
324
451
  parentNode.replace(list$1);
325
452
  }
@@ -327,12 +454,58 @@ const listReplace = listType => {
327
454
  if (!isImport) {
328
455
  listItem.select(0, 0);
329
456
  }
330
- const indent = getIndent(match[1]);
331
457
  if (indent) {
332
458
  listItem.setIndent(indent);
459
+ $retypeNestedList(listItem, listType, match);
460
+ }
461
+ // The marker belongs to the list the item actually ends up in, which is
462
+ // only known once it has been indented and retyped.
463
+ const listNode = listItem.getParent();
464
+ if (listMarker && list.$isListNode(listNode)) {
465
+ lexical.$setState(listNode, listMarkerState, listMarker);
466
+ }
467
+ if (columns !== null) {
468
+ setOpenColumn(columns, indent, match, listType);
333
469
  }
334
470
  };
335
471
  };
472
+
473
+ /**
474
+ * `setIndent` nests an item by copying the list it is in, so an item whose
475
+ * type differs from the list above it lands in a nested list of the wrong
476
+ * type. Split it out into a list of its own type, in place, so that a sublist
477
+ * can change type the way CommonMark lets it — `1. a` may be followed by an
478
+ * indented `- b`, and that by an indented `- [ ] c`.
479
+ */
480
+ function $retypeNestedList(listItem, listType, match) {
481
+ const nestedList = listItem.getParent();
482
+ if (!list.$isListNode(nestedList) || nestedList.getListType() === listType) {
483
+ return;
484
+ }
485
+ const wrapper = nestedList.getParent();
486
+ if (!list.$isListItemNode(wrapper)) {
487
+ return;
488
+ }
489
+ const retypedList = list.$createListNode(listType, listType === 'number' ? Number(match[2]) : undefined);
490
+ // `setIndent` either appends the item to the nested list or puts it in
491
+ // front of it, so the list of its own type belongs on whichever side of the
492
+ // one it was placed in it already sits on.
493
+ const isFirst = listItem.getPreviousSibling() === null;
494
+ // `append` moves the item without disturbing the selection, which
495
+ // `remove` would relocate to a sibling and leave there — the caret has to
496
+ // stay in the item the shortcut just created.
497
+ retypedList.append(listItem);
498
+ const retypedWrapper = list.$createListItemNode();
499
+ retypedWrapper.append(retypedList);
500
+ if (isFirst) {
501
+ wrapper.insertBefore(retypedWrapper);
502
+ } else {
503
+ wrapper.insertAfter(retypedWrapper);
504
+ }
505
+ if (nestedList.getChildrenSize() === 0) {
506
+ wrapper.remove();
507
+ }
508
+ }
336
509
  const $listExport = (listNode, exportChildren, depth, selection) => {
337
510
  const output = [];
338
511
  const children = listNode.getChildren();
@@ -1702,15 +1875,22 @@ function $importMarkdownNodes(markdownString, container, transformers, shouldPre
1702
1875
  const textFormatTransformersIndex = createTextFormatTransformersIndex(byType.textFormat);
1703
1876
  const lines = markdownString.split('\n');
1704
1877
  const linesLength = lines.length;
1705
- for (let i = 0; i < linesLength; i++) {
1706
- const lineText = lines[i];
1707
- const [imported, shiftedIndex] = $importMultiline(lines, i, byType.multilineElement, container);
1708
- if (imported) {
1709
- i = shiftedIndex;
1710
- continue;
1878
+
1879
+ // A list line is measured against the column its parent item's content
1880
+ // starts at, which only the line that opened that level knows. Blank lines
1881
+ // between list lines make the list loose rather than ending it — except
1882
+ // when they are being preserved, where they are content like any block.
1883
+ withListIndentColumns(!shouldPreserveNewLines, () => {
1884
+ for (let i = 0; i < linesLength; i++) {
1885
+ const lineText = lines[i];
1886
+ const [imported, shiftedIndex] = $importMultiline(lines, i, byType.multilineElement, container);
1887
+ if (imported) {
1888
+ i = shiftedIndex;
1889
+ continue;
1890
+ }
1891
+ $importBlocks(lineText, container, byType.element, textFormatTransformersIndex, byType.textMatch, shouldPreserveNewLines);
1711
1892
  }
1712
- $importBlocks(lineText, container, byType.element, textFormatTransformersIndex, byType.textMatch, shouldPreserveNewLines);
1713
- }
1893
+ });
1714
1894
  const children = container.getChildren();
1715
1895
  for (const child of children) {
1716
1896
  if (!shouldPreserveNewLines && isEmptyParagraph(child) && container.getChildrenSize() > 1) {
@@ -1854,26 +2034,36 @@ function $importBlocks(lineText, rootNode, elementTransformers, textFormatTransf
1854
2034
  }
1855
2035
  }
1856
2036
 
1857
- // Look in node for '\t' and create a TabNode for each occurrence.
2037
+ // Look in node for '\t' and create a TabNode for each occurrence. The
2038
+ // replacement nodes are built directly rather than through
2039
+ // `splitText(...offsets)`: spreading one argument per tab boundary overflows
2040
+ // the call stack on a long run of tabs, and the text can hold arbitrarily
2041
+ // many.
1858
2042
  function $normalizeMarkdownTextNode(textNode) {
1859
- const tabOffsets = new Set();
2043
+ // A TabNode is a TextNode whose content is a tab, so without this guard the
2044
+ // rebuild below would destroy it and create an equivalent one in its place.
2045
+ if (lexical.$isTabNode(textNode)) {
2046
+ return;
2047
+ }
1860
2048
  const text = textNode.getTextContent();
1861
- let index = text.indexOf('\t');
1862
-
1863
- // Find all tab occurrences
1864
- while (index !== -1) {
1865
- tabOffsets.add(index);
1866
- tabOffsets.add(index + 1);
1867
- index = text.indexOf('\t', index + 1);
2049
+ if (!text.includes('\t')) {
2050
+ return;
1868
2051
  }
1869
-
1870
- // Split node to isolate each tab then replace '\t' into TabNode
1871
- const splitNodes = textNode.splitText(...tabOffsets);
1872
- splitNodes.forEach(node => {
1873
- if (node.getTextContent() === '\t') {
1874
- node.replace(lexical.$createTabNode());
2052
+ const format = textNode.getFormat();
2053
+ const style = textNode.getStyle();
2054
+ const nodes = [];
2055
+ let start = 0;
2056
+ for (let index = text.indexOf('\t'); index !== -1; index = text.indexOf('\t', index + 1)) {
2057
+ if (index > start) {
2058
+ nodes.push(lexical.$createTextNode(text.slice(start, index)).setFormat(format).setStyle(style));
1875
2059
  }
1876
- });
2060
+ nodes.push(lexical.$createTabNode());
2061
+ start = index + 1;
2062
+ }
2063
+ if (start < text.length) {
2064
+ nodes.push(lexical.$createTextNode(text.slice(start)).setFormat(format).setStyle(style));
2065
+ }
2066
+ textNode.getParentOrThrow().splice(textNode.getIndexWithinParent(), 1, nodes);
1877
2067
  }
1878
2068
  function createTextFormatTransformersIndex(textTransformers) {
1879
2069
  const transformersByTag = {};
@@ -6,7 +6,7 @@
6
6
  *
7
7
  */
8
8
 
9
- import { $isParagraphNode, $isTextNode, $setState, $createTextNode, $getState, $findMatchingParent, $createLineBreakNode, createState, $isLineBreakNode, $getRoot, $isElementNode, $isDecoratorNode, $createParagraphNode, $createTabNode, mergeRegister, COLLABORATION_TAG, HISTORIC_TAG, COMPOSITION_END_TAG, $getSelection, $isRangeSelection, $addUpdateTag, HISTORY_PUSH_TAG, KEY_ENTER_COMMAND, COMMAND_PRIORITY_LOW, $isRootOrShadowRoot, $createRangeSelection, $setSelection, TEXT_TYPE_TO_FORMAT, ArtificialNode__DO_NOT_USE } from 'lexical';
9
+ import { $isParagraphNode, $isTextNode, $setState, $createTextNode, $getState, $findMatchingParent, $createLineBreakNode, createState, $isLineBreakNode, $getRoot, $isElementNode, $isDecoratorNode, $isTabNode, $createTabNode, $createParagraphNode, mergeRegister, COLLABORATION_TAG, HISTORIC_TAG, COMPOSITION_END_TAG, $getSelection, $isRangeSelection, $addUpdateTag, HISTORY_PUSH_TAG, KEY_ENTER_COMMAND, COMMAND_PRIORITY_LOW, $isRootOrShadowRoot, $createRangeSelection, $setSelection, TEXT_TYPE_TO_FORMAT, ArtificialNode__DO_NOT_USE } from 'lexical';
10
10
  import { $sliceSelectedTextNodeContent } from '@lexical/selection';
11
11
  import { CodeNode, $createCodeNode, $isCodeNode } from '@lexical/code-core';
12
12
  import { LinkNode, $isLinkNode, $createLinkNode, $isAutoLinkNode } from '@lexical/link';
@@ -278,6 +278,112 @@ function getIndent(whitespaces) {
278
278
  }
279
279
  return indent;
280
280
  }
281
+
282
+ /**
283
+ * The column the text after `whitespaces` starts at, expanding a tab to the
284
+ * next multiple of `LIST_INDENT_SIZE` the way CommonMark does.
285
+ */
286
+ function getColumn(whitespaces) {
287
+ let column = 0;
288
+ for (const char of whitespaces) {
289
+ column += char === '\t' ? LIST_INDENT_SIZE - column % LIST_INDENT_SIZE : 1;
290
+ }
291
+ return column;
292
+ }
293
+
294
+ /**
295
+ * The content columns of the list levels the lines placed so far have left
296
+ * open, outermost first, or null outside a markdown import.
297
+ *
298
+ * A sublist is measured against the column where its parent item's content
299
+ * begins rather than against a fixed number of spaces: that is what lets
300
+ * `1. a` take a three-space sublist while `- a` takes a two-space one, and
301
+ * what keeps two items written at the same column siblings even when that
302
+ * column is deep enough to have opened a level.
303
+ *
304
+ * Only the line that opened a level knows the column it was written at, and
305
+ * nothing in the tree records it afterwards, so the columns are carried from
306
+ * line to line for the length of one import. A shortcut typed into an editor
307
+ * is a line on its own with no such run behind it and keeps reading its indent
308
+ * as a fixed `LIST_INDENT_SIZE` per level, which is the step `$listExport`
309
+ * writes and the one the toolbar and Tab indent by.
310
+ */
311
+ let importListColumns = null;
312
+ let importJoinsLooseLists = false;
313
+
314
+ /**
315
+ * Run `fn` with the columns of a single markdown import tracked across the
316
+ * lines it places. Restores whatever was being tracked around it, so an import
317
+ * that runs inside another one does not disturb it.
318
+ *
319
+ * `joinLooseLists` reads a blank line between list lines as making the list
320
+ * loose rather than ending it. An import that preserves new lines keeps blank
321
+ * paragraphs as content, so there a blank line closes the list like any other
322
+ * block.
323
+ *
324
+ * @internal
325
+ */
326
+ function withListIndentColumns(joinLooseLists, fn) {
327
+ const previousColumns = importListColumns;
328
+ const previousJoins = importJoinsLooseLists;
329
+ importListColumns = [];
330
+ importJoinsLooseLists = joinLooseLists;
331
+ try {
332
+ return fn();
333
+ } finally {
334
+ importListColumns = previousColumns;
335
+ importJoinsLooseLists = previousJoins;
336
+ }
337
+ }
338
+
339
+ /**
340
+ * The indent `whitespaces` names given the levels open above it: the innermost
341
+ * level whose content column it reaches, or 0 when it reaches none of them.
342
+ */
343
+ function getColumnIndent(columns, whitespaces) {
344
+ const column = getColumn(whitespaces);
345
+ for (let i = columns.length - 1; i >= 0; i--) {
346
+ if (column >= columns[i]) {
347
+ return i + 1;
348
+ }
349
+ }
350
+ return 0;
351
+ }
352
+
353
+ /**
354
+ * The column this line opens its content at: where the matched marker ends,
355
+ * with tabs expanded, so `-\ta` opens at the tab stop and not two columns in.
356
+ * A check list item is marked by its bullet — the `[ ]` is content — so
357
+ * `- [ ] a` opens where `- a` does. Capped one `LIST_INDENT_SIZE` past the
358
+ * marker so that the indent `$listExport` writes always nests: a sublist of
359
+ * `100. ` is exported with four spaces, short of that marker's real content
360
+ * column.
361
+ */
362
+ function getContentColumn(match, listType) {
363
+ let prefix = match[0];
364
+ if (listType === 'check') {
365
+ const bullet = prefix.slice(match[1].length).match(/^[-*+]\s/);
366
+ if (bullet) {
367
+ prefix = match[1] + bullet[0];
368
+ }
369
+ }
370
+ return Math.min(getColumn(prefix), getColumn(match[1]) + LIST_INDENT_SIZE);
371
+ }
372
+
373
+ /**
374
+ * Record the content column that this line leaves open for the lines below it,
375
+ * closing the levels it stepped back out of.
376
+ */
377
+ function setOpenColumn(columns, indent, match, listType) {
378
+ // An indent read from `getIndent` rather than from the columns can name a
379
+ // level no line of this import opened, so any gap below it is filled with
380
+ // the fixed step that reading assumed.
381
+ for (let i = columns.length; i < indent; i++) {
382
+ columns[i] = (i + 1) * LIST_INDENT_SIZE;
383
+ }
384
+ columns.length = indent;
385
+ columns[indent] = getContentColumn(match, listType);
386
+ }
281
387
  const listReplace = listType => {
282
388
  return (parentNode, children, match, isImport) => {
283
389
  if ($isHeadingNode(parentNode) || !isImport && $isUnreplaceableBlock(parentNode)) {
@@ -285,13 +391,34 @@ const listReplace = listType => {
285
391
  }
286
392
  const previousNode = parentNode.getPreviousSibling();
287
393
  const nextNode = parentNode.getNextSibling();
288
- const listItem = $createListItemNode(listType === 'check' ? match[3] === 'x' : undefined);
394
+ const listItem = $createListItemNode(
395
+ // CHECK_LIST_REGEX matches case-insensitively, so `[X]` is checked too.
396
+ listType === 'check' ? /^x$/i.test(match[3] || '') : undefined);
289
397
  const firstMatchChar = match[0].trim()[0];
290
398
  const listMarker = (listType === 'bullet' || listType === 'check') && firstMatchChar === listMarkerState.parse(firstMatchChar) ? firstMatchChar : undefined;
291
- if ($isListNode(nextNode) && nextNode.getListType() === listType) {
292
- if (listMarker) {
293
- $setState(nextNode, listMarkerState, listMarker);
399
+ // A block of another kind closes every level above this line. Blank lines
400
+ // do not: they only make the list loose, and a blank line must not decide
401
+ // how the line after it is read, or the same sublist would be read one way
402
+ // written tightly and another written with a blank line before it. So the
403
+ // line is measured against, and placed into, the list it follows across
404
+ // any blank lines, and the levels close only when the nearest block that
405
+ // is not one is no longer the list itself. Outside such an import — a
406
+ // typed shortcut, or an import that keeps its blank lines as content — a
407
+ // blank line above the line is a block like any other.
408
+ const columns = importListColumns;
409
+ let precedingBlock = previousNode;
410
+ if (columns !== null) {
411
+ if (importJoinsLooseLists) {
412
+ while (precedingBlock !== null && isEmptyParagraph(precedingBlock)) {
413
+ precedingBlock = precedingBlock.getPreviousSibling();
414
+ }
294
415
  }
416
+ if (!$isListNode(precedingBlock)) {
417
+ columns.length = 0;
418
+ }
419
+ }
420
+ const indent = columns === null || columns.length === 0 ? getIndent(match[1]) : getColumnIndent(columns, match[1]);
421
+ if ($isListNode(nextNode) && nextNode.getListType() === listType) {
295
422
  const firstChild = nextNode.getFirstChild();
296
423
  if (firstChild !== null) {
297
424
  firstChild.insertBefore(listItem);
@@ -300,24 +427,24 @@ const listReplace = listType => {
300
427
  nextNode.append(listItem);
301
428
  }
302
429
  // The new list item lands at index 0, so the typed number becomes the
303
- // list's starting value. #8677.
304
- if (listType === 'number') {
430
+ // list's starting value. #8677. An indented item only passes through —
431
+ // `setIndent` below moves it into a sublist — so its number belongs to
432
+ // that sublist, not to the list it leaves.
433
+ if (listType === 'number' && indent === 0) {
305
434
  nextNode.setStart(Number(match[2]));
306
435
  }
307
436
  parentNode.remove();
308
- } else if ($isListNode(previousNode) && previousNode.getListType() === listType) {
309
- if (listMarker) {
310
- $setState(previousNode, listMarkerState, listMarker);
311
- }
312
- // The new item is appended at the end and inherits the existing
313
- // sequence, so the typed number is intentionally ignored here.
314
- previousNode.append(listItem);
437
+ } else if ($isListNode(precedingBlock) && (precedingBlock.getListType() === listType || indent > 0)) {
438
+ // An item of the same type continues the list, and inherits the
439
+ // existing sequence — the typed number is intentionally ignored. An
440
+ // indented item of another type belongs inside it too: it is appended
441
+ // at the top level here, `setIndent` below moves it to its level, and a
442
+ // list of the right type is spliced in once the level it lands in is
443
+ // known.
444
+ precedingBlock.append(listItem);
315
445
  parentNode.remove();
316
446
  } else {
317
447
  const list = $createListNode(listType, listType === 'number' ? Number(match[2]) : undefined);
318
- if (listMarker) {
319
- $setState(list, listMarkerState, listMarker);
320
- }
321
448
  list.append(listItem);
322
449
  parentNode.replace(list);
323
450
  }
@@ -325,12 +452,58 @@ const listReplace = listType => {
325
452
  if (!isImport) {
326
453
  listItem.select(0, 0);
327
454
  }
328
- const indent = getIndent(match[1]);
329
455
  if (indent) {
330
456
  listItem.setIndent(indent);
457
+ $retypeNestedList(listItem, listType, match);
458
+ }
459
+ // The marker belongs to the list the item actually ends up in, which is
460
+ // only known once it has been indented and retyped.
461
+ const listNode = listItem.getParent();
462
+ if (listMarker && $isListNode(listNode)) {
463
+ $setState(listNode, listMarkerState, listMarker);
464
+ }
465
+ if (columns !== null) {
466
+ setOpenColumn(columns, indent, match, listType);
331
467
  }
332
468
  };
333
469
  };
470
+
471
+ /**
472
+ * `setIndent` nests an item by copying the list it is in, so an item whose
473
+ * type differs from the list above it lands in a nested list of the wrong
474
+ * type. Split it out into a list of its own type, in place, so that a sublist
475
+ * can change type the way CommonMark lets it — `1. a` may be followed by an
476
+ * indented `- b`, and that by an indented `- [ ] c`.
477
+ */
478
+ function $retypeNestedList(listItem, listType, match) {
479
+ const nestedList = listItem.getParent();
480
+ if (!$isListNode(nestedList) || nestedList.getListType() === listType) {
481
+ return;
482
+ }
483
+ const wrapper = nestedList.getParent();
484
+ if (!$isListItemNode(wrapper)) {
485
+ return;
486
+ }
487
+ const retypedList = $createListNode(listType, listType === 'number' ? Number(match[2]) : undefined);
488
+ // `setIndent` either appends the item to the nested list or puts it in
489
+ // front of it, so the list of its own type belongs on whichever side of the
490
+ // one it was placed in it already sits on.
491
+ const isFirst = listItem.getPreviousSibling() === null;
492
+ // `append` moves the item without disturbing the selection, which
493
+ // `remove` would relocate to a sibling and leave there — the caret has to
494
+ // stay in the item the shortcut just created.
495
+ retypedList.append(listItem);
496
+ const retypedWrapper = $createListItemNode();
497
+ retypedWrapper.append(retypedList);
498
+ if (isFirst) {
499
+ wrapper.insertBefore(retypedWrapper);
500
+ } else {
501
+ wrapper.insertAfter(retypedWrapper);
502
+ }
503
+ if (nestedList.getChildrenSize() === 0) {
504
+ wrapper.remove();
505
+ }
506
+ }
334
507
  const $listExport = (listNode, exportChildren, depth, selection) => {
335
508
  const output = [];
336
509
  const children = listNode.getChildren();
@@ -1700,15 +1873,22 @@ function $importMarkdownNodes(markdownString, container, transformers, shouldPre
1700
1873
  const textFormatTransformersIndex = createTextFormatTransformersIndex(byType.textFormat);
1701
1874
  const lines = markdownString.split('\n');
1702
1875
  const linesLength = lines.length;
1703
- for (let i = 0; i < linesLength; i++) {
1704
- const lineText = lines[i];
1705
- const [imported, shiftedIndex] = $importMultiline(lines, i, byType.multilineElement, container);
1706
- if (imported) {
1707
- i = shiftedIndex;
1708
- continue;
1876
+
1877
+ // A list line is measured against the column its parent item's content
1878
+ // starts at, which only the line that opened that level knows. Blank lines
1879
+ // between list lines make the list loose rather than ending it — except
1880
+ // when they are being preserved, where they are content like any block.
1881
+ withListIndentColumns(!shouldPreserveNewLines, () => {
1882
+ for (let i = 0; i < linesLength; i++) {
1883
+ const lineText = lines[i];
1884
+ const [imported, shiftedIndex] = $importMultiline(lines, i, byType.multilineElement, container);
1885
+ if (imported) {
1886
+ i = shiftedIndex;
1887
+ continue;
1888
+ }
1889
+ $importBlocks(lineText, container, byType.element, textFormatTransformersIndex, byType.textMatch, shouldPreserveNewLines);
1709
1890
  }
1710
- $importBlocks(lineText, container, byType.element, textFormatTransformersIndex, byType.textMatch, shouldPreserveNewLines);
1711
- }
1891
+ });
1712
1892
  const children = container.getChildren();
1713
1893
  for (const child of children) {
1714
1894
  if (!shouldPreserveNewLines && isEmptyParagraph(child) && container.getChildrenSize() > 1) {
@@ -1852,26 +2032,36 @@ function $importBlocks(lineText, rootNode, elementTransformers, textFormatTransf
1852
2032
  }
1853
2033
  }
1854
2034
 
1855
- // Look in node for '\t' and create a TabNode for each occurrence.
2035
+ // Look in node for '\t' and create a TabNode for each occurrence. The
2036
+ // replacement nodes are built directly rather than through
2037
+ // `splitText(...offsets)`: spreading one argument per tab boundary overflows
2038
+ // the call stack on a long run of tabs, and the text can hold arbitrarily
2039
+ // many.
1856
2040
  function $normalizeMarkdownTextNode(textNode) {
1857
- const tabOffsets = new Set();
2041
+ // A TabNode is a TextNode whose content is a tab, so without this guard the
2042
+ // rebuild below would destroy it and create an equivalent one in its place.
2043
+ if ($isTabNode(textNode)) {
2044
+ return;
2045
+ }
1858
2046
  const text = textNode.getTextContent();
1859
- let index = text.indexOf('\t');
1860
-
1861
- // Find all tab occurrences
1862
- while (index !== -1) {
1863
- tabOffsets.add(index);
1864
- tabOffsets.add(index + 1);
1865
- index = text.indexOf('\t', index + 1);
2047
+ if (!text.includes('\t')) {
2048
+ return;
1866
2049
  }
1867
-
1868
- // Split node to isolate each tab then replace '\t' into TabNode
1869
- const splitNodes = textNode.splitText(...tabOffsets);
1870
- splitNodes.forEach(node => {
1871
- if (node.getTextContent() === '\t') {
1872
- node.replace($createTabNode());
2050
+ const format = textNode.getFormat();
2051
+ const style = textNode.getStyle();
2052
+ const nodes = [];
2053
+ let start = 0;
2054
+ for (let index = text.indexOf('\t'); index !== -1; index = text.indexOf('\t', index + 1)) {
2055
+ if (index > start) {
2056
+ nodes.push($createTextNode(text.slice(start, index)).setFormat(format).setStyle(style));
1873
2057
  }
1874
- });
2058
+ nodes.push($createTabNode());
2059
+ start = index + 1;
2060
+ }
2061
+ if (start < text.length) {
2062
+ nodes.push($createTextNode(text.slice(start)).setFormat(format).setStyle(style));
2063
+ }
2064
+ textNode.getParentOrThrow().splice(textNode.getIndexWithinParent(), 1, nodes);
1875
2065
  }
1876
2066
  function createTextFormatTransformersIndex(textTransformers) {
1877
2067
  const transformersByTag = {};