@ckeditor/ckeditor5-list 48.1.1 → 48.2.0-alpha.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.
@@ -120,6 +120,12 @@
120
120
  },
121
121
  {
122
122
  "elements": "li"
123
+ },
124
+ {
125
+ "elements": "li",
126
+ "styles": "list-style-type",
127
+ "isAlternative": true,
128
+ "_comment": "If `config.list.enableSkipLevelLists` is set to `true`, intermediate `<li>` wrappers with `list-style-type: none` are emitted to bridge missing indent levels."
123
129
  }
124
130
  ]
125
131
  },
package/dist/index.js CHANGED
@@ -476,7 +476,11 @@ import { DomEventObserver, Matcher, ModelTreeWalker, getViewFillerOffset } from
476
476
  continue;
477
477
  }
478
478
  // Merge with parent list item while outdenting and indent matches reference indent.
479
- if (block.getAttribute('listIndent') == referenceIndent) {
479
+ // The parent block may be null if the block has no ancestor with a lower indent (e.g. when skip-level
480
+ // lists are enabled and the first item starts at indent > 0). In that case, skip the merge and just
481
+ // decrease the indent. Without skip-level lists, the post-fixer guarantees sequential indents, so
482
+ // a parent with a lower indent always exists and this guard has no effect.
483
+ if (block.getAttribute('listIndent') == referenceIndent && parentBlocks.get(block)) {
480
484
  const mergedBlocks = mergeListItemIfNotLast(block, parentBlocks.get(block), writer);
481
485
  // All list item blocks are updated while merging so add those to visited set.
482
486
  for (const mergedBlock of mergedBlocks){
@@ -655,16 +659,65 @@ import { DomEventObserver, Matcher, ModelTreeWalker, getViewFillerOffset } from
655
659
  return listType == 'numbered' || listType == 'customNumbered';
656
660
  }
657
661
  /**
658
- * Checks if the given list item is the first item in the list.
662
+ * Checks if the given list item block is the first block of the first item in its list at the given indent.
659
663
  *
660
- * This function checks if there's any other list item before the given list item
661
- * at the same indent level with the same list type.
664
+ * Walks back over previous siblings and returns:
665
+ * - `true` if it reaches a non-list block or a list block at a lower indent (a new list begins here),
666
+ * - `false` if it finds a same-indent block of the same `listItemId` (a continuation of the current item) or of the same
667
+ * `listType` (the visible list already has earlier items),
668
+ * - `true` if it finds a same-indent block of a different `listType` and a different `listItemId` (a different list ends; ours
669
+ * starts here),
670
+ * - `false` if the loop ends (it reaches the first non-list-item block, or no more previous siblings) while passing only
671
+ * higher-indent blocks (those blocks live inside an intermediate skip-level `<li style="list-style-type:none">` wrapper
672
+ * at our indent).
673
+ *
674
+ * For example, in the model:
675
+ *
676
+ * ```
677
+ * ' # aaa'
678
+ * '# bbb'
679
+ * ```
680
+ *
681
+ * `bbb` is preceded by a higher-indent block `aaa`, which in the view is rendered inside an intermediate
682
+ * skip-level wrapper at indent 0:
683
+ *
684
+ * ```html
685
+ * <ol>
686
+ * <li style="list-style-type:none">
687
+ * <ol>
688
+ * <li>aaa</li>
689
+ * </ol>
690
+ * </li>
691
+ * <li>bbb</li>
692
+ * </ol>
693
+ * ```
694
+ *
695
+ * So `bbb` is the second visible item in the outer list and the function returns `false`.
662
696
  */ function isFirstListItemInList(listItem) {
663
- const previousItem = ListWalker.first(listItem, {
664
- sameIndent: true,
665
- sameAttributes: 'listType'
666
- });
667
- return !previousItem;
697
+ const itemIndent = listItem.getAttribute('listIndent');
698
+ const itemListType = listItem.getAttribute('listType');
699
+ const itemListItemId = listItem.getAttribute('listItemId');
700
+ let previous = listItem.previousSibling;
701
+ let sawHigherIndent = false;
702
+ while(isListItemBlock(previous)){
703
+ const previousIndent = previous.getAttribute('listIndent');
704
+ if (previousIndent < itemIndent) {
705
+ return true;
706
+ }
707
+ if (previousIndent === itemIndent) {
708
+ // The previous block belongs to the current item — we are a continuation block, not the first block of
709
+ // the list item, so item-block indent should not apply (a normal list indent should fall through instead).
710
+ if (previous.getAttribute('listItemId') === itemListItemId) {
711
+ return false;
712
+ }
713
+ return previous.getAttribute('listType') !== itemListType;
714
+ }
715
+ sawHigherIndent = true;
716
+ previous = previous.previousSibling;
717
+ }
718
+ // Reached the first non-list-item block (or no more previous siblings). If only higher-indent blocks were on
719
+ // the way, they live inside an intermediate skip-level wrapper at our indent — we are not first.
720
+ return !sawHigherIndent;
668
721
  }
669
722
  /**
670
723
  * Merges a given block to the given parent block if parent is a list item and there is no more blocks in the same item.
@@ -788,6 +841,10 @@ import { DomEventObserver, Matcher, ModelTreeWalker, getViewFillerOffset } from
788
841
  if (isSingleListItem(blocks) && !isFirstBlockOfListItem(blocks[0])) {
789
842
  return true;
790
843
  }
844
+ // When skip levels are allowed, any list item can always be indented further.
845
+ if (this.editor.config.get('list.enableSkipLevelLists')) {
846
+ return true;
847
+ }
791
848
  blocks = expandListBlocksToCompleteItems(blocks);
792
849
  firstBlock = blocks[0];
793
850
  // Check if there is any list item before selected items that could become a parent of selected items.
@@ -1011,6 +1068,12 @@ import { DomEventObserver, Matcher, ModelTreeWalker, getViewFillerOffset } from
1011
1068
  const attributeNames = listEditing.getListAttributeNames();
1012
1069
  model.change((writer)=>{
1013
1070
  const { firstElement, lastElement } = this._getMergeSubjectElements(selection, shouldMergeOnBlocksContentLevel);
1071
+ // A defensive guard. When `_getMergeSubjectElements()` cannot determine a valid pair of elements
1072
+ // to merge (for example because there is no list block before/after the current one),
1073
+ // the command should be a no-op instead of throwing on `null.getAttribute()`.
1074
+ if (!firstElement || !lastElement) {
1075
+ return;
1076
+ }
1014
1077
  const firstIndent = firstElement.getAttribute('listIndent') || 0;
1015
1078
  const lastIndent = lastElement.getAttribute('listIndent');
1016
1079
  const lastElementId = lastElement.getAttribute('listItemId');
@@ -1123,6 +1186,11 @@ import { DomEventObserver, Matcher, ModelTreeWalker, getViewFillerOffset } from
1123
1186
  sameIndent: true,
1124
1187
  lowerIndent: true
1125
1188
  });
1189
+ // Fallback for "skip-level" structures where the previous list block in the model
1190
+ // happens to live at a *higher* indent (no same-or-lower-indent block precedes it).
1191
+ if (!firstElement && isListItemBlock(positionParent.previousSibling)) {
1192
+ firstElement = positionParent.previousSibling;
1193
+ }
1126
1194
  } else {
1127
1195
  firstElement = positionParent.previousSibling;
1128
1196
  }
@@ -2139,6 +2207,55 @@ import { DomEventObserver, Matcher, ModelTreeWalker, getViewFillerOffset } from
2139
2207
  return applied;
2140
2208
  }
2141
2209
 
2210
+ /**
2211
+ * Returns a consuming upcast converter for skip-level list item wrappers. It detects intermediate `<li>` elements
2212
+ * with `list-style-type:none` (generated by the skip-level downcast or by external sources) and consumes them
2213
+ * without producing a model element, so they don't end up as empty list items in the model.
2214
+ *
2215
+ * The wrapper `<li>` is consumed, but its children (nested lists) are converted normally. Because `getIndent()`
2216
+ * counts all ancestor `<li>` elements (including the consumed wrapper), nested items receive the correct indent
2217
+ * values that reflect the skip-level gap.
2218
+ *
2219
+ * Only `<li>` elements whose sole meaningful content is a nested `<ul>`/`<ol>` are treated as intermediate wrappers.
2220
+ * Anything else (text, paragraphs, custom elements, even an empty `<li>` with `list-style-type:none` carrying only
2221
+ * attributes) falls through to the regular list item upcast, so its data and attributes can be preserved by GHS
2222
+ * or other plugins.
2223
+ *
2224
+ * @internal
2225
+ */ function listItemSkipLevelConsumer() {
2226
+ return (evt, data, conversionApi)=>{
2227
+ const viewItem = data.viewItem;
2228
+ if (viewItem.getStyle('list-style-type') !== 'none') {
2229
+ return;
2230
+ }
2231
+ if (!isSkipLevelWrapper(viewItem)) {
2232
+ return;
2233
+ }
2234
+ if (!conversionApi.consumable.consume(viewItem, {
2235
+ name: true
2236
+ })) {
2237
+ return;
2238
+ }
2239
+ const { modelRange, modelCursor } = conversionApi.convertChildren(viewItem, data.modelCursor);
2240
+ data.modelRange = modelRange;
2241
+ data.modelCursor = modelCursor;
2242
+ };
2243
+ }
2244
+ /**
2245
+ * Checks whether a `<li>` view element is a skip-level intermediate wrapper, i.e. its only child is a nested
2246
+ * `<ul>`/`<ol>`. Any other content (text, `<br>`, `<p>`, custom elements, NBSP, etc.) disqualifies the element,
2247
+ * so it is upcast as a regular list item.
2248
+ */ function isSkipLevelWrapper(viewItem) {
2249
+ let hasNestedList = false;
2250
+ for (const child of viewItem.getChildren()){
2251
+ if (child.is('element', 'ul') || child.is('element', 'ol')) {
2252
+ hasNestedList = true;
2253
+ continue;
2254
+ }
2255
+ return false;
2256
+ }
2257
+ return hasNestedList;
2258
+ }
2142
2259
  /**
2143
2260
  * Returns the upcast converter for list items. It's supposed to work after the block converters (content inside list items) are converted.
2144
2261
  *
@@ -2155,6 +2272,11 @@ import { DomEventObserver, Matcher, ModelTreeWalker, getViewFillerOffset } from
2155
2272
  if (!items.length) {
2156
2273
  return;
2157
2274
  }
2275
+ // All items already have list attributes set by a recursive conversion (e.g. children of a skip-level
2276
+ // wrapper <li> that was consumed without creating a model element). Nothing left to do here.
2277
+ if (items.every((item)=>item.hasAttribute('listItemId'))) {
2278
+ return;
2279
+ }
2158
2280
  const listItemId = data.viewItem.getAttribute('data-list-item-id') || ListItemUid.next();
2159
2281
  conversionApi.consumable.consume(data.viewItem, {
2160
2282
  attributes: 'data-list-item-id'
@@ -2263,9 +2385,10 @@ import { DomEventObserver, Matcher, ModelTreeWalker, getViewFillerOffset } from
2263
2385
  }
2264
2386
  // Update the stack for the current indent level.
2265
2387
  stack[itemIndent] = {
2266
- modelAttributes: Object.fromEntries(Array.from(node.getAttributes()).filter(([key])=>attributeNames.includes(key))),
2388
+ modelAttributes: getListModelAttributes(node),
2267
2389
  modelElement: node
2268
2390
  };
2391
+ fillStackForIntermediates(node, itemIndent, stack);
2269
2392
  // Find all blocks of the current node.
2270
2393
  const blocks = getListItemBlocks(node, {
2271
2394
  direction: 'forward'
@@ -2282,6 +2405,42 @@ import { DomEventObserver, Matcher, ModelTreeWalker, getViewFillerOffset } from
2282
2405
  }
2283
2406
  return itemsToRefresh;
2284
2407
  }
2408
+ // Returns model list attributes (those tracked by `attributeNames`) of the given item as a plain object.
2409
+ function getListModelAttributes(item) {
2410
+ return Object.fromEntries(Array.from(item.getAttributes()).filter(([key])=>attributeNames.includes(key)));
2411
+ }
2412
+ // Skip-level lists have view wrappers (<ol>/<ul>) at indent levels with no matching
2413
+ // model item. Each such wrapper inherits its attributes (listStart, listStyle, etc.)
2414
+ // from a nearby model item - the first sibling found at that indent, or the closest
2415
+ // lower-indent ancestor (mirroring the downcast's fallback). Remember that item here
2416
+ // so later we can compare the wrapper against the current model and tell whether it's
2417
+ // still up to date.
2418
+ function fillStackForIntermediates(node, itemIndent, stack) {
2419
+ for(let i = itemIndent - 1; i >= 0; i--){
2420
+ if (stack[i]) {
2421
+ break;
2422
+ }
2423
+ const siblingAtIndent = findSiblingListItemAt(node, i);
2424
+ let ancestorAtLowerIndent = null;
2425
+ if (!siblingAtIndent) {
2426
+ for(let k = i - 1; k >= 0; k--){
2427
+ if (stack[k]) {
2428
+ ancestorAtLowerIndent = stack[k].modelElement;
2429
+ break;
2430
+ }
2431
+ }
2432
+ }
2433
+ const referenceItem = siblingAtIndent || ancestorAtLowerIndent || node;
2434
+ stack[i] = {
2435
+ modelAttributes: {
2436
+ ...getListModelAttributes(referenceItem),
2437
+ listItemId: `list-item-skip-${i}`,
2438
+ listIndent: i
2439
+ },
2440
+ modelElement: referenceItem
2441
+ };
2442
+ }
2443
+ }
2285
2444
  function doesItemBlockRequiresRefresh(item, blocks) {
2286
2445
  const viewElement = editing.mapper.toViewElement(item);
2287
2446
  if (!viewElement) {
@@ -2338,14 +2497,20 @@ import { DomEventObserver, Matcher, ModelTreeWalker, getViewFillerOffset } from
2338
2497
  if (!isListElement && !isListItemElement) {
2339
2498
  continue;
2340
2499
  }
2341
- const eventName = `checkAttributes:${isListItemElement ? 'item' : 'list'}`;
2342
- const needsRefresh = listEditing.fire(eventName, {
2343
- viewElement: element,
2344
- modelAttributes: stack[indent].modelAttributes,
2345
- modelReferenceElement: stack[indent].modelElement
2346
- });
2347
- if (needsRefresh) {
2348
- break;
2500
+ // The stack is indexed by listIndent, so with skip-level lists it may have empty slots
2501
+ // for skipped indent levels. Skip attribute checking for those but still track the indent
2502
+ // level below. Without skip-level lists, the post-fixer ensures sequential indents, so the
2503
+ // stack is always fully populated and this guard has no effect.
2504
+ if (stack[indent]) {
2505
+ const eventName = `checkAttributes:${isListItemElement ? 'item' : 'list'}`;
2506
+ const needsRefresh = listEditing.fire(eventName, {
2507
+ viewElement: element,
2508
+ modelAttributes: stack[indent].modelAttributes,
2509
+ modelReferenceElement: stack[indent].modelElement
2510
+ });
2511
+ if (needsRefresh) {
2512
+ break;
2513
+ }
2349
2514
  }
2350
2515
  if (isListElement) {
2351
2516
  indent--;
@@ -2365,7 +2530,7 @@ import { DomEventObserver, Matcher, ModelTreeWalker, getViewFillerOffset } from
2365
2530
  * @param attributeNames A list of attribute names that should be converted if they are set.
2366
2531
  * @param strategies The strategies.
2367
2532
  * @param model The model.
2368
- */ function listItemDowncastConverter(attributeNames, strategies, model, { dataPipeline } = {}) {
2533
+ */ function listItemDowncastConverter(attributeNames, strategies, model, { dataPipeline, enableSkipLevelLists }) {
2369
2534
  const consumer = createAttributesConsumer(attributeNames, strategies);
2370
2535
  return (evt, data, conversionApi)=>{
2371
2536
  const { writer, mapper, consumable } = conversionApi;
@@ -2379,7 +2544,8 @@ import { DomEventObserver, Matcher, ModelTreeWalker, getViewFillerOffset } from
2379
2544
  }
2380
2545
  const options = {
2381
2546
  ...conversionApi.options,
2382
- dataPipeline
2547
+ dataPipeline,
2548
+ enableSkipLevelLists
2383
2549
  };
2384
2550
  // Use positions mapping instead of mapper.toViewElement( listItem ) to find outermost view element.
2385
2551
  // This is for cases when mapping is using inner view element like in the code blocks (pre > code).
@@ -2593,35 +2759,109 @@ import { DomEventObserver, Matcher, ModelTreeWalker, getViewFillerOffset } from
2593
2759
  }
2594
2760
  /**
2595
2761
  * Wraps the given list item with appropriate attribute elements for ul, ol, and li.
2762
+ *
2763
+ * For skip-level lists (where indent gaps exist, e.g. indent 0 → indent 2), this function
2764
+ * generates intermediate wrapper pairs (ul/ol + li) at each missing level. These intermediate
2765
+ * wrappers are invisible (`list-style-type: none` on the li). Scope `'list'` strategies are
2766
+ * applied so the wrapper element (ul/ol) carries the same classes and styles as real list
2767
+ * wrappers, while scope `'item'` strategies are skipped since there is no model element
2768
+ * backing the intermediate level.
2596
2769
  */ function wrapListItemBlock(listItem, viewRange, strategies, writer, options) {
2597
2770
  if (!listItem.hasAttribute('listIndent')) {
2598
2771
  return;
2599
2772
  }
2600
2773
  const listItemIndent = listItem.getAttribute('listIndent');
2774
+ const enableSkipLevelLists = options.enableSkipLevelLists;
2601
2775
  let currentListItem = listItem;
2602
2776
  for(let indent = listItemIndent; indent >= 0; indent--){
2603
- const listItemViewElement = createListItemElement(writer, indent, currentListItem.getAttribute('listItemId'));
2604
- const listViewElement = createListElement(writer, indent, currentListItem.getAttribute('listType'));
2605
- for (const strategy of strategies){
2606
- if ((strategy.scope == 'list' || strategy.scope == 'item') && currentListItem.hasAttribute(strategy.attributeName)) {
2607
- strategy.setAttributeOnDowncast(writer, currentListItem.getAttribute(strategy.attributeName), strategy.scope == 'list' ? listViewElement : listItemViewElement, options, currentListItem);
2777
+ // When ListWalker jumps over indent levels (e.g. from indent 2 to indent 0, either because
2778
+ // enableSkipLevelLists is enabled or because the item is at the start of a fragment and its
2779
+ // nearest ancestor is further up), the levels in between have no corresponding model element.
2780
+ // We detect these "intermediate" levels by checking if currentListItem's indent doesn't match
2781
+ // the current loop indent. Handling this regardless of the enableSkipLevelLists config makes the
2782
+ // downcast resilient to unexpected skip-level states in the model.
2783
+ const isIntermediate = currentListItem.getAttribute('listIndent') !== indent;
2784
+ if (isIntermediate) {
2785
+ // Intermediate levels get invisible wrappers: list-style-type:none hides the marker on <li>,
2786
+ // and scope:'item' strategies are not applied (no data-list-item-id, etc.) since there is no
2787
+ // model element backing this level.
2788
+ //
2789
+ // The <li> uses a fixed ID per indent (`list-item-skip-N`) so that sibling items sharing
2790
+ // the same skipped level merge into one <li> (e.g. two items at indent 1 with no parent
2791
+ // at indent 0 share one intermediate <li> at indent 0).
2792
+ //
2793
+ // The <ul/ol> type is chosen to ensure correct merging at this indent:
2794
+ // - If a real list item exists at this indent (found by walking forward), use its type
2795
+ // so the intermediate wrapper merges with that item's real wrapper.
2796
+ // - Otherwise, use the ancestor's type so all intermediates at this indent share the
2797
+ // same type and merge with each other.
2798
+ const siblingAtIndent = findSiblingListItemAt(listItem, indent);
2799
+ const referenceItem = siblingAtIndent || currentListItem;
2800
+ const listType = referenceItem.getAttribute('listType');
2801
+ const listItemViewElement = createListItemElement(writer, indent, `list-item-skip-${indent}`);
2802
+ const listViewElement = createListElement(writer, indent, listType);
2803
+ writer.setStyle('list-style-type', 'none', listItemViewElement);
2804
+ // Apply scope:'list' strategies so that intermediate <ol>/<ul> wrappers carry the same
2805
+ // classes and styles as real list wrappers (e.g. multi-level-list class, todo-list class,
2806
+ // list-style-type:none). Without this, intermediate and real list elements at the same
2807
+ // indent would have mismatched attributes and could not merge correctly, causing browsers
2808
+ // to render unwanted default markers.
2809
+ //
2810
+ // Use the reference item (sibling or ancestor) as the strategy source so the attributes
2811
+ // match whatever this intermediate is supposed to merge with.
2812
+ for (const strategy of strategies){
2813
+ if (strategy.scope == 'list' && referenceItem.hasAttribute(strategy.attributeName)) {
2814
+ strategy.setAttributeOnDowncast(writer, referenceItem.getAttribute(strategy.attributeName), listViewElement, options, referenceItem);
2815
+ }
2816
+ }
2817
+ viewRange = writer.wrap(viewRange, listItemViewElement);
2818
+ viewRange = writer.wrap(viewRange, listViewElement);
2819
+ } else {
2820
+ const listItemViewElement = createListItemElement(writer, indent, currentListItem.getAttribute('listItemId'));
2821
+ const listViewElement = createListElement(writer, indent, currentListItem.getAttribute('listType'));
2822
+ for (const strategy of strategies){
2823
+ if ((strategy.scope == 'list' || strategy.scope == 'item') && currentListItem.hasAttribute(strategy.attributeName)) {
2824
+ strategy.setAttributeOnDowncast(writer, currentListItem.getAttribute(strategy.attributeName), strategy.scope == 'list' ? listViewElement : listItemViewElement, options, currentListItem);
2825
+ }
2608
2826
  }
2827
+ viewRange = writer.wrap(viewRange, listItemViewElement);
2828
+ viewRange = writer.wrap(viewRange, listViewElement);
2609
2829
  }
2610
- viewRange = writer.wrap(viewRange, listItemViewElement);
2611
- viewRange = writer.wrap(viewRange, listViewElement);
2612
2830
  if (indent == 0) {
2613
2831
  break;
2614
2832
  }
2615
- currentListItem = ListWalker.first(currentListItem, {
2616
- lowerIndent: true
2617
- });
2618
- // There is no list item with lower indent, this means this is a document fragment containing
2619
- // only a part of nested list (like copy to clipboard) so we don't need to try to wrap it further.
2620
- if (!currentListItem) {
2621
- break;
2833
+ // Only look for a parent when at a real (non-intermediate) level. At intermediate levels
2834
+ // currentListItem already points to the nearest found ancestor and won't change.
2835
+ if (!isIntermediate) {
2836
+ const nextListItem = ListWalker.first(currentListItem, {
2837
+ lowerIndent: true
2838
+ });
2839
+ if (nextListItem) {
2840
+ currentListItem = nextListItem;
2841
+ } else if (!enableSkipLevelLists) {
2842
+ break;
2843
+ }
2622
2844
  }
2623
2845
  }
2624
2846
  }
2847
+ /**
2848
+ * Walks forward from the given list item through model siblings to find the first list item block
2849
+ * at exactly the specified indent level. Stops when it encounters a non-list block or a list item
2850
+ * at a lower indent (which means we left the current subtree).
2851
+ */ function findSiblingListItemAt(listItem, targetIndent) {
2852
+ let node = listItem.nextSibling;
2853
+ while(node && isListItemBlock(node)){
2854
+ const indent = node.getAttribute('listIndent');
2855
+ if (indent < targetIndent) {
2856
+ return null;
2857
+ }
2858
+ if (indent === targetIndent) {
2859
+ return node;
2860
+ }
2861
+ node = node.nextSibling;
2862
+ }
2863
+ return null;
2864
+ }
2625
2865
  // Returns the function that is responsible for consuming attributes that are set on the model node.
2626
2866
  function createAttributesConsumer(attributeNames, strategies) {
2627
2867
  const nonConsumingAttributes = strategies.filter((strategy)=>strategy.consume === false).map((strategy)=>strategy.attributeName);
@@ -2951,6 +3191,7 @@ function shouldUseBogusParagraph(item, attributeNames, blocks = getAllListItemBl
2951
3191
  const attributeNames = this.getListAttributeNames();
2952
3192
  const multiBlock = editor.config.get('list.multiBlock');
2953
3193
  const elementName = multiBlock ? 'paragraph' : 'listItem';
3194
+ const enableSkipLevelLists = !!editor.config.get('list.enableSkipLevelLists');
2954
3195
  editor.conversion.for('upcast')// Convert <li> to a generic paragraph (or listItem element) so the content of <li> is always inside a block.
2955
3196
  // Setting the listType attribute to let other features (to-do list) know that this is part of a list item.
2956
3197
  // This is also important to properly handle simple lists so that paragraphs inside a list item won't break the list item.
@@ -2978,6 +3219,60 @@ function shouldUseBogusParagraph(item, attributeNames, blocks = getAllListItemBl
2978
3219
  },
2979
3220
  converterPriority: 'high'
2980
3221
  }).add((dispatcher)=>{
3222
+ // A `<p>` that is the only `<p>` child of its `<li>` and carries no own attributes is
3223
+ // a bogus wrapper around the list item's content. Consume its name and convert its
3224
+ // children directly into the surrounding `<li>`'s paragraph instead of letting
3225
+ // `elementToElement('p')` above create a separate paragraph and force an autobreak.
3226
+ // Otherwise we would end up with two paragraphs (one of them empty), instead of one paragraph with content.
3227
+ dispatcher.on('element:p', (evt, data, conversionApi)=>{
3228
+ const viewElement = data.viewItem;
3229
+ if (!viewElement.parent || !viewElement.parent.is('element', 'li')) {
3230
+ return;
3231
+ }
3232
+ // Empty <p> (truly empty, e.g. `<li><p></p></li>`) must go through the standard
3233
+ // `elementToElement('p')` path so the EmptyBlock upcast (priority 'lowest') takes care of it.
3234
+ if (viewElement.isEmpty) {
3235
+ return;
3236
+ }
3237
+ // Bogus only when `<p>` carries no own attributes.
3238
+ if (!viewElement.getAttributeKeys().next().done) {
3239
+ return;
3240
+ }
3241
+ // And the surrounding <li> has no other block-level content besides nested lists
3242
+ // (any text or non-list element sibling means a multi-block list item where this
3243
+ // <p> must keep its own paragraph in the model).
3244
+ for (const sibling of viewElement.parent.getChildren()){
3245
+ if (sibling === viewElement) {
3246
+ continue;
3247
+ }
3248
+ if (sibling.is('element', 'ol') || sibling.is('element', 'ul')) {
3249
+ continue;
3250
+ }
3251
+ return;
3252
+ }
3253
+ // Mark the <p> as consumed so the lower-priority `elementToElement('p')` (and the
3254
+ // Paragraph plugin's default `<p>` converter) skip it — otherwise they would
3255
+ // create a separate model paragraph and trigger the auto-break empty paragraph
3256
+ // we are working around.
3257
+ conversionApi.consumable.consume(viewElement, {
3258
+ name: true
3259
+ });
3260
+ const { modelRange, modelCursor } = conversionApi.convertChildren(viewElement, data.modelCursor);
3261
+ data.modelRange = modelRange;
3262
+ data.modelCursor = modelCursor;
3263
+ }, {
3264
+ priority: 'highest'
3265
+ });
3266
+ if (enableSkipLevelLists) {
3267
+ // A consuming converter for intermediate `<li>` wrappers produced by the skip-level downcast
3268
+ // (or by external sources). It is registered with 'high' priority so it reliably runs before
3269
+ // any other `element:li` upcast converter. Registration order inside this `.add()` is already
3270
+ // enough for our own converters, but `high` guards against other plugins that may attach their
3271
+ // own `element:li` listeners at the default priority in a separate `.add()` block.
3272
+ dispatcher.on('element:li', listItemSkipLevelConsumer(), {
3273
+ priority: 'high'
3274
+ });
3275
+ }
2981
3276
  dispatcher.on('element:li', listItemUpcastConverter());
2982
3277
  });
2983
3278
  if (!multiBlock) {
@@ -2991,7 +3286,9 @@ function shouldUseBogusParagraph(item, attributeNames, blocks = getAllListItemBl
2991
3286
  view: bogusParagraphCreator(attributeNames),
2992
3287
  converterPriority: 'high'
2993
3288
  }).add((dispatcher)=>{
2994
- dispatcher.on('attribute', listItemDowncastConverter(attributeNames, this._downcastStrategies, model));
3289
+ dispatcher.on('attribute', listItemDowncastConverter(attributeNames, this._downcastStrategies, model, {
3290
+ enableSkipLevelLists
3291
+ }));
2995
3292
  dispatcher.on('remove', listItemDowncastRemoveConverter(model.schema));
2996
3293
  });
2997
3294
  editor.conversion.for('dataDowncast').elementToElement({
@@ -3002,7 +3299,8 @@ function shouldUseBogusParagraph(item, attributeNames, blocks = getAllListItemBl
3002
3299
  converterPriority: 'high'
3003
3300
  }).add((dispatcher)=>{
3004
3301
  dispatcher.on('attribute', listItemDowncastConverter(attributeNames, this._downcastStrategies, model, {
3005
- dataPipeline: true
3302
+ dataPipeline: true,
3303
+ enableSkipLevelLists
3006
3304
  }));
3007
3305
  });
3008
3306
  const modelToViewPositionMapper = createModelToViewPositionMapper(this._downcastStrategies, editor.editing.view);
@@ -3035,12 +3333,14 @@ function shouldUseBogusParagraph(item, attributeNames, blocks = getAllListItemBl
3035
3333
  // First the low level handler.
3036
3334
  model.document.registerPostFixer((writer)=>modelChangePostFixer$1(model, writer, attributeNames, this));
3037
3335
  // Then the callbacks for the specific lists.
3038
- // The indentation fixing must be the first one...
3039
- this.on('postFixer', (evt, { listNodes, writer })=>{
3040
- evt.return = fixListIndents(listNodes, writer) || evt.return;
3041
- }, {
3042
- priority: 'high'
3043
- });
3336
+ // The indentation fixing must be the first one (but only when skip levels are not allowed)...
3337
+ if (!this.editor.config.get('list.enableSkipLevelLists')) {
3338
+ this.on('postFixer', (evt, { listNodes, writer })=>{
3339
+ evt.return = fixListIndents(listNodes, writer) || evt.return;
3340
+ }, {
3341
+ priority: 'high'
3342
+ });
3343
+ }
3044
3344
  // ...then the item ids... and after that other fixers that rely on the correct indentation and ids.
3045
3345
  this.on('postFixer', (evt, { listNodes, writer, seenIds })=>{
3046
3346
  evt.return = fixListItemIds(listNodes, seenIds, writer) || evt.return;
@@ -4651,7 +4951,8 @@ const DEFAULT_LIST_TYPE$1 = 'default';
4651
4951
  dropdownView.panelView.children.add(listPropertiesView);
4652
4952
  });
4653
4953
  // Focus the editable after executing the command.
4654
- // Overrides a default behaviour where the focus is moved to the dropdown button (#12125).
4954
+ // Overrides a default behaviour where the focus is moved to the dropdown button.
4955
+ // See https://github.com/ckeditor/ckeditor5/issues/12125.
4655
4956
  dropdownView.on('execute', ()=>{
4656
4957
  editor.editing.view.focus();
4657
4958
  });
@@ -6743,7 +7044,7 @@ const NUMBERED_LIST_STYLE_TYPES = [
6743
7044
  if (itemToListHead.has(listHead)) {
6744
7045
  return;
6745
7046
  }
6746
- for(// Cache previousSibling and reuse for performance reasons. See #6581.
7047
+ for(// Cache previousSibling and reuse for performance reasons. See https://github.com/ckeditor/ckeditor5/issues/6581.
6747
7048
  let previousSibling = listHead.previousSibling; previousSibling && previousSibling.is('element', 'listItem'); previousSibling = listHead.previousSibling){
6748
7049
  listHead = previousSibling;
6749
7050
  if (itemToListHead.has(listHead)) {
@@ -7611,7 +7912,8 @@ const DEFAULT_LIST_TYPE = 'default';
7611
7912
  listIndent: nextSibling.getAttribute('listIndent')
7612
7913
  });
7613
7914
  // The outermost list item may not exist while removing elements between lists with different value
7614
- // of the `listIndent` attribute. In such a case we don't want to update anything. See: #8073.
7915
+ // of the `listIndent` attribute. In such a case we don't want to update anything.
7916
+ // See: https://github.com/ckeditor/ckeditor5/issues/8073.
7615
7917
  if (!mostOuterItemList) {
7616
7918
  return;
7617
7919
  }
@@ -7640,7 +7942,7 @@ const DEFAULT_LIST_TYPE = 'default';
7640
7942
  direction: 'forward'
7641
7943
  });
7642
7944
  // If the selection ends in a non-list element, there are no <listItem>s that would require adjustments.
7643
- // See: #8642.
7945
+ // See: https://github.com/ckeditor/ckeditor5/issues/8642.
7644
7946
  if (!secondListMostOuterItem) {
7645
7947
  firstMostOuterItem = null;
7646
7948
  return;
@@ -7976,7 +8278,8 @@ const DEFAULT_LIST_TYPE = 'default';
7976
8278
  // ■ Paragraph[] // <-- The inserted item.
7977
8279
  while(existingListItem.is('element', 'listItem') && existingListItem.getAttribute('listIndent') !== indent){
7978
8280
  existingListItem = existingListItem.previousSibling;
7979
- // If the item does not exist, most probably there is no other content in the editor. See: #8072.
8281
+ // If the item does not exist, most probably there is no other content in the editor.
8282
+ // See: https://github.com/ckeditor/ckeditor5/issues/8072.
7980
8283
  if (!existingListItem) {
7981
8284
  break;
7982
8285
  }
@@ -7999,7 +8302,7 @@ const DEFAULT_LIST_TYPE = 'default';
7999
8302
  wasFixed = true;
8000
8303
  } else {
8001
8304
  // Adjust the `listStyle`, `listReversed` and `listStart`
8002
- // attributes for inserted (pasted) items. See #8160.
8305
+ // attributes for inserted (pasted) items. See https://github.com/ckeditor/ckeditor5/issues/8160.
8003
8306
  //
8004
8307
  // ■ List item 1. // [listStyle="square", listType="bulleted"]
8005
8308
  // ○ List item 1.1. // [listStyle="circle", listType="bulleted"]