From 7be1f5d5d9e9847879c5ffd92e98c89328860fcc Mon Sep 17 00:00:00 2001 From: Marta Motyczynska Date: Sun, 12 Apr 2026 22:45:07 +0200 Subject: [PATCH 01/33] Allow skip level lists in the model (commands and postfixers). --- .../integrations/indentblocklistcommand.ts | 11 +- .../integrations/Indentblocklistcommand.js | 48 +- packages/ckeditor5-list/src/index.ts | 1 + .../ckeditor5-list/src/list/converters.ts | 24 +- .../ckeditor5-list/src/list/listediting.ts | 11 +- .../src/list/listindentcommand.ts | 33 +- packages/ckeditor5-list/src/list/listutils.ts | 8 - .../ckeditor5-list/src/list/utils/model.ts | 23 +- packages/ckeditor5-list/src/listconfig.ts | 19 + .../tests/list/listindentcommand.js | 410 ++++++++++++++++++ .../ckeditor5-list/tests/list/utils/model.js | 79 ++++ .../manual/documentlist-skip-levels.html | 30 ++ .../tests/manual/documentlist-skip-levels.js | 105 +++++ .../tests/manual/documentlist-skip-levels.md | 11 + 14 files changed, 772 insertions(+), 41 deletions(-) create mode 100644 packages/ckeditor5-list/tests/manual/documentlist-skip-levels.html create mode 100644 packages/ckeditor5-list/tests/manual/documentlist-skip-levels.js create mode 100644 packages/ckeditor5-list/tests/manual/documentlist-skip-levels.md diff --git a/packages/ckeditor5-indent/src/integrations/indentblocklistcommand.ts b/packages/ckeditor5-indent/src/integrations/indentblocklistcommand.ts index f2c5effeb83..e9d2b165026 100644 --- a/packages/ckeditor5-indent/src/integrations/indentblocklistcommand.ts +++ b/packages/ckeditor5-indent/src/integrations/indentblocklistcommand.ts @@ -9,7 +9,7 @@ import { Command, type Editor } from '@ckeditor/ckeditor5-core'; import type { ModelDocumentSelection, ModelElement } from '@ckeditor/ckeditor5-engine'; -import { _isListItemBlock } from '@ckeditor/ckeditor5-list'; +import { _isListHead, _isListItemBlock } from '@ckeditor/ckeditor5-list'; import type { IndentBehavior } from '../indentcommandbehavior/indentbehavior.js'; @@ -79,10 +79,11 @@ export class IndentBlockListCommand extends Command { if ( !options.firstListOnly ) { const blocks = Array.from( selection.getSelectedBlocks() ); + // Collect first items of each list in the selection. for ( const block of blocks ) { if ( _isListItemBlock( block ) && - block.getAttribute( 'listIndent' ) === 0 && + _isListHead( block ) && model.schema.checkAttribute( block, 'blockIndentList' ) ) { listItems.push( block ); @@ -114,16 +115,14 @@ export class IndentBlockListCommand extends Command { */ private _getFirstListItemIfSelectionIsAtListStart( selection: ModelDocumentSelection ): ModelElement | null { const position = selection.getFirstPosition()!; - const listUtils = this.editor.plugins.get( 'ListUtils' ); const parent = position.parent as ModelElement; const schema = this.editor.model.schema; if ( position.isAtStart && _isListItemBlock( parent ) && - parent.getAttribute( 'listIndent' ) == 0 && - schema.checkAttribute( parent, 'blockIndentList' ) && - listUtils.isFirstListItemInList( parent ) + _isListHead( parent ) && + schema.checkAttribute( parent, 'blockIndentList' ) ) { return parent; } diff --git a/packages/ckeditor5-indent/tests/integrations/Indentblocklistcommand.js b/packages/ckeditor5-indent/tests/integrations/Indentblocklistcommand.js index 9ab7d1f37fc..b6fb4119255 100644 --- a/packages/ckeditor5-indent/tests/integrations/Indentblocklistcommand.js +++ b/packages/ckeditor5-indent/tests/integrations/Indentblocklistcommand.js @@ -7,7 +7,7 @@ import { ModelTestEditor } from '@ckeditor/ckeditor5-core/tests/_utils/modeltest import { _setModelData, _getModelData } from '@ckeditor/ckeditor5-engine'; import { modelList } from '../../../ckeditor5-list/tests/list/_utils/utils.js'; -import { isFirstListItemInList, expandListBlocksToCompleteList } from '../../../ckeditor5-list/src/list/utils/model.js'; +import { expandListBlocksToCompleteList } from '../../../ckeditor5-list/src/list/utils/model.js'; import { IndentUsingOffset } from '../../src/indentcommandbehavior/indentusingoffset.js'; import { IndentUsingClasses } from '../../src/indentcommandbehavior/indentusingclasses.js'; import { IndentBlockListCommand } from '../../src/integrations/indentblocklistcommand.js'; @@ -31,7 +31,7 @@ describe( 'IndentBlockListCommand', () => { sinon.stub( editor.plugins, 'get' ).callsFake( name => { if ( name === 'ListUtils' ) { - return { isFirstListItemInList, expandListBlocksToCompleteList }; + return { expandListBlocksToCompleteList }; } if ( name === 'IndentBlockListIntegration' ) { @@ -195,6 +195,28 @@ describe( 'IndentBlockListCommand', () => { expect( command.isEnabled ).to.be.true; } ); } ); + + describe( 'adjacent lists of different types', () => { + it( 'should be true when selection is at start of first bulleted item after numbered list', () => { + _setModelData( model, modelList( [ + '# foo', + '# bar', + '* []baz' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false when selection is at start of second item in bulleted list after numbered list', () => { + _setModelData( model, modelList( [ + '# foo', + '* bar', + '* []baz' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + } ); } ); describe( 'execute', () => { @@ -649,6 +671,28 @@ describe( 'IndentBlockListCommand', () => { expect( command.isEnabled ).to.be.false; } ); } ); + + describe( 'adjacent lists of different types', () => { + it( 'should be true when selection is at start of first bulleted item after numbered list', () => { + _setModelData( model, modelList( [ + '# foo', + '# bar', + '* []baz' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false when selection is at start of second item in bulleted list after numbered list', () => { + _setModelData( model, modelList( [ + '# foo', + '* bar', + '* []baz' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + } ); } ); describe( 'execute', () => { diff --git a/packages/ckeditor5-list/src/index.ts b/packages/ckeditor5-list/src/index.ts index a50d0ee1f59..23908d845a2 100644 --- a/packages/ckeditor5-list/src/index.ts +++ b/packages/ckeditor5-list/src/index.ts @@ -77,6 +77,7 @@ export { getListItemBlocks as _getListItemBlocks, getNestedListBlocks as _getNestedListBlocks, getListItems as _getListItems, + isListHead as _isListHead, isFirstBlockOfListItem as _isFirstBlockOfListItem, isLastBlockOfListItem as _isLastBlockOfListItem, expandListBlocksToCompleteItems as _expandListBlocksToCompleteItems, diff --git a/packages/ckeditor5-list/src/list/converters.ts b/packages/ckeditor5-list/src/list/converters.ts index 2b4a60e1850..f3ab1d9c10f 100644 --- a/packages/ckeditor5-list/src/list/converters.ts +++ b/packages/ckeditor5-list/src/list/converters.ts @@ -327,15 +327,21 @@ export function reconvertItemsOnDataChange( continue; } - const eventName = `checkAttributes:${ isListItemElement ? 'item' : 'list' }` as const; - const needsRefresh = listEditing.fire( eventName, { - viewElement: element as ViewElement, - modelAttributes: stack[ indent ].modelAttributes, - modelReferenceElement: stack[ indent ].modelElement - } ); - - if ( needsRefresh ) { - break; + // The stack is indexed by listIndent, so with skip-level lists it may have empty slots + // for skipped indent levels. Skip attribute checking for those but still track the indent + // level below. Without skip-level lists, the post-fixer ensures sequential indents, so the + // stack is always fully populated and this guard has no effect. + if ( stack[ indent ] ) { + const eventName = `checkAttributes:${ isListItemElement ? 'item' : 'list' }` as const; + const needsRefresh = listEditing.fire( eventName, { + viewElement: element as ViewElement, + modelAttributes: stack[ indent ].modelAttributes, + modelReferenceElement: stack[ indent ].modelElement + } ); + + if ( needsRefresh ) { + break; + } } if ( isListElement ) { diff --git a/packages/ckeditor5-list/src/list/listediting.ts b/packages/ckeditor5-list/src/list/listediting.ts index 74f633435a5..1b6f766529e 100644 --- a/packages/ckeditor5-list/src/list/listediting.ts +++ b/packages/ckeditor5-list/src/list/listediting.ts @@ -540,16 +540,17 @@ export class ListEditing extends Plugin { private _setupModelPostFixing() { const model = this.editor.model; const attributeNames = this.getListAttributeNames(); - // Register list fixing. // First the low level handler. model.document.registerPostFixer( writer => modelChangePostFixer( model, writer, attributeNames, this ) ); // Then the callbacks for the specific lists. - // The indentation fixing must be the first one... - this.on( 'postFixer', ( evt, { listNodes, writer } ) => { - evt.return = fixListIndents( listNodes, writer ) || evt.return; - }, { priority: 'high' } ); + // The indentation fixing must be the first one (but only when skip levels are not allowed)... + if ( !this.editor.config.get( 'list.allowSkipLevels' ) ) { + this.on( 'postFixer', ( evt, { listNodes, writer } ) => { + evt.return = fixListIndents( listNodes, writer ) || evt.return; + }, { priority: 'high' } ); + } // ...then the item ids... and after that other fixers that rely on the correct indentation and ids. this.on( 'postFixer', ( evt, { listNodes, writer, seenIds } ) => { diff --git a/packages/ckeditor5-list/src/list/listindentcommand.ts b/packages/ckeditor5-list/src/list/listindentcommand.ts index 069e005d158..2211bcd253e 100644 --- a/packages/ckeditor5-list/src/list/listindentcommand.ts +++ b/packages/ckeditor5-list/src/list/listindentcommand.ts @@ -13,6 +13,7 @@ import type { ModelDocumentSelection, ModelElement } from '@ckeditor/ckeditor5-e import { expandListBlocksToCompleteItems, indentBlocks, + isListHead, isFirstBlockOfListItem, isListItemBlock, isSingleListItem, @@ -132,8 +133,22 @@ export class ListIndentCommand extends Command { return false; } - // If we are outdenting it is enough to be in list item. Every list item can always be outdented. + // If we are outdenting it is enough to be in list item. Every list item can always be outdented, + // unless IndentBlock is loaded and the selection is at the start of the first list item + // in the list — in that case, defer to the block outdent command. if ( this._direction == 'backward' ) { + if ( + this.editor.config.get( 'list.allowSkipLevels' ) && + this.editor.plugins.has( 'IndentBlockListIntegration' ) + ) { + const position = this.editor.model.document.selection.getFirstPosition()!; + const parent = position.parent as ListElement; + + if ( position.isAtStart && isListHead( parent ) ) { + return false; + } + } + return true; } @@ -142,6 +157,22 @@ export class ListIndentCommand extends Command { return true; } + // When skip levels are allowed, any list item can always be indented further, + // unless IndentBlock is loaded and the selection is at the start of the first list item + // in the list — in that case, defer to the block indent command. + if ( this.editor.config.get( 'list.allowSkipLevels' ) ) { + if ( this.editor.plugins.has( 'IndentBlockListIntegration' ) ) { + const position = this.editor.model.document.selection.getFirstPosition()!; + const parent = position.parent as ListElement; + + if ( position.isAtStart && isListHead( parent ) ) { + return false; + } + } + + return true; + } + blocks = expandListBlocksToCompleteItems( blocks ); firstBlock = blocks[ 0 ]; diff --git a/packages/ckeditor5-list/src/list/listutils.ts b/packages/ckeditor5-list/src/list/listutils.ts index 265ccce3550..b59a6f9dba3 100644 --- a/packages/ckeditor5-list/src/list/listutils.ts +++ b/packages/ckeditor5-list/src/list/listutils.ts @@ -16,7 +16,6 @@ import { expandListBlocksToCompleteItems, expandListBlocksToCompleteList, isFirstBlockOfListItem, - isFirstListItemInList, isListItemBlock, isNumberedListType } from './utils/model.js'; @@ -86,11 +85,4 @@ export class ListUtils extends Plugin { public isNumberedListType( listType: ListType ): boolean { return isNumberedListType( listType ); } - - /** - * Returns true if the given list item is the first item in the list. - */ - public isFirstListItemInList( listItem: ModelElement ): boolean { - return isFirstListItemInList( listItem ); - } } diff --git a/packages/ckeditor5-list/src/list/utils/model.ts b/packages/ckeditor5-list/src/list/utils/model.ts index 0502d66e9e1..6d4d32c290b 100644 --- a/packages/ckeditor5-list/src/list/utils/model.ts +++ b/packages/ckeditor5-list/src/list/utils/model.ts @@ -376,7 +376,11 @@ export function outdentBlocksWithMerge( } // Merge with parent list item while outdenting and indent matches reference indent. - if ( block.getAttribute( 'listIndent' ) == referenceIndent ) { + // The parent block may be null if the block has no ancestor with a lower indent (e.g. when skip-level + // lists are enabled and the first item starts at indent > 0). In that case, skip the merge and just + // decrease the indent. Without skip-level lists, the post-fixer guarantees sequential indents, so + // a parent with a lower indent always exists and this guard has no effect. + if ( block.getAttribute( 'listIndent' ) == referenceIndent && parentBlocks.get( block ) ) { const mergedBlocks = mergeListItemIfNotLast( block, parentBlocks.get( block ), writer ); // All list item blocks are updated while merging so add those to visited set. @@ -598,18 +602,17 @@ export function isNumberedListType( listType: ListType ): boolean { } /** - * Checks if the given list item is the first item in the list. + * Checks whether the given list item block is the first block of its list. A block is considered + * the first in its list if there is no preceding sibling that is a list item of the same type. + * This works for any starting indent level and correctly handles adjacent lists of different types. * - * This function checks if there's any other list item before the given list item - * at the same indent level with the same list type. + * @internal */ -export function isFirstListItemInList( listItem: ModelElement ): boolean { - const previousItem = ListWalker.first( listItem, { - sameIndent: true, - sameAttributes: 'listType' - } ); +export function isListHead( node: ListElement ): boolean { + const previousSibling = node.previousSibling; - return !previousItem; + return !previousSibling || !isListItemBlock( previousSibling ) || + previousSibling.getAttribute( 'listType' ) !== node.getAttribute( 'listType' ); } /** diff --git a/packages/ckeditor5-list/src/listconfig.ts b/packages/ckeditor5-list/src/listconfig.ts index 88511cdd35b..385154729dd 100644 --- a/packages/ckeditor5-list/src/listconfig.ts +++ b/packages/ckeditor5-list/src/listconfig.ts @@ -60,6 +60,25 @@ export interface ListConfig { * @default true */ enableListItemMarkerFormatting?: boolean; + + /** + * When set to `true`, list items can be indented by more than one level relative to their parent. + * By default, the editor enforces that each nested list item is only one level deeper than its parent. + * + * ```ts + * ClassicEditor + * .create( editorElement, { + * list: { + * allowSkipLevels: true + * } + * } ) + * .then( ... ) + * .catch( ... ); + * ``` + * + * @default false + */ + allowSkipLevels?: boolean; } /** diff --git a/packages/ckeditor5-list/tests/list/listindentcommand.js b/packages/ckeditor5-list/tests/list/listindentcommand.js index 628e71ce886..de9bd4ef63d 100644 --- a/packages/ckeditor5-list/tests/list/listindentcommand.js +++ b/packages/ckeditor5-list/tests/list/listindentcommand.js @@ -1194,4 +1194,414 @@ describe( 'ListIndentCommand', () => { } ); } ); } ); + + describe( 'forward (indent) - allowSkipLevels', () => { + let command; + + beforeEach( () => { + editor.config.set( 'list.allowSkipLevels', true ); + + command = new ListIndentCommand( editor, 'forward' ); + } ); + + afterEach( () => { + command.destroy(); + } ); + + describe( 'isEnabled', () => { + it( 'should be true for first list item (no IndentBlock)', () => { + _setModelData( model, modelList( [ + '* []0', + '* 1' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true for second list item', () => { + _setModelData( model, modelList( [ + '* 0', + '* []1' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true for a deeply nested item', () => { + _setModelData( model, modelList( [ + '* 0', + ' * 1', + ' * []2' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true when first item at given indent has no sibling', () => { + _setModelData( model, modelList( [ + '* 0', + ' * []1' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + describe( 'with IndentBlockListIntegration', () => { + beforeEach( () => { + sinon.stub( editor.plugins, 'has' ).callsFake( name => { + return name === 'IndentBlockListIntegration'; + } ); + } ); + + it( 'should be false when selection is at start of first list item', () => { + _setModelData( model, modelList( [ + '* []0', + '* 1' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false when a non-collapsed selection starts at the start of first list item', () => { + _setModelData( model, modelList( [ + '* [0]', + '* 1' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be true when selection is not at start of first list item', () => { + _setModelData( model, modelList( [ + '* 0[]', + '* 1' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true for second list item', () => { + _setModelData( model, modelList( [ + '* 0', + '* []1' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false when selection is at start of first item preceded by a non-list block', () => { + _setModelData( model, modelList( [ + 'foo', + '* []0', + '* 1' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false when selection is at start of first bulleted item after numbered list', () => { + _setModelData( model, modelList( [ + '# 0', + '# 1', + '* []2', + '* 3' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be true when selection is at start of a non-first item in the list', () => { + _setModelData( model, modelList( [ + '* 0', + '* []1', + '* 2' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + } ); + } ); + + describe( 'execute()', () => { + it( 'should indent the first list item by one level (skip-level)', () => { + _setModelData( model, modelList( [ + '* []0', + '* 1' + ] ) ); + + command.execute(); + + expect( _getModelData( model ) ).to.equalMarkup( modelList( [ + ' * []0', + '* 1' + ] ) ); + } ); + + it( 'should indent a list item that already has no sibling', () => { + _setModelData( model, modelList( [ + '* 0', + ' * []1' + ] ) ); + + command.execute(); + + expect( _getModelData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * []1' + ] ) ); + } ); + + it( 'should indent a deeply nested item', () => { + _setModelData( model, modelList( [ + '* 0', + ' * 1', + ' * []2' + ] ) ); + + command.execute(); + + expect( _getModelData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * 1', + ' * []2' + ] ) ); + } ); + + it( 'should indent only selected items when multiple items are selected', () => { + _setModelData( model, modelList( [ + '* 0', + '* [1', + '* 2', + '* 3]', + '* 4' + ] ) ); + + command.execute(); + + expect( _getModelData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * [1', + ' * 2', + ' * 3]', + '* 4' + ] ) ); + } ); + + it( 'should indent a list item together with its nested items', () => { + _setModelData( model, modelList( [ + '* 0', + '* []1', + ' * 2', + ' * 3', + ' * 4', + '* 5', + '* 6' + ] ) ); + + command.execute(); + + expect( _getModelData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * []1', + ' * 2', + ' * 3', + ' * 4', + '* 5', + '* 6' + ] ) ); + } ); + + describe( 'with IndentBlockListIntegration', () => { + beforeEach( () => { + sinon.stub( editor.plugins, 'has' ).callsFake( name => { + return name === 'IndentBlockListIntegration'; + } ); + } ); + + it( 'should indent the first list item when selection is not at the start of the item', () => { + _setModelData( model, modelList( [ + '* 0[]', + '* 1' + ] ) ); + + command.execute(); + + expect( _getModelData( model ) ).to.equalMarkup( modelList( [ + ' * 0[]', + '* 1' + ] ) ); + } ); + } ); + } ); + } ); + + describe( 'backward (outdent) - allowSkipLevels', () => { + let command; + + beforeEach( () => { + editor.config.set( 'list.allowSkipLevels', true ); + + command = new ListIndentCommand( editor, 'backward' ); + } ); + + afterEach( () => { + command.destroy(); + } ); + + describe( 'isEnabled', () => { + it( 'should be true if selection starts in skip-level list item', () => { + _setModelData( model, modelList( [ + '* 0', + ' * []1' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if selection starts in first list item', () => { + _setModelData( model, modelList( [ + ' * []0', + '* 1' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true in a deeply nested list item', () => { + _setModelData( model, modelList( [ + '* 0', + ' * 1', + ' * []2' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + describe( 'with IndentBlockListIntegration', () => { + beforeEach( () => { + sinon.stub( editor.plugins, 'has' ).callsFake( name => { + return name === 'IndentBlockListIntegration'; + } ); + } ); + + it( 'should be false when selection is at start of first list item', () => { + _setModelData( model, modelList( [ + ' * []0' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false when a non-collapsed selection starts at the start of first list item', () => { + _setModelData( model, modelList( [ + ' * [0]' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be true when selection is not at start of first list item', () => { + _setModelData( model, modelList( [ + ' * 0[]' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + } ); + } ); + + describe( 'execute()', () => { + it( 'should outdent a skip-level item', () => { + _setModelData( model, modelList( [ + '* 0', + ' * []1' + ] ) ); + + command.execute(); + + expect( _getModelData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * []1' + ] ) ); + } ); + + it( 'should outdent first item starting at indent > 0', () => { + _setModelData( model, modelList( [ + ' * []0' + ] ) ); + + command.execute(); + + expect( _getModelData( model ) ).to.equalMarkup( modelList( [ + ' * []0' + ] ) ); + } ); + + it( 'should outdent only selected items when multiple items are selected', () => { + _setModelData( model, modelList( [ + '* 0', + ' * [1', + ' * 2', + ' * 3]', + '* 4' + ] ) ); + + command.execute(); + + expect( _getModelData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* [1', + '* 2', + '* 3]', + '* 4' + ] ) ); + } ); + + it( 'should outdent a list item together with its nested items', () => { + _setModelData( model, modelList( [ + '* 0', + ' * []1', + ' * 2', + ' * 3', + ' * 4', + '* 5', + '* 6' + ] ) ); + + command.execute(); + + expect( _getModelData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* []1', + ' * 2', + ' * 3', + ' * 4', + '* 5', + '* 6' + ] ) ); + } ); + + describe( 'with IndentBlockListIntegration', () => { + beforeEach( () => { + sinon.stub( editor.plugins, 'has' ).callsFake( name => { + return name === 'IndentBlockListIntegration'; + } ); + } ); + + it( 'should outdent the first list item when selection is not at the start of the item', () => { + _setModelData( model, modelList( [ + ' * 0[]', + '* 1' + ] ) ); + + command.execute(); + + expect( _getModelData( model ) ).to.equalMarkup( modelList( [ + '* 0[]', + '* 1' + ] ) ); + } ); + } ); + } ); + } ); } ); diff --git a/packages/ckeditor5-list/tests/list/utils/model.js b/packages/ckeditor5-list/tests/list/utils/model.js index 233c541998e..92a97e7cfdd 100644 --- a/packages/ckeditor5-list/tests/list/utils/model.js +++ b/packages/ckeditor5-list/tests/list/utils/model.js @@ -13,6 +13,7 @@ import { indentBlocks, isFirstBlockOfListItem, isLastBlockOfListItem, + isListHead, isSingleListItem, ListItemUid, mergeListItemBefore, @@ -1821,4 +1822,82 @@ describe( 'List - utils - model', () => { ] ); } ); } ); + + describe( 'isListHead()', () => { + it( 'should return true for the first list item in the document', () => { + const input = modelList( [ + '* a', + '* b' + ] ); + + const fragment = _parseModel( input, schema ); + + expect( isListHead( fragment.getChild( 0 ) ) ).to.be.true; + } ); + + it( 'should return false for a list item preceded by another list item of the same type', () => { + const input = modelList( [ + '* a', + '* b' + ] ); + + const fragment = _parseModel( input, schema ); + + expect( isListHead( fragment.getChild( 1 ) ) ).to.be.false; + } ); + + it( 'should return true for a list item preceded by a non-list block', () => { + const input = modelList( [ + 'foo', + '* a' + ] ); + + const fragment = _parseModel( input, schema ); + + expect( isListHead( fragment.getChild( 1 ) ) ).to.be.true; + } ); + + it( 'should return true for a list item preceded by a list item of a different type', () => { + const input = modelList( [ + '* a', + '# b' + ] ); + + const fragment = _parseModel( input, schema ); + + expect( isListHead( fragment.getChild( 1 ) ) ).to.be.true; + } ); + + it( 'should return false for a nested list item preceded by a list item of the same type', () => { + const input = modelList( [ + '* a', + ' * b', + ' * c' + ] ); + + const fragment = _parseModel( input, schema ); + + expect( isListHead( fragment.getChild( 2 ) ) ).to.be.false; + } ); + + it( 'should return true for the first item after a list of a different type (regression)', () => { + const input = modelList( [ + '# a', + '# b', + '* c', + '* d' + ] ); + + const fragment = _parseModel( input, schema ); + + // First numbered item. + expect( isListHead( fragment.getChild( 0 ) ) ).to.be.true; + // Second numbered item. + expect( isListHead( fragment.getChild( 1 ) ) ).to.be.false; + // First bulleted item. + expect( isListHead( fragment.getChild( 2 ) ) ).to.be.true; + // Second bulleted item. + expect( isListHead( fragment.getChild( 3 ) ) ).to.be.false; + } ); + } ); } ); diff --git a/packages/ckeditor5-list/tests/manual/documentlist-skip-levels.html b/packages/ckeditor5-list/tests/manual/documentlist-skip-levels.html new file mode 100644 index 00000000000..ea12b65de3b --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/documentlist-skip-levels.html @@ -0,0 +1,30 @@ +
+

Numbered list (try indenting multiple times)

+
    +
  1. Item at level 0
  2. +
  3. Item at level 0 +
      +
    1. Item at level 1 +
        +
      1. Item at level 2
      2. +
      +
    2. +
    +
  4. +
  5. Item at level 0
  6. +
+ +

Bulleted list

+
    +
  • Item at level 0
  • +
  • Item at level 0
  • +
  • Item at level 0
  • +
+ +

Mixed content

+
    +
  1. A numbered list with lower-roman numbering that starts at 3.
  2. +
  3. Item with multiple blocks...
    ...and this is the 3rd block of it.
  4. +
  5. This is a list item with two paragraphs.

    And here is the last one.

  6. +
+
diff --git a/packages/ckeditor5-list/tests/manual/documentlist-skip-levels.js b/packages/ckeditor5-list/tests/manual/documentlist-skip-levels.js new file mode 100644 index 00000000000..c68493d0a17 --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/documentlist-skip-levels.js @@ -0,0 +1,105 @@ +/** + * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options + */ + +import { ClassicEditor } from '@ckeditor/ckeditor5-editor-classic'; +import { Alignment } from '@ckeditor/ckeditor5-alignment'; +import { ImageResize, ImageUpload, Image, ImageCaption, ImageStyle, ImageToolbar } from '@ckeditor/ckeditor5-image'; +import { CodeBlock } from '@ckeditor/ckeditor5-code-block'; +import { HorizontalLine } from '@ckeditor/ckeditor5-horizontal-line'; +import { HtmlEmbed } from '@ckeditor/ckeditor5-html-embed'; +import { HtmlComment } from '@ckeditor/ckeditor5-html-support'; +import { LinkImage, Link } from '@ckeditor/ckeditor5-link'; +import { PageBreak } from '@ckeditor/ckeditor5-page-break'; +import { SourceEditing } from '@ckeditor/ckeditor5-source-editing'; +import { TableCaption, Table, TableToolbar } from '@ckeditor/ckeditor5-table'; +import { Essentials } from '@ckeditor/ckeditor5-essentials'; +import { BlockQuote } from '@ckeditor/ckeditor5-block-quote'; +import { Bold, Italic } from '@ckeditor/ckeditor5-basic-styles'; +import { Heading } from '@ckeditor/ckeditor5-heading'; +import { Indent, IndentBlock } from '@ckeditor/ckeditor5-indent'; +import { MediaEmbed } from '@ckeditor/ckeditor5-media-embed'; +import { Paragraph } from '@ckeditor/ckeditor5-paragraph'; +import { TodoList } from '../../src/todolist.js'; + +import { List } from '../../src/list.js'; +import { ListProperties } from '../../src/listproperties.js'; + +ClassicEditor + .create( { + attachTo: document.querySelector( '#editor' ), + plugins: [ + Essentials, BlockQuote, Bold, Heading, Image, ImageCaption, ImageStyle, ImageToolbar, Indent, IndentBlock, Italic, Link, + MediaEmbed, Paragraph, Table, TableToolbar, CodeBlock, TableCaption, ImageResize, LinkImage, + HtmlEmbed, HtmlComment, Alignment, PageBreak, HorizontalLine, ImageUpload, + SourceEditing, List, ListProperties, TodoList + ], + toolbar: [ + 'sourceEditing', '|', + 'numberedList', 'bulletedList', 'todoList', '|', + 'outdent', 'indent', '|', + 'heading', '|', + 'bold', 'italic', 'link', '|', + 'blockQuote', 'insertTable', 'mediaEmbed', 'codeBlock', '|', + 'htmlEmbed', '|', + 'alignment', '|', + 'pageBreak', 'horizontalLine', '|', + 'undo', 'redo' + ], + table: { + contentToolbar: [ + 'tableColumn', 'tableRow', 'mergeTableCells', 'toggleTableCaption' + ] + }, + image: { + styles: [ + 'alignCenter', + 'alignLeft', + 'alignRight' + ], + resizeOptions: [ + { + name: 'resizeImage:original', + label: 'Original size', + value: null + }, + { + name: 'resizeImage:50', + label: '50%', + value: '50' + }, + { + name: 'resizeImage:75', + label: '75%', + value: '75' + } + ], + toolbar: [ + 'imageTextAlternative', 'toggleImageCaption', '|', + 'imageStyle:inline', 'imageStyle:breakText', 'imageStyle:wrapText', '|', + 'resizeImage' + ] + }, + htmlEmbed: { + showPreviews: true, + sanitizeHtml: html => ( { html, hasChange: false } ) + }, + list: { + properties: { + styles: true, + startIndex: true, + reversed: true + }, + allowSkipLevels: true + }, + menuBar: { + isVisible: true + } + } ) + .then( editor => { + window.editor = editor; + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/packages/ckeditor5-list/tests/manual/documentlist-skip-levels.md b/packages/ckeditor5-list/tests/manual/documentlist-skip-levels.md new file mode 100644 index 00000000000..fec7cc51b28 --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/documentlist-skip-levels.md @@ -0,0 +1,11 @@ +# List with skip levels (`allowSkipLevels: true`) + +Editor with `list.allowSkipLevels` set to `true`. List items can be indented by more than one level relative to their parent. + +## Testing + +1. Place the cursor on a list item and press `Tab` (or use the indent button) multiple times. The item should keep indenting without limit. +2. The first item of a list should also be indentable (the list can start at level > 0). +3. Outdenting should work as expected (one level at a time). +4. Check the source editing view to verify the nested `
    `/`
      ` structure. +5. Copy-paste list items between levels and verify the structure is preserved. From 15162bf159eaf3d7b3d6caa7d096ee295fec0000 Mon Sep 17 00:00:00 2001 From: Marta Motyczynska Date: Mon, 13 Apr 2026 16:39:25 +0200 Subject: [PATCH 02/33] It should be possible to outdent first list item (turn into paragraph) at indent 0. --- .../src/list/listindentcommand.ts | 16 +---- .../tests/list/listindentcommand.js | 65 +++++++++++++++++-- 2 files changed, 62 insertions(+), 19 deletions(-) diff --git a/packages/ckeditor5-list/src/list/listindentcommand.ts b/packages/ckeditor5-list/src/list/listindentcommand.ts index 2211bcd253e..4be8df1b3f0 100644 --- a/packages/ckeditor5-list/src/list/listindentcommand.ts +++ b/packages/ckeditor5-list/src/list/listindentcommand.ts @@ -133,22 +133,8 @@ export class ListIndentCommand extends Command { return false; } - // If we are outdenting it is enough to be in list item. Every list item can always be outdented, - // unless IndentBlock is loaded and the selection is at the start of the first list item - // in the list — in that case, defer to the block outdent command. + // If we are outdenting it is enough to be in list item. Every list item can always be outdented. if ( this._direction == 'backward' ) { - if ( - this.editor.config.get( 'list.allowSkipLevels' ) && - this.editor.plugins.has( 'IndentBlockListIntegration' ) - ) { - const position = this.editor.model.document.selection.getFirstPosition()!; - const parent = position.parent as ListElement; - - if ( position.isAtStart && isListHead( parent ) ) { - return false; - } - } - return true; } diff --git a/packages/ckeditor5-list/tests/list/listindentcommand.js b/packages/ckeditor5-list/tests/list/listindentcommand.js index de9bd4ef63d..fa926eb112e 100644 --- a/packages/ckeditor5-list/tests/list/listindentcommand.js +++ b/packages/ckeditor5-list/tests/list/listindentcommand.js @@ -1484,20 +1484,20 @@ describe( 'ListIndentCommand', () => { } ); } ); - it( 'should be false when selection is at start of first list item', () => { + it( 'should be true when selection is at start of first list item', () => { _setModelData( model, modelList( [ ' * []0' ] ) ); - expect( command.isEnabled ).to.be.false; + expect( command.isEnabled ).to.be.true; } ); - it( 'should be false when a non-collapsed selection starts at the start of first list item', () => { + it( 'should be true when a non-collapsed selection starts at the start of first list item', () => { _setModelData( model, modelList( [ ' * [0]' ] ) ); - expect( command.isEnabled ).to.be.false; + expect( command.isEnabled ).to.be.true; } ); it( 'should be true when selection is not at start of first list item', () => { @@ -1507,6 +1507,35 @@ describe( 'ListIndentCommand', () => { expect( command.isEnabled ).to.be.true; } ); + + it( 'should be true for a single list item at indent 0 with no block indent', () => { + _setModelData( model, modelList( [ + '* []0' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true when selection is at start of first list item preceded by a non-list block', () => { + _setModelData( model, modelList( [ + 'foo', + '* []0', + '* 1' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true when selection is at start of first bulleted item after numbered list', () => { + _setModelData( model, modelList( [ + '# 0', + '# 1', + '* []2', + '* 3' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); } ); } ); @@ -1601,6 +1630,34 @@ describe( 'ListIndentCommand', () => { '* 1' ] ) ); } ); + + it( 'should outdent the first list item at indent 0 back to a paragraph', () => { + _setModelData( model, modelList( [ + '* []0' + ] ) ); + + command.execute(); + + expect( _getModelData( model ) ).to.equalMarkup( modelList( [ + '[]0' + ] ) ); + } ); + + it( 'should outdent the first list item at indent 0 preceded by a non-list block', () => { + _setModelData( model, modelList( [ + 'foo', + '* []0', + '* 1' + ] ) ); + + command.execute(); + + expect( _getModelData( model ) ).to.equalMarkup( modelList( [ + 'foo', + '[]0', + '* 1' + ] ) ); + } ); } ); } ); } ); From 8df79190001919784e656a7a4ee99b74523723c2 Mon Sep 17 00:00:00 2001 From: Marta Motyczynska Date: Mon, 13 Apr 2026 23:31:36 +0200 Subject: [PATCH 03/33] Correct how top-level first list item in the list is found. --- .../integrations/indentblocklistcommand.ts | 11 +- .../integrations/Indentblocklistcommand.js | 78 +++++++- packages/ckeditor5-list/src/index.ts | 2 +- .../src/list/listindentcommand.ts | 5 +- packages/ckeditor5-list/src/list/listutils.ts | 8 + .../ckeditor5-list/src/list/utils/model.ts | 31 +++- .../tests/list/listindentcommand.js | 35 ++++ .../ckeditor5-list/tests/list/utils/model.js | 169 ++++++++++++++++-- 8 files changed, 311 insertions(+), 28 deletions(-) diff --git a/packages/ckeditor5-indent/src/integrations/indentblocklistcommand.ts b/packages/ckeditor5-indent/src/integrations/indentblocklistcommand.ts index e9d2b165026..8514097a806 100644 --- a/packages/ckeditor5-indent/src/integrations/indentblocklistcommand.ts +++ b/packages/ckeditor5-indent/src/integrations/indentblocklistcommand.ts @@ -9,7 +9,7 @@ import { Command, type Editor } from '@ckeditor/ckeditor5-core'; import type { ModelDocumentSelection, ModelElement } from '@ckeditor/ckeditor5-engine'; -import { _isListHead, _isListItemBlock } from '@ckeditor/ckeditor5-list'; +import { _isListItemBlock, _isTopLevelListItem } from '@ckeditor/ckeditor5-list'; import type { IndentBehavior } from '../indentcommandbehavior/indentbehavior.js'; @@ -79,11 +79,10 @@ export class IndentBlockListCommand extends Command { if ( !options.firstListOnly ) { const blocks = Array.from( selection.getSelectedBlocks() ); - // Collect first items of each list in the selection. for ( const block of blocks ) { if ( _isListItemBlock( block ) && - _isListHead( block ) && + _isTopLevelListItem( block ) && model.schema.checkAttribute( block, 'blockIndentList' ) ) { listItems.push( block ); @@ -115,14 +114,16 @@ export class IndentBlockListCommand extends Command { */ private _getFirstListItemIfSelectionIsAtListStart( selection: ModelDocumentSelection ): ModelElement | null { const position = selection.getFirstPosition()!; + const listUtils = this.editor.plugins.get( 'ListUtils' ); const parent = position.parent as ModelElement; const schema = this.editor.model.schema; if ( position.isAtStart && _isListItemBlock( parent ) && - _isListHead( parent ) && - schema.checkAttribute( parent, 'blockIndentList' ) + _isTopLevelListItem( parent ) && + schema.checkAttribute( parent, 'blockIndentList' ) && + listUtils.isFirstListItemInList( parent ) ) { return parent; } diff --git a/packages/ckeditor5-indent/tests/integrations/Indentblocklistcommand.js b/packages/ckeditor5-indent/tests/integrations/Indentblocklistcommand.js index b6fb4119255..888a099099b 100644 --- a/packages/ckeditor5-indent/tests/integrations/Indentblocklistcommand.js +++ b/packages/ckeditor5-indent/tests/integrations/Indentblocklistcommand.js @@ -7,7 +7,7 @@ import { ModelTestEditor } from '@ckeditor/ckeditor5-core/tests/_utils/modeltest import { _setModelData, _getModelData } from '@ckeditor/ckeditor5-engine'; import { modelList } from '../../../ckeditor5-list/tests/list/_utils/utils.js'; -import { expandListBlocksToCompleteList } from '../../../ckeditor5-list/src/list/utils/model.js'; +import { isFirstListItemInList, expandListBlocksToCompleteList } from '../../../ckeditor5-list/src/list/utils/model.js'; import { IndentUsingOffset } from '../../src/indentcommandbehavior/indentusingoffset.js'; import { IndentUsingClasses } from '../../src/indentcommandbehavior/indentusingclasses.js'; import { IndentBlockListCommand } from '../../src/integrations/indentblocklistcommand.js'; @@ -31,7 +31,7 @@ describe( 'IndentBlockListCommand', () => { sinon.stub( editor.plugins, 'get' ).callsFake( name => { if ( name === 'ListUtils' ) { - return { expandListBlocksToCompleteList }; + return { isFirstListItemInList, expandListBlocksToCompleteList }; } if ( name === 'IndentBlockListIntegration' ) { @@ -217,6 +217,43 @@ describe( 'IndentBlockListCommand', () => { expect( command.isEnabled ).to.be.false; } ); } ); + + describe( 'when skip-level lists are enabled', () => { + it( 'should be true when selection is at start of a skip-level list item preceded by a paragraph', () => { + _setModelData( model, modelList( [ + 'foo', + ' * []bar' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true when skip-level list item is the first element in the document', () => { + _setModelData( model, modelList( [ + ' * []bar' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false for a sublist item (has parent list item)', () => { + _setModelData( model, modelList( [ + '* foo', + ' * []bar' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false for a skip-level list item preceded by a list item of another type', () => { + _setModelData( model, modelList( [ + '# foo', + ' * []bar' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + } ); } ); describe( 'execute', () => { @@ -693,6 +730,43 @@ describe( 'IndentBlockListCommand', () => { expect( command.isEnabled ).to.be.false; } ); } ); + + describe( 'when skip-level lists are enabled', () => { + it( 'should be true when selection is at start of a skip-level list item preceded by a paragraph', () => { + _setModelData( model, modelList( [ + 'foo', + ' * []bar' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true when skip-level list item is the first element in the document', () => { + _setModelData( model, modelList( [ + ' * []bar' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false for a sublist item (has parent list item)', () => { + _setModelData( model, modelList( [ + '* foo', + ' * []bar' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false for a skip-level list item preceded by a list item of another type', () => { + _setModelData( model, modelList( [ + '# foo', + ' * []bar' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + } ); } ); describe( 'execute', () => { diff --git a/packages/ckeditor5-list/src/index.ts b/packages/ckeditor5-list/src/index.ts index 23908d845a2..4eb1898f248 100644 --- a/packages/ckeditor5-list/src/index.ts +++ b/packages/ckeditor5-list/src/index.ts @@ -77,9 +77,9 @@ export { getListItemBlocks as _getListItemBlocks, getNestedListBlocks as _getNestedListBlocks, getListItems as _getListItems, - isListHead as _isListHead, isFirstBlockOfListItem as _isFirstBlockOfListItem, isLastBlockOfListItem as _isLastBlockOfListItem, + isTopLevelListItem as _isTopLevelListItem, expandListBlocksToCompleteItems as _expandListBlocksToCompleteItems, expandListBlocksToCompleteList as _expandListBlocksToCompleteList, splitListItemBefore as _splitListItemBefore, diff --git a/packages/ckeditor5-list/src/list/listindentcommand.ts b/packages/ckeditor5-list/src/list/listindentcommand.ts index 4be8df1b3f0..0d68da68617 100644 --- a/packages/ckeditor5-list/src/list/listindentcommand.ts +++ b/packages/ckeditor5-list/src/list/listindentcommand.ts @@ -13,10 +13,11 @@ import type { ModelDocumentSelection, ModelElement } from '@ckeditor/ckeditor5-e import { expandListBlocksToCompleteItems, indentBlocks, - isListHead, isFirstBlockOfListItem, + isFirstListItemInList, isListItemBlock, isSingleListItem, + isTopLevelListItem, outdentBlocksWithMerge, sortBlocks, splitListItemBefore, @@ -151,7 +152,7 @@ export class ListIndentCommand extends Command { const position = this.editor.model.document.selection.getFirstPosition()!; const parent = position.parent as ListElement; - if ( position.isAtStart && isListHead( parent ) ) { + if ( position.isAtStart && isTopLevelListItem( parent ) && isFirstListItemInList( parent ) ) { return false; } } diff --git a/packages/ckeditor5-list/src/list/listutils.ts b/packages/ckeditor5-list/src/list/listutils.ts index b59a6f9dba3..265ccce3550 100644 --- a/packages/ckeditor5-list/src/list/listutils.ts +++ b/packages/ckeditor5-list/src/list/listutils.ts @@ -16,6 +16,7 @@ import { expandListBlocksToCompleteItems, expandListBlocksToCompleteList, isFirstBlockOfListItem, + isFirstListItemInList, isListItemBlock, isNumberedListType } from './utils/model.js'; @@ -85,4 +86,11 @@ export class ListUtils extends Plugin { public isNumberedListType( listType: ListType ): boolean { return isNumberedListType( listType ); } + + /** + * Returns true if the given list item is the first item in the list. + */ + public isFirstListItemInList( listItem: ModelElement ): boolean { + return isFirstListItemInList( listItem ); + } } diff --git a/packages/ckeditor5-list/src/list/utils/model.ts b/packages/ckeditor5-list/src/list/utils/model.ts index 6d4d32c290b..1b481005c59 100644 --- a/packages/ckeditor5-list/src/list/utils/model.ts +++ b/packages/ckeditor5-list/src/list/utils/model.ts @@ -602,17 +602,36 @@ export function isNumberedListType( listType: ListType ): boolean { } /** - * Checks whether the given list item block is the first block of its list. A block is considered - * the first in its list if there is no preceding sibling that is a list item of the same type. - * This works for any starting indent level and correctly handles adjacent lists of different types. + * Checks if the given list item is the first item in the list. + * + * This function checks if there's any other list item before the given list item + * at the same indent level with the same list type. + */ +export function isFirstListItemInList( listItem: ModelElement ): boolean { + const previousItem = ListWalker.first( listItem, { + sameIndent: true, + sameAttributes: 'listType' + } ); + + return !previousItem; +} + +/** + * Checks whether the given list item is at the top level of the list structure, meaning it is not + * nested inside another list item's scope. An item is considered top-level if it is at indent 0 + * (which is always top-level) or if its previous sibling is not a list item block (indicating + * a skip-level list that starts after a non-list element or at the beginning of the document). * * @internal */ -export function isListHead( node: ListElement ): boolean { +export function isTopLevelListItem( node: ListElement ): boolean { + if ( node.getAttribute( 'listIndent' ) === 0 ) { + return true; + } + const previousSibling = node.previousSibling; - return !previousSibling || !isListItemBlock( previousSibling ) || - previousSibling.getAttribute( 'listType' ) !== node.getAttribute( 'listType' ); + return !previousSibling || !isListItemBlock( previousSibling ); } /** diff --git a/packages/ckeditor5-list/tests/list/listindentcommand.js b/packages/ckeditor5-list/tests/list/listindentcommand.js index fa926eb112e..de225221870 100644 --- a/packages/ckeditor5-list/tests/list/listindentcommand.js +++ b/packages/ckeditor5-list/tests/list/listindentcommand.js @@ -1319,6 +1319,41 @@ describe( 'ListIndentCommand', () => { expect( command.isEnabled ).to.be.true; } ); + + it( 'should be false when selection is at start of a skip-level list item preceded by a paragraph', () => { + _setModelData( model, modelList( [ + 'foo', + ' * []0' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false when skip-level list item is the first element in the document', () => { + _setModelData( model, modelList( [ + ' * []0' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be true for a sublist item (not top-level, handled by list indent)', () => { + _setModelData( model, modelList( [ + '* 0', + ' * []1' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true for a skip-level list item preceded by a list item of another type', () => { + _setModelData( model, modelList( [ + '# 0', + ' * []1' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); } ); } ); diff --git a/packages/ckeditor5-list/tests/list/utils/model.js b/packages/ckeditor5-list/tests/list/utils/model.js index 92a97e7cfdd..cdb4a784fb2 100644 --- a/packages/ckeditor5-list/tests/list/utils/model.js +++ b/packages/ckeditor5-list/tests/list/utils/model.js @@ -13,8 +13,9 @@ import { indentBlocks, isFirstBlockOfListItem, isLastBlockOfListItem, - isListHead, + isFirstListItemInList, isSingleListItem, + isTopLevelListItem, ListItemUid, mergeListItemBefore, outdentBlocksWithMerge, @@ -1823,7 +1824,7 @@ describe( 'List - utils - model', () => { } ); } ); - describe( 'isListHead()', () => { + describe( 'isFirstListItemInList()', () => { it( 'should return true for the first list item in the document', () => { const input = modelList( [ '* a', @@ -1832,7 +1833,7 @@ describe( 'List - utils - model', () => { const fragment = _parseModel( input, schema ); - expect( isListHead( fragment.getChild( 0 ) ) ).to.be.true; + expect( isFirstListItemInList( fragment.getChild( 0 ) ) ).to.be.true; } ); it( 'should return false for a list item preceded by another list item of the same type', () => { @@ -1843,7 +1844,7 @@ describe( 'List - utils - model', () => { const fragment = _parseModel( input, schema ); - expect( isListHead( fragment.getChild( 1 ) ) ).to.be.false; + expect( isFirstListItemInList( fragment.getChild( 1 ) ) ).to.be.false; } ); it( 'should return true for a list item preceded by a non-list block', () => { @@ -1854,7 +1855,7 @@ describe( 'List - utils - model', () => { const fragment = _parseModel( input, schema ); - expect( isListHead( fragment.getChild( 1 ) ) ).to.be.true; + expect( isFirstListItemInList( fragment.getChild( 1 ) ) ).to.be.true; } ); it( 'should return true for a list item preceded by a list item of a different type', () => { @@ -1865,7 +1866,7 @@ describe( 'List - utils - model', () => { const fragment = _parseModel( input, schema ); - expect( isListHead( fragment.getChild( 1 ) ) ).to.be.true; + expect( isFirstListItemInList( fragment.getChild( 1 ) ) ).to.be.true; } ); it( 'should return false for a nested list item preceded by a list item of the same type', () => { @@ -1877,10 +1878,10 @@ describe( 'List - utils - model', () => { const fragment = _parseModel( input, schema ); - expect( isListHead( fragment.getChild( 2 ) ) ).to.be.false; + expect( isFirstListItemInList( fragment.getChild( 2 ) ) ).to.be.false; } ); - it( 'should return true for the first item after a list of a different type (regression)', () => { + it( 'should return true for the first item after a list of a different type', () => { const input = modelList( [ '# a', '# b', @@ -1891,13 +1892,157 @@ describe( 'List - utils - model', () => { const fragment = _parseModel( input, schema ); // First numbered item. - expect( isListHead( fragment.getChild( 0 ) ) ).to.be.true; + expect( isFirstListItemInList( fragment.getChild( 0 ) ) ).to.be.true; // Second numbered item. - expect( isListHead( fragment.getChild( 1 ) ) ).to.be.false; + expect( isFirstListItemInList( fragment.getChild( 1 ) ) ).to.be.false; // First bulleted item. - expect( isListHead( fragment.getChild( 2 ) ) ).to.be.true; + expect( isFirstListItemInList( fragment.getChild( 2 ) ) ).to.be.true; // Second bulleted item. - expect( isListHead( fragment.getChild( 3 ) ) ).to.be.false; + expect( isFirstListItemInList( fragment.getChild( 3 ) ) ).to.be.false; + } ); + + it( 'should return false for same-type item after nested item of different type', () => { + const input = modelList( [ + '* a', + ' # b', + '* c' + ] ); + + const fragment = _parseModel( input, schema ); + + expect( isFirstListItemInList( fragment.getChild( 2 ) ) ).to.be.false; + } ); + + it( 'should return true for a skip-level list item (indent > 0, first in document)', () => { + const input = modelList( [ + ' * a', + ' * b' + ] ); + + const fragment = _parseModel( input, schema ); + + expect( isFirstListItemInList( fragment.getChild( 0 ) ) ).to.be.true; + } ); + + it( 'should return true for a higher-indent item when the preceding item has a lower indent', () => { + const input = modelList( [ + ' * a', + ' * b' + ] ); + + const fragment = _parseModel( input, schema ); + + expect( isFirstListItemInList( fragment.getChild( 0 ) ) ).to.be.true; + expect( isFirstListItemInList( fragment.getChild( 1 ) ) ).to.be.true; + } ); + + it( 'should return true for same-type items at different indent levels (two separate lists)', () => { + const input = modelList( [ + ' * a', + '# b', + ' * c' + ] ); + + const fragment = _parseModel( input, schema ); + + expect( isFirstListItemInList( fragment.getChild( 0 ) ) ).to.be.true; + expect( isFirstListItemInList( fragment.getChild( 1 ) ) ).to.be.true; + expect( isFirstListItemInList( fragment.getChild( 2 ) ) ).to.be.true; + } ); + + it( 'should return false for same-type item after nested different-type item at higher indent', () => { + const input = modelList( [ + ' * a', + ' # b', + ' * c' + ] ); + + const fragment = _parseModel( input, schema ); + + expect( isFirstListItemInList( fragment.getChild( 0 ) ) ).to.be.true; + expect( isFirstListItemInList( fragment.getChild( 1 ) ) ).to.be.true; + expect( isFirstListItemInList( fragment.getChild( 2 ) ) ).to.be.false; + } ); + } ); + + describe( 'isTopLevelListItem()', () => { + it( 'should return true for a list item at indent 0', () => { + const input = modelList( [ + '* a', + '* b' + ] ); + + const fragment = _parseModel( input, schema ); + + expect( isTopLevelListItem( fragment.getChild( 0 ) ) ).to.be.true; + expect( isTopLevelListItem( fragment.getChild( 1 ) ) ).to.be.true; + } ); + + it( 'should return true for a list item at indent 0 after a different type list', () => { + const input = modelList( [ + '# a', + '* b' + ] ); + + const fragment = _parseModel( input, schema ); + + expect( isTopLevelListItem( fragment.getChild( 0 ) ) ).to.be.true; + expect( isTopLevelListItem( fragment.getChild( 1 ) ) ).to.be.true; + } ); + + it( 'should return false for a sublist item', () => { + const input = modelList( [ + '* a', + ' * b' + ] ); + + const fragment = _parseModel( input, schema ); + + expect( isTopLevelListItem( fragment.getChild( 0 ) ) ).to.be.true; + expect( isTopLevelListItem( fragment.getChild( 1 ) ) ).to.be.false; + } ); + + it( 'should return true for a skip-level list item preceded by a non-list block', () => { + const input = modelList( [ + 'foo', + ' * a' + ] ); + + const fragment = _parseModel( input, schema ); + + expect( isTopLevelListItem( fragment.getChild( 1 ) ) ).to.be.true; + } ); + + it( 'should return true for a skip-level list item that is first in the document', () => { + const input = modelList( [ + ' * a' + ] ); + + const fragment = _parseModel( input, schema ); + + expect( isTopLevelListItem( fragment.getChild( 0 ) ) ).to.be.true; + } ); + + it( 'should return false for a skip-level sublist item preceded by a list item', () => { + const input = modelList( [ + '* a', + ' * b' + ] ); + + const fragment = _parseModel( input, schema ); + + expect( isTopLevelListItem( fragment.getChild( 1 ) ) ).to.be.false; + } ); + + it( 'should return false for a skip-level list item preceded by a list item of another type', () => { + const input = modelList( [ + '# a', + ' * b' + ] ); + + const fragment = _parseModel( input, schema ); + + expect( isTopLevelListItem( fragment.getChild( 1 ) ) ).to.be.false; } ); } ); } ); From b38a39e49c92feaeee715641283a0fb3c9fba5d7 Mon Sep 17 00:00:00 2001 From: Marta Motyczynska Date: Tue, 14 Apr 2026 18:46:40 +0200 Subject: [PATCH 04/33] Downcast skip-level lists to valid HTML --- .../ckeditor5-list/src/list/converters.ts | 98 ++++++-- .../ckeditor5-list/src/list/listediting.ts | 13 +- .../tests/list/converters-data.js | 224 +++++++++++++++++ .../ckeditor5-list/tests/list/converters.js | 230 ++++++++++++++++++ 4 files changed, 537 insertions(+), 28 deletions(-) diff --git a/packages/ckeditor5-list/src/list/converters.ts b/packages/ckeditor5-list/src/list/converters.ts index f3ab1d9c10f..14c1ad2984f 100644 --- a/packages/ckeditor5-list/src/list/converters.ts +++ b/packages/ckeditor5-list/src/list/converters.ts @@ -370,7 +370,7 @@ export function listItemDowncastConverter( attributeNames: Array, strategies: Array, model: Model, - { dataPipeline }: { dataPipeline?: boolean } = {} + { dataPipeline, allowSkipLevels }: { dataPipeline?: boolean; allowSkipLevels?: boolean } = {} ): GetCallback> { const consumer = createAttributesConsumer( attributeNames, strategies ); @@ -390,7 +390,8 @@ export function listItemDowncastConverter( const options = { ...conversionApi.options, - dataPipeline + dataPipeline, + allowSkipLevels }; // Use positions mapping instead of mapper.toViewElement( listItem ) to find outermost view element. @@ -681,6 +682,10 @@ function unwrapListItemBlock( viewElement: ViewElement, viewWriter: ViewDowncast /** * Wraps the given list item with appropriate attribute elements for ul, ol, and li. + * + * For skip-level lists (where indent gaps exist, e.g. indent 0 → indent 2), this function + * generates intermediate wrapper pairs (ul/ol + li) at each missing level. These intermediate + * wrappers are invisible (`list-style-type: none` on the li) and carry no model-backed attributes. */ function wrapListItemBlock( listItem: ListElement, @@ -694,40 +699,81 @@ function wrapListItemBlock( } const listItemIndent = listItem.getAttribute( 'listIndent' ); + const allowSkipLevels = options.allowSkipLevels; let currentListItem: ListElement | null = listItem; for ( let indent = listItemIndent; indent >= 0; indent-- ) { - const listItemViewElement = createListItemElement( writer, indent, currentListItem.getAttribute( 'listItemId' ) ); - const listViewElement = createListElement( writer, indent, currentListItem.getAttribute( 'listType' ) ); - - for ( const strategy of strategies ) { - if ( - ( strategy.scope == 'list' || strategy.scope == 'item' ) && - currentListItem.hasAttribute( strategy.attributeName ) - ) { - strategy.setAttributeOnDowncast( - writer, - currentListItem.getAttribute( strategy.attributeName ), - strategy.scope == 'list' ? listViewElement : listItemViewElement, - options, - currentListItem - ); + // When allowSkipLevels is enabled and ListWalker jumps over indent levels (e.g. from indent 2 + // to indent 0), the levels in between have no corresponding model element. We detect these + // "intermediate" levels by checking if currentListItem's indent doesn't match the current + // loop indent. + const isIntermediate = allowSkipLevels && currentListItem.getAttribute( 'listIndent' ) !== indent; + + if ( isIntermediate ) { + // Intermediate levels get invisible wrappers: list-style-type:none hides the marker on
    • , + // and no strategies are applied (no data-list-item-id, listStyle, etc.) since there is no + // model element backing this level. + // + // The
    • uses a fixed ID per indent (`list-item-skip-N`) so that sibling items sharing + // the same skipped level merge into one
    • (e.g. two items at indent 1 with no parent + // at indent 0 share one intermediate
    • at indent 0). + // + // The
        uses the default real ID (list-type-indent) so it can merge with real list + // elements from other items at the same indent (e.g. a skip-level item at indent 2 followed + // by a real item at indent 0 — their
          at indent 0 must merge into one list). + const listType = currentListItem.getAttribute( 'listType' ); + + const listItemViewElement = createListItemElement( writer, indent, `list-item-skip-${ indent }` ); + const listViewElement = createListElement( writer, indent, listType ); + + writer.setStyle( 'list-style-type', 'none', listItemViewElement ); + + viewRange = writer.wrap( viewRange, listItemViewElement ); + viewRange = writer.wrap( viewRange, listViewElement ); + } else { + const listItemViewElement = createListItemElement( writer, indent, currentListItem.getAttribute( 'listItemId' ) ); + const listViewElement = createListElement( writer, indent, currentListItem.getAttribute( 'listType' ) ); + + for ( const strategy of strategies ) { + if ( + ( strategy.scope == 'list' || strategy.scope == 'item' ) && + currentListItem.hasAttribute( strategy.attributeName ) + ) { + strategy.setAttributeOnDowncast( + writer, + currentListItem.getAttribute( strategy.attributeName ), + strategy.scope == 'list' ? listViewElement : listItemViewElement, + options, + currentListItem + ); + } } - } - viewRange = writer.wrap( viewRange, listItemViewElement ); - viewRange = writer.wrap( viewRange, listViewElement ); + viewRange = writer.wrap( viewRange, listItemViewElement ); + viewRange = writer.wrap( viewRange, listViewElement ); + } if ( indent == 0 ) { break; } - currentListItem = ListWalker.first( currentListItem, { lowerIndent: true } ); - - // There is no list item with lower indent, this means this is a document fragment containing - // only a part of nested list (like copy to clipboard) so we don't need to try to wrap it further. - if ( !currentListItem ) { - break; + // Only look for a parent when at a real (non-intermediate) level. At intermediate levels + // currentListItem already points to the nearest found ancestor and won't change. + if ( !isIntermediate ) { + const nextListItem = ListWalker.first( currentListItem, { lowerIndent: true } ); + + if ( nextListItem ) { + currentListItem = nextListItem; + } else if ( !allowSkipLevels ) { + // There is no list item with lower indent, this means this is a document fragment + // containing only a part of nested list (like copy to clipboard) so we don't need + // to try to wrap it further. + // + // When allowSkipLevels is enabled, the loop continues to indent 0 producing + // intermediate wrappers at every remaining level to ensure a complete HTML structure + // regardless of whether the item has a preceding parent. + break; + } } } } diff --git a/packages/ckeditor5-list/src/list/listediting.ts b/packages/ckeditor5-list/src/list/listediting.ts index 1b6f766529e..eebd5477ff2 100644 --- a/packages/ckeditor5-list/src/list/listediting.ts +++ b/packages/ckeditor5-list/src/list/listediting.ts @@ -481,9 +481,14 @@ export class ListEditing extends Plugin { converterPriority: 'high' } ) .add( dispatcher => { + const allowSkipLevels = !!editor.config.get( 'list.allowSkipLevels' ); + dispatcher.on>( 'attribute', - listItemDowncastConverter( attributeNames, this._downcastStrategies, model ) + listItemDowncastConverter( + attributeNames, this._downcastStrategies, model, + { allowSkipLevels } + ) ); dispatcher.on( 'remove', listItemDowncastRemoveConverter( model.schema ) ); @@ -496,9 +501,13 @@ export class ListEditing extends Plugin { converterPriority: 'high' } ) .add( dispatcher => { + const allowSkipLevels = !!editor.config.get( 'list.allowSkipLevels' ); + dispatcher.on>( 'attribute', - listItemDowncastConverter( attributeNames, this._downcastStrategies, model, { dataPipeline: true } ) + listItemDowncastConverter( attributeNames, this._downcastStrategies, model, { + dataPipeline: true, allowSkipLevels + } ) ); } ); diff --git a/packages/ckeditor5-list/tests/list/converters-data.js b/packages/ckeditor5-list/tests/list/converters-data.js index c308f780d00..12f802d0e69 100644 --- a/packages/ckeditor5-list/tests/list/converters-data.js +++ b/packages/ckeditor5-list/tests/list/converters-data.js @@ -5,6 +5,7 @@ import { ListEditing } from '../../src/list/listediting.js'; +import { _setModelData } from '@ckeditor/ckeditor5-engine'; import { BoldEditing } from '@ckeditor/ckeditor5-basic-styles'; import { UndoEditing } from '@ckeditor/ckeditor5-undo'; import { ClipboardPipeline } from '@ckeditor/ckeditor5-clipboard'; @@ -2613,4 +2614,227 @@ describe( 'ListEditing - converters - data pipeline', () => { ); } ); } ); + + describe( 'skip-level lists', () => { + let skipEditor, skipModel; + + beforeEach( async () => { + skipEditor = await VirtualTestEditor.create( { + plugins: [ Paragraph, IndentEditing, ClipboardPipeline, BoldEditing, ListEditing, UndoEditing, + BlockQuoteEditing, TableEditing, HeadingEditing, CodeBlockEditing ], + list: { + allowSkipLevels: true + } + } ); + + skipModel = skipEditor.model; + + sinon.stub( skipEditor.editing.view, 'scrollToTheSelection' ).callsFake( () => {} ); + } ); + + afterEach( async () => { + await skipEditor.destroy(); + } ); + + it( 'should create intermediate wrappers for a single skipped level', () => { + _setModelData( skipModel, + 'A' + + 'B' + ); + + expect( skipEditor.getData( { skipListItemIds: true } ) ).to.equal( + '
            ' + + '
          • A' + + '
              ' + + '
            • ' + + '
                ' + + '
              • B
              • ' + + '
              ' + + '
            • ' + + '
            ' + + '
          • ' + + '
          ' + ); + } ); + + it( 'should create intermediate wrappers for multiple skipped levels', () => { + _setModelData( skipModel, + 'A' + + 'B' + ); + + expect( skipEditor.getData( { skipListItemIds: true } ) ).to.equal( + '
            ' + + '
          • A' + + '
              ' + + '
            • ' + + '
                ' + + '
              • ' + + '
                  ' + + '
                • B
                • ' + + '
                ' + + '
              • ' + + '
              ' + + '
            • ' + + '
            ' + + '
          • ' + + '
          ' + ); + } ); + + it( 'should create intermediate wrappers when the first item has a skip level', () => { + _setModelData( skipModel, + 'A' + + 'B' + ); + + expect( skipEditor.getData( { skipListItemIds: true } ) ).to.equal( + '
            ' + + '
          • ' + + '
              ' + + '
            • ' + + '
                ' + + '
              • A
              • ' + + '
              ' + + '
            • ' + + '
            ' + + '
          • ' + + '
          • B
          • ' + + '
          ' + ); + } ); + + it( 'should merge intermediate wrappers for sibling items at the same skip level', () => { + _setModelData( skipModel, + 'A' + + 'B' + ); + + expect( skipEditor.getData( { skipListItemIds: true } ) ).to.equal( + '
            ' + + '
          • ' + + '
              ' + + '
            • A
            • ' + + '
            • B
            • ' + + '
            ' + + '
          • ' + + '
          ' + ); + } ); + + it( 'should inherit the list type from the parent for intermediate levels', () => { + _setModelData( skipModel, + 'A' + + 'B' + ); + + expect( skipEditor.getData( { skipListItemIds: true } ) ).to.equal( + '
            ' + + '
          1. A' + + '
              ' + + '
            1. ' + + '
                ' + + '
              • B
              • ' + + '
              ' + + '
            2. ' + + '
            ' + + '
          2. ' + + '
          ' + ); + } ); + + it( 'should place intermediate and real items as siblings at the same indent', () => { + _setModelData( skipModel, + 'A' + + 'B' + + 'C' + ); + + expect( skipEditor.getData( { skipListItemIds: true } ) ).to.equal( + '
            ' + + '
          • A' + + '
              ' + + '
            • ' + + '
                ' + + '
              • B
              • ' + + '
              ' + + '
            • ' + + '
            • C
            • ' + + '
            ' + + '
          • ' + + '
          ' + ); + } ); + + it( 'should handle multi-block list item with skip level', () => { + _setModelData( skipModel, + 'A' + + 'B1' + + 'B2' + ); + + expect( skipEditor.getData( { skipListItemIds: true } ) ).to.equal( + '
            ' + + '
          • A' + + '
              ' + + '
            • ' + + '
                ' + + '
              • ' + + '

                B1

                ' + + '

                B2

                ' + + '
              • ' + + '
              ' + + '
            • ' + + '
            ' + + '
          • ' + + '
          ' + ); + } ); + + it( 'should create intermediate wrappers for a numbered list', () => { + _setModelData( skipModel, + 'A' + + 'B' + + 'C' + ); + + expect( skipEditor.getData( { skipListItemIds: true } ) ).to.equal( + '
            ' + + '
          1. A' + + '
              ' + + '
            1. ' + + '
                ' + + '
              1. B
              2. ' + + '
              ' + + '
            2. ' + + '
            3. C
            4. ' + + '
            ' + + '
          2. ' + + '
          ' + ); + } ); + + it( 'should handle a block widget at a skip level', () => { + _setModelData( skipModel, + 'A' + + '
          ' + + 'B' + + '
          ' + ); + + expect( skipEditor.getData( { skipListItemIds: true } ) ).to.equal( + '
            ' + + '
          • A' + + '
              ' + + '
            • ' + + '
                ' + + '
              • B

              • ' + + '
              ' + + '
            • ' + + '
            ' + + '
          • ' + + '
          ' + ); + } ); + } ); } ); diff --git a/packages/ckeditor5-list/tests/list/converters.js b/packages/ckeditor5-list/tests/list/converters.js index 6f08316922f..4a57c6f330b 100644 --- a/packages/ckeditor5-list/tests/list/converters.js +++ b/packages/ckeditor5-list/tests/list/converters.js @@ -1099,6 +1099,236 @@ describe( 'ListEditing - converters', () => { } ); } ); + describe( 'skip-level lists', () => { + let skipEditor, skipModel; + + beforeEach( async () => { + skipEditor = await VirtualTestEditor.create( { + plugins: [ Paragraph, IndentEditing, ClipboardPipeline, BoldEditing, ListEditing, UndoEditing, + BlockQuoteEditing, TableEditing, HeadingEditing, AlignmentEditing ], + list: { + allowSkipLevels: true + } + } ); + + skipModel = skipEditor.model; + + sinon.stub( skipEditor.editing.view, 'scrollToTheSelection' ).callsFake( () => {} ); + } ); + + afterEach( async () => { + await skipEditor.destroy(); + } ); + + it( 'should create intermediate wrappers for a single skipped level', () => { + _setModelData( skipModel, + 'A' + + 'B' + ); + + expect( _getViewData( skipEditor.editing.view, { withoutSelection: true } ) ).to.equal( + '
            ' + + '
          • ' + + 'A' + + '
              ' + + '
            • ' + + '
                ' + + '
              • B
              • ' + + '
              ' + + '
            • ' + + '
            ' + + '
          • ' + + '
          ' + ); + } ); + + it( 'should create intermediate wrappers for multiple skipped levels', () => { + _setModelData( skipModel, + 'A' + + 'B' + ); + + expect( _getViewData( skipEditor.editing.view, { withoutSelection: true } ) ).to.equal( + '
            ' + + '
          • ' + + 'A' + + '
              ' + + '
            • ' + + '
                ' + + '
              • ' + + '
                  ' + + '
                • B
                • ' + + '
                ' + + '
              • ' + + '
              ' + + '
            • ' + + '
            ' + + '
          • ' + + '
          ' + ); + } ); + + it( 'should create intermediate wrappers when the first item has a skip level', () => { + _setModelData( skipModel, + 'A' + + 'B' + ); + + expect( _getViewData( skipEditor.editing.view, { withoutSelection: true } ) ).to.equal( + '
            ' + + '
          • ' + + '
              ' + + '
            • ' + + '
                ' + + '
              • A
              • ' + + '
              ' + + '
            • ' + + '
            ' + + '
          • ' + + '
          • B
          • ' + + '
          ' + ); + } ); + + it( 'should merge intermediate wrappers for sibling items at the same skip level', () => { + _setModelData( skipModel, + 'A' + + 'B' + ); + + expect( _getViewData( skipEditor.editing.view, { withoutSelection: true } ) ).to.equal( + '
            ' + + '
          • ' + + '
              ' + + '
            • A
            • ' + + '
            • B
            • ' + + '
            ' + + '
          • ' + + '
          ' + ); + } ); + + it( 'should inherit the list type from the parent for intermediate levels', () => { + _setModelData( skipModel, + 'A' + + 'B' + ); + + expect( _getViewData( skipEditor.editing.view, { withoutSelection: true } ) ).to.equal( + '
            ' + + '
          1. ' + + 'A' + + '
              ' + + '
            1. ' + + '
                ' + + '
              • B
              • ' + + '
              ' + + '
            2. ' + + '
            ' + + '
          2. ' + + '
          ' + ); + } ); + + it( 'should place intermediate and real items as siblings at the same indent', () => { + _setModelData( skipModel, + 'A' + + 'B' + + 'C' + ); + + expect( _getViewData( skipEditor.editing.view, { withoutSelection: true } ) ).to.equal( + '
            ' + + '
          • ' + + 'A' + + '
              ' + + '
            • ' + + '
                ' + + '
              • B
              • ' + + '
              ' + + '
            • ' + + '
            • C
            • ' + + '
            ' + + '
          • ' + + '
          ' + ); + } ); + + it( 'should handle multi-block list item with skip level', () => { + _setModelData( skipModel, + 'A' + + 'B1' + + 'B2' + ); + + expect( _getViewData( skipEditor.editing.view, { withoutSelection: true } ) ).to.equal( + '
            ' + + '
          • ' + + 'A' + + '
              ' + + '
            • ' + + '
                ' + + '
              • ' + + '

                B1

                ' + + '

                B2

                ' + + '
              • ' + + '
              ' + + '
            • ' + + '
            ' + + '
          • ' + + '
          ' + ); + } ); + + it( 'should create intermediate wrappers for a numbered list', () => { + _setModelData( skipModel, + 'A' + + 'B' + + 'C' + ); + + expect( _getViewData( skipEditor.editing.view, { withoutSelection: true } ) ).to.equal( + '
            ' + + '
          1. ' + + 'A' + + '
              ' + + '
            1. ' + + '
                ' + + '
              1. B
              2. ' + + '
              ' + + '
            2. ' + + '
            3. C
            4. ' + + '
            ' + + '
          2. ' + + '
          ' + ); + } ); + + it( 'should handle a block widget at a skip level', () => { + _setModelData( skipModel, + 'A' + + '
          ' + + 'B' + + '
          ' + ); + + expect( _getViewData( skipEditor.editing.view, { withoutSelection: true } ) ).to.equal( + '
            ' + + '
          • ' + + 'A' + + '
              ' + + '
            • ' + + '
                ' + + '
              • B

              • ' + + '
              ' + + '
            • ' + + '
            ' + + '
          • ' + + '
          ' + ); + } ); + } ); + function getViewPosition( root, path, view ) { let parent = root; From 6290717325866bf5d5a143bfcae5fb46038bbfa1 Mon Sep 17 00:00:00 2001 From: Marta Motyczynska Date: Tue, 14 Apr 2026 23:11:28 +0200 Subject: [PATCH 05/33] Align argument handling with previous implementation. --- packages/ckeditor5-list/src/list/listediting.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ckeditor5-list/src/list/listediting.ts b/packages/ckeditor5-list/src/list/listediting.ts index eebd5477ff2..ad502ec0694 100644 --- a/packages/ckeditor5-list/src/list/listediting.ts +++ b/packages/ckeditor5-list/src/list/listediting.ts @@ -487,7 +487,7 @@ export class ListEditing extends Plugin { 'attribute', listItemDowncastConverter( attributeNames, this._downcastStrategies, model, - { allowSkipLevels } + allowSkipLevels ? { allowSkipLevels } : undefined ) ); From 26f709173fba84d51fa222f59674270f0cf99b09 Mon Sep 17 00:00:00 2001 From: Marta Motyczynska Date: Tue, 14 Apr 2026 23:42:33 +0200 Subject: [PATCH 06/33] Upcast skip level lists. --- .../ckeditor5-list/src/list/converters.ts | 36 +++ .../ckeditor5-list/src/list/listediting.ts | 12 +- .../tests/list/converters-data.js | 296 +++++++++++++++++- 3 files changed, 339 insertions(+), 5 deletions(-) diff --git a/packages/ckeditor5-list/src/list/converters.ts b/packages/ckeditor5-list/src/list/converters.ts index 14c1ad2984f..c95f28c3b48 100644 --- a/packages/ckeditor5-list/src/list/converters.ts +++ b/packages/ckeditor5-list/src/list/converters.ts @@ -58,6 +58,36 @@ import type { ListDowncastStrategy } from './listediting.js'; +/** + * Returns the upcast converter for skip-level list item wrappers. It detects intermediate `
        • ` elements + * with `list-style-type:none` (generated by the skip-level downcast or external sources) and unwraps them + * so they don't produce empty list items in the model. + * + * The wrapper `
        • ` is consumed without creating a model element, but its children (nested lists) are + * converted normally. Because `getIndent()` counts all ancestor `
        • ` elements (including the consumed wrapper), + * the nested items receive the correct indent values that reflect the skip-level gap. + * + * @internal + */ +export function listItemSkipLevelUpcastConverter(): GetCallback { + return ( evt, data, conversionApi ) => { + const viewItem = data.viewItem; + + if ( viewItem.getStyle( 'list-style-type' ) !== 'none' ) { + return; + } + + if ( !conversionApi.consumable.consume( viewItem, { name: true } ) ) { + return; + } + + const { modelRange, modelCursor } = conversionApi.convertChildren( viewItem, data.modelCursor ); + + data.modelRange = modelRange; + data.modelCursor = modelCursor; + }; +} + /** * Returns the upcast converter for list items. It's supposed to work after the block converters (content inside list items) are converted. * @@ -78,6 +108,12 @@ export function listItemUpcastConverter(): GetCallback { return; } + // All items already have list attributes set by a recursive conversion (e.g. children of a skip-level + // wrapper
        • that was consumed without creating a model element). Nothing left to do here. + if ( items.every( item => item.hasAttribute( 'listItemId' ) ) ) { + return; + } + const listItemId = data.viewItem.getAttribute( 'data-list-item-id' ) || ListItemUid.next(); conversionApi.consumable.consume( data.viewItem, { attributes: 'data-list-item-id' } ); diff --git a/packages/ckeditor5-list/src/list/listediting.ts b/packages/ckeditor5-list/src/list/listediting.ts index ad502ec0694..738032d61ca 100644 --- a/packages/ckeditor5-list/src/list/listediting.ts +++ b/packages/ckeditor5-list/src/list/listediting.ts @@ -45,6 +45,7 @@ import { createModelToViewPositionMapper, listItemDowncastConverter, listItemDowncastRemoveConverter, + listItemSkipLevelUpcastConverter, listItemUpcastConverter, reconvertItemsOnDataChange } from './converters.js'; @@ -435,6 +436,7 @@ export class ListEditing extends Plugin { const attributeNames = this.getListAttributeNames(); const multiBlock = editor.config.get( 'list.multiBlock' ); const elementName = multiBlock ? 'paragraph' : 'listItem'; + const allowSkipLevels = !!editor.config.get( 'list.allowSkipLevels' ); editor.conversion.for( 'upcast' ) // Convert
        • to a generic paragraph (or listItem element) so the content of
        • is always inside a block. @@ -463,6 +465,12 @@ export class ListEditing extends Plugin { converterPriority: 'high' } ) .add( dispatcher => { + if ( allowSkipLevels ) { + dispatcher.on( + 'element:li', listItemSkipLevelUpcastConverter(), { priority: 'high' } + ); + } + dispatcher.on( 'element:li', listItemUpcastConverter() ); } ); @@ -481,8 +489,6 @@ export class ListEditing extends Plugin { converterPriority: 'high' } ) .add( dispatcher => { - const allowSkipLevels = !!editor.config.get( 'list.allowSkipLevels' ); - dispatcher.on>( 'attribute', listItemDowncastConverter( @@ -501,8 +507,6 @@ export class ListEditing extends Plugin { converterPriority: 'high' } ) .add( dispatcher => { - const allowSkipLevels = !!editor.config.get( 'list.allowSkipLevels' ); - dispatcher.on>( 'attribute', listItemDowncastConverter( attributeNames, this._downcastStrategies, model, { diff --git a/packages/ckeditor5-list/tests/list/converters-data.js b/packages/ckeditor5-list/tests/list/converters-data.js index 12f802d0e69..c12138d0cc1 100644 --- a/packages/ckeditor5-list/tests/list/converters-data.js +++ b/packages/ckeditor5-list/tests/list/converters-data.js @@ -5,7 +5,7 @@ import { ListEditing } from '../../src/list/listediting.js'; -import { _setModelData } from '@ckeditor/ckeditor5-engine'; +import { _getModelData, _setModelData } from '@ckeditor/ckeditor5-engine'; import { BoldEditing } from '@ckeditor/ckeditor5-basic-styles'; import { UndoEditing } from '@ckeditor/ckeditor5-undo'; import { ClipboardPipeline } from '@ckeditor/ckeditor5-clipboard'; @@ -2836,5 +2836,299 @@ describe( 'ListEditing - converters - data pipeline', () => { '
        ' ); } ); + + describe( 'upcast', () => { + it( 'should not handle skip-level wrapper if the element was already consumed', () => { + skipEditor.conversion.for( 'upcast' ).add( dispatcher => { + dispatcher.on( 'element:li', ( evt, data, conversionApi ) => { + if ( data.viewItem.getStyle( 'list-style-type' ) === 'none' ) { + conversionApi.consumable.consume( data.viewItem, { name: true } ); + } + }, { priority: 'highest' } ); + } ); + + skipEditor.setData( + '
          ' + + '
        • A' + + '
            ' + + '
          • ' + + '
              ' + + '
            • B
            • ' + + '
            ' + + '
          • ' + + '
          ' + + '
        • ' + + '
        ' + ); + + expect( _getModelData( skipModel, { withoutSelection: true } ) ).to.equalMarkup( + 'A' + ); + } ); + + it( 'should not upcast single skip-level wrapper to the model and preserve indent gap', () => { + skipEditor.setData( + '
          ' + + '
        • A' + + '
            ' + + '
          • ' + + '
              ' + + '
            • B
            • ' + + '
            ' + + '
          • ' + + '
          ' + + '
        • ' + + '
        ' + ); + + expect( _getModelData( skipModel, { withoutSelection: true } ) ).to.equalMarkup( + 'A' + + 'B' + ); + } ); + + it( 'should not upcast multiple skip-level wrappers to the model and preserve indent gap', () => { + skipEditor.setData( + '
          ' + + '
        • A' + + '
            ' + + '
          • ' + + '
              ' + + '
            • ' + + '
                ' + + '
              • B
              • ' + + '
              ' + + '
            • ' + + '
            ' + + '
          • ' + + '
          ' + + '
        • ' + + '
        ' + ); + + expect( _getModelData( skipModel, { withoutSelection: true } ) ).to.equalMarkup( + 'A' + + 'B' + ); + } ); + + it( 'should not upcast skip-level wrappers to the model when the first item has a skip level', () => { + skipEditor.setData( + '
          ' + + '
        • ' + + '
            ' + + '
          • ' + + '
              ' + + '
            • A
            • ' + + '
            ' + + '
          • ' + + '
          ' + + '
        • ' + + '
        • B
        • ' + + '
        ' + ); + + expect( _getModelData( skipModel, { withoutSelection: true } ) ).to.equalMarkup( + 'A' + + 'B' + ); + } ); + + it( 'should not upcast skip-level wrapper but upcast sibling items under it', () => { + skipEditor.setData( + '
          ' + + '
        • ' + + '
            ' + + '
          • A
          • ' + + '
          • B
          • ' + + '
          ' + + '
        • ' + + '
        ' + ); + + expect( _getModelData( skipModel, { withoutSelection: true } ) ).to.equalMarkup( + 'A' + + 'B' + ); + } ); + + it( 'should not upcast skip-level wrapper to the model with mixed list types', () => { + skipEditor.setData( + '
          ' + + '
        1. A' + + '
            ' + + '
          1. ' + + '
              ' + + '
            • B
            • ' + + '
            ' + + '
          2. ' + + '
          ' + + '
        2. ' + + '
        ' + ); + + expect( _getModelData( skipModel, { withoutSelection: true } ) ).to.equalMarkup( + 'A' + + 'B' + ); + } ); + + it( 'should not upcast skip-level wrapper to the model when a real item follows at an intermediate indent', () => { + skipEditor.setData( + '
          ' + + '
        • A' + + '
            ' + + '
          • ' + + '
              ' + + '
            • B
            • ' + + '
            ' + + '
          • ' + + '
          • C
          • ' + + '
          ' + + '
        • ' + + '
        ' + ); + + expect( _getModelData( skipModel, { withoutSelection: true } ) ).to.equalMarkup( + 'A' + + 'B' + + 'C' + ); + } ); + + it( 'should not upcast skip-level wrapper to the model in a numbered list', () => { + skipEditor.setData( + '
          ' + + '
        1. A' + + '
            ' + + '
          1. ' + + '
              ' + + '
            1. B
            2. ' + + '
            ' + + '
          2. ' + + '
          3. C
          4. ' + + '
          ' + + '
        2. ' + + '
        ' + ); + + expect( _getModelData( skipModel, { withoutSelection: true } ) ).to.equalMarkup( + 'A' + + 'B' + + 'C' + ); + } ); + + it( 'should roundtrip a single skipped level (model -> data -> model)', () => { + _setModelData( skipModel, + 'A' + + 'B' + ); + + const data = skipEditor.getData(); + + skipEditor.setData( data ); + + expect( _getModelData( skipModel, { withoutSelection: true } ) ).to.equalMarkup( + 'A' + + 'B' + ); + } ); + + it( 'should roundtrip multiple skipped levels (model -> data -> model)', () => { + _setModelData( skipModel, + 'A' + + 'B' + ); + + const data = skipEditor.getData(); + + skipEditor.setData( data ); + + expect( _getModelData( skipModel, { withoutSelection: true } ) ).to.equalMarkup( + 'A' + + 'B' + ); + } ); + + it( 'should roundtrip when the first item starts at a skip level', () => { + _setModelData( skipModel, + 'A' + + 'B' + ); + + const data = skipEditor.getData(); + + skipEditor.setData( data ); + + expect( _getModelData( skipModel, { withoutSelection: true } ) ).to.equalMarkup( + 'A' + + 'B' + ); + } ); + + it( 'should roundtrip list with skip and real item at intermediate indent', () => { + _setModelData( skipModel, + 'A' + + 'B' + + 'C' + ); + + const data = skipEditor.getData(); + + skipEditor.setData( data ); + + expect( _getModelData( skipModel, { withoutSelection: true } ) ).to.equalMarkup( + 'A' + + 'B' + + 'C' + ); + } ); + + it( 'should roundtrip a multi-block list item with skip level', () => { + _setModelData( skipModel, + 'A' + + 'B1' + + 'B2' + ); + + const data = skipEditor.getData(); + + skipEditor.setData( data ); + + expect( _getModelData( skipModel, { withoutSelection: true } ) ).to.equalMarkup( + 'A' + + 'B1' + + 'B2' + ); + } ); + } ); + + describe( 'upcast without allowSkipLevels', () => { + it( 'should upcast skip-level wrapper as a regular list item when allowSkipLevels is disabled', () => { + // Uses the main editor which does NOT have allowSkipLevels. + // The phantom
      • is treated as a regular (empty) list item, producing an + // extra empty paragraph at indent 1 with sequential indentation (0, 1, 2). + editor.setData( + '
          ' + + '
        • A' + + '
            ' + + '
          • ' + + '
              ' + + '
            • B
            • ' + + '
            ' + + '
          • ' + + '
          ' + + '
        • ' + + '
        ' + ); + + expect( _getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + 'A' + + '' + + 'B' + ); + } ); + } ); } ); } ); From cf79dacd0f7611df32e73eb449692d84dce2f216 Mon Sep 17 00:00:00 2001 From: Marta Motyczynska Date: Wed, 15 Apr 2026 17:50:22 +0200 Subject: [PATCH 07/33] Fix intermediate list wrappers missing classes in skip-level lists (fixes todo and multi-level lists). --- .../ckeditor5-list/src/list/converters.ts | 37 +++++- .../tests/list/converters-data.js | 6 +- .../ckeditor5-list/tests/list/converters.js | 63 +++++++++- .../tests/todolist/todolistediting.js | 111 ++++++++++++++++++ 4 files changed, 205 insertions(+), 12 deletions(-) diff --git a/packages/ckeditor5-list/src/list/converters.ts b/packages/ckeditor5-list/src/list/converters.ts index c95f28c3b48..b89d0ea0c49 100644 --- a/packages/ckeditor5-list/src/list/converters.ts +++ b/packages/ckeditor5-list/src/list/converters.ts @@ -721,7 +721,10 @@ function unwrapListItemBlock( viewElement: ViewElement, viewWriter: ViewDowncast * * For skip-level lists (where indent gaps exist, e.g. indent 0 → indent 2), this function * generates intermediate wrapper pairs (ul/ol + li) at each missing level. These intermediate - * wrappers are invisible (`list-style-type: none` on the li) and carry no model-backed attributes. + * wrappers are invisible (`list-style-type: none` on the li). Scope `'list'` strategies are + * applied so the wrapper element (ul/ol) carries the same classes and styles as real list + * wrappers, while scope `'item'` strategies are skipped since there is no model element + * backing the intermediate level. */ function wrapListItemBlock( listItem: ListElement, @@ -747,23 +750,45 @@ function wrapListItemBlock( if ( isIntermediate ) { // Intermediate levels get invisible wrappers: list-style-type:none hides the marker on
      • , - // and no strategies are applied (no data-list-item-id, listStyle, etc.) since there is no + // and scope:'item' strategies are not applied (no data-list-item-id, etc.) since there is no // model element backing this level. // // The
      • uses a fixed ID per indent (`list-item-skip-N`) so that sibling items sharing // the same skipped level merge into one
      • (e.g. two items at indent 1 with no parent // at indent 0 share one intermediate
      • at indent 0). // - // The
          uses the default real ID (list-type-indent) so it can merge with real list - // elements from other items at the same indent (e.g. a skip-level item at indent 2 followed - // by a real item at indent 0 — their
            at indent 0 must merge into one list). - const listType = currentListItem.getAttribute( 'listType' ); + // The
              uses the list type from the original item being wrapped (not the ancestor) + // so it can merge with real list elements from other items at the same indent. For example, + // a bulleted item indented past its numbered parent must produce a
                at the intermediate + // level so it merges with sibling
                  items, rather than an
                    that would split them + // into separate lists. + const listType = listItem.getAttribute( 'listType' ); const listItemViewElement = createListItemElement( writer, indent, `list-item-skip-${ indent }` ); const listViewElement = createListElement( writer, indent, listType ); writer.setStyle( 'list-style-type', 'none', listItemViewElement ); + // Apply scope:'list' strategies so that intermediate
                      /
                        wrappers carry the same + // classes and styles as real list wrappers (e.g. multi-level-list class, todo-list class, + // list-style-type:none). Without this, intermediate and real list elements at the same + // indent would have mismatched attributes and could not merge correctly, causing browsers + // to render unwanted default markers. + for ( const strategy of strategies ) { + if ( + strategy.scope == 'list' && + listItem.hasAttribute( strategy.attributeName ) + ) { + strategy.setAttributeOnDowncast( + writer, + listItem.getAttribute( strategy.attributeName ), + listViewElement, + options, + listItem + ); + } + } + viewRange = writer.wrap( viewRange, listItemViewElement ); viewRange = writer.wrap( viewRange, listViewElement ); } else { diff --git a/packages/ckeditor5-list/tests/list/converters-data.js b/packages/ckeditor5-list/tests/list/converters-data.js index c12138d0cc1..973854e562a 100644 --- a/packages/ckeditor5-list/tests/list/converters-data.js +++ b/packages/ckeditor5-list/tests/list/converters-data.js @@ -2722,7 +2722,7 @@ describe( 'ListEditing - converters - data pipeline', () => { ); } ); - it( 'should inherit the list type from the parent for intermediate levels', () => { + it( 'should inherit the list type from the child item for intermediate levels', () => { _setModelData( skipModel, 'A' + 'B' @@ -2731,13 +2731,13 @@ describe( 'ListEditing - converters - data pipeline', () => { expect( skipEditor.getData( { skipListItemIds: true } ) ).to.equal( '
                          ' + '
                        1. A' + - '
                            ' + + '
                              ' + '
                            • ' + '
                                ' + '
                              • B
                              • ' + '
                              ' + '
                            • ' + - '
                          ' + + '
                      ' + '' + '
                    ' ); diff --git a/packages/ckeditor5-list/tests/list/converters.js b/packages/ckeditor5-list/tests/list/converters.js index 4a57c6f330b..3150abb67c8 100644 --- a/packages/ckeditor5-list/tests/list/converters.js +++ b/packages/ckeditor5-list/tests/list/converters.js @@ -4,6 +4,7 @@ */ import { ListEditing } from '../../src/list/listediting.js'; +import { ListPropertiesEditing } from '../../src/listproperties/listpropertiesediting.js'; import { ModelRange, _getModelData, _parseModel, _setModelData, _getViewData } from '@ckeditor/ckeditor5-engine'; @@ -1208,7 +1209,7 @@ describe( 'ListEditing - converters', () => { ); } ); - it( 'should inherit the list type from the parent for intermediate levels', () => { + it( 'should inherit the list type from the child item for intermediate levels', () => { _setModelData( skipModel, 'A' + 'B' @@ -1218,13 +1219,13 @@ describe( 'ListEditing - converters', () => { '
                      ' + '
                    1. ' + 'A' + - '
                        ' + + '
                          ' + '
                        • ' + '
                            ' + '
                          • B
                          • ' + '
                          ' + '
                        • ' + - '
                      ' + + '
                ' + '' + '
' ); @@ -1304,6 +1305,30 @@ describe( 'ListEditing - converters', () => { ); } ); + it( 'should merge intermediate and real list wrappers in a mixed-type list', () => { + _setModelData( skipModel, + 'A' + + 'B' + + 'C' + ); + + expect( _getViewData( skipEditor.editing.view, { withoutSelection: true } ) ).to.equal( + '
    ' + + '
  1. ' + + 'A' + + '
      ' + + '
    • ' + + '
        ' + + '
      • B
      • ' + + '
      ' + + '
    • ' + + '
    • C
    • ' + + '
    ' + + '
  2. ' + + '
' + ); + } ); + it( 'should handle a block widget at a skip level', () => { _setModelData( skipModel, 'A' + @@ -1327,6 +1352,38 @@ describe( 'ListEditing - converters', () => { '' ); } ); + + it( 'should apply scope:list strategies on intermediate wrappers', async () => { + const editor = await VirtualTestEditor.create( { + plugins: [ Paragraph, ListEditing, ListPropertiesEditing ], + list: { + allowSkipLevels: true, + properties: { styles: true } + } + } ); + + _setModelData( editor.model, + 'A' + + 'B' + ); + + expect( _getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( + '
    ' + + '
  1. ' + + 'A' + + '
      ' + + '
    1. ' + + '
        ' + + '
      1. B
      2. ' + + '
      ' + + '
    2. ' + + '
    ' + + '
  2. ' + + '
' + ); + + await editor.destroy(); + } ); } ); function getViewPosition( root, path, view ) { diff --git a/packages/ckeditor5-list/tests/todolist/todolistediting.js b/packages/ckeditor5-list/tests/todolist/todolistediting.js index 0ecbc769aef..0134f527265 100644 --- a/packages/ckeditor5-list/tests/todolist/todolistediting.js +++ b/packages/ckeditor5-list/tests/todolist/todolistediting.js @@ -1355,6 +1355,117 @@ describe( 'TodoListEditing', () => { } ); } ); + describe( 'skip-level lists', () => { + let skipEditor, skipModel, skipView; + + beforeEach( async () => { + skipEditor = await ClassicTestEditor.create( editorElement, { + plugins: [ Paragraph, TodoListEditing, BlockQuoteEditing, TableEditing, HeadingEditing, AlignmentEditing ], + list: { + allowSkipLevels: true + } + } ); + + skipModel = skipEditor.model; + skipView = skipEditor.editing.view; + + skipEditor.plugins.get( 'ListEditing' )._downcastStrategies.splice( + skipEditor.plugins.get( 'ListEditing' )._downcastStrategies.findIndex( + strategy => strategy.attributeName === 'listItemId' ), 1 ); + } ); + + afterEach( async () => { + await skipEditor.destroy(); + } ); + + it( 'should apply todo-list class on intermediate list wrappers', () => { + _setModelData( skipModel, + 'foo' + + 'bar' + ); + + expect( _getViewData( skipView, { withoutSelection: true } ) ).to.equalMarkup( + '
    ' + + '
  • ' + + '' + + '' + + '' + + '' + + '' + + 'foo' + + '' + + '' + + '
      ' + + '
    • ' + + '
        ' + + '
      • ' + + '' + + '' + + '' + + '' + + '' + + 'bar' + + '' + + '' + + '
      • ' + + '
      ' + + '
    • ' + + '
    ' + + '
  • ' + + '
' + ); + } ); + + it( 'should merge intermediate and real todo list wrappers at the same indent', () => { + _setModelData( skipModel, + 'foo' + + 'bar' + + 'baz' + ); + + expect( _getViewData( skipView, { withoutSelection: true } ) ).to.equalMarkup( + '
    ' + + '
  • ' + + '' + + '' + + '' + + '' + + '' + + 'foo' + + '' + + '' + + '
      ' + + '
    • ' + + '
        ' + + '
      • ' + + '' + + '' + + '' + + '' + + '' + + 'bar' + + '' + + '' + + '
      • ' + + '
      ' + + '
    • ' + + '
    • ' + + '' + + '' + + '' + + '' + + '' + + 'baz' + + '' + + '' + + '
    • ' + + '
    ' + + '
  • ' + + '
' + ); + } ); + } ); + async function createEditor( config = {} ) { return ClassicTestEditor.create( editorElement, { plugins: [ Paragraph, TodoListEditing, BlockQuoteEditing, TableEditing, HeadingEditing, AlignmentEditing ], From 9c50fc1d6f291295403d722c5a2fd9973d31b0fc Mon Sep 17 00:00:00 2001 From: Marta Motyczynska Date: Wed, 15 Apr 2026 18:12:20 +0200 Subject: [PATCH 08/33] Improve manual test for skip level lists to be able to dynamically add/remove some features. --- .../manual/documentlist-skip-levels.html | 26 +++++ .../tests/manual/documentlist-skip-levels.js | 96 +++++++++++++++---- 2 files changed, 105 insertions(+), 17 deletions(-) diff --git a/packages/ckeditor5-list/tests/manual/documentlist-skip-levels.html b/packages/ckeditor5-list/tests/manual/documentlist-skip-levels.html index ea12b65de3b..2cbef16580a 100644 --- a/packages/ckeditor5-list/tests/manual/documentlist-skip-levels.html +++ b/packages/ckeditor5-list/tests/manual/documentlist-skip-levels.html @@ -1,3 +1,29 @@ +
+
+ + + +
+
+ + +
+
+ + +

Numbered list (try indenting multiple times)

    diff --git a/packages/ckeditor5-list/tests/manual/documentlist-skip-levels.js b/packages/ckeditor5-list/tests/manual/documentlist-skip-levels.js index c68493d0a17..4e0eb54598d 100644 --- a/packages/ckeditor5-list/tests/manual/documentlist-skip-levels.js +++ b/packages/ckeditor5-list/tests/manual/documentlist-skip-levels.js @@ -9,7 +9,7 @@ import { ImageResize, ImageUpload, Image, ImageCaption, ImageStyle, ImageToolbar import { CodeBlock } from '@ckeditor/ckeditor5-code-block'; import { HorizontalLine } from '@ckeditor/ckeditor5-horizontal-line'; import { HtmlEmbed } from '@ckeditor/ckeditor5-html-embed'; -import { HtmlComment } from '@ckeditor/ckeditor5-html-support'; +import { HtmlComment, GeneralHtmlSupport } from '@ckeditor/ckeditor5-html-support'; import { LinkImage, Link } from '@ckeditor/ckeditor5-link'; import { PageBreak } from '@ckeditor/ckeditor5-page-break'; import { SourceEditing } from '@ckeditor/ckeditor5-source-editing'; @@ -21,20 +21,50 @@ import { Heading } from '@ckeditor/ckeditor5-heading'; import { Indent, IndentBlock } from '@ckeditor/ckeditor5-indent'; import { MediaEmbed } from '@ckeditor/ckeditor5-media-embed'; import { Paragraph } from '@ckeditor/ckeditor5-paragraph'; +import { PasteFromOffice } from '@ckeditor/ckeditor5-paste-from-office'; import { TodoList } from '../../src/todolist.js'; import { List } from '../../src/list.js'; import { ListProperties } from '../../src/listproperties.js'; -ClassicEditor - .create( { - attachTo: document.querySelector( '#editor' ), - plugins: [ - Essentials, BlockQuote, Bold, Heading, Image, ImageCaption, ImageStyle, ImageToolbar, Indent, IndentBlock, Italic, Link, - MediaEmbed, Paragraph, Table, TableToolbar, CodeBlock, TableCaption, ImageResize, LinkImage, - HtmlEmbed, HtmlComment, Alignment, PageBreak, HorizontalLine, ImageUpload, - SourceEditing, List, ListProperties, TodoList - ], +const editorElement = document.querySelector( '#editor' ); + +const controls = { + skipLevels: document.querySelector( '#skipLevels' ), + indentBlock: document.querySelector( '#indentBlock' ), + ghs: document.querySelector( '#ghs' ), + pfo: document.querySelector( '#pfo' ), + listProperties: document.querySelector( '#listProperties' ) +}; + +let editor = null; + +function getEditorConfig() { + const plugins = [ + Essentials, BlockQuote, Bold, Heading, Image, ImageCaption, ImageStyle, ImageToolbar, Indent, Italic, Link, + MediaEmbed, Paragraph, Table, TableToolbar, CodeBlock, TableCaption, ImageResize, LinkImage, + HtmlEmbed, HtmlComment, Alignment, PageBreak, HorizontalLine, ImageUpload, + SourceEditing, List, TodoList + ]; + + if ( controls.indentBlock.checked ) { + plugins.push( IndentBlock ); + } + + if ( controls.ghs.checked ) { + plugins.push( GeneralHtmlSupport ); + } + + if ( controls.pfo.checked ) { + plugins.push( PasteFromOffice ); + } + + if ( controls.listProperties.checked ) { + plugins.push( ListProperties ); + } + + const config = { + plugins, toolbar: [ 'sourceEditing', '|', 'numberedList', 'bulletedList', 'todoList', '|', @@ -91,15 +121,47 @@ ClassicEditor startIndex: true, reversed: true }, - allowSkipLevels: true + allowSkipLevels: controls.skipLevels.checked + }, + htmlSupport: { + allow: [ + { + name: /^.*$/, + styles: true, + attributes: true, + classes: true + } + ] }, menuBar: { isVisible: true } - } ) - .then( editor => { - window.editor = editor; - } ) - .catch( err => { - console.error( err.stack ); + }; + + return config; +} + +function createEditor() { + const initialize = () => + ClassicEditor.create( { + ...getEditorConfig(), + attachTo: editorElement + } ) + .then( newEditor => { + editor = newEditor; + window.editor = editor; + } ); + + return Promise.resolve() + .then( () => editor && editor.destroy() ) + .then( initialize ) + .catch( err => console.error( err ) ); +} + +createEditor(); + +Object.values( controls ).forEach( input => { + input.addEventListener( 'change', () => { + createEditor(); } ); +} ); From 177dd257c52496d0571ddaf902d926e9bb5578b8 Mon Sep 17 00:00:00 2001 From: Marta Motyczynska Date: Wed, 15 Apr 2026 18:40:10 +0200 Subject: [PATCH 09/33] Update dev dependencies. --- packages/ckeditor5-list/package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/ckeditor5-list/package.json b/packages/ckeditor5-list/package.json index 01dd00f3c36..88af5398a24 100644 --- a/packages/ckeditor5-list/package.json +++ b/packages/ckeditor5-list/package.json @@ -67,6 +67,7 @@ "@ckeditor/ckeditor5-media-embed": "workspace:*", "@ckeditor/ckeditor5-page-break": "workspace:*", "@ckeditor/ckeditor5-paragraph": "workspace:*", + "@ckeditor/ckeditor5-paste-from-office": "workspace:*", "@ckeditor/ckeditor5-remove-format": "workspace:*", "@ckeditor/ckeditor5-source-editing": "workspace:*", "@ckeditor/ckeditor5-table": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3a1e9ef21f..4f7ae9b64e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2380,6 +2380,9 @@ importers: '@ckeditor/ckeditor5-paragraph': specifier: workspace:* version: link:../ckeditor5-paragraph + '@ckeditor/ckeditor5-paste-from-office': + specifier: workspace:* + version: link:../ckeditor5-paste-from-office '@ckeditor/ckeditor5-remove-format': specifier: workspace:* version: link:../ckeditor5-remove-format From d70e231712ff49b934a1e1ba7f17390d865ce1c0 Mon Sep 17 00:00:00 2001 From: Marta Motyczynska Date: Wed, 15 Apr 2026 20:45:37 +0200 Subject: [PATCH 10/33] Update manual test so it has initial data after reload. --- .../tests/manual/documentlist-skip-levels.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/ckeditor5-list/tests/manual/documentlist-skip-levels.js b/packages/ckeditor5-list/tests/manual/documentlist-skip-levels.js index 4e0eb54598d..f8e166172d7 100644 --- a/packages/ckeditor5-list/tests/manual/documentlist-skip-levels.js +++ b/packages/ckeditor5-list/tests/manual/documentlist-skip-levels.js @@ -28,6 +28,7 @@ import { List } from '../../src/list.js'; import { ListProperties } from '../../src/listproperties.js'; const editorElement = document.querySelector( '#editor' ); +const INITIAL_DATA = editorElement.innerHTML; const controls = { skipLevels: document.querySelector( '#skipLevels' ), @@ -146,11 +147,11 @@ function createEditor() { ClassicEditor.create( { ...getEditorConfig(), attachTo: editorElement - } ) - .then( newEditor => { - editor = newEditor; - window.editor = editor; - } ); + } ).then( newEditor => { + editor = newEditor; + window.editor = editor; + editor.setData( INITIAL_DATA ); + } ); return Promise.resolve() .then( () => editor && editor.destroy() ) From 47aeec2ae658fc07a0b1d9fdcec428d91f1c7132 Mon Sep 17 00:00:00 2001 From: Marta Motyczynska Date: Wed, 15 Apr 2026 21:24:14 +0200 Subject: [PATCH 11/33] Fix intermediate wrapper merging in skip-level lists with mixed types. --- .../ckeditor5-list/src/list/converters.ts | 48 ++++++++-- .../tests/list/converters-data.js | 6 +- .../ckeditor5-list/tests/list/converters.js | 94 ++++++++++++++++++- 3 files changed, 133 insertions(+), 15 deletions(-) diff --git a/packages/ckeditor5-list/src/list/converters.ts b/packages/ckeditor5-list/src/list/converters.ts index b89d0ea0c49..c5fbecd3766 100644 --- a/packages/ckeditor5-list/src/list/converters.ts +++ b/packages/ckeditor5-list/src/list/converters.ts @@ -757,12 +757,14 @@ function wrapListItemBlock( // the same skipped level merge into one
  1. (e.g. two items at indent 1 with no parent // at indent 0 share one intermediate
  2. at indent 0). // - // The
      uses the list type from the original item being wrapped (not the ancestor) - // so it can merge with real list elements from other items at the same indent. For example, - // a bulleted item indented past its numbered parent must produce a
        at the intermediate - // level so it merges with sibling
          items, rather than an
            that would split them - // into separate lists. - const listType = listItem.getAttribute( 'listType' ); + // The
              type is chosen to ensure correct merging at this indent: + // - If a real list item exists at this indent (found by walking forward), use its type + // so the intermediate wrapper merges with that item's real wrapper. + // - Otherwise, use the ancestor's type so all intermediates at this indent share the + // same type and merge with each other. + const siblingAtIndent = findSiblingListItemAt( listItem, indent ); + const referenceItem = siblingAtIndent || currentListItem; + const listType = referenceItem.getAttribute( 'listType' ); const listItemViewElement = createListItemElement( writer, indent, `list-item-skip-${ indent }` ); const listViewElement = createListElement( writer, indent, listType ); @@ -774,17 +776,20 @@ function wrapListItemBlock( // list-style-type:none). Without this, intermediate and real list elements at the same // indent would have mismatched attributes and could not merge correctly, causing browsers // to render unwanted default markers. + // + // Use the reference item (sibling or ancestor) as the strategy source so the attributes + // match whatever this intermediate is supposed to merge with. for ( const strategy of strategies ) { if ( strategy.scope == 'list' && - listItem.hasAttribute( strategy.attributeName ) + referenceItem.hasAttribute( strategy.attributeName ) ) { strategy.setAttributeOnDowncast( writer, - listItem.getAttribute( strategy.attributeName ), + referenceItem.getAttribute( strategy.attributeName ), listViewElement, options, - listItem + referenceItem ); } } @@ -839,6 +844,31 @@ function wrapListItemBlock( } } +/** + * Walks forward from the given list item through model siblings to find the first list item block + * at exactly the specified indent level. Stops when it encounters a non-list block or a list item + * at a lower indent (which means we left the current subtree). + */ +function findSiblingListItemAt( listItem: ListElement, targetIndent: number ): ListElement | null { + let node = listItem.nextSibling; + + while ( node && isListItemBlock( node ) ) { + const indent = ( node as ListElement ).getAttribute( 'listIndent' ); + + if ( indent < targetIndent ) { + return null; + } + + if ( indent === targetIndent ) { + return node as ListElement; + } + + node = node.nextSibling; + } + + return null; +} + // Returns the function that is responsible for consuming attributes that are set on the model node. function createAttributesConsumer( attributeNames: Array, strategies: Array ) { const nonConsumingAttributes = strategies diff --git a/packages/ckeditor5-list/tests/list/converters-data.js b/packages/ckeditor5-list/tests/list/converters-data.js index 973854e562a..8a0bbdd681b 100644 --- a/packages/ckeditor5-list/tests/list/converters-data.js +++ b/packages/ckeditor5-list/tests/list/converters-data.js @@ -2722,7 +2722,7 @@ describe( 'ListEditing - converters - data pipeline', () => { ); } ); - it( 'should inherit the list type from the child item for intermediate levels', () => { + it( 'should inherit the list type from the ancestor for intermediate levels without a sibling', () => { _setModelData( skipModel, 'A' + 'B' @@ -2731,13 +2731,13 @@ describe( 'ListEditing - converters - data pipeline', () => { expect( skipEditor.getData( { skipListItemIds: true } ) ).to.equal( '
                ' + '
              1. A' + - '
                  ' + + '
                    ' + '
                  1. ' + '
                      ' + '
                    • B
                    • ' + '
                    ' + '
                  2. ' + - '
                ' + + '
              ' + '' + '
          ' ); diff --git a/packages/ckeditor5-list/tests/list/converters.js b/packages/ckeditor5-list/tests/list/converters.js index 3150abb67c8..fc0914e55ca 100644 --- a/packages/ckeditor5-list/tests/list/converters.js +++ b/packages/ckeditor5-list/tests/list/converters.js @@ -1209,7 +1209,7 @@ describe( 'ListEditing - converters', () => { ); } ); - it( 'should inherit the list type from the child item for intermediate levels', () => { + it( 'should inherit the list type from the ancestor for intermediate levels without a sibling', () => { _setModelData( skipModel, 'A' + 'B' @@ -1219,13 +1219,13 @@ describe( 'ListEditing - converters', () => { '
            ' + '
          1. ' + 'A' + - '
              ' + + '
                ' + '
              1. ' + '
                  ' + '
                • B
                • ' + '
                ' + '
              2. ' + - '
            ' + + '
          ' + '' + '
' ); @@ -1353,6 +1353,94 @@ describe( 'ListEditing - converters', () => { ); } ); + it( 'should merge intermediate wrappers when child items at different depths have different types', () => { + _setModelData( skipModel, + 'A' + + 'B' + + 'C' + ); + + expect( _getViewData( skipEditor.editing.view, { withoutSelection: true } ) ).to.equal( + '
    ' + + '
  • ' + + 'A' + + '
      ' + + '
    • ' + + '
        ' + + '
      1. ' + + 'B' + + '
          ' + + '
        • C
        • ' + + '
        ' + + '
      2. ' + + '
      ' + + '
    • ' + + '
    ' + + '
  • ' + + '
' + ); + } ); + + it( 'should not pick a sibling from a different list context (lower indent boundary)', () => { + _setModelData( skipModel, + 'A' + + 'B' + + 'D' + + 'C' + ); + + expect( _getViewData( skipEditor.editing.view, { withoutSelection: true } ) ).to.equal( + '
    ' + + '
  1. ' + + 'A' + + '
      ' + + '
    1. ' + + '
        ' + + '
      • B
      • ' + + '
      ' + + '
    2. ' + + '
    ' + + '
  2. ' + + '
' + + '
    ' + + '
  • ' + + 'D' + + '
      ' + + '
    • C
    • ' + + '
    ' + + '
  • ' + + '
' + ); + } ); + + it( 'should use sibling type at one intermediate level and ancestor type at another', () => { + _setModelData( skipModel, + 'A' + + 'B' + + 'C' + ); + + expect( _getViewData( skipEditor.editing.view, { withoutSelection: true } ) ).to.equal( + '
    ' + + '
  1. ' + + 'A' + + '
      ' + + '
    • ' + + '
        ' + + '
      1. ' + + '
          ' + + '
        • B
        • ' + + '
        ' + + '
      2. ' + + '
      ' + + '
    • ' + + '
    • C
    • ' + + '
    ' + + '
  2. ' + + '
' + ); + } ); + it( 'should apply scope:list strategies on intermediate wrappers', async () => { const editor = await VirtualTestEditor.create( { plugins: [ Paragraph, ListEditing, ListPropertiesEditing ], From 61c0e5892d140131faf6fcd44dc3f929be1f93fd Mon Sep 17 00:00:00 2001 From: Marta Motyczynska Date: Sat, 18 Apr 2026 13:20:38 +0200 Subject: [PATCH 12/33] Decouple indent block list and list packages (skip-level lists context). --- .../integrations/indentblocklistcommand.ts | 6 +- .../indentblocklistintegration.ts | 6 +- packages/ckeditor5-list/src/index.ts | 1 - .../src/list/listindentcommand.ts | 15 +- .../ckeditor5-list/src/list/utils/model.ts | 18 -- .../list/integrations/indentmulticommand.js | 132 ++++++++ .../tests/list/listindentcommand.js | 294 +++++------------- .../ckeditor5-list/tests/list/utils/model.js | 82 ----- 8 files changed, 219 insertions(+), 335 deletions(-) diff --git a/packages/ckeditor5-indent/src/integrations/indentblocklistcommand.ts b/packages/ckeditor5-indent/src/integrations/indentblocklistcommand.ts index 8514097a806..3774027c06c 100644 --- a/packages/ckeditor5-indent/src/integrations/indentblocklistcommand.ts +++ b/packages/ckeditor5-indent/src/integrations/indentblocklistcommand.ts @@ -9,7 +9,7 @@ import { Command, type Editor } from '@ckeditor/ckeditor5-core'; import type { ModelDocumentSelection, ModelElement } from '@ckeditor/ckeditor5-engine'; -import { _isListItemBlock, _isTopLevelListItem } from '@ckeditor/ckeditor5-list'; +import { _isListItemBlock } from '@ckeditor/ckeditor5-list'; import type { IndentBehavior } from '../indentcommandbehavior/indentbehavior.js'; @@ -82,7 +82,7 @@ export class IndentBlockListCommand extends Command { for ( const block of blocks ) { if ( _isListItemBlock( block ) && - _isTopLevelListItem( block ) && + block.getAttribute( 'listIndent' ) === 0 && model.schema.checkAttribute( block, 'blockIndentList' ) ) { listItems.push( block ); @@ -121,7 +121,7 @@ export class IndentBlockListCommand extends Command { if ( position.isAtStart && _isListItemBlock( parent ) && - _isTopLevelListItem( parent ) && + parent.getAttribute( 'listIndent' ) === 0 && schema.checkAttribute( parent, 'blockIndentList' ) && listUtils.isFirstListItemInList( parent ) ) { diff --git a/packages/ckeditor5-indent/src/integrations/indentblocklistintegration.ts b/packages/ckeditor5-indent/src/integrations/indentblocklistintegration.ts index d1364bcff85..e4d6527ee37 100644 --- a/packages/ckeditor5-indent/src/integrations/indentblocklistintegration.ts +++ b/packages/ckeditor5-indent/src/integrations/indentblocklistintegration.ts @@ -242,7 +242,11 @@ export class IndentBlockListIntegration extends Plugin { const indentCommand = editor.commands.get( 'indent' ) as MultiCommand; const outdentCommand = editor.commands.get( 'outdent' ) as MultiCommand; - indentCommand.registerChildCommand( editor.commands.get( 'indentBlockList' )! ); + // Priority is highest so that block indent takes precedence over list indent (`indentList` is registered + // at `high`). When the selection is at the start of the first list item at indent 0, the block indent + // command adds margin instead of increasing the list indent level. For items at higher indent levels, + // this command is disabled and falls through to `indentList`. + indentCommand.registerChildCommand( editor.commands.get( 'indentBlockList' )!, { priority: 'highest' } ); outdentCommand.registerChildCommand( editor.commands.get( 'outdentBlockList' )! ); indentCommand.registerChildCommand( editor.commands.get( 'indentBlockListItem' )! ); diff --git a/packages/ckeditor5-list/src/index.ts b/packages/ckeditor5-list/src/index.ts index 4eb1898f248..a50d0ee1f59 100644 --- a/packages/ckeditor5-list/src/index.ts +++ b/packages/ckeditor5-list/src/index.ts @@ -79,7 +79,6 @@ export { getListItems as _getListItems, isFirstBlockOfListItem as _isFirstBlockOfListItem, isLastBlockOfListItem as _isLastBlockOfListItem, - isTopLevelListItem as _isTopLevelListItem, expandListBlocksToCompleteItems as _expandListBlocksToCompleteItems, expandListBlocksToCompleteList as _expandListBlocksToCompleteList, splitListItemBefore as _splitListItemBefore, diff --git a/packages/ckeditor5-list/src/list/listindentcommand.ts b/packages/ckeditor5-list/src/list/listindentcommand.ts index 0d68da68617..fd379b883b0 100644 --- a/packages/ckeditor5-list/src/list/listindentcommand.ts +++ b/packages/ckeditor5-list/src/list/listindentcommand.ts @@ -14,10 +14,8 @@ import { expandListBlocksToCompleteItems, indentBlocks, isFirstBlockOfListItem, - isFirstListItemInList, isListItemBlock, isSingleListItem, - isTopLevelListItem, outdentBlocksWithMerge, sortBlocks, splitListItemBefore, @@ -144,19 +142,8 @@ export class ListIndentCommand extends Command { return true; } - // When skip levels are allowed, any list item can always be indented further, - // unless IndentBlock is loaded and the selection is at the start of the first list item - // in the list — in that case, defer to the block indent command. + // When skip levels are allowed, any list item can always be indented further. if ( this.editor.config.get( 'list.allowSkipLevels' ) ) { - if ( this.editor.plugins.has( 'IndentBlockListIntegration' ) ) { - const position = this.editor.model.document.selection.getFirstPosition()!; - const parent = position.parent as ListElement; - - if ( position.isAtStart && isTopLevelListItem( parent ) && isFirstListItemInList( parent ) ) { - return false; - } - } - return true; } diff --git a/packages/ckeditor5-list/src/list/utils/model.ts b/packages/ckeditor5-list/src/list/utils/model.ts index 1b481005c59..cf4aaf7f819 100644 --- a/packages/ckeditor5-list/src/list/utils/model.ts +++ b/packages/ckeditor5-list/src/list/utils/model.ts @@ -616,24 +616,6 @@ export function isFirstListItemInList( listItem: ModelElement ): boolean { return !previousItem; } -/** - * Checks whether the given list item is at the top level of the list structure, meaning it is not - * nested inside another list item's scope. An item is considered top-level if it is at indent 0 - * (which is always top-level) or if its previous sibling is not a list item block (indicating - * a skip-level list that starts after a non-list element or at the beginning of the document). - * - * @internal - */ -export function isTopLevelListItem( node: ListElement ): boolean { - if ( node.getAttribute( 'listIndent' ) === 0 ) { - return true; - } - - const previousSibling = node.previousSibling; - - return !previousSibling || !isListItemBlock( previousSibling ); -} - /** * 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. */ diff --git a/packages/ckeditor5-list/tests/list/integrations/indentmulticommand.js b/packages/ckeditor5-list/tests/list/integrations/indentmulticommand.js index e0be28a5adf..74c68971318 100644 --- a/packages/ckeditor5-list/tests/list/integrations/indentmulticommand.js +++ b/packages/ckeditor5-list/tests/list/integrations/indentmulticommand.js @@ -1498,6 +1498,138 @@ describe( 'Indent MultiCommand integrations', () => { } ); } ); + describe( 'list with indent block and allowSkipLevels', () => { + let element, editor, model; + let indentListSpy, outdentListSpy, indentBlockListSpy, outdentBlockListSpy; + + beforeEach( async () => { + element = document.createElement( 'div' ); + document.body.appendChild( element ); + + editor = await ClassicTestEditor.create( element, { + plugins: [ Paragraph, ListEditing, IndentEditing, IndentBlock ], + list: { + allowSkipLevels: true + } + } ); + + model = editor.model; + + indentListSpy = sinon.spy( editor.commands.get( 'indentList' ), 'execute' ); + outdentListSpy = sinon.spy( editor.commands.get( 'outdentList' ), 'execute' ); + indentBlockListSpy = sinon.spy( editor.commands.get( 'indentBlockList' ), 'execute' ); + outdentBlockListSpy = sinon.spy( editor.commands.get( 'outdentBlockList' ), 'execute' ); + } ); + + afterEach( async () => { + element.remove(); + await editor.destroy(); + } ); + + describe( 'indent command', () => { + it( 'should execute indentBlockList when at start of first list item at indent 0', () => { + _setModelData( model, modelList( [ + '* []A', + '* B' + ] ) ); + + editor.commands.get( 'indent' ).execute(); + + expect( indentBlockListSpy.callCount ).to.equal( 1, 'indentBlockList command call count' ); + expect( indentListSpy.callCount ).to.equal( 0, 'indentList command call count' ); + } ); + + it( 'should execute indentBlockList when a non-collapsed selection starts at the start of first list item at indent 0', () => { + _setModelData( model, modelList( [ + '* [A]', + '* B' + ] ) ); + + editor.commands.get( 'indent' ).execute(); + + expect( indentBlockListSpy.callCount ).to.equal( 1, 'indentBlockList command call count' ); + expect( indentListSpy.callCount ).to.equal( 0, 'indentList command call count' ); + } ); + + it( 'should execute indentList when cursor is not at start of first list item', () => { + _setModelData( model, modelList( [ + '* A[]', + '* B' + ] ) ); + + editor.commands.get( 'indent' ).execute(); + + expect( indentBlockListSpy.callCount ).to.equal( 0, 'indentBlockList command call count' ); + expect( indentListSpy.callCount ).to.equal( 1, 'indentList command call count' ); + } ); + + it( 'should execute indentList when at start of first skip-level list item (indent > 0)', () => { + _setModelData( model, modelList( [ + ' * []A' + ] ) ); + + editor.commands.get( 'indent' ).execute(); + + expect( indentBlockListSpy.callCount ).to.equal( 0, 'indentBlockList command call count' ); + expect( indentListSpy.callCount ).to.equal( 1, 'indentList command call count' ); + } ); + + it( 'should execute indentList when at start of second list item', () => { + _setModelData( model, modelList( [ + '* A', + '* []B' + ] ) ); + + editor.commands.get( 'indent' ).execute(); + + expect( indentBlockListSpy.callCount ).to.equal( 0, 'indentBlockList command call count' ); + expect( indentListSpy.callCount ).to.equal( 1, 'indentList command call count' ); + } ); + } ); + + describe( 'outdent command', () => { + it( 'should execute outdentBlockList when at start of first list item at indent 0 with block indent', () => { + _setModelData( model, modelList( [ + '* []A', + '* B' + ] ) ); + + // Apply block indent first so outdentBlockList can be enabled. + model.change( writer => { + writer.setAttribute( 'blockIndentList', '40px', model.document.getRoot().getChild( 0 ) ); + writer.setAttribute( 'blockIndentList', '40px', model.document.getRoot().getChild( 1 ) ); + } ); + + editor.commands.get( 'outdent' ).execute(); + + expect( outdentBlockListSpy.callCount ).to.equal( 1, 'outdentBlockList command call count' ); + expect( outdentListSpy.callCount ).to.equal( 0, 'outdentList command call count' ); + } ); + + it( 'should execute outdentList when at start of first skip-level list item (indent > 0)', () => { + _setModelData( model, modelList( [ + ' * []A' + ] ) ); + + editor.commands.get( 'outdent' ).execute(); + + expect( outdentBlockListSpy.callCount ).to.equal( 0, 'outdentBlockList command call count' ); + expect( outdentListSpy.callCount ).to.equal( 1, 'outdentList command call count' ); + } ); + + it( 'should execute outdentList when at start of first list item at indent 0 without block indent', () => { + _setModelData( model, modelList( [ + '* []A' + ] ) ); + + editor.commands.get( 'outdent' ).execute(); + + expect( outdentBlockListSpy.callCount ).to.equal( 0, 'outdentBlockList command call count' ); + expect( outdentListSpy.callCount ).to.equal( 1, 'outdentList command call count' ); + } ); + } ); + } ); + // @param {Iterable.} input // @param {Iterable.} expected // @param {String} commandName Name of a command to execute. diff --git a/packages/ckeditor5-list/tests/list/listindentcommand.js b/packages/ckeditor5-list/tests/list/listindentcommand.js index de225221870..56272fc4f08 100644 --- a/packages/ckeditor5-list/tests/list/listindentcommand.js +++ b/packages/ckeditor5-list/tests/list/listindentcommand.js @@ -1246,114 +1246,51 @@ describe( 'ListIndentCommand', () => { expect( command.isEnabled ).to.be.true; } ); - describe( 'with IndentBlockListIntegration', () => { - beforeEach( () => { - sinon.stub( editor.plugins, 'has' ).callsFake( name => { - return name === 'IndentBlockListIntegration'; - } ); - } ); - - it( 'should be false when selection is at start of first list item', () => { - _setModelData( model, modelList( [ - '* []0', - '* 1' - ] ) ); - - expect( command.isEnabled ).to.be.false; - } ); - - it( 'should be false when a non-collapsed selection starts at the start of first list item', () => { - _setModelData( model, modelList( [ - '* [0]', - '* 1' - ] ) ); - - expect( command.isEnabled ).to.be.false; - } ); - - it( 'should be true when selection is not at start of first list item', () => { - _setModelData( model, modelList( [ - '* 0[]', - '* 1' - ] ) ); - - expect( command.isEnabled ).to.be.true; - } ); - - it( 'should be true for second list item', () => { - _setModelData( model, modelList( [ - '* 0', - '* []1' - ] ) ); - - expect( command.isEnabled ).to.be.true; - } ); - - it( 'should be false when selection is at start of first item preceded by a non-list block', () => { - _setModelData( model, modelList( [ - 'foo', - '* []0', - '* 1' - ] ) ); - - expect( command.isEnabled ).to.be.false; - } ); - - it( 'should be false when selection is at start of first bulleted item after numbered list', () => { - _setModelData( model, modelList( [ - '# 0', - '# 1', - '* []2', - '* 3' - ] ) ); - - expect( command.isEnabled ).to.be.false; - } ); - - it( 'should be true when selection is at start of a non-first item in the list', () => { - _setModelData( model, modelList( [ - '* 0', - '* []1', - '* 2' - ] ) ); + it( 'should be true when a non-collapsed selection starts at the start of first list item', () => { + _setModelData( model, modelList( [ + '* [0]', + '* 1' + ] ) ); - expect( command.isEnabled ).to.be.true; - } ); + expect( command.isEnabled ).to.be.true; + } ); - it( 'should be false when selection is at start of a skip-level list item preceded by a paragraph', () => { - _setModelData( model, modelList( [ - 'foo', - ' * []0' - ] ) ); + it( 'should be true when selection is at start of first item preceded by a non-list block', () => { + _setModelData( model, modelList( [ + 'foo', + '* []0', + '* 1' + ] ) ); - expect( command.isEnabled ).to.be.false; - } ); + expect( command.isEnabled ).to.be.true; + } ); - it( 'should be false when skip-level list item is the first element in the document', () => { - _setModelData( model, modelList( [ - ' * []0' - ] ) ); + it( 'should be true when selection is at start of first bulleted item after numbered list', () => { + _setModelData( model, modelList( [ + '# 0', + '# 1', + '* []2', + '* 3' + ] ) ); - expect( command.isEnabled ).to.be.false; - } ); + expect( command.isEnabled ).to.be.true; + } ); - it( 'should be true for a sublist item (not top-level, handled by list indent)', () => { - _setModelData( model, modelList( [ - '* 0', - ' * []1' - ] ) ); + it( 'should be true when selection is at start of a skip-level list item preceded by a paragraph', () => { + _setModelData( model, modelList( [ + 'foo', + ' * []0' + ] ) ); - expect( command.isEnabled ).to.be.true; - } ); + expect( command.isEnabled ).to.be.true; + } ); - it( 'should be true for a skip-level list item preceded by a list item of another type', () => { - _setModelData( model, modelList( [ - '# 0', - ' * []1' - ] ) ); + it( 'should be true when skip-level list item is the first element in the document', () => { + _setModelData( model, modelList( [ + ' * []0' + ] ) ); - expect( command.isEnabled ).to.be.true; - } ); + expect( command.isEnabled ).to.be.true; } ); } ); @@ -1446,26 +1383,18 @@ describe( 'ListIndentCommand', () => { ] ) ); } ); - describe( 'with IndentBlockListIntegration', () => { - beforeEach( () => { - sinon.stub( editor.plugins, 'has' ).callsFake( name => { - return name === 'IndentBlockListIntegration'; - } ); - } ); - - it( 'should indent the first list item when selection is not at the start of the item', () => { - _setModelData( model, modelList( [ - '* 0[]', - '* 1' - ] ) ); + it( 'should indent the first list item when selection is not at the start of the item', () => { + _setModelData( model, modelList( [ + '* 0[]', + '* 1' + ] ) ); - command.execute(); + command.execute(); - expect( _getModelData( model ) ).to.equalMarkup( modelList( [ - ' * 0[]', - '* 1' - ] ) ); - } ); + expect( _getModelData( model ) ).to.equalMarkup( modelList( [ + ' * 0[]', + '* 1' + ] ) ); } ); } ); } ); @@ -1512,65 +1441,20 @@ describe( 'ListIndentCommand', () => { expect( command.isEnabled ).to.be.true; } ); - describe( 'with IndentBlockListIntegration', () => { - beforeEach( () => { - sinon.stub( editor.plugins, 'has' ).callsFake( name => { - return name === 'IndentBlockListIntegration'; - } ); - } ); - - it( 'should be true when selection is at start of first list item', () => { - _setModelData( model, modelList( [ - ' * []0' - ] ) ); - - expect( command.isEnabled ).to.be.true; - } ); - - it( 'should be true when a non-collapsed selection starts at the start of first list item', () => { - _setModelData( model, modelList( [ - ' * [0]' - ] ) ); - - expect( command.isEnabled ).to.be.true; - } ); - - it( 'should be true when selection is not at start of first list item', () => { - _setModelData( model, modelList( [ - ' * 0[]' - ] ) ); - - expect( command.isEnabled ).to.be.true; - } ); - - it( 'should be true for a single list item at indent 0 with no block indent', () => { - _setModelData( model, modelList( [ - '* []0' - ] ) ); - - expect( command.isEnabled ).to.be.true; - } ); - - it( 'should be true when selection is at start of first list item preceded by a non-list block', () => { - _setModelData( model, modelList( [ - 'foo', - '* []0', - '* 1' - ] ) ); + it( 'should be true when selection is at start of first skip-level list item', () => { + _setModelData( model, modelList( [ + ' * []0' + ] ) ); - expect( command.isEnabled ).to.be.true; - } ); + expect( command.isEnabled ).to.be.true; + } ); - it( 'should be true when selection is at start of first bulleted item after numbered list', () => { - _setModelData( model, modelList( [ - '# 0', - '# 1', - '* []2', - '* 3' - ] ) ); + it( 'should be true for a single list item at indent 0', () => { + _setModelData( model, modelList( [ + '* []0' + ] ) ); - expect( command.isEnabled ).to.be.true; - } ); + expect( command.isEnabled ).to.be.true; } ); } ); @@ -1645,54 +1529,32 @@ describe( 'ListIndentCommand', () => { ] ) ); } ); - describe( 'with IndentBlockListIntegration', () => { - beforeEach( () => { - sinon.stub( editor.plugins, 'has' ).callsFake( name => { - return name === 'IndentBlockListIntegration'; - } ); - } ); - - it( 'should outdent the first list item when selection is not at the start of the item', () => { - _setModelData( model, modelList( [ - ' * 0[]', - '* 1' - ] ) ); - - command.execute(); - - expect( _getModelData( model ) ).to.equalMarkup( modelList( [ - '* 0[]', - '* 1' - ] ) ); - } ); - - it( 'should outdent the first list item at indent 0 back to a paragraph', () => { - _setModelData( model, modelList( [ - '* []0' - ] ) ); + it( 'should outdent the first list item at indent 0 back to a paragraph', () => { + _setModelData( model, modelList( [ + '* []0' + ] ) ); - command.execute(); + command.execute(); - expect( _getModelData( model ) ).to.equalMarkup( modelList( [ - '[]0' - ] ) ); - } ); + expect( _getModelData( model ) ).to.equalMarkup( modelList( [ + '[]0' + ] ) ); + } ); - it( 'should outdent the first list item at indent 0 preceded by a non-list block', () => { - _setModelData( model, modelList( [ - 'foo', - '* []0', - '* 1' - ] ) ); + it( 'should outdent the first list item at indent 0 preceded by a non-list block', () => { + _setModelData( model, modelList( [ + 'foo', + '* []0', + '* 1' + ] ) ); - command.execute(); + command.execute(); - expect( _getModelData( model ) ).to.equalMarkup( modelList( [ - 'foo', - '[]0', - '* 1' - ] ) ); - } ); + expect( _getModelData( model ) ).to.equalMarkup( modelList( [ + 'foo', + '[]0', + '* 1' + ] ) ); } ); } ); } ); diff --git a/packages/ckeditor5-list/tests/list/utils/model.js b/packages/ckeditor5-list/tests/list/utils/model.js index cdb4a784fb2..ab2915b2c2f 100644 --- a/packages/ckeditor5-list/tests/list/utils/model.js +++ b/packages/ckeditor5-list/tests/list/utils/model.js @@ -15,7 +15,6 @@ import { isLastBlockOfListItem, isFirstListItemInList, isSingleListItem, - isTopLevelListItem, ListItemUid, mergeListItemBefore, outdentBlocksWithMerge, @@ -1964,85 +1963,4 @@ describe( 'List - utils - model', () => { expect( isFirstListItemInList( fragment.getChild( 2 ) ) ).to.be.false; } ); } ); - - describe( 'isTopLevelListItem()', () => { - it( 'should return true for a list item at indent 0', () => { - const input = modelList( [ - '* a', - '* b' - ] ); - - const fragment = _parseModel( input, schema ); - - expect( isTopLevelListItem( fragment.getChild( 0 ) ) ).to.be.true; - expect( isTopLevelListItem( fragment.getChild( 1 ) ) ).to.be.true; - } ); - - it( 'should return true for a list item at indent 0 after a different type list', () => { - const input = modelList( [ - '# a', - '* b' - ] ); - - const fragment = _parseModel( input, schema ); - - expect( isTopLevelListItem( fragment.getChild( 0 ) ) ).to.be.true; - expect( isTopLevelListItem( fragment.getChild( 1 ) ) ).to.be.true; - } ); - - it( 'should return false for a sublist item', () => { - const input = modelList( [ - '* a', - ' * b' - ] ); - - const fragment = _parseModel( input, schema ); - - expect( isTopLevelListItem( fragment.getChild( 0 ) ) ).to.be.true; - expect( isTopLevelListItem( fragment.getChild( 1 ) ) ).to.be.false; - } ); - - it( 'should return true for a skip-level list item preceded by a non-list block', () => { - const input = modelList( [ - 'foo', - ' * a' - ] ); - - const fragment = _parseModel( input, schema ); - - expect( isTopLevelListItem( fragment.getChild( 1 ) ) ).to.be.true; - } ); - - it( 'should return true for a skip-level list item that is first in the document', () => { - const input = modelList( [ - ' * a' - ] ); - - const fragment = _parseModel( input, schema ); - - expect( isTopLevelListItem( fragment.getChild( 0 ) ) ).to.be.true; - } ); - - it( 'should return false for a skip-level sublist item preceded by a list item', () => { - const input = modelList( [ - '* a', - ' * b' - ] ); - - const fragment = _parseModel( input, schema ); - - expect( isTopLevelListItem( fragment.getChild( 1 ) ) ).to.be.false; - } ); - - it( 'should return false for a skip-level list item preceded by a list item of another type', () => { - const input = modelList( [ - '# a', - ' * b' - ] ); - - const fragment = _parseModel( input, schema ); - - expect( isTopLevelListItem( fragment.getChild( 1 ) ) ).to.be.false; - } ); - } ); } ); From 3d1beeabf0249dda81da6c243f2c42289d32ebb7 Mon Sep 17 00:00:00 2001 From: Marta Motyczynska Date: Sat, 18 Apr 2026 13:52:34 +0200 Subject: [PATCH 13/33] Update tests. --- .../integrations/indentblocklistcommand.ts | 2 +- .../integrations/Indentblocklistcommand.js | 20 +++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/ckeditor5-indent/src/integrations/indentblocklistcommand.ts b/packages/ckeditor5-indent/src/integrations/indentblocklistcommand.ts index 3774027c06c..f2c5effeb83 100644 --- a/packages/ckeditor5-indent/src/integrations/indentblocklistcommand.ts +++ b/packages/ckeditor5-indent/src/integrations/indentblocklistcommand.ts @@ -121,7 +121,7 @@ export class IndentBlockListCommand extends Command { if ( position.isAtStart && _isListItemBlock( parent ) && - parent.getAttribute( 'listIndent' ) === 0 && + parent.getAttribute( 'listIndent' ) == 0 && schema.checkAttribute( parent, 'blockIndentList' ) && listUtils.isFirstListItemInList( parent ) ) { diff --git a/packages/ckeditor5-indent/tests/integrations/Indentblocklistcommand.js b/packages/ckeditor5-indent/tests/integrations/Indentblocklistcommand.js index 888a099099b..9610c183f7d 100644 --- a/packages/ckeditor5-indent/tests/integrations/Indentblocklistcommand.js +++ b/packages/ckeditor5-indent/tests/integrations/Indentblocklistcommand.js @@ -218,22 +218,22 @@ describe( 'IndentBlockListCommand', () => { } ); } ); - describe( 'when skip-level lists are enabled', () => { - it( 'should be true when selection is at start of a skip-level list item preceded by a paragraph', () => { + describe( 'skip-level list items (listIndent > 0)', () => { + it( 'should be false when selection is at start of a skip-level list item preceded by a paragraph', () => { _setModelData( model, modelList( [ 'foo', ' * []bar' ] ) ); - expect( command.isEnabled ).to.be.true; + expect( command.isEnabled ).to.be.false; } ); - it( 'should be true when skip-level list item is the first element in the document', () => { + it( 'should be false when skip-level list item is the first element in the document', () => { _setModelData( model, modelList( [ ' * []bar' ] ) ); - expect( command.isEnabled ).to.be.true; + expect( command.isEnabled ).to.be.false; } ); it( 'should be false for a sublist item (has parent list item)', () => { @@ -731,22 +731,22 @@ describe( 'IndentBlockListCommand', () => { } ); } ); - describe( 'when skip-level lists are enabled', () => { - it( 'should be true when selection is at start of a skip-level list item preceded by a paragraph', () => { + describe( 'skip-level list items (listIndent > 0)', () => { + it( 'should be false when selection is at start of a skip-level list item preceded by a paragraph', () => { _setModelData( model, modelList( [ 'foo', ' * []bar' ] ) ); - expect( command.isEnabled ).to.be.true; + expect( command.isEnabled ).to.be.false; } ); - it( 'should be true when skip-level list item is the first element in the document', () => { + it( 'should be false when skip-level list item is the first element in the document', () => { _setModelData( model, modelList( [ ' * []bar' ] ) ); - expect( command.isEnabled ).to.be.true; + expect( command.isEnabled ).to.be.false; } ); it( 'should be false for a sublist item (has parent list item)', () => { From d5ab2ebddf28afd5cb6bb0a2cf53c9bef9506814 Mon Sep 17 00:00:00 2001 From: Marta Motyczynska Date: Sun, 19 Apr 2026 11:24:44 +0200 Subject: [PATCH 14/33] Fixes after code review. --- packages/ckeditor5-list/src/list/converters.ts | 14 ++++++++------ packages/ckeditor5-list/src/list/listediting.ts | 6 ++++-- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/ckeditor5-list/src/list/converters.ts b/packages/ckeditor5-list/src/list/converters.ts index 14c1ad2984f..81214e934ba 100644 --- a/packages/ckeditor5-list/src/list/converters.ts +++ b/packages/ckeditor5-list/src/list/converters.ts @@ -370,7 +370,7 @@ export function listItemDowncastConverter( attributeNames: Array, strategies: Array, model: Model, - { dataPipeline, allowSkipLevels }: { dataPipeline?: boolean; allowSkipLevels?: boolean } = {} + { dataPipeline, allowSkipLevels }: { dataPipeline?: boolean; allowSkipLevels?: boolean } ): GetCallback> { const consumer = createAttributesConsumer( attributeNames, strategies ); @@ -703,11 +703,13 @@ function wrapListItemBlock( let currentListItem: ListElement | null = listItem; for ( let indent = listItemIndent; indent >= 0; indent-- ) { - // When allowSkipLevels is enabled and ListWalker jumps over indent levels (e.g. from indent 2 - // to indent 0), the levels in between have no corresponding model element. We detect these - // "intermediate" levels by checking if currentListItem's indent doesn't match the current - // loop indent. - const isIntermediate = allowSkipLevels && currentListItem.getAttribute( 'listIndent' ) !== indent; + // When ListWalker jumps over indent levels (e.g. from indent 2 to indent 0, either because + // allowSkipLevels is enabled or because the item is at the start of a fragment and its + // nearest ancestor is further up), the levels in between have no corresponding model element. + // We detect these "intermediate" levels by checking if currentListItem's indent doesn't match + // the current loop indent. Handling this regardless of the allowSkipLevels config makes the + // downcast resilient to unexpected skip-level states in the model. + const isIntermediate = currentListItem.getAttribute( 'listIndent' ) !== indent; if ( isIntermediate ) { // Intermediate levels get invisible wrappers: list-style-type:none hides the marker on
  • , diff --git a/packages/ckeditor5-list/src/list/listediting.ts b/packages/ckeditor5-list/src/list/listediting.ts index ad502ec0694..7af7a6d320e 100644 --- a/packages/ckeditor5-list/src/list/listediting.ts +++ b/packages/ckeditor5-list/src/list/listediting.ts @@ -486,8 +486,10 @@ export class ListEditing extends Plugin { dispatcher.on>( 'attribute', listItemDowncastConverter( - attributeNames, this._downcastStrategies, model, - allowSkipLevels ? { allowSkipLevels } : undefined + attributeNames, + this._downcastStrategies, + model, + { allowSkipLevels } ) ); From 250bac4ddd2df6eaa191f958640da79d4ba867a9 Mon Sep 17 00:00:00 2001 From: Marta Motyczynska Date: Sun, 19 Apr 2026 12:27:16 +0200 Subject: [PATCH 15/33] Code review fixes. --- packages/ckeditor5-list/src/list/converters.ts | 14 +++++++------- packages/ckeditor5-list/src/list/listediting.ts | 9 +++++++-- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/ckeditor5-list/src/list/converters.ts b/packages/ckeditor5-list/src/list/converters.ts index 69533c3fac4..51b7a2776bc 100644 --- a/packages/ckeditor5-list/src/list/converters.ts +++ b/packages/ckeditor5-list/src/list/converters.ts @@ -59,17 +59,17 @@ import type { } from './listediting.js'; /** - * Returns the upcast converter for skip-level list item wrappers. It detects intermediate `
  • ` elements - * with `list-style-type:none` (generated by the skip-level downcast or external sources) and unwraps them - * so they don't produce empty list items in the model. + * Returns a consuming upcast converter for skip-level list item wrappers. It detects intermediate `
  • ` elements + * with `list-style-type:none` (generated by the skip-level downcast or by external sources) and consumes them + * without producing a model element, so they don't end up as empty list items in the model. * - * The wrapper `
  • ` is consumed without creating a model element, but its children (nested lists) are - * converted normally. Because `getIndent()` counts all ancestor `
  • ` elements (including the consumed wrapper), - * the nested items receive the correct indent values that reflect the skip-level gap. + * The wrapper `
  • ` is consumed, but its children (nested lists) are converted normally. Because `getIndent()` + * counts all ancestor `
  • ` elements (including the consumed wrapper), nested items receive the correct indent + * values that reflect the skip-level gap. * * @internal */ -export function listItemSkipLevelUpcastConverter(): GetCallback { +export function listItemSkipLevelConsumer(): GetCallback { return ( evt, data, conversionApi ) => { const viewItem = data.viewItem; diff --git a/packages/ckeditor5-list/src/list/listediting.ts b/packages/ckeditor5-list/src/list/listediting.ts index 4fd724c811d..4d01fa394de 100644 --- a/packages/ckeditor5-list/src/list/listediting.ts +++ b/packages/ckeditor5-list/src/list/listediting.ts @@ -45,7 +45,7 @@ import { createModelToViewPositionMapper, listItemDowncastConverter, listItemDowncastRemoveConverter, - listItemSkipLevelUpcastConverter, + listItemSkipLevelConsumer, listItemUpcastConverter, reconvertItemsOnDataChange } from './converters.js'; @@ -466,8 +466,13 @@ export class ListEditing extends Plugin { } ) .add( dispatcher => { if ( allowSkipLevels ) { + // A consuming converter for intermediate `
  • ` wrappers produced by the skip-level downcast + // (or by external sources). It is registered with 'high' priority so it reliably runs before + // any other `element:li` upcast converter. Registration order inside this `.add()` is already + // enough for our own converters, but `high` guards against other plugins that may attach their + // own `element:li` listeners at the default priority in a separate `.add()` block. dispatcher.on( - 'element:li', listItemSkipLevelUpcastConverter(), { priority: 'high' } + 'element:li', listItemSkipLevelConsumer(), { priority: 'high' } ); } From 47297a18adbf5005feb70ce4e017b2275b412c7e Mon Sep 17 00:00:00 2001 From: Marta Motyczynska Date: Mon, 20 Apr 2026 14:55:01 +0200 Subject: [PATCH 16/33] Review changes. --- ...stcommand.js => indentblocklistcommand.js} | 0 .../tests/list/listindentcommand.js | 22 +++++++++++++++++++ 2 files changed, 22 insertions(+) rename packages/ckeditor5-indent/tests/integrations/{Indentblocklistcommand.js => indentblocklistcommand.js} (100%) diff --git a/packages/ckeditor5-indent/tests/integrations/Indentblocklistcommand.js b/packages/ckeditor5-indent/tests/integrations/indentblocklistcommand.js similarity index 100% rename from packages/ckeditor5-indent/tests/integrations/Indentblocklistcommand.js rename to packages/ckeditor5-indent/tests/integrations/indentblocklistcommand.js diff --git a/packages/ckeditor5-list/tests/list/listindentcommand.js b/packages/ckeditor5-list/tests/list/listindentcommand.js index 56272fc4f08..edfa752acab 100644 --- a/packages/ckeditor5-list/tests/list/listindentcommand.js +++ b/packages/ckeditor5-list/tests/list/listindentcommand.js @@ -1381,6 +1381,18 @@ describe( 'ListIndentCommand', () => { '* 5', '* 6' ] ) ); + + command.execute(); + + expect( _getModelData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * []1', + ' * 2', + ' * 3', + ' * 4', + '* 5', + '* 6' + ] ) ); } ); it( 'should indent the first list item when selection is not at the start of the item', () => { @@ -1487,6 +1499,16 @@ describe( 'ListIndentCommand', () => { it( 'should outdent only selected items when multiple items are selected', () => { _setModelData( model, modelList( [ + '* 0', + ' * [1', + ' * 2', + ' * 3]', + '* 4' + ] ) ); + + command.execute(); + + expect( _getModelData( model ) ).to.equalMarkup( modelList( [ '* 0', ' * [1', ' * 2', From aaec3c32951c20bc175f33261c2cb11d64a34389 Mon Sep 17 00:00:00 2001 From: Marta Motyczynska Date: Mon, 20 Apr 2026 16:01:59 +0200 Subject: [PATCH 17/33] Review fixes. --- packages/ckeditor5-list/tests/list/converters-data.js | 2 +- packages/ckeditor5-list/tests/list/converters.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ckeditor5-list/tests/list/converters-data.js b/packages/ckeditor5-list/tests/list/converters-data.js index 12f802d0e69..b6d09fcf1f5 100644 --- a/packages/ckeditor5-list/tests/list/converters-data.js +++ b/packages/ckeditor5-list/tests/list/converters-data.js @@ -2814,7 +2814,7 @@ describe( 'ListEditing - converters - data pipeline', () => { ); } ); - it( 'should handle a block widget at a skip level', () => { + it( 'should handle a container at a skip level', () => { _setModelData( skipModel, 'A' + '
    ' + diff --git a/packages/ckeditor5-list/tests/list/converters.js b/packages/ckeditor5-list/tests/list/converters.js index 4a57c6f330b..b17de40685c 100644 --- a/packages/ckeditor5-list/tests/list/converters.js +++ b/packages/ckeditor5-list/tests/list/converters.js @@ -1304,7 +1304,7 @@ describe( 'ListEditing - converters', () => { ); } ); - it( 'should handle a block widget at a skip level', () => { + it( 'should handle a container at a skip level', () => { _setModelData( skipModel, 'A' + '
    ' + From b91d7b629946b09620bb93ddd013d08222867ef9 Mon Sep 17 00:00:00 2001 From: Marta Motyczynska Date: Mon, 20 Apr 2026 16:43:56 +0200 Subject: [PATCH 18/33] Add test for skip level list and GHS and content inside intermediate wrapper. --- .../tests/list/converters-data.js | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/packages/ckeditor5-list/tests/list/converters-data.js b/packages/ckeditor5-list/tests/list/converters-data.js index c12138d0cc1..2dd720743c3 100644 --- a/packages/ckeditor5-list/tests/list/converters-data.js +++ b/packages/ckeditor5-list/tests/list/converters-data.js @@ -15,9 +15,11 @@ import { IndentEditing } from '@ckeditor/ckeditor5-indent'; import { TableEditing } from '@ckeditor/ckeditor5-table'; import { CodeBlockEditing } from '@ckeditor/ckeditor5-code-block'; import { Paragraph } from '@ckeditor/ckeditor5-paragraph'; +import { GeneralHtmlSupport } from '@ckeditor/ckeditor5-html-support'; import { testUtils } from '@ckeditor/ckeditor5-core/tests/_utils/utils.js'; import { VirtualTestEditor } from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor.js'; +import { ClassicTestEditor } from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor.js'; import { setupTestHelpers } from './_utils/utils.js'; import { stubUid } from './_utils/uid.js'; @@ -3130,5 +3132,56 @@ describe( 'ListEditing - converters - data pipeline', () => { ); } ); } ); + + describe( 'upcast with GeneralHtmlSupport', () => { + let ghsEditor, ghsModel, ghsElement; + + beforeEach( async () => { + ghsElement = document.createElement( 'div' ); + document.body.appendChild( ghsElement ); + + ghsEditor = await ClassicTestEditor.create( ghsElement, { + plugins: [ Paragraph, IndentEditing, ClipboardPipeline, BoldEditing, ListEditing, UndoEditing, + BlockQuoteEditing, TableEditing, HeadingEditing, CodeBlockEditing, GeneralHtmlSupport ], + list: { + allowSkipLevels: true + }, + htmlSupport: { + allow: [ + { + name: /./, + styles: true, + attributes: true, + classes: true + } + ] + } + } ); + + ghsModel = ghsEditor.model; + } ); + + afterEach( async () => { + ghsElement.remove(); + await ghsEditor.destroy(); + } ); + + it( 'should preserve text from a skip-level wrapper that unexpectedly contains text content', () => { + ghsEditor.setData( + '
      ' + + '
    1. ' + + '
        ' + + '
      1. foobar
      2. ' + + '
      ' + + '
    2. ' + + '
    ' + ); + + expect( _getModelData( ghsModel, { withoutSelection: true } ) ).to.equalMarkup( + 'foobar' + ); + } ); + } ); } ); } ); From b0adee9139267386dd9b9670f68bf9cb720a14fb Mon Sep 17 00:00:00 2001 From: Marta Motyczynska Date: Tue, 21 Apr 2026 18:20:19 +0200 Subject: [PATCH 19/33] Extend autoformat for lists to accept any number. --- .../ckeditor5-autoformat/src/autoformat.ts | 19 +- .../ckeditor5-autoformat/tests/autoformat.js | 254 +++++++++++++++++- .../tests/manual/documentlist-skip-levels.js | 3 +- 3 files changed, 268 insertions(+), 8 deletions(-) diff --git a/packages/ckeditor5-autoformat/src/autoformat.ts b/packages/ckeditor5-autoformat/src/autoformat.ts index faad1d2f327..f049f880efc 100644 --- a/packages/ckeditor5-autoformat/src/autoformat.ts +++ b/packages/ckeditor5-autoformat/src/autoformat.ts @@ -73,7 +73,10 @@ export class Autoformat extends Plugin { * * When typed: * - `* ` or `- ` – A paragraph will be changed into a bulleted list. - * - `1. ` or `1) ` – A paragraph will be changed into a numbered list ("1" can be any digit or a list of digits). + * - `. ` or `) ` – A paragraph will be changed into a numbered list. + * If the paragraph is adjacent to an existing list, the typed number is ignored and the item joins the list + * as the next sequential item. Otherwise, a new list is created with the `listStart` attribute set to the typed number + * (when the {@link module:list/listproperties~ListProperties start index feature} is enabled). * - `[] ` or `[ ] ` – A paragraph will be changed into a to-do list. * - `[x] ` or `[ x ] ` – A paragraph will be changed into a checked to-do list. */ @@ -85,7 +88,19 @@ export class Autoformat extends Plugin { } if ( commands.get( 'numberedList' ) ) { - blockAutoformatEditing( this.editor, this, /^1[.|)]\s$/, 'numberedList' ); + const numberedListCommand = commands.get( 'numberedList' )!; + const hasStartIndexFeature = !!commands.get( 'listStart' ); + + blockAutoformatEditing( this.editor, this, /^(\d+)[.|)]\s$/, ( { match } ) => { + if ( !numberedListCommand.isEnabled || numberedListCommand.value === true ) { + return false; + } + + this.editor.execute( 'numberedList', hasStartIndexFeature ? + { additionalAttributes: { listStart: parseInt( match[ 1 ] ) } } : + undefined + ); + } ); } if ( commands.get( 'todoList' ) ) { diff --git a/packages/ckeditor5-autoformat/tests/autoformat.js b/packages/ckeditor5-autoformat/tests/autoformat.js index f897b290763..ae06e8d0afc 100644 --- a/packages/ckeditor5-autoformat/tests/autoformat.js +++ b/packages/ckeditor5-autoformat/tests/autoformat.js @@ -6,7 +6,7 @@ import { Autoformat } from '../src/autoformat.js'; import { Paragraph } from '@ckeditor/ckeditor5-paragraph'; -import { ListEditing, TodoListEditing } from '@ckeditor/ckeditor5-list'; +import { ListEditing, ListPropertiesEditing, TodoListEditing } from '@ckeditor/ckeditor5-list'; import { HeadingEditing, HeadingCommand } from '@ckeditor/ckeditor5-heading'; import { BoldEditing, StrikethroughEditing, CodeEditing, ItalicEditing } from '@ckeditor/ckeditor5-basic-styles'; import { BlockQuoteEditing } from '@ckeditor/ckeditor5-block-quote'; @@ -278,11 +278,40 @@ describe( 'Autoformat', () => { ); } ); - it( 'should not replace digit with numbered list item when digit is different than "1"', () => { + it( 'should replace digit with numbered list item when digit is different than "1"', () => { _setModelData( model, '3.[]' ); insertSpace(); - expect( _getModelData( model ) ).to.equal( '3. []' ); + expect( _getModelData( model ) ).to.equal( + '[]' + ); + } ); + + it( 'should replace multi-digit number with numbered list item', () => { + _setModelData( model, '12.[]' ); + insertSpace(); + + expect( _getModelData( model ) ).to.equal( + '[]' + ); + } ); + + it( 'should replace digit with numbered list item using the parenthesis format when digit is not "1"', () => { + _setModelData( model, '5)[]' ); + insertSpace(); + + expect( _getModelData( model ) ).to.equal( + '[]' + ); + } ); + + it( 'should not replace digit character when inside numbered list item (digit different than "1")', () => { + _setModelData( model, '5.[]' ); + insertSpace(); + + expect( _getModelData( model ) ).to.equal( + '5. []' + ); } ); it( 'should not replace digit character after ', () => { @@ -1631,11 +1660,40 @@ describe( 'Autoformat', () => { ); } ); - it( 'should not replace digit with numbered list item when digit is different than "1"', () => { + it( 'should replace digit with numbered list item when digit is different than "1"', () => { _setModelData( model, '3.[]' ); insertSpace(); - expect( _getModelData( model ) ).to.equal( '3. []' ); + expect( _getModelData( model ) ).to.equal( + '[]' + ); + } ); + + it( 'should replace multi-digit number with numbered list item', () => { + _setModelData( model, '12.[]' ); + insertSpace(); + + expect( _getModelData( model ) ).to.equal( + '[]' + ); + } ); + + it( 'should replace digit with numbered list item using the parenthesis format when digit is not "1"', () => { + _setModelData( model, '5)[]' ); + insertSpace(); + + expect( _getModelData( model ) ).to.equal( + '[]' + ); + } ); + + it( 'should not replace digit character when inside numbered list item (digit different than "1")', () => { + _setModelData( model, '5.[]' ); + insertSpace(); + + expect( _getModelData( model ) ).to.equal( + '5. []' + ); } ); it( 'should not replace digit character after ', () => { @@ -2698,6 +2756,192 @@ describe( 'Autoformat', () => { } ); } ); + describe( 'with list properties (startIndex)', () => { + beforeEach( async () => { + editor = await VirtualTestEditor.create( { + plugins: [ + Enter, + Paragraph, + Autoformat, + ListEditing, + ListPropertiesEditing, + HeadingEditing, + UndoEditing + ], + list: { + properties: { + startIndex: true, + reversed: false, + styles: false + } + } + } ); + + model = editor.model; + doc = model.document; + + stubUid(); + } ); + + afterEach( () => { + return editor.destroy(); + } ); + + it( 'should set listStart attribute to 1 when typing "1. "', () => { + _setModelData( model, '1.[]' ); + insertSpace(); + + expect( _getModelData( model ) ).to.equal( + '[]' + ); + } ); + + it( 'should set listStart attribute to the typed number when typing "5. "', () => { + _setModelData( model, '5.[]' ); + insertSpace(); + + expect( _getModelData( model ) ).to.equal( + '[]' + ); + } ); + + it( 'should set listStart attribute to the typed number for multi-digit "12. "', () => { + _setModelData( model, '12.[]' ); + insertSpace(); + + expect( _getModelData( model ) ).to.equal( + '[]' + ); + } ); + + it( 'should set listStart attribute to 0 when typing "0. "', () => { + _setModelData( model, '0.[]' ); + insertSpace(); + + expect( _getModelData( model ) ).to.equal( + '[]' + ); + } ); + + it( 'should set listStart attribute to the typed number for the parenthesis format "5) "', () => { + _setModelData( model, '5)[]' ); + insertSpace(); + + expect( _getModelData( model ) ).to.equal( + '[]' + ); + } ); + + it( 'should ignore typed number and inherit listStart from adjacent numbered list above', () => { + _setModelData( model, + 'Item 1' + + '5.[]' + ); + insertSpace(); + + expect( _getModelData( model ) ).to.equal( + 'Item 1' + + '[]' + ); + } ); + + it( 'should start a new numbered list with typed listStart when adjacent list is bulleted', () => { + _setModelData( model, + 'Item 1' + + '5.[]' + ); + insertSpace(); + + expect( _getModelData( model ) ).to.equal( + 'Item 1' + + '[]' + ); + } ); + } ); + + describe( 'with list properties but startIndex disabled', () => { + beforeEach( async () => { + editor = await VirtualTestEditor.create( { + plugins: [ + Enter, + Paragraph, + Autoformat, + ListEditing, + ListPropertiesEditing, + HeadingEditing, + UndoEditing + ], + list: { + properties: { + startIndex: false, + reversed: false, + styles: false + } + } + } ); + + model = editor.model; + doc = model.document; + + stubUid(); + } ); + + afterEach( () => { + return editor.destroy(); + } ); + + it( 'should not set listStart attribute when typing "5. "', () => { + _setModelData( model, '5.[]' ); + insertSpace(); + + expect( _getModelData( model ) ).to.equal( + '[]' + ); + } ); + } ); + + describe( 'with single-block lists plugin and list properties (startIndex)', () => { + beforeEach( async () => { + editor = await VirtualTestEditor.create( { + plugins: [ + Enter, + Paragraph, + Autoformat, + ListEditing, + ListPropertiesEditing, + HeadingEditing, + UndoEditing + ], + list: { + multiBlock: false, + properties: { + startIndex: true, + reversed: false, + styles: false + } + } + } ); + + model = editor.model; + doc = model.document; + + stubUid(); + } ); + + afterEach( () => { + return editor.destroy(); + } ); + + it( 'should set listStart attribute to the typed number when typing "5. "', () => { + _setModelData( model, '5.[]' ); + insertSpace(); + + expect( _getModelData( model ) ).to.equal( + '[]' + ); + } ); + } ); + function insertSpace() { model.change( writer => { writer.insertText( ' ', doc.selection.getFirstPosition() ); diff --git a/packages/ckeditor5-list/tests/manual/documentlist-skip-levels.js b/packages/ckeditor5-list/tests/manual/documentlist-skip-levels.js index f8e166172d7..b9518af30eb 100644 --- a/packages/ckeditor5-list/tests/manual/documentlist-skip-levels.js +++ b/packages/ckeditor5-list/tests/manual/documentlist-skip-levels.js @@ -22,6 +22,7 @@ import { Indent, IndentBlock } from '@ckeditor/ckeditor5-indent'; import { MediaEmbed } from '@ckeditor/ckeditor5-media-embed'; import { Paragraph } from '@ckeditor/ckeditor5-paragraph'; import { PasteFromOffice } from '@ckeditor/ckeditor5-paste-from-office'; +import { Autoformat } from '@ckeditor/ckeditor5-autoformat'; import { TodoList } from '../../src/todolist.js'; import { List } from '../../src/list.js'; @@ -45,7 +46,7 @@ function getEditorConfig() { Essentials, BlockQuote, Bold, Heading, Image, ImageCaption, ImageStyle, ImageToolbar, Indent, Italic, Link, MediaEmbed, Paragraph, Table, TableToolbar, CodeBlock, TableCaption, ImageResize, LinkImage, HtmlEmbed, HtmlComment, Alignment, PageBreak, HorizontalLine, ImageUpload, - SourceEditing, List, TodoList + SourceEditing, List, TodoList, Autoformat ]; if ( controls.indentBlock.checked ) { From 4c5afd0648c19e5b8d66c8cfef99fbec0116906d Mon Sep 17 00:00:00 2001 From: Marta Motyczynska Date: Thu, 23 Apr 2026 21:55:53 +0200 Subject: [PATCH 20/33] Improve detecting which items should be reconverted when using skip-level lists. --- .../ckeditor5-list/src/list/converters.ts | 27 +++++++ .../ckeditor5-list/tests/list/converters.js | 76 +++++++++++++++++++ .../tests/listproperties/converters.js | 36 +++++++++ 3 files changed, 139 insertions(+) diff --git a/packages/ckeditor5-list/src/list/converters.ts b/packages/ckeditor5-list/src/list/converters.ts index 1a84bc5b939..9609f089f92 100644 --- a/packages/ckeditor5-list/src/list/converters.ts +++ b/packages/ckeditor5-list/src/list/converters.ts @@ -260,6 +260,33 @@ export function reconvertItemsOnDataChange( modelElement: node }; + // Skip-level lists have view wrappers (
      /
        ) at indent levels with no matching + // model item. Each such wrapper inherits its attributes (listStart, listStyle, etc.) + // from a nearby model item - the first sibling found at that indent, or the current + // node as a fallback. Remember that item here so later we can compare the wrapper + // against the current model and tell whether it's still up to date. + for ( let i = itemIndent - 1; i >= 0; i-- ) { + if ( stack[ i ] ) { + break; + } + + const siblingAtIndent = findSiblingListItemAt( node, i ); + const referenceItem: ListElement = siblingAtIndent || node; + + const modelAttributes: ListItemAttributesMap = { + ...Object.fromEntries( + Array.from( referenceItem.getAttributes() ) + .filter( ( [ key ] ) => attributeNames.includes( key ) ) + ), + listItemId: `list-item-skip-${ i }` + }; + + stack[ i ] = { + modelAttributes, + modelElement: referenceItem + }; + } + // Find all blocks of the current node. const blocks = getListItemBlocks( node, { direction: 'forward' } ); diff --git a/packages/ckeditor5-list/tests/list/converters.js b/packages/ckeditor5-list/tests/list/converters.js index 6b7ee0e4f08..e3fc0aa0286 100644 --- a/packages/ckeditor5-list/tests/list/converters.js +++ b/packages/ckeditor5-list/tests/list/converters.js @@ -1472,6 +1472,82 @@ describe( 'ListEditing - converters', () => { await editor.destroy(); } ); + + it( 'should refresh intermediate wrappers when listType changes on an item following a skip-level item', () => { + _setModelData( skipModel, + 'aaa' + + 'bbb' + ); + + skipModel.change( writer => { + writer.setAttribute( 'listType', 'bulleted', skipModel.document.getRoot().getChild( 1 ) ); + } ); + + expect( _getViewData( skipEditor.editing.view, { withoutSelection: true } ) ).to.equal( + '
          ' + + '
        • ' + + '
            ' + + '
          1. aaa
          2. ' + + '
          ' + + '
        • ' + + '
        • bbb
        • ' + + '
        ' + ); + } ); + + it( 'should keep consistent marker styling after indenting multiple items into a skip level', () => { + _setModelData( skipModel, + 'aaa' + + 'bbb' + + 'ccc' + + 'ddd' + ); + + // Indent bbb, ccc, ddd one by one (each as a separate model change). + for ( let i = 1; i <= 3; i++ ) { + skipModel.change( writer => { + writer.setAttribute( 'listIndent', 2, skipModel.document.getRoot().getChild( i ) ); + } ); + } + + // All three items end up at indent 2 and share a single intermediate wrapper at indent 1, + // so their markers are consistent (all rendered by the same
          ). + expect( _getViewData( skipEditor.editing.view, { withoutSelection: true } ) ).to.equal( + '
            ' + + '
          1. ' + + 'aaa' + + '
              ' + + '
            1. ' + + '
                ' + + '
              • bbb
              • ' + + '
              • ccc
              • ' + + '
              • ddd
              • ' + + '
              ' + + '
            2. ' + + '
            ' + + '
          2. ' + + '
          ' + ); + } ); + + it( 'should refresh intermediate wrappers after undoing an outdent of a skip-level item', () => { + _setModelData( skipModel, + 'aaa' + + 'bbb' + + 'ccc' + ); + + const initialView = _getViewData( skipEditor.editing.view, { withoutSelection: true } ); + + // Outdent bbb from indent 2 to indent 1. + skipModel.change( writer => { + writer.setAttribute( 'listIndent', 1, skipModel.document.getRoot().getChild( 1 ) ); + } ); + + skipEditor.execute( 'undo' ); + + expect( _getViewData( skipEditor.editing.view, { withoutSelection: true } ) ).to.equal( initialView ); + } ); } ); function getViewPosition( root, path, view ) { diff --git a/packages/ckeditor5-list/tests/listproperties/converters.js b/packages/ckeditor5-list/tests/listproperties/converters.js index 7625260120a..b92f083076b 100644 --- a/packages/ckeditor5-list/tests/listproperties/converters.js +++ b/packages/ckeditor5-list/tests/listproperties/converters.js @@ -2121,6 +2121,42 @@ describe( 'ListPropertiesEditing - converters', () => { } ); } ); } ); + + // See https://github.com/ckeditor/ckeditor5-commercial/issues/9765. + it( 'should refresh intermediate wrappers when listStart changes after a skip-level item', async () => { + const skipEditor = await VirtualTestEditor.create( { + plugins: [ Paragraph, IndentEditing, ClipboardPipeline, BoldEditing, ListPropertiesEditing, + UndoEditing, BlockQuoteEditing, TableEditing, HeadingEditing, AlignmentEditing ], + list: { + properties: { styles: false, startIndex: true, reversed: false }, + allowSkipLevels: true + } + } ); + + sinon.stub( skipEditor.editing.view, 'scrollToTheSelection' ).callsFake( () => {} ); + + _setModelData( skipEditor.model, + 'aaa' + + 'bbb' + ); + + skipEditor.model.change( writer => { + writer.setAttribute( 'listStart', 5, skipEditor.model.document.getRoot().getChild( 1 ) ); + } ); + + expect( _getViewData( skipEditor.editing.view, { withoutSelection: true } ) ).to.equal( + '
            ' + + '
          1. ' + + '
              ' + + '
            1. aaa
            2. ' + + '
            ' + + '
          2. ' + + '
          3. bbb
          4. ' + + '
          ' + ); + + await skipEditor.destroy(); + } ); } ); describe( 'change list type', () => { From 0834c16b648b674d1b5f0087e35a424932e57a54 Mon Sep 17 00:00:00 2001 From: Marta Motyczynska Date: Sun, 26 Apr 2026 18:16:25 +0200 Subject: [PATCH 21/33] Code review fix. --- packages/ckeditor5-list/src/list/converters.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ckeditor5-list/src/list/converters.ts b/packages/ckeditor5-list/src/list/converters.ts index 9609f089f92..1b76733906f 100644 --- a/packages/ckeditor5-list/src/list/converters.ts +++ b/packages/ckeditor5-list/src/list/converters.ts @@ -278,7 +278,8 @@ export function reconvertItemsOnDataChange( Array.from( referenceItem.getAttributes() ) .filter( ( [ key ] ) => attributeNames.includes( key ) ) ), - listItemId: `list-item-skip-${ i }` + listItemId: `list-item-skip-${ i }`, + listIndent: i }; stack[ i ] = { From 257bc3c2154af86f4b563a4f13ef31914e17521d Mon Sep 17 00:00:00 2001 From: Marta Motyczynska Date: Sun, 26 Apr 2026 19:24:55 +0200 Subject: [PATCH 22/33] Improve collecting list items to be reconverted. --- .../ckeditor5-list/src/list/converters.ts | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/ckeditor5-list/src/list/converters.ts b/packages/ckeditor5-list/src/list/converters.ts index 1b76733906f..ea564803b39 100644 --- a/packages/ckeditor5-list/src/list/converters.ts +++ b/packages/ckeditor5-list/src/list/converters.ts @@ -262,16 +262,29 @@ export function reconvertItemsOnDataChange( // Skip-level lists have view wrappers (
            /
              ) at indent levels with no matching // model item. Each such wrapper inherits its attributes (listStart, listStyle, etc.) - // from a nearby model item - the first sibling found at that indent, or the current - // node as a fallback. Remember that item here so later we can compare the wrapper - // against the current model and tell whether it's still up to date. + // from a nearby model item - the first sibling found at that indent, or the closest + // lower-indent ancestor (mirroring the downcast's fallback). Remember that item here + // so later we can compare the wrapper against the current model and tell whether it's + // still up to date. for ( let i = itemIndent - 1; i >= 0; i-- ) { if ( stack[ i ] ) { break; } const siblingAtIndent = findSiblingListItemAt( node, i ); - const referenceItem: ListElement = siblingAtIndent || node; + + let ancestorAtLowerIndent: ListElement | null = null; + + if ( !siblingAtIndent ) { + for ( let k = i - 1; k >= 0; k-- ) { + if ( stack[ k ] ) { + ancestorAtLowerIndent = stack[ k ].modelElement; + break; + } + } + } + + const referenceItem: ListElement = siblingAtIndent || ancestorAtLowerIndent || node; const modelAttributes: ListItemAttributesMap = { ...Object.fromEntries( From 9e7887852016e6c441d8ea8ab6954a8d3c414b59 Mon Sep 17 00:00:00 2001 From: Marta Motyczynska Date: Mon, 27 Apr 2026 17:09:16 +0200 Subject: [PATCH 23/33] Don't block indent list item at indent 0 after a skip-level. --- .../integrations/indentblocklistcommand.js | 28 +++++++++ .../ckeditor5-list/src/list/utils/model.ts | 48 +++++++++++++-- .../list/integrations/indentmulticommand.js | 28 +++++++++ .../ckeditor5-list/tests/list/utils/model.js | 60 ++++++++++++------- 4 files changed, 138 insertions(+), 26 deletions(-) diff --git a/packages/ckeditor5-indent/tests/integrations/indentblocklistcommand.js b/packages/ckeditor5-indent/tests/integrations/indentblocklistcommand.js index 9610c183f7d..4d188af8e8a 100644 --- a/packages/ckeditor5-indent/tests/integrations/indentblocklistcommand.js +++ b/packages/ckeditor5-indent/tests/integrations/indentblocklistcommand.js @@ -253,6 +253,16 @@ describe( 'IndentBlockListCommand', () => { expect( command.isEnabled ).to.be.false; } ); + + it( 'should be false for a top-level item placed after a skip-level nested list', () => { + // `bbb` is the second visible item in the outer list, so block indent must not kick in. + _setModelData( model, modelList( [ + ' # aaa', + '# []bbb' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); } ); } ); @@ -766,6 +776,24 @@ describe( 'IndentBlockListCommand', () => { expect( command.isEnabled ).to.be.false; } ); + + // See ckeditor/ckeditor5-commercial#9763. + it( 'should be false for a top-level item placed after a same-type skip-level nested list', () => { + // In the view this model is rendered as: + //
                + //
              1. + //
                1. aaa
                + //
              2. + //
              3. bbb
              4. + //
              + // `bbb` is the second visible item in the outer list, so block indent must not kick in. + _setModelData( model, modelList( [ + ' # aaa', + '# []bbb' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); } ); } ); diff --git a/packages/ckeditor5-list/src/list/utils/model.ts b/packages/ckeditor5-list/src/list/utils/model.ts index cf4aaf7f819..ccd837a8430 100644 --- a/packages/ckeditor5-list/src/list/utils/model.ts +++ b/packages/ckeditor5-list/src/list/utils/model.ts @@ -602,18 +602,56 @@ export function isNumberedListType( listType: ListType ): boolean { } /** - * Checks if the given list item is the first item in the list. + * Checks if the given list item is the first item in its list at the given indent. * - * This function checks if there's any other list item before the given list item - * at the same indent level with the same list type. + * Returns `false` when any preceding list block belongs to the same list — either as a same-indent + * sibling of the same `listType`, or as a higher-indent block (a regular nested child, or an + * intermediate skip-level `
            • ` wrapper). For example, in the model: + * + * ``` + * ' # aaa' + * '# bbb' + * ``` + * + * `bbb` is preceded by a higher-indent block `aaa`, which in the view is rendered inside an intermediate + * skip-level wrapper at indent 0: + * + * ```html + *
                + *
              1. + *
                  + *
                1. aaa
                2. + *
                + *
              2. + *
              3. bbb
              4. + *
              + * ``` + * + * So `bbb` is the second visible item in the outer list and the function returns `false`. */ export function isFirstListItemInList( listItem: ModelElement ): boolean { - const previousItem = ListWalker.first( listItem, { + // Same-indent, same-type predecessor. If it exists, we are not the first item in the list. + const sameIndentMatch = ListWalker.first( listItem, { sameIndent: true, sameAttributes: 'listType' } ); - return !previousItem; + if ( sameIndentMatch ) { + return false; + } + + // A higher-indent immediate predecessor means we are nested below something at our indent + // (a real parent or an intermediate skip-level wrapper), so we are not first either. + const previousItem = listItem.previousSibling; + + if ( + isListItemBlock( previousItem ) && + ( previousItem.getAttribute( 'listIndent' ) as number ) > ( listItem.getAttribute( 'listIndent' ) as number ) + ) { + return false; + } + + return true; } /** diff --git a/packages/ckeditor5-list/tests/list/integrations/indentmulticommand.js b/packages/ckeditor5-list/tests/list/integrations/indentmulticommand.js index 74c68971318..a60db66870d 100644 --- a/packages/ckeditor5-list/tests/list/integrations/indentmulticommand.js +++ b/packages/ckeditor5-list/tests/list/integrations/indentmulticommand.js @@ -1585,6 +1585,34 @@ describe( 'Indent MultiCommand integrations', () => { expect( indentBlockListSpy.callCount ).to.equal( 0, 'indentBlockList command call count' ); expect( indentListSpy.callCount ).to.equal( 1, 'indentList command call count' ); } ); + + // See ckeditor/ckeditor5-commercial#9763. + it( 'should execute indentList (not indentBlockList) when at start of a top-level item ' + + 'placed after a skip-level nested list', () => { + // In the view this model is rendered as: + //
                + //
              1. + //
                1. A
                + //
              2. + //
              3. B
              4. + //
              + // `B` is the second visible item in the outer list, so Tab should indent it as a list item + // (joining the nested list under the intermediate wrapper), not apply block indent. + _setModelData( model, modelList( [ + ' # A', + '# []B' + ] ) ); + + editor.commands.get( 'indent' ).execute(); + + expect( indentBlockListSpy.callCount ).to.equal( 0, 'indentBlockList command call count' ); + expect( indentListSpy.callCount ).to.equal( 1, 'indentList command call count' ); + + expect( _getModelData( model ) ).to.equalMarkup( modelList( [ + ' # A', + ' # []B' + ] ) ); + } ); } ); describe( 'outdent command', () => { diff --git a/packages/ckeditor5-list/tests/list/utils/model.js b/packages/ckeditor5-list/tests/list/utils/model.js index ab2915b2c2f..2efa86cd36d 100644 --- a/packages/ckeditor5-list/tests/list/utils/model.js +++ b/packages/ckeditor5-list/tests/list/utils/model.js @@ -1824,7 +1824,7 @@ describe( 'List - utils - model', () => { } ); describe( 'isFirstListItemInList()', () => { - it( 'should return true for the first list item in the document', () => { + it( 'should return true for the first item in a list and false for the next same-type sibling', () => { const input = modelList( [ '* a', '* b' @@ -1833,16 +1833,6 @@ describe( 'List - utils - model', () => { const fragment = _parseModel( input, schema ); expect( isFirstListItemInList( fragment.getChild( 0 ) ) ).to.be.true; - } ); - - it( 'should return false for a list item preceded by another list item of the same type', () => { - const input = modelList( [ - '* a', - '* b' - ] ); - - const fragment = _parseModel( input, schema ); - expect( isFirstListItemInList( fragment.getChild( 1 ) ) ).to.be.false; } ); @@ -1857,7 +1847,7 @@ describe( 'List - utils - model', () => { expect( isFirstListItemInList( fragment.getChild( 1 ) ) ).to.be.true; } ); - it( 'should return true for a list item preceded by a list item of a different type', () => { + it( 'should return true for a list item preceded by a list item of a different type at the same indent', () => { const input = modelList( [ '* a', '# b' @@ -1868,7 +1858,7 @@ describe( 'List - utils - model', () => { expect( isFirstListItemInList( fragment.getChild( 1 ) ) ).to.be.true; } ); - it( 'should return false for a nested list item preceded by a list item of the same type', () => { + it( 'should return false for a nested list item preceded by a same-indent same-type sibling', () => { const input = modelList( [ '* a', ' * b', @@ -1912,7 +1902,7 @@ describe( 'List - utils - model', () => { expect( isFirstListItemInList( fragment.getChild( 2 ) ) ).to.be.false; } ); - it( 'should return true for a skip-level list item (indent > 0, first in document)', () => { + it( 'should return true for a skip-level list item that is first in the document', () => { const input = modelList( [ ' * a', ' * b' @@ -1923,19 +1913,47 @@ describe( 'List - utils - model', () => { expect( isFirstListItemInList( fragment.getChild( 0 ) ) ).to.be.true; } ); - it( 'should return true for a higher-indent item when the preceding item has a lower indent', () => { + it( 'should return false for a top-level item placed after a same-type skip-level nested list', () => { + // `bbb` is the second visible item in the outer numbered list, so it is not first. const input = modelList( [ - ' * a', - ' * b' + ' # aaa', + '# bbb' ] ); const fragment = _parseModel( input, schema ); expect( isFirstListItemInList( fragment.getChild( 0 ) ) ).to.be.true; - expect( isFirstListItemInList( fragment.getChild( 1 ) ) ).to.be.true; + expect( isFirstListItemInList( fragment.getChild( 1 ) ) ).to.be.false; + } ); + + it( 'should return false for an item placed after a higher-indent block of a different type', () => { + // The higher-indent block is rendered inside a phantom skip-level wrapper at our indent, + // so the visible list already has earlier items regardless of the wrapper's type. + const input = modelList( [ + ' * a', + '# b' + ] ); + + const fragment = _parseModel( input, schema ); + + expect( isFirstListItemInList( fragment.getChild( 1 ) ) ).to.be.false; + } ); + + it( 'should return false along a chain of higher-indent skip-level items', () => { + const input = modelList( [ + ' * a', + ' * b', + '* c' + ] ); + + const fragment = _parseModel( input, schema ); + + expect( isFirstListItemInList( fragment.getChild( 0 ) ) ).to.be.true; + expect( isFirstListItemInList( fragment.getChild( 1 ) ) ).to.be.false; + expect( isFirstListItemInList( fragment.getChild( 2 ) ) ).to.be.false; } ); - it( 'should return true for same-type items at different indent levels (two separate lists)', () => { + it( 'should return false for a top-level item placed after a different-type skip-level nested list and before list item', () => { const input = modelList( [ ' * a', '# b', @@ -1945,11 +1963,11 @@ describe( 'List - utils - model', () => { const fragment = _parseModel( input, schema ); expect( isFirstListItemInList( fragment.getChild( 0 ) ) ).to.be.true; - expect( isFirstListItemInList( fragment.getChild( 1 ) ) ).to.be.true; + expect( isFirstListItemInList( fragment.getChild( 1 ) ) ).to.be.false; expect( isFirstListItemInList( fragment.getChild( 2 ) ) ).to.be.true; } ); - it( 'should return false for same-type item after nested different-type item at higher indent', () => { + it( 'should return true for a top-level item placed after a different-type skip-level nested list and before list item', () => { const input = modelList( [ ' * a', ' # b', From cb78d8a8ca60994e313949f84289de55d77ca9b8 Mon Sep 17 00:00:00 2001 From: Marta Motyczynska Date: Mon, 27 Apr 2026 17:16:57 +0200 Subject: [PATCH 24/33] Update tests. --- .../tests/integrations/indentblocklistcommand.js | 9 --------- .../tests/list/integrations/indentmulticommand.js | 8 -------- 2 files changed, 17 deletions(-) diff --git a/packages/ckeditor5-indent/tests/integrations/indentblocklistcommand.js b/packages/ckeditor5-indent/tests/integrations/indentblocklistcommand.js index 4d188af8e8a..b6ed1c6982c 100644 --- a/packages/ckeditor5-indent/tests/integrations/indentblocklistcommand.js +++ b/packages/ckeditor5-indent/tests/integrations/indentblocklistcommand.js @@ -777,16 +777,7 @@ describe( 'IndentBlockListCommand', () => { expect( command.isEnabled ).to.be.false; } ); - // See ckeditor/ckeditor5-commercial#9763. it( 'should be false for a top-level item placed after a same-type skip-level nested list', () => { - // In the view this model is rendered as: - //
                - //
              1. - //
                1. aaa
                - //
              2. - //
              3. bbb
              4. - //
              - // `bbb` is the second visible item in the outer list, so block indent must not kick in. _setModelData( model, modelList( [ ' # aaa', '# []bbb' diff --git a/packages/ckeditor5-list/tests/list/integrations/indentmulticommand.js b/packages/ckeditor5-list/tests/list/integrations/indentmulticommand.js index a60db66870d..9bcd515eeec 100644 --- a/packages/ckeditor5-list/tests/list/integrations/indentmulticommand.js +++ b/packages/ckeditor5-list/tests/list/integrations/indentmulticommand.js @@ -1586,16 +1586,8 @@ describe( 'Indent MultiCommand integrations', () => { expect( indentListSpy.callCount ).to.equal( 1, 'indentList command call count' ); } ); - // See ckeditor/ckeditor5-commercial#9763. it( 'should execute indentList (not indentBlockList) when at start of a top-level item ' + 'placed after a skip-level nested list', () => { - // In the view this model is rendered as: - //
                - //
              1. - //
                1. A
                - //
              2. - //
              3. B
              4. - //
              // `B` is the second visible item in the outer list, so Tab should indent it as a list item // (joining the nested list under the intermediate wrapper), not apply block indent. _setModelData( model, modelList( [ From 6c34808afbce7eaff73b802aabe7732e011c5fe1 Mon Sep 17 00:00:00 2001 From: Marta Motyczynska Date: Mon, 27 Apr 2026 18:07:06 +0200 Subject: [PATCH 25/33] Review fix. --- .../ckeditor5-list/src/list/utils/model.ts | 58 ++++++++++++------- .../ckeditor5-list/tests/list/utils/model.js | 37 ++++++++++++ 2 files changed, 74 insertions(+), 21 deletions(-) diff --git a/packages/ckeditor5-list/src/list/utils/model.ts b/packages/ckeditor5-list/src/list/utils/model.ts index ccd837a8430..23c9736a4ca 100644 --- a/packages/ckeditor5-list/src/list/utils/model.ts +++ b/packages/ckeditor5-list/src/list/utils/model.ts @@ -602,11 +602,18 @@ export function isNumberedListType( listType: ListType ): boolean { } /** - * Checks if the given list item is the first item in its list at the given indent. + * Checks if the given list item block is the first block of the first item in its list at the given indent. * - * Returns `false` when any preceding list block belongs to the same list — either as a same-indent - * sibling of the same `listType`, or as a higher-indent block (a regular nested child, or an - * intermediate skip-level `
            • ` wrapper). For example, in the model: + * Walks back over previous siblings and returns: + * - `true` if it reaches a non-list block or a list block at a lower indent (a new list begins here), + * - `false` if it finds a same-indent block of the same `listItemId` (a continuation of the current item) or of the same + * `listType` (the visible list already has earlier items), + * - `true` if it finds a same-indent block of a different `listType` and a different `listItemId` (a different list ends; ours + * starts here), + * - `false` if it walks off the start of the document while passing only higher-indent blocks (those blocks + * live inside an intermediate skip-level `
            • ` wrapper at our indent). + * + * For example, in the model: * * ``` * ' # aaa' @@ -630,28 +637,37 @@ export function isNumberedListType( listType: ListType ): boolean { * So `bbb` is the second visible item in the outer list and the function returns `false`. */ export function isFirstListItemInList( listItem: ModelElement ): boolean { - // Same-indent, same-type predecessor. If it exists, we are not the first item in the list. - const sameIndentMatch = ListWalker.first( listItem, { - sameIndent: true, - sameAttributes: 'listType' - } ); + const itemIndent = listItem.getAttribute( 'listIndent' ) as number; + const itemListType = listItem.getAttribute( 'listType' ); + const itemListItemId = listItem.getAttribute( 'listItemId' ); - if ( sameIndentMatch ) { - return false; - } + let previous = listItem.previousSibling; + let sawHigherIndent = false; - // A higher-indent immediate predecessor means we are nested below something at our indent - // (a real parent or an intermediate skip-level wrapper), so we are not first either. - const previousItem = listItem.previousSibling; + while ( isListItemBlock( previous ) ) { + const previousIndent = previous.getAttribute( 'listIndent' ) as number; - if ( - isListItemBlock( previousItem ) && - ( previousItem.getAttribute( 'listIndent' ) as number ) > ( listItem.getAttribute( 'listIndent' ) as number ) - ) { - return false; + if ( previousIndent < itemIndent ) { + return true; + } + + if ( previousIndent === itemIndent ) { + // The previous block belongs to the current item — we are a continuation block, not the first block of + // the list item, so item-block indent should not apply (a normal list indent should fall through instead). + if ( previous.getAttribute( 'listItemId' ) === itemListItemId ) { + return false; + } + + return previous.getAttribute( 'listType' ) !== itemListType; + } + + sawHigherIndent = true; + previous = previous.previousSibling; } - return true; + // Walked off the start of the document. If only higher-indent blocks were on the way, they + // live inside an intermediate skip-level wrapper at our indent — we are not first. + return !sawHigherIndent; } /** diff --git a/packages/ckeditor5-list/tests/list/utils/model.js b/packages/ckeditor5-list/tests/list/utils/model.js index 2efa86cd36d..2d3b311aa8d 100644 --- a/packages/ckeditor5-list/tests/list/utils/model.js +++ b/packages/ckeditor5-list/tests/list/utils/model.js @@ -1980,5 +1980,42 @@ describe( 'List - utils - model', () => { expect( isFirstListItemInList( fragment.getChild( 1 ) ) ).to.be.true; expect( isFirstListItemInList( fragment.getChild( 2 ) ) ).to.be.false; } ); + + it( 'should return true for an item starting a new list after a different-type list with a nested list', () => { + const input = modelList( [ + '# a', + ' * b', + '* c' + ] ); + + const fragment = _parseModel( input, schema ); + + expect( isFirstListItemInList( fragment.getChild( 2 ) ) ).to.be.true; + } ); + + it( 'should return false for a continuation block of a multi-block list item', () => { + const input = modelList( [ + '* a', + ' text' + ] ); + + const fragment = _parseModel( input, schema ); + + expect( isFirstListItemInList( fragment.getChild( 0 ) ) ).to.be.true; + expect( isFirstListItemInList( fragment.getChild( 1 ) ) ).to.be.false; + } ); + + it( 'should return false for an item placed after a multi-block continuation of a preceding item', () => { + const input = modelList( [ + '* a', + '* b', + ' text', + '* c' + ] ); + + const fragment = _parseModel( input, schema ); + + expect( isFirstListItemInList( fragment.getChild( 3 ) ) ).to.be.false; + } ); } ); } ); From 82866bfd2da9f75c387784dda4447979ee336f74 Mon Sep 17 00:00:00 2001 From: Marta Motyczynska Date: Mon, 27 Apr 2026 18:38:10 +0200 Subject: [PATCH 26/33] Add one more case to tests. --- packages/ckeditor5-list/tests/list/utils/model.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/ckeditor5-list/tests/list/utils/model.js b/packages/ckeditor5-list/tests/list/utils/model.js index 2d3b311aa8d..93153cfc827 100644 --- a/packages/ckeditor5-list/tests/list/utils/model.js +++ b/packages/ckeditor5-list/tests/list/utils/model.js @@ -1993,6 +1993,18 @@ describe( 'List - utils - model', () => { expect( isFirstListItemInList( fragment.getChild( 2 ) ) ).to.be.true; } ); + it( 'should return true for an item starting a new list after a different-type list with a skip-level nested list', () => { + const input = modelList( [ + '# a', + ' * b', + '* c' + ] ); + + const fragment = _parseModel( input, schema ); + + expect( isFirstListItemInList( fragment.getChild( 2 ) ) ).to.be.true; + } ); + it( 'should return false for a continuation block of a multi-block list item', () => { const input = modelList( [ '* a', From 31118e834881d0359090661ff7c4994c4ebcac9e Mon Sep 17 00:00:00 2001 From: Marta Motyczynska Date: Mon, 27 Apr 2026 22:02:47 +0200 Subject: [PATCH 27/33] Backsace in list item with no same or lower indent preceeding block should merge to previous list item. --- .../src/list/listmergecommand.ts | 13 +++ .../tests/list/integrations/delete.js | 79 +++++++++++++++++++ .../tests/list/listmergecommand.js | 60 ++++++++++++++ 3 files changed, 152 insertions(+) diff --git a/packages/ckeditor5-list/src/list/listmergecommand.ts b/packages/ckeditor5-list/src/list/listmergecommand.ts index 86339e1da3e..4784277ef06 100644 --- a/packages/ckeditor5-list/src/list/listmergecommand.ts +++ b/packages/ckeditor5-list/src/list/listmergecommand.ts @@ -75,6 +75,13 @@ export class ListMergeCommand extends Command { model.change( writer => { const { firstElement, lastElement } = this._getMergeSubjectElements( selection, shouldMergeOnBlocksContentLevel ); + // A defensive guard. When `_getMergeSubjectElements()` cannot determine a valid pair of elements + // to merge (for example because there is no list block before/after the current one), + // the command should be a no-op instead of throwing on `null.getAttribute()`. + if ( !firstElement || !lastElement ) { + return; + } + const firstIndent = firstElement.getAttribute( 'listIndent' ) || 0; const lastIndent = lastElement.getAttribute( 'listIndent' ); const lastElementId = lastElement.getAttribute( 'listItemId' ); @@ -213,6 +220,12 @@ export class ListMergeCommand extends Command { // * b // c firstElement = ListWalker.first( positionParent, { sameIndent: true, lowerIndent: true } ); + + // Fallback for "skip-level" structures where the previous list block in the model + // happens to live at a *higher* indent (no same-or-lower-indent block precedes it). + if ( !firstElement && isListItemBlock( positionParent.previousSibling ) ) { + firstElement = positionParent.previousSibling; + } } else { firstElement = positionParent.previousSibling; } diff --git a/packages/ckeditor5-list/tests/list/integrations/delete.js b/packages/ckeditor5-list/tests/list/integrations/delete.js index 9705d1c8667..e83db9d015c 100644 --- a/packages/ckeditor5-list/tests/list/integrations/delete.js +++ b/packages/ckeditor5-list/tests/list/integrations/delete.js @@ -6644,6 +6644,85 @@ describe( 'ListEditing integrations: backspace & delete', () => { } ); } ); + describe( 'backspace (backward) - skip-level lists', () => { + let skipElement, skipEditor, skipModel, skipView; + let skipEventInfo, skipDomEventData; + + beforeEach( async () => { + skipElement = document.createElement( 'div' ); + document.body.appendChild( skipElement ); + + skipEditor = await ClassicTestEditor.create( skipElement, { + plugins: [ ListEditing, Paragraph, Delete ], + list: { + allowSkipLevels: true + } + } ); + + skipModel = skipEditor.model; + skipView = skipEditor.editing.view; + + skipEventInfo = new BubblingEventInfo( skipView.document, 'delete' ); + skipDomEventData = new ViewDocumentDomEventData( skipView, { + preventDefault: sinon.spy() + }, { + direction: 'backward', + unit: 'codePoint', + sequence: 1 + } ); + } ); + + afterEach( async () => { + skipElement.remove(); + + await skipEditor.destroy(); + } ); + + it( 'should not throw and should merge the item into the previous one when the previous list block has a higher indent', () => { + _setModelData( skipModel, modelList( [ + ' # aaa', + ' # []bbb' + ] ) ); + + expect( () => skipView.document.fire( skipEventInfo, skipDomEventData ) ).to.not.throw(); + + expect( _getModelData( skipModel ) ).to.equalMarkup( modelList( [ + ' # aaa', + ' []bbb' + ] ) ); + } ); + + it( 'should merge the item into the previous one when the previous list block has an even higher indent', () => { + _setModelData( skipModel, modelList( [ + ' # aaa', + ' # []bbb' + ] ) ); + + expect( () => skipView.document.fire( skipEventInfo, skipDomEventData ) ).to.not.throw(); + + expect( _getModelData( skipModel ) ).to.equalMarkup( modelList( [ + ' # aaa', + ' []bbb' + ] ) ); + } ); + + it( 'should keep nested children of the merged list item and re-indent them', () => { + _setModelData( skipModel, modelList( [ + ' # aaa', + ' # []bbb', + ' # ccc' + ] ) ); + + expect( () => skipView.document.fire( skipEventInfo, skipDomEventData ) ).to.not.throw(); + + expect( _getModelData( skipModel ) ).to.equalMarkup( modelList( [ + ' # aaa', + ' []bbb', + ' # ccc' + ] ) ); + } ); + } ); + // @param {Iterable.} input // @param {Iterable.} expected // @param {Boolean|Object.} eventStopped Boolean when preventDefault() and stop() were called/not called together. diff --git a/packages/ckeditor5-list/tests/list/listmergecommand.js b/packages/ckeditor5-list/tests/list/listmergecommand.js index 7293103039b..61a1c35730c 100644 --- a/packages/ckeditor5-list/tests/list/listmergecommand.js +++ b/packages/ckeditor5-list/tests/list/listmergecommand.js @@ -451,6 +451,66 @@ describe( 'ListMergeCommand', () => { } ); } ); } ); + + describe( 'previous list block has a higher indent (skip-level lists)', () => { + it( 'should merge with previous list item that has a higher indent', () => { + runTest( { + input: [ + ' # aaa', + ' # []bbb' + ], + expected: [ + ' # aaa', + ' []bbb' + ], + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge with previous list item that has an even higher indent (multiple-level gap)', () => { + runTest( { + input: [ + ' # aaa', + ' # []bbb' + ], + expected: [ + ' # aaa', + ' []bbb' + ], + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should keep nested children of the merged list item and re-indent them', () => { + runTest( { + input: [ + ' # aaa', + ' # []bbb', + ' # ccc' + ], + expected: [ + ' # aaa', + ' []bbb', + ' # ccc' + ], + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should not throw when previous sibling is not a list item (defensive guard)', () => { + _setModelData( model, modelList( [ + 'foo', + ' # []bbb' + ] ) ); + + expect( () => command.execute() ).to.not.throw(); + + expect( _getModelData( model ) ).to.equalMarkup( modelList( [ + 'foo', + ' # []bbb' + ] ) ); + } ); + } ); } ); describe( 'collapsed selection at the end of a list item', () => { From 33d46aa59bb58948091997dc0ecd61c814624f7b Mon Sep 17 00:00:00 2001 From: Marta Motyczynska Date: Tue, 28 Apr 2026 18:04:11 +0200 Subject: [PATCH 28/33] Refactor after code review. --- .../ckeditor5-list/src/list/converters.ts | 99 ++++++++++--------- 1 file changed, 55 insertions(+), 44 deletions(-) diff --git a/packages/ckeditor5-list/src/list/converters.ts b/packages/ckeditor5-list/src/list/converters.ts index ea564803b39..22e35526a61 100644 --- a/packages/ckeditor5-list/src/list/converters.ts +++ b/packages/ckeditor5-list/src/list/converters.ts @@ -253,53 +253,11 @@ export function reconvertItemsOnDataChange( // Update the stack for the current indent level. stack[ itemIndent ] = { - modelAttributes: Object.fromEntries( - Array.from( node.getAttributes() ) - .filter( ( [ key ] ) => attributeNames.includes( key ) ) - ), + modelAttributes: getListModelAttributes( node ), modelElement: node }; - // Skip-level lists have view wrappers (
                /
                  ) at indent levels with no matching - // model item. Each such wrapper inherits its attributes (listStart, listStyle, etc.) - // from a nearby model item - the first sibling found at that indent, or the closest - // lower-indent ancestor (mirroring the downcast's fallback). Remember that item here - // so later we can compare the wrapper against the current model and tell whether it's - // still up to date. - for ( let i = itemIndent - 1; i >= 0; i-- ) { - if ( stack[ i ] ) { - break; - } - - const siblingAtIndent = findSiblingListItemAt( node, i ); - - let ancestorAtLowerIndent: ListElement | null = null; - - if ( !siblingAtIndent ) { - for ( let k = i - 1; k >= 0; k-- ) { - if ( stack[ k ] ) { - ancestorAtLowerIndent = stack[ k ].modelElement; - break; - } - } - } - - const referenceItem: ListElement = siblingAtIndent || ancestorAtLowerIndent || node; - - const modelAttributes: ListItemAttributesMap = { - ...Object.fromEntries( - Array.from( referenceItem.getAttributes() ) - .filter( ( [ key ] ) => attributeNames.includes( key ) ) - ), - listItemId: `list-item-skip-${ i }`, - listIndent: i - }; - - stack[ i ] = { - modelAttributes, - modelElement: referenceItem - }; - } + fillStackForIntermediates( node, itemIndent, stack ); // Find all blocks of the current node. const blocks = getListItemBlocks( node, { direction: 'forward' } ); @@ -321,6 +279,59 @@ export function reconvertItemsOnDataChange( return itemsToRefresh; } + // Returns model list attributes (those tracked by `attributeNames`) of the given item as a plain object. + function getListModelAttributes( item: ListElement ): ListItemAttributesMap { + return Object.fromEntries( + Array.from( item.getAttributes() ) + .filter( ( [ key ] ) => attributeNames.includes( key ) ) + ); + } + + // Skip-level lists have view wrappers (
                    /
                      ) at indent levels with no matching + // model item. Each such wrapper inherits its attributes (listStart, listStyle, etc.) + // from a nearby model item - the first sibling found at that indent, or the closest + // lower-indent ancestor (mirroring the downcast's fallback). Remember that item here + // so later we can compare the wrapper against the current model and tell whether it's + // still up to date. + function fillStackForIntermediates( + node: ListElement, + itemIndent: number, + stack: Array<{ + modelAttributes: ListItemAttributesMap; + modelElement: ListElement; + }> + ) { + for ( let i = itemIndent - 1; i >= 0; i-- ) { + if ( stack[ i ] ) { + break; + } + + const siblingAtIndent = findSiblingListItemAt( node, i ); + + let ancestorAtLowerIndent: ListElement | null = null; + + if ( !siblingAtIndent ) { + for ( let k = i - 1; k >= 0; k-- ) { + if ( stack[ k ] ) { + ancestorAtLowerIndent = stack[ k ].modelElement; + break; + } + } + } + + const referenceItem: ListElement = siblingAtIndent || ancestorAtLowerIndent || node; + + stack[ i ] = { + modelAttributes: { + ...getListModelAttributes( referenceItem ), + listItemId: `list-item-skip-${ i }`, + listIndent: i + }, + modelElement: referenceItem + }; + } + } + function doesItemBlockRequiresRefresh( item: ModelElement, blocks?: Array ) { const viewElement = editing.mapper.toViewElement( item ); From 8d66f35cab75d4866a9f1123b33282c531aa2ccc Mon Sep 17 00:00:00 2001 From: Marta Motyczynska Date: Tue, 28 Apr 2026 18:14:55 +0200 Subject: [PATCH 29/33] Update jsdocs. --- packages/ckeditor5-list/src/list/utils/model.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/ckeditor5-list/src/list/utils/model.ts b/packages/ckeditor5-list/src/list/utils/model.ts index 23c9736a4ca..78ab2ac495b 100644 --- a/packages/ckeditor5-list/src/list/utils/model.ts +++ b/packages/ckeditor5-list/src/list/utils/model.ts @@ -610,8 +610,9 @@ export function isNumberedListType( listType: ListType ): boolean { * `listType` (the visible list already has earlier items), * - `true` if it finds a same-indent block of a different `listType` and a different `listItemId` (a different list ends; ours * starts here), - * - `false` if it walks off the start of the document while passing only higher-indent blocks (those blocks - * live inside an intermediate skip-level `
                    • ` wrapper at our indent). + * - `false` if the loop ends (it reaches the first non-list-item block, or no more previous siblings) while passing only + * higher-indent blocks (those blocks live inside an intermediate skip-level `
                    • ` wrapper + * at our indent). * * For example, in the model: * @@ -665,8 +666,8 @@ export function isFirstListItemInList( listItem: ModelElement ): boolean { previous = previous.previousSibling; } - // Walked off the start of the document. If only higher-indent blocks were on the way, they - // live inside an intermediate skip-level wrapper at our indent — we are not first. + // Reached the first non-list-item block (or no more previous siblings). If only higher-indent blocks were on + // the way, they live inside an intermediate skip-level wrapper at our indent — we are not first. return !sawHigherIndent; } From 1faa8c2fdc8e9c6e4ae28868ab8776e5d2987d8c Mon Sep 17 00:00:00 2001 From: Marta Motyczynska Date: Tue, 12 May 2026 22:15:59 +0200 Subject: [PATCH 30/33] Improve detecting skip-level lists. --- .../ckeditor5-list/src/list/converters.ts | 33 ++++ .../tests/list/converters-data.js | 185 ++++++++++++++++++ 2 files changed, 218 insertions(+) diff --git a/packages/ckeditor5-list/src/list/converters.ts b/packages/ckeditor5-list/src/list/converters.ts index 22e35526a61..ded11032b2c 100644 --- a/packages/ckeditor5-list/src/list/converters.ts +++ b/packages/ckeditor5-list/src/list/converters.ts @@ -67,6 +67,11 @@ import type { * counts all ancestor `
                    • ` elements (including the consumed wrapper), nested items receive the correct indent * values that reflect the skip-level gap. * + * Only `
                    • ` elements whose sole meaningful content is a nested `
                        `/`
                          ` are treated as intermediate wrappers. + * Anything else (text, paragraphs, custom elements, even an empty `
                        1. ` with `list-style-type:none` carrying only + * attributes) falls through to the regular list item upcast, so its data and attributes can be preserved by GHS + * or other plugins. + * * @internal */ export function listItemSkipLevelConsumer(): GetCallback { @@ -77,6 +82,10 @@ export function listItemSkipLevelConsumer(): GetCallback { return; } + if ( !isSkipLevelWrapper( viewItem ) ) { + return; + } + if ( !conversionApi.consumable.consume( viewItem, { name: true } ) ) { return; } @@ -88,6 +97,30 @@ export function listItemSkipLevelConsumer(): GetCallback { }; } +/** + * Checks whether a `
                        2. ` view element is a skip-level intermediate wrapper, i.e. its only meaningful content + * is a nested `
                            `/`
                              `. Whitespace-only text nodes are ignored. Any other content (real text, `
                              `, + * `

                              `, custom elements, etc.) disqualifies the element, so it is upcast as a regular list item. + */ +function isSkipLevelWrapper( viewItem: ViewElement ): boolean { + let hasNestedList = false; + + for ( const child of viewItem.getChildren() ) { + if ( child.is( 'element', 'ul' ) || child.is( 'element', 'ol' ) ) { + hasNestedList = true; + continue; + } + + if ( child.is( '$text' ) && !child.data.trim() ) { + continue; + } + + return false; + } + + return hasNestedList; +} + /** * Returns the upcast converter for list items. It's supposed to work after the block converters (content inside list items) are converted. * diff --git a/packages/ckeditor5-list/tests/list/converters-data.js b/packages/ckeditor5-list/tests/list/converters-data.js index ae81287418f..cf350b7ea9d 100644 --- a/packages/ckeditor5-list/tests/list/converters-data.js +++ b/packages/ckeditor5-list/tests/list/converters-data.js @@ -3021,6 +3021,80 @@ describe( 'ListEditing - converters - data pipeline', () => { ); } ); + it( 'should upcast an empty

                            1. as a regular list item', () => { + skipEditor.setData( + '
                                ' + + '
                              1. ' + + '
                              ' + ); + + expect( _getModelData( skipModel, { withoutSelection: true } ) ).to.equalMarkup( + '' + ); + } ); + + it( 'should upcast
                            2. with text-only content as a regular list item', () => { + skipEditor.setData( + '
                                ' + + '
                              1. foobar
                              2. ' + + '
                              ' + ); + + expect( _getModelData( skipModel, { withoutSelection: true } ) ).to.equalMarkup( + 'foobar' + ); + } ); + + it( 'should upcast
                            3. with a paragraph child (no nested list) as a regular list item', () => { + skipEditor.setData( + '
                                ' + + '
                              1. foobar

                              2. ' + + '
                              ' + ); + + expect( _getModelData( skipModel, { withoutSelection: true } ) ).to.equalMarkup( + 'foobar' + ); + } ); + + it( 'should still treat
                            4. with only whitespace around a nested list as a wrapper', () => { + skipEditor.setData( + '
                                ' + + '
                              • A' + + '
                                  ' + + '
                                • \n ' + + '
                                    ' + + '
                                  • B
                                  • ' + + '
                                  ' + + '
                                • ' + + '
                                ' + + '
                              • ' + + '
                              ' + ); + + expect( _getModelData( skipModel, { withoutSelection: true } ) ).to.equalMarkup( + 'A' + + 'B' + ); + } ); + + it( 'should upcast
                            5. with text mixed with a nested list as a regular list item', () => { + skipEditor.setData( + '
                                ' + + '
                              • leading text' + + '
                                  ' + + '
                                • B
                                • ' + + '
                                ' + + '
                              • ' + + '
                              ' + ); + + expect( _getModelData( skipModel, { withoutSelection: true } ) ).to.equalMarkup( + 'leading text' + + 'B' + ); + } ); + it( 'should roundtrip a single skipped level (model -> data -> model)', () => { _setModelData( skipModel, 'A' + @@ -3182,6 +3256,117 @@ describe( 'ListEditing - converters - data pipeline', () => { 'listIndent="1" listItemId="a00" listType="numbered">foobar' ); } ); + + it( 'should preserve attributes/classes on an empty
                            6. ', () => { + ghsEditor.setData( + '
                                ' + + '
                              1. ' + + '
                              ' + ); + + expect( _getModelData( ghsModel, { withoutSelection: true } ) ).to.equalMarkup( + '' + + '' + ); + } ); + + it( 'should preserve attributes on
                            7. with text-only content', () => { + ghsEditor.setData( + '
                                ' + + '
                              1. foobar
                              2. ' + + '
                              ' + ); + + expect( _getModelData( ghsModel, { withoutSelection: true } ) ).to.equalMarkup( + '' + + 'foobar' + + '' + ); + } ); + } ); + + describe( 'upcast with GeneralHtmlSupport without allowSkipLevels', () => { + let ghsEditor, ghsModel, ghsElement; + + beforeEach( async () => { + ghsElement = document.createElement( 'div' ); + document.body.appendChild( ghsElement ); + + ghsEditor = await ClassicTestEditor.create( ghsElement, { + plugins: [ Paragraph, IndentEditing, ClipboardPipeline, BoldEditing, ListEditing, UndoEditing, + BlockQuoteEditing, TableEditing, HeadingEditing, CodeBlockEditing, GeneralHtmlSupport ], + htmlSupport: { + allow: [ + { + name: /./, + styles: true, + attributes: true, + classes: true + } + ] + } + } ); + + ghsModel = ghsEditor.model; + } ); + + afterEach( async () => { + ghsElement.remove(); + await ghsEditor.destroy(); + } ); + + it( 'should preserve attributes/classes on an empty
                            8. ', () => { + ghsEditor.setData( + '
                                ' + + '
                              1. ' + + '
                              ' + ); + + expect( _getModelData( ghsModel, { withoutSelection: true } ) ).to.equalMarkup( + '' + + '' + ); + } ); + + it( 'should preserve attributes on
                            9. with text-only content', () => { + ghsEditor.setData( + '
                                ' + + '
                              1. foobar
                              2. ' + + '
                              ' + ); + + expect( _getModelData( ghsModel, { withoutSelection: true } ) ).to.equalMarkup( + '' + + 'foobar' + + '' + ); + } ); } ); } ); } ); From 25f076a30a41fdff3bf7ae789147bec807fa6904 Mon Sep 17 00:00:00 2001 From: Marta Motyczynska Date: Tue, 12 May 2026 22:25:15 +0200 Subject: [PATCH 31/33] Fix failing tests and coverage. --- .../ckeditor5-list/src/list/converters.ts | 10 ++----- .../tests/list/converters-data.js | 29 +++---------------- 2 files changed, 7 insertions(+), 32 deletions(-) diff --git a/packages/ckeditor5-list/src/list/converters.ts b/packages/ckeditor5-list/src/list/converters.ts index ded11032b2c..6a9e05a3dfa 100644 --- a/packages/ckeditor5-list/src/list/converters.ts +++ b/packages/ckeditor5-list/src/list/converters.ts @@ -98,9 +98,9 @@ export function listItemSkipLevelConsumer(): GetCallback { } /** - * Checks whether a `
                            10. ` view element is a skip-level intermediate wrapper, i.e. its only meaningful content - * is a nested `
                                `/`
                                  `. Whitespace-only text nodes are ignored. Any other content (real text, `
                                  `, - * `

                                  `, custom elements, etc.) disqualifies the element, so it is upcast as a regular list item. + * Checks whether a `

                                1. ` view element is a skip-level intermediate wrapper, i.e. its only child is a nested + * `
                                    `/`
                                      `. Any other content (text, `
                                      `, `

                                      `, custom elements, NBSP, etc.) disqualifies the element, + * so it is upcast as a regular list item. */ function isSkipLevelWrapper( viewItem: ViewElement ): boolean { let hasNestedList = false; @@ -111,10 +111,6 @@ function isSkipLevelWrapper( viewItem: ViewElement ): boolean { continue; } - if ( child.is( '$text' ) && !child.data.trim() ) { - continue; - } - return false; } diff --git a/packages/ckeditor5-list/tests/list/converters-data.js b/packages/ckeditor5-list/tests/list/converters-data.js index cf350b7ea9d..eb35eeba129 100644 --- a/packages/ckeditor5-list/tests/list/converters-data.js +++ b/packages/ckeditor5-list/tests/list/converters-data.js @@ -3057,27 +3057,6 @@ describe( 'ListEditing - converters - data pipeline', () => { ); } ); - it( 'should still treat

                                    1. with only whitespace around a nested list as a wrapper', () => { - skipEditor.setData( - '
                                        ' + - '
                                      • A' + - '
                                          ' + - '
                                        • \n ' + - '
                                            ' + - '
                                          • B
                                          • ' + - '
                                          ' + - '
                                        • ' + - '
                                        ' + - '
                                      • ' + - '
                                      ' - ); - - expect( _getModelData( skipModel, { withoutSelection: true } ) ).to.equalMarkup( - 'A' + - 'B' - ); - } ); - it( 'should upcast
                                    2. with text mixed with a nested list as a regular list item', () => { skipEditor.setData( '
                                        ' + @@ -3268,8 +3247,8 @@ describe( 'ListEditing - converters - data pipeline', () => { '' + @@ -3339,8 +3318,8 @@ describe( 'ListEditing - converters - data pipeline', () => { '' + From 249ca5c142941559dbc9951b8603905b4b66374c Mon Sep 17 00:00:00 2001 From: Mateusz Baginski Date: Wed, 13 May 2026 13:32:06 +0200 Subject: [PATCH 32/33] Add skip level list changelogs. --- .changelog/20260513132102_ck_9847.md | 8 ++++++++ .changelog/20260513132321_ck_9847.md | 7 +++++++ 2 files changed, 15 insertions(+) create mode 100644 .changelog/20260513132102_ck_9847.md create mode 100644 .changelog/20260513132321_ck_9847.md diff --git a/.changelog/20260513132102_ck_9847.md b/.changelog/20260513132102_ck_9847.md new file mode 100644 index 00000000000..92ea1f6ac19 --- /dev/null +++ b/.changelog/20260513132102_ck_9847.md @@ -0,0 +1,8 @@ +--- +type: Feature +scope: + - ckeditor5-list + - ckeditor5-autoformat +--- + +Numbered list autoformat now accepts any starting number. Typing any number followed by `.` or `)` and a space (e.g. `5. `) creates a numbered list. When the `list.properties.startIndex` option is enabled, the list starts at the typed number. diff --git a/.changelog/20260513132321_ck_9847.md b/.changelog/20260513132321_ck_9847.md new file mode 100644 index 00000000000..e355c6ae2ea --- /dev/null +++ b/.changelog/20260513132321_ck_9847.md @@ -0,0 +1,7 @@ +--- +type: Feature +scope: + - ckeditor5-list +--- + +Added support for skip-level lists. List items can now be indented by more than one level at a time by enabling the `list.allowSkipLevels` configuration option. From 19b27a53997cff397fa1e43c6efbeff4b4a7e268 Mon Sep 17 00:00:00 2001 From: Marta Motyczynska Date: Wed, 13 May 2026 14:38:09 +0200 Subject: [PATCH 33/33] Update metadata for skip-level lists. --- packages/ckeditor5-list/ckeditor5-metadata.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/ckeditor5-list/ckeditor5-metadata.json b/packages/ckeditor5-list/ckeditor5-metadata.json index 9d1a77cf864..6a9a5b530c0 100644 --- a/packages/ckeditor5-list/ckeditor5-metadata.json +++ b/packages/ckeditor5-list/ckeditor5-metadata.json @@ -120,6 +120,12 @@ }, { "elements": "li" + }, + { + "elements": "li", + "styles": "list-style-type", + "isAlternative": true, + "_comment": "If `config.list.allowSkipLevels` is set to `true`, intermediate `
                                      • ` wrappers with `list-style-type: none` are emitted to bridge missing indent levels." } ] },