diff --git a/packages/ckeditor5-list/src/documentlist/documentlistediting.js b/packages/ckeditor5-list/src/documentlist/documentlistediting.js index a9d0b144fc5..106338d2715 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 DocumentListSplitCommand from './documentlistsplitcommand'; import { listItemDowncastConverter, listItemParagraphDowncastConverter, @@ -27,6 +28,11 @@ import { fixListIndents, fixListItemIds } from './utils/postfixers'; +import { + getAllListItemBlocks, + isFirstBlockOfListItem, + isLastBlockOfListItem +} from './utils/model'; import { iterateSiblingListBlocks } from './utils/listwalker'; import '../../theme/documentlist.css'; @@ -57,6 +63,8 @@ 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,14 +92,69 @@ 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( 'splitListItem', new DocumentListSplitCommand( editor ) ); + + // 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; + const positionParent = doc.selection.getFirstPosition().parent; + + if ( doc.selection.isCollapsed && positionParent.hasAttribute( 'listItemId' ) && positionParent.isEmpty ) { + // * a → * a + // * [] → [] + if ( isFirstBlockOfListItem( positionParent ) ) { + editor.execute( 'outdentList' ); + + data.preventDefault(); + evt.stop(); + } + // * a → * a + // [] → * [] + else if ( isLastBlockOfListItem( positionParent ) ) { + editor.execute( 'splitListItem' ); + + data.preventDefault(); + evt.stop(); + } + } + }, { context: 'li' } ); + + // In some cases, after the default block splitting, we want to modify the new block to become a new list item + // instead of an additional block in the same list item. + this.listenTo( enterCommand, 'afterExecute', () => { + const splitCommand = commands.get( 'splitListItem' ); + + // The command has not refreshed because the change block related to EnterCommand#execute() is not over yet. + // Let's keep it up to date and take advantage of DocumentListSplitCommand#isEnabled. + splitCommand.refresh(); + + if ( !splitCommand.isEnabled ) { + return; + } + + const doc = editor.model.document; + const positionParent = doc.selection.getLastPosition().parent; + const listItemBlocks = getAllListItemBlocks( positionParent ); + + // Keep in mind this split happens after the default enter handler was executed. For instance: + // + // │ Initial state │ After default enter │ Here in #afterExecute │ + // ├───────────────────────────┼───────────────────────────┼───────────────────────────┤ + // │ * a[] │ * a │ * a │ + // │ │ [] │ * [] │ + if ( listItemBlocks.length === 2 ) { + editor.execute( 'splitListItem' ); + } + } ); } /** * @inheritDoc */ afterInit() { - const commands = this.editor.commands; - + const editor = this.editor; + const commands = editor.commands; const indent = commands.get( 'indent' ); const outdent = commands.get( 'outdent' ); diff --git a/packages/ckeditor5-list/src/documentlist/documentlistsplitcommand.js b/packages/ckeditor5-list/src/documentlist/documentlistsplitcommand.js new file mode 100644 index 00000000000..1d6e9fe1184 --- /dev/null +++ b/packages/ckeditor5-list/src/documentlist/documentlistsplitcommand.js @@ -0,0 +1,82 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module list/documentlist/documentlistsplitcommand + */ + +import { Command } from 'ckeditor5/src/core'; +import { + isFirstBlockOfListItem, + sortBlocks, + splitListItemBefore +} from './utils/model'; + +/** + * The document list split command that splits the list item at the selection. + * + * It is used by the {@link module:list/documentlist~DocumentList document list feature}. + * + * @extends module:core/command~Command + */ +export default class DocumentListSplitCommand extends Command { + /** + * @inheritDoc + */ + refresh() { + this.isEnabled = this._checkEnabled(); + } + + /** + * Splits the list item at the selection. + * + * @fires execute + * @fires afterExecute + */ + execute() { + const editor = this.editor; + + editor.model.change( writer => { + const positionParent = editor.model.document.selection.getFirstPosition().parent; + const changedBlocks = splitListItemBefore( positionParent, 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 ~DocumentListSplitCommand#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 doc = this.editor.model.document; + const positionParent = doc.selection.getFirstPosition().parent; + + return doc.selection.isCollapsed && + positionParent.hasAttribute( 'listItemId' ) && + !isFirstBlockOfListItem( positionParent ); + } +} diff --git a/packages/ckeditor5-list/tests/documentlist/documentlistediting-integrations.js b/packages/ckeditor5-list/tests/documentlist/documentlistediting-integrations.js new file mode 100644 index 00000000000..a1fca787ed2 --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/documentlistediting-integrations.js @@ -0,0 +1,1648 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. 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( + '' + ); + } ); + + 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( + '' + ); + } ); + } ); + + 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( '' ) + } ); + + 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( '

Y

' ) + } ); + + 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

' ) + } ); + + 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( '' ) + } ); + } ); + + 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( '' ) + } ); + + 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( '' ) + } ); + + 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
  • ' ) + } ); + } ); + + 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( '' ) + } ); + + 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( '' ) + } ); + + 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( '' ) + } ); + + 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( '' ) + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + 'Aa' + + '' + + 'b' + + 'B' + + 'C' + ); + } ); + } ); + } ); + + describe( 'enter key handling', () => { + const changedBlocks = []; + let domEventData, splitCommand, indentCommand, eventInfo, splitCommandExecuteSpy, outdentCommandExecuteSpy; + + beforeEach( () => { + eventInfo = new EventInfo( view.document, 'enter' ); + domEventData = new DomEventData( view.document, { + preventDefault: sinon.spy() + } ); + + splitCommand = editor.commands.get( 'splitListItem' ); + indentCommand = editor.commands.get( 'outdentList' ); + + splitCommandExecuteSpy = sinon.spy( splitCommand, 'execute' ); + outdentCommandExecuteSpy = sinon.spy( indentCommand, 'execute' ); + + changedBlocks.length = 0; + + splitCommand.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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + sinon.assert.notCalled( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + sinon.assert.notCalled( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + 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( splitCommandExecuteSpy ); + + sinon.assert.calledOnce( domEventData.domEvent.preventDefault ); + expect( eventInfo.stop.called ).to.be.undefined; + } ); + } ); + } ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-list/tests/documentlist/documentlistediting.js b/packages/ckeditor5-list/tests/documentlist/documentlistediting.js index 9f18ed0f59b..af10603e76f 100644 --- a/packages/ckeditor5-list/tests/documentlist/documentlistediting.js +++ b/packages/ckeditor5-list/tests/documentlist/documentlistediting.js @@ -18,10 +18,11 @@ 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 ListEditing from '../../src/list/listediting'; import DocumentListIndentCommand from '../../src/documentlist/documentlistindentcommand'; +import DocumentListSplitCommand from '../../src/documentlist/documentlistsplitcommand'; + import stubUid from './_utils/uid'; import { prepareTest } from './_utils/utils'; @@ -117,6 +118,12 @@ describe( 'DocumentListEditing', () => { expect( command ).to.be.instanceOf( DocumentListIndentCommand ); } ); + it( 'should register the splitListItem command', () => { + const command = editor.commands.get( 'splitListItem' ); + + expect( command ).to.be.instanceOf( DocumentListSplitCommand ); + } ); + it( 'should add indent list command to indent command', async () => { const editor = await VirtualTestEditor.create( { plugins: [ Paragraph, IndentEditing, DocumentListEditing ] @@ -704,448 +711,4 @@ describe( 'DocumentListEditing', () => { } ); } ); } ); - - 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( - '' - ); - } ); - - 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( - '' - ); - } ); - } ); - - 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( '' ) - } ); - - 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( '

    Y

    ' ) - } ); - - 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

    ' ) - } ); - - 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( '' ) - } ); - } ); - - 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( '' ) - } ); - - 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( '' ) - } ); - - 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
  • ' ) - } ); - } ); - - 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( '' ) - } ); - - 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( '' ) - } ); - - 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( '' ) - } ); - - 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( '' ) - } ); - - expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( - 'Aa' + - '' + - 'b' + - 'B' + - 'C' - ); - } ); - } ); - } ); } ); diff --git a/packages/ckeditor5-list/tests/documentlist/documentlistsplitcommand.js b/packages/ckeditor5-list/tests/documentlist/documentlistsplitcommand.js new file mode 100644 index 00000000000..25b2d41c836 --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/documentlistsplitcommand.js @@ -0,0 +1,264 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import DocumentListSplitCommand from '../../src/documentlist/documentlistsplitcommand'; +import stubUid from './_utils/uid'; +import { modelList } from './_utils/utils'; + +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( 'DocumentListSplitCommand', () => { + let editor, command, model, doc, root; + let changedBlocks; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + editor = new Editor(); + editor.model = new Model(); + + model = editor.model; + doc = model.document; + root = doc.createRoot(); + + model.schema.register( 'paragraph', { inheritAllFrom: '$block' } ); + model.schema.register( 'blockQuote', { inheritAllFrom: '$container' } ); + model.schema.extend( '$container', { allowAttributes: [ 'listType', 'listIndent', 'listItemId' ] } ); + + command = new DocumentListSplitCommand( editor ); + + command.on( 'afterExecute', ( evt, data ) => { + changedBlocks = data; + } ); + + stubUid(); + } ); + + afterEach( () => { + command.destroy(); + } ); + + describe( 'isEnabled', () => { + it( 'should be false if selection is not in a list item', () => { + setData( model, modelList( [ + '[]' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false if selection is not collapsed in a list item', () => { + setData( model, modelList( [ + '* a', + ' [b]' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false if selection is in the first block of a list item', () => { + setData( model, modelList( [ + '* a[]' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be true if selection is collapsed in a non-first block of a list item', () => { + setData( model, modelList( [ + '* a', + ' []' + ] ) ); + + expect( command.isEnabled ).to.be.true; + + setData( model, modelList( [ + '* a', + ' b[]' + ] ) ); + + expect( command.isEnabled ).to.be.true; + + setData( model, modelList( [ + '* a', + ' []b' + ] ) ); + + expect( command.isEnabled ).to.be.true; + + setData( model, modelList( [ + '* a', + ' b[]c' + ] ) ); + + expect( command.isEnabled ).to.be.true; + + setData( model, modelList( [ + '* a', + ' b[]c', + ' d' + ] ) ); + + 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 ); + } ); + } ); + + it( 'should create another list item when the selection in an empty last block (two blocks in total)', () => { + setData( model, modelList( [ + '* a', + ' []' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* a', + '* [] {id:a00}' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 1 ) + ] ); + } ); + + it( 'should create another list item when the selection in an empty last block (three blocks in total)', () => { + setData( model, modelList( [ + '* a', + ' b', + ' []' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* a', + ' b', + '* [] {id:a00}' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 2 ) + ] ); + } ); + + it( 'should create another list item when the selection in an empty last block (followed by a list item)', () => { + setData( model, modelList( [ + '* a', + ' b', + ' []', + '* ' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* a', + ' b', + '* [] {id:a00}', + '* ' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 2 ) + ] ); + } ); + + it( 'should create another list item in a nested structure (last block of the list item)', () => { + setData( model, modelList( [ + '* a', + ' b', + ' * c', + ' []' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* a', + ' b', + ' * c', + ' * [] {id:a00}' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 3 ) + ] ); + } ); + + it( 'should create another list item in a nested structure (middle block of the list item)', () => { + setData( model, modelList( [ + '* a', + ' b', + ' * c', + ' d[]', + ' e' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* a', + ' b', + ' * c', + ' * d[] {id:a00}', + ' e' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 3 ), + root.getChild( 4 ) + ] ); + } ); + + it( 'should create another list item in a nested structure (middle block of the list item, followed by list items)', () => { + setData( model, modelList( [ + '* a', + ' b', + ' * c', + ' d[]', + ' e', + ' * f', + '* g' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* a', + ' b', + ' * c', + ' * d[] {id:a00}', + ' e', + ' * f', + '* g' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 3 ), + root.getChild( 4 ) + ] ); + } ); + } ); +} );