Skip to content
Closed
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
7be1f5d
Allow skip level lists in the model (commands and postfixers).
mmotyczynska Apr 12, 2026
15162bf
It should be possible to outdent first list item (turn into paragraph…
mmotyczynska Apr 13, 2026
8df7919
Correct how top-level first list item in the list is found.
mmotyczynska Apr 13, 2026
b38a39e
Downcast skip-level lists to valid HTML
mmotyczynska Apr 14, 2026
6290717
Align argument handling with previous implementation.
mmotyczynska Apr 14, 2026
c6fa743
Merge branch 'cc/9702-downcast-skip-level-lists' into cc/9703-upcast-…
mmotyczynska Apr 14, 2026
26f7091
Upcast skip level lists.
mmotyczynska Apr 14, 2026
cf79dac
Fix intermediate list wrappers missing classes in skip-level lists (f…
mmotyczynska Apr 15, 2026
9c50fc1
Improve manual test for skip level lists to be able to dynamically ad…
mmotyczynska Apr 15, 2026
177dd25
Update dev dependencies.
mmotyczynska Apr 15, 2026
d70e231
Update manual test so it has initial data after reload.
mmotyczynska Apr 15, 2026
47aeec2
Fix intermediate wrapper merging in skip-level lists with mixed types.
mmotyczynska Apr 15, 2026
61c0e58
Decouple indent block list and list packages (skip-level lists context).
mmotyczynska Apr 18, 2026
3d1beea
Update tests.
mmotyczynska Apr 18, 2026
320a97e
Merge branch 'cc/9701-allow-skip-level-in-model' into cc/9702-downcas…
mmotyczynska Apr 18, 2026
d5ab2eb
Fixes after code review.
mmotyczynska Apr 19, 2026
1beaadb
Merge branch 'cc/9702-downcast-skip-level-lists' into cc/9703-upcast-…
mmotyczynska Apr 19, 2026
250bac4
Code review fixes.
mmotyczynska Apr 19, 2026
65158be
Merge branch 'cc/9703-upcast-skip-level-lists' into cc/9704-skip-leve…
mmotyczynska Apr 19, 2026
47297a1
Review changes.
mmotyczynska Apr 20, 2026
aaec3c3
Review fixes.
mmotyczynska Apr 20, 2026
0b2c9ca
Merge pull request #20061 from ckeditor/cc/9701-allow-skip-level-in-m…
mmotyczynska Apr 20, 2026
ecc197d
Merge pull request #20071 from ckeditor/cc/9702-downcast-skip-level-l…
mmotyczynska Apr 20, 2026
b91d7b6
Add test for skip level list and GHS and content inside intermediate …
mmotyczynska Apr 20, 2026
95f9d2a
Merge remote-tracking branch 'origin/master' into cc/epic/9587-skip-l…
mmotyczynska Apr 20, 2026
b420c13
Merge branch 'cc/epic/9587-skip-level-lists' into cc/9703-upcast-skip…
mmotyczynska Apr 20, 2026
bc9fc2b
Merge branch 'cc/9703-upcast-skip-level-lists' into cc/9704-skip-leve…
mmotyczynska Apr 20, 2026
6ddb096
Merge pull request #20072 from ckeditor/cc/9703-upcast-skip-level-lists
mmotyczynska Apr 21, 2026
6dd8131
Merge pull request #20079 from ckeditor/cc/9704-skip-levels-in-multi-…
mmotyczynska Apr 21, 2026
b0adee9
Extend autoformat for lists to accept any number.
mmotyczynska Apr 21, 2026
6307248
Merge pull request #20090 from ckeditor/cc/9705-autoformat-for-number…
mmotyczynska Apr 22, 2026
4c5afd0
Improve detecting which items should be reconverted when using skip-l…
mmotyczynska Apr 23, 2026
b8a039c
Merge remote-tracking branch 'origin/cc/epic/9587-skip-level-lists' i…
mmotyczynska Apr 26, 2026
0834c16
Code review fix.
mmotyczynska Apr 26, 2026
257bc3c
Improve collecting list items to be reconverted.
mmotyczynska Apr 26, 2026
9e78878
Don't block indent list item at indent 0 after a skip-level.
mmotyczynska Apr 27, 2026
cb78d8a
Update tests.
mmotyczynska Apr 27, 2026
6c34808
Review fix.
mmotyczynska Apr 27, 2026
82866bf
Add one more case to tests.
mmotyczynska Apr 27, 2026
31118e8
Backsace in list item with no same or lower indent preceeding block s…
mmotyczynska Apr 27, 2026
33d46aa
Refactor after code review.
mmotyczynska Apr 28, 2026
8d66f35
Update jsdocs.
mmotyczynska Apr 28, 2026
c6b67cd
Merge pull request #20104 from ckeditor/cc/9765-changing-list-start-a…
mmotyczynska Apr 28, 2026
3ec26cd
Merge pull request #20111 from ckeditor/cc/9763-tab-at-list-item-afte…
mmotyczynska Apr 28, 2026
5160363
Merge pull request #20112 from ckeditor/cc/9762-backspace-in-list-ite…
mmotyczynska Apr 28, 2026
1faa8c2
Improve detecting skip-level lists.
mmotyczynska May 12, 2026
25f076a
Fix failing tests and coverage.
mmotyczynska May 12, 2026
89b5fd7
Merge branch 'master' into cc/epic/9587-skip-level-lists
Mati365 May 13, 2026
249ca5c
Add skip level list changelogs.
Mati365 May 13, 2026
19b27a5
Update metadata for skip-level lists.
mmotyczynska May 13, 2026
5316c0d
Merge pull request #20144 from ckeditor/cc/9847-metadata-for-skip-lev…
mmotyczynska May 13, 2026
0630c42
Merge pull request #20143 from ckeditor/ck/9847
Mati365 May 13, 2026
577bf41
Merge pull request #20139 from ckeditor/cc/9778-review-detecting-skip…
mmotyczynska May 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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' )! );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,65 @@ 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( '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.false;
} );

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.false;
} );

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', () => {
Expand Down Expand Up @@ -649,6 +708,65 @@ 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( '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.false;
} );

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.false;
} );

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', () => {
Expand Down
124 changes: 89 additions & 35 deletions packages/ckeditor5-list/src/list/converters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,15 +327,21 @@ export function reconvertItemsOnDataChange(
continue;
}

const eventName = `checkAttributes:${ isListItemElement ? 'item' : 'list' }` as const;
const needsRefresh = listEditing.fire<ListEditingCheckAttributesEvent>( 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<ListEditingCheckAttributesEvent>( eventName, {
viewElement: element as ViewElement,
modelAttributes: stack[ indent ].modelAttributes,
modelReferenceElement: stack[ indent ].modelElement
} );

if ( needsRefresh ) {
break;
}
}

if ( isListElement ) {
Expand Down Expand Up @@ -364,7 +370,7 @@ export function listItemDowncastConverter(
attributeNames: Array<string>,
strategies: Array<ListDowncastStrategy>,
model: Model,
{ dataPipeline }: { dataPipeline?: boolean } = {}
{ dataPipeline, allowSkipLevels }: { dataPipeline?: boolean; allowSkipLevels?: boolean }
Comment thread
mmotyczynska marked this conversation as resolved.
): GetCallback<DowncastAttributeEvent<ListElement>> {
const consumer = createAttributesConsumer( attributeNames, strategies );

Expand All @@ -384,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.
Expand Down Expand Up @@ -675,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,
Expand All @@ -688,40 +699,83 @@ 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 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 <li>,
// and no strategies are applied (no data-list-item-id, listStyle, etc.) since there is no
// model element backing this level.
//
// The <li> uses a fixed ID per indent (`list-item-skip-N`) so that sibling items sharing
// the same skipped level merge into one <li> (e.g. two items at indent 1 with no parent
// at indent 0 share one intermediate <li> at indent 0).
//
// The <ul/ol> 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 <ul> 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;
}
}
}
}
Expand Down
26 changes: 19 additions & 7 deletions packages/ckeditor5-list/src/list/listediting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,9 +481,16 @@ export class ListEditing extends Plugin {
converterPriority: 'high'
} )
.add( dispatcher => {
const allowSkipLevels = !!editor.config.get( 'list.allowSkipLevels' );

dispatcher.on<DowncastAttributeEvent<ListElement>>(
'attribute',
listItemDowncastConverter( attributeNames, this._downcastStrategies, model )
listItemDowncastConverter(
attributeNames,
this._downcastStrategies,
model,
{ allowSkipLevels }
)
);

dispatcher.on<DowncastRemoveEvent>( 'remove', listItemDowncastRemoveConverter( model.schema ) );
Expand All @@ -496,9 +503,13 @@ export class ListEditing extends Plugin {
converterPriority: 'high'
} )
.add( dispatcher => {
const allowSkipLevels = !!editor.config.get( 'list.allowSkipLevels' );

dispatcher.on<DowncastAttributeEvent<ListElement>>(
'attribute',
listItemDowncastConverter( attributeNames, this._downcastStrategies, model, { dataPipeline: true } )
listItemDowncastConverter( attributeNames, this._downcastStrategies, model, {
dataPipeline: true, allowSkipLevels
} )
);
} );

Expand Down Expand Up @@ -540,16 +551,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<ListEditingPostFixerEvent>( '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<ListEditingPostFixerEvent>( '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<ListEditingPostFixerEvent>( 'postFixer', ( evt, { listNodes, writer, seenIds } ) => {
Expand Down
5 changes: 5 additions & 0 deletions packages/ckeditor5-list/src/list/listindentcommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,11 @@ export class ListIndentCommand extends Command {
return true;
}

// When skip levels are allowed, any list item can always be indented further.
if ( this.editor.config.get( 'list.allowSkipLevels' ) ) {
return true;
}

blocks = expandListBlocksToCompleteItems( blocks );
firstBlock = blocks[ 0 ];

Expand Down
6 changes: 5 additions & 1 deletion packages/ckeditor5-list/src/list/utils/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading