Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
8 changes: 8 additions & 0 deletions .changelog/20260513132102_ck_9847.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions .changelog/20260513132321_ck_9847.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 17 additions & 2 deletions packages/ckeditor5-autoformat/src/autoformat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
* - `<number>. ` or `<number>) ` &ndash; 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 `[ ] ` &ndash; A paragraph will be changed into a to-do list.
* - `[x] ` or `[ x ] ` &ndash; A paragraph will be changed into a checked to-do list.
*/
Expand All @@ -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
);
} );
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Undo-on-backspace triggered when autoformat callback declines

Low Severity

When the numbered-list autoformat callback returns false (because the selection is already inside a numbered list), blockAutoformatEditing still calls requestUndoOnBackspace() unconditionally. Previously, the command-string approach exited before enqueueChange when command.value === true, avoiding this side effect. Now, typing something like "5. " inside an existing numbered list item correctly preserves the text, but the next backspace press may trigger an undo of the entire recent typing batch instead of just deleting the space character.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5160363. Configure here.

}

if ( commands.get( 'todoList' ) ) {
Expand Down
254 changes: 249 additions & 5 deletions packages/ckeditor5-autoformat/tests/autoformat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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, '<paragraph>3.[]</paragraph>' );
insertSpace();

expect( _getModelData( model ) ).to.equal( '<paragraph>3. []</paragraph>' );
expect( _getModelData( model ) ).to.equal(
'<paragraph listIndent="0" listItemId="a00" listType="numbered">[]</paragraph>'
);
} );

it( 'should replace multi-digit number with numbered list item', () => {
_setModelData( model, '<paragraph>12.[]</paragraph>' );
insertSpace();

expect( _getModelData( model ) ).to.equal(
'<paragraph listIndent="0" listItemId="a00" listType="numbered">[]</paragraph>'
);
} );

it( 'should replace digit with numbered list item using the parenthesis format when digit is not "1"', () => {
_setModelData( model, '<paragraph>5)[]</paragraph>' );
insertSpace();

expect( _getModelData( model ) ).to.equal(
'<paragraph listIndent="0" listItemId="a00" listType="numbered">[]</paragraph>'
);
} );

it( 'should not replace digit character when inside numbered list item (digit different than "1")', () => {
_setModelData( model, '<paragraph listIndent="0" listItemId="a00" listType="numbered">5.[]</paragraph>' );
insertSpace();

expect( _getModelData( model ) ).to.equal(
'<paragraph listIndent="0" listItemId="a00" listType="numbered">5. []</paragraph>'
);
} );

it( 'should not replace digit character after <softBreak>', () => {
Expand Down Expand Up @@ -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, '<paragraph>3.[]</paragraph>' );
insertSpace();

expect( _getModelData( model ) ).to.equal( '<paragraph>3. []</paragraph>' );
expect( _getModelData( model ) ).to.equal(
'<listItem listIndent="0" listItemId="a00" listType="numbered">[]</listItem>'
);
} );

it( 'should replace multi-digit number with numbered list item', () => {
_setModelData( model, '<paragraph>12.[]</paragraph>' );
insertSpace();

expect( _getModelData( model ) ).to.equal(
'<listItem listIndent="0" listItemId="a00" listType="numbered">[]</listItem>'
);
} );

it( 'should replace digit with numbered list item using the parenthesis format when digit is not "1"', () => {
_setModelData( model, '<paragraph>5)[]</paragraph>' );
insertSpace();

expect( _getModelData( model ) ).to.equal(
'<listItem listIndent="0" listItemId="a00" listType="numbered">[]</listItem>'
);
} );

it( 'should not replace digit character when inside numbered list item (digit different than "1")', () => {
_setModelData( model, '<listItem listIndent="0" listItemId="a00" listType="numbered">5.[]</listItem>' );
insertSpace();

expect( _getModelData( model ) ).to.equal(
'<listItem listIndent="0" listItemId="a00" listType="numbered">5. []</listItem>'
);
} );

it( 'should not replace digit character after <softBreak>', () => {
Expand Down Expand Up @@ -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, '<paragraph>1.[]</paragraph>' );
insertSpace();

expect( _getModelData( model ) ).to.equal(
'<paragraph listIndent="0" listItemId="a00" listStart="1" listType="numbered">[]</paragraph>'
);
} );

it( 'should set listStart attribute to the typed number when typing "5. "', () => {
_setModelData( model, '<paragraph>5.[]</paragraph>' );
insertSpace();

expect( _getModelData( model ) ).to.equal(
'<paragraph listIndent="0" listItemId="a00" listStart="5" listType="numbered">[]</paragraph>'
);
} );

it( 'should set listStart attribute to the typed number for multi-digit "12. "', () => {
_setModelData( model, '<paragraph>12.[]</paragraph>' );
insertSpace();

expect( _getModelData( model ) ).to.equal(
'<paragraph listIndent="0" listItemId="a00" listStart="12" listType="numbered">[]</paragraph>'
);
} );

it( 'should set listStart attribute to 0 when typing "0. "', () => {
_setModelData( model, '<paragraph>0.[]</paragraph>' );
insertSpace();

expect( _getModelData( model ) ).to.equal(
'<paragraph listIndent="0" listItemId="a00" listStart="0" listType="numbered">[]</paragraph>'
);
} );

it( 'should set listStart attribute to the typed number for the parenthesis format "5) "', () => {
_setModelData( model, '<paragraph>5)[]</paragraph>' );
insertSpace();

expect( _getModelData( model ) ).to.equal(
'<paragraph listIndent="0" listItemId="a00" listStart="5" listType="numbered">[]</paragraph>'
);
} );

it( 'should ignore typed number and inherit listStart from adjacent numbered list above', () => {
_setModelData( model,
'<paragraph listIndent="0" listItemId="a01" listStart="1" listType="numbered">Item 1</paragraph>' +
'<paragraph>5.[]</paragraph>'
);
insertSpace();

expect( _getModelData( model ) ).to.equal(
'<paragraph listIndent="0" listItemId="a01" listStart="1" listType="numbered">Item 1</paragraph>' +
'<paragraph listIndent="0" listItemId="a00" listStart="1" listType="numbered">[]</paragraph>'
);
} );

it( 'should start a new numbered list with typed listStart when adjacent list is bulleted', () => {
_setModelData( model,
'<paragraph listIndent="0" listItemId="a01" listType="bulleted">Item 1</paragraph>' +
'<paragraph>5.[]</paragraph>'
);
insertSpace();

expect( _getModelData( model ) ).to.equal(
'<paragraph listIndent="0" listItemId="a01" listType="bulleted">Item 1</paragraph>' +
'<paragraph listIndent="0" listItemId="a00" listStart="5" listType="numbered">[]</paragraph>'
);
} );
} );

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, '<paragraph>5.[]</paragraph>' );
insertSpace();

expect( _getModelData( model ) ).to.equal(
'<paragraph listIndent="0" listItemId="a00" listType="numbered">[]</paragraph>'
);
} );
} );

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, '<paragraph>5.[]</paragraph>' );
insertSpace();

expect( _getModelData( model ) ).to.equal(
'<listItem listIndent="0" listItemId="a00" listStart="5" listType="numbered">[]</listItem>'
);
} );
} );

function insertSpace() {
model.change( writer => {
writer.insertText( ' ', doc.selection.getFirstPosition() );
Expand Down
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
Loading