diff --git a/packages/ckeditor5-engine/src/view/renderer.ts b/packages/ckeditor5-engine/src/view/renderer.ts index 3a8ce273b2c..81ab96bef7f 100644 --- a/packages/ckeditor5-engine/src/view/renderer.ts +++ b/packages/ckeditor5-engine/src/view/renderer.ts @@ -357,7 +357,7 @@ export default class Renderer extends ObservableMixin() { // // Converting live list to an array to make the list static. const actualDomChildren = Array.from( - this.domConverter.mapViewToDom( viewElement )!.childNodes + domElement.childNodes ); const expectedDomChildren = Array.from( this.domConverter.viewChildrenToDom( viewElement, { withChildren: false } ) diff --git a/packages/ckeditor5-html-support/src/integrations/documentlist.ts b/packages/ckeditor5-html-support/src/integrations/documentlist.ts index d41f871bce3..0233abeb73b 100644 --- a/packages/ckeditor5-html-support/src/integrations/documentlist.ts +++ b/packages/ckeditor5-html-support/src/integrations/documentlist.ts @@ -89,9 +89,7 @@ export default class DocumentListElementSupport extends Plugin { const allowAttributes = viewElements.map( element => getHtmlAttributeName( element ) ); - schema.extend( '$block', { allowAttributes } ); - schema.extend( '$blockObject', { allowAttributes } ); - schema.extend( '$container', { allowAttributes } ); + schema.extend( '$listItem', { allowAttributes } ); conversion.for( 'upcast' ).add( dispatcher => { dispatcher.on( diff --git a/packages/ckeditor5-list/src/documentlist/converters.ts b/packages/ckeditor5-list/src/documentlist/converters.ts index 2497cc7b2b9..1e5f18b29a1 100644 --- a/packages/ckeditor5-list/src/documentlist/converters.ts +++ b/packages/ckeditor5-list/src/documentlist/converters.ts @@ -269,7 +269,7 @@ export function reconvertItemsOnDataChange( return true; } - if ( !item.is( 'element', 'paragraph' ) ) { + if ( !item.is( 'element', 'paragraph' ) && !item.is( 'element', 'listItem' ) ) { return false; } diff --git a/packages/ckeditor5-list/src/documentlist/documentlistcommand.ts b/packages/ckeditor5-list/src/documentlist/documentlistcommand.ts index 72ed13e0ed4..0b1f4aa8c9f 100644 --- a/packages/ckeditor5-list/src/documentlist/documentlistcommand.ts +++ b/packages/ckeditor5-list/src/documentlist/documentlistcommand.ts @@ -19,7 +19,8 @@ import { ListItemUid, sortBlocks, getSelectedBlockObject, - isListItemBlock + isListItemBlock, + canBecomeSimpleListItem } from './utils/model'; /** @@ -75,7 +76,7 @@ export default class DocumentListCommand extends Command { const selectedBlockObject = getSelectedBlockObject( model ); const blocks = Array.from( document.selection.getSelectedBlocks() ) - .filter( block => model.schema.checkAttribute( block, 'listType' ) ); + .filter( block => model.schema.checkAttribute( block, 'listType' ) || canBecomeSimpleListItem( block, model.schema ) ); // Whether we are turning off some items. const turnOff = options.forceValue !== undefined ? !options.forceValue : this.value; @@ -92,7 +93,7 @@ export default class DocumentListCommand extends Command { changedBlocks.push( ...splitListItemBefore( itemBlocks[ 1 ], writer ) ); } - // Convert list blocks to plain blocks. + // Strip list attributes. changedBlocks.push( ...removeListAttributes( blocks, writer ) ); // Outdent items following the selected list item. @@ -117,6 +118,11 @@ export default class DocumentListCommand extends Command { for ( const block of blocks ) { // Promote the given block to the list item. if ( !block.hasAttribute( 'listType' ) ) { + // Rename block to a simple list item if this option is enabled. + if ( !block.is( 'element', 'listItem' ) && canBecomeSimpleListItem( block, model.schema ) ) { + writer.rename( block, 'listItem' ); + } + writer.setAttributes( { listIndent: 0, listItemId: ListItemUid.next(), @@ -194,7 +200,7 @@ export default class DocumentListCommand extends Command { } for ( const block of blocks ) { - if ( schema.checkAttribute( block, 'listType' ) ) { + if ( schema.checkAttribute( block, 'listType' ) || canBecomeSimpleListItem( block, schema ) ) { return true; } } diff --git a/packages/ckeditor5-list/src/documentlist/documentlistediting.ts b/packages/ckeditor5-list/src/documentlist/documentlistediting.ts index aa706dbb16f..6601ce84f30 100644 --- a/packages/ckeditor5-list/src/documentlist/documentlistediting.ts +++ b/packages/ckeditor5-list/src/documentlist/documentlistediting.ts @@ -9,6 +9,7 @@ import { Plugin, + type Editor, type MultiCommand } from 'ckeditor5/src/core'; @@ -108,12 +109,22 @@ export default class DocumentListEditing extends Plugin { return [ Enter, Delete, DocumentListUtils ] as const; } + /** + * @inheritDoc + */ + constructor( editor: Editor ) { + super( editor ); + + editor.config.define( 'list.multiBlock', true ); + } + /** * @inheritDoc */ public init(): void { const editor = this.editor; const model = editor.model; + const multiBlock = editor.config.get( 'list.multiBlock' ); if ( editor.plugins.has( 'ListEditing' ) ) { /** @@ -125,9 +136,18 @@ export default class DocumentListEditing extends Plugin { throw new CKEditorError( 'document-list-feature-conflict', this, { conflictPlugin: 'ListEditing' } ); } - model.schema.extend( '$container', { allowAttributes: LIST_BASE_ATTRIBUTES } ); - model.schema.extend( '$block', { allowAttributes: LIST_BASE_ATTRIBUTES } ); - model.schema.extend( '$blockObject', { allowAttributes: LIST_BASE_ATTRIBUTES } ); + model.schema.register( '$listItem', { allowAttributes: LIST_BASE_ATTRIBUTES } ); + + if ( multiBlock ) { + model.schema.extend( '$container', { allowAttributesOf: '$listItem' } ); + model.schema.extend( '$block', { allowAttributesOf: '$listItem' } ); + model.schema.extend( '$blockObject', { allowAttributesOf: '$listItem' } ); + } else { + model.schema.register( 'listItem', { + inheritAllFrom: '$block', + allowAttributesOf: '$listItem' + } ); + } for ( const attribute of LIST_BASE_ATTRIBUTES ) { model.schema.setAttributeProperties( attribute, { @@ -142,12 +162,14 @@ export default class DocumentListEditing extends Plugin { editor.commands.add( 'indentList', new DocumentListIndentCommand( editor, 'forward' ) ); editor.commands.add( 'outdentList', new DocumentListIndentCommand( editor, 'backward' ) ); - editor.commands.add( 'mergeListItemBackward', new DocumentListMergeCommand( editor, 'backward' ) ); - editor.commands.add( 'mergeListItemForward', new DocumentListMergeCommand( editor, 'forward' ) ); - editor.commands.add( 'splitListItemBefore', new DocumentListSplitCommand( editor, 'before' ) ); editor.commands.add( 'splitListItemAfter', new DocumentListSplitCommand( editor, 'after' ) ); + if ( multiBlock ) { + editor.commands.add( 'mergeListItemBackward', new DocumentListMergeCommand( editor, 'backward' ) ); + editor.commands.add( 'mergeListItemForward', new DocumentListMergeCommand( editor, 'forward' ) ); + } + this._setupDeleteIntegration(); this._setupEnterIntegration(); this._setupTabIntegration(); @@ -208,8 +230,8 @@ export default class DocumentListEditing extends Plugin { */ private _setupDeleteIntegration() { const editor = this.editor; - const mergeBackwardCommand: DocumentListMergeCommand = editor.commands.get( 'mergeListItemBackward' )!; - const mergeForwardCommand: DocumentListMergeCommand = editor.commands.get( 'mergeListItemForward' )!; + const mergeBackwardCommand: DocumentListMergeCommand | undefined = editor.commands.get( 'mergeListItemBackward' ); + const mergeForwardCommand: DocumentListMergeCommand | undefined = editor.commands.get( 'mergeListItemForward' ); this.listenTo( editor.editing.view.document, 'delete', ( evt, data ) => { const selection = editor.model.document.selection; @@ -248,7 +270,7 @@ export default class DocumentListEditing extends Plugin { } // Merge block with previous one (on the block level or on the content level). else { - if ( !mergeBackwardCommand.isEnabled ) { + if ( !mergeBackwardCommand || !mergeBackwardCommand.isEnabled ) { return; } @@ -267,7 +289,7 @@ export default class DocumentListEditing extends Plugin { return; } - if ( !mergeForwardCommand.isEnabled ) { + if ( !mergeForwardCommand || !mergeForwardCommand.isEnabled ) { return; } @@ -390,13 +412,18 @@ export default class DocumentListEditing extends Plugin { const editor = this.editor; const model = editor.model; const attributeNames = this.getListAttributeNames(); + const multiBlock = editor.config.get( 'list.multiBlock' ); + const elementName = multiBlock ? 'paragraph' : 'listItem'; editor.conversion.for( 'upcast' ) - // Convert
  • to a generic paragraph so the content of
  • is always inside a block. + // Convert
  • to a generic paragraph (or listItem element) so the content of
  • is always inside a block. // Setting the listType attribute to let other features (to-do list) know that this is part of a list item. + // This is also important to properly handle simple lists so that paragraphs inside a list item won't break the list item. + //
  • <-- converted to listItem + //

    <-- should be also converted to listItem, so it won't split and replace the listItem generated from the above li. .elementToElement( { view: 'li', - model: ( viewElement, { writer } ) => writer.createElement( 'paragraph', { listType: '' } ) + model: ( viewElement, { writer } ) => writer.createElement( elementName, { listType: '' } ) } ) // Convert paragraph to the list block (without list type defined yet). // This is important to properly handle bogus paragraph and to-do lists. @@ -407,7 +434,7 @@ export default class DocumentListEditing extends Plugin { view: 'p', model: ( viewElement, { writer } ) => { if ( viewElement.parent && viewElement.parent.is( 'element', 'li' ) ) { - return writer.createElement( 'paragraph', { listType: '' } ); + return writer.createElement( elementName, { listType: '' } ); } return null; @@ -420,9 +447,17 @@ export default class DocumentListEditing extends Plugin { dispatcher.on( 'element:ol', listUpcastCleanList(), { priority: 'high' } ); } ); + if ( !multiBlock ) { + editor.conversion.for( 'downcast' ) + .elementToElement( { + model: 'listItem', + view: 'p' + } ); + } + editor.conversion.for( 'editingDowncast' ) .elementToElement( { - model: 'paragraph', + model: elementName, view: bogusParagraphCreator( attributeNames ), converterPriority: 'high' } ) @@ -435,7 +470,7 @@ export default class DocumentListEditing extends Plugin { editor.conversion.for( 'dataDowncast' ) .elementToElement( { - model: 'paragraph', + model: elementName, view: bogusParagraphCreator( attributeNames, { dataPipeline: true } ), converterPriority: 'high' } ) @@ -649,6 +684,7 @@ function modelChangePostFixer( ) { const changes = model.document.differ.getChanges(); const itemToListHead = new Map(); + const multiBlock = documentListEditing.editor.config.get( 'list.multiBlock' ); let applied = false; @@ -693,6 +729,19 @@ function modelChangePostFixer( findAndAddListHeadToMap( entry.range.start.getShiftedBy( 1 ), itemToListHead ); } } + + // Make sure that there is no left over listItem element without attributes or a block with list attributes that is not a listItem. + if ( !multiBlock && entry.type == 'attribute' && LIST_BASE_ATTRIBUTES.includes( entry.attributeKey ) ) { + const element = entry.range.start.nodeAfter!; + + if ( entry.attributeNewValue === null && element && element.is( 'element', 'listItem' ) ) { + writer.rename( element, 'paragraph' ); + applied = true; + } else if ( entry.attributeOldValue === null && element && element.is( 'element' ) && element.name != 'listItem' ) { + writer.rename( element, 'listItem' ); + applied = true; + } + } } // Make sure that IDs are not shared by split list. diff --git a/packages/ckeditor5-list/src/documentlist/documentlistmergecommand.ts b/packages/ckeditor5-list/src/documentlist/documentlistmergecommand.ts index bb221e00765..2c78c6e3b59 100644 --- a/packages/ckeditor5-list/src/documentlist/documentlistmergecommand.ts +++ b/packages/ckeditor5-list/src/documentlist/documentlistmergecommand.ts @@ -106,7 +106,7 @@ export default class DocumentListMergeCommand extends Command { // Check if the element after it was in the same list item and adjust it if needed. const nextSibling = lastElementAfterDelete.nextSibling; - changedBlocks.push( lastElementAfterDelete as any ); + changedBlocks.push( lastElementAfterDelete as Element ); if ( nextSibling && nextSibling !== lastElement && nextSibling.getAttribute( 'listItemId' ) == lastElementId ) { changedBlocks.push( ...mergeListItemBefore( nextSibling, lastElementAfterDelete, writer ) ); diff --git a/packages/ckeditor5-list/src/documentlist/utils/model.ts b/packages/ckeditor5-list/src/documentlist/utils/model.ts index 3096212aca5..b1ccba25539 100644 --- a/packages/ckeditor5-list/src/documentlist/utils/model.ts +++ b/packages/ckeditor5-list/src/documentlist/utils/model.ts @@ -393,6 +393,14 @@ export function removeListAttributes( ): Array { blocks = toArray( blocks ); + // Convert simple list items to plain paragraphs. + for ( const block of blocks ) { + if ( block.is( 'element', 'listItem' ) ) { + writer.rename( block, 'paragraph' ); + } + } + + // Remove list attributes. for ( const block of blocks ) { for ( const attributeKey of block.getAttributeKeys() ) { if ( attributeKey.startsWith( 'list' ) ) { @@ -546,7 +554,21 @@ export function getSelectedBlockObject( model: Model ): Element | null { return null; } -// Merges a given block to the given parent block if parent is a list item and there is no more blocks in the same item. +/** + * Checks whether the given block can be replaced by a listItem. + * + * Note that this is possible only when multiBlock = false option is set in feature config. + * + * @param block A block to be tested. + * @param schema The schema of the document. + */ +export function canBecomeSimpleListItem( block: Element, schema: Schema ): boolean { + return schema.checkChild( block.parent as Element, 'listItem' ) && schema.checkChild( block, '$text' ) && !schema.isObject( block ); +} + +/** + * Merges a given block to the given parent block if parent is a list item and there is no more blocks in the same item. + */ function mergeListItemIfNotLast( block: ListElement, parentBlock: ListElement, diff --git a/packages/ckeditor5-list/src/documentlist/utils/postfixers.ts b/packages/ckeditor5-list/src/documentlist/utils/postfixers.ts index 34ccfb64f3a..c398f5517cc 100644 --- a/packages/ckeditor5-list/src/documentlist/utils/postfixers.ts +++ b/packages/ckeditor5-list/src/documentlist/utils/postfixers.ts @@ -135,6 +135,17 @@ export function fixListItemIds( seenIds.add( listItemId ); + // Make sure that all items in a simple list have unique IDs. + if ( node.is( 'element', 'listItem' ) ) { + if ( node.getAttribute( 'listItemId' ) != listItemId ) { + writer.setAttribute( 'listItemId', listItemId, node ); + + applied = true; + } + + continue; + } + for ( const block of getListItemBlocks( node, { direction: 'forward' } ) ) { visited.add( block ); diff --git a/packages/ckeditor5-list/src/documentlistproperties/documentlistpropertiesediting.ts b/packages/ckeditor5-list/src/documentlistproperties/documentlistpropertiesediting.ts index 251fca2d653..b63aa811922 100644 --- a/packages/ckeditor5-list/src/documentlistproperties/documentlistpropertiesediting.ts +++ b/packages/ckeditor5-list/src/documentlistproperties/documentlistpropertiesediting.ts @@ -68,12 +68,10 @@ export default class DocumentListPropertiesEditing extends Plugin { constructor( editor: Editor ) { super( editor ); - editor.config.define( 'list', { - properties: { - styles: true, - startIndex: false, - reversed: false - } + editor.config.define( 'list.properties', { + styles: true, + startIndex: false, + reversed: false } ); } @@ -91,9 +89,7 @@ export default class DocumentListPropertiesEditing extends Plugin { for ( const strategy of strategies ) { strategy.addCommand( editor ); - model.schema.extend( '$container', { allowAttributes: strategy.attributeName } ); - model.schema.extend( '$block', { allowAttributes: strategy.attributeName } ); - model.schema.extend( '$blockObject', { allowAttributes: strategy.attributeName } ); + model.schema.extend( '$listItem', { allowAttributes: strategy.attributeName } ); // Register downcast strategy. documentListEditing.registerDowncastStrategy( { diff --git a/packages/ckeditor5-list/src/listconfig.ts b/packages/ckeditor5-list/src/listconfig.ts index 2ff7dea0ca8..10efa045ab3 100644 --- a/packages/ckeditor5-list/src/listconfig.ts +++ b/packages/ckeditor5-list/src/listconfig.ts @@ -33,6 +33,17 @@ export interface ListConfig { * Read more in {@link module:list/listconfig~ListPropertiesConfig}. */ properties?: ListPropertiesConfig; + + /** + * Allows multiple blocks in single list item. + * + * With this option enabled you can have block widgets, for example images or even tables, within a list item. + * + * **Note:** This is enabled by default. + * + * @default true + */ + multiBlock?: boolean; } /** diff --git a/packages/ckeditor5-list/src/tododocumentlist/tododocumentlistediting.ts b/packages/ckeditor5-list/src/tododocumentlist/tododocumentlistediting.ts index 570facf16fc..ff0a8d917d5 100644 --- a/packages/ckeditor5-list/src/tododocumentlist/tododocumentlistediting.ts +++ b/packages/ckeditor5-list/src/tododocumentlist/tododocumentlistediting.ts @@ -73,15 +73,15 @@ export default class TodoDocumentListEditing extends Plugin { const model = editor.model; const editing = editor.editing; const documentListEditing = editor.plugins.get( DocumentListEditing ); + const multiBlock = editor.config.get( 'list.multiBlock' ); + const elementName = multiBlock ? 'paragraph' : 'listItem'; editor.commands.add( 'todoList', new DocumentListCommand( editor, 'todo' ) ); editor.commands.add( 'checkTodoList', new CheckTodoDocumentListCommand( editor ) ); editing.view.addObserver( TodoCheckboxChangeObserver ); - model.schema.extend( '$container', { allowAttributes: 'todoListChecked' } ); - model.schema.extend( '$block', { allowAttributes: 'todoListChecked' } ); - model.schema.extend( '$blockObject', { allowAttributes: 'todoListChecked' } ); + model.schema.extend( '$listItem', { allowAttributes: 'todoListChecked' } ); model.schema.addAttributeCheck( ( context, attributeName ) => { const item = context.last; @@ -115,7 +115,7 @@ export default class TodoDocumentListEditing extends Plugin { } ); editor.conversion.for( 'downcast' ).elementToElement( { - model: 'paragraph', + model: elementName, view: ( element, { writer } ) => { if ( isDescriptionBlock( element, documentListEditing.getListAttributeNames() ) ) { return writer.createContainerElement( 'span', { class: 'todo-list__label__description' } ); @@ -445,7 +445,7 @@ function attributeUpcastConsumingConverter( matcherPattern: MatcherPattern ): Ge * Returns true if the given list item block should be converted as a description block of a to-do list item. */ function isDescriptionBlock( modelElement: Element, listAttributeNames: Array ): boolean { - return modelElement.is( 'element', 'paragraph' ) && + return ( modelElement.is( 'element', 'paragraph' ) || modelElement.is( 'element', 'listItem' ) ) && modelElement.getAttribute( 'listType' ) == 'todo' && isFirstBlockOfListItem( modelElement ) && hasOnlyListAttributes( modelElement, listAttributeNames ); diff --git a/packages/ckeditor5-list/tests/documentlist/_utils-tests/utils.js b/packages/ckeditor5-list/tests/documentlist/_utils-tests/utils.js index 4de11cdcfac..239aabfc019 100644 --- a/packages/ckeditor5-list/tests/documentlist/_utils-tests/utils.js +++ b/packages/ckeditor5-list/tests/documentlist/_utils-tests/utils.js @@ -524,6 +524,24 @@ describe( 'mockList()', () => { '* bar {id:000}' ] ) ).to.throw( Error, 'ID conflict: 000' ); } ); + + it( 'should allow using different default block', () => { + modelList.defaultBlock = 'listItem'; + + expect( modelList( ` + text + * foo + # bar + # baz + ` ) ).to.equalMarkup( + 'text' + + 'foo' + + 'bar' + + 'baz' + ); + + modelList.defaultBlock = 'paragraph'; + } ); } ); describe( 'stringifyList()', () => { @@ -921,5 +939,25 @@ describe( 'stringifyList()', () => { ' # 0' ].join( '\n' ) ); } ); + + it( 'should allow using different default block', () => { + modelList.defaultBlock = 'listItem'; + model.schema.register( 'listItem', { inheritAllFrom: '$block' } ); + + const input = parseModel( + 'a' + + 'b' + + 'c', + model.schema + ); + + expect( stringifyList( input ) ).to.equalMarkup( [ + '* a', + '# b', + '# c' + ].join( '\n' ) ); + + modelList.defaultBlock = 'paragraph'; + } ); } ); } ); diff --git a/packages/ckeditor5-list/tests/documentlist/_utils/utils.js b/packages/ckeditor5-list/tests/documentlist/_utils/utils.js index 5c15f135b73..75e82280dac 100644 --- a/packages/ckeditor5-list/tests/documentlist/_utils/utils.js +++ b/packages/ckeditor5-list/tests/documentlist/_utils/utils.js @@ -312,6 +312,8 @@ export function modelList( lines, { ignoreIdConflicts = false } = {} ) { return items.join( '' ); } +modelList.defaultBlock = 'paragraph'; + /** * Returns document list pseudo markdown notation for a given document fragment or element. * @@ -349,7 +351,7 @@ export function stringifyList( fragmentOrElement ) { function stringifyNode( node, writer ) { const fragment = writer.createDocumentFragment(); - if ( node.is( 'element', 'paragraph' ) ) { + if ( node.is( 'element', modelList.defaultBlock ) ) { for ( const child of node.getChildren() ) { writer.append( writer.cloneElement( child ), fragment ); } @@ -369,7 +371,7 @@ function stringifyNode( node, writer ) { } function stringifyElement( content, listAttributes = {} ) { - let name = 'paragraph'; + let name = listAttributes.listItemId ? modelList.defaultBlock : 'paragraph'; let elementAttributes = ''; let selectionBefore = ''; let selectionAfter = ''; diff --git a/packages/ckeditor5-list/tests/documentlist/converters-data-single-block.js b/packages/ckeditor5-list/tests/documentlist/converters-data-single-block.js new file mode 100644 index 00000000000..2c35c4de7b4 --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/converters-data-single-block.js @@ -0,0 +1,1939 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import DocumentListEditing from '../../src/documentlist/documentlistediting'; + +import BoldEditing from '@ckeditor/ckeditor5-basic-styles/src/bold/boldediting'; +import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting'; +import ClipboardPipeline from '@ckeditor/ckeditor5-clipboard/src/clipboardpipeline'; +import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting'; +import HeadingEditing from '@ckeditor/ckeditor5-heading/src/headingediting'; +import IndentEditing from '@ckeditor/ckeditor5-indent/src/indentediting'; +import TableEditing from '@ckeditor/ckeditor5-table/src/tableediting'; +import CodeBlockEditing from '@ckeditor/ckeditor5-code-block/src/codeblockediting'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; + +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import { setupTestHelpers } from './_utils/utils'; +import stubUid from './_utils/uid'; + +describe( 'DocumentListEditing (multiBlock=false) - converters - data pipeline', () => { + let editor, model, view, test; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + editor = await VirtualTestEditor.create( { + plugins: [ Paragraph, IndentEditing, ClipboardPipeline, BoldEditing, DocumentListEditing, UndoEditing, + BlockQuoteEditing, TableEditing, HeadingEditing, CodeBlockEditing ], + list: { + multiBlock: false + } + } ); + + model = editor.model; + view = editor.editing.view; + + model.schema.register( 'foo', { + allowWhere: '$block', + allowAttributes: [ 'listIndent', 'listType' ], + isBlock: true, + isObject: true + } ); + + // Stub `view.scrollToTheSelection` as it will fail on VirtualTestEditor without DOM. + sinon.stub( view, 'scrollToTheSelection' ).callsFake( () => {} ); + stubUid(); + + test = setupTestHelpers( editor ); + } ); + + afterEach( async () => { + await editor.destroy(); + } ); + + describe( 'flat lists', () => { + it( 'single item', () => { + test.data( + '
    • x
    ', + 'x' + ); + } ); + + it( 'single item with spaces', () => { + test.data( + '
    •  x 
    ', + ' x ' + ); + } ); + + it( 'multiple items', () => { + test.data( + '
      ' + + '
    • a
    • ' + + '
    • b
    • ' + + '
    • c
    • ' + + '
    ', + + 'a' + + 'b' + + 'c' + ); + } ); + + it( 'single multi-block item', () => { + test.data( + '
      ' + + '
    • ' + + '

      a

      ' + + '

      b

      ' + + '
    • ' + + '
    ', + + 'a' + + 'b', + + '
      ' + + '
    • a
    • ' + + '
    • b
    • ' + + '
    ' + ); + } ); + + it( 'multiple multi-block items', () => { + test.data( + '
      ' + + '
    • ' + + '

      a

      ' + + '

      b

      ' + + '
    • ' + + '
    • ' + + '

      c

      ' + + '

      d

      ' + + '
    • ' + + '
    ', + + 'a' + + 'b' + + 'c' + + 'd', + + '
      ' + + '
    • a
    • ' + + '
    • b
    • ' + + '
    • c
    • ' + + '
    • d
    • ' + + '
    ' + ); + } ); + + it( 'multiple items with leading space in first', () => { + test.data( + '
      ' + + '
    •  a
    • ' + + '
    • b
    • ' + + '
    • c
    • ' + + '
    ', + + ' a' + + 'b' + + 'c' + ); + } ); + + it( 'multiple items with trailing space in last', () => { + test.data( + '
      ' + + '
    • a
    • ' + + '
    • b
    • ' + + '
    • ' + + '
    ', + + 'a' + + 'b' + + 'c ' + ); + } ); + + it( 'items and text', () => { + test.data( + '

    xxx

    ' + + '
      ' + + '
    • a
    • ' + + '
    • b
    • ' + + '
    ' + + '

    yyy

    ' + + '
      ' + + '
    • c
    • ' + + '
    • d
    • ' + + '
    ', + + 'xxx' + + 'a' + + 'b' + + 'yyy' + + 'c' + + 'd' + ); + } ); + + it( 'numbered list', () => { + test.data( + '
      ' + + '
    1. a
    2. ' + + '
    3. b
    4. ' + + '
    ', + + 'a' + + 'b' + ); + } ); + + it( 'mixed list and content #1', () => { + test.data( + '

    xxx

    ' + + '
      ' + + '
    • a
    • ' + + '
    • b
    • ' + + '
    ' + + '
      ' + + '
    1. c
    2. ' + + '
    3. d
    4. ' + + '
    ' + + '

    yyy

    ', + + 'xxx' + + 'a' + + 'b' + + 'c' + + 'd' + + 'yyy' + ); + } ); + + it( 'mixed list and content #2', () => { + test.data( + '
      ' + + '
    1. a
    2. ' + + '
    ' + + '

    xxx

    ' + + '
      ' + + '
    • b
    • ' + + '
    • c
    • ' + + '
    ' + + '

    yyy

    ' + + '
      ' + + '
    • d
    • ' + + '
    ', + + 'a' + + 'xxx' + + 'b' + + 'c' + + 'yyy' + + 'd' + ); + } ); + + it( 'clears incorrect elements', () => { + test.data( + '
      ' + + 'x' + + '
    • a
    • ' + + '
    • b
    • ' + + '

      xxx

      ' + + 'x' + + '
    ' + + '

    c

    ', + + 'a' + + 'b' + + 'c', + + '
      ' + + '
    • a
    • ' + + '
    • b
    • ' + + '
    ' + + '

    c

    ' + ); + } ); + + it( 'clears whitespaces', () => { + test.data( + '

    foo

    ' + + '
      ' + + '
    • xxx
    • ' + + '
    • yyy
    • ' + + '
    ', + + 'foo' + + 'xxx' + + 'yyy', + + '

    foo

    ' + + '
      ' + + '
    • xxx
    • ' + + '
    • yyy
    • ' + + '
    ' + ); + } ); + + it( 'single item with `font-weight` style', () => { + test.data( + '
      ' + + '
    1. foo
    2. ' + + '
    ', + + '' + + '<$text bold="true">foo' + + '', + + '
      ' + + '
    1. foo
    2. ' + + '
    ' + ); + } ); + + it( 'model test for mixed content', () => { + test.data( + '
      ' + + '
    1. a
    2. ' + + '
    ' + + '

    xxx

    ' + + '
      ' + + '
    • b
    • ' + + '
    • c
    • ' + + '
    ' + + '

    yyy

    ' + + '
      ' + + '
    • d
    • ' + + '
    ', + + 'a' + + 'xxx' + + 'b' + + 'c' + + 'yyy' + + 'd' + ); + } ); + + it( 'blockquote inside a list item', () => { + test.data( + '
      ' + + '
    • ' + + '
      ' + + '

      foo

      ' + + '

      bar

      ' + + '
      ' + + '
    • ' + + '
    ', + + '
    ' + + 'foo' + + 'bar' + + '
    ', + + '
    ' + + '

    foo

    ' + + '

    bar

    ' + + '
    ' + ); + } ); + + it( 'code block inside a list item', () => { + test.data( + '
      ' + + '
    • ' + + '
      abc
      ' + + '
    • ' + + '
    ', + + '' + + 'abc' + + '' + ); + } ); + + it( 'table inside a list item', () => { + test.data( + '
      ' + + '
    • ' + + '
      ' + + '' + + '' + + '' + + '' + + '' + + '' + + '
      foo
      ' + + '
      ' + + '
    • ' + + '
    ', + + '' + + '' + + '' + + 'foo' + + '' + + '' + + '
    ', + + '
    ' + + '' + + '' + + '' + + '' + + '' + + '' + + '
    foo
    ' + + '
    ' + ); + } ); + + describe( 'auto-paragraphing', () => { + it( 'before and inside the list', () => { + test.data( + 'text' + + '
      ' + + '
    • foo
    • ' + + '
    ', + + 'text' + + 'foo', + + '

    text

    ' + + '
      ' + + '
    • foo
    • ' + + '
    ' + ); + } ); + + it( 'before the list', () => { + test.data( + 'text' + + '
      ' + + '
    • foo

    • ' + + '
    ', + + 'text' + + 'foo', + + '

    text

    ' + + '
      ' + + '
    • foo
    • ' + + '
    ' + ); + } ); + + it( 'after and inside the list', () => { + test.data( + '
      ' + + '
    • foo
    • ' + + '
    ' + + 'text', + + 'foo' + + 'text', + + '
      ' + + '
    • foo
    • ' + + '
    ' + + '

    text

    ' + ); + } ); + + it( 'after the list', () => { + test.data( + '
      ' + + '
    • foo

    • ' + + '
    ' + + 'text', + + 'foo' + + 'text', + + '
      ' + + '
    • foo
    • ' + + '
    ' + + '

    text

    ' + ); + } ); + + it( 'inside the list', () => { + test.data( + '

    text

    ' + + '
      ' + + '
    • foo
    • ' + + '
    ', + + 'text' + + 'foo', + + '

    text

    ' + + '
      ' + + '
    • foo
    • ' + + '
    ' + ); + } ); + + it( 'inside the list with multiple blocks', () => { + test.data( + '
      ' + + '
    • ' + + 'foo' + + '

      bar

      ' + + 'baz' + + '
    • ' + + '
    ', + + 'foo' + + 'bar' + + 'baz', + + '
      ' + + '
    • foo
    • ' + + '
    • bar
    • ' + + '
    • baz
    • ' + + '
    ' + ); + } ); + } ); + + describe( 'block elements inside list items', () => { + describe( 'single block', () => { + it( 'single item', () => { + test.data( + '
    • Foo

    ', + 'Foo', + '
    • Foo
    ' + ); + } ); + + it( 'multiple items', () => { + test.data( + '
      ' + + '
    • Foo

    • ' + + '
    • Bar

    • ' + + '
    ', + + 'Foo' + + 'Bar', + + '
      ' + + '
    • Foo
    • ' + + '
    • Bar
    • ' + + '
    ' + ); + } ); + + it( 'nested items', () => { + test.data( + '
      ' + + '
    • ' + + '

      Foo

      ' + + '
        ' + + '
      1. Bar

      2. ' + + '
      ' + + '
    • ' + + '
    ', + + 'Foo' + + 'Bar', + + '
      ' + + '
    • ' + + 'Foo' + + '
        ' + + '
      1. Bar
      2. ' + + '
      ' + + '
    • ' + + '
    ' + ); + } ); + } ); + + describe( 'multiple blocks', () => { + it( 'single item', () => { + test.data( + '
      ' + + '
    • ' + + '

      Foo

      ' + + '

      Bar

      ' + + '
    • ' + + '
    ', + + 'Foo' + + 'Bar', + + '

    Foo

    ' + + '
      ' + + '
    • Bar
    • ' + + '
    ' + ); + } ); + + it( 'multiple blocks in a single list item', () => { + test.data( + '
      ' + + '
    • Foo

      Bar

    • ' + + '
    • abc
    • ' + + '
    ', + + 'Foo' + + 'Bar' + + 'abc', + + '
      ' + + '
    • Foo
    • ' + + '
    • Bar
    • ' + + '
    • abc
    • ' + + '
    ' + ); + } ); + + it( 'nested list with multiple blocks', () => { + test.data( + '
      ' + + '
    1. ' + + '

      123

      ' + + '

      456

      ' + + '
        ' + + '
      • ' + + '

        Foo

        ' + + '

        Bar

        ' + + '
      • ' + + '
      ' + + '
    2. ' + + '
    ', + + '123' + + '456' + + 'Foo' + + 'Bar', + + '
      ' + + '
    1. 123
    2. ' + + '
    3. 456
    4. ' + + '
    ' + + '

    Foo

    ' + + '
      ' + + '
    • Bar
    • ' + + '
    ' + ); + } ); + + it( 'nested list with following blocks', () => { + test.data( + '
      ' + + '
    1. ' + + '

      123

      ' + + '
        ' + + '
      • ' + + '

        Foo

        ' + + '

        Bar

        ' + + '
      • ' + + '
      ' + + '

      456

      ' + + '
    2. ' + + '
    ', + + '123' + + 'Foo' + + 'Bar' + + '456', + + '
      ' + + '
    1. 123
    2. ' + + '
    ' + + '

    Foo

    ' + + '
      ' + + '
    • Bar
    • ' + + '
    ' + + '
      ' + + '
    1. 456
    2. ' + + '
    ' + ); + } ); + } ); + + describe( 'inline + block', () => { + it( 'single item', () => { + test.data( + '
      ' + + '
    • ' + + 'Foo' + + '

      Bar

      ' + + '
    • ' + + '
    ', + + 'Foo' + + 'Bar', + + '
      ' + + '
    • Foo
    • ' + + '
    • Bar
    • ' + + '
    ' + ); + } ); + + it( 'split by list items', () => { + test.data( + '
      ' + + '
    • Foo
    • ' + + '
    • Bar

    • ' + + '
    ', + + 'Foo' + + 'Bar', + + '
      ' + + '
    • Foo
    • ' + + '
    • Bar
    • ' + + '
    ' + ); + } ); + + it( 'nested split by list items', () => { + test.data( + '
      ' + + '
    • ' + + 'Foo' + + '
        ' + + '
      1. Bar

      2. ' + + '
      ' + + '
    • ' + + '
    ', + + 'Foo' + + 'Bar', + + '
      ' + + '
    • ' + + 'Foo' + + '
        ' + + '
      1. Bar
      2. ' + + '
      ' + + '
    • ' + + '
    ' + ); + } ); + } ); + + describe( 'block + inline', () => { + it( 'single item', () => { + test.data( + '
      ' + + '
    • ' + + '

      Foo

      ' + + 'Bar' + + '
    • ' + + '
    ', + + 'Foo' + + 'Bar', + + '
      ' + + '
    • Foo
    • ' + + '
    • Bar
    • ' + + '
    ' + ); + } ); + + it( 'multiple items', () => { + test.data( + '
      ' + + '
    • ' + + '

      Foo

      ' + + 'Bar' + + '
    • ' + + '
    • ' + + '

      Foz

      ' + + 'Baz' + + '
    • ' + + '
    ', + + 'Foo' + + 'Bar' + + 'Foz' + + 'Baz', + + '
      ' + + '
    • Foo
    • ' + + '
    • Bar
    • ' + + '
    • Foz
    • ' + + '
    • Baz
    • ' + + '
    ' + ); + } ); + + it( 'split by list items', () => { + test.data( + '
      ' + + '
    • ' + + '

      Bar

      ' + + '
    • Foo
    • ' + + '' + + '
    ', + + 'Bar' + + 'Foo', + + '
      ' + + '
    • Bar
    • ' + + '
    • Foo
    • ' + + '
    ' + ); + } ); + + it( 'nested split by list items', () => { + test.data( + '
      ' + + '
    • ' + + '

      Bar

      ' + + '
        ' + + '
      1. Foo
      2. ' + + '
      ' + + '
    • ' + + '
    ', + + 'Bar' + + 'Foo', + + '
      ' + + '
    • ' + + 'Bar' + + '
        ' + + '
      1. Foo
      2. ' + + '
      ' + + '
    • ' + + '
    ' + ); + } ); + } ); + + describe( 'complex', () => { + it( 'single item with inline block inline', () => { + test.data( + '
      ' + + '
    • ' + + 'Foo' + + '

      Bar

      ' + + 'Baz' + + '
    • ' + + '
    ', + + 'Foo' + + 'Bar' + + 'Baz', + + '
      ' + + '
    • Foo
    • ' + + '
    • Bar
    • ' + + '
    • Baz
    • ' + + '
    ' + ); + } ); + + it( 'single item with inline block block', () => { + test.data( + '
      ' + + '
    • ' + + 'Txt' + + '

      Foo

      ' + + '

      Bar

      ' + + '
    • ' + + '
    ', + + 'Txt' + + 'Foo' + + 'Bar', + + '
      ' + + '
    • Txt
    • ' + + '
    • Foo
    • ' + + '
    • Bar
    • ' + + '
    ' + ); + } ); + + it( 'single item with block block inline', () => { + test.data( + '
      ' + + '
    • ' + + '

      Foo

      ' + + '

      Bar

      ' + + 'Text' + + '
    • ' + + '
    ', + + 'Foo' + + 'Bar' + + 'Text', + + '
      ' + + '
    • Foo
    • ' + + '
    • Bar
    • ' + + '
    • Text
    • ' + + '
    ' + ); + } ); + + it( 'single item with block block block', () => { + test.data( + '
      ' + + '
    • ' + + '

      Foo

      ' + + '

      Bar

      ' + + '

      Baz

      ' + + '
    • ' + + '
    ', + + 'Foo' + + 'Bar' + + 'Baz', + + '
      ' + + '
    • Foo
    • ' + + '
    • Bar
    • ' + + '
    • Baz
    • ' + + '
    ' + ); + } ); + } ); + + describe( 'with block not allowed inside a list', () => { + beforeEach( () => { + model.schema.register( 'splitBlock', { allowWhere: '$block', allowContentOf: '$block', isBlock: true } ); + editor.conversion.elementToElement( { model: 'splitBlock', view: 'div' } ); + } ); + + it( 'single item with inline block inline', () => { + test.data( + '
      ' + + '
    • ' + + 'Foo' + + '
      Bar
      ' + + 'Baz' + + '
    • ' + + '
    ', + + 'Foo' + + 'Bar' + + 'Baz', + + '
      ' + + '
    • Foo
    • ' + + '
    ' + + '
    Bar
    ' + + '
      ' + + '
    • Baz
    • ' + + '
    ' + ); + } ); + } ); + + describe( 'block that are not allowed in the list item', () => { + beforeEach( () => { + model.schema.addAttributeCheck( ( context, attributeName ) => { + if ( context.endsWith( 'heading1' ) && attributeName == 'listItemId' ) { + return false; + } + } ); + } ); + + it( 'single block in list item', () => { + test.data( + '
      ' + + '
    • ' + + '

      foo

      ' + + '
    • ' + + '
    ', + + 'foo', + + '

    foo

    ' + ); + } ); + + it( 'multiple blocks in list item', () => { + test.data( + '
      ' + + '
    • ' + + '

      foo

      ' + + '

      bar

      ' + + '
    • ' + + '
    ', + + 'foo' + + 'bar', + + '

    foo

    ' + + '

    bar

    ' + ); + } ); + + it( 'multiple mixed blocks in list item (first is outside the list)', () => { + test.data( + '
      ' + + '
    • ' + + '

      foo

      ' + + '

      bar

      ' + + '
    • ' + + '
    ', + + 'foo' + + 'bar', + + '

    foo

    ' + + '
      ' + + '
    • bar
    • ' + + '
    ' + ); + } ); + + it( 'multiple mixed blocks in list item (last is outside the list)', () => { + test.data( + '
      ' + + '
    • ' + + '

      foo

      ' + + '

      bar

      ' + + '
    • ' + + '
    ', + + 'foo' + + 'bar', + + '
      ' + + '
    • foo
    • ' + + '
    ' + + '

    bar

    ' + ); + } ); + + it( 'multiple mixed blocks in list item (middle one is outside the list)', () => { + test.data( + '
      ' + + '
    • ' + + '

      foo

      ' + + '

      bar

      ' + + '

      baz

      ' + + '
    • ' + + '
    ', + + 'foo' + + 'bar' + + 'baz', + + '
      ' + + '
    • foo
    • ' + + '
    ' + + '

    bar

    ' + + '
      ' + + '
    • baz
    • ' + + '
    ' + ); + } ); + + it( 'before nested list aaa', () => { + test.data( + '
      ' + + '
    • ' + + '

      ' + + '
        ' + + '
      • x
      • ' + + '
      ' + + '
    • ' + + '
    ', + + '' + + 'x', + + '

     

    ' + + '
      ' + + '
    • x
    • ' + + '
    ' + ); + } ); + } ); + } ); + } ); + + describe( 'nested lists', () => { + describe( 'non HTML compliant list fixing', () => { + it( 'ul in ul', () => { + test.data( + '
      ' + + '
        ' + + '
      • 1.1
      • ' + + '
      ' + + '
    ', + + '1.1', + + '
      ' + + '
    • 1.1
    • ' + + '
    ' + ); + } ); + + it( 'ul in ol', () => { + test.data( + '
      ' + + '
        ' + + '
      • 1.1
      • ' + + '
      ' + + '
    ', + + '1.1', + + '
      ' + + '
    • 1.1
    • ' + + '
    ' + ); + } ); + + it( 'ul in ul (previous sibling is li)', () => { + test.data( + '
      ' + + '
    • 1
    • ' + + '
        ' + + '
      • 2.1
      • ' + + '
      ' + + '
    ', + + '1' + + '2.1', + + '
      ' + + '
    • 1' + + '
        ' + + '
      • 2.1
      • ' + + '
      ' + + '
    • ' + + '
    ' + ); + } ); + + it( 'ul in deeply nested ul - base index > 0 #1', () => { + test.data( + '
      ' + + '
    • 1.1
    • ' + + '
    • 1.2' + + '
        ' + + '
          ' + + '
            ' + + '
              ' + + '
            • 2.1
            • ' + + '
            ' + + '
          ' + + '
        ' + + '
      ' + + '
    • ' + + '
    ', + + '1.1' + + '1.2' + + '2.1', + + '
      ' + + '
    • 1.1
    • ' + + '
    • 1.2' + + '
        ' + + '
      • 2.1
      • ' + + '
      ' + + '
    • ' + + '
    ' + ); + } ); + + it( 'ul in deeply nested ul - base index > 0 #2', () => { + test.data( + '
      ' + + '
    • 1.1
    • ' + + '
    • 1.2' + + '
        ' + + '
      • 2.1
      • ' + + '
          ' + + '
            ' + + '
              ' + + '
            • 3.1
            • ' + + '
            ' + + '
          ' + + '
        ' + + '
      • 2.2
      • ' + + '
      ' + + '
    • ' + + '
    ', + + '1.1' + + '1.2' + + '2.1' + + '3.1' + + '2.2', + + '
      ' + + '
    • 1.1
    • ' + + '
    • 1.2' + + '
        ' + + '
      • 2.1' + + '
          ' + + '
        • 3.1
        • ' + + '
        ' + + '
      • ' + + '
      • 2.2
      • ' + + '
      ' + + '
    • ' + + '
    ' + ); + } ); + + it( 'ul in deeply nested ul inside li', () => { + test.data( + '
      ' + + '
    • A' + + '
        ' + + '
          ' + + '
            ' + + '
              ' + + '
            • B
            • ' + + '
            ' + + '
          ' + + '
        ' + + '
      • C
      • ' + + '
      ' + + '
    • ' + + '
    ', + + 'A' + + 'B' + + 'C', + + '
      ' + + '
    • A' + + '
        ' + + '
      • B
      • ' + + '
      • C
      • ' + + '
      ' + + '
    • ' + + '
    ' + ); + } ); + + it( 'ul in deeply nested ul/ol', () => { + test.data( + '
      ' + + '
    • A' + + '
        ' + + '
          ' + + '
            ' + + '
              ' + + '
            • B
            • ' + + '
            ' + + '
          ' + + '
        ' + + '
      1. C
      2. ' + + '
      ' + + '
    • ' + + '
    ', + + 'A' + + 'B' + + 'C', + + '
      ' + + '
    • A' + + '
        ' + + '
      • B
      • ' + + '
      ' + + '
        ' + + '
      1. C
      2. ' + + '
      ' + + '
    • ' + + '
    ' + ); + } ); + + it( 'ul in ul (complex case)', () => { + test.data( + '
      ' + + '
    1. 1
    2. ' + + '
        ' + + '
      • A
      • ' + + '
          ' + + '
        1. 1
        2. ' + + '
        ' + + '
      ' + + '
    3. 2
    4. ' + + '
    5. 3
    6. ' + + '
        ' + + '
      • A
      • ' + + '
      • B
      • ' + + '
      ' + + '
    ' + + '
      ' + + '
    • A
    • ' + + '
        ' + + '
      1. 1
      2. ' + + '
      3. 2
      4. ' + + '
      ' + + '
    ', + + '1' + + 'A' + + '1' + + '2' + + '3' + + 'A' + + 'B' + + 'A' + + '1' + + '2', + + '
      ' + + '
    1. 1' + + '
        ' + + '
      • A' + + '
          ' + + '
        1. 1
        2. ' + + '
        ' + + '
      • ' + + '
      ' + + '
    2. ' + + '
    3. 2
    4. ' + + '
    5. 3' + + '
        ' + + '
      • A
      • ' + + '
      • B
      • ' + + '
      ' + + '
    6. ' + + '
    ' + + '
      ' + + '
    • A' + + '
        ' + + '
      1. 1
      2. ' + + '
      3. 2
      4. ' + + '
      ' + + '
    • ' + + '
    ' + ); + } ); + + it( 'ol in ol (deep structure)', () => { + test.data( + '
      ' + + '
    1. A1
    2. ' + + '
        ' + + '
          ' + + '
            ' + + '
              ' + + '
                ' + + '
                  ' + + '
                    ' + + '
                  1. B8
                  2. ' + + '
                  ' + + '
                ' + + '
              ' + + '
            ' + + '
          ' + + '
        1. C3
        2. ' + + '
            ' + + '
          1. D4
          2. ' + + '
          ' + + '
        ' + + '
      1. E2
      2. ' + + '
      ' + + '
    ', + + 'A1' + + 'B8' + + 'C3' + + 'D4' + + 'E2', + + '
      ' + + '
    1. A1' + + '
        ' + + '
      1. B8
      2. ' + + '
      3. C3' + + '
          ' + + '
        1. D4
        2. ' + + '
        ' + + '
      4. ' + + '
      5. E2
      6. ' + + '
      ' + + '
    2. ' + + '
    ' + ); + } ); + + it( 'block elements wrapping nested ul', () => { + test.data( + 'text before' + + '
      ' + + '
    • ' + + 'text' + + '
      ' + + '
        ' + + '
      • inner
      • ' + + '
      ' + + '
      ' + + '
    • ' + + '
    ', + + 'text before' + + 'text' + + 'inner', + + '

    text before

    ' + + '
      ' + + '
    • ' + + 'text' + + '
        ' + + '
      • inner
      • ' + + '
      ' + + '
    • ' + + '
    ' + ); + } ); + } ); + + it( 'bullet list simple structure', () => { + test.data( + '

    foo

    ' + + '
      ' + + '
    • ' + + '1' + + '
        ' + + '
      • 1.1
      • ' + + '
      ' + + '
    • ' + + '
    ' + + '

    bar

    ', + + 'foo' + + '1' + + '1.1' + + 'bar' + ); + } ); + + it( 'bullet list deep structure', () => { + test.data( + '

    foo

    ' + + '
      ' + + '
    • ' + + '1' + + '
        ' + + '
      • ' + + '1.1' + + '
        • 1.1.1
        • 1.1.2
        • 1.1.3
        • 1.1.4
        ' + + '
      • ' + + '
      • ' + + '1.2' + + '
        • 1.2.1
        ' + + '
      • ' + + '
      ' + + '
    • ' + + '
    • 2
    • ' + + '
    • ' + + '3' + + '
        ' + + '
      • ' + + '3.1' + + '
          ' + + '
        • ' + + '3.1.1' + + '
          • 3.1.1.1
          ' + + '
        • ' + + '
        • 3.1.2
        • ' + + '
        ' + + '
      • ' + + '
      ' + + '
    • ' + + '
    ' + + '

    bar

    ', + + 'foo' + + '1' + + '1.1' + + '1.1.1' + + '1.1.2' + + '1.1.3' + + '1.1.4' + + '1.2' + + '1.2.1' + + '2' + + '3' + + '3.1' + + '3.1.1' + + '3.1.1.1' + + '3.1.2' + + 'bar' + ); + } ); + + it( 'mixed lists deep structure', () => { + test.data( + '

    foo

    ' + + '
      ' + + '
    • ' + + '1' + + '
        ' + + '
      • ' + + '1.1' + + '
        • 1.1.1
        • 1.1.2
        ' + + '
        1. 1.1.3
        2. 1.1.4
        ' + + '
      • ' + + '
      • ' + + '1.2' + + '
        • 1.2.1
        ' + + '
      • ' + + '
      ' + + '
    • ' + + '
    • 2
    • ' + + '
    • ' + + '3' + + '
        ' + + '
      1. ' + + '3.1' + + '
          ' + + '
        • ' + + '3.1.1' + + '
          1. 3.1.1.1
          ' + + '
          • 3.1.1.2
          ' + + '
        • ' + + '
        • 3.1.2
        • ' + + '
        ' + + '
      2. ' + + '
      ' + + '
        ' + + '
      • 3.2
      • ' + + '
      • 3.3
      • ' + + '
      ' + + '
    • ' + + '
    ' + + '

    bar

    ', + + 'foo' + + '1' + + '1.1' + + '1.1.1' + + '1.1.2' + + '1.1.3' + + '1.1.4' + + '1.2' + + '1.2.1' + + '2' + + '3' + + '3.1' + + '3.1.1' + + '3.1.1.1' + + '3.1.1.2' + + '3.1.2' + + '3.2' + + '3.3' + + 'bar' + ); + } ); + + it( 'mixed lists deep structure, white spaces, incorrect content, empty items', () => { + test.data( + '

    foo

    ' + + '
      ' + + ' xxx' + + '
    • ' + + ' 1' + + '
        ' + + ' xxx' + + '
      • ' + + '
        • 1.1.2
        ' + + '
        1. 1.1.3
        2. 1.1.4
        ' + + '
      • ' + + '
      • ' + + '
        • 1.2.1
        ' + + '
      • ' + + ' xxx' + + '
      ' + + '
    • ' + + '
    • 2
    • ' + + '
    • ' + + '
        ' + + '

        xxx

        ' + + '
      1. ' + + ' 3.1' + // Test multiple text nodes in
      2. . + '
          ' + + '
        • ' + + ' 3.1.1' + + '
          1. 3.1.1.1
          ' + + '
          • 3.1.1.2
          ' + + '
        • ' + + '
        • 3.1.2
        • ' + + '
        ' + + '
      3. ' + + '
      ' + + '

      xxx

      ' + + '
        ' + + '
      • 3.2
      • ' + + '
      • 3.3
      • ' + + '
      ' + + '
    • ' + + '

      xxx

      ' + + '
    ' + + '

    bar

    ', + + 'foo' + + '1' + + '' + + '' + + '1.1.2' + + '1.1.3' + + '1.1.4' + + '' + + '1.2.1' + + '2' + + '' + + '3<$text bold="true">.1' + + '3.1.1' + + '3.1.1.1' + + '3.1.1.2' + + '3.1.2' + + 'xxx' + + '3.2' + + '3.3' + + 'bar', + + '

    foo

    ' + + '
      ' + + '
    • 1' + + '
        ' + + '
      •  ' + + '
          ' + + '
        •  
        • ' + + '
        • 1.1.2
        • ' + + '
        ' + + '
          ' + + '
        1. 1.1.3
        2. ' + + '
        3. 1.1.4
        4. ' + + '
        ' + + '
      • ' + + '
      •  ' + + '
          ' + + '
        • 1.2.1
        • ' + + '
        ' + + '
      • ' + + '
      ' + + '
    • ' + + '
    • 2
    • ' + + '
    •  ' + + '
        ' + + '
      1. 3.1' + + '
          ' + + '
        • 3.1.1' + + '
            ' + + '
          1. 3.1.1.1
          2. ' + + '
          ' + + '
            ' + + '
          • 3.1.1.2
          • ' + + '
          ' + + '
        • ' + + '
        • 3.1.2
        • ' + + '
        ' + + '
      2. ' + + '
      ' + + '
    • ' + + '
    • xxx' + + '
        ' + + '
      • 3.2
      • ' + + '
      • 3.3
      • ' + + '
      ' + + '
    • ' + + '
    ' + + '

    bar

    ' + ); + } ); + + it( 'blockquote with nested list inside a list item', () => { + test.data( + '
      ' + + '
    • ' + + '
      ' + + '
        ' + + '
      • foo
      • ' + + '
      • bar
      • ' + + '
      ' + + '
      ' + + '
    • ' + + '
    ', + + '
    ' + + 'foo' + + 'bar' + + '
    ', + + '
    ' + + '
      ' + + '
    • foo
    • ' + + '
    • bar
    • ' + + '
    ' + + '
    ' + ); + } ); + + describe( 'auto-paragraphing', () => { + it( 'empty outer list', () => { + test.data( + '
      ' + + '
    • ' + + '
        ' + + '
      • foo
      • ' + + '
      ' + + '
    • ' + + '
    ', + + '' + + 'foo', + + '
      ' + + '
    • ' + + ' ' + + '
        ' + + '
      • foo
      • ' + + '
      ' + + '
    • ' + + '
    ' + ); + } ); + + it( 'empty inner list', () => { + test.data( + '
      ' + + '
    • foo' + + '
        ' + + '
      • ' + + '
      ' + + '
    • ' + + '
    ', + + 'foo' + + '', + + '
      ' + + '
    • ' + + 'foo' + + '
        ' + + '
      •  
      • ' + + '
      ' + + '
    • ' + + '
    ' + ); + } ); + + it( 'empty inner and outer list', () => { + test.data( + 'foo' + + '
      ' + + '
    • ' + + '
        ' + + '
      • ' + + '
      ' + + '
    • ' + + '
    ', + + 'foo' + + '' + + '', + + '

    foo

    ' + + '
      ' + + '
    • ' + + ' ' + + '
        ' + + '
      •  
      • ' + + '
      ' + + '
    • ' + + '
    ' + ); + } ); + + it( 'multiple blocks', () => { + test.data( + 'a' + + '
      ' + + '
    • ' + + 'b' + + '
        ' + + '
      • ' + + 'c' + + '
      • ' + + '
      ' + + 'd' + + '
    • ' + + '
    ' + + 'e', + + 'a' + + 'b' + + 'c' + + 'd' + + 'e', + + '

    a

    ' + + '
      ' + + '
    • ' + + 'b' + + '
        ' + + '
      • c
      • ' + + '
      ' + + '
    • ' + + '
    • d
    • ' + + '
    ' + + '

    e

    ' + ); + } ); + } ); + + describe( 'model tests for nested lists', () => { + it( 'should properly set listIndent and listType', () => { + //
      in the middle will be fixed by postfixer to bulleted list. + test.data( + '

      foo

      ' + + '
        ' + + '
      • ' + + '1' + + '
          ' + + '
        • 1.1
        • ' + + '
        ' + + '
          ' + + '
        1. ' + + '1.2' + + '
            ' + + '
          1. 1.2.1
          2. ' + + '
          ' + + '
        2. ' + + '
        3. 1.3
        4. ' + + '
        ' + + '
      • ' + + '
      • 2
      • ' + + '
      ' + + '

      bar

      ', + + 'foo' + + '1' + + '1.1' + + '1.2' + + '1.2.1' + + '1.3' + + '2' + + 'bar', + + '

      foo

      ' + + '
        ' + + '
      • ' + + '1' + + '
          ' + + '
        • 1.1
        • ' + + '
        ' + + '
          ' + + '
        1. ' + + '1.2' + + '
            ' + + '
          1. 1.2.1
          2. ' + + '
          ' + + '
        2. ' + + '
        3. 1.3
        4. ' + + '
        ' + + '
      • ' + + '
      • 2
      • ' + + '
      ' + + '

      bar

      ' + ); + } ); + } ); + } ); + + describe( 'list item content should be able to detect if it is inside some list item', () => { + beforeEach( () => { + model.schema.register( 'obj', { inheritAllFrom: '$inlineObject' } ); + + editor.conversion.for( 'upcast' ).add( dispatcher => { + dispatcher.on( 'element:obj', ( evt, data, conversionApi ) => { + const modelCursor = data.modelCursor; + const modelItem = modelCursor.parent; + const viewItem = data.viewItem; + + // This is the main part. + if ( !modelItem.hasAttribute( 'listType' ) ) { + return; + } + + if ( !conversionApi.consumable.consume( viewItem, { name: true } ) ) { + return; + } + + const writer = conversionApi.writer; + + writer.setAttribute( 'listType', 'todo', modelItem ); + + data.modelRange = writer.createRange( modelCursor ); + } ); + } ); + + editor.plugins.get( DocumentListEditing ).registerDowncastStrategy( { + scope: 'list', + attributeName: 'listType', + + setAttributeOnDowncast( writer, value, element ) { + if ( value === 'todo' ) { + writer.addClass( 'todo-list', element ); + } + } + } ); + + editor.conversion.elementToElement( { model: 'obj', view: 'obj' } ); + } ); + + it( 'content directly inside LI element', () => { + test.data( + '
        ' + + '
      • foo
      • ' + + '
      ' + + '

      bar

      ', + + 'foo' + + 'bar', + + '
        ' + + '
      • foo
      • ' + + '
      ' + + '

       bar

      ' + ); + } ); + + it( 'content inside a P in LI element', () => { + test.data( + '
        ' + + '
      • ' + + '' + + '

        foo

        ' + + '

        123

        ' + + '
      • ' + + '
      ' + + '

      bar

      ', + + 'foo' + + '123' + + 'bar', + + '
        ' + + '
      • foo
      • ' + + '
      • 123
      • ' + + '
      ' + + '

       bar

      ' + ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-list/tests/documentlist/documentlistcommand-single-block.js b/packages/ckeditor5-list/tests/documentlist/documentlistcommand-single-block.js new file mode 100644 index 00000000000..c0e641bafdb --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/documentlistcommand-single-block.js @@ -0,0 +1,1093 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import DocumentListCommand from '../../src/documentlist/documentlistcommand'; +import DocumentListEditing from '../../src/documentlist/documentlistediting'; + +import stubUid from './_utils/uid'; +import { modelList } from './_utils/utils'; + +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; + +describe( 'DocumentListCommand (multiBlock=false)', () => { + let editor, command, model, root, changedBlocks; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + editor = await VirtualTestEditor.create( { + plugins: [ Paragraph, DocumentListEditing ], + list: { + multiBlock: false + } + } ); + + model = editor.model; + root = model.document.getRoot(); + + stubUid(); + modelList.defaultBlock = 'listItem'; + } ); + + afterEach( async () => { + modelList.defaultBlock = 'paragraph'; + await editor.destroy(); + } ); + + describe( 'bulleted', () => { + beforeEach( () => { + command = new DocumentListCommand( editor, 'bulleted' ); + + command.on( 'afterExecute', ( evt, data ) => { + changedBlocks = data; + } ); + } ); + + afterEach( () => { + command.destroy(); + } ); + + describe( 'constructor()', () => { + it( 'should create list command with given type and value set to false', () => { + setData( model, '[]' ); + + expect( command.type ).to.equal( 'bulleted' ); + expect( command.value ).to.be.false; + } ); + } ); + + describe( 'value', () => { + it( 'should be false if first position in selection is not in a list item', () => { + setData( model, modelList( [ + '0[]', + '* 1' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be false if first position in selection is in a list item of different type', () => { + setData( model, modelList( [ + '# 0[]', + '# 1' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be false if any of selected blocks is not a list item (non-list after list)', () => { + setData( model, modelList( [ + '* [0', + '1]' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be false if any of selected blocks is not a list item (non-list before list)', () => { + setData( model, modelList( [ + '[0', + '* 1]' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be false if any of selected blocks is not a list item (non-list between lists)', () => { + setData( model, modelList( [ + '* [0', + '1', + '* 2]' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be false if any of selected blocks is not a same type list item', () => { + setData( model, modelList( [ + '* [0', + '# 1]' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be true if first position in selection is in a list item of same type', () => { + setData( model, modelList( [ + '* 0[]', + '* 1' + ] ) ); + + expect( command.value ).to.be.true; + } ); + + it( 'should be true if first position in selection is in a following block of the list item', () => { + setData( model, modelList( [ + '* 0', + ' 1[]' + ] ) ); + + expect( command.value ).to.be.true; + } ); + } ); + + describe( 'isEnabled', () => { + it( 'should be true if entire selection is in a list', () => { + setData( model, modelList( [ '* [a]' ] ) ); + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if entire selection is in a block which can be turned into a list', () => { + setData( model, '[a]' ); + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if any of the selected blocks can be converted to a list (the last element does not allow)', () => { + model.schema.register( 'block', { inheritAllFrom: '$blockObject' } ); + editor.conversion.elementToElement( { model: 'block', view: 'div' } ); + + setData( model, + '[a' + + ']' + ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if any of the selected blocks can be converted to a list (the first element does not allow)', () => { + model.schema.register( 'block', { inheritAllFrom: '$blockObject' } ); + editor.conversion.elementToElement( { model: 'block', view: 'div' } ); + + setData( model, + '[' + + 'b]' + ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false if all of the selected blocks can not be converted to a list', () => { + model.schema.register( 'block', { inheritAllFrom: '$blockObject' } ); + editor.conversion.elementToElement( { model: 'block', view: 'div' } ); + + setData( model, + '[]' + + 'b' + ); + + expect( command.isEnabled ).to.be.false; + } ); + } ); + + describe( 'execute()', () => { + it( 'should use parent batch', () => { + setData( model, '[0]' ); + + model.change( writer => { + expect( writer.batch.operations.length, 'before' ).to.equal( 0 ); + + command.execute(); + + expect( writer.batch.operations.length, 'after' ).to.be.above( 0 ); + } ); + } ); + + describe( 'options.forceValue', () => { + it( 'should force converting into the list if the `options.forceValue` is set to `true`', () => { + setData( model, modelList( [ + 'fo[]o' + ] ) ); + + command.execute( { forceValue: true } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* fo[]o {id:a00}' + ] ) ); + } ); + + it( 'should not modify list item if not needed if the list if the `options.forceValue` is set to `true`', () => { + setData( model, modelList( [ + '* fo[]o' + ] ) ); + + command.execute( { forceValue: true } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* fo[]o' + ] ) ); + } ); + + it( 'should force converting into the paragraph if the `options.forceValue` is set to `false`', () => { + setData( model, modelList( [ + '* fo[]o' + ] ) ); + + command.execute( { forceValue: false } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'fo[]o' + ] ) ); + } ); + + it( 'should not modify list item if not needed if the `options.forceValue` is set to `false`', () => { + setData( model, modelList( [ + 'fo[]o' + ] ) ); + + command.execute( { forceValue: false } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'fo[]o' + ] ) ); + } ); + } ); + + describe( 'when turning on', () => { + it( 'should turn the closest block into a list item', () => { + setData( model, 'fo[]o' ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* fo[]o {id:a00}' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ) + ] ); + } ); + + it( 'should change the type of an existing (closest) list item', () => { + setData( model, modelList( [ + '# fo[]o' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* fo[]o' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ) + ] ); + } ); + + it( 'should make a list items from multiple paragraphs', () => { + setData( model, modelList( [ + 'fo[o', + 'ba]r' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* fo[o {id:a00}', + '* ba]r {id:a01}' + ] ) ); + + expect( changedBlocks.length ).to.equal( 2 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ), + root.getChild( 1 ) + ] ); + } ); + + it( 'should make a list items from multiple paragraphs mixed with list items', () => { + setData( model, modelList( [ + 'a', + '[b', + '* c', + 'd]', + 'e' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'a', + '* [b {id:a00}', + '* c', + '* d] {id:a01}', + 'e' + ] ) ); + + expect( changedBlocks.length ).to.equal( 2 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 1 ), + root.getChild( 3 ) + ] ); + } ); + + it( 'should not change type of nested list if parent is selected', () => { + setData( model, modelList( [ + '# [a', + '# b]', + ' # c', + '# d' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* [a', + '* b]', + ' # c', + '# d' + ] ) ); + + expect( changedBlocks.length ).to.equal( 2 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ), + root.getChild( 1 ) + ] ); + } ); + + it( 'should change the type of the whole list if the selection is collapsed (bulleted lists at the boundaries)', () => { + setData( model, modelList( [ + '* a', + '# b[]', + ' # c', + '# d', + '* e' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* a', + '* b[]', + ' # c', + '* d', + '* e' + ] ) ); + + expect( changedBlocks.length ).to.equal( 2 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 1 ), + root.getChild( 3 ) + ] ); + } ); + } ); + + describe( 'when turning off', () => { + it( 'should strip the list attributes from the closest list item (single list item)', () => { + setData( model, modelList( [ + '* fo[]o' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'fo[]o' + ] ) ); + + expect( changedBlocks.length ).to.equal( 1 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ) + ] ); + } ); + + it( 'should strip the list attributes from the closest item (multiple list items, selection in first item)', () => { + setData( model, modelList( [ + '* f[]oo', + '* bar', + '* baz' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'f[]oo', + '* bar', + '* baz' + ] ) ); + + expect( changedBlocks.length ).to.equal( 1 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ) + ] ); + } ); + + it( 'should strip the list attributes from the closest item (multiple list items, selection in the middle item)', () => { + setData( model, modelList( [ + '* foo', + '* b[]ar', + '* baz' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* foo', + 'b[]ar', + '* baz' + ] ) ); + + expect( changedBlocks.length ).to.equal( 1 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 1 ) + ] ); + } ); + + it( 'should strip the list attributes from the closest item (multiple list items, selection in the last item)', () => { + setData( model, modelList( [ + '* foo', + '* bar', + '* b[]az' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* foo', + '* bar', + 'b[]az' + ] ) ); + + expect( changedBlocks.length ).to.equal( 1 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 2 ) + ] ); + } ); + + describe( 'with nested lists inside', () => { + it( 'should strip the list attributes from the closest item and decrease indent of children (first item)', () => { + setData( model, modelList( [ + '* f[]oo', + ' * bar', + ' * baz', + ' * qux' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'f[]oo', + '* bar', + '* baz', + ' * qux' + ] ) ); + + expect( changedBlocks.length ).to.equal( 4 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ), + root.getChild( 1 ), + root.getChild( 2 ), + root.getChild( 3 ) + ] ); + } ); + + it( 'should strip the list attributes from the closest item and decrease indent of children (middle item)', () => { + setData( model, modelList( [ + '* foo', + '* b[]ar', + ' * baz', + ' * qux' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* foo', + 'b[]ar', + '* baz', + ' * qux' + ] ) ); + + expect( changedBlocks.length ).to.equal( 3 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 1 ), + root.getChild( 2 ), + root.getChild( 3 ) + ] ); + } ); + + it( 'should strip the list attributes from the selected items and decrease indent of nested list', () => { + setData( model, modelList( [ + '0', + '* 1', + ' * 2', + ' * 3[]', // <- this is turned off. + ' * 4', // <- this has to become indent = 0, because it will be first item on a new list. + ' * 5', // <- this should be still be a child of item above, so indent = 1. + ' * 6', // <- this has to become indent = 0, because it should not be a child of any of items above. + ' * 7', // <- this should be still be a child of item above, so indent = 1. + ' * 8', // <- this has to become indent = 0. + ' * 9', // <- this should still be a child of item above, so indent = 1. + ' * 10', // <- this should still be a child of item above, so indent = 2. + ' * 11', // <- this should still be at the same level as item above, so indent = 2. + '* 12', // <- this and all below are left unchanged. + ' * 13', + ' * 14' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '0', + '* 1', + ' * 2', + '3[]', + '* 4', + ' * 5', + '* 6', + ' * 7', + '* 8', + ' * 9', + ' * 10', + ' * 11', + '* 12', + ' * 13', + ' * 14' + ] ) ); + + expect( changedBlocks.length ).to.equal( 9 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 3 ), + root.getChild( 4 ), + root.getChild( 5 ), + root.getChild( 6 ), + root.getChild( 7 ), + root.getChild( 8 ), + root.getChild( 9 ), + root.getChild( 10 ), + root.getChild( 11 ) + ] ); + } ); + } ); + } ); + } ); + } ); + + describe( 'numbered', () => { + beforeEach( () => { + command = new DocumentListCommand( editor, 'numbered' ); + + command.on( 'afterExecute', ( evt, data ) => { + changedBlocks = data; + } ); + } ); + + afterEach( () => { + command.destroy(); + } ); + + describe( 'constructor()', () => { + it( 'should create list command with given type and value set to false', () => { + setData( model, '[]' ); + + expect( command.type ).to.equal( 'numbered' ); + expect( command.value ).to.be.false; + } ); + } ); + + describe( 'value', () => { + it( 'should be false if first position in selection is not in a list item', () => { + setData( model, modelList( [ + '0[]', + '# 1' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be false if first position in selection is in a list item of different type', () => { + setData( model, modelList( [ + '* 0[]', + '* 1' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be false if any of selected blocks is not a list item (non-list after list)', () => { + setData( model, modelList( [ + '# [0', + '1]' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be false if any of selected blocks is not a list item (non-list before list)', () => { + setData( model, modelList( [ + '[0', + '# 1]' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be false if any of selected blocks is not a list item (non-list between lists)', () => { + setData( model, modelList( [ + '# [0', + '1', + '# 2]' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be false if any of selected blocks is not a same type list item', () => { + setData( model, modelList( [ + '# [0', + '* 1]' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be true if first position in selection is in a list item of same type', () => { + setData( model, modelList( [ + '# 0[]', + '# 1' + ] ) ); + + expect( command.value ).to.be.true; + } ); + + it( 'should be true if first position in selection is in a following block of the list item', () => { + setData( model, modelList( [ + '# 0', + ' 1[]' + ] ) ); + + expect( command.value ).to.be.true; + } ); + } ); + + describe( 'isEnabled', () => { + it( 'should be true if entire selection is in a list', () => { + setData( model, modelList( [ '# [a]' ] ) ); + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if entire selection is in a block which can be turned into a list', () => { + setData( model, '[a]' ); + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if any of the selected blocks can be converted into a list (the last element does not allow)', () => { + model.schema.register( 'block', { inheritAllFrom: '$blockObject' } ); + editor.conversion.elementToElement( { model: 'block', view: 'div' } ); + + setData( model, + '[a' + + ']' + ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if any of the selected blocks acan be converted into a list (the first element does not allow)', () => { + model.schema.register( 'block', { inheritAllFrom: '$blockObject' } ); + editor.conversion.elementToElement( { model: 'block', view: 'div' } ); + + setData( model, + '[' + + 'b]' + ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false if all of the selected blocks do not allow converting to a list', () => { + model.schema.register( 'block', { inheritAllFrom: '$blockObject' } ); + editor.conversion.elementToElement( { model: 'block', view: 'div' } ); + + setData( model, + '[]' + + 'b' + ); + + expect( command.isEnabled ).to.be.false; + } ); + } ); + + describe( 'execute()', () => { + it( 'should use parent batch', () => { + setData( model, '[0]' ); + + model.change( writer => { + expect( writer.batch.operations.length, 'before' ).to.equal( 0 ); + + command.execute(); + + expect( writer.batch.operations.length, 'after' ).to.be.above( 0 ); + } ); + } ); + + describe( 'options.forceValue', () => { + it( 'should force converting into the list if the `options.forceValue` is set to `true`', () => { + setData( model, modelList( [ + 'fo[]o' + ] ) ); + + command.execute( { forceValue: true } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# fo[]o {id:a00}' + ] ) ); + } ); + + it( 'should not modify list item if not needed if the list if the `options.forceValue` is set to `true`', () => { + setData( model, modelList( [ + '# fo[]o' + ] ) ); + + command.execute( { forceValue: true } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# fo[]o' + ] ) ); + } ); + + it( 'should force converting into the paragraph if the `options.forceValue` is set to `false`', () => { + setData( model, modelList( [ + '# fo[]o' + ] ) ); + + command.execute( { forceValue: false } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'fo[]o' + ] ) ); + } ); + + it( 'should not modify list item if not needed if the `options.forceValue` is set to `false`', () => { + setData( model, modelList( [ + 'fo[]o' + ] ) ); + + command.execute( { forceValue: false } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'fo[]o' + ] ) ); + } ); + } ); + + describe( 'when turning on', () => { + it( 'should turn the closest block into a list item', () => { + setData( model, 'fo[]o' ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# fo[]o {id:a00}' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ) + ] ); + } ); + + it( 'should change the type of an existing (closest) list item', () => { + setData( model, modelList( [ + '* fo[]o' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# fo[]o' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ) + ] ); + } ); + + it( 'should make a list items from multiple paragraphs', () => { + setData( model, modelList( [ + 'fo[o', + 'ba]r' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# fo[o {id:a00}', + '# ba]r {id:a01}' + ] ) ); + + expect( changedBlocks.length ).to.equal( 2 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ), + root.getChild( 1 ) + ] ); + } ); + + it( 'should make a list items from multiple paragraphs mixed with list items', () => { + setData( model, modelList( [ + 'a', + '[b', + '# c', + 'd]', + 'e' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'a', + '# [b {id:a00}', + '# c', + '# d] {id:a01}', + 'e' + ] ) ); + + expect( changedBlocks.length ).to.equal( 2 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 1 ), + root.getChild( 3 ) + ] ); + } ); + + it( 'should not change type of nested list if parent is selected', () => { + setData( model, modelList( [ + '* [a', + '* b]', + ' * c', + '* d' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# [a', + '# b]', + ' * c', + '* d' + ] ) ); + + expect( changedBlocks.length ).to.equal( 2 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ), + root.getChild( 1 ) + ] ); + } ); + + it( 'should change the type of the whole list if the selection is collapsed (bulleted lists at the boundaries)', () => { + setData( model, modelList( [ + '# a', + '* b[]', + ' * c', + '* d', + '# e' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# a', + '# b[]', + ' * c', + '# d', + '# e' + ] ) ); + + expect( changedBlocks.length ).to.equal( 2 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 1 ), + root.getChild( 3 ) + ] ); + } ); + } ); + + describe( 'when turning off', () => { + it( 'should strip the list attributes from the closest list item (single list item)', () => { + setData( model, modelList( [ + '# fo[]o' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'fo[]o' + ] ) ); + + expect( changedBlocks.length ).to.equal( 1 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ) + ] ); + } ); + + it( 'should strip the list attributes from the closest item (multiple list items, selection in first item)', () => { + setData( model, modelList( [ + '# f[]oo', + '# bar', + '# baz' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'f[]oo', + '# bar', + '# baz' + ] ) ); + + expect( changedBlocks.length ).to.equal( 1 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ) + ] ); + } ); + + it( 'should strip the list attributes from the closest item (multiple list items, selection in the middle item)', () => { + setData( model, modelList( [ + '# foo', + '# b[]ar', + '# baz' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# foo', + 'b[]ar', + '# baz' + ] ) ); + + expect( changedBlocks.length ).to.equal( 1 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 1 ) + ] ); + } ); + + it( 'should strip the list attributes from the closest item (multiple list items, selection in the last item)', () => { + setData( model, modelList( [ + '# foo', + '# bar', + '# b[]az' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# foo', + '# bar', + 'b[]az' + ] ) ); + + expect( changedBlocks.length ).to.equal( 1 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 2 ) + ] ); + } ); + + describe( 'with nested lists inside', () => { + it( 'should strip the list attributes from the closest item and decrease indent of children (first item)', () => { + setData( model, modelList( [ + '# f[]oo', + ' # bar', + ' # baz', + ' # qux' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'f[]oo', + '# bar', + '# baz', + ' # qux' + ] ) ); + + expect( changedBlocks.length ).to.equal( 4 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ), + root.getChild( 1 ), + root.getChild( 2 ), + root.getChild( 3 ) + ] ); + } ); + + it( 'should strip the list attributes from the closest item and decrease indent of children (middle item)', () => { + setData( model, modelList( [ + '# foo', + '# b[]ar', + ' # baz', + ' # qux' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# foo', + 'b[]ar', + '# baz', + ' # qux' + ] ) ); + + expect( changedBlocks.length ).to.equal( 3 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 1 ), + root.getChild( 2 ), + root.getChild( 3 ) + ] ); + } ); + + it( 'should strip the list attributes from the selected items and decrease indent of nested list', () => { + setData( model, modelList( [ + '0', + '# 1', + ' # 2', + ' # 3[]', // <- this is turned off. + ' # 4', // <- this has to become indent = 0, because it will be first item on a new list. + ' # 5', // <- this should be still be a child of item above, so indent = 1. + ' # 6', // <- this has to become indent = 0, because it should not be a child of any of items above. + ' # 7', // <- this should be still be a child of item above, so indent = 1. + ' # 8', // <- this has to become indent = 0. + ' # 9', // <- this should still be a child of item above, so indent = 1. + ' # 10', // <- this should still be a child of item above, so indent = 2. + ' # 11', // <- this should still be at the same level as item above, so indent = 2. + '# 12', // <- this and all below are left unchanged. + ' # 13', + ' # 14' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '0', + '# 1', + ' # 2', + '3[]', + '# 4', + ' # 5', + '# 6', + ' # 7', + '# 8', + ' # 9', + ' # 10', + ' # 11', + '# 12', + ' # 13', + ' # 14' + ] ) ); + + expect( changedBlocks.length ).to.equal( 9 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 3 ), + root.getChild( 4 ), + root.getChild( 5 ), + root.getChild( 6 ), + root.getChild( 7 ), + root.getChild( 8 ), + root.getChild( 9 ), + root.getChild( 10 ), + root.getChild( 11 ) + ] ); + } ); + } ); + } ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-list/tests/documentlist/documentlistediting-single-block.js b/packages/ckeditor5-list/tests/documentlist/documentlistediting-single-block.js new file mode 100644 index 00000000000..98a21d7ec00 --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/documentlistediting-single-block.js @@ -0,0 +1,401 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import DocumentListEditing from '../../src/documentlist/documentlistediting'; + +import BoldEditing from '@ckeditor/ckeditor5-basic-styles/src/bold/boldediting'; +import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting'; +import ClipboardPipeline from '@ckeditor/ckeditor5-clipboard/src/clipboardpipeline'; +import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting'; +import HeadingEditing from '@ckeditor/ckeditor5-heading/src/headingediting'; +import IndentEditing from '@ckeditor/ckeditor5-indent/src/indentediting'; +import TableEditing from '@ckeditor/ckeditor5-table/src/tableediting'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import AlignmentEditing from '@ckeditor/ckeditor5-alignment/src/alignmentediting'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; + +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import { getData as getModelData, setData as setModelData, parse as parseModel } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; + +import DocumentListIndentCommand from '../../src/documentlist/documentlistindentcommand'; +import DocumentListSplitCommand from '../../src/documentlist/documentlistsplitcommand'; + +import stubUid from './_utils/uid'; +import { prepareTest } from './_utils/utils'; + +describe( 'DocumentListEditing (multiBlock=false)', () => { + let editor, model, view; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + editor = await VirtualTestEditor.create( { + list: { + multiBlock: false + }, + plugins: [ Paragraph, ClipboardPipeline, BoldEditing, DocumentListEditing, UndoEditing, + BlockQuoteEditing, TableEditing, HeadingEditing, AlignmentEditing ] + } ); + + model = editor.model; + view = editor.editing.view; + + model.schema.extend( 'paragraph', { + allowAttributes: 'foo' + } ); + + // Stub `view.scrollToTheSelection` as it will fail on VirtualTestEditor without DOM. + sinon.stub( view, 'scrollToTheSelection' ).callsFake( () => {} ); + stubUid(); + } ); + + afterEach( async () => { + await editor.destroy(); + } ); + + it( 'should set proper schema rules', () => { + expect( model.schema.checkAttribute( [ '$root', 'listItem' ], 'listItemId' ) ).to.be.true; + expect( model.schema.checkAttribute( [ '$root', 'listItem' ], 'listIndent' ) ).to.be.true; + expect( model.schema.checkAttribute( [ '$root', 'listItem' ], 'listType' ) ).to.be.true; + expect( model.schema.checkAttribute( [ '$root', 'paragraph' ], 'listItemId' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'paragraph' ], 'listIndent' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'paragraph' ], 'listType' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'heading1' ], 'listItemId' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'heading1' ], 'listIndent' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'heading1' ], 'listType' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'blockQuote' ], 'listItemId' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'blockQuote' ], 'listIndent' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'blockQuote' ], 'listType' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'table' ], 'listItemId' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'table' ], 'listIndent' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'table' ], 'listType' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'tableCell' ], 'listItemId' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'tableCell' ], 'listIndent' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'tableCell' ], 'listType' ) ).to.be.false; + } ); + + describe( 'commands', () => { + it( 'should register indent list command', () => { + const command = editor.commands.get( 'indentList' ); + + expect( command ).to.be.instanceOf( DocumentListIndentCommand ); + } ); + + it( 'should register outdent list command', () => { + const command = editor.commands.get( 'outdentList' ); + + expect( command ).to.be.instanceOf( DocumentListIndentCommand ); + } ); + + it( 'should register the splitListItemBefore command', () => { + const command = editor.commands.get( 'splitListItemBefore' ); + + expect( command ).to.be.instanceOf( DocumentListSplitCommand ); + } ); + + it( 'should register the splitListItemAfter command', () => { + const command = editor.commands.get( 'splitListItemAfter' ); + + expect( command ).to.be.instanceOf( DocumentListSplitCommand ); + } ); + + it( 'should add indent list command to indent command', async () => { + const editor = await VirtualTestEditor.create( { + plugins: [ Paragraph, IndentEditing, DocumentListEditing ] + } ); + + const indentListCommand = editor.commands.get( 'indentList' ); + const indentCommand = editor.commands.get( 'indent' ); + + const spy = sinon.stub( indentListCommand, 'execute' ); + + indentListCommand.isEnabled = true; + indentCommand.execute(); + + sinon.assert.calledOnce( spy ); + + await editor.destroy(); + } ); + + it( 'should add outdent list command to outdent command', async () => { + const editor = await VirtualTestEditor.create( { + plugins: [ Paragraph, IndentEditing, DocumentListEditing ] + } ); + + const outdentListCommand = editor.commands.get( 'outdentList' ); + const outdentCommand = editor.commands.get( 'outdent' ); + + const spy = sinon.stub( outdentListCommand, 'execute' ); + + outdentListCommand.isEnabled = true; + outdentCommand.execute(); + + sinon.assert.calledOnce( spy ); + + await editor.destroy(); + } ); + } ); + + describe( 'post fixer', () => { + describe( 'insert', () => { + function testList( input, inserted, output ) { + const selection = prepareTest( model, input ); + + model.change( () => { + model.change( writer => { + writer.insert( parseModel( inserted, model.schema ), selection.getFirstPosition() ); + } ); + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( output ); + } + + it( 'should make sure that all list items have a unique IDs (insert after)', () => { + testList( + 'a' + + '[]', + + 'x', + + 'a' + + 'x' + ); + } ); + + it( 'should make sure that all list items have a unique IDs (insert before)', () => { + testList( + '[]' + + 'a', + + 'x', + + 'x' + + 'a' + ); + } ); + } ); + + describe( 'rename', () => { + it( 'to element that does not allow list attributes', () => { + const modelBefore = + 'a' + + 'b' + + '[c]' + + 'd' + + 'e' + + 'f' + + 'g' + + 'h' + + 'i'; + + const expectedModel = + 'a' + + 'b' + + 'c' + + 'd' + + 'e' + + 'f' + + 'g' + + 'h' + + 'i'; + + const selection = prepareTest( model, modelBefore ); + + model.change( writer => { + writer.rename( selection.getFirstPosition().nodeAfter, 'paragraph' ); + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( expectedModel ); + } ); + } ); + + describe( 'changing list attributes', () => { + it( 'remove list attributes', () => { + const modelBefore = + 'a' + + 'b' + + '[c]' + + 'd' + + 'e' + + 'f' + + 'g' + + 'h' + + 'i'; + + const expectedModel = + 'a' + + 'b' + + 'c' + + 'd' + + 'e' + + 'f' + + 'g' + + 'h' + + 'i'; + + const selection = prepareTest( model, modelBefore ); + const element = selection.getFirstPosition().nodeAfter; + + model.change( writer => { + writer.removeAttribute( 'listItemId', element ); + writer.removeAttribute( 'listIndent', element ); + writer.removeAttribute( 'listType', element ); + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( expectedModel ); + } ); + + it( 'add list attributes', () => { + const modelBefore = + 'a' + + 'b' + + '[c]' + + 'd' + + 'e' + + 'f' + + 'g'; + + const expectedModel = + 'a' + + 'b' + + 'c' + + 'd' + + 'e' + + 'f' + + 'g'; + + const selection = prepareTest( model, modelBefore ); + const element = selection.getFirstPosition().nodeAfter; + + model.change( writer => { + writer.setAttribute( 'listItemId', 'c', element ); + writer.setAttribute( 'listIndent', 2, element ); + writer.setAttribute( 'listType', 'bulleted', element ); + writer.setAttribute( 'listIndent', 2, element.nextSibling ); + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( expectedModel ); + } ); + } ); + } ); + + describe( 'upcast', () => { + it( 'should split multi block to a separate list items', () => { + editor.setData( + '
        ' + + '
      • ' + + '

        foo

        ' + + '

        bar

        ' + + '
      • ' + + '
      ' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + 'foo' + + 'bar' + ); + } ); + + it( 'should split multi block nested list to a separate list items', () => { + editor.setData( + '
        ' + + '
      • ' + + '
          ' + + '
        • ' + + '

          foo

          ' + + '

          bar

          ' + + '
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + '' + + 'foo' + + 'bar' + ); + } ); + + it( 'should split multi block nested block to a separate list items', () => { + editor.setData( + '
        ' + + '
      • ' + + '

        foo

        ' + + '
          ' + + '
        • ' + + '

          a

          ' + + '

          b

          ' + + '
        • ' + + '
        ' + + '

        bar

        ' + + '
      • ' + + '
      ' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + 'foo' + + 'a' + + 'b' + + 'bar' + ); + } ); + } ); + + describe( 'downcast - editing', () => { + it( 'should use bogus paragraph', () => { + setModelData( model, + 'foo' + ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
        ' + + '
      • foo
      • ' + + '
      ' + ); + } ); + + it( 'should use paragraph if there are any non-list attributes on the block', () => { + setModelData( model, + 'foo' + ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
        ' + + '
      • foo

      • ' + + '
      ' + ); + } ); + + it( 'should refresh item after adding non-list attribute', () => { + setModelData( model, + 'foo' + ); + + editor.execute( 'alignment', { value: 'center' } ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
        ' + + '
      • foo

      • ' + + '
      ' + ); + } ); + + it( 'should refresh item after removing non-list attribute', () => { + setModelData( model, + 'foo' + ); + + editor.execute( 'alignment', { value: 'left' } ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
        ' + + '
      • foo
      • ' + + '
      ' + ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-list/tests/documentlist/documentlistediting.js b/packages/ckeditor5-list/tests/documentlist/documentlistediting.js index 3f6deb5903e..5c2e2a4f9dc 100644 --- a/packages/ckeditor5-list/tests/documentlist/documentlistediting.js +++ b/packages/ckeditor5-list/tests/documentlist/documentlistediting.js @@ -76,6 +76,12 @@ describe( 'DocumentListEditing', () => { expect( editor.plugins.get( DocumentListEditing ) ).to.be.instanceOf( DocumentListEditing ); } ); + it( 'should define config', () => { + expect( editor.config.get( 'list' ) ).to.deep.equal( { + multiBlock: true + } ); + } ); + it( 'should throw if loaded alongside ListEditing plugin', async () => { let caughtError; @@ -91,6 +97,9 @@ describe( 'DocumentListEditing', () => { } ); it( 'should set proper schema rules', () => { + expect( model.schema.checkAttribute( [ '$root', 'listItem' ], 'listItemId' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'listItem' ], 'listIndent' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'listItem' ], 'listType' ) ).to.be.false; expect( model.schema.checkAttribute( [ '$root', 'paragraph' ], 'listItemId' ) ).to.be.true; expect( model.schema.checkAttribute( [ '$root', 'paragraph' ], 'listIndent' ) ).to.be.true; expect( model.schema.checkAttribute( [ '$root', 'paragraph' ], 'listType' ) ).to.be.true; diff --git a/packages/ckeditor5-list/tests/documentlist/documentlistindentcommand-single-block.js b/packages/ckeditor5-list/tests/documentlist/documentlistindentcommand-single-block.js new file mode 100644 index 00000000000..96093e8781a --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/documentlistindentcommand-single-block.js @@ -0,0 +1,793 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import DocumentListEditing from '../../src/documentlist/documentlistediting'; +import DocumentListIndentCommand from '../../src/documentlist/documentlistindentcommand'; +import stubUid from './_utils/uid'; +import { modelList } from './_utils/utils'; + +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; + +describe( 'DocumentListIndentCommand (multiBlock=false)', () => { + let editor, model, root; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + editor = await VirtualTestEditor.create( { + plugins: [ DocumentListEditing, Paragraph ], + list: { + multiBlock: false + } + } ); + + model = editor.model; + root = model.document.getRoot(); + + stubUid(); + modelList.defaultBlock = 'listItem'; + } ); + + afterEach( async () => { + modelList.defaultBlock = 'paragraph'; + await editor.destroy(); + } ); + + describe( 'forward (indent)', () => { + let command; + + beforeEach( () => { + command = new DocumentListIndentCommand( editor, 'forward' ); + } ); + + afterEach( () => { + command.destroy(); + } ); + + describe( 'isEnabled', () => { + it( 'should be true if selection starts in list item', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + ' * []5', + '* 6' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false if selection starts in first list item', () => { + setData( model, modelList( [ + '* []0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + ' * 5', + '* 6' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false if selection starts in first list item at given indent', () => { + setData( model, modelList( [ + '* 0', + ' * 1', + '* 2', + ' * []3', + ' * 4' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false if selection starts in first list item (different list type)', () => { + setData( model, modelList( [ + '* 0', + ' * 1', + '# 2', + ' * 3', + '* []4' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false if selection is in first list item with different type than previous list', () => { + setData( model, modelList( [ + '* 0', + '# []1' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false if selection starts in a list item that has higher indent than it\'s previous sibling', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' * []2', + ' * 3', + ' * 4', + ' * 5', + '* 6' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false if selection starts before a list item', () => { + setData( model, modelList( [ + '[]0', + '* 1', + '* 2' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + } ); + + describe( 'execute()', () => { + it( 'should use parent batch', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + ' * []5', + '* 6' + ] ) ); + + model.change( writer => { + expect( writer.batch.operations.length ).to.equal( 0 ); + + command.execute(); + + expect( writer.batch.operations.length ).to.be.above( 0 ); + } ); + } ); + + it( 'should increment indent attribute by 1', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + ' * []5', + '* 6' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + ' * []5', + '* 6' + ] ) ); + } ); + + it( 'should increment indent of all sub-items of indented item', () => { + setData( model, modelList( [ + '* 0', + '* []1', + ' * 2', + ' * 3', + ' * 4', + ' * 5', + '* 6' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * []1', + ' * 2', + ' * 3', + ' * 4', + ' * 5', + '* 6' + ] ) ); + } ); + + describe( 'mixed list types', () => { + it( 'should not change list item type if the indented list item is the first one in the nested list (bulleted)', () => { + setData( model, modelList( [ + '* 0', + '* 1[]', + ' # 2', + '* 3' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * 1[]', + ' # 2', + '* 3' + ] ) ); + } ); + + it( 'should not change list item type if the indented list item is the first one in the nested list (numbered)', () => { + setData( model, modelList( [ + '# 0', + '# 1[]', + ' * 2', + '# 3' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# 0', + ' # 1[]', + ' * 2', + '# 3' + ] ) ); + } ); + + it( 'should adjust list type to the previous list item (numbered)', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' # 2', + '* []3' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + ' # 2', + ' # []3' + ] ) ); + } ); + + it( 'should not change list item type if the indented list item is the first one in the nested list', () => { + setData( model, modelList( [ + '* 0', + '* []1', + ' # 2', + ' * 3', + ' # 4' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * []1', + ' # 2', + ' * 3', + ' # 4' + ] ) ); + } ); + + it( 'should not change list item type if the first item in the nested list (has more items)', () => { + setData( model, modelList( [ + '* 0', + '* []1', + ' # 2', + ' * 3', + ' # 4', + '* 5', + ' # 6' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * []1', + ' # 2', + ' * 3', + ' # 4', + '* 5', + ' # 6' + ] ) ); + } ); + } ); + + describe( 'non-collapsed selection', () => { + it( 'should increment indent of all selected item when multiple items are selected', () => { + setData( model, modelList( [ + '* 0', + '* [1', + ' * 2', + ' * 3]', + ' * 4', + ' * 5', + '* 6' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * [1', + ' * 2', + ' * 3]', + ' * 4', + ' * 5', + '* 6' + ] ) ); + } ); + + describe( 'mixed list types', () => { + it( 'should not change list types for the first list items', () => { + setData( model, modelList( [ + '* 0', + '* [1', + ' # 2]', + ' * 3' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * [1', + ' # 2]', + ' * 3' + ] ) ); + } ); + + it( 'should not change list types for the first list items (with nested lists)', () => { + setData( model, modelList( [ + '* 0', + '* [1', + ' # 2]', + '* 3' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * [1', + ' # 2]', + '* 3' + ] ) ); + } ); + + it( 'should align the list type if become a part of other list (bulleted)', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' # 2', + '* [3', + '* 4]' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + ' # 2', + ' # [3', + ' # 4]' + ] ) ); + } ); + + it( 'should align the list type if become a part of other list (numbered)', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' # 2', + ' # [3', + '* 4]' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + ' # 2', + ' # [3', + ' # 4]' + ] ) ); + } ); + + it( 'should align the list type (bigger structure)', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' # 2', + ' * 3', + ' * [4', + ' # 5', + ' * 6', + ' # 7]' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + ' # 2', + ' * 3', + ' * [4', + ' * 5', + ' * 6', + ' * 7]' + ] ) ); + } ); + } ); + } ); + + it( 'should fire "afterExecute" event after finish all operations with all changed items', done => { + setData( model, modelList( [ + '* 0', + '* []1', + ' * 2', + ' * 3', + ' * 4', + ' * 5', + '* 6' + ] ) ); + + command.on( 'afterExecute', ( evt, data ) => { + expect( data ).to.deep.equal( [ + root.getChild( 1 ), + root.getChild( 2 ), + root.getChild( 3 ), + root.getChild( 4 ), + root.getChild( 5 ) + ] ); + + done(); + } ); + + command.execute(); + } ); + } ); + } ); + + describe( 'backward (outdent)', () => { + let command; + + beforeEach( () => { + command = new DocumentListIndentCommand( editor, 'backward' ); + } ); + + afterEach( () => { + command.destroy(); + } ); + + describe( 'isEnabled', () => { + it( 'should be true if selection starts in list item', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + ' * []5', + '* 6' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if selection starts in first list item', () => { + setData( model, modelList( [ + '* []0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + ' * 5', + '* 6' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if selection starts in a list item that has higher indent than it\'s previous sibling', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' * []2', + ' * 3', + ' * 4', + ' * 5', + '* 6' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false if selection starts before a list', () => { + setData( model, modelList( [ + '[0', + '* 1]', + ' * 2' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be true with selection in the middle block of a list item', () => { + setData( model, modelList( [ + '* 0', + ' []1', + ' 2' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true with selection in the last block of a list item', () => { + setData( model, modelList( [ + '* 0', + ' 1', + ' []2' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + } ); + + describe( 'execute()', () => { + it( 'should decrement indent attribute by 1 (if it is higher than 0)', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + ' * []5', + '* 6' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + '* []5', + '* 6' + ] ) ); + } ); + + it( 'should remove list attributes (if indent is less than to 0)', () => { + setData( model, modelList( [ + '* []0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + ' * 5', + '* 6' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '[]0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + ' * 5', + '* 6' + ] ) ); + } ); + + it( 'should decrement indent of all sub-items of outdented item', () => { + setData( model, modelList( [ + '* 0', + '* []1', + ' * 2', + ' * 3', + ' * 4', + ' * 5', + '* 6' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '[]1', + '* 2', + ' * 3', + ' * 4', + '* 5', + '* 6' + ] ) ); + } ); + + it( 'should outdent all selected item when multiple items are selected', () => { + setData( model, modelList( [ + '* 0', + '* [1', + ' * 2', + ' * 3]', + ' * 4', + ' * 5', + '* 6' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '[1', + '* 2', + ' * 3]', + ' * 4', + '* 5', + '* 6' + ] ) ); + } ); + + it( 'should not merge item if parent has no more following blocks', () => { + setData( model, modelList( [ + '* 0', + ' * []1', + '* 2' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* []1', + '* 2' + ] ) ); + } ); + + it( 'should handle higher indent drop between items', () => { + setData( model, modelList( [ + '* 0', + ' * 1', + ' * 2', + ' * [3', + ' * 4]', + ' * 5' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * 1', + ' * 2', + ' * [3', + '* 4]', + ' * 5' + ] ) ); + } ); + + it( 'should align a list item type after outdenting item', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' # 2[]', + '* 3' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + '* 2[]', + '* 3' + ] ) ); + } ); + + it( 'should align a list item type after outdenting the last list item', () => { + setData( model, modelList( [ + '# 0', + ' * 1', + ' * 2[]', + '# 3' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# 0', + ' * 1', + '# 2[]', + '# 3' + ] ) ); + } ); + + it( 'should align the list item type after the more indented item', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' # 2', + ' * 3', + ' # 4[]' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + ' # 2', + ' * 3', + '* 4[]' + ] ) ); + } ); + + it( 'should outdent the whole nested list (and align appropriate list item types)', () => { + setData( model, modelList( [ + '* 0', + ' # []1', + ' # 2', + ' * 3', + ' # 4', + '* 5', + ' # 6' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* []1', + ' # 2', + ' * 3', + ' # 4', + '* 5', + ' # 6' + ] ) ); + } ); + + it( 'should align list item typed after outdenting a bigger structure', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' # 2', + ' * 3', + ' # [4', + ' * 5', + ' # 6', + ' * 7]' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + ' # 2', + ' * 3', + ' * [4', + ' # 5', + ' # 6', + ' # 7]' + ] ) ); + } ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-list/tests/documentlist/integrations/clipboard-single-block.js b/packages/ckeditor5-list/tests/documentlist/integrations/clipboard-single-block.js new file mode 100644 index 00000000000..e3daf9efa8c --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/integrations/clipboard-single-block.js @@ -0,0 +1,562 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* global document */ + +import DocumentListEditing from '../../../src/documentlist/documentlistediting'; +import { isListItemBlock } from '../../../src/documentlist/utils/model'; +import { modelList } from '../_utils/utils'; + +import BoldEditing from '@ckeditor/ckeditor5-basic-styles/src/bold/boldediting'; +import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting'; +import ClipboardPipeline from '@ckeditor/ckeditor5-clipboard/src/clipboardpipeline'; +import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting'; +import HeadingEditing from '@ckeditor/ckeditor5-heading/src/headingediting'; +import TableEditing from '@ckeditor/ckeditor5-table/src/tableediting'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import ImageBlockEditing from '@ckeditor/ckeditor5-image/src/image/imageblockediting'; +import ImageInlineEditing from '@ckeditor/ckeditor5-image/src/image/imageinlineediting'; +import Widget from '@ckeditor/ckeditor5-widget/src/widget'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; + +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import { + getData as getModelData, + parse as parseModel, + setData as setModelData +} from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { parse as parseView } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; + +import stubUid from '../_utils/uid'; + +describe( 'DocumentListEditing (multiBlock=false) integrations: clipboard copy & paste', () => { + let element, editor, model, modelDoc, modelRoot, view; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + element = document.createElement( 'div' ); + document.body.appendChild( element ); + + editor = await ClassicTestEditor.create( element, { + plugins: [ + Paragraph, ClipboardPipeline, BoldEditing, DocumentListEditing, UndoEditing, + BlockQuoteEditing, TableEditing, HeadingEditing, ImageBlockEditing, ImageInlineEditing, Widget + ], + list: { + multiBlock: false + } + } ); + + model = editor.model; + modelDoc = model.document; + modelRoot = modelDoc.getRoot(); + + view = editor.editing.view; + + model.schema.extend( 'paragraph', { + allowAttributes: 'foo' + } ); + + // Stub `view.scrollToTheSelection` as it will fail on VirtualTestEditor without DOM. + sinon.stub( view, 'scrollToTheSelection' ).callsFake( () => { } ); + stubUid(); + modelList.defaultBlock = 'listItem'; + } ); + + afterEach( async () => { + element.remove(); + modelList.defaultBlock = 'paragraph'; + + await editor.destroy(); + } ); + + describe( 'copy and getSelectedContent()', () => { + it( 'should be able to downcast part of a nested list', () => { + setModelData( model, + 'A' + + '[B1' + + 'B2' + + 'C1]' + + 'C2' + ); + + const modelFragment = model.getSelectedContent( model.document.selection ); + const viewFragment = editor.data.toView( modelFragment ); + const data = editor.data.htmlProcessor.toData( viewFragment ); + + expect( data ).to.equal( + '
        ' + + '
      • B1
      • ' + + '
      • B2' + + '
          ' + + '
        • C1
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'should be able to downcast part of a deep nested list', () => { + setModelData( model, + 'A' + + 'B1' + + 'B2' + + '[C1' + + 'C2]' + ); + + const modelFragment = model.getSelectedContent( model.document.selection ); + const viewFragment = editor.data.toView( modelFragment ); + const data = editor.data.htmlProcessor.toData( viewFragment ); + + expect( data ).to.equal( + '
        ' + + '
      • C1
      • ' + + '
      • C2
      • ' + + '
      ' + ); + } ); + + describe( 'UX enhancements', () => { + describe( 'preserving list structure when a cross-list item selection existed', () => { + it( 'should return a list structure, if more than a single list item was selected', () => { + setModelData( model, modelList( [ + '* Fo[o', + '* Ba]r' + ] ) ); + + const modelFragment = model.getSelectedContent( model.document.selection ); + + expect( modelFragment.childCount ).to.equal( 2 ); + expect( Array.from( modelFragment.getChildren() ).every( isListItemBlock ) ).to.be.true; + } ); + + it( 'should return a list structure, if a nested items were included in the selection', () => { + setModelData( model, modelList( [ + '* Fo[o', + ' Bar', + ' * B]az' + ] ) ); + + const modelFragment = model.getSelectedContent( model.document.selection ); + + expect( modelFragment.childCount ).to.equal( 3 ); + expect( Array.from( modelFragment.getChildren() ).every( isListItemBlock ) ).to.be.true; + } ); + + // Note: This test also verifies support for arbitrary selection passed to getSelectedContent(). + it( 'should return a list structure, if multiple list items were selected from the outside', () => { + setModelData( model, modelList( [ + '* Foo', + '* Bar' + ] ) ); + + // [* Foo + // * Bar] + // + // Note: It is impossible to set a document selection like this because the postfixer will normalize it to + // * [Foo + // * Bar] + const modelFragment = model.getSelectedContent( model.createSelection( model.document.getRoot(), 'in' ) ); + + expect( modelFragment.childCount ).to.equal( 2 ); + expect( Array.from( modelFragment.getChildren() ).every( isListItemBlock ) ).to.be.true; + } ); + } ); + } ); + } ); + + describe( 'paste and insertContent() integration', () => { + it( 'should be triggered on DataController#insertContent()', () => { + setModelData( model, + 'A' + + 'B[]' + + 'C' + ); + + editor.model.insertContent( + parseModel( + 'X' + + 'Y', + model.schema + ) + ); + + expect( getModelData( model ) ).to.equalMarkup( + 'A' + + 'BX' + + 'Y[]' + + 'C' + ); + } ); + + it( 'should be triggered when selectable is passed', () => { + setModelData( model, + 'A' + + 'B[]' + + 'C' + ); + + model.insertContent( + parseModel( + 'X' + + 'Y', + model.schema + ), + model.createRange( + model.createPositionFromPath( modelRoot, [ 1, 1 ] ), + model.createPositionFromPath( modelRoot, [ 1, 1 ] ) + ) + ); + + expect( getModelData( model ) ).to.equalMarkup( + 'A' + + 'B[]X' + + 'Y' + + 'C' + ); + } ); + + // Just checking that it doesn't crash. #69 + it( 'should work if an element is passed to DataController#insertContent()', () => { + setModelData( model, + 'A' + + 'B[]' + + 'C' + ); + + model.change( writer => { + const item = writer.createElement( 'listItem', { listType: 'bulleted', listItemId: 'x', listIndent: '0' } ); + writer.insertText( 'X', item ); + + model.insertContent( item ); + } ); + + expect( getModelData( model ) ).to.equalMarkup( + 'A' + + 'BX[]' + + 'C' + ); + } ); + + // Just checking that it doesn't crash. #69 + it( 'should work if an element is passed to DataController#insertContent() - case #69', () => { + setModelData( model, + 'A' + + 'B[]' + + 'C' + ); + + model.change( writer => { + model.insertContent( writer.createText( 'X' ) ); + } ); + + expect( getModelData( model ) ).to.equalMarkup( + 'A' + + 'BX[]' + + 'C' + ); + } ); + + it( 'should fix indents of pasted list items', () => { + setModelData( model, + 'A' + + 'B[]' + + 'C' + ); + + const clipboard = editor.plugins.get( 'ClipboardPipeline' ); + + clipboard.fire( 'inputTransformation', { + content: parseView( '
      • X
        • Y
      ' ) + } ); + + expect( getModelData( model ) ).to.equalMarkup( + 'A' + + 'BX' + + 'Y[]' + + 'C' + ); + } ); + + it( 'should not fix indents of list items that are separated by non-list element', () => { + setModelData( model, + 'A' + + 'B[]' + + 'C' + ); + + const clipboard = editor.plugins.get( 'ClipboardPipeline' ); + + clipboard.fire( 'inputTransformation', { + content: parseView( '
      • W
        • X

      Y

      • Z
      ' ) + } ); + + expect( getModelData( model ) ).to.equalMarkup( + 'A' + + 'BW' + + 'X' + + 'Y' + + 'Z[]' + + 'C' + ); + } ); + + it( 'should co-work correctly with post fixer', () => { + setModelData( model, + 'A' + + 'B[]' + + 'C' + ); + + const clipboard = editor.plugins.get( 'ClipboardPipeline' ); + + clipboard.fire( 'inputTransformation', { + content: parseView( '

      X

      • Y
      ' ) + } ); + + expect( getModelData( model ) ).to.equalMarkup( + 'A' + + 'BX' + + 'Y[]' + + 'C' + ); + } ); + + it( 'should work if items are pasted between paragraph elements', () => { + // Wrap all changes in one block to avoid post-fixing the selection + // (which may be incorret) in the meantime. + model.change( () => { + setModelData( model, + 'A' + + 'B[]' + + 'C' + ); + + const clipboard = editor.plugins.get( 'ClipboardPipeline' ); + + clipboard.fire( 'inputTransformation', { + content: parseView( '
      • X
        • Y
      ' ) + } ); + } ); + + expect( getModelData( model ) ).to.equalMarkup( + 'A' + + 'B' + + 'X' + + 'Y[]' + + 'C' + ); + } ); + + it( 'should create correct model when list items are pasted in top-level list', () => { + setModelData( model, + 'A[]' + + 'B' + ); + + const clipboard = editor.plugins.get( 'ClipboardPipeline' ); + + clipboard.fire( 'inputTransformation', { + content: parseView( '
      • X
        • Y
      ' ) + } ); + + expect( getModelData( model ) ).to.equalMarkup( + 'AX' + + 'Y[]' + + 'B' + ); + } ); + + it( 'should create correct model when list items are pasted in non-list context', () => { + setModelData( model, + 'A[]' + + 'B' + ); + + const clipboard = editor.plugins.get( 'ClipboardPipeline' ); + + clipboard.fire( 'inputTransformation', { + content: parseView( '
      • X
        • Y
      ' ) + } ); + + expect( getModelData( model ) ).to.equalMarkup( + 'AX' + + 'Y[]' + + 'B' + ); + } ); + + it( 'should not crash when "empty content" is inserted', () => { + setModelData( model, '[]' ); + + expect( () => { + model.change( writer => { + editor.model.insertContent( writer.createDocumentFragment() ); + } ); + } ).not.to.throw(); + } ); + + it( 'should correctly handle item that is pasted without its parent', () => { + // Wrap all changes in one block to avoid post-fixing the selection + // (which may be incorret) in the meantime. + model.change( () => { + setModelData( model, + 'Foo' + + 'A' + + 'B' + + '[]' + + 'Bar' + ); + + const clipboard = editor.plugins.get( 'ClipboardPipeline' ); + + clipboard.fire( 'inputTransformation', { + content: parseView( '
    1. X
    2. ' ) + } ); + } ); + + expect( getModelData( model ) ).to.equalMarkup( + 'Foo' + + 'A' + + 'B' + + 'X[]' + + 'Bar' + ); + } ); + + it( 'should correctly handle item that is pasted without its parent #2', () => { + // Wrap all changes in one block to avoid post-fixing the selection + // (which may be incorret) in the meantime. + model.change( () => { + setModelData( model, + 'Foo' + + 'A' + + 'B' + + '[]' + + 'Bar' + ); + + const clipboard = editor.plugins.get( 'ClipboardPipeline' ); + + clipboard.fire( 'inputTransformation', { + content: parseView( '
    3. X
      • Y
    4. ' ) + } ); + } ); + + expect( getModelData( model ) ).to.equalMarkup( + 'Foo' + + 'A' + + 'B' + + 'X' + + 'Y[]' + + 'Bar' + ); + } ); + + it( 'should handle block elements inside pasted list #1', () => { + setModelData( model, + 'A' + + 'B[]' + + 'C' + ); + + const clipboard = editor.plugins.get( 'ClipboardPipeline' ); + + clipboard.fire( 'inputTransformation', { + content: parseView( '
      • W
        • X

          Y

          Z
      ' ) + } ); + + expect( getModelData( model ) ).to.equalMarkup( + 'A' + + 'BW' + + 'X' + + 'Y' + + 'Z[]' + + 'C' + ); + } ); + + it( 'should handle block elements inside pasted list #2', () => { + setModelData( model, + 'A[]' + + 'B' + + 'C' + ); + + const clipboard = editor.plugins.get( 'ClipboardPipeline' ); + + clipboard.fire( 'inputTransformation', { + content: parseView( '
      • W
        • X

          Y

          Z
      ' ) + } ); + + expect( getModelData( model ) ).to.equalMarkup( + 'AW' + + 'X' + + 'Y' + + 'Z[]' + + 'B' + + 'C' + ); + } ); + + it( 'should handle block elements inside pasted list #3', () => { + setModelData( model, + 'A[]' + + 'B' + + 'C' + ); + + const clipboard = editor.plugins.get( 'ClipboardPipeline' ); + + clipboard.fire( 'inputTransformation', { + content: parseView( '
      • W

        X

        Y

      • Z
      ' ) + } ); + + expect( getModelData( model ) ).to.equalMarkup( + 'AW' + + 'X' + + 'Y' + + 'Z[]' + + 'B' + + 'C' + ); + } ); + + it( 'should properly handle split of list items with non-standard converters', () => { + setModelData( model, + 'A[]' + + 'B' + + 'C' + ); + + editor.model.schema.register( 'splitBlock', { allowWhere: '$block' } ); + + editor.conversion.for( 'downcast' ).elementToElement( { model: 'splitBlock', view: 'splitBlock' } ); + editor.conversion.for( 'upcast' ).add( dispatcher => dispatcher.on( 'element:splitBlock', ( evt, data, conversionApi ) => { + const splitBlock = conversionApi.writer.createElement( 'splitBlock' ); + + conversionApi.consumable.consume( data.viewItem, { name: true } ); + conversionApi.safeInsert( splitBlock, data.modelCursor ); + conversionApi.updateConversionResult( splitBlock, data ); + } ) ); + + const clipboard = editor.plugins.get( 'ClipboardPipeline' ); + + clipboard.fire( 'inputTransformation', { + content: parseView( '
      • ab
      ' ) + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + 'Aa' + + '' + + 'b' + + 'B' + + 'C' + ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-list/tests/documentlist/integrations/delete-single-block.js b/packages/ckeditor5-list/tests/documentlist/integrations/delete-single-block.js new file mode 100644 index 00000000000..f8b2dff6a15 --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/integrations/delete-single-block.js @@ -0,0 +1,2711 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* global document */ + +import DocumentListEditing from '../../../src/documentlist/documentlistediting'; + +import Delete from '@ckeditor/ckeditor5-typing/src/delete'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import Widget from '@ckeditor/ckeditor5-widget/src/widget'; +import { toWidget } from '@ckeditor/ckeditor5-widget/src/utils'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; + +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import { + getData as getModelData, + setData as setModelData +} from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { DomEventData } from '@ckeditor/ckeditor5-engine'; + +import stubUid from '../_utils/uid'; +import { modelList } from '../_utils/utils'; +import BubblingEventInfo from '@ckeditor/ckeditor5-engine/src/view/observer/bubblingeventinfo'; + +describe( 'DocumentListEditing (multiBlock=false) integrations: backspace & delete', () => { + const blocksChangedByCommands = []; + + let element; + let editor, model, view; + let eventInfo, domEventData; + let splitAfterCommand, outdentCommand, + commandSpies, + splitAfterCommandExecuteSpy, outdentCommandExecuteSpy; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + element = document.createElement( 'div' ); + document.body.appendChild( element ); + + editor = await ClassicTestEditor.create( element, { + plugins: [ + DocumentListEditing, Paragraph, Delete, Widget + ], + list: { + multiBlock: false + } + } ); + + model = editor.model; + view = editor.editing.view; + + model.schema.extend( 'paragraph', { + allowAttributes: 'foo' + } ); + + model.schema.register( 'blockWidget', { + isObject: true, + isBlock: true, + allowIn: '$root', + allowAttributesOf: '$container' + } ); + + editor.conversion.for( 'downcast' ).elementToElement( { + model: 'blockWidget', + view: ( modelItem, { writer } ) => { + return toWidget( writer.createContainerElement( 'blockwidget', { class: 'block-widget' } ), writer ); + } + } ); + + editor.model.schema.register( 'inlineWidget', { + isObject: true, + isInline: true, + allowWhere: '$text', + allowAttributesOf: '$text' + } ); + + // The view element has no children. + editor.conversion.for( 'downcast' ).elementToElement( { + model: 'inlineWidget', + view: ( modelItem, { writer } ) => toWidget( + writer.createContainerElement( 'inlinewidget', { class: 'inline-widget' } ), writer, { label: 'inline widget' } + ) + } ); + + stubUid(); + modelList.defaultBlock = 'listItem'; + + eventInfo = new BubblingEventInfo( view.document, 'delete' ); + + splitAfterCommand = editor.commands.get( 'splitListItemAfter' ); + outdentCommand = editor.commands.get( 'outdentList' ); + + splitAfterCommandExecuteSpy = sinon.spy(); + outdentCommandExecuteSpy = sinon.spy(); + + splitAfterCommand.on( 'execute', splitAfterCommandExecuteSpy ); + outdentCommand.on( 'execute', outdentCommandExecuteSpy ); + + commandSpies = { + outdent: outdentCommandExecuteSpy, + splitAfter: splitAfterCommandExecuteSpy + }; + + blocksChangedByCommands.length = 0; + + outdentCommand.on( 'afterExecute', ( evt, data ) => { + blocksChangedByCommands.push( ...data ); + } ); + + splitAfterCommand.on( 'afterExecute', ( evt, data ) => { + blocksChangedByCommands.push( ...data ); + } ); + } ); + + afterEach( async () => { + element.remove(); + modelList.defaultBlock = 'paragraph'; + + await editor.destroy(); + } ); + + describe( 'backspace (backward)', () => { + beforeEach( () => { + domEventData = new DomEventData( view, { + preventDefault: sinon.spy() + }, { + direction: 'backward', + unit: 'codePoint', + sequence: 1 + } ); + } ); + + describe( 'single block list item', () => { + it( 'should not engage when the selection is in the middle of a text', () => { + runTest( { + input: [ + '* a[]b' + ], + expected: [ + '* []b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + describe( 'collapsed selection at the beginning of a list item', () => { + describe( 'item before is empty', () => { + it( 'should remove list when in empty only element of a list', () => { + runTest( { + input: [ + '* []' + ], + expected: [ + '[]' + ], + eventStopped: true, + executedCommands: { + outdent: 1, + splitAfter: 0 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge non empty list item with with previous list item as a block', () => { + runTest( { + input: [ + '* ', + '* []b' + ], + expected: [ + '* []b {id:001}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge empty list item with with previous empty list item', () => { + runTest( { + input: [ + '* ', + '* []' + ], + expected: [ + '* []' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge indented list item with with previous empty list item', () => { + runTest( { + input: [ + '* ', + ' * []a' + ], + expected: [ + '* []a {id:001}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge indented empty list item with with previous empty list item', () => { + runTest( { + input: [ + '* ', + ' * []' + ], + expected: [ + '* []' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge list item with with previous indented empty list item', () => { + runTest( { + input: [ + '* ', + ' * ', + '* []a' + ], + expected: [ + '* ', + '* []a{id:002}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge empty list item with with previous indented empty list item', () => { + runTest( { + input: [ + '* ', + ' * ', + '* []' + ], + expected: [ + '* ', + ' * []' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + + describe( 'item before is not empty', () => { + it( 'should merge non empty list item with with previous list item as a block', () => { + runTest( { + input: [ + '* a', + '* []b' + ], + expected: [ + '* a[]b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge empty list item with with previous list item as a block', () => { + runTest( { + input: [ + '* a', + '* []' + ], + expected: [ + '* a[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge indented list item with with parent list item as a block', () => { + runTest( { + input: [ + '* a', + ' * []b' + ], + expected: [ + '* a[]b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge indented empty list item with with parent list item as a block', () => { + runTest( { + input: [ + '* a', + ' * []' + ], + expected: [ + '* a[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge list item with with previous list item with higher indent as a block', () => { + runTest( { + input: [ + '* a', + ' * b', + '* []c' + ], + expected: [ + '* a', + ' * b[]c' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge empty list item with with previous list item with higher indent as a block', () => { + runTest( { + input: [ + '* a', + ' * b', + '* []' + ], + expected: [ + '* a', + ' * b[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should keep merged list item\'s children', () => { + runTest( { + input: [ + '* a', + ' * []b', + ' * c', + ' * d', + ' * e', + ' * f' + ], + expected: [ + '* a[]b', + ' * c {id:002}', + ' * d {id:003}', + ' * e {id:004}', + ' * f {id:005}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + } ); + + describe( 'collapsed selection at the end of a list item', () => { + describe( 'item after is empty', () => { + it( 'should merge non empty list item with with previous list item as a block', () => { + runTest( { + input: [ + '* ', + '* []b' + ], + expected: [ + '* []b{id:001}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + // Default behaviour of backspace? + it( 'should merge empty list item with with previous empty list item', () => { + runTest( { + input: [ + '* ', + '* []' + ], + expected: [ + '* []' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge indented list item with with previous empty list item', () => { + runTest( { + input: [ + '* ', + ' * []a' + ], + expected: [ + '* []a {id:001}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge indented empty list item with with previous empty list item', () => { + runTest( { + input: [ + '* ', + ' * []' + ], + expected: [ + '* []' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge list item with with previous indented empty list item', () => { + runTest( { + input: [ + '* ', + ' * ', + '* []a' + ], + expected: [ + '* ', + '* []a{id:002}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge empty list item with with previous indented empty list item', () => { + runTest( { + input: [ + '* ', + ' * ', + '* []' + ], + expected: [ + '* ', + ' * []' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + + describe( 'item before is not empty', () => { + it( 'should merge non empty list item with with previous list item as a block', () => { + runTest( { + input: [ + '* a', + '* []b' + ], + expected: [ + '* a[]b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge empty list item with with previous list item as a block', () => { + runTest( { + input: [ + '* a', + '* []' + ], + expected: [ + '* a[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge indented list item with with parent list item as a block', () => { + runTest( { + input: [ + '* a', + ' * []b' + ], + expected: [ + '* a[]b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge indented empty list item with with parent list item as a block', () => { + runTest( { + input: [ + '* a', + ' * []' + ], + expected: [ + '* a[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge list item with with previous list item with higher indent as a block', () => { + runTest( { + input: [ + '* a', + ' * b', + '* []c' + ], + expected: [ + '* a', + ' * b[]c' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge empty list item with with previous list item with higher indent as a block', () => { + runTest( { + input: [ + '* a', + ' * b', + '* []' + ], + expected: [ + '* a', + ' * b[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should keep merged list item\'s children', () => { + runTest( { + input: [ + '* a', + ' * []b', + ' * c', + ' * d', + ' * e', + ' * f' + ], + expected: [ + '* a[]b', + ' * c {id:002}', + ' * d {id:003}', + ' * e {id:004}', + ' * f {id:005}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + } ); + + describe( 'non-collapsed selection starting in first block of a list item', () => { + describe( 'first position in empty block', () => { + it( 'should merge two empty list items', () => { + runTest( { + input: [ + 'a', + '* [', + '* ]' + ], + expected: [ + 'a', + '* []' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge non empty list item', () => { + runTest( { + input: [ + '* [', + '* ]text' + ], + expected: [ + '* []text{id:001}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge non empty list item and delete text', () => { + runTest( { + input: [ + '* [', + '* te]xt' + ], + expected: [ + '* []xt{id:001}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge and adjust indentation of child list item when end selection is at the beginning of item', () => { + runTest( { + input: [ + '* [', + '* a', + ' * ]b' + ], + expected: [ + '* []', + ' * b {id:002}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge and adjust indentation of child list items', () => { + runTest( { + input: [ + '* [', + '* a', + ' * b]c', + ' * d' + ], + expected: [ + '* []c{id:002}', + ' * d{id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge and adjust indentation of child list items when selection at the end of an item', () => { + runTest( { + input: [ + '* [', + '* a', + ' * bc]', + ' * d' + ], + expected: [ + '* []{id:000}', + ' * d{id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should delete all items till the end of selection and merge last list item', () => { + runTest( { + input: [ + '* [', + '* a', + ' * b', + '* ]d' + ], + expected: [ + '* []', + '* d {id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should delete all items and text till the end of selection and merge last list item', () => { + runTest( { + input: [ + '* [', + '* a', + ' * b', + '* d]e' + ], + expected: [ + '* []e{id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + + describe( 'first position in non-empty block', () => { + it( 'should merge two list items', () => { + runTest( { + input: [ + '* [text', + '* ano]ther' + ], + expected: [ + '* []ther{id:001}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge two list items if selection is in the middle', () => { + runTest( { + input: [ + '* te[xt', + '* ano]ther' + ], + expected: [ + '* te[]ther' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge non empty list item', () => { + runTest( { + input: [ + '* text[', + '* ]another' + ], + expected: [ + '* text[]another' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge non empty list item and delete text', () => { + runTest( { + input: [ + '* text[', + '* ano]ther' + ], + expected: [ + '* text[]ther' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge and adjust indentation of child list item when end selection is at the beginning of item', () => { + runTest( { + input: [ + '* text[', + '* a', + ' * ]b', + ' * c' + ], + expected: [ + '* text[]', + ' * b {id:002}', + ' * c {id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge and adjust indentation of child list items', () => { + runTest( { + input: [ + '* text[', + '* a', + ' * b]c', + ' * d' + ], + expected: [ + '* text[]c', + ' * d{id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge and adjust indentation of child list items when selection at the end of an item', () => { + runTest( { + input: [ + '* text[', + '* a', + ' * bc]', + ' * d' + ], + expected: [ + '* text[]{id:000}', + ' * d{id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should delete all items till the end of selection and merge last list item', () => { + runTest( { + input: [ + '* text[', + '* a', + ' * b', + '* ]d' + ], + expected: [ + '* text[]', + '* d {id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should delete all items and text till the end of selection and merge last list item', () => { + runTest( { + input: [ + '* text[', + '* a', + ' * b', + '* d]e' + ], + expected: [ + '* text[]e' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + } ); + } ); + + describe( 'selection outside list', () => { + it( 'should not engage for a
    5. that is not a document list item', () => { + model.schema.register( 'thirdPartyListItem', { inheritAllFrom: '$block' } ); + + editor.conversion.for( 'downcast' ).elementToElement( { + model: 'thirdPartyListItem', + view: ( modelItem, { writer } ) => writer.createContainerElement( 'li' ) + } ); + + runTest( { + input: [ + 'a', + '[]b' + ], + expected: [ + 'a[]b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + describe( 'collapsed selection', () => { + it( 'no list editing commands should be executed outside list (empty paragraph)', () => { + runTest( { + input: [ + '[]' + ], + expected: [ + '[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'no list editing commands should be executed outside list (selection at the beginning of text)', () => { + runTest( { + input: [ + '[]text' + ], + expected: [ + '[]text' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'no list editing commands should be executed outside list (selection at the end of text)', () => { + runTest( { + input: [ + 'text[]' + ], + expected: [ + 'tex[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'no list editing commands should be executed outside list (selection in the middle of text)', () => { + runTest( { + input: [ + 'te[]xt' + ], + expected: [ + 't[]xt' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'no list editing commands should be executed next to a list', () => { + runTest( { + input: [ + '1[]', + '* 2' + ], + expected: [ + '[]', + '* 2' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'no list editing commands should be executed when merging two lists', () => { + runTest( { + input: [ + '* 1', + '[]2', + '* 3' + ], + expected: [ + '* 1[]2', + '* 3 {id:002}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'no list editing commands should be executed when merging two lists - one nested', () => { + runTest( { + input: [ + '* 1', + '[]2', + '* 3', + ' * 4' + ], + expected: [ + '* 1[]2', + '* 3 {id:002}', + ' * 4 {id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'empty list should be deleted', () => { + runTest( { + input: [ + '* ', + '[]2', + '* 3' + ], + expected: [ + '[]2', + '* 3 {id:002}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + + describe( 'non-collapsed selection', () => { + describe( 'outside list', () => { + it( 'no list editing commands should be executed', () => { + runTest( { + input: [ + 't[ex]t' + ], + expected: [ + 't[]t' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'no list editing commands should be executed when outside list when next to a list', () => { + runTest( { + input: [ + 't[ex]t', + '* 1' + ], + expected: [ + 't[]t', + '* 1' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + + describe( 'only start in a list', () => { + it( 'no list editing commands should be executed when doing delete', () => { + runTest( { + input: [ + '* te[xt', + 'aa]' + ], + expected: [ + '* te[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + + describe( 'only end in a list', () => { + it( 'should delete everything till end of selection', () => { + runTest( { + input: [ + '[', + '* te]xt' + ], + expected: [ + '* []xt {id:001}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should delete everything till the end of selection and adjust remaining item list indentation', () => { + runTest( { + input: [ + 'a[', + '* b]b', + ' * c' + ], + expected: [ + 'a[]b', + '* c {id:002}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + + describe( 'spanning multiple lists', () => { + it( 'should merge lists into one with one list item', () => { + runTest( { + input: [ + '* a[a', + 'b', + '* c]c' + ], + expected: [ + '* a[]c' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge two lists into one with two list items', () => { + runTest( { + input: [ + '* a[', + 'c', + '* d]', + '* e' + ], + expected: [ + '* a[]', + '* e {id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge two lists into one with deeper indendation', () => { + runTest( { + input: [ + '* a', + ' * b[', + 'c', + '* d', + ' * e', + ' * f]f', + ' * g' + ], + expected: [ + '* a', + ' * b[]f', + ' * g {id:006}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge two lists into one and keep items after selection', () => { + runTest( { + input: [ + '* a[', + 'c', + '* d', + ' * e]e', + '* f' + ], + expected: [ + '* a[]e', + '* f {id:004}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge lists of different types to a single list and keep item lists types', () => { + runTest( { + input: [ + '* a', + '* b[b', + 'c', + '# d]d', + '# d' + ], + expected: [ + '* a', + '* b[]d', + '# d {id:004}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge lists of mixed types to a single list and keep item lists types', () => { + runTest( { + input: [ + '* a', + '# b[b', + 'c', + '# d]d', + ' * f' + ], + expected: [ + '* a', + '# b[]d', + ' * f {id:004}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + } ); + } ); + } ); + + describe( 'delete (forward)', () => { + beforeEach( () => { + domEventData = new DomEventData( view, { + preventDefault: sinon.spy() + }, { + direction: 'forward', + unit: 'codePoint', + sequence: 1 + } ); + } ); + + describe( 'single block list item', () => { + it( 'should not engage when the selection is in the middle of a text', () => { + runTest( { + input: [ + '* a[]b' + ], + expected: [ + '* a[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + describe( 'collapsed selection at the end of a list item', () => { + describe( 'item after is empty', () => { + it( 'should not remove list when in empty only element of a list', () => { + runTest( { + input: [ + '* []' + ], + expected: [ + '[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should remove next empty list item', () => { + runTest( { + input: [ + '* b[]', + '* ' + ], + expected: [ + '* b[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should remove next empty list item when current is empty', () => { + runTest( { + input: [ + '* []', + '* ' + ], + expected: [ + '* []' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should remove current list item if empty and replace with indented', () => { + runTest( { + input: [ + '* []', + ' * a' + ], + expected: [ + '* []a {id:001}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should remove next empty indented item list', () => { + runTest( { + input: [ + '* []', + ' * ' + ], + expected: [ + '* []' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should replace current empty list item with next list item', () => { + runTest( { + input: [ + '* ', + ' * []', + '* a' + ], + expected: [ + '* ', + '* []a{id:002}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should remove next empty list item when current is also empty', () => { + runTest( { + input: [ + '* ', + ' * []', + '* ' + ], + expected: [ + '* ', + ' * []' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + + describe( 'next list item is not empty', () => { + it( 'should merge text from next list item with current list item text', () => { + runTest( { + input: [ + '* a[]', + '* b' + ], + expected: [ + '* a[]b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should delete next empty item list', () => { + runTest( { + input: [ + '* a[]', + '* ' + ], + expected: [ + '* a[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge text of indented list item with current list item', () => { + runTest( { + input: [ + '* a[]', + ' * b' + ], + expected: [ + '* a[]b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should remove indented empty list item', () => { + runTest( { + input: [ + '* a[]', + ' * ' + ], + expected: [ + '* a[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge text of lower indent list item', () => { + runTest( { + input: [ + '* a', + ' * b[]', + '* c' + ], + expected: [ + '* a', + ' * b[]c' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should delete next empty list item with lower ident', () => { + runTest( { + input: [ + '* a', + ' * b[]', + '* ' + ], + expected: [ + '* a', + ' * b[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge following item list of first block and adjust it\'s children', () => { + runTest( { + input: [ + '* a[]', + ' * b', + ' * c', + ' * d', + ' * e', + ' * f' + ], + expected: [ + '* a[]b', + ' * c {id:002}', + ' * d {id:003}', + ' * e {id:004}', + ' * f {id:005}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + } ); + + describe( 'non-collapsed selection starting in first block of a list item', () => { + describe( 'first position in empty block', () => { + it( 'should merge two empty list items', () => { + runTest( { + input: [ + 'a', + '* [', + '* ]' + ], + expected: [ + 'a', + '* []' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge non empty list item', () => { + runTest( { + input: [ + '* [', + '* ]text' + ], + expected: [ + '* []text{id:001}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge non empty list item and delete text', () => { + runTest( { + input: [ + '* [', + '* te]xt' + ], + expected: [ + '* []xt{id:001}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge and adjust indentation of child list item when end selection is at the beginning of item', () => { + runTest( { + input: [ + '* [', + '* a', + ' * ]b' + ], + expected: [ + '* []', + ' * b {id:002}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge and adjust indentation of child list items', () => { + runTest( { + input: [ + '* [', + '* a', + ' * b]c', + ' * d' + ], + expected: [ + '* []c{id:002}', + ' * d{id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge and adjust indentation of child list items when selection at the end of an item', () => { + runTest( { + input: [ + '* [', + '* a', + ' * bc]', + ' * d' + ], + expected: [ + '* []{id:000}', + ' * d{id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should delete all items till the end of selection and merge last list item', () => { + runTest( { + input: [ + '* [', + '* a', + ' * b', + '* ]d' + ], + expected: [ + '* []', + '* d {id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should delete all items and text till the end of selection and merge last list item', () => { + runTest( { + input: [ + '* [', + '* a', + ' * b', + '* d]e' + ], + expected: [ + '* []e{id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + + describe( 'first position in non-empty block', () => { + it( 'should merge two list items', () => { + runTest( { + input: [ + '* [text', + '* ano]ther' + ], + expected: [ + '* []ther{id:001}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge two list items if selection starts in the middle of text', () => { + runTest( { + input: [ + '* te[xt', + '* ano]ther' + ], + expected: [ + '* te[]ther' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge non empty list item', () => { + runTest( { + input: [ + '* text[', + '* ]another' + ], + expected: [ + '* text[]another' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge non empty list item and delete text', () => { + runTest( { + input: [ + '* text[', + '* ano]ther' + ], + expected: [ + '* text[]ther' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge and adjust indentation of child list item when end selection is at the beginning of item', () => { + runTest( { + input: [ + '* text[', + '* a', + ' * ]b', + ' * c' + ], + expected: [ + '* text[]', + ' * b {id:002}', + ' * c {id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge and adjust indentation of child list items', () => { + runTest( { + input: [ + '* text[', + '* a', + ' * b]c', + ' * d' + ], + expected: [ + '* text[]c', + ' * d {id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge and adjust indentation of child list items when selection at the end of an item', () => { + runTest( { + input: [ + '* text[', + '* a', + ' * bc]', + ' * d' + ], + expected: [ + '* text[] {id:000}', + ' * d {id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should delete all items till the end of selection and merge last list item', () => { + runTest( { + input: [ + '* text[', + '* a', + ' * b', + '* ]d' + ], + expected: [ + '* text[]', + '* d {id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should delete all items and text till the end of selection and merge last list item', () => { + runTest( { + input: [ + '* text[', + '* a', + ' * b', + '* d]e' + ], + expected: [ + '* text[]e' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + } ); + } ); + + describe( 'selection outside list', () => { + it( 'should not engage for a
    6. that is not a document list item', () => { + model.schema.register( 'thirdPartyListItem', { inheritAllFrom: '$block' } ); + + editor.conversion.for( 'downcast' ).elementToElement( { + model: 'thirdPartyListItem', + view: ( modelItem, { writer } ) => writer.createContainerElement( 'li' ) + } ); + + runTest( { + input: [ + 'a[]', + 'b' + ], + expected: [ + 'a[]b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + describe( 'collapsed selection', () => { + it( 'no list editing commands should be executed outside list (empty paragraph)', () => { + runTest( { + input: [ + '[]' + ], + expected: [ + '[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'no list editing commands should be executed outside list (selection at the beginning of text)', () => { + runTest( { + input: [ + '[]text' + ], + expected: [ + '[]ext' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'no list editing commands should be executed outside list (selection at the end of text)', () => { + runTest( { + input: [ + 'text[]' + ], + expected: [ + 'text[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'no list editing commands should be executed outside list (selection in the middle of text)', () => { + runTest( { + input: [ + 'te[]xt' + ], + expected: [ + 'te[]t' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'no list editing commands should be executed next to a list', () => { + runTest( { + input: [ + '* 1', + '[]2' + ], + expected: [ + '* 1', + '[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'empty list should be deleted', () => { + runTest( { + input: [ + '* 1', + '2[]', + '* ' + ], + expected: [ + '* 1', + '2[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + + describe( 'non-collapsed selection', () => { + describe( 'outside list', () => { + it( 'no list editing commands should be executed', () => { + runTest( { + input: [ + 't[ex]t' + ], + expected: [ + 't[]t' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'no list editing commands should be executed when outside list when next to a list', () => { + runTest( { + input: [ + 't[ex]t', + '* 1' + ], + expected: [ + 't[]t', + '* 1' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + + describe( 'only start in a list', () => { + it( 'no list editing commands should be executed when doing delete', () => { + runTest( { + input: [ + '* te[xt', + 'aa]' + ], + expected: [ + '* te[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + + describe( 'only end in a list', () => { + it( 'should delete everything till end of selection', () => { + runTest( { + input: [ + '[', + '* te]xt' + ], + expected: [ + '* []xt {id:001}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should delete everything till the end of selection and adjust remaining block to item list', () => { + runTest( { + input: [ + 'a[', + '* b]b', + ' c' + ], + expected: [ + 'a[]b', + '* c {id:a00}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should delete everything till the end of selection and adjust remaining item list indentation', () => { + runTest( { + input: [ + 'a[', + '* b]b', + ' * c' + ], + expected: [ + 'a[]b', + '* c {id:002}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + + describe( 'spanning multiple lists', () => { + it( 'should merge lists into one with one list item', () => { + runTest( { + input: [ + '* a[a', + 'b', + '* c]c' + ], + expected: [ + '* a[]c' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge two lists into one with two list items', () => { + runTest( { + input: [ + '* a[', + 'c', + '* d]', + '* e' + ], + expected: [ + '* a[]', + '* e {id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge two lists into one with deeper indendation', () => { + runTest( { + input: [ + '* a', + ' * b[', + 'c', + '* d', + ' * e', + ' * f]f', + ' * g' + ], + expected: [ + '* a', + ' * b[]f', + ' * g {id:006}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge lists of different types to a single list and keep item lists types', () => { + runTest( { + input: [ + '* a', + '* b[b', + 'c', + '# d]d', + '# d' + ], + expected: [ + '* a', + '* b[]d', + '# d {id:004}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge lists of mixed types to a single list and keep item lists types', () => { + runTest( { + input: [ + '* a', + '# b[b', + 'c', + '# d]d', + ' * f' + ], + expected: [ + '* a', + '# b[]d', + ' * f {id:004}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + } ); + } ); + } ); + + // @param {Iterable.} input + // @param {Iterable.} expected + // @param {Boolean|Object.} eventStopped Boolean when preventDefault() and stop() were called/not called together. + // Object, when mixed behavior was expected. + // @param {Object.} executedCommands Numbers of command executions. + // @param {Array.} changedBlocks Indexes of changed blocks. + function runTest( { input, expected, eventStopped, executedCommands = {}, changedBlocks = [] } ) { + setModelData( model, modelList( input ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( expected ) ); + + if ( typeof eventStopped === 'object' ) { + expect( domEventData.domEvent.preventDefault.called ).to.equal( eventStopped.preventDefault, 'preventDefault() call' ); + expect( !!eventInfo.stop.called ).to.equal( eventStopped.stop, 'eventInfo.stop() call' ); + } else { + expect( domEventData.domEvent.preventDefault.callCount ).to.equal( eventStopped ? 1 : 0, 'preventDefault() call' ); + expect( eventInfo.stop.called ).to.equal( eventStopped ? true : undefined, 'eventInfo.stop() call' ); + } + + for ( const name in executedCommands ) { + expect( commandSpies[ name ].callCount ).to.equal( executedCommands[ name ], `${ name } command call count` ); + } + + expect( blocksChangedByCommands.map( block => block.index ) ).to.deep.equal( changedBlocks, 'changed blocks\' indexes' ); + } +} ); diff --git a/packages/ckeditor5-list/tests/documentlistproperties/documentlistpropertiesediting.js b/packages/ckeditor5-list/tests/documentlistproperties/documentlistpropertiesediting.js index 60008d1e7d3..0553872e5f4 100644 --- a/packages/ckeditor5-list/tests/documentlistproperties/documentlistpropertiesediting.js +++ b/packages/ckeditor5-list/tests/documentlistproperties/documentlistpropertiesediting.js @@ -33,12 +33,10 @@ describe( 'DocumentListPropertiesEditing', () => { } ); it( 'should have default values', () => { - expect( editor.config.get( 'list' ) ).to.deep.equal( { - properties: { - styles: true, - startIndex: false, - reversed: false - } + expect( editor.config.get( 'list.properties' ) ).to.deep.equal( { + styles: true, + startIndex: false, + reversed: false } ); } ); diff --git a/packages/ckeditor5-list/tests/manual/documentlist-simple.html b/packages/ckeditor5-list/tests/manual/documentlist-simple.html new file mode 100644 index 00000000000..903a1ba3810 --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/documentlist-simple.html @@ -0,0 +1,32 @@ +
      +

      This is a test for list feature.

      +

      Some more text for testing.

      +
        +
      • Bullet list item 1
      • +
      • Bullet list item 2
      • +
      • Bullet list item 3
      • +
      • Bullet list item 4
      • +
      • Bullet list item 5
      • +
      • Bullet list item 6
      • +
      • Bullet list item 7
      • +
      • Bullet list item 8
      • +
      +

      Paragraph.

      +

      Another testing paragraph.

      +
        +
      1. Numbered list item 1
      2. +
      +
        +
      • Another bullet list
      • +
      +
      + +

      Editor content preview

      +
      + + diff --git a/packages/ckeditor5-list/tests/manual/documentlist-simple.js b/packages/ckeditor5-list/tests/manual/documentlist-simple.js new file mode 100644 index 00000000000..a5e05d02cf5 --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/documentlist-simple.js @@ -0,0 +1,65 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals console, window, document */ + +import { ClassicEditor } from '@ckeditor/ckeditor5-editor-classic'; +import { Enter } from '@ckeditor/ckeditor5-enter'; +import { Typing } from '@ckeditor/ckeditor5-typing'; +import { Heading } from '@ckeditor/ckeditor5-heading'; +import { Paragraph } from '@ckeditor/ckeditor5-paragraph'; +import { Undo } from '@ckeditor/ckeditor5-undo'; +import { Clipboard } from '@ckeditor/ckeditor5-clipboard'; +import { Indent } from '@ckeditor/ckeditor5-indent'; +import { Alignment } from '@ckeditor/ckeditor5-alignment'; +import { SourceEditing } from '@ckeditor/ckeditor5-source-editing'; +import { GeneralHtmlSupport } from '@ckeditor/ckeditor5-html-support'; +import { Autoformat } from '@ckeditor/ckeditor5-autoformat'; + +import DocumentList from '../../src/documentlist'; +import TodoDocumentList from '../../src/tododocumentlist'; + +ClassicEditor + .create( document.querySelector( '#editor' ), { + plugins: [ + Enter, Typing, Heading, Paragraph, Undo, DocumentList, TodoDocumentList, Indent, Clipboard, Alignment, SourceEditing, + GeneralHtmlSupport, Autoformat + ], + toolbar: [ + 'heading', '|', + 'bulletedList', 'numberedList', 'todoList', '|', + 'outdent', 'indent', '|', + 'alignment', '|', + 'undo', 'redo', '|', + 'sourceEditing' + ], + list: { + multiBlock: false + }, + htmlSupport: { + allow: [ + { + name: /./, + styles: true, + attributes: true, + classes: true + } + ] + } + } ) + .then( editor => { + window.editor = editor; + + const contentPreviewBox = document.getElementById( 'preview' ); + + contentPreviewBox.innerHTML = editor.getData(); + + editor.model.document.on( 'change:data', () => { + contentPreviewBox.innerHTML = editor.getData(); + } ); + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/packages/ckeditor5-list/tests/manual/documentlist-simple.md b/packages/ckeditor5-list/tests/manual/documentlist-simple.md new file mode 100644 index 00000000000..ea316d4174d --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/documentlist-simple.md @@ -0,0 +1,9 @@ +### Loading + +1. The data should be loaded with: + * two paragraphs, + * bulleted list with eight items, + * two paragraphs, + * numbered list with one item, + * bullet list with one item. +2. Toolbar should have two buttons: for bullet and for numbered list. diff --git a/packages/ckeditor5-list/tests/manual/todo-documentlist-simple.html b/packages/ckeditor5-list/tests/manual/todo-documentlist-simple.html new file mode 100644 index 00000000000..e36503201f3 --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/todo-documentlist-simple.html @@ -0,0 +1,44 @@ +

      Editor

      +
      +

      This is a test for list feature.

      +

      Some more text for testing.

      +
        +
      • To-do list item 1
      • +
      • To-do list item 2
      • +
      • To-do list item 3
      • +
      • To-do list item 4
      • +
      • To-do list item 5
      • +
      • + To-do list item 6 +
          +
        • + +
        • +
        +
      • +
      • To-do list item 7
      • +
      • To-do list item 8
      • +
      +

      Paragraph.

      +

      Another testing paragraph.

      +
        +
      1. Numbered list item
      2. +
      +
        +
      • To-do list item
      • +
      +
        +
      • Bullet list
      • +
      +

      Checkbox in paragraph

      +
      + +

      Editor content preview

      +
      + + diff --git a/packages/ckeditor5-list/tests/manual/todo-documentlist-simple.js b/packages/ckeditor5-list/tests/manual/todo-documentlist-simple.js new file mode 100644 index 00000000000..b473ca6d253 --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/todo-documentlist-simple.js @@ -0,0 +1,100 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals console, window, document */ + +import { ClassicEditor } from '@ckeditor/ckeditor5-editor-classic'; + +import { Essentials } from '@ckeditor/ckeditor5-essentials'; +import { Autoformat } from '@ckeditor/ckeditor5-autoformat'; +import { BlockQuote } from '@ckeditor/ckeditor5-block-quote'; +import { Bold, Italic } from '@ckeditor/ckeditor5-basic-styles'; +import { Heading } from '@ckeditor/ckeditor5-heading'; +import { Link, LinkImage } from '@ckeditor/ckeditor5-link'; +import { MediaEmbed } from '@ckeditor/ckeditor5-media-embed'; +import { Paragraph } from '@ckeditor/ckeditor5-paragraph'; +import { Table, TableToolbar } from '@ckeditor/ckeditor5-table'; +import { FontSize } from '@ckeditor/ckeditor5-font'; +import { Indent } from '@ckeditor/ckeditor5-indent'; +import { SourceEditing } from '@ckeditor/ckeditor5-source-editing'; +import { GeneralHtmlSupport } from '@ckeditor/ckeditor5-html-support'; +import { Alignment } from '@ckeditor/ckeditor5-alignment'; +import { CloudServices } from '@ckeditor/ckeditor5-cloud-services'; +import { EasyImage } from '@ckeditor/ckeditor5-easy-image'; +import { Image, ImageResize, ImageInsert } from '@ckeditor/ckeditor5-image'; + +import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config'; + +import Documentlist from '../../src/documentlist'; +import TodoDocumentlist from '../../src/tododocumentlist'; + +ClassicEditor + .create( document.querySelector( '#editor' ), { + plugins: [ + Essentials, + Autoformat, + BlockQuote, + Bold, + Heading, + Italic, + Link, + MediaEmbed, + Paragraph, + Table, + TableToolbar, + FontSize, + Indent, + Documentlist, + TodoDocumentlist, + SourceEditing, + GeneralHtmlSupport, + Alignment, + Image, + CloudServices, + EasyImage, + ImageResize, + ImageInsert, + LinkImage + ], + toolbar: [ + 'heading', + '|', + 'bulletedList', 'numberedList', 'todoList', 'outdent', 'indent', + '|', + 'bold', 'link', 'fontSize', 'alignment', + '|', + 'insertTable', 'insertImage', + '|', + 'undo', 'redo', '|', 'sourceEditing' + ], + cloudServices: CS_CONFIG, + table: { + contentToolbar: [ + 'tableColumn', + 'tableRow', + 'mergeTableCells' + ] + }, + list: { + multiBlock: false + }, + htmlSupport: { + allow: [ { name: /.*/, attributes: true, classes: true, styles: true } ] + } + } ) + .then( editor => { + window.editor = editor; + + const contentPreviewBox = document.getElementById( 'preview' ); + + contentPreviewBox.innerHTML = editor.getData(); + + editor.model.document.on( 'change:data', () => { + contentPreviewBox.innerHTML = editor.getData(); + } ); + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/packages/ckeditor5-list/tests/manual/todo-documentlist-simple.md b/packages/ckeditor5-list/tests/manual/todo-documentlist-simple.md new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/ckeditor5-list/tests/tododocumentlist/tododocumentlistediting-single-block.js b/packages/ckeditor5-list/tests/tododocumentlist/tododocumentlistediting-single-block.js new file mode 100644 index 00000000000..5ac2f9c234a --- /dev/null +++ b/packages/ckeditor5-list/tests/tododocumentlist/tododocumentlistediting-single-block.js @@ -0,0 +1,749 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* global document */ + +import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting'; +import HeadingEditing from '@ckeditor/ckeditor5-heading/src/headingediting'; +import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import TableEditing from '@ckeditor/ckeditor5-table/src/tableediting'; +import GeneralHtmlSupport from '@ckeditor/ckeditor5-html-support/src/generalhtmlsupport'; +import AlignmentEditing from '@ckeditor/ckeditor5-alignment/src/alignmentediting'; + +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; + +import TodoDocumentListEditing from '../../src/tododocumentlist/tododocumentlistediting'; +import DocumentListEditing from '../../src/documentlist/documentlistediting'; +import DocumentListCommand from '../../src/documentlist/documentlistcommand'; +import CheckTodoDocumentListCommand from '../../src/tododocumentlist/checktododocumentlistcommand'; +import TodoCheckboxChangeObserver from '../../src/tododocumentlist/todocheckboxchangeobserver'; +import DocumentListPropertiesEditing from '../../src/documentlistproperties/documentlistpropertiesediting'; + +import stubUid from '../documentlist/_utils/uid'; + +describe( 'TodoDocumentListEditing (multiBlock=false)', () => { + let editor, model, view, editorElement; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + editorElement = document.createElement( 'div' ); + document.body.appendChild( editorElement ); + + editor = await ClassicTestEditor.create( editorElement, { + plugins: [ Paragraph, TodoDocumentListEditing, BlockQuoteEditing, TableEditing, HeadingEditing, AlignmentEditing ], + list: { + multiBlock: false + } + } ); + + model = editor.model; + view = editor.editing.view; + + stubUid(); + } ); + + afterEach( async () => { + editorElement.remove(); + + await editor.destroy(); + } ); + + it( 'should have pluginName', () => { + expect( TodoDocumentListEditing.pluginName ).to.equal( 'TodoDocumentListEditing' ); + } ); + + it( 'should load DocumentListEditing', () => { + expect( TodoDocumentListEditing.requires ).to.have.members( [ DocumentListEditing ] ); + } ); + + describe( 'commands', () => { + it( 'should register todoList command', () => { + const command = editor.commands.get( 'todoList' ); + + expect( command ).to.be.instanceOf( DocumentListCommand ); + expect( command ).to.have.property( 'type', 'todo' ); + } ); + + it( 'should register checkTodoList command', () => { + const command = editor.commands.get( 'checkTodoList' ); + + expect( command ).to.be.instanceOf( CheckTodoDocumentListCommand ); + } ); + } ); + + it( 'should register TodoCheckboxChangeObserver', () => { + expect( view.getObserver( TodoCheckboxChangeObserver ) ).to.be.instanceOf( TodoCheckboxChangeObserver ); + } ); + + it( 'should set proper schema rules', () => { + const paragraph = new ModelElement( 'paragraph', { listItemId: 'foo', listType: 'todo' } ); + const heading = new ModelElement( 'heading1', { listItemId: 'foo', listType: 'todo' } ); + const blockQuote = new ModelElement( 'blockQuote', { listItemId: 'foo', listType: 'todo' } ); + const table = new ModelElement( 'table', { listItemId: 'foo', listType: 'todo' }, [ ] ); + const listItem = new ModelElement( 'listItem', { listItemId: 'foo', listType: 'todo' }, [ ] ); + + expect( model.schema.checkAttribute( [ '$root', paragraph ], 'todoListChecked' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', heading ], 'todoListChecked' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', blockQuote ], 'todoListChecked' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', table ], 'todoListChecked' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', listItem ], 'todoListChecked' ) ).to.be.true; + } ); + + describe( 'upcast', () => { + it( 'should convert li with a checkbox before the first text node as a to-do list item', () => { + testUpcast( + '
      • foo
      ', + 'foo' + ); + } ); + + it( 'should convert the full markup generated by the editor', () => { + testUpcast( + '
      • foo
      ', + 'foo' + ); + + testUpcast( + editor.getData(), + 'foo' + ); + } ); + + it( 'should convert li with checked checkbox as checked to-do list item', () => { + testUpcast( + '
        ' + + '
      • a
      • ' + + '
      • b
      • ' + + '
      • c
      • ' + + '
      ', + 'a' + + 'b' + + 'c' + ); + } ); + + it( 'should not convert li with checkbox in the middle of the text', () => { + testUpcast( + '
      • FooBar
      ', + 'FooBar' + ); + } ); + + it( 'should split items with checkboxes - bulleted list', () => { + testUpcast( + '
        ' + + '
      • foo
      • ' + + '
      • bar
      • ' + + '
      • baz
      • ' + + '
      ', + 'foo' + + 'bar' + + 'baz' + ); + } ); + + it( 'should split items with checkboxes - numbered list', () => { + testUpcast( + '
        ' + + '
      1. foo
      2. ' + + '
      3. bar
      4. ' + + '
      5. baz
      6. ' + + '
      ', + 'foo' + + 'bar' + + 'baz' + ); + } ); + + it( 'should convert li with a checkbox in a nested list', () => { + testUpcast( + '
        ' + + '
      • ' + + '' + + 'foo' + + '
        • foo
        ' + + '
      • ' + + '
      ', + 'foo' + + 'foo' + ); + } ); + + it( 'should convert li with checkboxes in a nested lists (bulleted > todo > todo)', () => { + testUpcast( + '
        ' + + '
      • ' + + '
          ' + + '
        • ' + + 'foo' + + '
          • bar
          ' + + '
        • ' + + '
        ' + + '
      • ' + + '
      ', + '' + + 'foo' + + 'bar' + ); + } ); + + it( 'should convert li with a checkbox and a paragraph', () => { + testUpcast( + '
        ' + + '
      • ' + + '' + + '

        foo

        ' + + '
      • ' + + '
      ', + 'foo' + ); + } ); + + it( 'should convert li with a checkbox and two paragraphs', () => { + testUpcast( + '
        ' + + '
      • ' + + '' + + '

        foo

        ' + + '

        bar

        ' + + '
      • ' + + '
      ', + 'foo' + + 'bar' + ); + } ); + + it( 'should convert li with a checkbox and a blockquote', () => { + testUpcast( + '
        ' + + '
      • ' + + '' + + '
        foo
        ' + + '
      • ' + + '
      ', + '
      ' + + 'foo' + + '
      ' + ); + } ); + + it( 'should convert li with a checkbox and a heading', () => { + testUpcast( + '
        ' + + '
      • ' + + '' + + '

        foo

        ' + + '
      • ' + + '
      ', + 'foo' + ); + } ); + + it( 'should convert li with a checkbox and a table', () => { + testUpcast( + '
        ' + + '
      • ' + + '' + + '
        foo
        ' + + '
      • ' + + '
      ', + '' + + '' + + '' + + 'foo' + + '' + + '' + + '
      ' + ); + } ); + + it( 'should not convert checkbox if consumed by other converter', () => { + model.schema.register( 'input', { inheritAllFrom: '$inlineObject' } ); + editor.conversion.elementToElement( { model: 'input', view: 'input', converterPriority: 'high' } ); + + testUpcast( + '
        ' + + '
      • foo
      • ' + + '
      ', + 'foo' + ); + } ); + + it( 'should not convert label element if already consumed', () => { + model.schema.register( 'label', { inheritAllFrom: '$inlineObject', allowChildren: '$text' } ); + editor.conversion.elementToElement( { model: 'label', view: 'label', converterPriority: 'high' } ); + + testUpcast( + '
        ' + + '
      • ' + + '
      ', + '' + ); + } ); + } ); + + describe( 'upcast - list properties integration', () => { + let editor, model; + + beforeEach( async () => { + editor = await VirtualTestEditor.create( { + plugins: [ Paragraph, TodoDocumentListEditing, DocumentListPropertiesEditing ], + list: { + properties: { + startIndex: true + } + } + } ); + + model = editor.model; + view = editor.editing.view; + } ); + + afterEach( () => { + return editor.destroy(); + } ); + + it( 'should not convert list style on to-do list', () => { + editor.setData( + '
        ' + + '
      • Foo
      • ' + + '
      • Bar
      • ' + + '
      ' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + 'Foo' + + 'Bar' + ); + } ); + + it( 'should not convert list start on to-do list', () => { + editor.setData( + '
        ' + + '
      1. Foo
      2. ' + + '
      3. Bar
      4. ' + + '
      ' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + 'Foo' + + 'Bar' + ); + } ); + } ); + + describe( 'upcast - GHS integration', () => { + let element, editor, model; + + beforeEach( async () => { + element = document.createElement( 'div' ); + document.body.appendChild( element ); + + editor = await ClassicTestEditor.create( element, { + plugins: [ Paragraph, TodoDocumentListEditing, GeneralHtmlSupport ], + htmlSupport: { + allow: [ + { + name: /./, + styles: true, + attributes: true, + classes: true + } + ] + } + } ); + + model = editor.model; + view = editor.editing.view; + } ); + + afterEach( async () => { + element.remove(); + await editor.destroy(); + } ); + + it( 'should consume all to-do list related elements and attributes so GHS will not handle them (with description)', () => { + editor.setData( + '
        ' + + '
      • ' + + '' + + '
      • ' + + '
      ' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + '' + + 'foo' + + '' + ); + } ); + + it( 'should consume all to-do list related elements and attributes so GHS will not handle them (without description)', () => { + editor.setData( + '
        ' + + '
      • ' + + '' + + '

        foo

        ' + + '
      • ' + + '
      ' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + 'foo' + ); + } ); + + it( 'should not consume other label elements', () => { + editor.setData( '

      ' ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + '<$text htmlLabel="{}">foo' + ); + } ); + } ); + + describe( 'downcast - editing', () => { + it( 'should convert a todo list item', () => { + testEditing( + 'foo', + '
        ' + + '
      • ' + + '' + + '' + + '' + + '' + + '' + + 'foo' + + '' + + '' + + '
      • ' + + '
      ' + ); + } ); + + it( 'should convert a nested todo list item', () => { + testEditing( + 'foo' + + 'foo', + '
        ' + + '
      • ' + + '' + + '' + + '' + + '' + + '' + + 'foo' + + '' + + '' + + '
          ' + + '
        • ' + + '' + + '' + + '' + + '' + + '' + + 'foo' + + '' + + '' + + '
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'should convert to-do list item mixed with bulleted list items', () => { + testEditing( + 'foo' + + 'bar' + + 'baz', + '
        ' + + '
      • ' + + 'foo' + + '
      • ' + + '
      ' + + '
        ' + + '
      • ' + + '' + + '' + + '' + + '' + + 'bar' + + '' + + '
      • ' + + '
      ' + + '
        ' + + '
      • baz
      • ' + + '
      ' + ); + } ); + + it( 'should convert to-do list item mixed with numbered list items', () => { + testEditing( + 'foo' + + 'bar' + + 'baz', + '
        ' + + '
      1. ' + + 'foo' + + '
      2. ' + + '
      ' + + '
        ' + + '
      • ' + + '' + + '' + + '' + + '' + + 'bar' + + '' + + '
      • ' + + '
      ' + + '
        ' + + '
      1. baz
      2. ' + + '
      ' + ); + } ); + + it( 'should wrap a checkbox and first paragraph in a span with a special label class', () => { + testEditing( + 'foo' + + 'bar', + '
        ' + + '
      • ' + + '' + + '' + + '' + + '' + + '' + + 'foo' + + '' + + '' + + '

        bar

        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'should not use description span if there is an alignment set on the paragraph', () => { + setModelData( model, + 'foo' + ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
        ' + + '
      • ' + + '' + + '' + + '' + + '' + + 'foo' + + '' + + '
      • ' + + '
      ' + ); + + editor.execute( 'alignment', { value: 'right' } ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
        ' + + '
      • ' + + '' + + '' + + '' + + '' + + '' + + '

        ' + + 'foo' + + '

        ' + + '
      • ' + + '
      ' + ); + + editor.execute( 'alignment', { value: 'left' } ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
        ' + + '
      • ' + + '' + + '' + + '' + + '' + + 'foo' + + '' + + '
      • ' + + '
      ' + ); + } ); + + it( 'should use description span even if there is an selection attribute on block', () => { + setModelData( model, + '[]' + ); + + model.change( writer => writer.setSelectionAttribute( 'bold', true ) ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + '' + ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
        ' + + '
      • ' + + '' + + '' + + '' + + '' + + '' + + '' + + '
      • ' + + '
      ' + ); + } ); + } ); + + describe( 'downcast - data', () => { + it( 'should convert a todo list item', () => { + testData( + 'foo', + '
        ' + + '
      • ' + + '' + + '
      • ' + + '
      ' + ); + } ); + + it( 'should convert a nested todo list item', () => { + testData( + 'foo' + + 'foo', + '
        ' + + '
      • ' + + '' + + '
          ' + + '
        • ' + + '' + + '
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'should convert to-do list item mixed with bulleted list items', () => { + testData( + 'foo' + + 'bar' + + 'baz', + '
        ' + + '
      • foo
      • ' + + '
      ' + + '
        ' + + '
      • ' + + '' + + '
      • ' + + '
      ' + + '
        ' + + '
      • baz
      • ' + + '
      ' + ); + } ); + + it( 'should convert to-do list item mixed with numbered list items', () => { + testData( + 'foo' + + 'bar' + + 'baz', + '
        ' + + '
      1. foo
      2. ' + + '
      ' + + '
        ' + + '
      • ' + + '' + + '
      • ' + + '
      ' + + '
        ' + + '
      1. baz
      2. ' + + '
      ' + ); + } ); + + it( 'should wrap a checkbox and first paragraph in a label element', () => { + testData( + 'foo' + + 'bar', + '
        ' + + '
      • ' + + '' + + '

        bar

        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'should convert a todo list item with alignment set', () => { + testData( + 'foo', + + '
        ' + + '
      • ' + + '' + + '

        foo

        ' + + '
      • ' + + '
      ' + ); + } ); + } ); + + function testUpcast( input, output ) { + editor.setData( input ); + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( output ); + } + + function testEditing( input, output ) { + setModelData( model, input ); + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( output ); + } + + function testData( input, output ) { + setModelData( model, input ); + expect( editor.getData() ).to.equalMarkup( output ); + } +} );