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. 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-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-indent/tests/integrations/Indentblocklistcommand.js b/packages/ckeditor5-indent/tests/integrations/indentblocklistcommand.js similarity index 91% rename from packages/ckeditor5-indent/tests/integrations/Indentblocklistcommand.js rename to packages/ckeditor5-indent/tests/integrations/indentblocklistcommand.js index 9ab7d1f37fc..b6ed1c6982c 100644 --- a/packages/ckeditor5-indent/tests/integrations/Indentblocklistcommand.js +++ b/packages/ckeditor5-indent/tests/integrations/indentblocklistcommand.js @@ -195,6 +195,75 @@ 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; + } ); + + 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; + } ); + } ); } ); describe( 'execute', () => { @@ -649,6 +718,74 @@ 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; + } ); + + it( 'should be false for a top-level item placed after a same-type skip-level nested list', () => { + _setModelData( model, modelList( [ + ' # aaa', + '# []bbb' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + } ); } ); describe( 'execute', () => { 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." } ] }, diff --git a/packages/ckeditor5-list/package.json b/packages/ckeditor5-list/package.json index 32a38d5267a..4e835176bc6 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/packages/ckeditor5-list/src/list/converters.ts b/packages/ckeditor5-list/src/list/converters.ts index 2b4a60e1850..6a9e05a3dfa 100644 --- a/packages/ckeditor5-list/src/list/converters.ts +++ b/packages/ckeditor5-list/src/list/converters.ts @@ -58,6 +58,65 @@ import type { ListDowncastStrategy } from './listediting.js'; +/** + * 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, 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. + * + * 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 { + return ( evt, data, conversionApi ) => { + const viewItem = data.viewItem; + + if ( viewItem.getStyle( 'list-style-type' ) !== 'none' ) { + return; + } + + if ( !isSkipLevelWrapper( viewItem ) ) { + return; + } + + if ( !conversionApi.consumable.consume( viewItem, { name: true } ) ) { + return; + } + + const { modelRange, modelCursor } = conversionApi.convertChildren( viewItem, data.modelCursor ); + + data.modelRange = modelRange; + data.modelCursor = modelCursor; + }; +} + +/** + * Checks whether a `
      2. ` 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; + + for ( const child of viewItem.getChildren() ) { + if ( child.is( 'element', 'ul' ) || child.is( 'element', 'ol' ) ) { + hasNestedList = true; + 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. * @@ -78,6 +137,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

          1. 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' } ); @@ -217,13 +282,12 @@ 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 }; + fillStackForIntermediates( node, itemIndent, stack ); + // Find all blocks of the current node. const blocks = getListItemBlocks( node, { direction: 'forward' } ); @@ -244,6 +308,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 ); @@ -327,15 +444,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 ) { @@ -364,7 +487,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 ); @@ -384,7 +507,8 @@ export function listItemDowncastConverter( const options = { ...conversionApi.options, - dataPipeline + dataPipeline, + allowSkipLevels }; // Use positions mapping instead of mapper.toViewElement( listItem ) to find outermost view element. @@ -675,6 +799,13 @@ 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). 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, @@ -688,42 +819,137 @@ 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
              • , + // 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
                  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 ); + + 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. + // + // 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' && + referenceItem.hasAttribute( strategy.attributeName ) + ) { + strategy.setAttributeOnDowncast( + writer, + referenceItem.getAttribute( strategy.attributeName ), + listViewElement, + options, + referenceItem + ); + } } - } - viewRange = writer.wrap( viewRange, listItemViewElement ); - viewRange = writer.wrap( viewRange, listViewElement ); + 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 ); + } if ( indent == 0 ) { break; } - currentListItem = ListWalker.first( currentListItem, { lowerIndent: true } ); + // 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; + } + } + } +} + +/** + * 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; - // 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; + 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. diff --git a/packages/ckeditor5-list/src/list/listediting.ts b/packages/ckeditor5-list/src/list/listediting.ts index 74f633435a5..4d01fa394de 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, + listItemSkipLevelConsumer, 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,17 @@ export class ListEditing extends Plugin { converterPriority: 'high' } ) .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', listItemSkipLevelConsumer(), { priority: 'high' } + ); + } + dispatcher.on( 'element:li', listItemUpcastConverter() ); } ); @@ -483,7 +496,12 @@ export class ListEditing extends Plugin { .add( dispatcher => { dispatcher.on>( 'attribute', - listItemDowncastConverter( attributeNames, this._downcastStrategies, model ) + listItemDowncastConverter( + attributeNames, + this._downcastStrategies, + model, + { allowSkipLevels } + ) ); dispatcher.on( 'remove', listItemDowncastRemoveConverter( model.schema ) ); @@ -498,7 +516,9 @@ export class ListEditing extends Plugin { .add( dispatcher => { dispatcher.on>( 'attribute', - listItemDowncastConverter( attributeNames, this._downcastStrategies, model, { dataPipeline: true } ) + listItemDowncastConverter( attributeNames, this._downcastStrategies, model, { + dataPipeline: true, allowSkipLevels + } ) ); } ); @@ -540,16 +560,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..fd379b883b0 100644 --- a/packages/ckeditor5-list/src/list/listindentcommand.ts +++ b/packages/ckeditor5-list/src/list/listindentcommand.ts @@ -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 ]; 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/src/list/utils/model.ts b/packages/ckeditor5-list/src/list/utils/model.ts index 0502d66e9e1..78ab2ac495b 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,73 @@ 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 block is the first block of 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. + * 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 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: + * + * ``` + * ' # 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, { - sameIndent: true, - sameAttributes: 'listType' - } ); + const itemIndent = listItem.getAttribute( 'listIndent' ) as number; + const itemListType = listItem.getAttribute( 'listType' ); + const itemListItemId = listItem.getAttribute( 'listItemId' ); + + let previous = listItem.previousSibling; + let sawHigherIndent = false; + + while ( isListItemBlock( previous ) ) { + const previousIndent = previous.getAttribute( 'listIndent' ) as number; + + 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 !previousItem; + // 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; } /** 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/converters-data.js b/packages/ckeditor5-list/tests/list/converters-data.js index c308f780d00..eb35eeba129 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 { _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'; @@ -14,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'; @@ -2613,4 +2616,736 @@ 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 ancestor for intermediate levels without a sibling', () => { + _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 container at a skip level', () => { + _setModelData( skipModel, + 'A' + + '
                      ' + + 'B' + + '
                      ' + ); + + expect( skipEditor.getData( { skipListItemIds: true } ) ).to.equal( + '
                        ' + + '
                      • A' + + '
                          ' + + '
                        • ' + + '
                            ' + + '
                          • B

                          • ' + + '
                          ' + + '
                        • ' + + '
                        ' + + '
                      • ' + + '
                      ' + ); + } ); + + 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 upcast an empty
                    • as a regular list item', () => { + skipEditor.setData( + '
                        ' + + '
                      1. ' + + '
                      ' + ); + + expect( _getModelData( skipModel, { withoutSelection: true } ) ).to.equalMarkup( + '' + ); + } ); + + it( 'should upcast
                    • 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
                    • 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 upcast
                    • 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' + + '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' + ); + } ); + } ); + + 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' + ); + } ); + + it( 'should preserve attributes/classes on an empty
                    • ', () => { + ghsEditor.setData( + '
                        ' + + '
                      1. ' + + '
                      ' + ); + + expect( _getModelData( ghsModel, { withoutSelection: true } ) ).to.equalMarkup( + '' + + '' + ); + } ); + + it( 'should preserve attributes on
                    • 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
                    • ', () => { + ghsEditor.setData( + '
                        ' + + '
                      1. ' + + '
                      ' + ); + + expect( _getModelData( ghsModel, { withoutSelection: true } ) ).to.equalMarkup( + '' + + '' + ); + } ); + + it( 'should preserve attributes on
                    • with text-only content', () => { + ghsEditor.setData( + '
                        ' + + '
                      1. foobar
                      2. ' + + '
                      ' + ); + + expect( _getModelData( ghsModel, { withoutSelection: true } ) ).to.equalMarkup( + '' + + 'foobar' + + '' + ); + } ); + } ); + } ); } ); diff --git a/packages/ckeditor5-list/tests/list/converters.js b/packages/ckeditor5-list/tests/list/converters.js index 6f08316922f..e3fc0aa0286 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'; @@ -1099,6 +1100,456 @@ 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 ancestor for intermediate levels without a sibling', () => { + _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 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 container at a skip level', () => { + _setModelData( skipModel, + 'A' + + '
                      ' + + 'B' + + '
                      ' + ); + + expect( _getViewData( skipEditor.editing.view, { withoutSelection: true } ) ).to.equal( + '
                        ' + + '
                      • ' + + 'A' + + '
                          ' + + '
                        • ' + + '
                            ' + + '
                          • B

                          • ' + + '
                          ' + + '
                        • ' + + '
                        ' + + '
                      • ' + + '
                      ' + ); + } ); + + 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 ], + 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(); + } ); + + 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 ) { let parent = root; 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/integrations/indentmulticommand.js b/packages/ckeditor5-list/tests/list/integrations/indentmulticommand.js index e0be28a5adf..9bcd515eeec 100644 --- a/packages/ckeditor5-list/tests/list/integrations/indentmulticommand.js +++ b/packages/ckeditor5-list/tests/list/integrations/indentmulticommand.js @@ -1498,6 +1498,158 @@ 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' ); + } ); + + it( 'should execute indentList (not indentBlockList) when at start of a top-level item ' + + 'placed after a skip-level nested list', () => { + // `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', () => { + 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 628e71ce886..edfa752acab 100644 --- a/packages/ckeditor5-list/tests/list/listindentcommand.js +++ b/packages/ckeditor5-list/tests/list/listindentcommand.js @@ -1194,4 +1194,390 @@ 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; + } ); + + 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; + } ); + + 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.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; + } ); + + 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; + } ); + + 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; + } ); + } ); + + 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' + ] ) ); + + 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', () => { + _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; + } ); + + 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; + } ); + + it( 'should be true for a single list item at indent 0', () => { + _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' + ] ) ); + + 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' + ] ) ); + } ); + + 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' + ] ) ); + } ); + } ); + } ); } ); 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', () => { diff --git a/packages/ckeditor5-list/tests/list/utils/model.js b/packages/ckeditor5-list/tests/list/utils/model.js index 233c541998e..93153cfc827 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, + isFirstListItemInList, isSingleListItem, ListItemUid, mergeListItemBefore, @@ -1821,4 +1822,212 @@ describe( 'List - utils - model', () => { ] ); } ); } ); + + describe( 'isFirstListItemInList()', () => { + it( 'should return true for the first item in a list and false for the next same-type sibling', () => { + 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.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( isFirstListItemInList( fragment.getChild( 1 ) ) ).to.be.true; + } ); + + 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' + ] ); + + const fragment = _parseModel( input, schema ); + + expect( isFirstListItemInList( fragment.getChild( 1 ) ) ).to.be.true; + } ); + + it( 'should return false for a nested list item preceded by a same-indent same-type sibling', () => { + const input = modelList( [ + '* a', + ' * b', + ' * c' + ] ); + + const fragment = _parseModel( input, schema ); + + expect( isFirstListItemInList( fragment.getChild( 2 ) ) ).to.be.false; + } ); + + it( 'should return true for the first item after a list of a different type', () => { + const input = modelList( [ + '# a', + '# b', + '* c', + '* d' + ] ); + + const fragment = _parseModel( input, schema ); + + // First numbered item. + expect( isFirstListItemInList( fragment.getChild( 0 ) ) ).to.be.true; + // Second numbered item. + expect( isFirstListItemInList( fragment.getChild( 1 ) ) ).to.be.false; + // First bulleted item. + expect( isFirstListItemInList( fragment.getChild( 2 ) ) ).to.be.true; + // Second bulleted item. + 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 that is first in the document', () => { + const input = modelList( [ + ' * a', + ' * b' + ] ); + + const fragment = _parseModel( input, schema ); + + expect( isFirstListItemInList( fragment.getChild( 0 ) ) ).to.be.true; + } ); + + 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( [ + ' # aaa', + '# bbb' + ] ); + + 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 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 false for a top-level item placed after a different-type skip-level nested list and before list item', () => { + 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.true; + } ); + + 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', + ' * 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; + } ); + + 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 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', + ' 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; + } ); + } ); } ); 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', () => { 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..2cbef16580a --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/documentlist-skip-levels.html @@ -0,0 +1,56 @@ +
                        +
                        + + + +
                        +
                        + + +
                        +
                        + + + +
                        +

                        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..b9518af30eb --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/documentlist-skip-levels.js @@ -0,0 +1,169 @@ +/** + * @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, 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'; +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 { 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'; +import { ListProperties } from '../../src/listproperties.js'; + +const editorElement = document.querySelector( '#editor' ); +const INITIAL_DATA = editorElement.innerHTML; + +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, Autoformat + ]; + + 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', '|', + '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: controls.skipLevels.checked + }, + htmlSupport: { + allow: [ + { + name: /^.*$/, + styles: true, + attributes: true, + classes: true + } + ] + }, + menuBar: { + isVisible: true + } + }; + + return config; +} + +function createEditor() { + const initialize = () => + ClassicEditor.create( { + ...getEditorConfig(), + attachTo: editorElement + } ).then( newEditor => { + editor = newEditor; + window.editor = editor; + editor.setData( INITIAL_DATA ); + } ); + + return Promise.resolve() + .then( () => editor && editor.destroy() ) + .then( initialize ) + .catch( err => console.error( err ) ); +} + +createEditor(); + +Object.values( controls ).forEach( input => { + input.addEventListener( 'change', () => { + createEditor(); + } ); +} ); 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. 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 ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa80aa4ae0b..c2307e4403d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2386,6 +2386,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