diff --git a/packages/ckeditor5-list/package.json b/packages/ckeditor5-list/package.json index 6682202e3db..83612411696 100644 --- a/packages/ckeditor5-list/package.json +++ b/packages/ckeditor5-list/package.json @@ -48,6 +48,7 @@ "@ckeditor/ckeditor5-typing": "^31.1.0", "@ckeditor/ckeditor5-undo": "^31.1.0", "@ckeditor/ckeditor5-utils": "^31.1.0", + "@ckeditor/ckeditor5-widget": "^31.1.0", "webpack": "^5.58.1", "webpack-cli": "^4.9.0" }, diff --git a/packages/ckeditor5-list/src/documentlist/documentlistediting.js b/packages/ckeditor5-list/src/documentlist/documentlistediting.js index 81ba395077d..b7273691e34 100644 --- a/packages/ckeditor5-list/src/documentlist/documentlistediting.js +++ b/packages/ckeditor5-list/src/documentlist/documentlistediting.js @@ -14,6 +14,7 @@ import { CKEditorError } from 'ckeditor5/src/utils'; import DocumentListIndentCommand from './documentlistindentcommand'; import DocumentListCommand from './documentlistcommand'; +import DocumentListMergeCommand from './documentlistmergecommand'; import DocumentListSplitCommand from './documentlistsplitcommand'; import { listItemDowncastConverter, @@ -31,9 +32,11 @@ import { import { getAllListItemBlocks, isFirstBlockOfListItem, - isLastBlockOfListItem + isLastBlockOfListItem, + isSingleListItem, + getSelectedBlockObject } from './utils/model'; -import { iterateSiblingListBlocks } from './utils/listwalker'; +import ListWalker, { iterateSiblingListBlocks } from './utils/listwalker'; import '../../theme/documentlist.css'; @@ -63,8 +66,6 @@ export default class DocumentListEditing extends Plugin { init() { const editor = this.editor; const model = editor.model; - const commands = editor.commands; - const enterCommand = commands.get( 'enter' ); if ( editor.plugins.has( 'ListEditing' ) ) { /** @@ -84,8 +85,6 @@ export default class DocumentListEditing extends Plugin { model.on( 'insertContent', createModelIndentPasteFixer( model ), { priority: 'high' } ); - this._setupConversion(); - // Register commands. editor.commands.add( 'numberedList', new DocumentListCommand( editor, 'numbered' ) ); editor.commands.add( 'bulletedList', new DocumentListCommand( editor, 'bulleted' ) ); @@ -93,9 +92,153 @@ 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' ) ); + this._setupConversion(); + this._setupDeleteIntegration(); + this._setupEnterIntegration(); + } + + /** + * @inheritDoc + */ + afterInit() { + const editor = this.editor; + const commands = editor.commands; + const indent = commands.get( 'indent' ); + const outdent = commands.get( 'outdent' ); + + if ( indent ) { + indent.registerChildCommand( commands.get( 'indentList' ) ); + } + + if ( outdent ) { + outdent.registerChildCommand( commands.get( 'outdentList' ) ); + } + } + + /** + * Registers the conversion helpers for the document-list feature. + * @private + */ + _setupConversion() { + const editor = this.editor; + const model = editor.model; + const attributes = [ 'listItemId', 'listType', 'listIndent' ]; + + editor.conversion.for( 'upcast' ) + .elementToElement( { view: 'li', model: 'paragraph' } ) + .add( dispatcher => { + dispatcher.on( 'element:li', listItemUpcastConverter() ); + dispatcher.on( 'element:ul', listUpcastCleanList(), { priority: 'high' } ); + dispatcher.on( 'element:ol', listUpcastCleanList(), { priority: 'high' } ); + } ); + + editor.conversion.for( 'editingDowncast' ).add( dispatcher => downcastConverters( dispatcher ) ); + editor.conversion.for( 'dataDowncast' ).add( dispatcher => downcastConverters( dispatcher, { dataPipeline: true } ) ); + + function downcastConverters( dispatcher, options = {} ) { + dispatcher.on( 'insert:paragraph', listItemParagraphDowncastConverter( attributes, model, options ), { priority: 'high' } ); + + for ( const attributeName of attributes ) { + dispatcher.on( `attribute:${ attributeName }`, listItemDowncastConverter( attributes, model ) ); + } + } + + editor.data.mapper.registerViewToModelLength( 'li', listItemViewToModelLengthMapper( editor.data.mapper, model.schema ) ); + this.listenTo( model.document, 'change:data', reconvertItemsOnDataChange( model, editor.editing ) ); + } + + /** + * Attaches the listener to the {@link module:engine/view/document~Document#event:delete} event and handles backspace/delete + * keys in and around document lists. + * + * @private + */ + _setupDeleteIntegration() { + const editor = this.editor; + const mergeBackwardCommand = editor.commands.get( 'mergeListItemBackward' ); + const mergeForwardCommand = editor.commands.get( 'mergeListItemForward' ); + + this.listenTo( editor.editing.view.document, 'delete', ( evt, data ) => { + const selection = editor.model.document.selection; + + editor.model.change( () => { + const firstPosition = selection.getFirstPosition(); + + if ( selection.isCollapsed && data.direction == 'backward' ) { + if ( !firstPosition.isAtStart ) { + return; + } + + const positionParent = firstPosition.parent; + + if ( !positionParent.hasAttribute( 'listItemId' ) ) { + return; + } + + const previousBlock = ListWalker.first( positionParent, { sameIndent: true, sameItemType: true } ); + + // Outdent the first block of a first list item. + if ( !previousBlock && positionParent.getAttribute( 'listIndent' ) === 0 ) { + if ( !isLastBlockOfListItem( positionParent ) ) { + editor.execute( 'splitListItemAfter' ); + } + + editor.execute( 'outdentList' ); + } + // Merge block with previous one (on the block level or on the content level). + else { + if ( !mergeBackwardCommand.isEnabled ) { + return; + } + + mergeBackwardCommand.execute( { + shouldMergeOnBlocksContentLevel: shouldMergeOnBlocksContentLevel( editor.model, 'backward' ) + } ); + } + + data.preventDefault(); + evt.stop(); + } + // Non-collapsed selection or forward delete. + else { + // Collapsed selection should trigger forward merging only if at the end of a block. + if ( selection.isCollapsed && !selection.getLastPosition().isAtEnd ) { + return; + } + + if ( !mergeForwardCommand.isEnabled ) { + return; + } + + mergeForwardCommand.execute( { + shouldMergeOnBlocksContentLevel: shouldMergeOnBlocksContentLevel( editor.model, 'forward' ) + } ); + + data.preventDefault(); + evt.stop(); + } + } ); + }, { context: 'li' } ); + } + + /** + * Attaches a listener to the {@link module:engine/view/document~Document#event:enter} event and handles enter key press + * in document lists. + * + * @private + */ + _setupEnterIntegration() { + const editor = this.editor; + const model = editor.model; + const commands = editor.commands; + const enterCommand = commands.get( 'enter' ); + // Overwrite the default Enter key behavior: outdent or split the list in certain cases. this.listenTo( editor.editing.view.document, 'enter', ( evt, data ) => { const doc = model.document; @@ -160,56 +303,6 @@ export default class DocumentListEditing extends Plugin { } } ); } - - /** - * @inheritDoc - */ - afterInit() { - const editor = this.editor; - const commands = editor.commands; - const indent = commands.get( 'indent' ); - const outdent = commands.get( 'outdent' ); - - if ( indent ) { - indent.registerChildCommand( commands.get( 'indentList' ) ); - } - - if ( outdent ) { - outdent.registerChildCommand( commands.get( 'outdentList' ) ); - } - } - - /** - * Registers the conversion helpers for the document-list feature. - * @private - */ - _setupConversion() { - const editor = this.editor; - const model = editor.model; - const attributes = [ 'listItemId', 'listType', 'listIndent' ]; - - editor.conversion.for( 'upcast' ) - .elementToElement( { view: 'li', model: 'paragraph' } ) - .add( dispatcher => { - dispatcher.on( 'element:li', listItemUpcastConverter() ); - dispatcher.on( 'element:ul', listUpcastCleanList(), { priority: 'high' } ); - dispatcher.on( 'element:ol', listUpcastCleanList(), { priority: 'high' } ); - } ); - - editor.conversion.for( 'editingDowncast' ).add( dispatcher => downcastConverters( dispatcher ) ); - editor.conversion.for( 'dataDowncast' ).add( dispatcher => downcastConverters( dispatcher, { dataPipeline: true } ) ); - - function downcastConverters( dispatcher, options = {} ) { - dispatcher.on( 'insert:paragraph', listItemParagraphDowncastConverter( attributes, model, options ), { priority: 'high' } ); - - for ( const attributeName of attributes ) { - dispatcher.on( `attribute:${ attributeName }`, listItemDowncastConverter( attributes, model ) ); - } - } - - editor.data.mapper.registerViewToModelLength( 'li', listItemViewToModelLengthMapper( editor.data.mapper, model.schema ) ); - this.listenTo( model.document, 'change:data', reconvertItemsOnDataChange( model, editor.editing ) ); - } } // Post-fixer that reacts to changes on document and fixes incorrect model states (invalid `listItemId` and `listIndent` values). @@ -367,3 +460,36 @@ function createModelIndentPasteFixer( model ) { } ); }; } + +// Decides whether the merge should be accompanied by the model's `deleteContent()`, for instance, to get rid of the inline +// content in the selection or take advantage of the heuristics in `deleteContent()` that helps convert lists into paragraphs +// in certain cases. +// +// @param {module:engine/model/model~Model} model +// @param {'backward'|'forward'} direction +// @returns {Boolean} +function shouldMergeOnBlocksContentLevel( model, direction ) { + const selection = model.document.selection; + + if ( !selection.isCollapsed ) { + return !getSelectedBlockObject( model ); + } + + if ( direction === 'forward' ) { + return true; + } + + const firstPosition = selection.getFirstPosition(); + const positionParent = firstPosition.parent; + const previousSibling = positionParent.previousSibling; + + if ( model.schema.isObject( previousSibling ) ) { + return false; + } + + if ( previousSibling.isEmpty ) { + return true; + } + + return isSingleListItem( [ positionParent, previousSibling ] ); +} diff --git a/packages/ckeditor5-list/src/documentlist/documentlistindentcommand.js b/packages/ckeditor5-list/src/documentlist/documentlistindentcommand.js index 2563974b251..f2788b2d7c2 100644 --- a/packages/ckeditor5-list/src/documentlist/documentlistindentcommand.js +++ b/packages/ckeditor5-list/src/documentlist/documentlistindentcommand.js @@ -13,7 +13,7 @@ import { indentBlocks, isFirstBlockOfListItem, isSingleListItem, - outdentBlocks, + outdentBlocksWithMerge, sortBlocks, splitListItemBefore } from './utils/model'; @@ -82,7 +82,7 @@ export default class DocumentListIndentCommand extends Command { if ( this._direction == 'forward' ) { changedBlocks.push( ...indentBlocks( blocks, writer, { expand: true } ) ); } else { - changedBlocks.push( ...outdentBlocks( blocks, writer, { expand: true } ) ); + changedBlocks.push( ...outdentBlocksWithMerge( blocks, writer ) ); } } diff --git a/packages/ckeditor5-list/src/documentlist/documentlistmergecommand.js b/packages/ckeditor5-list/src/documentlist/documentlistmergecommand.js new file mode 100644 index 00000000000..76ece299ab9 --- /dev/null +++ b/packages/ckeditor5-list/src/documentlist/documentlistmergecommand.js @@ -0,0 +1,235 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module list/documentlist/documentlistmergecommand + */ + +import { Command } from 'ckeditor5/src/core'; +import { + getNestedListBlocks, + indentBlocks, + sortBlocks, + isFirstBlockOfListItem, + mergeListItemBefore, + isSingleListItem, + getSelectedBlockObject +} from './utils/model'; +import ListWalker from './utils/listwalker'; + +/** + * The document list merge command. It is used by the {@link module:list/documentlist~DocumentList list feature}. + * + * @extends module:core/command~Command + */ +export default class DocumentListMergeCommand extends Command { + /** + * Creates an instance of the command. + * + * @param {module:core/editor/editor~Editor} editor The editor instance. + * @param {'backward'|'forward'} direction Whether list item should be merged before or after the selected block. + */ + constructor( editor, direction ) { + super( editor ); + + /** + * Whether list item should be merged before or after the selected block. + * + * @readonly + * @private + * @member {'backward'|'forward'} + */ + this._direction = direction; + } + + /** + * @inheritDoc + */ + refresh() { + this.isEnabled = this._checkEnabled(); + } + + /** + * Merges list blocks together (depending on the {@link #constructor}'s `direction` parameter). + * + * @fires execute + * @fires afterExecute + * @param {Object} [options] Command options. + * @param {String|Boolean} [options.shouldMergeOnBlocksContentLevel=false] When set `true`, merging will be performed together + * with {@link module:engine/model/model~Model#deleteContent} to get rid of the inline content in the selection or take advantage + * of the heuristics in `deleteContent()` that helps convert lists into paragraphs in certain cases. + */ + execute( { shouldMergeOnBlocksContentLevel = false } = {} ) { + const model = this.editor.model; + const selection = model.document.selection; + const changedBlocks = []; + + model.change( writer => { + const { firstElement, lastElement } = this._getMergeSubjectElements( selection, shouldMergeOnBlocksContentLevel ); + + const firstIndent = firstElement.getAttribute( 'listIndent' ) || 0; + const lastIndent = lastElement.getAttribute( 'listIndent' ); + const lastElementId = lastElement.getAttribute( 'listItemId' ); + + if ( firstIndent != lastIndent ) { + const nestedLastElementBlocks = getNestedListBlocks( lastElement ); + + changedBlocks.push( ...indentBlocks( [ lastElement, ...nestedLastElementBlocks ], writer, { + indentBy: firstIndent - lastIndent, + + // If outdenting, the entire sub-tree that follows must be included. + expand: firstIndent < lastIndent + } ) ); + } + + if ( shouldMergeOnBlocksContentLevel ) { + let sel = selection; + + if ( selection.isCollapsed ) { + // TODO what if one of blocks is an object (for example a table or block image)? + sel = writer.createSelection( writer.createRange( + writer.createPositionAt( firstElement, 'end' ), + writer.createPositionAt( lastElement, 0 ) + ) ); + } + + // Delete selected content. Replace entire content only for non-collapsed selection. + model.deleteContent( sel, { doNotResetEntireContent: selection.isCollapsed } ); + + // Get the last "touched" element after deleteContent call (can't use the lastElement because + // it could get merged into the firstElement while deleting content). + const lastElementAfterDelete = sel.getLastPosition().parent; + + // Check if the element after it was in the same list item and adjust it if needed. + const nextSibling = lastElementAfterDelete.nextSibling; + + changedBlocks.push( lastElementAfterDelete ); + + if ( nextSibling && nextSibling !== lastElement && nextSibling.getAttribute( 'listItemId' ) == lastElementId ) { + changedBlocks.push( ...mergeListItemBefore( nextSibling, lastElementAfterDelete, writer ) ); + } + } else { + changedBlocks.push( ...mergeListItemBefore( lastElement, firstElement, writer ) ); + } + + this._fireAfterExecute( changedBlocks ); + } ); + } + + /** + * Fires the `afterExecute` event. + * + * @private + * @param {Array.} changedBlocks The changed list elements. + */ + _fireAfterExecute( changedBlocks ) { + /** + * Event fired by the {@link #execute} method. + * + * It allows to execute an action after executing the {@link ~DocumentListMergeCommand#execute} method, + * for example adjusting attributes of changed list items. + * + * @protected + * @event afterExecute + */ + this.fire( 'afterExecute', sortBlocks( new Set( changedBlocks ) ) ); + } + + /** + * Checks whether the command can be enabled in the current context. + * + * @private + * @returns {Boolean} Whether the command should be enabled. + */ + _checkEnabled() { + const model = this.editor.model; + const selection = model.document.selection; + const selectedBlockObject = getSelectedBlockObject( model ); + + if ( selection.isCollapsed || selectedBlockObject ) { + const positionParent = selectedBlockObject || selection.getFirstPosition().parent; + + if ( !positionParent.hasAttribute( 'listItemId' ) ) { + return false; + } + + const siblingNode = this._direction == 'backward' ? + positionParent.previousSibling : + positionParent.nextSibling; + + if ( !siblingNode ) { + return false; + } + + if ( isSingleListItem( [ positionParent, siblingNode ] ) ) { + return false; + } + } else { + const lastPosition = selection.getLastPosition(); + const firstPosition = selection.getFirstPosition(); + + // If deleting within a single block of a list item, there's no need to merge anything. + // The default delete should be executed instead. + if ( lastPosition.parent === firstPosition.parent ) { + return false; + } + + if ( !lastPosition.parent.hasAttribute( 'listItemId' ) ) { + return false; + } + } + + return true; + } + + /** + * Returns the boundary elements the merge should be executed for. These are not necessarily selection's first + * and last position parents but sometimes sibling or even further blocks depending on the context. + * + * @param {module:engine/model/selection~Selection} selection The selection the merge is executed for. + * @param {Boolean} shouldMergeOnBlocksContentLevel When `true`, merge is performed together with + * {@link module:engine/model/model~Model#deleteContent} to remove the inline content within the selection. + * @returns {Object} elements + * @returns {module:engine/model/element~Element} elements.firstElement + * @returns {module:engine/model/element~Element} elements.lastElement + */ + _getMergeSubjectElements( selection, shouldMergeOnBlocksContentLevel ) { + const model = this.editor.model; + const selectedBlockObject = getSelectedBlockObject( model ); + let firstElement, lastElement; + + if ( selection.isCollapsed || selectedBlockObject ) { + const positionParent = selectedBlockObject || selection.getFirstPosition().parent; + const isFirstBlock = isFirstBlockOfListItem( positionParent ); + + if ( this._direction == 'backward' ) { + lastElement = positionParent; + + if ( isFirstBlock && !shouldMergeOnBlocksContentLevel ) { + // For the "c" as an anchorElement: + // * a + // * b + // * [c] <-- this block should be merged with "a" + // It should find "a" element to merge with: + // * a + // * b + // c + firstElement = ListWalker.first( positionParent, { sameIndent: true, lowerIndent: true } ); + } else { + firstElement = positionParent.previousSibling; + } + } else { + // In case of the forward merge there is no case as above, just merge with next sibling. + firstElement = positionParent; + lastElement = positionParent.nextSibling; + } + } else { + firstElement = selection.getFirstPosition().parent; + lastElement = selection.getLastPosition().parent; + } + + return { firstElement, lastElement }; + } +} diff --git a/packages/ckeditor5-list/src/documentlist/utils/model.js b/packages/ckeditor5-list/src/documentlist/utils/model.js index e2562efdeb0..7339ce5c2b9 100644 --- a/packages/ckeditor5-list/src/documentlist/utils/model.js +++ b/packages/ckeditor5-list/src/documentlist/utils/model.js @@ -233,40 +233,44 @@ export function mergeListItemBefore( listBlock, parentBlock, writer ) { * @param {module:engine/model/element~Element|Iterable.} blocks The block or iterable of blocks. * @param {module:engine/model/writer~Writer} writer The model writer. * @param {Object} [options] - * @param {Boolean} [options.expand=false] Whether should expand the list of blocks to include complete list items - * (all blocks of given list items). + * @param {Boolean} [options.expand=false] Whether should expand the list of blocks to include complete list items. + * @param {Number} [options.indentBy=1] The number of levels the indentation should change (could be negative). */ -export function indentBlocks( blocks, writer, { expand } = {} ) { +export function indentBlocks( blocks, writer, { expand, indentBy = 1 } = {} ) { blocks = toArray( blocks ); // Expand the selected blocks to contain the whole list items. const allBlocks = expand ? expandListBlocksToCompleteItems( blocks ) : blocks; for ( const block of allBlocks ) { - writer.setAttribute( 'listIndent', block.getAttribute( 'listIndent' ) + 1, block ); + const blockIndent = block.getAttribute( 'listIndent' ) + indentBy; + + if ( blockIndent < 0 ) { + removeListAttributes( block, writer ); + } else { + writer.setAttribute( 'listIndent', blockIndent, block ); + } } return allBlocks; } /** - * Decreases indentation of given list blocks. + * Decreases indentation of given list of blocks. If the indentation of some blocks matches the indentation + * of surrounding blocks, they get merged together. * * @protected * @param {module:engine/model/element~Element|Iterable.} blocks The block or iterable of blocks. * @param {module:engine/model/writer~Writer} writer The model writer. - * @param {Object} [options] - * @param {Boolean} [options.expand=false] Whether should expand the list of blocks to include complete list items - * (all blocks of given list items). */ -export function outdentBlocks( blocks, writer, { expand } = {} ) { +export function outdentBlocksWithMerge( blocks, writer ) { blocks = toArray( blocks ); // Expand the selected blocks to contain the whole list items. - const allBlocks = expand ? expandListBlocksToCompleteItems( blocks ) : blocks; + const allBlocks = expandListBlocksToCompleteItems( blocks ); const visited = new Set(); - const referenceIndex = Math.min( ...allBlocks.map( block => block.getAttribute( 'listIndent' ) ) ); + const referenceIndent = Math.min( ...allBlocks.map( block => block.getAttribute( 'listIndent' ) ) ); const parentBlocks = new Map(); // Collect parent blocks before the list structure gets altered. @@ -290,7 +294,7 @@ export function outdentBlocks( blocks, writer, { expand } = {} ) { } // Merge with parent list item while outdenting and indent matches reference indent. - if ( block.getAttribute( 'listIndent' ) == referenceIndex ) { + if ( block.getAttribute( 'listIndent' ) == referenceIndent ) { const mergedBlocks = mergeListItemIfNotLast( block, parentBlocks.get( block ), writer ); // All list item blocks are updated while merging so add those to visited set. @@ -425,7 +429,7 @@ export function outdentFollowingItems( lastBlock, writer ) { break; } - // We check if that's item indent is lower as current relative indent. + // We check if that's item indent is lower than current relative indent. if ( indent < currentIndent ) { // If it is, current relative indent becomes that indent. currentIndent = indent; @@ -435,9 +439,6 @@ export function outdentFollowingItems( lastBlock, writer ) { // Note, that if we just changed the current relative indent, the newIndent will be equal to 0. const newIndent = indent - currentIndent; - // Save the entry in changes array. We do not apply it at the moment, because we will need to - // reverse the changes so the last item is changed first. - // This is to keep model in correct state all the time. writer.setAttribute( 'listIndent', newIndent, node ); changedBlocks.push( node ); } @@ -453,7 +454,31 @@ export function outdentFollowingItems( lastBlock, writer ) { * @returns {Array.} The sorted array of blocks. */ export function sortBlocks( blocks ) { - return Array.from( blocks ).sort( ( a, b ) => a.index - b.index ); + return Array.from( blocks ) + .filter( block => block.root.rootName !== '$graveyard' ) + .sort( ( a, b ) => a.index - b.index ); +} + +/** + * Returns a selected block object. If a selected object is inline or when there is no selected + * object, `null` is returned. + * + * @protected + * @param {module:engine/model/model~Model} model The instance of editor model. + * @returns {module:engine/model/element~Element|null} Selected block object or `null`. + */ +export function getSelectedBlockObject( model ) { + const selectedElement = model.document.selection.getSelectedElement(); + + if ( !selectedElement ) { + return null; + } + + if ( model.schema.isObject( selectedElement ) && model.schema.isBlock( selectedElement ) ) { + return selectedElement; + } + + 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. diff --git a/packages/ckeditor5-list/tests/documentlist/_utils-tests/utils.js b/packages/ckeditor5-list/tests/documentlist/_utils-tests/utils.js index 3a60cb8765f..8bbce0a3359 100644 --- a/packages/ckeditor5-list/tests/documentlist/_utils-tests/utils.js +++ b/packages/ckeditor5-list/tests/documentlist/_utils-tests/utils.js @@ -226,6 +226,110 @@ describe( 'mockList()', () => { ); } ); + it( 'should allow passing custom element (no selection)', () => { + expect( modelList( [ + '* ' + ] ) ).to.equalMarkup( + '' + ); + } ); + + it( 'should allow passing custom element (self closing, no attributes)', () => { + expect( modelList( [ + '* ' + ] ) ).to.equalMarkup( + '' + ); + } ); + + it( 'should allow passing custom element (self closing, with attributes)', () => { + expect( modelList( [ + '* ' + ] ) ).to.equalMarkup( + '' + ); + } ); + + it( 'should allow passing custom element (empty)', () => { + expect( modelList( [ + '* []', + '* bar' + ] ) ).to.equalMarkup( + '[]' + + 'bar' + ); + } ); + + it( 'should allow passing custom element (nested)', () => { + expect( modelList( [ + '* []', + '* bar' + ] ) ).to.equalMarkup( + '[]' + + 'bar' + ); + } ); + + it( 'should allow passing custom element (nested mixed)', () => { + expect( modelList( [ + '* [ab]', + '* bar' + ] ) ).to.equalMarkup( + '[ab]' + + 'bar' + ); + } ); + + it( 'should allow passing custom element (selected)', () => { + expect( modelList( [ + '* [foo]', + '* bar' + ] ) ).to.equalMarkup( + '[foo]' + + 'bar' + ); + } ); + + it( 'should allow passing custom element (selection starts before)', () => { + expect( modelList( [ + '* [foo', + '* bar]' + ] ) ).to.equalMarkup( + '[foo' + + 'bar]' + ); + } ); + + it( 'should allow passing custom element (selection ends before)', () => { + expect( modelList( [ + '* [bar', + '* ]foo' + ] ) ).to.equalMarkup( + '[bar]' + + 'foo' + ); + } ); + + it( 'should allow passing custom element (selection starts after)', () => { + expect( modelList( [ + '* foo[', + '* bar]' + ] ) ).to.equalMarkup( + 'foo' + + '[bar]' + ); + } ); + + it( 'should allow passing custom element (selection ends after)', () => { + expect( modelList( [ + '* [bar', + '* foo]' + ] ) ).to.equalMarkup( + '[bar' + + 'foo]' + ); + } ); + it( 'should allow to customize the list item id (suffix)', () => { expect( modelList( [ '* foo{abc}', @@ -281,6 +385,13 @@ describe( 'mockList()', () => { ' baz' ] ) ).to.throw( Error, 'Invalid indent: bar' ); } ); + + it( 'should throw when ID is reused', () => { + expect( () => modelList( [ + '* foo', + '* bar {id:000}' + ] ) ).to.throw( Error, 'ID conflict: 000' ); + } ); } ); describe( 'stringifyList()', () => { @@ -293,48 +404,390 @@ describe( 'stringifyList()', () => { model.schema.extend( '$container', { allowAttributes: [ 'listType', 'listIndent', 'listItemId' ] } ); } ); - it( 'flat list', () => { - const input = parseModel( - 'aaa' + - 'bbb', - model.schema - ); - - expect( stringifyList( input ) ).to.equal( [ - '* aaa', - '* bbb' - ].join( '\n' ) ); + describe( 'bulleted list', () => { + it( 'flat list', () => { + const input = parseModel( + 'aaa' + + 'bbb', + model.schema + ); + + expect( stringifyList( input ) ).to.equal( [ + '* aaa', + '* bbb' + ].join( '\n' ) ); + } ); + + it( 'flat list with multi-block items', () => { + const input = parseModel( + 'aaa' + + 'bbb' + + 'ccc', + model.schema + ); + + expect( stringifyList( input ) ).to.equal( [ + '* aaa', + ' bbb', + '* ccc' + ].join( '\n' ) ); + } ); + + it( 'nested list with multi-block items', () => { + const input = parseModel( + 'aaa' + + 'bbb' + + 'ccc', + model.schema + ); + + expect( stringifyList( input ) ).to.equal( [ + '* aaa', + ' * bbb', + ' ccc' + ].join( '\n' ) ); + } ); + + it( 'nested list with many items', () => { + const input = parseModel( + 'aaa' + + 'bbb' + + 'ccc' + + 'ddd', + model.schema + ); + + expect( stringifyList( input ) ).to.equal( [ + '* aaa', + ' * bbb', + ' * ccc', + ' * ddd' + ].join( '\n' ) ); + } ); + + it( 'many indentations', () => { + const input = parseModel( + 'aaa' + + 'bbb' + + 'ccc' + + 'ddd', + model.schema + ); + + expect( stringifyList( input ) ).to.equal( [ + '* aaa', + ' * bbb', + ' * ccc', + ' * ddd' + ].join( '\n' ) ); + } ); + + it( 'many indentations with multiple blocks', () => { + const input = parseModel( + 'aaa' + + 'aaa' + + 'bbb' + + 'bbb' + + 'ccc' + + 'ccc' + + 'ddd' + + 'ddd', + model.schema + ); + + expect( stringifyList( input ) ).to.equalMarkup( [ + '* aaa', + ' aaa', + ' * bbb', + ' bbb', + ' * ccc', + ' ccc', + ' * ddd', + ' ddd' + ].join( '\n' ) ); + } ); + + it( 'nested multi-blocks item', () => { + const input = parseModel( + 'aaa' + + 'bbb' + + 'ccc', + model.schema + ); + + expect( stringifyList( input ) ).to.equalMarkup( [ + '* aaa', + ' * bbb', + ' ccc' + ].join( '\n' ) ); + } ); + + it( 'nested multi-blocks item followed by a list item', () => { + const input = parseModel( + 'aaa' + + 'bbb' + + 'ccc' + + 'ddd', + model.schema + ); + + expect( stringifyList( input ) ).to.equalMarkup( [ + '* aaa', + ' * bbb', + ' ccc', + '* ddd' + ].join( '\n' ) ); + } ); + + it( 'single list item', () => { + const input = parseModel( + 'a', + model.schema + ); + + expect( stringifyList( input ) ).to.equalMarkup( [ + '* a' + ].join( '\n' ) ); + } ); + + it( 'empty list item', () => { + const input = parseModel( + '', + model.schema + ); + + expect( stringifyList( input ) ).to.equalMarkup( [ + '* ' + ].join( '\n' ) ); + } ); } ); - it( 'flat list with multi-block items', () => { - const input = parseModel( - 'aaa' + - 'bbb' + - 'ccc', - model.schema - ); - - expect( stringifyList( input ) ).to.equal( [ - '* aaa', - ' bbb', - '* ccc' - ].join( '\n' ) ); + describe( 'numbered list', () => { + it( 'flat list', () => { + const input = parseModel( + 'aaa' + + 'bbb', + model.schema + ); + + expect( stringifyList( input ) ).to.equal( [ + '# aaa', + '# bbb' + ].join( '\n' ) ); + } ); + + it( 'flat list with multi-block items', () => { + const input = parseModel( + 'aaa' + + 'bbb' + + 'ccc', + model.schema + ); + + expect( stringifyList( input ) ).to.equal( [ + '# aaa', + ' bbb', + '# ccc' + ].join( '\n' ) ); + } ); + + it( 'nested list with multi-block items', () => { + const input = parseModel( + 'aaa' + + 'bbb' + + 'ccc', + model.schema + ); + + expect( stringifyList( input ) ).to.equal( [ + '# aaa', + ' # bbb', + ' ccc' + ].join( '\n' ) ); + } ); + + it( 'nested list with many items', () => { + const input = parseModel( + 'aaa' + + 'bbb' + + 'ccc' + + 'ddd', + model.schema + ); + + expect( stringifyList( input ) ).to.equal( [ + '# aaa', + ' # bbb', + ' # ccc', + ' # ddd' + ].join( '\n' ) ); + } ); + + it( 'many indentations', () => { + const input = parseModel( + 'aaa' + + 'bbb' + + 'ccc' + + 'ddd', + model.schema + ); + + expect( stringifyList( input ) ).to.equal( [ + '# aaa', + ' # bbb', + ' # ccc', + ' # ddd' + ].join( '\n' ) ); + } ); + + it( 'many indentations with multiple blocks', () => { + const input = parseModel( + 'aaa' + + 'aaa' + + 'bbb' + + 'bbb' + + 'ccc' + + 'ccc' + + 'ddd' + + 'ddd', + model.schema + ); + + expect( stringifyList( input ) ).to.equal( [ + '# aaa', + ' aaa', + ' # bbb', + ' bbb', + ' # ccc', + ' ccc', + ' # ddd', + ' ddd' + ].join( '\n' ) ); + } ); + + it( 'nested multi-blocks item', () => { + const input = parseModel( + 'aaa' + + 'bbb' + + 'ccc', + model.schema + ); + + expect( stringifyList( input ) ).to.equal( [ + '# aaa', + ' # bbb', + ' ccc' + ].join( '\n' ) ); + } ); + + it( 'nested multi-blocks item followed by a list item', () => { + const input = parseModel( + 'aaa' + + 'bbb' + + 'ccc' + + 'ddd', + model.schema + ); + + expect( stringifyList( input ) ).to.equal( [ + '# aaa', + ' # bbb', + ' ccc', + '# ddd' + ].join( '\n' ) ); + } ); + + it( 'single list item', () => { + const input = parseModel( + 'a', + model.schema + ); + + expect( stringifyList( input ) ).to.equal( [ + '# a' + ].join( '\n' ) ); + } ); + + it( 'empty list item', () => { + const input = parseModel( + '', + model.schema + ); + + expect( stringifyList( input ) ).to.equal( [ + '# ' + ].join( '\n' ) ); + } ); } ); - it( 'nested list with multi-block items', () => { - const input = parseModel( - 'aaa' + - 'bbb' + - 'ccc', - model.schema - ); - - expect( stringifyList( input ) ).to.equal( [ - '* aaa', - ' * bbb', - ' ccc' - ].join( '\n' ) ); + describe( 'mixed lists', () => { + it( 'bulleted and numbered list', () => { + const input = parseModel( + 'a' + + '0', + model.schema + ); + + expect( stringifyList( input ) ).to.equalMarkup( [ + '* a', + '# 0' + ].join( '\n' ) ); + } ); + + it( 'numbered list item with nested bulleted list item', () => { + const input = parseModel( + '0' + + 'a', + model.schema + ); + + expect( stringifyList( input ) ).to.equalMarkup( [ + '# 0', + ' * a' + ].join( '\n' ) ); + } ); + + it( 'bulleted list item with nested numbered list item', () => { + const input = parseModel( + 'a' + + '0', + model.schema + ); + + expect( stringifyList( input ) ).to.equalMarkup( [ + '* a', + ' # 0' + ].join( '\n' ) ); + } ); + + it( 'numbered list with many blocks and nested bulleted list item', () => { + const input = parseModel( + '0' + + '1' + + 'a', + model.schema + ); + + expect( stringifyList( input ) ).to.equalMarkup( [ + '# 0', + ' 1', + ' * a' + ].join( '\n' ) ); + } ); + + it( 'bulleted list with many blocks and nested numbered list item', () => { + const input = parseModel( + 'a' + + 'b' + + '0', + model.schema + ); + + expect( stringifyList( input ) ).to.equalMarkup( [ + '* a', + ' b', + ' # 0' + ].join( '\n' ) ); + } ); } ); - - // TODO } ); diff --git a/packages/ckeditor5-list/tests/documentlist/_utils/utils.js b/packages/ckeditor5-list/tests/documentlist/_utils/utils.js index b44d2043212..5ee22bdb2fa 100644 --- a/packages/ckeditor5-list/tests/documentlist/_utils/utils.js +++ b/packages/ckeditor5-list/tests/documentlist/_utils/utils.js @@ -4,6 +4,7 @@ */ import Model from '@ckeditor/ckeditor5-engine/src/model/model'; +import DocumentFragment from '@ckeditor/ckeditor5-engine/src/model/documentfragment'; import { getData as getModelData, parse as parseModel, stringify as stringifyModel } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; import ListWalker from '../../../src/documentlist/utils/listwalker'; @@ -26,7 +27,7 @@ export function prepareTest( model, input ) { const selection = parsedResult.selection; // Ensure no undo step is generated. - model.enqueueChange( 'transparent', writer => { + model.enqueueChange( { isUndoable: false }, writer => { // Replace existing model in document by new one. writer.remove( writer.createRangeIn( modelRoot ) ); writer.insert( modelDocumentFragment, modelRoot ); @@ -222,6 +223,7 @@ export function setupTestHelpers( editor ) { export function modelList( lines ) { const items = []; const stack = []; + const seenIds = new Set(); let prevIndent = -1; @@ -251,6 +253,12 @@ export function modelList( lines ) { return ''; } ); + if ( seenIds.has( listItemId ) ) { + throw new Error( 'ID conflict: ' + listItemId ); + } + + seenIds.add( listItemId ); + stack[ listIndent ] = { listItemId, listType: marker == '#' ? 'numbered' : 'bulleted' @@ -267,17 +275,22 @@ export function modelList( lines ) { } /** - * Returns document list pseudo markdown notation for a given document fragment. + * Returns document list pseudo markdown notation for a given document fragment or element. * - * @param {module:engine/model/documentfragment~DocumentFragment} fragment The document fragment to stringify to pseudo markdown notation. + * @param {module:engine/model/documentfragment~DocumentFragment|module:engine/model/element~Element} fragmentOrElement The document + * fragment or element to stringify to pseudo markdown notation. * @returns {String} */ -export function stringifyList( fragment ) { +export function stringifyList( fragmentOrElement ) { const model = new Model(); const lines = []; + if ( fragmentOrElement.is( 'element' ) ) { + fragmentOrElement = new DocumentFragment( [ fragmentOrElement ] ); + } + model.change( writer => { - for ( let node = fragment.getChild( 0 ); node; node = node.nextSibling ) { + for ( let node = fragmentOrElement.getChild( 0 ); node; node = node.nextSibling ) { let pad = ''; if ( node.hasAttribute( 'listItemId' ) ) { @@ -317,9 +330,43 @@ function stringifyNode( node, writer ) { return stringifyModel( fragment ); } -function stringifyElement( content, attributes = {}, name = 'paragraph' ) { - [ , name, content ] = content.match( /^<([^>]+)>([^<]*)?/ ) || [ null, name, content ]; - attributes = Object.entries( attributes ).map( ( [ key, value ] ) => ` ${ key }="${ value }"` ).join( '' ); +function stringifyElement( content, listAttributes = {} ) { + let name = 'paragraph'; + let elementAttributes = ''; + let selectionBefore = ''; + let selectionAfter = ''; + + const regexp = new RegExp( + '^(?[\\[\\]])?' + // [\\w+)(?[^>]+)?/>' + // For instance OR + '|' + + '<(?\\w+)(?[^>]+)?>' + // For instance OR ... + '(?.*)' + + '(?:)' + // Note: Match here in the closing tag. + ')' + + '(?[\\[\\]])?$' // ] or ] + ); + + const match = content.match( regexp ); + + if ( match ) { + name = match.groups.nameSelfClosing || match.groups.name; + elementAttributes = match.groups.elementAttributes || match.groups.elementSelfClosingAttributes || ''; + content = match.groups.content || ''; + + if ( match.groups.selectionBefore ) { + selectionBefore = match.groups.selectionBefore; + } + + if ( match.groups.selectionAfter ) { + selectionAfter = match.groups.selectionAfter; + } + } + + listAttributes = Object.entries( listAttributes ).map( ( [ key, value ] ) => ` ${ key }="${ value }"` ).join( '' ); - return `<${ name }${ attributes }>${ content }`; + return `${ selectionBefore }` + + `<${ name }${ elementAttributes }${ listAttributes }>${ content }` + + `${ selectionAfter }`; } diff --git a/packages/ckeditor5-list/tests/documentlist/documentlistediting-integrations.js b/packages/ckeditor5-list/tests/documentlist/documentlistediting-integrations.js deleted file mode 100644 index 594c9de4cc0..00000000000 --- a/packages/ckeditor5-list/tests/documentlist/documentlistediting-integrations.js +++ /dev/null @@ -1,1783 +0,0 @@ -/** - * @license Copyright (c) 2003-2022, 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 TableEditing from '@ckeditor/ckeditor5-table/src/tableediting'; -import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; -import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; -import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; - -import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; -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 { DomEventData } from '@ckeditor/ckeditor5-engine'; - -import stubUid from './_utils/uid'; -import { modelList } from './_utils/utils'; - -describe( 'DocumentListEditing integrations', () => { - let editor, model, modelDoc, modelRoot, view; - - testUtils.createSinonSandbox(); - - beforeEach( async () => { - editor = await VirtualTestEditor.create( { - plugins: [ Paragraph, ClipboardPipeline, BoldEditing, DocumentListEditing, UndoEditing, - BlockQuoteEditing, TableEditing, HeadingEditing ] - } ); - - model = editor.model; - modelDoc = model.document; - modelRoot = modelDoc.getRoot(); - - view = editor.editing.view; - - model.schema.extend( 'paragraph', { - allowAttributes: 'foo' - } ); - - model.schema.register( 'nonListable', { - allowWhere: '$block', - allowContentOf: '$block', - inheritTypesFrom: '$block', - allowAttributes: 'foo' - } ); - - editor.conversion.elementToElement( { model: 'nonListable', view: 'div' } ); - - // Stub `view.scrollToTheSelection` as it will fail on VirtualTestEditor without DOM. - sinon.stub( view, 'scrollToTheSelection' ).callsFake( () => {} ); - stubUid(); - } ); - - afterEach( async () => { - await editor.destroy(); - } ); - - describe( 'clipboard integration', () => { - 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( '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 paragraph = writer.createElement( 'paragraph', { listType: 'bulleted', listItemId: 'x', listIndent: '0' } ); - writer.insertText( 'X', paragraph ); - - model.insertContent( paragraph ); - } ); - - 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( '
  • X
  • ' ) - } ); - } ); - - 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( '
  • X
    • Y
  • ' ) - } ); - } ); - - 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' - ); - } ); - } ); - } ); - - describe( 'enter key handling', () => { - const changedBlocks = []; - let domEventData, splitBeforeCommand, splitAfterCommand, indentCommand, - eventInfo, splitBeforeCommandExecuteSpy, splitAfterCommandExecuteSpy, outdentCommandExecuteSpy; - - beforeEach( () => { - eventInfo = new EventInfo( view.document, 'enter' ); - domEventData = new DomEventData( view.document, { - preventDefault: sinon.spy() - } ); - - splitBeforeCommand = editor.commands.get( 'splitListItemBefore' ); - splitAfterCommand = editor.commands.get( 'splitListItemAfter' ); - indentCommand = editor.commands.get( 'outdentList' ); - - splitBeforeCommandExecuteSpy = sinon.spy( splitBeforeCommand, 'execute' ); - splitAfterCommandExecuteSpy = sinon.spy( splitAfterCommand, 'execute' ); - outdentCommandExecuteSpy = sinon.spy( indentCommand, 'execute' ); - - changedBlocks.length = 0; - - splitBeforeCommand.on( 'afterExecute', ( evt, data ) => { - changedBlocks.push( ...data ); - } ); - - splitAfterCommand.on( 'afterExecute', ( evt, data ) => { - changedBlocks.push( ...data ); - } ); - - indentCommand.on( 'afterExecute', ( evt, data ) => { - changedBlocks.push( ...data ); - } ); - } ); - - describe( 'collapsed selection', () => { - describe( 'with just one block per list item', () => { - it( 'should outdent if the slection in the only empty list item (convert into paragraph and turn off the list)', () => { - setModelData( model, modelList( [ - '* []' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '[]' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [ - modelRoot.getChild( 0 ) - ] ); - - sinon.assert.calledOnce( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.true; - } ); - - it( 'should outdent if the slection in the last empty list item (convert the item into paragraph)', () => { - setModelData( model, modelList( [ - '* a', - '* []' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* a', - '[]' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [ - modelRoot.getChild( 1 ) - ] ); - - sinon.assert.calledOnce( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.true; - } ); - - it( 'should create another list item when the selection in a non-empty only list item', () => { - setModelData( model, modelList( [ - '* a[]' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* a', - '* [] {id:a00}' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [ - modelRoot.getChild( 1 ) - ] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.calledOnce( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - it( 'should outdent if the selection in an empty, last sub-list item', () => { - setModelData( model, modelList( [ - '* a', - ' # b', - ' * c', - ' * []' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* a', - ' # b', - ' * c', - ' # []' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [ - modelRoot.getChild( 3 ) - ] ); - - sinon.assert.calledOnce( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.true; - } ); - } ); - - describe( 'with multiple blocks in a list item', () => { - it( 'should outdent if the selection is anchored in an empty, last item block', () => { - setModelData( model, modelList( [ - '* a', - ' # b', - ' # []' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* a', - ' # b', - '* []' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [ - modelRoot.getChild( 2 ) - ] ); - - sinon.assert.calledOnce( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.true; - } ); - - it( 'should outdent if the selection is anchored in an empty, only sub-item block', () => { - setModelData( model, modelList( [ - '* a', - ' # b', - ' * []', - ' #' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* a', - ' # b', - ' # []', - ' #' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [ - modelRoot.getChild( 2 ) - ] ); - - sinon.assert.calledOnce( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.true; - } ); - - it( 'should create another block when the selection at the start of a non-empty first block', () => { - setModelData( model, modelList( [ - '* a[]', - ' b', - ' c' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* a', - ' []', - ' b', - ' c' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - it( 'should create another block when the selection at the end of a non-empty first block', () => { - setModelData( model, modelList( [ - '* []a', - ' b', - ' c' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* ', - ' []a', - ' b', - ' c' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - it( 'should create another block when the selection at the start of a non-empty last block', () => { - setModelData( model, modelList( [ - '* a', - ' b', - ' []c' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* a', - ' b', - ' ', - ' []c' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - it( 'should create another block when the selection at the end of a non-empty last block', () => { - setModelData( model, modelList( [ - '* a', - ' b', - ' c[]' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* a', - ' b', - ' c', - ' []' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - it( 'should create another block when the selection in an empty middle block', () => { - setModelData( model, modelList( [ - '* a', - ' []', - ' c' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* a', - ' ', - ' []', - ' c' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - it( 'should create another list item when the selection in an empty last block (two blocks in total)', () => { - setModelData( model, modelList( [ - '* a', - ' []' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* a', - '* [] {id:a00}' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [ - modelRoot.getChild( 1 ) - ] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.calledOnce( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.true; - } ); - - it( 'should create another list item when the selection in an empty last block (three blocks in total)', () => { - setModelData( model, modelList( [ - '* a', - ' b', - ' []' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* a', - ' b', - '* [] {id:a00}' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [ - modelRoot.getChild( 2 ) - ] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.calledOnce( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.true; - } ); - - it( 'should create another list item when the selection in an empty last block (followed by a list item)', () => { - setModelData( model, modelList( [ - '* a', - ' b', - ' []', - '* ' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* a', - ' b', - '* [] {id:a00}', - '* ' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [ - modelRoot.getChild( 2 ) - ] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.calledOnce( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.true; - } ); - - it( 'should create another list item when the selection in an empty first block (followed by another block)', () => { - setModelData( model, modelList( [ - '* []', - ' b' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* []', - '* b {id:a00}' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [ - modelRoot.getChild( 1 ) - ] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.calledOnce( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.true; - } ); - - it( 'should create another list item when the selection in an empty first block (followed by multiple blocks)', () => { - setModelData( model, modelList( [ - '* []', - ' a', - ' b' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* []', - '* a {id:a00}', - ' b' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [ - modelRoot.getChild( 1 ), - modelRoot.getChild( 2 ) - ] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.calledOnce( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.true; - } ); - - it( 'should create another list item when the selection in an empty first block (followed by multiple blocks and an item)', - () => { - setModelData( model, modelList( [ - '* []', - ' a', - ' b', - '* c' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* []', - '* a {id:a00}', - ' b', - '* c' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [ - modelRoot.getChild( 1 ), - modelRoot.getChild( 2 ) - ] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.calledOnce( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.true; - } ); - } ); - } ); - - describe( 'non-collapsed selection', () => { - describe( 'with just one block per list item', () => { - it( 'should create another list item if the selection contains some content at the end of the list item', () => { - setModelData( model, modelList( [ - '* a[b]' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* a', - '* [] {id:a00}' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [ - modelRoot.getChild( 1 ) - ] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.calledOnce( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - it( 'should create another list item if the selection contains some content at the start of the list item', () => { - setModelData( model, modelList( [ - '* [a]b' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* ', - '* []b {id:a00}' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [ - modelRoot.getChild( 1 ) - ] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.calledOnce( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - it( 'should clean the content and turn off the list if slection contains all content at the zero indent level', () => { - setModelData( model, modelList( [ - '* [a', - '* b]' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '[]' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - it( 'should clean the content and move the selection when it contains some content at the zero indent level', () => { - setModelData( model, modelList( [ - '* a[b', - '* b]' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* a', - '* []' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - it( 'should clean the content when the selection contains all content at a deeper indent level', () => { - setModelData( model, modelList( [ - '* a', - ' # b', - ' * [c', - ' * d]' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* a', - ' # b', - ' * []' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - describe( 'cross-indent level selection', () => { - it( 'should clean the content and remove list across different indentation levels (list the only content)', () => { - setModelData( model, modelList( [ - '* [ab', - ' # cd]' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '[]' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - it( 'should clean the content across different indentation levels (one level, entire blocks)', () => { - setModelData( model, modelList( [ - 'foo', - '* [ab', - ' # cd]' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - 'foo', - '* []' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - it( 'should clean the content across different indentation levels (one level, subset of blocks)', () => { - setModelData( model, modelList( [ - 'foo', - '* a[b', - ' # c]d' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - 'foo', - '* a', - ' # []d' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - it( 'should clean the content across different indentation levels (two levels, entire blocks)', () => { - setModelData( model, modelList( [ - '* [ab', - ' # cd', - ' * ef]', - ' * gh' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* []', - ' * gh {id:003}' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - it( 'should clean the content across different indentation levels (two levels, subset of blocks)', () => { - setModelData( model, modelList( [ - '* a[b', - ' # cd', - ' * e]f', - ' * gh' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* a', - ' * []f {id:002}', - ' * gh {id:003}' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - it( 'should clean the content across different indentation levels (three levels, entire blocks)', () => { - setModelData( model, modelList( [ - 'foo', - '* [ab', - ' # cd', - ' * ef', - ' * gh]' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - 'foo', - '* []' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - it( 'should clean the content and remove list across different indentation levels ' + - '(three levels, list the only content)', () => { - setModelData( model, modelList( [ - '* [ab', - ' # cd', - ' * ef', - ' * gh]' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '[]' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - it( 'should clean the content across different indentation levels (three levels, subset of blocks)', () => { - setModelData( model, modelList( [ - '* a[b', - ' # cd', - ' * ef', - ' # g]h', - ' * ij' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* a', - ' # []h {id:003}', - '* ij {id:004}' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - it( 'should clean the content across different indentation levels (one level, start at first, entire blocks)', () => { - setModelData( model, modelList( [ - '* ab', - ' # [cd', - ' * ef', - ' * gh]' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* ab', - ' # []' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - it( 'should clean the content across different indentation levels (one level, start at first, part of blocks)', () => { - setModelData( model, modelList( [ - '* ab', - ' # c[d', - ' * ef', - ' * g]h' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* ab', - ' # c', - ' * []h {id:003}' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - it( 'should clean the content across different indentation levels (level up then down, subset of blocks)', () => { - setModelData( model, modelList( [ - '* ab', - ' # c[d', - ' * ef', - ' # g]h' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* ab', - ' # c', - ' # []h {id:003}' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - it( 'should clean the content across different indentation levels (level up then down, entire of blocks)', () => { - setModelData( model, modelList( [ - '* ab', - ' # [cd', - ' * ef', - ' # gh]', - '* ij' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* ab', - ' # []', - '* ij {id:004}' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - it( 'should clean the content across different indentation levels (level up then down, preceded by an item)', () => { - setModelData( model, modelList( [ - '* ab', - ' # cd', - ' # [ef', - ' * gh', - ' # ij]', - '* kl' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* ab', - ' # cd', - ' # []', - '* kl {id:005}' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - } ); - } ); - - describe( 'with multiple blocks in a list item', () => { - it( 'should clean the selected content (partial blocks)', () => { - setModelData( model, modelList( [ - '* a[b', - ' c]d' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* a', - '* []d {id:a00}' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [ - modelRoot.getChild( 1 ) - ] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.calledOnce( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - it( 'should clean the selected content (entire blocks)', () => { - setModelData( model, modelList( [ - 'foo', - '* [ab', - ' cd]' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - 'foo', - '* []' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - it( 'should clean the selected content (entire block, middle one)', () => { - setModelData( model, modelList( [ - '* ab', - ' [cd]', - ' ef' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* ab', - ' []', - ' ef' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - it( 'should clean the selected content (entire blocks, starting from the second)', () => { - setModelData( model, modelList( [ - '* ab', - ' [cd', - ' ef]' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - // Generally speaking, we'd rather expect something like this: - // * ab - // [] - // But there is no easy way to tell what the original selection looked like when it came to EnterCommand#afterExecute. - // Enter deletes all the content first [cd, ef] and in #afterExecute it looks like the original selection was: - // * ab - // [] - // and the algorithm falls back to splitting in this case. There's even a test for this kind of selection. - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* ab', - '* [] {id:a00}' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [ - modelRoot.getChild( 1 ) - ] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.calledOnce( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - it( 'should clean the selected content (partial blocks, starting from the second)', () => { - setModelData( model, modelList( [ - '* ab', - ' c[d', - ' e]f' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* ab', - ' c', - ' []f' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - it( 'should clean the selected content (entire blocks, three blocks in total)', () => { - setModelData( model, modelList( [ - '* [ab', - ' cd', - ' ef]', - '* gh' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* []', - '* gh {id:003}' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - it( 'should clean the selected content (entire blocks, across list items)', () => { - setModelData( model, modelList( [ - 'foo', - '* [ab', - ' cd', - ' ef', - '* gh]' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - 'foo', - '* []' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - it( 'should clean the selected content (entire blocks + a partial block, across list items)', () => { - setModelData( model, modelList( [ - '* [ab', - ' cd', - ' ef', - '* g]h' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* ', - '* []h {id:003}' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - it( 'should clean the selected content (partial blocks, across list items)', () => { - setModelData( model, modelList( [ - '* ab', - ' cd', - ' e[f', - '* g]h' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* ab', - ' cd', - ' e', - '* []h' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - describe( 'cross-indent level selection', () => { - it( 'should clean the selected content (partial blocks)', () => { - setModelData( model, modelList( [ - '* ab', - ' * cd', - ' e[f', - ' gh', - ' * i]j' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* ab', - ' * cd', - ' e', - ' * []j {id:004}' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - it( 'should clean the selected content (partial blocks + entire block)', () => { - setModelData( model, modelList( [ - '* ab', - ' * cd', - ' e[f', - ' gh', - ' * ij]' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* ab', - ' * cd', - ' e', - ' * [] {id:004}' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - - it( 'should clean the selected content (across two middle levels)', () => { - setModelData( model, modelList( [ - '* ab', - ' c[d', - ' * ef', - ' g]h', - ' * ij' - ] ) ); - - view.document.fire( eventInfo, domEventData ); - - expect( getModelData( model ) ).to.equalMarkup( modelList( [ - '* ab', - ' c', - ' * []h', - ' * ij {id:004}' - ] ) ); - - expect( changedBlocks ).to.deep.equal( [] ); - - sinon.assert.notCalled( outdentCommandExecuteSpy ); - sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); - sinon.assert.notCalled( splitAfterCommandExecuteSpy ); - - sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); - expect( eventInfo.stop.called ).to.be.undefined; - } ); - } ); - } ); - } ); - } ); -} ); diff --git a/packages/ckeditor5-list/tests/documentlist/documentlistmergecommand.js b/packages/ckeditor5-list/tests/documentlist/documentlistmergecommand.js new file mode 100644 index 00000000000..dc57cbc545d --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/documentlistmergecommand.js @@ -0,0 +1,3047 @@ +/** + * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import { modelList } from './_utils/utils'; +import DocumentListMergeCommand from '../../src/documentlist/documentlistmergecommand'; + +import Editor from '@ckeditor/ckeditor5-core/src/editor/editor'; +import Model from '@ckeditor/ckeditor5-engine/src/model/model'; + +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; + +describe( 'DocumentListMergeCommand', () => { + let editor, model, doc, command; + let blocksChangedByCommands = []; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + editor = new Editor(); + editor.model = new Model(); + + model = editor.model; + doc = model.document; + doc.createRoot(); + + model.schema.register( 'paragraph', { inheritAllFrom: '$block' } ); + model.schema.register( 'blockQuote', { inheritAllFrom: '$container' } ); + model.schema.extend( '$container', { allowAttributes: [ 'listType', 'listIndent', 'listItemId' ] } ); + model.schema.register( 'blockWidget', { + isObject: true, + isBlock: true, + allowIn: '$root', + allowAttributesOf: '$container' + } ); + + editor.model.schema.register( 'inlineWidget', { + isObject: true, + isInline: true, + allowWhere: '$text', + allowAttributesOf: '$text' + } ); + } ); + + afterEach( () => { + command.destroy(); + } ); + + describe( 'backward', () => { + beforeEach( () => { + command = new DocumentListMergeCommand( editor, 'backward' ); + + command.on( 'afterExecute', ( evt, data ) => { + blocksChangedByCommands = data; + } ); + } ); + + describe( 'isEnabled', () => { + describe( 'collapsed selection', () => { + it( 'should be false when not in a list item', () => { + setData( model, modelList( [ + 'a[]' + ] ) ); + + expect( command.isEnabled ).to.be.false; + + setData( model, modelList( [ + '* a', + '[]b' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be true when there is a preceding list item', () => { + setData( model, modelList( [ + '* a', + '* []' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false when there is no preceding list item', () => { + setData( model, modelList( [ + '* []' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false when there is a preceding block in the same list item', () => { + setData( model, modelList( [ + '* a', + ' []' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + } ); + + describe( 'block object', () => { + it( 'should be false when not in a list item', () => { + setData( model, modelList( [ + '[]' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be true when there is a preceding list item', () => { + setData( model, modelList( [ + '* a', + '* []' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false when there is no preceding list item', () => { + setData( model, modelList( [ + '* []' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false when there is a preceding block in the same list item', () => { + setData( model, modelList( [ + '* a', + ' []' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + } ); + + describe( 'inline object', () => { + it( 'should be false when not in a list item', () => { + setData( model, modelList( [ + '[]' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false when there is a preceding list item but the selection stays in a single item', () => { + setData( model, modelList( [ + '* a', + '* []' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false when there is no preceding list item', () => { + setData( model, modelList( [ + '* []' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false when there is a preceding block in the same list item', () => { + setData( model, modelList( [ + '* a', + ' []' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + } ); + + describe( 'non-collapsed selection', () => { + it( 'should be false if the selection starts and ends in the same list item but nothing precedes', () => { + setData( model, modelList( [ + '* [a]b' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false if the selection focuses in a non-list item', () => { + setData( model, modelList( [ + '* [a', + 'b]' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be true if the selection focuses in a list item', () => { + setData( model, modelList( [ + '* [a', + '* b]' + ] ) ); + + expect( command.isEnabled ).to.be.true; + + setData( model, modelList( [ + '[a', + '* b]' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + } ); + } ); + + describe( 'execute()', () => { + it( 'should use parent batch', () => { + setData( model, modelList( [ + '* a', + '* []' + ] ) ); + + 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( 'single block list item', () => { + describe( 'collapsed selection at the beginning of a list item', () => { + describe( 'item before is empty (shouldMergeOnBlocksContentLevel = true)', () => { + it( 'should merge non empty list item with with previous list item as a block', () => { + runTest( { + input: [ + '* ', + '* []b' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []b {id:001}' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge empty list item with with previous empty list item', () => { + runTest( { + input: [ + '* ', + '* []' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge indented list item with with previous empty list item', () => { + runTest( { + input: [ + '* ', + ' * []a' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []a {id:001}' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge indented empty list item with with previous empty list item', () => { + runTest( { + input: [ + '* ', + ' * []' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge list item with with previous indented empty list item', () => { + runTest( { + input: [ + '* ', + ' * ', + '* []a' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* ', + ' * []a{id:002}' + ], + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge empty list item with with previous indented empty list item', () => { + runTest( { + input: [ + '* ', + ' * ', + '* []' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* ', + ' * []' + ], + changedBlocks: [ 1 ] + } ); + } ); + } ); + + 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' + ], + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge empty list item with with previous list item as a block', () => { + runTest( { + input: [ + '* a', + '* []' + ], + expected: [ + '* a', + ' []' + ], + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge indented list item with with parent list item as a block', () => { + runTest( { + input: [ + '* a', + ' * []b' + ], + expected: [ + '* a', + ' []b' + ], + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge indented empty list item with with parent list item as a block', () => { + runTest( { + input: [ + '* a', + ' * []' + ], + expected: [ + '* a', + ' []' + ], + changedBlocks: [ 1 ] + } ); + } ); + + 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' + ], + changedBlocks: [ 2 ] + } ); + } ); + + it( 'should merge empty list item with with previous list item with higher indent as a block', () => { + runTest( { + input: [ + '* a', + ' * b', + '* []' + ], + expected: [ + '* a', + ' * b', + ' []' + ], + changedBlocks: [ 2 ] + } ); + } ); + + it( 'should keep merged list item\'s children', () => { + runTest( { + input: [ + '* a', + ' * []b', + ' * c', + ' * d', + ' e', + ' * f', + ' * g', + ' h' + ], + expected: [ + '* a', + ' []b', + ' * c', + ' * d', + ' e', + ' * f', + ' * g', + ' h' + ], + changedBlocks: [ 1, 2, 3, 4, 5, 6, 7 ] + } ); + } ); + } ); + } ); + + describe( 'collapsed selection at the end of a list item', () => { + describe( 'item after is empty (shouldMergeOnBlocksContentLevel = true)', () => { + it( 'should merge non empty list item with with previous list item as a block', () => { + runTest( { + input: [ + '* ', + '* []b' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []b{id:001}' + ], + changedBlocks: [ 0 ] + } ); + } ); + + // Default behaviour of backspace? + it( 'should merge empty list item with with previous empty list item', () => { + runTest( { + input: [ + '* ', + '* []' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge indented list item with with previous empty list item', () => { + runTest( { + input: [ + '* ', + ' * []a' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []a {id:001}' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge indented empty list item with with previous empty list item', () => { + runTest( { + input: [ + '* ', + ' * []' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge list item with with previous indented empty list item', () => { + runTest( { + input: [ + '* ', + ' * ', + '* []a' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* ', + ' * []a{id:002}' + ], + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge empty list item with with previous indented empty list item', () => { + runTest( { + input: [ + '* ', + ' * ', + '* []' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* ', + ' * []' + ], + changedBlocks: [ 1 ] + } ); + } ); + } ); + + 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' + ], + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge empty list item with with previous list item as a block', () => { + runTest( { + input: [ + '* a', + '* []' + ], + expected: [ + '* a', + ' []' + ], + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge indented list item with with parent list item as a block', () => { + runTest( { + input: [ + '* a', + ' * []b' + ], + expected: [ + '* a', + ' []b' + ], + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge indented empty list item with with parent list item as a block', () => { + runTest( { + input: [ + '* a', + ' * []' + ], + expected: [ + '* a', + ' []' + ], + changedBlocks: [ 1 ] + } ); + } ); + + 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' + ], + changedBlocks: [ 2 ] + } ); + } ); + + it( 'should merge empty list item with with previous list item with higher indent as a block', () => { + runTest( { + input: [ + '* a', + ' * b', + '* []' + ], + expected: [ + '* a', + ' * b', + ' []' + ], + changedBlocks: [ 2 ] + } ); + } ); + + it( 'should keep merged list item\'s children', () => { + runTest( { + input: [ + '* a', + ' * []b', + ' * c', + ' * d', + ' e', + ' * f', + ' * g', + ' h' + ], + expected: [ + '* a', + ' []b', + ' * c', + ' * d', + ' e', + ' * f', + ' * g', + ' h' + ], + changedBlocks: [ 1, 2, 3, 4, 5, 6, 7 ] + } ); + } ); + } ); + } ); + } ); + + describe( 'multi-block list item', () => { + describe( 'collapsed selection at the beginning of a list item', () => { + describe( 'item before is empty (shouldMergeOnBlocksContentLevel = true)', () => { + it( 'should merge with previous list item and keep blocks intact', () => { + runTest( { + input: [ + '* ', + '* []b', + ' c' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []b{id:001}', + ' c' + ], + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should merge with previous list item and keep complex blocks intact ', () => { + runTest( { + input: [ + '* ', + '* []b', + ' c', + ' * d', + ' e', + ' * f', + ' * g', + ' h', + ' * i', + ' * j', + ' k', + ' l' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []b {id:001}', + ' c', + ' * d {id:003}', + ' e', + ' * f {id:005}', + ' * g {id:006}', + ' h', + ' * i {id:008}', + ' * j {id:009}', + ' k', + ' l' + ], + changedBlocks: [ 0, 1, 10 ] + } ); + } ); + + it( 'should merge list item with first block empty with previous empty list item', () => { + runTest( { + input: [ + '* ', + '* []', + ' a' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []', + ' a' + ], + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should merge indented list item with with previous empty list item', () => { + runTest( { + input: [ + '* ', + ' * []a', + ' b' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []a {id:001}', + ' b' + ], + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should merge indented list having block and indented list item with previous empty list item', () => { + runTest( { + input: [ + '* ', + ' * []a', + ' b', + ' * c' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []a {id:001}', + ' b', + ' * c {id:003}' + ], + changedBlocks: [ 0, 1, 2 ] + } ); + } ); + + it( 'should merge indented empty list item with previous empty list item', () => { + runTest( { + input: [ + '* ', + ' * []', + ' text' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []', + ' text' + ], + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should merge list item with with previous indented empty list item', () => { + runTest( { + input: [ + '* ', + ' * ', + '* []a', + ' b' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* ', + ' * []a{id:002}', + ' b' + ], + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should merge empty list item with with previous indented empty list item', () => { + runTest( { + input: [ + '* ', + ' * ', + '* []', + ' text' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* ', + ' * []', + ' text' + ], + changedBlocks: [ 1, 2 ] + } ); + } ); + } ); + + describe( 'item before is not empty', () => { + it( 'should merge with previous list item and keep blocks intact', () => { + runTest( { + input: [ + '* a', + '* []b', + ' c' + ], + expected: [ + '* a', + ' []b', + ' c' + ], + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should merge block to a previous list item', () => { + runTest( { + input: [ + '* b', + ' * c', + ' []d', + ' e' + ], + expected: [ + '* b', + ' * c', + ' []d', + ' e' + ], + changedBlocks: [ 2 ] + } ); + } ); + + it( 'should merge with previous list item and keep complex blocks intact', () => { + runTest( { + input: [ + '* a', + '* []b', + ' c', + ' * d', + ' e', + ' * f', + ' * g', + ' h', + ' * i', + ' * j', + ' k', + ' l' + ], + expected: [ + '* a', + ' []b', + ' c', + ' * d', + ' e', + ' * f', + ' * g', + ' h', + ' * i', + ' * j', + ' k', + ' l' + ], + changedBlocks: [ 1, 2, 11 ] + } ); + } ); + + it( 'should merge list item with first block empty with previous list item', () => { + runTest( { + input: [ + '* a', + '* []', + ' b' + ], + expected: [ + '* a', + ' []', + ' b' + ], + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should merge indented list item with with previous list item as blocks', () => { + runTest( { + input: [ + '* a', + ' * []a', + ' b' + ], + expected: [ + '* a', + ' []a', + ' b' + ], + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should merge indented list having block and indented list item with previous list item', () => { + runTest( { + input: [ + '* a', + ' * []b', + ' c', + ' * d' + ], + expected: [ + '* a', + ' []b', + ' c', + ' * d' + ], + changedBlocks: [ 1, 2, 3 ] + } ); + } ); + + it( 'should merge indented empty list item with previous list item', () => { + runTest( { + input: [ + '* a', + ' * []', + ' text' + ], + expected: [ + '* a', + ' []', + ' text' + ], + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should merge list item with with previous indented empty list item', () => { + runTest( { + input: [ + '* a', + ' * b', + '* []c', + ' d' + ], + expected: [ + '* a', + ' * b', + ' []c', + ' d' + ], + changedBlocks: [ 2, 3 ] + } ); + } ); + } ); + } ); + + describe( 'collapsed selection in the middle of the list item', () => { + it( 'should merge block to a previous list item', () => { + runTest( { + input: [ + '* A', + ' * B', + ' # C', + ' # D', + ' []X', + ' # Z', + ' V', + '* E', + '* F' + ], + expected: [ + '* A', + ' * B', + ' # C', + ' # D', + ' []X', + ' # Z', + ' V', + '* E', + '* F' + ], + changedBlocks: [ 4, 5 ] + } ); + } ); + } ); + } ); + + describe( 'around widgets', () => { + describe( 'block widgets', () => { + it( 'should merge a selected block widget into a block', () => { + runTest( { + input: [ + '* a', + '* []' + ], + expected: [ + '* a', + ' []' + ], + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge into a nested block with a block widget', () => { + runTest( { + input: [ + '* a', + ' * []' + ], + expected: [ + '* a', + ' []' + ], + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge an item into the previous one despite a block widget precededing it', () => { + runTest( { + input: [ + '* a', + ' ', + '* []' + ], + expected: [ + '* a', + ' ', + ' []' + ], + changedBlocks: [ 2 ] + } ); + } ); + + it( 'should merge an item into the previous one despite a block widget precededing it at a deeper level', () => { + runTest( { + input: [ + '* a', + ' * ', + '* []' + ], + expected: [ + '* a', + ' * ', + ' []' + ], + changedBlocks: [ 2 ] + } ); + } ); + + it( 'should merge an item into the previous one (down) despite a block widget precededing it at a lower level', () => { + runTest( { + input: [ + '* a', + ' * ', + ' * []' + ], + expected: [ + '* a', + ' * ', + ' []' + ], + changedBlocks: [ 2 ] + } ); + } ); + + it( 'should merge into a block with a block widget', () => { + runTest( { + input: [ + '* ', + '* a[]' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: false + }, + expected: [ + '* ', + ' a[]' + ], + changedBlocks: [ 1 ] + } ); + } ); + } ); + + describe( 'inline images', () => { + it( 'should merge an empty list item into preceding list item containing an inline widget', () => { + runTest( { + input: [ + '* a', + '* []' + ], + expected: [ + '* a', + ' []' + ], + changedBlocks: [ 1 ] + } ); + } ); + } ); + } ); + } ); + } ); + + describe( 'forward', () => { + beforeEach( () => { + command = new DocumentListMergeCommand( editor, 'forward' ); + + command.on( 'afterExecute', ( evt, data ) => { + blocksChangedByCommands = data; + } ); + } ); + + describe( 'isEnabled', () => { + describe( 'collapsed selection', () => { + it( 'should be false when not in a list item', () => { + setData( model, modelList( [ + 'a[]' + ] ) ); + + expect( command.isEnabled ).to.be.false; + + setData( model, modelList( [ + '[]a', + '* b' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be true when there is a following list item', () => { + setData( model, modelList( [ + '* []', + '* a' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false when there is no following list item', () => { + setData( model, modelList( [ + '* []' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false when there is a following block in the same list item', () => { + setData( model, modelList( [ + '* []', + ' a' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + } ); + + describe( 'block object', () => { + it( 'should be false when not in a list item', () => { + setData( model, modelList( [ + '[]' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be true when there is a following list item', () => { + setData( model, modelList( [ + '* []', + '* a' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false when there is no following list item', () => { + setData( model, modelList( [ + '* []' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false when there is a following block in the same list item', () => { + setData( model, modelList( [ + '* []', + ' a' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + } ); + + describe( 'non-collapsed selection', () => { + it( 'should be false if the selection focuses in a non-list item', () => { + setData( model, modelList( [ + '* [a', + 'b]' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be true if the selection focuses in a list item', () => { + setData( model, modelList( [ + '* [a', + '* b]' + ] ) ); + + expect( command.isEnabled ).to.be.true; + + setData( model, modelList( [ + '[a', + '* b]' + ] ) ); + + // Because deleteContent must happen. + expect( command.isEnabled ).to.be.true; + } ); + } ); + } ); + + describe( 'execute()', () => { + it( 'should use parent batch', () => { + setData( model, modelList( [ + '* []', + '* a' + ] ) ); + + 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( 'single block list item', () => { + describe( 'collapsed selection at the end of a list item', () => { + describe( 'item after is empty', () => { + it( 'should remove next empty list item', () => { + runTest( { + input: [ + '* b[]', + '* ' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* b[]' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should remove next empty list item when current is empty', () => { + runTest( { + input: [ + '* []', + '* ' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should remove current list item if empty and replace with indented', () => { + runTest( { + input: [ + '* []', + ' * a' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []a {id:001}' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should remove next empty indented item list', () => { + runTest( { + input: [ + '* []', + ' * ' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should replace current empty list item with next list item', () => { + runTest( { + input: [ + '* ', + ' * []', + '* a' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* ', + ' * []a{id:002}' + ], + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should remove next empty list item when current is also empty', () => { + runTest( { + input: [ + '* ', + ' * []', + '* ' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* ', + ' * []' + ], + changedBlocks: [ 1 ] + } ); + } ); + } ); + + describe( 'next list item is not empty', () => { + it( 'should merge text from next list item with current list item text', () => { + runTest( { + input: [ + '* a[]', + '* b' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* a[]b' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should delete next empty item list', () => { + runTest( { + input: [ + '* a[]', + '* ' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* a[]' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge text of indented list item with current list item', () => { + runTest( { + input: [ + '* a[]', + ' * b' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* a[]b' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should remove indented empty list item', () => { + runTest( { + input: [ + '* a[]', + ' * ' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* a[]' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge text of lower indent list item', () => { + runTest( { + input: [ + '* a', + ' * b[]', + '* c' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* a', + ' * b[]c' + ], + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should delete next empty list item with lower ident', () => { + runTest( { + input: [ + '* a', + ' * b[]', + '* ' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* a', + ' * b[]' + ], + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge following item list of first block and adjust it\'s children', () => { + runTest( { + input: [ + '* a[]', + ' * b', + ' * c', + ' * d', + ' e', + ' * f', + ' * g', + ' h' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* a[]b', + ' * c {id:002}', + ' * d {id:003}', + ' e', + ' * f {id:005}', + ' * g {id:006}', + ' h' + ], + changedBlocks: [ 0, 1, 2, 3, 4, 5, 6 ] + } ); + } ); + + it( 'should merge following first block of an item list and make second block a first one', () => { + runTest( { + input: [ + '* a[]', + ' * b', + ' b2', + ' * c', + ' * d', + ' e' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* a[]b', + ' b2', + ' * c {id:003}', + ' * d {id:004}', + ' e' + ], + changedBlocks: [ 0, 1, 2, 3, 4 ] + } ); + } ); + } ); + } ); + + 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', + '* [', + '* ]' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + 'a', + '* []' + ], + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge non empty list item', () => { + runTest( { + input: [ + '* [', + '* ]text' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []text{id:001}' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge non empty list item and delete text', () => { + runTest( { + input: [ + '* [', + '* te]xt' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []xt{id:001}' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge and adjust indentation of child list item when end selection is at the beginning of item', () => { + runTest( { + input: [ + '* [', + '* a', + ' * ]b' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []', + '* b {id:002}' + ], + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should merge and adjust indentation of child list items', () => { + runTest( { + input: [ + '* [', + '* a', + ' * b]c', + ' * d' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []c{id:002}', + ' * d{id:003}' + ], + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should merge and adjust indentation of child list items when selection at the end of an item', () => { + runTest( { + input: [ + '* [', + '* a', + ' * bc]', + ' * d' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []{id:000}', + ' * d{id:003}' + ], + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should delete all items till the end of selection and merge last list item', () => { + runTest( { + input: [ + '* [', + '* a', + ' * b', + '* ]d' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []', + '* d {id:003}' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should delete all items and text till the end of selection and merge last list item', () => { + runTest( { + input: [ + '* [', + '* a', + ' * b', + '* d]e' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []e{id:003}' + ], + changedBlocks: [ 0 ] + } ); + } ); + } ); + + describe( 'first position in non-empty block', () => { + it( 'should merge two list items', () => { + runTest( { + input: [ + '* [text', + '* ano]ther' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []ther{id:001}' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge two list items if selection starts in the middle of text', () => { + runTest( { + input: [ + '* te[xt', + '* ano]ther' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* te[]ther' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge non empty list item', () => { + runTest( { + input: [ + '* text[', + '* ]another' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* text[]another' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge non empty list item and delete text', () => { + runTest( { + input: [ + '* text[', + '* ano]ther' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* text[]ther' + ], + changedBlocks: [ 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' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* text[]', + '* b {id:002}', + ' * c {id:003}' + ], + changedBlocks: [ 0, 1, 2 ] + } ); + } ); + + it( 'should merge and adjust indentation of child list items', () => { + runTest( { + input: [ + '* text[', + '* a', + ' * b]c', + ' * d' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* text[]c', + ' * d {id:003}' + ], + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should merge and adjust indentation of child list items when selection at the end of an item', () => { + runTest( { + input: [ + '* text[', + '* a', + ' * bc]', + ' * d' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* text[] {id:000}', + ' * d {id:003}' + ], + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should delete all items till the end of selection and merge last list item', () => { + runTest( { + input: [ + '* text[', + '* a', + ' * b', + '* ]d' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* text[]', + '* d {id:003}' + ], + changedBlocks: [ 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' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* text[]e' + ], + changedBlocks: [ 0 ] + } ); + } ); + } ); + } ); + } ); + + describe( 'multi-block list item', () => { + describe( 'collapsed selection at the end of a list item', () => { + describe( 'item after is empty', () => { + it( 'should remove empty list item', () => { + runTest( { + input: [ + '* a', + ' b[]', + '* ' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* a', + ' b[]' + ], + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge following complex list item with current one', () => { + runTest( { + input: [ + '* ', + ' []', + '* b', + ' c', + ' * d {id:d}', + ' e', + ' * f {id:f}', + ' * g {id:g}', + ' h', + ' * i {id:i}', + ' * j {id:j}', + ' k', + ' l' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* ', + '* []b {id:002}', + ' c', + ' * d {id:d}', + ' e', + ' * f {id:f}', + ' * g {id:g}', + ' h', + ' * i {id:i}', + ' * j {id:j}', + ' k', + ' l' + ], + changedBlocks: [ 1, 2, 11 ] + } ); + } ); + + it( 'should merge indented list item with with currently selected list item', () => { + runTest( { + input: [ + '* []', + ' * a', + ' b' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []a{id:001}', + ' b' + ], + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should merge indented list having block and indented list item with previous empty list item', () => { + runTest( { + input: [ + '* []', + ' * a', + ' b', + ' * c' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []a {id:001}', + ' b', + ' * c {id:003}' + ], + changedBlocks: [ 0, 1, 2 ] + } ); + } ); + + it( 'should merge indented list item with first block empty', () => { + runTest( { + input: [ + '* []', + ' * ', + ' text' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []', + ' text' + ], + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should merge next outdented list item', () => { + runTest( { + input: [ + '* ', + ' * []', + '* a', + ' b' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* ', + ' * []a {id:002}', + ' b' + ], + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should merge next outdented list item with first block empty', () => { + runTest( { + input: [ + '* ', + ' * []', + '* ', + ' text' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* ', + ' * []', + ' text' + ], + changedBlocks: [ 1, 2 ] + } ); + } ); + } ); + + describe( 'list item after is not empty', () => { + it( 'should merge with previous list item and keep blocks intact', () => { + runTest( { + input: [ + '* a[]', + '* b', + ' c' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* a[]b', + ' c' + ], + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should merge all following outdented blocks', () => { + runTest( { + input: [ + '* b', + ' * c', + ' c2[]', + ' d', + ' e' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* b', + ' * c', + ' c2[]d', + ' e' + ], + changedBlocks: [ 2, 3 ] + } ); + } ); + + it( 'should merge complex list item', () => { + runTest( { + input: [ + '* a', + ' a2[]', + '* b', + ' c', + ' * d', + ' e', + ' * f', + ' * g', + ' h', + ' * i', + ' * j', + ' k', + ' l' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* a', + ' a2[]b', + ' c', + ' * d {id:004}', + ' e', + ' * f {id:006}', + ' * g {id:007}', + ' h', + ' * i {id:009}', + ' * j {id:010}', + ' k', + ' l' + ], + changedBlocks: [ 1, 2, 11 ] + } ); + } ); + + it( 'should merge list item with next multi-block list item', () => { + runTest( { + input: [ + '* a', + ' a2[]', + '* b', + ' b2' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* a', + ' a2[]b', + ' b2' + ], + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should merge outdented multi-block list item', () => { + runTest( { + input: [ + '* a', + ' a2[]', + ' * b', + ' b2' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* a', + ' a2[]b', + ' b2' + ], + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should merge an outdented list item in an outdented list item', () => { + runTest( { + input: [ + '* a', + ' * b', + ' c[]', + ' * d' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* a', + ' * b', + ' c[]d' + ], + changedBlocks: [ 2 ] + } ); + } ); + + it( 'should merge indented empty list item', () => { + runTest( { + input: [ + '* a', + ' * b', + ' c[]', + ' * ' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* a', + ' * b', + ' c[]' + ], + changedBlocks: [ 2 ] + } ); + } ); + + it( 'should merge list item with with next outdented list item', () => { + runTest( { + input: [ + '* a', + ' * b[]', + '* c', + ' d' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* a', + ' * b[]c', + ' d' + ], + changedBlocks: [ 1, 2 ] + } ); + } ); + } ); + } ); + + describe( 'collapsed selection in the middle of the list item', () => { + it( 'should merge next indented list item', () => { + runTest( { + input: [ + '* A', + ' * B', + ' # C', + ' # D', + ' X[]', + ' # Z', + ' V', + '* E', + '* F' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* A', + ' * B', + ' # C', + ' # D', + ' X[]Z', + ' V', + '* E {id:007}', + '* F {id:008}' + ], + changedBlocks: [ 4 ] + } ); + } ); + } ); + + 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: [ + '* [', + '* ]', + ' ' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []', + ' ' + ], + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should merge non empty list item', () => { + runTest( { + input: [ + '* [', + '* ]text' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []text {id:001}' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge non empty list item and delete text', () => { + runTest( { + input: [ + '* [', + '* te]xt' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []xt {id:001}' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge and adjust indentation of child list item when end selection is at the beginning of item', () => { + runTest( { + input: [ + '* [', + '* a', + ' * ]b' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []', + '* b {id:002}' + ], + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should merge and adjust indentation of child list items', () => { + runTest( { + input: [ + '* [', + '* a', + ' * b]c', + ' * d' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []c {id:002}', + ' * d {id:003}' + ], + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should merge and adjust indentation of child list items when selection at the end of an item', () => { + runTest( { + input: [ + '* [', + '* a', + ' * bc]', + ' * d' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* [] {id:000}', + ' * d {id:003}' + ], + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should delete all items till the end of selection and merge last list item', () => { + runTest( { + input: [ + '* [', + '* a', + ' * b', + '* ]d' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []', + '* d {id:003}' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should delete all items and text till the end of selection and merge last list item', () => { + runTest( { + input: [ + '* [', + '* a', + ' * b', + '* d]e' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []e{id:003}' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should delete all following items till the end of selection and merge last list item', () => { + runTest( { + input: [ + '* [', + ' text', + '* a', + ' * b', + '* d]e' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []e {id:004}' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should delete all following items till the end of selection and merge last list itemx', () => { + runTest( { + input: [ + '* [', + ' * b', + ' ]c', + ' * d', + ' e' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []', + '* c', + ' * d {id:003}', + ' e' + ], + changedBlocks: [ 0, 1, 2, 3 ] + } ); + } ); + + it( 'should delete items till the end of selection and merge middle block with following blocks', () => { + runTest( { + input: [ + '* [', + ' * b', + ' c]d', + ' * e', + ' f' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []d {id:001}', + ' * e {id:003}', + ' f' + ], + changedBlocks: [ 0, 1, 2 ] + } ); + } ); + + it( 'should delete items till the end of selection and merge following blocks', () => { + runTest( { + input: [ + '* [', + ' * b', + ' cd]', + ' * e', + ' f', + ' s' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []', + ' * e {id:003}', + ' f', + '* s {id:001}' + ], + changedBlocks: [ 0, 1, 2, 3 ] + } ); + } ); + } ); + + describe( 'first position in non-empty block', () => { + it( 'should merge two list items', () => { + runTest( { + input: [ + '* [text', + '* ano]ther', + ' text' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []ther {id:001}', + ' text' + ], + changedBlocks: [ 0, 1 ] + } ); + } ); + + // Not related to merge command + it( 'should merge two list items with selection in the middle', () => { + runTest( { + input: [ + '* te[xt', + '* ano]ther' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* te[]ther' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge non empty list item', () => { + runTest( { + input: [ + '* [', + '* ]text' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []text {id:001}' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge non empty list item and delete text', () => { + runTest( { + input: [ + '* [', + '* te]xt' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []xt{id:001}' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge and adjust indentation of child list item when end selection is at the beginning of item', () => { + runTest( { + input: [ + '* [', + '* a', + ' * ]b', + ' * c' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []', + '* b {id:002}', + ' * c {id:003}' + ], + changedBlocks: [ 0, 1, 2 ] + } ); + } ); + + it( 'should merge and adjust indentation of child list items', () => { + runTest( { + input: [ + '* [', + '* a', + ' * b]c', + ' * d' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []c{id:002}', + ' * d{id:003}' + ], + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should merge and adjust indentation of child list items when selection at the end of an item', () => { + runTest( { + input: [ + '* [', + '* a', + ' * bc]', + ' * d' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* [] {id:000}', + ' * d {id:003}' + ], + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should delete all items till the end of selection and merge last list item', () => { + runTest( { + input: [ + '* [', + '* a', + ' * b', + '* ]d' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []', + '* d {id:003}' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should delete all items and text till the end of selection and merge last list item', () => { + runTest( { + input: [ + '* [', + '* a', + ' * b', + '* d]e' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []e{id:003}' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should delete all items and text till the end of selection and adjust orphan elements', () => { + runTest( { + input: [ + '* [', + '* a', + ' * b]', + ' c', + ' * d', + ' e', + ' f', + ' g' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []', + ' c', + ' * d {id:004}', + ' e', + '* f {id:001}', + ' g' + ], + changedBlocks: [ 0, 1, 2, 3 ] + } ); + } ); + } ); + } ); + } ); + + describe( 'selection outside list', () => { + describe( 'non-collapsed selection', () => { + describe( 'only end in a list', () => { + it( 'should delete everything till end of selection', () => { + runTest( { + input: [ + '[', + '* te]xt' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* []xt {id:001}' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should delete everything till the end of selection and adjust remaining block to item list', () => { + runTest( { + input: [ + 'a[', + '* b]b', + ' c' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + 'a[]b', + '* c' + ], + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should delete everything till the end of selection and adjust remaining item list indentation', () => { + runTest( { + input: [ + 'a[', + '* b]b', + ' * c' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + 'a[]b', + ' * c {id:002}' + ], + // Note: Technically speaking "c" should also be included but wasn't; was fixed by model post-fixer. + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should delete selection and adjust remaining item list indentation (multi-block)', () => { + runTest( { + input: [ + 'a[', + '* b]b', + ' * c', + ' d' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + 'a[]b', + ' * c {id:002}', + ' d' + ], + // Note: Technically speaking "c" and "d" should also be included but weren't; fixed by model post-fixer. + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should remove selection and adjust remaining list', () => { + runTest( { + input: [ + 'a[', + '* b]b', + ' * c', + ' d' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + 'a[]b', + ' * c {id:002}', + '* d {id:001}' + ], + // Note: Technically speaking "c" and "d" should also be included but weren't; fixed by model post-fixer. + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should remove selection and adjust remaining list (multi-block)', () => { + runTest( { + input: [ + 'a[', + '* b', + ' * c', + ' d]d', + ' * e', + ' f' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + 'a[]d', + ' * e {id:004}', + ' f' + ], + changedBlocks: [ 0, 1, 2 ] + } ); + } ); + } ); + + describe( 'spanning multiple lists', () => { + it( 'should merge lists into one with one list item', () => { + runTest( { + input: [ + '* a[a', + 'b', + '* c]c' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* a[]c' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge lists into one with two blocks', () => { + runTest( { + input: [ + '* a', + ' b[b', + 'c', + '* d]d' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* a', + ' b[]d' + ], + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge two lists into one with two list items', () => { + runTest( { + input: [ + '* a[', + 'c', + '* d]', + '* e' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* a[]', + '* e {id:003}' + ], + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge two lists into one with two list items (multiple blocks)', () => { + runTest( { + input: [ + '* a[', + 'c', + '* d]', + ' e', + '* f' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* a[]', + ' e', + '* f {id:004}' + ], + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should merge two lists into one with two list items and adjust indentation', () => { + runTest( { + input: [ + '* a[', + 'c', + '* d', + ' * e]e', + ' * f', + ' g' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* a[]e', + ' * f {id:004}', + ' g' + ], + changedBlocks: [ 0, 1, 2 ] + } ); + } ); + + it( 'should merge two lists into one with deeper indendation', () => { + runTest( { + input: [ + '* a', + ' * b[', + 'c', + '* d', + ' * e', + ' * f]f', + ' * g' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* a', + ' * b[]f', + ' * g {id:006}' + ], + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should merge two lists into one with deeper indentation (multiple blocks)', () => { + runTest( { + input: [ + '* a', + ' * b[', + 'c', + '* d', + ' * e]e', + ' * f', + ' g' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* a', + ' * b[]e', + ' * f {id:005}', + ' g' + ], + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge two lists into one and keep items after selection', () => { + runTest( { + input: [ + '* a[', + 'c', + '* d', + ' * e]e', + '* f', + ' g' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* a[]e', + '* f {id:004}', + ' g' + ], + changedBlocks: [ 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' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* a', + '* b[]d', + '# d {id:004}' + ], + changedBlocks: [ 1 ] + } ); + } ); + + 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' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* a', + '# b[]d', + ' * f {id:004}' + ], + changedBlocks: [ 1 ] + } ); + } ); + } ); + } ); + } ); + + describe( 'around widgets', () => { + describe( 'block widgets', () => { + it( 'should merge into a block with a block widget', () => { + runTest( { + input: [ + '* a[]', + '* ' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: false + }, + expected: [ + '* a[]', + ' ' + ], + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge into a nested block with a block widget', () => { + runTest( { + input: [ + '* a[]', + ' * ' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: false + }, + expected: [ + '* a[]', + ' ' + ], + changedBlocks: [ 1 ] + } ); + } ); + } ); + + describe( 'inline images', () => { + it( 'should merge a list item into following list item containing an inline widget', () => { + runTest( { + input: [ + '* a[]', + '* b' + ], + commandOptions: { + shouldMergeOnBlocksContentLevel: true + }, + expected: [ + '* a[]b' + ], + changedBlocks: [ 0 ] + } ); + } ); + } ); + } ); + } ); + } ); + + // @param {Iterable.} input + // @param {Iterable.} expected + // @param {Array.} changedBlocks Indexes of changed blocks. + function runTest( { input, commandOptions, expected, changedBlocks = [] } ) { + setData( model, modelList( input ) ); + + if ( !command.isEnabled ) { + throw new Error( 'Yikes. The command is disabled but should be executed.' ); + } + + command.execute( commandOptions ); + + expect( getData( model ) ).to.equalMarkup( modelList( expected ) ); + + expect( blocksChangedByCommands.map( block => block.index ) ).to.deep.equal( changedBlocks, 'changed blocks\' indexes' ); + } +} ); diff --git a/packages/ckeditor5-list/tests/documentlist/integrations/clipboard.js b/packages/ckeditor5-list/tests/documentlist/integrations/clipboard.js new file mode 100644 index 00000000000..219d0998c04 --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/integrations/clipboard.js @@ -0,0 +1,500 @@ +/** + * @license Copyright (c) 2003-2022, 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 TableEditing from '@ckeditor/ckeditor5-table/src/tableediting'; +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 { + 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 integrations: clipboard copy & paste', () => { + let editor, model, modelDoc, modelRoot, view; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + editor = await VirtualTestEditor.create( { + plugins: [ + Paragraph, ClipboardPipeline, BoldEditing, DocumentListEditing, UndoEditing, + BlockQuoteEditing, TableEditing, HeadingEditing + ] + } ); + + 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(); + } ); + + afterEach( async () => { + 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( '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 paragraph = writer.createElement( 'paragraph', { listType: 'bulleted', listItemId: 'x', listIndent: '0' } ); + writer.insertText( 'X', paragraph ); + + model.insertContent( paragraph ); + } ); + + 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( '
  • X
  • ' ) + } ); + } ); + + 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( '
  • X
    • Y
  • ' ) + } ); + } ); + + 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.js b/packages/ckeditor5-list/tests/documentlist/integrations/delete.js new file mode 100644 index 00000000000..3bfb4966519 --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/integrations/delete.js @@ -0,0 +1,6599 @@ +/** + * @license Copyright (c) 2003-2022, 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 integrations: backspace & delete', () => { + const blocksChangedByCommands = []; + + let element; + let editor, model, view; + let eventInfo, domEventData; + let mergeBackwardCommand, mergeForwardCommand, splitAfterCommand, outdentCommand, + commandSpies, + mergeBackwardCommandExecuteSpy, mergeForwardCommandExecuteSpy, splitAfterCommandExecuteSpy, outdentCommandExecuteSpy; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + element = document.createElement( 'div' ); + document.body.appendChild( element ); + + editor = await ClassicTestEditor.create( element, { + plugins: [ + DocumentListEditing, Paragraph, Delete, Widget + ] + } ); + + model = editor.model; + view = editor.editing.view; + + model.schema.extend( 'paragraph', { + allowAttributes: 'foo' + } ); + + model.schema.register( 'blockWidget', { + isObject: 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(); + + eventInfo = new BubblingEventInfo( view.document, 'delete' ); + + splitAfterCommand = editor.commands.get( 'splitListItemAfter' ); + outdentCommand = editor.commands.get( 'outdentList' ); + mergeBackwardCommand = editor.commands.get( 'mergeListItemBackward' ); + mergeForwardCommand = editor.commands.get( 'mergeListItemForward' ); + + splitAfterCommandExecuteSpy = sinon.spy(); + outdentCommandExecuteSpy = sinon.spy(); + mergeBackwardCommandExecuteSpy = sinon.spy(); + mergeForwardCommandExecuteSpy = sinon.spy(); + + splitAfterCommand.on( 'execute', splitAfterCommandExecuteSpy ); + outdentCommand.on( 'execute', outdentCommandExecuteSpy ); + mergeBackwardCommand.on( 'execute', mergeBackwardCommandExecuteSpy ); + mergeForwardCommand.on( 'execute', mergeForwardCommandExecuteSpy ); + + commandSpies = { + outdent: outdentCommandExecuteSpy, + splitAfter: splitAfterCommandExecuteSpy, + mergeBackward: mergeBackwardCommandExecuteSpy, + mergeForward: mergeForwardCommandExecuteSpy + }; + + blocksChangedByCommands.length = 0; + + outdentCommand.on( 'afterExecute', ( evt, data ) => { + blocksChangedByCommands.push( ...data ); + } ); + + splitAfterCommand.on( 'afterExecute', ( evt, data ) => { + blocksChangedByCommands.push( ...data ); + } ); + + mergeBackwardCommand.on( 'afterExecute', ( evt, data ) => { + blocksChangedByCommands.push( ...data ); + } ); + + mergeForwardCommand.on( 'afterExecute', ( evt, data ) => { + blocksChangedByCommands.push( ...data ); + } ); + } ); + + afterEach( async () => { + element.remove(); + + 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, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + 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, + mergeBackward: 0, + mergeForward: 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge empty list item with with previous empty list item', () => { + runTest( { + input: [ + '* ', + '* []' + ], + expected: [ + '* []' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge indented list item with with previous empty list item', () => { + runTest( { + input: [ + '* ', + ' * []a' + ], + expected: [ + '* []a {id:001}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge indented empty list item with with previous empty list item', () => { + runTest( { + input: [ + '* ', + ' * []' + ], + expected: [ + '* []' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge list item with with previous indented empty list item', () => { + runTest( { + input: [ + '* ', + ' * ', + '* []a' + ], + expected: [ + '* ', + ' * []a{id:002}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge empty list item with with previous indented empty list item', () => { + runTest( { + input: [ + '* ', + ' * ', + '* []' + ], + expected: [ + '* ', + ' * []' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 1 ] + } ); + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge empty list item with with previous list item as a block', () => { + runTest( { + input: [ + '* a', + '* []' + ], + expected: [ + '* a', + ' []' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge indented list item with with parent list item as a block', () => { + runTest( { + input: [ + '* a', + ' * []b' + ], + expected: [ + '* a', + ' []b' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge indented empty list item with with parent list item as a block', () => { + runTest( { + input: [ + '* a', + ' * []' + ], + expected: [ + '* a', + ' []' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 1 ] + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 2 ] + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 2 ] + } ); + } ); + + it( 'should keep merged list item\'s children', () => { + runTest( { + input: [ + '* a', + ' * []b', + ' * c', + ' * d', + ' e', + ' * f', + ' * g', + ' h' + ], + expected: [ + '* a', + ' []b', + ' * c', + ' * d', + ' e', + ' * f', + ' * g', + ' h' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 1, 2, 3, 4, 5, 6, 7 ] + } ); + } ); + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 0 ] + } ); + } ); + + // Default behaviour of backspace? + it( 'should merge empty list item with with previous empty list item', () => { + runTest( { + input: [ + '* ', + '* []' + ], + expected: [ + '* []' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge indented list item with with previous empty list item', () => { + runTest( { + input: [ + '* ', + ' * []a' + ], + expected: [ + '* []a {id:001}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge indented empty list item with with previous empty list item', () => { + runTest( { + input: [ + '* ', + ' * []' + ], + expected: [ + '* []' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge list item with with previous indented empty list item', () => { + runTest( { + input: [ + '* ', + ' * ', + '* []a' + ], + expected: [ + '* ', + ' * []a{id:002}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge empty list item with with previous indented empty list item', () => { + runTest( { + input: [ + '* ', + ' * ', + '* []' + ], + expected: [ + '* ', + ' * []' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 1 ] + } ); + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge empty list item with with previous list item as a block', () => { + runTest( { + input: [ + '* a', + '* []' + ], + expected: [ + '* a', + ' []' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge indented list item with with parent list item as a block', () => { + runTest( { + input: [ + '* a', + ' * []b' + ], + expected: [ + '* a', + ' []b' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge indented empty list item with with parent list item as a block', () => { + runTest( { + input: [ + '* a', + ' * []' + ], + expected: [ + '* a', + ' []' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 1 ] + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 2 ] + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 2 ] + } ); + } ); + + it( 'should keep merged list item\'s children', () => { + runTest( { + input: [ + '* a', + ' * []b', + ' * c', + ' * d', + ' e', + ' * f', + ' * g', + ' h' + ], + expected: [ + '* a', + ' []b', + ' * c', + ' * d', + ' e', + ' * f', + ' * g', + ' h' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 1, 2, 3, 4, 5, 6, 7 ] + } ); + } ); + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge non empty list item', () => { + runTest( { + input: [ + '* [', + '* ]text' + ], + expected: [ + '* []text{id:001}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge non empty list item and delete text', () => { + runTest( { + input: [ + '* [', + '* te]xt' + ], + expected: [ + '* []xt{id:001}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should merge and adjust indentation of child list items', () => { + runTest( { + input: [ + '* [', + '* a', + ' * b]c', + ' * d' + ], + expected: [ + '* []c{id:002}', + ' * d{id:003}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + } ); + + describe( 'first position in non-empty block', () => { + it( 'should merge two list items', () => { + runTest( { + input: [ + '* [text', + '* ano]ther' + ], + expected: [ + '* []ther{id:001}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge two list items if selection is in the middle', () => { + runTest( { + input: [ + '* te[xt', + '* ano]ther' + ], + expected: [ + '* te[]ther' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge non empty list item', () => { + runTest( { + input: [ + '* text[', + '* ]another' + ], + expected: [ + '* text[]another' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge non empty list item and delete text', () => { + runTest( { + input: [ + '* text[', + '* ano]ther' + ], + expected: [ + '* text[]ther' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1, 2 ] + } ); + } ); + + it( 'should merge and adjust indentation of child list items', () => { + runTest( { + input: [ + '* text[', + '* a', + ' * b]c', + ' * d' + ], + expected: [ + '* text[]c', + ' * d{id:003}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + } ); + } ); + } ); + + describe( 'multi-block list item', () => { + describe( 'collapsed selection at the beginning of a list item', () => { + describe( 'no item before', () => { + it( 'should split the list item and then outdent if selection anchored in a first empty of many blocks', () => { + runTest( { + input: [ + '* []', + ' a', + ' b' + ], + expected: [ + '[]', + '* a {id:a00}', + ' b' + ], + eventStopped: true, + executedCommands: { + outdent: 1, + splitAfter: 1, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [ 1, 2, 0 ] + } ); + } ); + } ); + + describe( 'item before is empty', () => { + it( 'should merge with previous list item and keep blocks intact', () => { + runTest( { + input: [ + '* ', + '* []b', + ' c' + ], + expected: [ + '* []b{id:001}', + ' c' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should merge with previous list item and keep complex blocks intact ', () => { + runTest( { + input: [ + '* ', + '* []b', + ' c', + ' * d', + ' e', + ' * f', + ' * g', + ' h', + ' * i', + ' * j', + ' k', + ' l' + ], + expected: [ + '* []b {id:001}', + ' c', + ' * d {id:003}', + ' e', + ' * f {id:005}', + ' * g {id:006}', + ' h', + ' * i {id:008}', + ' * j {id:009}', + ' k', + ' l' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 0, 1, 10 ] + } ); + } ); + + it( 'should merge list item with first block empty with previous empty list item', () => { + runTest( { + input: [ + '* ', + '* []', + ' a' + ], + expected: [ + '* []', + ' a' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should merge indented list item with with previous empty list item', () => { + runTest( { + input: [ + '* ', + ' * []a', + ' b' + ], + expected: [ + '* []a {id:001}', + ' b' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should merge indented list having block and indented list item with previous empty list item', () => { + runTest( { + input: [ + '* ', + ' * []a', + ' b', + ' * c' + ], + expected: [ + '* []a {id:001}', + ' b', + ' * c {id:003}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 0, 1, 2 ] + } ); + } ); + + it( 'should merge indented empty list item with previous empty list item', () => { + runTest( { + input: [ + '* ', + ' * []', + ' text' + ], + expected: [ + '* []', + ' text' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should merge list item with with previous indented empty list item', () => { + runTest( { + input: [ + '* ', + ' * ', + '* []a', + ' b' + ], + expected: [ + '* ', + ' * []a{id:002}', + ' b' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should merge empty list item with with previous indented empty list item', () => { + runTest( { + input: [ + '* ', + ' * ', + '* []', + ' text' + ], + expected: [ + '* ', + ' * []', + ' text' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 1, 2 ] + } ); + } ); + } ); + + describe( 'item before is not empty', () => { + it( 'should merge with previous list item and keep blocks intact', () => { + runTest( { + input: [ + '* a', + '* []b', + ' c' + ], + expected: [ + '* a', + ' []b', + ' c' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should merge block to a previous list item', () => { + runTest( { + input: [ + '* b', + ' * c', + ' []d', + ' e' + ], + expected: [ + '* b', + ' * c', + ' []d', + ' e' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 2 ] + } ); + } ); + + it( 'should merge with previous list item and keep complex blocks intact', () => { + runTest( { + input: [ + '* a', + '* []b', + ' c', + ' * d', + ' e', + ' * f', + ' * g', + ' h', + ' * i', + ' * j', + ' k', + ' l' + ], + expected: [ + '* a', + ' []b', + ' c', + ' * d', + ' e', + ' * f', + ' * g', + ' h', + ' * i', + ' * j', + ' k', + ' l' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 1, 2, 11 ] + } ); + } ); + + it( 'should merge list item with first block empty with previous list item', () => { + runTest( { + input: [ + '* a', + '* []', + ' b' + ], + expected: [ + '* a', + ' []', + ' b' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should merge indented list item with with previous list item as blocks', () => { + runTest( { + input: [ + '* a', + ' * []a', + ' b' + ], + expected: [ + '* a', + ' []a', + ' b' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should merge indented list having block and indented list item with previous list item', () => { + runTest( { + input: [ + '* a', + ' * []b', + ' c', + ' * d' + ], + expected: [ + '* a', + ' []b', + ' c', + ' * d' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 1, 2, 3 ] + } ); + } ); + + it( 'should merge indented empty list item with previous list item', () => { + runTest( { + input: [ + '* a', + ' * []', + ' text' + ], + expected: [ + '* a', + ' []', + ' text' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should merge list item with with previous indented empty list item', () => { + runTest( { + input: [ + '* a', + ' * b', + '* []c', + ' d' + ], + expected: [ + '* a', + ' * b', + ' []c', + ' d' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 2, 3 ] + } ); + } ); + } ); + } ); + + describe( 'collapsed selection in the middle of the list item', () => { + it( 'should merge block to a previous list item', () => { + runTest( { + input: [ + '* A', + ' * B', + ' # C', + ' # D', + ' []X', + ' # Z', + ' V', + '* E', + '* F' + ], + expected: [ + '* A', + ' * B', + ' # C', + ' # D', + ' []X', + ' # Z', + ' V', + '* E', + '* F' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 4, 5 ] + } ); + } ); + } ); + + 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: [ + '* [', + '* ]', + ' ' + ], + expected: [ + '* []', + ' ' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should merge non empty list item', () => { + runTest( { + input: [ + '* [', + '* ]text' + ], + expected: [ + '* []text{id:001}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge non empty list item and delete text', () => { + runTest( { + input: [ + '* [', + '* te]xt' + ], + expected: [ + '* []xt{id:001}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should merge and adjust indentation of child list items', () => { + runTest( { + input: [ + '* [', + '* a', + ' * b]c', + ' * d' + ], + expected: [ + '* []c{id:002}', + ' * d{id:003}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should delete all following items till the end of selection and merge last list item', () => { + runTest( { + input: [ + '* [', + ' text', + '* a', + ' * b', + '* d]e' + ], + expected: [ + '* []e{id:004}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should delete all following items till the end of selection and merge last list itemx', () => { + runTest( { + input: [ + '* [', + ' * b', + ' ]c', + ' * d', + ' e' + ], + expected: [ + '* []', + '* c', + ' * d {id:003}', + ' e' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1, 2, 3 ] + } ); + } ); + + it( 'should delete items till the end of selection and merge middle block with following blocks', () => { + runTest( { + input: [ + '* [', + ' * b', + ' c]d', + ' * e', + ' f' + ], + expected: [ + '* []d{id:001}', + ' * e{id:003}', + ' f' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1, 2 ] + } ); + } ); + + it( 'should delete items till the end of selection and merge following blocks', () => { + runTest( { + input: [ + '* [', + ' * b', + ' cd]', + ' * e', + ' f', + ' s' + ], + expected: [ + '* []', + ' * e {id:003}', + ' f', + '* s {id:001}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1, 2, 3 ] + } ); + } ); + } ); + + describe( 'first position in non-empty block', () => { + it( 'should merge two list items', () => { + runTest( { + input: [ + '* [text', + '* ano]ther', + ' text' + ], + expected: [ + '* []ther{id:001}', + ' text' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + // Not related to merge command + it( 'should merge two list items with selection in the middle', () => { + runTest( { + input: [ + '* te[xt', + '* ano]ther' + ], + expected: [ + '* te[]ther' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge non empty list item', () => { + runTest( { + input: [ + '* [', + '* ]text' + ], + expected: [ + '* []text{id:001}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge non empty list item and delete text', () => { + runTest( { + input: [ + '* [', + '* te]xt' + ], + expected: [ + '* []xt{id:001}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge and adjust indentation of child list item when end selection is at the beginning of item', () => { + runTest( { + input: [ + '* [', + '* a', + ' * ]b', + ' * c' + ], + expected: [ + '* []', + '* b {id:002}', + ' * c {id:003}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1, 2 ] + } ); + } ); + + it( 'should merge and adjust indentation of child list items', () => { + runTest( { + input: [ + '* [', + '* a', + ' * b]c', + ' * d' + ], + expected: [ + '* []c{id:002}', + ' * d{id:003}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should delete all items and text till the end of selection and adjust orphan elements', () => { + runTest( { + input: [ + '* [', + '* a', + ' * b]', + ' c', + ' * d', + ' e', + ' f', + ' g' + ], + expected: [ + '* []', + ' c', + ' * d {id:004}', + ' e', + '* f {id:001}', + ' g' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1, 2, 3 ] + } ); + } ); + } ); + } ); + } ); + + describe( 'selection outside list', () => { + it( 'should not engage for a
  • 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, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + 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, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + 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, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + 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, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + 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, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + 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, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + 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, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + 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, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + 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, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + } ); + + 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, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + 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, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + } ); + + 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, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'no list editing commands should be executed when doing delete (multi-block list)', () => { + runTest( { + input: [ + '* te[xt1', + ' text2', + ' * text3', + 'text4]' + ], + expected: [ + '* te[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should delete everything till end of selection and merge remaining text', () => { + runTest( { + input: [ + '* text1', + ' tex[t2', + ' * text3', + 'tex]t4' + ], + expected: [ + '* text1', + ' tex[]t4' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + } ); + + describe( 'only end in a list', () => { + it( 'should delete everything till end of selection', () => { + runTest( { + input: [ + '[', + '* te]xt' + ], + expected: [ + '* []xt {id:001}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 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' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + // Note: Technically speaking "c" should also be included but wasn't; was fixed by model post-fixer. + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should delete selection and adjust remaining item list indentation (multi-block)', () => { + runTest( { + input: [ + 'a[', + '* b]b', + ' * c', + ' d' + ], + expected: [ + 'a[]b', + '* c {id:002}', + ' d' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + // Note: Technically speaking "c" and "d" should also be included but weren't; fixed by model post-fixer. + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should remove selection and adjust remaining list', () => { + runTest( { + input: [ + 'a[', + '* b]b', + ' * c', + ' d' + ], + expected: [ + 'a[]b', + '* c {id:002}', + '* d {id:001}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + // Note: Technically speaking "c" and "d" should also be included but weren't; fixed by model post-fixer. + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should remove selection and adjust remaining list (multi-block)', () => { + runTest( { + input: [ + 'a[', + '* b', + ' * c', + ' d]d', + ' * e', + ' f' + ], + expected: [ + 'a[]d', + '* e {id:004}', + ' f' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1, 2 ] + } ); + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge lists into one with two blocks', () => { + runTest( { + input: [ + '* a', + ' b[b', + 'c', + '* d]d' + ], + expected: [ + '* a', + ' b[]d' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge two lists into one with two list items', () => { + runTest( { + input: [ + '* a[', + 'c', + '* d]', + '* e' + ], + expected: [ + '* a[]', + '* e {id:003}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge two lists into one with two list items (multiple blocks)', () => { + runTest( { + input: [ + '* a[', + 'c', + '* d]', + ' e', + '* f' + ], + expected: [ + '* a[]', + ' e', + '* f {id:004}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should merge two lists into one with two list items and adjust indentation', () => { + runTest( { + input: [ + '* a[', + 'c', + '* d', + ' * e]e', + ' * f', + ' g' + ], + expected: [ + '* a[]e', + ' * f {id:004}', + ' g' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1, 2 ] + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should merge two lists into one with deeper indentation (multiple blocks)', () => { + runTest( { + input: [ + '* a', + ' * b[', + 'c', + '* d', + ' * e]e', + ' * f', + ' g' + ], + expected: [ + '* a', + ' * b[]e', + ' * f {id:005}', + ' g' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge two lists into one and keep items after selection', () => { + runTest( { + input: [ + '* a[', + 'c', + '* d', + ' * e]e', + '* f', + ' g' + ], + expected: [ + '* a[]e', + '* f {id:004}', + ' g' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 1 ] + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 1 ] + } ); + } ); + } ); + } ); + } ); + + describe( 'around widgets', () => { + describe( 'block widgets', () => { + it( 'should delete a paragraph and select a block widget in a list that precedes it', () => { + runTest( { + input: [ + '* ', + '[]' + ], + expected: [ + '* []' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should select a block widget in a list that precedes a non-empty paragraph', () => { + runTest( { + input: [ + '* ', + '[]foo' + ], + expected: [ + '* []', + 'foo' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should delete a paragraph and select a block widget at a deeper level (2nd block) in a list that precedes it', () => { + runTest( { + input: [ + '* a', + ' ', + '[]' + ], + expected: [ + '* a', + ' []' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should delete a paragraph and select a block widget at a deeper level (1st block) in a list that precedes it', () => { + runTest( { + input: [ + '* a', + ' * ', + '[]' + ], + expected: [ + '* a', + ' * []' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should merge an item into the previous one despite a block widget precededing it', () => { + runTest( { + input: [ + '* a', + ' ', + '* []' + ], + expected: [ + '* a', + ' ', + ' []' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 2 ] + } ); + } ); + + it( 'should merge an item into the previous one despite a block widget precededing it at a deeper level', () => { + runTest( { + input: [ + '* a', + ' * ', + '* []' + ], + expected: [ + '* a', + ' * ', + ' []' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 2 ] + } ); + } ); + + it( 'should merge an item into the previous one (down) despite a block widget precededing it at a lower level', () => { + runTest( { + input: [ + '* a', + ' * ', + ' * []' + ], + expected: [ + '* a', + ' * ', + ' []' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 2 ] + } ); + } ); + + it( 'should delete an item block and select a block widget that precedes it', () => { + runTest( { + input: [ + '* a', + ' b', + ' ', + ' []' + ], + expected: [ + '* a', + ' b', + ' []' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it.skip( 'should delete a block widget and move the selection to the list item block that precedes it', () => { + runTest( { + input: [ + '* a', + ' []' + ], + expected: [ + '* a', + ' []' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [] + } ); + } ); + + it.skip( 'should delete a block widget and move the selection to the block that precedes it (multiple blocks)', () => { + runTest( { + input: [ + '* a', + ' []', + ' b' + ], + expected: [ + '* a', + ' []', + ' b' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [] + } ); + } ); + + it.skip( 'should delete a block widget and move the selection to the block that precedes it (nested item follows)', () => { + runTest( { + input: [ + '* a', + ' []', + ' * b' + ], + expected: [ + '* a', + ' []', + ' * b {id:002}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [] + } ); + } ); + + it.skip( 'should delete a block widget and move the selection down to the (shallower) block that precedes it', () => { + runTest( { + input: [ + '* a', + ' * []' + ], + expected: [ + '* a', + ' * []' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [] + } ); + } ); + + it.skip( 'should delete a block widget and move the selection down to the block that precedes it (multiple blocks)', () => { + runTest( { + input: [ + '* a', + ' * []', + ' b' + ], + expected: [ + '* a', + ' * []', + ' b' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [] + } ); + } ); + + it( 'should remove list when its entire cotent is selected (including a block widget), same indentation levels', () => { + runTest( { + input: [ + '* [a', + ' ]' + ], + expected: [ + '[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should remove multiple list item blocks (including a block widget) within the selection, block follows', () => { + runTest( { + input: [ + '* [a', + ' ]', + ' b' + ], + expected: [ + '* []', + ' b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should remove multiple list item blocks (including a block widget) within the selection, nested block follows', () => { + runTest( { + input: [ + '* [a', + ' ]', + ' * b' + ], + expected: [ + '* []', + ' * b {id:002}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should remove list when its entire cotent is selected (including a block widget), mixed indentation levels', () => { + runTest( { + input: [ + '* [a', + ' * ]' + ], + expected: [ + '[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should remove multiple list item blocks (including a block widget) within the selection, mixed indent levels', () => { + runTest( { + input: [ + '* [a', + ' * ]', + ' * b' + ], + expected: [ + '* []', + ' * b {id:002}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should remove multiple list item blocks (including a block widget) within the selection, ' + + 'mixed indent levels, following block', () => { + runTest( { + input: [ + '* [a', + ' * ]', + ' b' + ], + expected: [ + '* []', + ' b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should remove multiple list item blocks (including a block widget) within the selection, ' + + 'mixed indent levels, following block at a deeper level', () => { + runTest( { + input: [ + '* [a', + ' * ]', + ' b' + ], + expected: [ + '* []', + ' * b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it.skip( 'should remove a block widget surrounded by block containing inline images at boundaries', () => { + runTest( { + input: [ + '* a', + ' []', + ' b' + ], + expected: [ + '* a', + ' []', + ' b' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [] + } ); + } ); + } ); + + describe( 'inline images', () => { + it( 'should remove an inline widget if only content of a block', () => { + runTest( { + input: [ + '* []' + ], + expected: [ + '* []' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should merge a paragraph into preceding list containing an inline widget', () => { + runTest( { + input: [ + '* a', + '[]' + ], + expected: [ + '* a[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should merge an empty list item into preceding list item containing an inline widget', () => { + runTest( { + input: [ + '* a', + '* []' + ], + expected: [ + '* a', + ' []' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 1, + mergeForward: 0 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should remove an inline widget in a list item block containing other content (before)', () => { + runTest( { + input: [ + '* a[]' + ], + expected: [ + '* a[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should remove an inline widget in a list item block containing other content (after)', () => { + runTest( { + input: [ + '* []a' + ], + expected: [ + '* []a' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should remove an inline widget in a middle list item block', () => { + runTest( { + input: [ + '* a', + ' []', + ' b' + ], + expected: [ + '* a', + ' []', + ' b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should remove an inline widget in a nested list item block', () => { + runTest( { + input: [ + '* a', + ' * []', + ' b' + ], + expected: [ + '* a', + ' * []', + ' b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + } ); + } ); + } ); + + 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, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + 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, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should remove next empty list item', () => { + runTest( { + input: [ + '* b[]', + '* ' + ], + expected: [ + '* b[]' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should remove next empty list item when current is empty', () => { + runTest( { + input: [ + '* []', + '* ' + ], + expected: [ + '* []' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should remove current list item if empty and replace with indented', () => { + runTest( { + input: [ + '* []', + ' * a' + ], + expected: [ + '* []a {id:001}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should remove next empty indented item list', () => { + runTest( { + input: [ + '* []', + ' * ' + ], + expected: [ + '* []' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should replace current empty list item with next list item', () => { + runTest( { + input: [ + '* ', + ' * []', + '* a' + ], + expected: [ + '* ', + ' * []a{id:002}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should remove next empty list item when current is also empty', () => { + runTest( { + input: [ + '* ', + ' * []', + '* ' + ], + expected: [ + '* ', + ' * []' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 1 ] + } ); + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should delete next empty item list', () => { + runTest( { + input: [ + '* a[]', + '* ' + ], + expected: [ + '* a[]' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge text of indented list item with current list item', () => { + runTest( { + input: [ + '* a[]', + ' * b' + ], + expected: [ + '* a[]b' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should remove indented empty list item', () => { + runTest( { + input: [ + '* a[]', + ' * ' + ], + expected: [ + '* a[]' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge text of lower indent list item', () => { + runTest( { + input: [ + '* a', + ' * b[]', + '* c' + ], + expected: [ + '* a', + ' * b[]c' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should delete next empty list item with lower ident', () => { + runTest( { + input: [ + '* a', + ' * b[]', + '* ' + ], + expected: [ + '* a', + ' * b[]' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge following item list of first block and adjust it\'s children', () => { + runTest( { + input: [ + '* a[]', + ' * b', + ' * c', + ' * d', + ' e', + ' * f', + ' * g', + ' h' + ], + expected: [ + '* a[]b', + ' * c {id:002}', + ' * d {id:003}', + ' e', + ' * f {id:005}', + ' * g {id:006}', + ' h' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1, 2, 3, 4, 5, 6 ] + } ); + } ); + + it( 'should merge following first block of an item list and make second block a first one', () => { + runTest( { + input: [ + '* a[]', + ' * b', + ' b2', + ' * c', + ' * d', + ' e' + ], + expected: [ + '* a[]b', + ' b2', + ' * c {id:003}', + ' * d {id:004}', + ' e' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1, 2, 3, 4 ] + } ); + } ); + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge non empty list item', () => { + runTest( { + input: [ + '* [', + '* ]text' + ], + expected: [ + '* []text{id:001}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge non empty list item and delete text', () => { + runTest( { + input: [ + '* [', + '* te]xt' + ], + expected: [ + '* []xt{id:001}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should merge and adjust indentation of child list items', () => { + runTest( { + input: [ + '* [', + '* a', + ' * b]c', + ' * d' + ], + expected: [ + '* []c{id:002}', + ' * d{id:003}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + } ); + + describe( 'first position in non-empty block', () => { + it( 'should merge two list items', () => { + runTest( { + input: [ + '* [text', + '* ano]ther' + ], + expected: [ + '* []ther{id:001}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge non empty list item', () => { + runTest( { + input: [ + '* text[', + '* ]another' + ], + expected: [ + '* text[]another' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge non empty list item and delete text', () => { + runTest( { + input: [ + '* text[', + '* ano]ther' + ], + expected: [ + '* text[]ther' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1, 2 ] + } ); + } ); + + it( 'should merge and adjust indentation of child list items', () => { + runTest( { + input: [ + '* text[', + '* a', + ' * b]c', + ' * d' + ], + expected: [ + '* text[]c', + ' * d {id:003}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + } ); + } ); + } ); + + describe( 'multi-block list item', () => { + describe( 'collapsed selection at the end of a list item', () => { + describe( 'item after is empty', () => { + it( 'should remove empty list item', () => { + runTest( { + input: [ + '* a', + ' b[]', + '* ' + ], + expected: [ + '* a', + ' b[]' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge following complex list item with current one', () => { + runTest( { + input: [ + '* ', + ' []', + '* b', + ' c', + ' * d {id:d}', + ' e', + ' * f {id:f}', + ' * g {id:g}', + ' h', + ' * i {id:i}', + ' * j {id:j}', + ' k', + ' l' + ], + expected: [ + '* ', + '* []b {id:002}', + ' c', + ' * d {id:d}', + ' e', + ' * f {id:f}', + ' * g {id:g}', + ' h', + ' * i {id:i}', + ' * j {id:j}', + ' k', + ' l' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 1, 2, 11 ] + } ); + } ); + + it( 'should merge and remove block of same list item', () => { + runTest( { + input: [ + '* []', + ' a' + ], + expected: [ + '* []a' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should merge indented list item with with currently selected list item', () => { + runTest( { + input: [ + '* []', + ' * a', + ' b' + ], + expected: [ + '* []a{id:001}', + ' b' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should merge indented list having block and indented list item with previous empty list item', () => { + runTest( { + input: [ + '* []', + ' * a', + ' b', + ' * c' + ], + expected: [ + '* []a {id:001}', + ' b', + ' * c {id:003}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1, 2 ] + } ); + } ); + + it( 'should merge indented list item with first block empty', () => { + runTest( { + input: [ + '* []', + ' * ', + ' text' + ], + expected: [ + '* []', + ' text' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should merge next outdented list item', () => { + runTest( { + input: [ + '* ', + ' * []', + '* a', + ' b' + ], + expected: [ + '* ', + ' * []a {id:002}', + ' b' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should merge next outdented list item with first block empty', () => { + runTest( { + input: [ + '* ', + ' * []', + '* ', + ' text' + ], + expected: [ + '* ', + ' * []', + ' text' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 1, 2 ] + } ); + } ); + } ); + + describe( 'list item after is not empty', () => { + it( 'should merge with previous list item and keep blocks intact', () => { + runTest( { + input: [ + '* a[]', + '* b', + ' c' + ], + expected: [ + '* a[]b', + ' c' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should merge all following outdented blocks', () => { + runTest( { + input: [ + '* b', + ' * c', + ' c2[]', + ' d', + ' e' + ], + expected: [ + '* b', + ' * c', + ' c2[]d', + ' e' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 2, 3 ] + } ); + } ); + + it( 'should merge complex list item', () => { + runTest( { + input: [ + '* a', + ' a2[]', + '* b', + ' c', + ' * d', + ' e', + ' * f', + ' * g', + ' h', + ' * i', + ' * j', + ' k', + ' l' + ], + expected: [ + '* a', + ' a2[]b', + ' c', + ' * d {id:004}', + ' e', + ' * f {id:006}', + ' * g {id:007}', + ' h', + ' * i {id:009}', + ' * j {id:010}', + ' k', + ' l' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 1, 2, 11 ] + } ); + } ); + + it( 'should merge list item with next multi-block list item', () => { + runTest( { + input: [ + '* a', + ' a2[]', + '* b', + ' b2' + ], + expected: [ + '* a', + ' a2[]b', + ' b2' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should merge outdented multi-block list item', () => { + runTest( { + input: [ + '* a', + ' a2[]', + ' * b', + ' b2' + ], + expected: [ + '* a', + ' a2[]b', + ' b2' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should merge an outdented list item in an outdented list item', () => { + runTest( { + input: [ + '* a', + ' * b', + ' c[]', + ' * d' + ], + expected: [ + '* a', + ' * b', + ' c[]d' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 2 ] + } ); + } ); + + it( 'should merge indented empty list item', () => { + runTest( { + input: [ + '* a', + ' * b', + ' c[]', + ' * ' + ], + expected: [ + '* a', + ' * b', + ' c[]' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 2 ] + } ); + } ); + + it( 'should merge list item with with next outdented list item', () => { + runTest( { + input: [ + '* a', + ' * b[]', + '* c', + ' d' + ], + expected: [ + '* a', + ' * b[]c', + ' d' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 1, 2 ] + } ); + } ); + } ); + } ); + + describe( 'collapsed selection in the middle of the list item', () => { + it( 'should merge next indented list item', () => { + runTest( { + input: [ + '* A', + ' * B', + ' # C', + ' # D', + ' X[]', + ' # Z', + ' V', + '* E', + '* F' + ], + expected: [ + '* A', + ' * B', + ' # C', + ' # D', + ' X[]Z', + ' V', + '* E {id:007}', + '* F {id:008}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 4 ] + } ); + } ); + } ); + + 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: [ + '* [', + '* ]', + ' ' + ], + expected: [ + '* []', + ' ' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should merge non empty list item', () => { + runTest( { + input: [ + '* [', + '* ]text' + ], + expected: [ + '* []text {id:001}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge non empty list item and delete text', () => { + runTest( { + input: [ + '* [', + '* te]xt' + ], + expected: [ + '* []xt {id:001}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should merge and adjust indentation of child list items', () => { + runTest( { + input: [ + '* [', + '* a', + ' * b]c', + ' * d' + ], + expected: [ + '* []c {id:002}', + ' * d {id:003}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should delete all following items till the end of selection and merge last list item', () => { + runTest( { + input: [ + '* [', + ' text', + '* a', + ' * b', + '* d]e' + ], + expected: [ + '* []e {id:004}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should delete all following items till the end of selection and merge last list itemx', () => { + runTest( { + input: [ + '* [', + ' * b', + ' ]c', + ' * d', + ' e' + ], + expected: [ + '* []', + '* c', + ' * d {id:003}', + ' e' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1, 2, 3 ] + } ); + } ); + + it( 'should delete items till the end of selection and merge middle block with following blocks', () => { + runTest( { + input: [ + '* [', + ' * b', + ' c]d', + ' * e', + ' f' + ], + expected: [ + '* []d {id:001}', + ' * e {id:003}', + ' f' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1, 2 ] + } ); + } ); + + it( 'should delete items till the end of selection and merge following blocks', () => { + runTest( { + input: [ + '* [', + ' * b', + ' cd]', + ' * e', + ' f', + ' s' + ], + expected: [ + '* []', + ' * e {id:003}', + ' f', + '* s {id:001}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1, 2, 3 ] + } ); + } ); + } ); + + describe( 'first position in non-empty block', () => { + it( 'should merge two list items', () => { + runTest( { + input: [ + '* [text', + '* ano]ther', + ' text' + ], + expected: [ + '* []ther {id:001}', + ' text' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + // Not related to merge command + it( 'should merge two list items with selection in the middle', () => { + runTest( { + input: [ + '* te[xt', + '* ano]ther' + ], + expected: [ + '* te[]ther' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge non empty list item', () => { + runTest( { + input: [ + '* [', + '* ]text' + ], + expected: [ + '* []text {id:001}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge non empty list item and delete text', () => { + runTest( { + input: [ + '* [', + '* te]xt' + ], + expected: [ + '* []xt{id:001}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge and adjust indentation of child list item when end selection is at the beginning of item', () => { + runTest( { + input: [ + '* [', + '* a', + ' * ]b', + ' * c' + ], + expected: [ + '* []', + '* b {id:002}', + ' * c {id:003}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1, 2 ] + } ); + } ); + + it( 'should merge and adjust indentation of child list items', () => { + runTest( { + input: [ + '* [', + '* a', + ' * b]c', + ' * d' + ], + expected: [ + '* []c{id:002}', + ' * d{id:003}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should delete all items and text till the end of selection and adjust orphan elements', () => { + runTest( { + input: [ + '* [', + '* a', + ' * b]', + ' c', + ' * d', + ' e', + ' f', + ' g' + ], + expected: [ + '* []', + ' c', + ' * d {id:004}', + ' e', + '* f {id:001}', + ' g' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1, 2, 3 ] + } ); + } ); + } ); + } ); + } ); + + describe( 'selection outside list', () => { + it( 'should not engage for a
  • 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, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + 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, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + 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, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + 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, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + 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, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + 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, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'empty list should be deleted', () => { + runTest( { + input: [ + '* 1', + '2[]', + '* ' + ], + expected: [ + '* 1', + '2[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + } ); + + 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, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + 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, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + } ); + + 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, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'no list editing commands should be executed when doing delete (multi-block list)', () => { + runTest( { + input: [ + '* te[xt1', + ' text2', + ' * text3', + 'text4]' + ], + expected: [ + '* te[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should delete everything till end of selection and merge remaining text', () => { + runTest( { + input: [ + '* text1', + ' tex[t2', + ' * text3', + 'tex]t4' + ], + expected: [ + '* text1', + ' tex[]t4' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + } ); + + describe( 'only end in a list', () => { + it( 'should delete everything till end of selection', () => { + runTest( { + input: [ + '[', + '* te]xt' + ], + expected: [ + '* []xt {id:001}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 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' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + // Note: Technically speaking "c" should also be included but wasn't; was fixed by model post-fixer. + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should delete selection and adjust remaining item list indentation (multi-block)', () => { + runTest( { + input: [ + 'a[', + '* b]b', + ' * c', + ' d' + ], + expected: [ + 'a[]b', + '* c {id:002}', + ' d' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + // Note: Technically speaking "c" and "d" should also be included but weren't; fixed by model post-fixer. + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should remove selection and adjust remaining list', () => { + runTest( { + input: [ + 'a[', + '* b]b', + ' * c', + ' d' + ], + expected: [ + 'a[]b', + '* c {id:002}', + '* d {id:001}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + // Note: Technically speaking "c" and "d" should also be included but weren't; fixed by model post-fixer. + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should remove selection and adjust remaining list (multi-block)', () => { + runTest( { + input: [ + 'a[', + '* b', + ' * c', + ' d]d', + ' * e', + ' f' + ], + expected: [ + 'a[]d', + '* e {id:004}', + ' f' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1, 2 ] + } ); + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge lists into one with two blocks', () => { + runTest( { + input: [ + '* a', + ' b[b', + 'c', + '* d]d' + ], + expected: [ + '* a', + ' b[]d' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge two lists into one with two list items', () => { + runTest( { + input: [ + '* a[', + 'c', + '* d]', + '* e' + ], + expected: [ + '* a[]', + '* e {id:003}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge two lists into one with two list items (multiple blocks)', () => { + runTest( { + input: [ + '* a[', + 'c', + '* d]', + ' e', + '* f' + ], + expected: [ + '* a[]', + ' e', + '* f {id:004}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should merge two lists into one with two list items and adjust indentation', () => { + runTest( { + input: [ + '* a[', + 'c', + '* d', + ' * e]e', + ' * f', + ' g' + ], + expected: [ + '* a[]e', + ' * f {id:004}', + ' g' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0, 1, 2 ] + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should merge two lists into one with deeper indentation (multiple blocks)', () => { + runTest( { + input: [ + '* a', + ' * b[', + 'c', + '* d', + ' * e]e', + ' * f', + ' g' + ], + expected: [ + '* a', + ' * b[]e', + ' * f {id:005}', + ' g' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should merge two lists into one and keep items after selection', () => { + runTest( { + input: [ + '* a[', + 'c', + '* d', + ' * e]e', + '* f', + ' g' + ], + expected: [ + '* a[]e', + '* f {id:004}', + ' g' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 1 ] + } ); + } ); + + 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: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 1 ] + } ); + } ); + } ); + } ); + } ); + + describe( 'around widgets', () => { + describe( 'block widgets', () => { + it( 'should delete a paragraph and select a block widget in a list that follows it', () => { + runTest( { + input: [ + '[]', + '* ' + ], + expected: [ + '* [] {id:001}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should select a block widget in a list that follows a non-empty paragraph', () => { + runTest( { + input: [ + 'foo[]', + '* ' + ], + expected: [ + 'foo', + '* []' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should delete a paragraph and select a block widget (1st block) in a list that follows it', () => { + runTest( { + input: [ + '[]', + '* ', + ' a' + ], + expected: [ + '* [] {id:001}', + ' a' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it.skip( 'should merge an item into the next one despite a block widget following it', () => { + runTest( { + input: [ + '* []', + '* ', + ' a' + ], + expected: [ + '* [] {id:001}', + ' a' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it.skip( 'should merge an item into the next one despite a block widget following it at a deeper level', () => { + runTest( { + input: [ + '* a', + '* []', + ' * ' + ], + expected: [ + '* a', + ' * [] {id:002}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it.skip( 'should merge an item into the next one despite a block widget following it at an even deeper level', () => { + runTest( { + input: [ + '* a', + ' * []', + ' * ' + ], + expected: [ + '* a', + ' * [] {id:002}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should delete an item block and select a block widget that follows it', () => { + runTest( { + input: [ + '* a', + ' []', + ' ', + ' b' + ], + expected: [ + '* a', + ' []', + ' b' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it.skip( 'should delete a block widget and move the selection to the list item block that follows it', () => { + runTest( { + input: [ + '* a', + ' []' + ], + expected: [ + '* a', + ' []' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [] + } ); + } ); + + it.skip( 'should delete a block widget and move the selection to the block that follows it (multiple blocks)', () => { + runTest( { + input: [ + '* a', + ' []', + ' b' + ], + expected: [ + '* a', + ' []', + ' b' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [] + } ); + } ); + + it.skip( 'should delete a block widget and move the selection to the block that follows it (nested item follows)', () => { + runTest( { + input: [ + '* a', + ' []', + ' * b' + ], + expected: [ + '* a', + ' []', + ' * b {id:002}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [] + } ); + } ); + + it.skip( 'should delete a block widget and move the selection down to the (shallower) block that follows it', () => { + runTest( { + input: [ + '* a', + ' * []' + ], + expected: [ + '* a', + ' * []' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [] + } ); + } ); + + it.skip( 'should delete a block widget and move the selection down to the block that follows it (multiple blocks)', () => { + runTest( { + input: [ + '* a', + ' * []', + ' b' + ], + expected: [ + '* a', + ' * []', + ' b' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [] + } ); + } ); + + it( 'should remove list when its entire cotent is selected (including a block widget), same indentation levels', () => { + runTest( { + input: [ + '* [a', + ' ]' + ], + expected: [ + '[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should remove multiple list item blocks (including a block widget) within the selection, block follows', () => { + runTest( { + input: [ + '* [a', + ' ]', + ' b' + ], + expected: [ + '* []', + ' b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should remove multiple list item blocks (including a block widget) within the selection, nested block follows', () => { + runTest( { + input: [ + '* [a', + ' ]', + ' * b' + ], + expected: [ + '* []', + ' * b {id:002}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should remove list when its entire cotent is selected (including a block widget), mixed indentation levels', () => { + runTest( { + input: [ + '* [a', + ' * ]' + ], + expected: [ + '[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should remove multiple list item blocks (including a block widget) within the selection, mixed indent levels', () => { + runTest( { + input: [ + '* [a', + ' * ]', + ' * b' + ], + expected: [ + '* []', + ' * b {id:002}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should remove multiple list item blocks (including a block widget) within the selection, ' + + 'mixed indent levels, following block', () => { + runTest( { + input: [ + '* [a', + ' * ]', + ' b' + ], + expected: [ + '* []', + ' b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should remove multiple list item blocks (including a block widget) within the selection, ' + + 'mixed indent levels, following block at a deeper level', () => { + runTest( { + input: [ + '* [a', + ' * ]', + ' b' + ], + expected: [ + '* []', + ' * b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it.skip( 'should remove a block widget surrounded by block containing inline images at boundaries', () => { + runTest( { + input: [ + '* a', + ' []', + ' b' + ], + expected: [ + '* a', + ' []', + ' b' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [] + } ); + } ); + } ); + + describe( 'inline images', () => { + it( 'should remove an inline widget if only content of a block', () => { + runTest( { + input: [ + '* []' + ], + expected: [ + '* []' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should merge a paragraph into following list containing an inline widget', () => { + runTest( { + input: [ + '[]', + '* a' + ], + expected: [ + '* []a {id:001}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should merge an empty list item into following list item containing an inline widget', () => { + runTest( { + input: [ + '* []', + '* a' + ], + expected: [ + '* []a {id:001}' + ], + eventStopped: true, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 1 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should remove an inline widget in a list item block containing other content (before)', () => { + runTest( { + input: [ + '* a[]' + ], + expected: [ + '* a[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should remove an inline widget in a list item block containing other content (after)', () => { + runTest( { + input: [ + '* []a' + ], + expected: [ + '* []a' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should remove an inline widget in a middle list item block', () => { + runTest( { + input: [ + '* a', + ' []', + ' b' + ], + expected: [ + '* a', + ' []', + ' b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should remove an inline widget in a nested list item block', () => { + runTest( { + input: [ + '* a', + ' * []', + ' b' + ], + expected: [ + '* a', + ' * []', + ' b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + } ); + } ); + } ); + + // @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/documentlist/integrations/enter.js b/packages/ckeditor5-list/tests/documentlist/integrations/enter.js new file mode 100644 index 00000000000..3b7eb86104f --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/integrations/enter.js @@ -0,0 +1,1331 @@ +/** + * @license Copyright (c) 2003-2022, 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 TableEditing from '@ckeditor/ckeditor5-table/src/tableediting'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; + +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +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'; + +describe( 'DocumentListEditing integrations: enter key', () => { + const changedBlocks = []; + + let editor, model, modelDoc, modelRoot, view; + let eventInfo, domEventData; + let splitBeforeCommand, splitAfterCommand, indentCommand, + splitBeforeCommandExecuteSpy, splitAfterCommandExecuteSpy, outdentCommandExecuteSpy; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + editor = await VirtualTestEditor.create( { + plugins: [ + Paragraph, ClipboardPipeline, BoldEditing, DocumentListEditing, UndoEditing, + BlockQuoteEditing, TableEditing, HeadingEditing + ] + } ); + + 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(); + + eventInfo = new EventInfo( view.document, 'enter' ); + domEventData = new DomEventData( view.document, { + preventDefault: sinon.spy() + } ); + + splitBeforeCommand = editor.commands.get( 'splitListItemBefore' ); + splitAfterCommand = editor.commands.get( 'splitListItemAfter' ); + indentCommand = editor.commands.get( 'outdentList' ); + + splitBeforeCommandExecuteSpy = sinon.spy( splitBeforeCommand, 'execute' ); + splitAfterCommandExecuteSpy = sinon.spy( splitAfterCommand, 'execute' ); + outdentCommandExecuteSpy = sinon.spy( indentCommand, 'execute' ); + + changedBlocks.length = 0; + + splitBeforeCommand.on( 'afterExecute', ( evt, data ) => { + changedBlocks.push( ...data ); + } ); + + splitAfterCommand.on( 'afterExecute', ( evt, data ) => { + changedBlocks.push( ...data ); + } ); + + indentCommand.on( 'afterExecute', ( evt, data ) => { + changedBlocks.push( ...data ); + } ); + } ); + + afterEach( async () => { + await editor.destroy(); + } ); + + describe( 'collapsed selection', () => { + describe( 'with just one block per list item', () => { + it( 'should outdent if the slection in the only empty list item (convert into paragraph and turn off the list)', () => { + setModelData( model, modelList( [ + '* []' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '[]' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + modelRoot.getChild( 0 ) + ] ); + + sinon.assert.calledOnce( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.true; + } ); + + it( 'should outdent if the slection in the last empty list item (convert the item into paragraph)', () => { + setModelData( model, modelList( [ + '* a', + '* []' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* a', + '[]' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + modelRoot.getChild( 1 ) + ] ); + + sinon.assert.calledOnce( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.true; + } ); + + it( 'should create another list item when the selection in a non-empty only list item', () => { + setModelData( model, modelList( [ + '* a[]' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* a', + '* [] {id:a00}' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + modelRoot.getChild( 1 ) + ] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.calledOnce( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + it( 'should outdent if the selection in an empty, last sub-list item', () => { + setModelData( model, modelList( [ + '* a', + ' # b', + ' * c', + ' * []' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* a', + ' # b', + ' * c', + ' # []' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + modelRoot.getChild( 3 ) + ] ); + + sinon.assert.calledOnce( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.true; + } ); + } ); + + describe( 'with multiple blocks in a list item', () => { + it( 'should outdent if the selection is anchored in an empty, last item block', () => { + setModelData( model, modelList( [ + '* a', + ' # b', + ' # []' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* a', + ' # b', + '* []' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + modelRoot.getChild( 2 ) + ] ); + + sinon.assert.calledOnce( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.true; + } ); + + it( 'should outdent if the selection is anchored in an empty, only sub-item block', () => { + setModelData( model, modelList( [ + '* a', + ' # b', + ' * []', + ' #' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* a', + ' # b', + ' # []', + ' #' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + modelRoot.getChild( 2 ) + ] ); + + sinon.assert.calledOnce( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.true; + } ); + + it( 'should create another block when the selection at the start of a non-empty first block', () => { + setModelData( model, modelList( [ + '* a[]', + ' b', + ' c' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* a', + ' []', + ' b', + ' c' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + it( 'should create another block when the selection at the end of a non-empty first block', () => { + setModelData( model, modelList( [ + '* []a', + ' b', + ' c' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* ', + ' []a', + ' b', + ' c' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + it( 'should create another block when the selection at the start of a non-empty last block', () => { + setModelData( model, modelList( [ + '* a', + ' b', + ' []c' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* a', + ' b', + ' ', + ' []c' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + it( 'should create another block when the selection at the end of a non-empty last block', () => { + setModelData( model, modelList( [ + '* a', + ' b', + ' c[]' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* a', + ' b', + ' c', + ' []' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + it( 'should create another block when the selection in an empty middle block', () => { + setModelData( model, modelList( [ + '* a', + ' []', + ' c' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* a', + ' ', + ' []', + ' c' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + it( 'should create another list item when the selection in an empty last block (two blocks in total)', () => { + setModelData( model, modelList( [ + '* a', + ' []' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* a', + '* [] {id:a00}' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + modelRoot.getChild( 1 ) + ] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.calledOnce( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.true; + } ); + + it( 'should create another list item when the selection in an empty last block (three blocks in total)', () => { + setModelData( model, modelList( [ + '* a', + ' b', + ' []' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* a', + ' b', + '* [] {id:a00}' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + modelRoot.getChild( 2 ) + ] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.calledOnce( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.true; + } ); + + it( 'should create another list item when the selection in an empty last block (followed by a list item)', () => { + setModelData( model, modelList( [ + '* a', + ' b', + ' []', + '* ' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* a', + ' b', + '* [] {id:a00}', + '* ' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + modelRoot.getChild( 2 ) + ] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.calledOnce( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.true; + } ); + + it( 'should create another list item when the selection in an empty first block (followed by another block)', () => { + setModelData( model, modelList( [ + '* []', + ' b' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* []', + '* b {id:a00}' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + modelRoot.getChild( 1 ) + ] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.calledOnce( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.true; + } ); + + it( 'should create another list item when the selection in an empty first block (followed by multiple blocks)', () => { + setModelData( model, modelList( [ + '* []', + ' a', + ' b' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* []', + '* a {id:a00}', + ' b' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + modelRoot.getChild( 1 ), + modelRoot.getChild( 2 ) + ] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.calledOnce( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.true; + } ); + + it( 'should create another list item when the selection in an empty first block (followed by multiple blocks and an item)', + () => { + setModelData( model, modelList( [ + '* []', + ' a', + ' b', + '* c' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* []', + '* a {id:a00}', + ' b', + '* c' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + modelRoot.getChild( 1 ), + modelRoot.getChild( 2 ) + ] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.calledOnce( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.true; + } ); + } ); + } ); + + describe( 'non-collapsed selection', () => { + describe( 'with just one block per list item', () => { + it( 'should create another list item if the selection contains some content at the end of the list item', () => { + setModelData( model, modelList( [ + '* a[b]' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* a', + '* [] {id:a00}' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + modelRoot.getChild( 1 ) + ] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.calledOnce( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + it( 'should create another list item if the selection contains some content at the start of the list item', () => { + setModelData( model, modelList( [ + '* [a]b' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* ', + '* []b {id:a00}' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + modelRoot.getChild( 1 ) + ] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.calledOnce( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + it( 'should clean the content and turn off the list if slection contains all content at the zero indent level', () => { + setModelData( model, modelList( [ + '* [a', + '* b]' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '[]' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + it( 'should clean the content and move the selection when it contains some content at the zero indent level', () => { + setModelData( model, modelList( [ + '* a[b', + '* b]' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* a', + '* []' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + it( 'should clean the content when the selection contains all content at a deeper indent level', () => { + setModelData( model, modelList( [ + '* a', + ' # b', + ' * [c', + ' * d]' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* a', + ' # b', + ' * []' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + describe( 'cross-indent level selection', () => { + it( 'should clean the content and remove list across different indentation levels (list the only content)', () => { + setModelData( model, modelList( [ + '* [ab', + ' # cd]' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '[]' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + it( 'should clean the content across different indentation levels (one level, entire blocks)', () => { + setModelData( model, modelList( [ + 'foo', + '* [ab', + ' # cd]' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + 'foo', + '* []' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + it( 'should clean the content across different indentation levels (one level, subset of blocks)', () => { + setModelData( model, modelList( [ + 'foo', + '* a[b', + ' # c]d' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + 'foo', + '* a', + ' # []d' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + it( 'should clean the content across different indentation levels (two levels, entire blocks)', () => { + setModelData( model, modelList( [ + '* [ab', + ' # cd', + ' * ef]', + ' * gh' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* []', + ' * gh {id:003}' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + it( 'should clean the content across different indentation levels (two levels, subset of blocks)', () => { + setModelData( model, modelList( [ + '* a[b', + ' # cd', + ' * e]f', + ' * gh' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* a', + ' * []f {id:002}', + ' * gh {id:003}' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + it( 'should clean the content across different indentation levels (three levels, entire blocks)', () => { + setModelData( model, modelList( [ + 'foo', + '* [ab', + ' # cd', + ' * ef', + ' * gh]' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + 'foo', + '* []' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + it( 'should clean the content and remove list across different indentation levels ' + + '(three levels, list the only content)', () => { + setModelData( model, modelList( [ + '* [ab', + ' # cd', + ' * ef', + ' * gh]' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '[]' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + it( 'should clean the content across different indentation levels (three levels, subset of blocks)', () => { + setModelData( model, modelList( [ + '* a[b', + ' # cd', + ' * ef', + ' # g]h', + ' * ij' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* a', + ' # []h {id:003}', + '* ij {id:004}' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + it( 'should clean the content across different indentation levels (one level, start at first, entire blocks)', () => { + setModelData( model, modelList( [ + '* ab', + ' # [cd', + ' * ef', + ' * gh]' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* ab', + ' # []' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + it( 'should clean the content across different indentation levels (one level, start at first, part of blocks)', () => { + setModelData( model, modelList( [ + '* ab', + ' # c[d', + ' * ef', + ' * g]h' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* ab', + ' # c', + ' * []h {id:003}' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + it( 'should clean the content across different indentation levels (level up then down, subset of blocks)', () => { + setModelData( model, modelList( [ + '* ab', + ' # c[d', + ' * ef', + ' # g]h' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* ab', + ' # c', + ' # []h {id:003}' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + it( 'should clean the content across different indentation levels (level up then down, entire of blocks)', () => { + setModelData( model, modelList( [ + '* ab', + ' # [cd', + ' * ef', + ' # gh]', + '* ij' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* ab', + ' # []', + '* ij {id:004}' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + it( 'should clean the content across different indentation levels (level up then down, preceded by an item)', () => { + setModelData( model, modelList( [ + '* ab', + ' # cd', + ' # [ef', + ' * gh', + ' # ij]', + '* kl' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* ab', + ' # cd', + ' # []', + '* kl {id:005}' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + } ); + } ); + + describe( 'with multiple blocks in a list item', () => { + it( 'should clean the selected content (partial blocks)', () => { + setModelData( model, modelList( [ + '* a[b', + ' c]d' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* a', + '* []d {id:a00}' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + modelRoot.getChild( 1 ) + ] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.calledOnce( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + it( 'should clean the selected content (entire blocks)', () => { + setModelData( model, modelList( [ + 'foo', + '* [ab', + ' cd]' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + 'foo', + '* []' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + it( 'should clean the selected content (entire block, middle one)', () => { + setModelData( model, modelList( [ + '* ab', + ' [cd]', + ' ef' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* ab', + ' []', + ' ef' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + it( 'should clean the selected content (entire blocks, starting from the second)', () => { + setModelData( model, modelList( [ + '* ab', + ' [cd', + ' ef]' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + // Generally speaking, we'd rather expect something like this: + // * ab + // [] + // But there is no easy way to tell what the original selection looked like when it came to EnterCommand#afterExecute. + // Enter deletes all the content first [cd, ef] and in #afterExecute it looks like the original selection was: + // * ab + // [] + // and the algorithm falls back to splitting in this case. There's even a test for this kind of selection. + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* ab', + '* [] {id:a00}' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + modelRoot.getChild( 1 ) + ] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.calledOnce( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + it( 'should clean the selected content (partial blocks, starting from the second)', () => { + setModelData( model, modelList( [ + '* ab', + ' c[d', + ' e]f' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* ab', + ' c', + ' []f' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + it( 'should clean the selected content (entire blocks, three blocks in total)', () => { + setModelData( model, modelList( [ + '* [ab', + ' cd', + ' ef]', + '* gh' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* []', + '* gh {id:003}' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + it( 'should clean the selected content (entire blocks, across list items)', () => { + setModelData( model, modelList( [ + 'foo', + '* [ab', + ' cd', + ' ef', + '* gh]' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + 'foo', + '* []' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + it( 'should clean the selected content (entire blocks + a partial block, across list items)', () => { + setModelData( model, modelList( [ + '* [ab', + ' cd', + ' ef', + '* g]h' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* ', + '* []h {id:003}' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + it( 'should clean the selected content (partial blocks, across list items)', () => { + setModelData( model, modelList( [ + '* ab', + ' cd', + ' e[f', + '* g]h' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* ab', + ' cd', + ' e', + '* []h' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + describe( 'cross-indent level selection', () => { + it( 'should clean the selected content (partial blocks)', () => { + setModelData( model, modelList( [ + '* ab', + ' * cd', + ' e[f', + ' gh', + ' * i]j' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* ab', + ' * cd', + ' e', + ' * []j {id:004}' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + it( 'should clean the selected content (partial blocks + entire block)', () => { + setModelData( model, modelList( [ + '* ab', + ' * cd', + ' e[f', + ' gh', + ' * ij]' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* ab', + ' * cd', + ' e', + ' * [] {id:004}' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + + it( 'should clean the selected content (across two middle levels)', () => { + setModelData( model, modelList( [ + '* ab', + ' c[d', + ' * ef', + ' g]h', + ' * ij' + ] ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( [ + '* ab', + ' c', + ' * []h', + ' * ij {id:004}' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [] ); + + sinon.assert.notCalled( outdentCommandExecuteSpy ); + sinon.assert.notCalled( splitBeforeCommandExecuteSpy ); + sinon.assert.notCalled( splitAfterCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + } ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-list/tests/documentlist/utils/model.js b/packages/ckeditor5-list/tests/documentlist/utils/model.js index 97b68007f33..61eef020816 100644 --- a/packages/ckeditor5-list/tests/documentlist/utils/model.js +++ b/packages/ckeditor5-list/tests/documentlist/utils/model.js @@ -15,7 +15,7 @@ import { isSingleListItem, ListItemUid, mergeListItemBefore, - outdentBlocks, + outdentBlocksWithMerge, outdentFollowingItems, removeListAttributes, splitListItemBefore @@ -1075,229 +1075,233 @@ describe( 'DocumentList - utils - model', () => { } ); describe( 'indentBlocks()', () => { - it( 'flat items', () => { - const input = modelList( [ - '* a', - ' b', - '* c', - ' d' - ] ); - - const fragment = parseModel( input, schema ); - const blocks = [ - fragment.getChild( 1 ), - fragment.getChild( 2 ) - ]; - - stubUid(); - - model.change( writer => indentBlocks( blocks, writer ) ); - - expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ - '* a', - ' * b{000}', - ' * c', - '* d{002}' - ] ) ); - } ); - - it( 'nested lists should keep structure', () => { - const input = modelList( [ - '* a', - ' * b', - ' * c', - ' * d', - '* e' - ] ); - - const fragment = parseModel( input, schema ); - const blocks = [ - fragment.getChild( 1 ), - fragment.getChild( 2 ), - fragment.getChild( 3 ) - ]; - - stubUid(); - - model.change( writer => indentBlocks( blocks, writer ) ); - - expect( stringifyModel( fragment ) ).to.equal( modelList( [ - '* a', - ' * b', - ' * c', - ' * d', - '* e' - ] ) ); - } ); - - it( 'should apply indentation on all blocks of given items (expand = true)', () => { - const input = modelList( [ - '* 0', - '* 1', - ' 2', - '* 3', - ' 4', - '* 5' - ] ); - - const fragment = parseModel( input, schema ); - const blocks = [ - fragment.getChild( 2 ), - fragment.getChild( 3 ) - ]; - - model.change( writer => indentBlocks( blocks, writer, { expand: true } ) ); - - expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ - '* 0', - ' * 1', - ' 2', - ' * 3', - ' 4', - '* 5' - ] ) ); - } ); - } ); - - describe( 'outdentBlocks()', () => { - it( 'should handle outdenting', () => { - const input = modelList( [ - '* 0', - ' * 1', - ' * 2', - ' * 3', - '* 4' - ] ); - - const fragment = parseModel( input, schema ); - const blocks = [ - fragment.getChild( 1 ), - fragment.getChild( 2 ), - fragment.getChild( 3 ) - ]; - - let changedBlocks; - - model.change( writer => { - changedBlocks = outdentBlocks( blocks, writer ); + describe( 'indentBy = 1', () => { + it( 'flat items', () => { + const input = modelList( [ + '* a', + ' b', + '* c', + ' d' + ] ); + + const fragment = parseModel( input, schema ); + const blocks = [ + fragment.getChild( 1 ), + fragment.getChild( 2 ) + ]; + + stubUid(); + + model.change( writer => indentBlocks( blocks, writer ) ); + + expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ + '* a', + ' * b{000}', + ' * c', + '* d{002}' + ] ) ); } ); - expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ - '* 0', - '* 1', - ' * 2', - '* 3', - '* 4' - ] ) ); - - expect( changedBlocks ).to.deep.equal( blocks ); - } ); - - it( 'should remove list attributes if outdented below 0', () => { - const input = modelList( [ - '* 0', - '* 1', - '* 2', - ' * 3', - '* 4' - ] ); - - const fragment = parseModel( input, schema ); - const blocks = [ - fragment.getChild( 2 ), - fragment.getChild( 3 ), - fragment.getChild( 4 ) - ]; - - let changedBlocks; - - model.change( writer => { - changedBlocks = outdentBlocks( blocks, writer ); + it( 'nested lists should keep structure', () => { + const input = modelList( [ + '* a', + ' * b', + ' * c', + ' * d', + '* e' + ] ); + + const fragment = parseModel( input, schema ); + const blocks = [ + fragment.getChild( 1 ), + fragment.getChild( 2 ), + fragment.getChild( 3 ) + ]; + + stubUid(); + + model.change( writer => indentBlocks( blocks, writer ) ); + + expect( stringifyModel( fragment ) ).to.equal( modelList( [ + '* a', + ' * b', + ' * c', + ' * d', + '* e' + ] ) ); } ); - expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ - '* 0', - '* 1', - '2', - '* 3', - '4' - ] ) ); - - expect( changedBlocks ).to.deep.equal( blocks ); - } ); - - it( 'should not remove attributes other than lists if outdented below 0', () => { - const input = modelList( [ - '* 0', - '* 1', - ' * 2', - '* 3', - ' * 4' - ] ); - - const fragment = parseModel( input, schema ); - const blocks = [ - fragment.getChild( 2 ), - fragment.getChild( 3 ), - fragment.getChild( 4 ) - ]; - - let changedBlocks; - - model.change( writer => { - changedBlocks = outdentBlocks( blocks, writer ); + it( 'should apply indentation on all blocks of given items (expand = true)', () => { + const input = modelList( [ + '* 0', + '* 1', + ' 2', + '* 3', + ' 4', + '* 5' + ] ); + + const fragment = parseModel( input, schema ); + const blocks = [ + fragment.getChild( 2 ), + fragment.getChild( 3 ) + ]; + + model.change( writer => indentBlocks( blocks, writer, { expand: true } ) ); + + expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ + '* 0', + ' * 1', + ' 2', + ' * 3', + ' 4', + '* 5' + ] ) ); } ); - - expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ - '* 0', - '* 1', - '* 2', - '3', - '* 4' - ] ) ); - - expect( changedBlocks ).to.deep.equal( blocks ); } ); - it( 'should apply indentation on all blocks of given items (expand = true)', () => { - const input = modelList( [ - '* 0', - ' * 1', - ' 2', - ' * 3', - ' 4', - ' * 5' - ] ); - - const fragment = parseModel( input, schema ); - const blocks = [ - fragment.getChild( 2 ), - fragment.getChild( 3 ) - ]; - - let changedBlocks; + describe( 'indentBy = -1', () => { + it( 'should handle outdenting', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' * 2', + ' * 3', + '* 4' + ] ); + + const fragment = parseModel( input, schema ); + const blocks = [ + fragment.getChild( 1 ), + fragment.getChild( 2 ), + fragment.getChild( 3 ) + ]; + + let changedBlocks; + + model.change( writer => { + changedBlocks = indentBlocks( blocks, writer, { indentBy: -1 } ); + } ); + + expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + ' * 2', + '* 3', + '* 4' + ] ) ); + + expect( changedBlocks ).to.deep.equal( blocks ); + } ); - model.change( writer => { - changedBlocks = outdentBlocks( blocks, writer, { expand: true } ); + it( 'should remove list attributes if outdented below 0', () => { + const input = modelList( [ + '* 0', + '* 1', + '* 2', + ' * 3', + '* 4' + ] ); + + const fragment = parseModel( input, schema ); + const blocks = [ + fragment.getChild( 2 ), + fragment.getChild( 3 ), + fragment.getChild( 4 ) + ]; + + let changedBlocks; + + model.change( writer => { + changedBlocks = indentBlocks( blocks, writer, { indentBy: -1 } ); + } ); + + expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + '2', + '* 3', + '4' + ] ) ); + + expect( changedBlocks ).to.deep.equal( blocks ); } ); - expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ - '* 0', - '* 1', - ' 2', - '* 3', - ' 4', - ' * 5' - ] ) ); + it( 'should not remove attributes other than lists if outdented below 0', () => { + const input = modelList( [ + '* 0', + '* 1', + ' * 2', + '* 3', + ' * 4' + ] ); + + const fragment = parseModel( input, schema ); + const blocks = [ + fragment.getChild( 2 ), + fragment.getChild( 3 ), + fragment.getChild( 4 ) + ]; + + let changedBlocks; + + model.change( writer => { + changedBlocks = indentBlocks( blocks, writer, { indentBy: -1 } ); + } ); + + expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + '* 2', + '3', + '* 4' + ] ) ); + + expect( changedBlocks ).to.deep.equal( blocks ); + } ); - expect( changedBlocks ).to.deep.equal( [ - fragment.getChild( 1 ), - fragment.getChild( 2 ), - fragment.getChild( 3 ), - fragment.getChild( 4 ) - ] ); + it( 'should apply indentation on all blocks of given items (expand = true)', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' 2', + ' * 3', + ' 4', + ' * 5' + ] ); + + const fragment = parseModel( input, schema ); + const blocks = [ + fragment.getChild( 2 ), + fragment.getChild( 3 ) + ]; + + let changedBlocks; + + model.change( writer => { + changedBlocks = indentBlocks( blocks, writer, { expand: true, indentBy: -1 } ); + } ); + + expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + ' 2', + '* 3', + ' 4', + ' * 5' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + fragment.getChild( 1 ), + fragment.getChild( 2 ), + fragment.getChild( 3 ), + fragment.getChild( 4 ) + ] ); + } ); } ); + } ); + describe( 'outdentBlocksWithMerge()', () => { it( 'should merge nested items to the parent item if nested block is not the last block of parent list item', () => { const input = modelList( [ '* 0', @@ -1315,7 +1319,7 @@ describe( 'DocumentList - utils - model', () => { let changedBlocks; model.change( writer => { - changedBlocks = outdentBlocks( blocks, writer, { expand: true } ); + changedBlocks = outdentBlocksWithMerge( blocks, writer ); } ); expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ @@ -1349,7 +1353,7 @@ describe( 'DocumentList - utils - model', () => { let changedBlocks; model.change( writer => { - changedBlocks = outdentBlocks( blocks, writer, { expand: true } ); + changedBlocks = outdentBlocksWithMerge( blocks, writer ); } ); expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ @@ -1383,7 +1387,7 @@ describe( 'DocumentList - utils - model', () => { let changedBlocks; model.change( writer => { - changedBlocks = outdentBlocks( blocks, writer, { expand: true } ); + changedBlocks = outdentBlocksWithMerge( blocks, writer ); } ); expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ diff --git a/packages/ckeditor5-list/tests/manual/listmocking.html b/packages/ckeditor5-list/tests/manual/listmocking.html index 4efb7417aac..c369169a17f 100644 --- a/packages/ckeditor5-list/tests/manual/listmocking.html +++ b/packages/ckeditor5-list/tests/manual/listmocking.html @@ -58,6 +58,21 @@ padding: 5px; margin: 5px; } + + .block-widget { + width: 100%; + height: 50px; + background: hsl(120deg 54% 57%); + display: block; + } + + .inline-widget { + width: 25px; + height: 25px; + background: hsl(303, 82%, 41%); + display: inline-block; + margin: 0 5px; + }

    Input

    diff --git a/packages/ckeditor5-list/tests/manual/listmocking.js b/packages/ckeditor5-list/tests/manual/listmocking.js index 4d06cc5b4fa..cdd99ac481b 100644 --- a/packages/ckeditor5-list/tests/manual/listmocking.js +++ b/packages/ckeditor5-list/tests/manual/listmocking.js @@ -13,6 +13,8 @@ import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import Undo from '@ckeditor/ckeditor5-undo/src/undo'; import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard'; import Indent from '@ckeditor/ckeditor5-indent/src/indent'; +import Widget from '@ckeditor/ckeditor5-widget/src/widget'; +import { toWidget } from '@ckeditor/ckeditor5-widget/src/utils'; import { parse as parseModel, setData as setModelData, @@ -24,12 +26,50 @@ import DocumentList from '../../src/documentlist'; ClassicEditor .create( document.querySelector( '#editor' ), { - plugins: [ Enter, Typing, Heading, Paragraph, Undo, Clipboard, DocumentList, Indent ], + plugins: [ Enter, Typing, Heading, Paragraph, Undo, Clipboard, DocumentList, Indent, Widget ], toolbar: [ 'heading', '|', 'bulletedList', 'numberedList', 'outdent', 'indent', '|', 'undo', 'redo' ] } ) .then( editor => { window.editor = editor; + editor.model.schema.register( 'blockWidget', { + isObject: true, + isBlock: true, + allowIn: '$root', + allowAttributesOf: '$container' + } ); + + editor.conversion.for( 'editingDowncast' ).elementToElement( { + model: 'blockWidget', + view: ( modelItem, { writer } ) => { + return toWidget( writer.createContainerElement( 'blockwidget', { class: 'block-widget' } ), writer ); + } + } ); + + editor.conversion.for( 'dataDowncast' ).elementToElement( { + model: 'blockWidget', + view: ( modelItem, { writer } ) => writer.createContainerElement( 'blockwidget', { class: 'block-widget' } ) + } ); + + editor.model.schema.register( 'inlineWidget', { + isObject: true, + isInline: true, + allowWhere: '$text', + allowAttributesOf: '$text' + } ); + + editor.conversion.for( 'editingDowncast' ).elementToElement( { + model: 'inlineWidget', + view: ( modelItem, { writer } ) => toWidget( + writer.createContainerElement( 'inlinewidget', { class: 'inline-widget' } ), writer, { label: 'inline widget' } + ) + } ); + + editor.conversion.for( 'dataDowncast' ).elementToElement( { + model: 'inlineWidget', + view: ( modelItem, { writer } ) => writer.createContainerElement( 'inlinewidget', { class: 'inline-widget' } ) + } ); + const model = 'A\n' + 'B\n' + 'C\n' + @@ -158,7 +198,7 @@ const onPaste = () => { } }; -const onHighlighChange = () => { +const onHighlightChange = () => { document.querySelector( '.ck-editor' ).classList.toggle( 'highlight-lists' ); }; @@ -166,5 +206,5 @@ document.getElementById( 'btn-process-input' ).addEventListener( 'click', proces document.getElementById( 'btn-process-editor-model' ).addEventListener( 'click', processEditorModel ); document.getElementById( 'btn-copy-output' ).addEventListener( 'click', copyOutput ); document.getElementById( 'data-input' ).addEventListener( 'paste', onPaste ); -document.getElementById( 'chbx-highlight-lists' ).addEventListener( 'change', onHighlighChange ); +document.getElementById( 'chbx-highlight-lists' ).addEventListener( 'change', onHighlightChange ); diff --git a/packages/ckeditor5-list/theme/documentlist.css b/packages/ckeditor5-list/theme/documentlist.css index b81200285d0..e156479eb22 100644 --- a/packages/ckeditor5-list/theme/documentlist.css +++ b/packages/ckeditor5-list/theme/documentlist.css @@ -1,17 +1,8 @@ /* - * Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ .ck-editor__editable .ck-list-bogus-paragraph { - /* - * Use display:inline-block to force Chrome/Safari to limit text mutations to this element. - * See https://github.com/ckeditor/ckeditor5/issues/6062. - */ - display: inline-block; - - /* - * Make it possible to click the whole line to put selection inside it. - */ - width: 100%; + display: block; }