diff --git a/docs/_snippets/framework/tutorials/inline-widget.js b/docs/_snippets/framework/tutorials/inline-widget.js index 6761c6d2100..30512e890f1 100644 --- a/docs/_snippets/framework/tutorials/inline-widget.js +++ b/docs/_snippets/framework/tutorials/inline-widget.js @@ -195,8 +195,6 @@ class PlaceholderEditing extends Plugin { const placeholderView = viewWriter.createContainerElement( 'span', { class: 'placeholder' - }, { - isAllowedInsideAttributeElement: true } ); // Insert the placeholder name (as a text). diff --git a/docs/framework/guides/tutorials/implementing-an-inline-widget.md b/docs/framework/guides/tutorials/implementing-an-inline-widget.md index 1129b00bf4a..65bc5318d8f 100644 --- a/docs/framework/guides/tutorials/implementing-an-inline-widget.md +++ b/docs/framework/guides/tutorials/implementing-an-inline-widget.md @@ -361,8 +361,6 @@ export default class PlaceholderEditing extends Plugin { const placeholderView = viewWriter.createContainerElement( 'span', { class: 'placeholder' - }, { - isAllowedInsideAttributeElement: true } ); // Insert the placeholder name (as a text). @@ -944,8 +942,6 @@ class PlaceholderEditing extends Plugin { const placeholderView = viewWriter.createContainerElement( 'span', { class: 'placeholder' - }, { - isAllowedInsideAttributeElement: true } ); // Insert the placeholder name (as a text). diff --git a/packages/ckeditor5-block-quote/src/blockquoteediting.js b/packages/ckeditor5-block-quote/src/blockquoteediting.js index c16b7096976..dd5ff54b44a 100644 --- a/packages/ckeditor5-block-quote/src/blockquoteediting.js +++ b/packages/ckeditor5-block-quote/src/blockquoteediting.js @@ -45,8 +45,7 @@ export default class BlockQuoteEditing extends Plugin { editor.commands.add( 'blockQuote', new BlockQuoteCommand( editor ) ); schema.register( 'blockQuote', { - allowWhere: '$block', - allowContentOf: '$root' + inheritAllFrom: '$container' } ); editor.conversion.elementToElement( { model: 'blockQuote', view: 'blockquote' } ); diff --git a/packages/ckeditor5-block-quote/tests/blockquoteediting.js b/packages/ckeditor5-block-quote/tests/blockquoteediting.js index dc10f4f8179..7dd6e1bfffe 100644 --- a/packages/ckeditor5-block-quote/tests/blockquoteediting.js +++ b/packages/ckeditor5-block-quote/tests/blockquoteediting.js @@ -56,6 +56,14 @@ describe( 'BlockQuoteEditing', () => { expect( model.schema.checkChild( [ '$root', 'blockQuote' ], 'foo' ) ).to.be.false; } ); + it( 'inherits attributes from $container', () => { + model.schema.extend( '$container', { + allowAttributes: 'foo' + } ); + + expect( model.schema.checkAttribute( 'blockQuote', 'foo' ) ).to.be.true; + } ); + it( 'adds converters to the data pipeline', () => { const data = '

x

'; diff --git a/packages/ckeditor5-code-block/package.json b/packages/ckeditor5-code-block/package.json index dc243cb2d27..9cbc7e59501 100644 --- a/packages/ckeditor5-code-block/package.json +++ b/packages/ckeditor5-code-block/package.json @@ -27,6 +27,7 @@ "@ckeditor/ckeditor5-editor-classic": "^33.0.0", "@ckeditor/ckeditor5-image": "^33.0.0", "@ckeditor/ckeditor5-indent": "^33.0.0", + "@ckeditor/ckeditor5-list": "^33.0.0", "@ckeditor/ckeditor5-markdown-gfm": "^33.0.0", "@ckeditor/ckeditor5-paragraph": "^33.0.0", "@ckeditor/ckeditor5-theme-lark": "^33.0.0", diff --git a/packages/ckeditor5-code-block/src/codeblockediting.js b/packages/ckeditor5-code-block/src/codeblockediting.js index 064cb6802f6..3813955464b 100644 --- a/packages/ckeditor5-code-block/src/codeblockediting.js +++ b/packages/ckeditor5-code-block/src/codeblockediting.js @@ -87,6 +87,7 @@ export default class CodeBlockEditing extends Plugin { const schema = editor.model.schema; const model = editor.model; const view = editor.editing.view; + const isDocumentListEditingLoaded = editor.plugins.has( 'DocumentListEditing' ); const normalizedLanguagesDefs = getNormalizedAndLocalizedLanguageDefinitions( editor ); @@ -97,19 +98,20 @@ export default class CodeBlockEditing extends Plugin { editor.commands.add( 'indentCodeBlock', new IndentCodeBlockCommand( editor ) ); editor.commands.add( 'outdentCodeBlock', new OutdentCodeBlockCommand( editor ) ); - const getCommandExecuter = commandName => { - return ( data, cancel ) => { - const command = this.editor.commands.get( commandName ); + this.listenTo( view.document, 'tab', ( evt, data ) => { + const commandName = data.shiftKey ? 'outdentCodeBlock' : 'indentCodeBlock'; + const command = editor.commands.get( commandName ); - if ( command.isEnabled ) { - this.editor.execute( commandName ); - cancel(); - } - }; - }; + if ( !command.isEnabled ) { + return; + } - editor.keystrokes.set( 'Tab', getCommandExecuter( 'indentCodeBlock' ) ); - editor.keystrokes.set( 'Shift+Tab', getCommandExecuter( 'outdentCodeBlock' ) ); + editor.execute( commandName ); + + data.stopPropagation(); + data.preventDefault(); + evt.stop(); + }, { context: 'pre' } ); schema.register( 'codeBlock', { allowWhere: '$block', @@ -118,8 +120,17 @@ export default class CodeBlockEditing extends Plugin { allowAttributes: [ 'language' ] } ); + // Allow all list* attributes on `codeBlock` (integration with DocumentList). // Disallow all attributes on $text inside `codeBlock`. - schema.addAttributeCheck( context => { + schema.addAttributeCheck( ( context, attributeName ) => { + const isDocumentListAttributeOnCodeBlock = context.endsWith( 'codeBlock' ) && + attributeName.startsWith( 'list' ) && + attributeName !== 'list'; + + if ( isDocumentListEditingLoaded && isDocumentListAttributeOnCodeBlock ) { + return true; + } + if ( context.endsWith( 'codeBlock $text' ) ) { return false; } @@ -210,7 +221,11 @@ export default class CodeBlockEditing extends Plugin { const outdent = commands.get( 'outdent' ); if ( indent ) { - indent.registerChildCommand( commands.get( 'indentCodeBlock' ) ); + // Priority is highest due to integration with `IndentList` command of `List` plugin. + // If selection is in a code block we give priority to it. This way list item cannot be indented + // but if we would give priority to indenting list item then user would have to indent list item + // as much as possible and only then he could indent code block. + indent.registerChildCommand( commands.get( 'indentCodeBlock' ), { priority: 'highest' } ); } if ( outdent ) { diff --git a/packages/ckeditor5-code-block/tests/codeblock-integration.js b/packages/ckeditor5-code-block/tests/codeblock-integration.js index d8508579f0f..34a60737084 100644 --- a/packages/ckeditor5-code-block/tests/codeblock-integration.js +++ b/packages/ckeditor5-code-block/tests/codeblock-integration.js @@ -9,7 +9,8 @@ import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import GFMDataProcessor from '@ckeditor/ckeditor5-markdown-gfm/src/gfmdataprocessor'; import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import ImageInlineEditing from '@ckeditor/ckeditor5-image/src/image/imageinlineediting'; -import { getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import DocumentListEditing from '@ckeditor/ckeditor5-list/src/documentlist/documentlistediting'; +import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import CodeBlockUI from '../src/codeblockui'; import CodeBlockEditing from '../src/codeblockediting'; @@ -154,4 +155,72 @@ describe( 'CodeBlock - integration', () => { expect( getData( editor.model ) ).to.equal( '[]' ); } ); } ); + + describe( 'with DocumentListEditing', () => { + let editor, model; + + describe( 'when DocumentListEditing is loaded', () => { + beforeEach( async () => { + editor = await ClassicTestEditor + .create( '', { + plugins: [ CodeBlockEditing, DocumentListEditing, Enter, Paragraph ] + } ); + + model = editor.model; + } ); + + afterEach( async () => { + await editor.destroy(); + } ); + + it( 'should allow all attributes starting with list* in the schema', () => { + setData( model, '[]foo' ); + + const codeBlock = model.document.getRoot().getChild( 0 ); + + expect( model.schema.checkAttribute( codeBlock, 'listItemId' ), 'listItemId' ).to.be.true; + expect( model.schema.checkAttribute( codeBlock, 'listType' ), 'listType' ).to.be.true; + expect( model.schema.checkAttribute( codeBlock, 'listStart' ), 'listStart' ).to.be.true; + expect( model.schema.checkAttribute( codeBlock, 'listFoo' ), 'listFoo' ).to.be.true; + } ); + + it( 'should disallow attributes that do not start with "list" in the schema but include the sequence', () => { + setData( model, '[]foo' ); + + const codeBlock = model.document.getRoot().getChild( 0 ); + + expect( model.schema.checkAttribute( codeBlock, 'list' ), 'list' ).to.be.false; + expect( model.schema.checkAttribute( codeBlock, 'fooList' ), 'fooList' ).to.be.false; + expect( model.schema.checkAttribute( codeBlock, 'alist' ), 'alist' ).to.be.false; + expect( model.schema.checkAttribute( codeBlock, 'alistb' ), 'alistb' ).to.be.false; + expect( model.schema.checkAttribute( codeBlock, 'LISTbar' ), 'LISTbar' ).to.be.false; + } ); + } ); + + describe( 'when DocumentListEditing is not loaded', () => { + beforeEach( async () => { + editor = await ClassicTestEditor + .create( '', { + plugins: [ CodeBlockEditing, Enter, Paragraph ] + } ); + + model = editor.model; + } ); + + afterEach( async () => { + await editor.destroy(); + } ); + + it( 'should disallow all attributes starting with list* in the schema', () => { + setData( model, '[]foo' ); + + const codeBlock = model.document.getRoot().getChild( 0 ); + + expect( model.schema.checkAttribute( codeBlock, 'listItemId' ), 'listItemId' ).to.be.false; + expect( model.schema.checkAttribute( codeBlock, 'listType' ), 'listType' ).to.be.false; + expect( model.schema.checkAttribute( codeBlock, 'listStart' ), 'listStart' ).to.be.false; + expect( model.schema.checkAttribute( codeBlock, 'listFoo' ), 'listFoo' ).to.be.false; + } ); + } ); + } ); } ); diff --git a/packages/ckeditor5-code-block/tests/codeblockediting.js b/packages/ckeditor5-code-block/tests/codeblockediting.js index 4794aeeafed..fce111f2d06 100644 --- a/packages/ckeditor5-code-block/tests/codeblockediting.js +++ b/packages/ckeditor5-code-block/tests/codeblockediting.js @@ -185,7 +185,7 @@ describe( 'CodeBlockEditing', () => { } ); it( 'should execute outdentCodeBlock command on Shift+Tab keystroke', () => { - domEvtDataStub.keyCode += getCode( 'Shift' ); + domEvtDataStub.shiftKey = true; setModelData( model, '[]foo' ); @@ -223,6 +223,180 @@ describe( 'CodeBlockEditing', () => { sinon.assert.notCalled( domEvtDataStub.preventDefault ); sinon.assert.notCalled( domEvtDataStub.stopPropagation ); } ); + + it( 'should not call indent block command when outside `pre` context', () => { + const indentBlockCommand = editor.commands.get( 'indentCodeBlock' ); + const indentBlockCommandSpy = sinon.spy( indentBlockCommand, 'execute' ); + + setModelData( model, + '[]foo', + 'bar' + ); + + editor.editing.view.document.fire( 'keydown', domEvtDataStub ); + + sinon.assert.notCalled( indentBlockCommandSpy ); + sinon.assert.notCalled( domEvtDataStub.preventDefault ); + sinon.assert.notCalled( domEvtDataStub.stopPropagation ); + } ); + + it( 'should not call outdent block command when outside `pre` context', () => { + const outdentBlockCommand = editor.commands.get( 'outdentCodeBlock' ); + const outdentBlockCommandSpy = sinon.spy( outdentBlockCommand, 'execute' ); + + domEvtDataStub.shiftKey = true; + + setModelData( model, + '[]foo', + 'bar' + ); + + editor.editing.view.document.fire( 'keydown', domEvtDataStub ); + + sinon.assert.notCalled( outdentBlockCommandSpy ); + sinon.assert.notCalled( domEvtDataStub.preventDefault ); + sinon.assert.notCalled( domEvtDataStub.stopPropagation ); + } ); + + it( 'should not indent on tab key when tab event was captured by listener with higher priority', () => { + setModelData( model, '[]foo' ); + + const onTabPress = ( bubblingEventInfo, domEventData ) => { + domEventData.preventDefault(); + domEventData.stopPropagation(); + bubblingEventInfo.stop(); + }; + + const onTabPressSpy = sinon.spy( onTabPress ); + + editor.editing.view.document.on( 'tab', onTabPressSpy, { context: 'pre', priority: 'highest' } ); + + editor.editing.view.document.fire( 'keydown', domEvtDataStub ); + + sinon.assert.notCalled( editor.execute ); + sinon.assert.calledOnce( domEvtDataStub.preventDefault ); + sinon.assert.calledOnce( domEvtDataStub.stopPropagation ); + sinon.assert.calledOnce( onTabPressSpy ); + } ); + + it( 'should not be stopped by a listener with lower priority', () => { + setModelData( model, '[]foo' ); + + const onTabPress = ( bubblingEventInfo, domEventData ) => { + domEventData.preventDefault(); + domEventData.stopPropagation(); + bubblingEventInfo.stop(); + }; + + const onTabPressSpy = sinon.spy( onTabPress ); + + editor.editing.view.document.on( 'tab', onTabPressSpy, { context: 'pre', priority: 'low' } ); + + editor.editing.view.document.fire( 'keydown', domEvtDataStub ); + + sinon.assert.calledOnce( editor.execute ); + sinon.assert.calledWithExactly( editor.execute, 'indentCodeBlock' ); + sinon.assert.calledOnce( domEvtDataStub.preventDefault ); + sinon.assert.calledOnce( domEvtDataStub.stopPropagation ); + sinon.assert.notCalled( onTabPressSpy ); + } ); + + it( 'should not outdent on tab key when tab event was captured by listener with higher priority', () => { + setModelData( model, '[]foo' ); + + model.change( writer => { + // foo[] + writer.insertText( ' ', model.document.getRoot().getChild( 0 ), 0 ); + } ); + + domEvtDataStub.shiftKey = true; + + const onTabPress = ( bubblingEventInfo, domEventData ) => { + domEventData.preventDefault(); + domEventData.stopPropagation(); + bubblingEventInfo.stop(); + }; + + const onTabPressSpy = sinon.spy( onTabPress ); + + editor.editing.view.document.on( 'tab', onTabPressSpy, { context: 'pre', priority: 'highest' } ); + + editor.editing.view.document.fire( 'keydown', domEvtDataStub ); + + sinon.assert.notCalled( editor.execute ); + sinon.assert.calledOnce( domEvtDataStub.preventDefault ); + sinon.assert.calledOnce( domEvtDataStub.stopPropagation ); + sinon.assert.calledOnce( onTabPressSpy ); + } ); + + it( 'outdent should not be stopped by a listener with lower priority', () => { + setModelData( model, '[]foo' ); + + model.change( writer => { + // []foo + writer.insertText( ' ', model.document.getRoot().getChild( 0 ) ); + } ); + + domEvtDataStub.shiftKey = true; + + const onTabPress = ( bubblingEventInfo, domEventData ) => { + domEventData.preventDefault(); + domEventData.stopPropagation(); + bubblingEventInfo.stop(); + }; + + const onTabPressSpy = sinon.spy( onTabPress ); + + editor.editing.view.document.on( 'tab', onTabPressSpy, { context: 'pre', priority: 'lowest' } ); + + editor.editing.view.document.fire( 'keydown', domEvtDataStub ); + + sinon.assert.calledOnce( editor.execute ); + sinon.assert.calledWithExactly( editor.execute, 'outdentCodeBlock' ); + sinon.assert.calledOnce( domEvtDataStub.preventDefault ); + sinon.assert.calledOnce( domEvtDataStub.stopPropagation ); + sinon.assert.notCalled( onTabPressSpy ); + } ); + + it( 'should not stop tab event if indent command was not executed', () => { + model.schema.register( 'fakePre', { + allowIn: '$root' + } ); + + editor.conversion.elementToElement( { + model: 'fakePre', + view: 'pre' + } ); + + setModelData( model, '[]' ); + + editor.editing.view.document.fire( 'keydown', domEvtDataStub ); + + sinon.assert.neverCalledWith( editor.execute, 'indentCodeBlock' ); + sinon.assert.notCalled( domEvtDataStub.preventDefault ); + sinon.assert.notCalled( domEvtDataStub.stopPropagation ); + } ); + + it( 'should not stop tab event if outdent command was not executed', () => { + model.schema.register( 'fakePre', { + allowIn: '$root' + } ); + + editor.conversion.elementToElement( { + model: 'fakePre', + view: 'pre' + } ); + + setModelData( model, '[]' ); + + domEvtDataStub.shiftKey = true; + + editor.editing.view.document.fire( 'keydown', domEvtDataStub ); + + sinon.assert.neverCalledWith( editor.execute, 'outdentCodeBlock' ); + sinon.assert.notCalled( domEvtDataStub.preventDefault ); + sinon.assert.notCalled( domEvtDataStub.stopPropagation ); + } ); } ); describe( 'enter key handling', () => { diff --git a/packages/ckeditor5-core/src/multicommand.js b/packages/ckeditor5-core/src/multicommand.js index dcee892b397..f5a9cfdc273 100644 --- a/packages/ckeditor5-core/src/multicommand.js +++ b/packages/ckeditor5-core/src/multicommand.js @@ -5,6 +5,8 @@ import Command from './command'; +import insertToPriorityArray from '@ckeditor/ckeditor5-utils/src/inserttopriorityarray'; + /** * @module core/multicommand */ @@ -14,16 +16,17 @@ import Command from './command'; * * This command is used to proxy multiple commands. The multi-command is enabled when * at least one of its registered child commands is enabled. - * When executing a multi-command the first command that is enabled will be executed. + * When executing a multi-command the first enabled command with highest priority will be executed. * * const multiCommand = new MultiCommand( editor ); * * const commandFoo = new Command( editor ); * const commandBar = new Command( editor ); * - * // Register child commands. + * // Register a child command. * multiCommand.registerChildCommand( commandFoo ); - * multiCommand.registerChildCommand( commandBar ); + * // Register a child command with a low priority. + * multiCommand.registerChildCommand( commandBar, { priority: 'low' } ); * * // Enable one of the commands. * commandBar.isEnabled = true; @@ -40,12 +43,12 @@ export default class MultiCommand extends Command { super( editor ); /** - * Registered child commands. + * Registered child commands definitions. * - * @type {Array.} + * @type {Array.} * @private */ - this._childCommands = []; + this._childCommandsDefinitions = []; } /** @@ -56,23 +59,25 @@ export default class MultiCommand extends Command { } /** - * Executes the first of it registered child commands. + * Executes the first enabled command which has the highest priority of all registered child commands. * * @returns {*} The value returned by the {@link module:core/command~Command#execute `command.execute()`}. */ execute( ...args ) { const command = this._getFirstEnabledCommand(); - return command != null && command.execute( args ); + return !!command && command.execute( args ); } /** * Registers a child command. * * @param {module:core/command~Command} command + * @param {Object} options An object with configuration options. + * @param {module:utils/priorities~PriorityString} [options.priority='normal'] Priority of a command to register. */ - registerChildCommand( command ) { - this._childCommands.push( command ); + registerChildCommand( command, options = { priority: 'normal' } ) { + insertToPriorityArray( this._childCommandsDefinitions, { command, priority: options.priority } ); // Change multi command enabled state when one of registered commands changes state. command.on( 'change:isEnabled', () => this._checkEnabled() ); @@ -90,12 +95,14 @@ export default class MultiCommand extends Command { } /** - * Returns a first enabled command or undefined if none of them is enabled. + * Returns a first enabled command with the highest priority or `undefined` if none of them is enabled. * * @returns {module:core/command~Command|undefined} * @private */ _getFirstEnabledCommand() { - return this._childCommands.find( command => command.isEnabled ); + const commandDefinition = this._childCommandsDefinitions.find( ( { command } ) => command.isEnabled ); + + return commandDefinition && commandDefinition.command; } } diff --git a/packages/ckeditor5-core/tests/multicommand.js b/packages/ckeditor5-core/tests/multicommand.js index 5a03be6cd78..73c7a26fac0 100644 --- a/packages/ckeditor5-core/tests/multicommand.js +++ b/packages/ckeditor5-core/tests/multicommand.js @@ -4,12 +4,16 @@ */ import MultiCommand from '../src/multicommand'; -import ModelTestEditor from './_utils/modeltesteditor'; import Command from '../src/command'; +import ModelTestEditor from './_utils/modeltesteditor'; +import testUtils from './_utils/utils'; + describe( 'MultiCommand', () => { let editor, multiCommand; + testUtils.createSinonSandbox(); + beforeEach( () => { return ModelTestEditor .create() @@ -21,7 +25,6 @@ describe( 'MultiCommand', () => { afterEach( () => { multiCommand.destroy(); - return editor.destroy(); } ); @@ -136,4 +139,90 @@ describe( 'MultiCommand', () => { sinon.assert.notCalled( spyC ); } ); } ); + + describe( 'support for command\'s priority', () => { + it( 'should execute command with higher priority', () => { + const commandA = new Command( editor ); + const commandB = new Command( editor ); + + multiCommand.registerChildCommand( commandB, { priority: 'high' } ); + multiCommand.registerChildCommand( commandA, { priority: 'low' } ); + + const spyA = sinon.spy( commandA, 'execute' ); + const spyB = sinon.spy( commandB, 'execute' ); + + commandA.isEnabled = true; + commandB.isEnabled = true; + + multiCommand.execute(); + + sinon.assert.notCalled( spyA ); + sinon.assert.calledOnce( spyB ); + } ); + + it( 'should execute command with higher priority even if it was registered after command with lower priority', () => { + const commandA = new Command( editor ); + const commandB = new Command( editor ); + + multiCommand.registerChildCommand( commandA, { priority: 'low' } ); + multiCommand.registerChildCommand( commandB, { priority: 'high' } ); + + const spyA = sinon.spy( commandA, 'execute' ); + const spyB = sinon.spy( commandB, 'execute' ); + + commandA.isEnabled = true; + commandB.isEnabled = true; + + multiCommand.execute(); + + sinon.assert.notCalled( spyA ); + sinon.assert.calledOnce( spyB ); + } ); + + it( 'should execute first registered command if all have the same priority', () => { + const commandA = new Command( editor ); + const commandB = new Command( editor ); + const commandC = new Command( editor ); + + multiCommand.registerChildCommand( commandA, { priority: 'normal' } ); + multiCommand.registerChildCommand( commandB, { priority: 'normal' } ); + multiCommand.registerChildCommand( commandC, { priority: 'normal' } ); + + const spyA = sinon.spy( commandA, 'execute' ); + const spyB = sinon.spy( commandB, 'execute' ); + const spyC = sinon.spy( commandC, 'execute' ); + + commandA.isEnabled = true; + commandB.isEnabled = true; + commandC.isEnabled = true; + + multiCommand.execute(); + + sinon.assert.calledOnce( spyA ); + sinon.assert.notCalled( spyB ); + sinon.assert.notCalled( spyC ); + } ); + + it( 'should execute command with lower priority if commands with higher priority are disabled', () => { + const commandA = new Command( editor ); + const commandB = new Command( editor ); + const commandC = new Command( editor ); + + multiCommand.registerChildCommand( commandA, { priority: 'low' } ); + multiCommand.registerChildCommand( commandB, { priority: 'high' } ); + multiCommand.registerChildCommand( commandC, { priority: 'highest' } ); + + const spyA = sinon.spy( commandA, 'execute' ); + const spyB = sinon.spy( commandB, 'execute' ); + const spyC = sinon.spy( commandC, 'execute' ); + + commandA.isEnabled = true; + + multiCommand.execute(); + + sinon.assert.calledOnce( spyA ); + sinon.assert.notCalled( spyB ); + sinon.assert.notCalled( spyC ); + } ); + } ); } ); diff --git a/packages/ckeditor5-engine/docs/framework/guides/deep-dive/schema.md b/packages/ckeditor5-engine/docs/framework/guides/deep-dive/schema.md index 03693861892..4bdf68a4be9 100644 --- a/packages/ckeditor5-engine/docs/framework/guides/deep-dive/schema.md +++ b/packages/ckeditor5-engine/docs/framework/guides/deep-dive/schema.md @@ -101,6 +101,33 @@ Here is a table listing various model elements and their properties registered i false false + + $container + false + false + false + false + false + false + + + $blockObject + true + true[1] + true + false + true[2] + true[3] + + + $inlineObject + false + true[1] + true + true + true[2] + true[3] + $clipboardHolder false @@ -202,7 +229,7 @@ Here is a table listing various model elements and their properties registered i horizontalLine - false + true true[1] true false @@ -247,7 +274,7 @@ Here is a table listing various model elements and their properties registered i pageBreak - false + true true[1] true false @@ -409,19 +436,39 @@ At the same time, elements like paragraphs, list items, or headings **are not** ## Generic items -There are three basic generic items: `$root`, `$block` and `$text`. They are defined as follows: +There are several generic items (classes of elements) available: `$root`, `$container`, `$block`, `$blockObject`, `$inlineObject`, and `$text`. They are defined as follows: ```js schema.register( '$root', { isLimit: true } ); + +schema.register( '$container', { + allowIn: [ '$root', '$container' ] +} ); + schema.register( '$block', { - allowIn: '$root', + allowIn: [ '$root', '$container' ], isBlock: true } ); + +schema.register( '$blockObject', { + allowWhere: '$block', + isBlock: true, + isObject: true +} ); + +schema.register( '$inlineObject', { + allowWhere: '$text', + allowAttributesOf: '$text', + isInline: true, + isObject: true +} ); + schema.register( '$text', { allowIn: '$block', - isInline: true + isInline: true, + isContent: true } ); ``` @@ -455,12 +502,13 @@ Thanks to the fact that the `` definition is inherited from `<$block> ```js schema.register( 'blockQuote', { - allowWhere: '$block', - allowContentOf: '$root' + inheritAllFrom: '$container' } ); ``` -Thanks to that, despite the fact that block quote and paragraph features know nothing about themselves, paragraphs will be allowed in block quotes and block quotes will be allowed in all places where blocks are allowed. So if anyone registers a `
` element (with the `allowContentOf: '$root'` rule), that `
` elements will allow block quotes, too. +Because `<$block>` is allowed in `<$container>` (see `schema.register( '$block' ...)`), despite the fact that block quote and paragraph features know nothing about each other, paragraphs will be allowed in block quotes: schema rules allow chaining. + +Taking this even further, if anyone registers a `
` element (with the `allowContentOf: '$root'` rule), because `<$container>` is also allowed in `<$root>` (see `schema.register( '$container' ...)`) `
` elements will allow block quotes out–of–the–box. You can read more about the format of the item definition in {@link module:engine/model/schema~SchemaItemDefinition}. diff --git a/packages/ckeditor5-engine/src/conversion/downcasthelpers.js b/packages/ckeditor5-engine/src/conversion/downcasthelpers.js index 332da730da2..43797afd715 100644 --- a/packages/ckeditor5-engine/src/conversion/downcasthelpers.js +++ b/packages/ckeditor5-engine/src/conversion/downcasthelpers.js @@ -151,8 +151,9 @@ export default class DowncastHelpers extends ConversionHelpers { * the view element. Note that the view will be reconverted if any of the listed attributes changes. * @param {Boolean} [config.model.children] Specifies whether the view element requires reconversion if the list * of the model child nodes changed. - * @param {module:engine/view/elementdefinition~ElementDefinition|Function} config.view A view element definition or a function - * that takes the model element and {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast conversion API} + * @param {module:engine/view/elementdefinition~ElementDefinition|module:engine/conversion/downcasthelpers~ElementCreatorFunction} + * config.view A view element definition or a function that takes the model element and + * {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast conversion API} * as parameters and returns a view container element. * @returns {module:engine/conversion/downcasthelpers~DowncastHelpers} */ @@ -377,7 +378,8 @@ export default class DowncastHelpers extends ConversionHelpers { * @param {Object} config Conversion configuration. * @param {String|Object} config.model The key of the attribute to convert from or a `{ key, values }` object. `values` is an array * of `String`s with possible values if the model attribute is an enumerable. - * @param {module:engine/view/elementdefinition~ElementDefinition|Function|Object} config.view A view element definition or a function + * @param {module:engine/view/elementdefinition~ElementDefinition|Object| + * module:engine/conversion/downcasthelpers~AttributeElementCreatorFunction} config.view A view element definition or a function * that takes the model attribute value and * {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast conversion API} as parameters and returns a view * attribute element. If `config.model.values` is given, `config.view` should be an object assigning values from `config.model.values` @@ -459,8 +461,9 @@ export default class DowncastHelpers extends ConversionHelpers { * @param {Object} config Conversion configuration. * @param {String|Object} config.model The key of the attribute to convert from or a `{ key, values, [ name ] }` object describing * the attribute key, possible values and, optionally, an element name to convert from. - * @param {String|Object|Function} config.view A view attribute key, or a `{ key, value }` object or a function that takes - * the model attribute value and {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast conversion API} + * @param {String|Object|module:engine/conversion/downcasthelpers~AttributeCreatorFunction} config.view A view attribute key, + * or a `{ key, value }` object or a function that takes the model attribute value and + * {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast conversion API} * as parameters and returns a `{ key, value }` object. If the `key` is `'class'`, the `value` can be a `String` or an * array of `String`s. If the `key` is `'style'`, the `value` is an object with key-value pairs. In other cases, `value` is a `String`. * If `config.model.values` is set, `config.view` should be an object assigning values from `config.model.values` to @@ -971,10 +974,10 @@ export function wrap( elementCreator ) { // Recreate current wrapping node. It will be used to unwrap view range if the attribute value has changed // or the attribute was removed. - const oldViewElement = elementCreator( data.attributeOldValue, conversionApi ); + const oldViewElement = elementCreator( data.attributeOldValue, conversionApi, data ); // Create node to wrap with. - const newViewElement = elementCreator( data.attributeNewValue, conversionApi ); + const newViewElement = elementCreator( data.attributeNewValue, conversionApi, data ); if ( !oldViewElement && !newViewElement ) { return; @@ -1037,7 +1040,7 @@ export function insertElement( elementCreator, consumer = defaultConsumer ) { return; } - const viewElement = elementCreator( data.item, conversionApi ); + const viewElement = elementCreator( data.item, conversionApi, data ); if ( !viewElement ) { return; @@ -1085,7 +1088,7 @@ export function insertStructure( elementCreator, consumer ) { conversionApi.writer._registerSlotFactory( createSlotFactory( data.item, slotsMap, conversionApi ) ); // View creation. - const viewElement = elementCreator( data.item, conversionApi ); + const viewElement = elementCreator( data.item, conversionApi, data ); conversionApi.writer._clearSlotFactory(); @@ -1376,8 +1379,8 @@ function changeAttribute( attributeCreator ) { return; } - const oldAttribute = attributeCreator( data.attributeOldValue, conversionApi ); - const newAttribute = attributeCreator( data.attributeNewValue, conversionApi ); + const oldAttribute = attributeCreator( data.attributeOldValue, conversionApi, data ); + const newAttribute = attributeCreator( data.attributeNewValue, conversionApi, data ); if ( !oldAttribute && !newAttribute ) { return; @@ -1653,7 +1656,8 @@ function removeHighlight( highlightDescriptor ) { // @param {String|Object} config.model The description or a name of the model element to convert. // @param {String|Array.} [config.model.attributes] List of attributes triggering element reconversion. // @param {Boolean} [config.model.children] Should reconvert element if the list of model child nodes changed. -// @param {module:engine/view/elementdefinition~ElementDefinition|Function} config.view +// @param {module:engine/view/elementdefinition~ElementDefinition|module:engine/conversion/downcasthelpers~ElementCreatorFunction} +// config.view // @returns {Function} Conversion helper. function downcastElementToElement( config ) { config = cloneDeep( config ); @@ -1760,10 +1764,11 @@ function downcastElementToStructure( config ) { // @param {Object} config Conversion configuration. // @param {String|Object} config.model The key of the attribute to convert from or a `{ key, values }` object. `values` is an array // of `String`s with possible values if the model attribute is an enumerable. -// @param {module:engine/view/elementdefinition~ElementDefinition|Function|Object} config.view A view element definition or a function -// that takes the model attribute value and {@link module:engine/view/downcastwriter~DowncastWriter view downcast writer} -// as parameters and returns a view attribute element. If `config.model.values` is -// given, `config.view` should be an object assigning values from `config.model.values` to view element definitions or functions. +// @param {module:engine/view/elementdefinition~ElementDefinition|module:engine/conversion/downcasthelpers~AttributeElementCreatorFunction| +// Object} config.view A view element definition or a function that takes the model attribute value and +// {@link module:engine/view/downcastwriter~DowncastWriter view downcast writer} as parameters and returns a view attribute element. +// If `config.model.values` is given, `config.view` should be an object assigning values from `config.model.values` to view element +// definitions or functions. // @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority. // @returns {Function} Conversion helper. function downcastAttributeToElement( config ) { @@ -1798,9 +1803,10 @@ function downcastAttributeToElement( config ) { // @param {Object} config Conversion configuration. // @param {String|Object} config.model The key of the attribute to convert from or a `{ key, values, [ name ] }` object describing // the attribute key, possible values and, optionally, an element name to convert from. -// @param {String|Object|Function} config.view A view attribute key, or a `{ key, value }` object or a function that takes -// the model attribute value and returns a `{ key, value }` object. If `key` is `'class'`, `value` can be a `String` or an -// array of `String`s. If `key` is `'style'`, `value` is an object with key-value pairs. In other cases, `value` is a `String`. +// @param {String|Object|module:engine/conversion/downcasthelpers~AttributeCreatorFunction} config.view A view attribute key, +// or a `{ key, value }` object or a function that takes the model attribute value and returns a `{ key, value }` object. +// If `key` is `'class'`, `value` can be a `String` or an array of `String`s. If `key` is `'style'`, `value` is an object with +// key-value pairs. In other cases, `value` is a `String`. // If `config.model.values` is set, `config.view` should be an object assigning values from `config.model.values` to // `{ key, value }` objects or a functions. // @param {module:utils/priorities~PriorityString} [config.converterPriority='normal'] Converter priority. @@ -2386,6 +2392,23 @@ function defaultConsumer( item, consumable, { preflight } = {} ) { * @see module:engine/conversion/downcasthelpers~insertStructure */ +/** + * A view element creator function that takes the model element and {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi + * downcast conversion API} as parameters and returns a view container element. + * + * @callback module:engine/conversion/downcasthelpers~ElementCreatorFunction + * @param {module:engine/model/element~Element} element The model element to be converted to the view structure. + * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi The conversion interface. + * @param {Object} data Additional information about the change (same as for + * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:insert `insert`} event). + * @param {module:engine/model/item~Item} data.item Inserted item. + * @param {module:engine/model/range~Range} data.range Range spanning over inserted item. + * @returns {module:engine/view/element~Element} The view element. + * + * @see module:engine/conversion/downcasthelpers~DowncastHelpers#elementToElement + * @see module:engine/conversion/downcasthelpers~insertElement + */ + /** * A function that takes the model element and {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast * conversion API} as parameters and returns a view container element with slots for model child nodes to be converted into. @@ -2393,12 +2416,60 @@ function defaultConsumer( item, consumable, { preflight } = {} ) { * @callback module:engine/conversion/downcasthelpers~StructureCreatorFunction * @param {module:engine/model/element~Element} element The model element to be converted to the view structure. * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi The conversion interface. + * @param {Object} data Additional information about the change (same as for + * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:insert `insert`} event). + * @param {module:engine/model/item~Item} data.item Inserted item. + * @param {module:engine/model/range~Range} data.range Range spanning over inserted item. * @returns {module:engine/view/element~Element} The view structure with slots for model child nodes. * * @see module:engine/conversion/downcasthelpers~DowncastHelpers#elementToStructure * @see module:engine/conversion/downcasthelpers~insertStructure */ +/** + * A view element creator function that takes the model attribute value and + * {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast conversion API} as parameters and returns a view + * attribute element. + * + * @callback module:engine/conversion/downcasthelpers~AttributeElementCreatorFunction + * @param {*} attributeValue The model attribute value to be converted to the view attribute element. + * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi The conversion interface. + * @param {Object} data Additional information about the change (same as for + * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute `attribute`} event). + * @param {module:engine/model/item~Item|module:engine/model/documentselection~DocumentSelection} data.item Changed item + * or converted selection. + * @param {module:engine/model/range~Range} data.range Range spanning over changed item or selection range. + * @param {String} data.attributeKey Attribute key. + * @param {*} data.attributeOldValue Attribute value before the change. This is `null` when selection attribute is converted. + * @param {*} data.attributeNewValue New attribute value. + * @returns {module:engine/view/attributeelement~AttributeElement} The view attribute element. + * + * @see module:engine/conversion/downcasthelpers~DowncastHelpers#attributeToElement + * @see module:engine/conversion/downcasthelpers~wrap + */ + +/** + * A function that takes the model attribute value and + * {@link module:engine/conversion/downcastdispatcher~DowncastConversionApi downcast conversion API} + * as parameters. + * + * @callback module:engine/conversion/downcasthelpers~AttributeCreatorFunction + * @param {*} attributeValue The model attribute value to be converted to the view attribute element. + * @param {module:engine/conversion/downcastdispatcher~DowncastConversionApi} conversionApi The conversion interface. + * @param {Object} data Additional information about the change (same as for + * {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher#event:attribute `attribute`} event). + * @param {module:engine/model/item~Item|module:engine/model/documentselection~DocumentSelection} data.item Changed item + * or converted selection. + * @param {module:engine/model/range~Range} data.range Range spanning over changed item or selection range. + * @param {String} data.attributeKey Attribute key. + * @param {*} data.attributeOldValue Attribute value before the change. This is `null` when selection attribute is converted. + * @param {*} data.attributeNewValue New attribute value. + * @returns {Object|null} A `{ key, value }` object. If `key` is `'class'`, `value` can be a `String` or an + * array of `String`s. If `key` is `'style'`, `value` is an object with key-value pairs. In other cases, `value` is a `String`. + * + * @see module:engine/conversion/downcasthelpers~DowncastHelpers#attributeToAttribute + */ + /** * A function that is expected to consume all the consumables that were used by the element creator. * diff --git a/packages/ckeditor5-engine/src/conversion/mapper.js b/packages/ckeditor5-engine/src/conversion/mapper.js index 0c485cd6309..6fb8f04540e 100644 --- a/packages/ckeditor5-engine/src/conversion/mapper.js +++ b/packages/ckeditor5-engine/src/conversion/mapper.js @@ -121,9 +121,9 @@ export default class Mapper { * * Make sure that the model element is correctly converted to the view. * - * @error mapping-view-position-parent-not-found + * @error mapping-model-position-view-parent-not-found */ - throw new CKEditorError( 'mapping-view-position-parent-not-found', this, { modelPosition: data.modelPosition } ); + throw new CKEditorError( 'mapping-model-position-view-parent-not-found', this, { modelPosition: data.modelPosition } ); } data.viewPosition = this.findPositionIn( viewContainer, data.modelPosition.offset ); diff --git a/packages/ckeditor5-engine/src/conversion/upcastdispatcher.js b/packages/ckeditor5-engine/src/conversion/upcastdispatcher.js index 2213d11415d..a5800747b72 100644 --- a/packages/ckeditor5-engine/src/conversion/upcastdispatcher.js +++ b/packages/ckeditor5-engine/src/conversion/upcastdispatcher.js @@ -151,6 +151,16 @@ export default class UpcastDispatcher { */ this._modelCursor = null; + /** + * The list of elements that were created during splitting but should not get removed on conversion end even if they are empty. + * + * After the conversion process the list is cleared. + * + * @private + * @type {Set.} + */ + this._emptyElementsToKeep = new Set(); + /** * An interface passed by the dispatcher to the event callbacks. * @@ -167,6 +177,7 @@ export default class UpcastDispatcher { // Advanced API - use only if custom position handling is needed. this.conversionApi.splitToAllowedParent = this._splitToAllowedParent.bind( this ); this.conversionApi.getSplitParts = this._getSplitParts.bind( this ); + this.conversionApi.keepEmptyElement = this._keepEmptyElement.bind( this ); } /** @@ -226,6 +237,7 @@ export default class UpcastDispatcher { // Clear split elements & parents lists. this._splitParts.clear(); this._cursorParents.clear(); + this._emptyElementsToKeep.clear(); // Clear conversion API. this.conversionApi.writer = null; @@ -451,6 +463,15 @@ export default class UpcastDispatcher { return parts; } + /** + * Mark an element that were created during splitting that it should not get removed on conversion end even if it's empty. + * + * @private + */ + _keepEmptyElement( element ) { + this._emptyElementsToKeep.add( element ); + } + /** * Checks if there are any empty elements created while splitting and removes them. * @@ -463,7 +484,7 @@ export default class UpcastDispatcher { let anyRemoved = false; for ( const element of this._splitParts.keys() ) { - if ( element.isEmpty ) { + if ( element.isEmpty && !this._emptyElementsToKeep.has( element ) ) { this.conversionApi.writer.remove( element ); this._splitParts.delete( element ); @@ -757,6 +778,15 @@ function createContextTree( contextDefinition, writer ) { * @returns {Array.} */ +/** + * Mark an element that was created during splitting that it should not get removed on conversion end even if it's empty. + * + * **Note:** This is an advanced method. For most cases you will not need to keep the split empty element. + * + * @method #keepEmptyElement + * @param {module:engine/model/element~Element} element + */ + /** * Stores information about what parts of the processed view item are still waiting to be handled. After a piece of view item * was converted, an appropriate consumable value should be diff --git a/packages/ckeditor5-engine/src/model/differ.js b/packages/ckeditor5-engine/src/model/differ.js index 879f1823df5..fc2011e033f 100644 --- a/packages/ckeditor5-engine/src/model/differ.js +++ b/packages/ckeditor5-engine/src/model/differ.js @@ -423,12 +423,12 @@ export default class Differ { for ( const action of actions ) { if ( action === 'i' ) { // Generate diff item for this element and insert it into the diff set. - diffSet.push( this._getInsertDiff( element, i, elementChildren[ i ].name ) ); + diffSet.push( this._getInsertDiff( element, i, elementChildren[ i ] ) ); i++; } else if ( action === 'r' ) { // Generate diff item for this element and insert it into the diff set. - diffSet.push( this._getRemoveDiff( element, i, snapshotChildren[ j ].name ) ); + diffSet.push( this._getRemoveDiff( element, i, snapshotChildren[ j ] ) ); j++; } else if ( action === 'a' ) { @@ -929,14 +929,15 @@ export default class Differ { * @private * @param {module:engine/model/element~Element} parent The element in which the change happened. * @param {Number} offset The offset at which change happened. - * @param {String} name The name of the removed element or `'$text'` for a character. + * @param {Object} elementSnapshot The snapshot of the removed element a character. * @returns {Object} The diff item. */ - _getInsertDiff( parent, offset, name ) { + _getInsertDiff( parent, offset, elementSnapshot ) { return { type: 'insert', position: Position._createAt( parent, offset ), - name, + name: elementSnapshot.name, + attributes: new Map( elementSnapshot.attributes ), length: 1, changeCount: this._changeCount++ }; @@ -948,14 +949,15 @@ export default class Differ { * @private * @param {module:engine/model/element~Element} parent The element in which change happened. * @param {Number} offset The offset at which change happened. - * @param {String} name The name of the removed element or `'$text'` for a character. + * @param {Object} elementSnapshot The snapshot of the removed element a character. * @returns {Object} The diff item. */ - _getRemoveDiff( parent, offset, name ) { + _getRemoveDiff( parent, offset, elementSnapshot ) { return { type: 'remove', position: Position._createAt( parent, offset ), - name, + name: elementSnapshot.name, + attributes: new Map( elementSnapshot.attributes ), length: 1, changeCount: this._changeCount++ }; @@ -1233,6 +1235,12 @@ function _changesInGraveyardFilter( entry ) { * @member {String} module:engine/model/differ~DiffItemInsert#name */ +/** + * Map of attributes that were set on the item while it was inserted. + * + * @member {Map.} module:engine/model/differ~DiffItemInsert#attributes + */ + /** * The position where the node was inserted. * @@ -1264,6 +1272,12 @@ function _changesInGraveyardFilter( entry ) { * @member {String} module:engine/model/differ~DiffItemRemove#name */ +/** + * Map of attributes that were set on the item while it was removed. + * + * @member {Map.} module:engine/model/differ~DiffItemRemove#attributes + */ + /** * The position where the node was removed. * diff --git a/packages/ckeditor5-engine/src/model/model.js b/packages/ckeditor5-engine/src/model/model.js index 464c62d2ace..95c8e66d91b 100644 --- a/packages/ckeditor5-engine/src/model/model.js +++ b/packages/ckeditor5-engine/src/model/model.js @@ -21,6 +21,7 @@ import ModelSelection from './selection'; import OperationFactory from './operation/operationfactory'; import insertContent from './utils/insertcontent'; +import insertObject from './utils/insertobject'; import deleteContent from './utils/deletecontent'; import modifySelection from './utils/modifyselection'; import getSelectedContent from './utils/getselectedcontent'; @@ -80,7 +81,7 @@ export default class Model { */ this._currentWriter = null; - [ 'insertContent', 'deleteContent', 'modifySelection', 'getSelectedContent', 'applyOperation' ] + [ 'insertContent', 'insertObject', 'deleteContent', 'modifySelection', 'getSelectedContent', 'applyOperation' ] .forEach( methodName => this.decorate( methodName ) ); // Adding operation validation with `highest` priority, so it is called before any other feature would like @@ -96,11 +97,28 @@ export default class Model { isLimit: true } ); + this.schema.register( '$container', { + allowIn: [ '$root', '$container' ] + } ); + this.schema.register( '$block', { - allowIn: '$root', + allowIn: [ '$root', '$container' ], isBlock: true } ); + this.schema.register( '$blockObject', { + allowWhere: '$block', + isBlock: true, + isObject: true + } ); + + this.schema.register( '$inlineObject', { + allowWhere: '$text', + allowAttributesOf: '$text', + isInline: true, + isObject: true + } ); + this.schema.register( '$text', { allowIn: '$block', isInline: true, @@ -304,6 +322,9 @@ export default class Model { * Inserts content at the position in the editor specified by the selection, as one would expect the paste * functionality to work. * + * **Note**: If you want to insert an {@glink framework/guides/deep-dive/schema#object-elements object element} + * (e.g. a {@link module:widget/utils~toWidget widget}), see {@link #insertObject} instead. + * * This is a high-level method. It takes the {@link #schema schema} into consideration when inserting * the content, clears the given selection's content before inserting nodes and moves the selection * to its target position at the end of the process. @@ -435,6 +456,89 @@ export default class Model { return insertContent( this, content, selectable, placeOrOffset ); } + /** + * Inserts an {@glink framework/guides/deep-dive/schema#object-elements object element} at a specific position in the editor content. + * + * This is a high-level API: + * * It takes the {@link #schema schema} into consideration, + * * It clears the content of passed `selectable` before inserting, + * * It can move the selection at the end of the process, + * * It will copy the selected block's attributes to preserve them upon insertion, + * * It can split elements or wrap inline objects with paragraphs if they are not allowed in target position, + * * etc. + * + * # Notes + * + * * If you want to insert a non-object content, see {@link #insertContent} instead. + * * For lower-level API, see {@link module:engine/model/writer~Writer `Writer`}. + * * Unlike {@link module:engine/model/writer~Writer `Writer`}, this method does not have to be used inside + * a {@link #change `change()` block}. + * * Inserting object into the model is not enough to make CKEditor 5 render that content to the user. + * CKEditor 5 implements a model-view-controller architecture and what `model.insertObject()` does + * is only adding nodes to the model. Additionally, you need to define + * {@glink framework/guides/architecture/editing-engine#conversion converters} between the model and view + * and define those nodes in the {@glink framework/guides/architecture/editing-engine#schema schema}. + * + * # Examples + * + * Use the following code to insert an object at the current selection and keep the selection on the inserted element: + * + * const rawHtmlEmbedElement = writer.createElement( 'rawHtml' ); + * + * model.insertObject( rawHtmlEmbedElement, null, null, { + * setSelection: 'on' + * } ); + * + * Use the following code to insert an object at the current selection and nudge the selection after the inserted object: + * + * const pageBreakElement = writer.createElement( 'pageBreak' ); + * + * model.insertObject( pageBreakElement, null, null, { + * setSelection: 'after' + * } ); + * + * Use the following code to insert an object at the current selection and avoid splitting the content (non-destructive insertion): + * + * const tableElement = writer.createElement( 'table' ); + * + * model.insertObject( tableElement, null, null, { + * findOptimalPosition: 'auto' + * } ); + * + * Use the following code to insert an object at the specific range (also: replace the content of the range): + * + * const tableElement = writer.createElement( 'table' ); + * const range = model.createRangeOn( model.document.getRoot().getChild( 1 ) ); + * + * model.insertObject( tableElement, range ); + * + * @param {module:engine/model/element~Element} object An object to be inserted into the model document. + * @param {module:engine/model/selection~Selectable} [selectable=model.document.selection] + * A selectable where the content should be inserted. If not specified, the current + * {@link module:engine/model/document~Document#selection document selection} will be used instead. + * @param {Number|'before'|'end'|'after'|'on'|'in'} placeOrOffset Specifies the exact place or offset for the insertion to take place, + * relative to `selectable`. + * @param {Object} [options] Additional options. + * @param {'auto'|'before'|'after'} [options.findOptimalPosition] An option that, when set, adjusts the insertion position (relative to + * `selectable` and `placeOrOffset`) so that the content of `selectable` is not split upon insertion (a.k.a. non-destructive insertion). + * * When `'auto'`, the algorithm will decide whether to insert the object before or after `selectable` to avoid content splitting. + * * When `'before'`, the closest position before `selectable` will be used that will not result in content splitting. + * * When `'after'`, the closest position after `selectable` will be used that will not result in content splitting. + * + * Note that this option works only for block objects. Inline objects are inserted into text and do not split blocks. + * @param {'on'|'after'} [options.setSelection] An option that, when set, moves the + * {@link module:engine/model/document~Document#selection document selection} after inserting the object. + * * When `'on'`, the document selection will be set on the inserted object. + * * When `'after'`, the document selection will move to the closest text node after the inserted object. If there is no + * such text node, a paragraph will be created and the document selection will be moved inside it. + * @returns {module:engine/model/range~Range} A range which contains all the performed changes. This is a range that, if removed, + * would return the model to the state before the insertion. If no changes were preformed by `insertObject()`, returns a range collapsed + * at the insertion position. + */ + insertObject( object, selectable, placeOrOffset, options ) { + return insertObject( this, object, selectable, placeOrOffset, options ); + } + /** * Deletes content of the selection and merge siblings. The resulting selection is always collapsed. * @@ -903,12 +1007,25 @@ export default class Model { * listener to this event so it can be fully customized by the features. * * **Note** The `selectable` parameter for the {@link #insertContent} is optional. When `undefined` value is passed the method uses - * `model.document.selection`. + * {@link module:engine/model/document~Document#selection document selection}. * * @event insertContent * @param {Array} args The arguments passed to the original method. */ + /** + * Event fired when {@link #insertObject} method is called. + * + * The {@link #insertObject default action of that method} is implemented as a + * listener to this event so it can be fully customized by the features. + * + * **Note** The `selectable` parameter for the {@link #insertObject} is optional. When `undefined` value is passed the method uses + * {@link module:engine/model/document~Document#selection document selection}. + * + * @event insertObject + * @param {Array} args The arguments passed to the original method. + */ + /** * Event fired when {@link #deleteContent} method is called. * diff --git a/packages/ckeditor5-engine/src/model/schema.js b/packages/ckeditor5-engine/src/model/schema.js index 0ca2d4b946e..e6e156ae0bb 100644 --- a/packages/ckeditor5-engine/src/model/schema.js +++ b/packages/ckeditor5-engine/src/model/schema.js @@ -834,6 +834,23 @@ export default class Schema { return null; } + /** + * Sets attributes allowed by the schema on given node. + * + * @param {module:engine/model/node~Node} node A node to set attributes on. + * @param {Object} attributes Attributes keys and values. + * @param {module:engine/model/writer~Writer} writer An instance of the model writer. + */ + setAllowedAttributes( node, attributes, writer ) { + const model = writer.model; + + for ( const [ attributeName, attributeValue ] of Object.entries( attributes ) ) { + if ( model.schema.checkAttribute( node, attributeName ) ) { + writer.setAttribute( attributeName, attributeValue, node ); + } + } + } + /** * Removes attributes disallowed by the schema. * @@ -863,6 +880,34 @@ export default class Schema { } } + /** + * Gets attributes of a node that have given property. + * + * @param {module:engine/model/node~Node} node Node to get attributes from. + * @param {String} propertyName Name of the property that attribute must have to return it. + * @param {Boolean|Symbol|String|Number|Object|null|undefined} propertyValue Desired value of the property that we want to check. + * When `undefined` attributes will be returned if they have set a given property no matter what the value is. If specified it will + * return attributes which given property's value is equal to this parameter. + * @returns {Object} Object with attributes' names as key and attributes' values as value. + */ + getAttributesWithProperty( node, propertyName, propertyValue ) { + const attributes = {}; + + for ( const [ attributeName, attributeValue ] of node.getAttributes() ) { + const attributeProperties = this.getAttributeProperties( attributeName ); + + if ( attributeProperties[ propertyName ] === undefined ) { + continue; + } + + if ( propertyValue === undefined || propertyValue === attributeProperties[ propertyName ] ) { + attributes[ attributeName ] = attributeValue; + } + } + + return attributes; + } + /** * Creates an instance of the schema context. * @@ -1135,19 +1180,39 @@ mix( Schema, ObservableMixin ); * * # Generic items * - * There are three basic generic items: `$root`, `$block` and `$text`. - * They are defined as follows: + * There are several generic items (classes of elements) available: `$root`, `$container`, `$block`, `$blockObject`, + * `$inlineObject`, and `$text`. They are defined as follows: * - * this.schema.register( '$root', { + * schema.register( '$root', { * isLimit: true * } ); - * this.schema.register( '$block', { - * allowIn: '$root', + * + * schema.register( '$container', { + * allowIn: [ '$root', '$container' ] + * } ); + * + * schema.register( '$block', { + * allowIn: [ '$root', '$container' ], * isBlock: true * } ); - * this.schema.register( '$text', { + * + * schema.register( '$blockObject', { + * allowWhere: '$block', + * isBlock: true, + * isObject: true + * } ); + * + * schema.register( '$inlineObject', { + * allowWhere: '$text', + * allowAttributesOf: '$text', + * isInline: true, + * isObject: true + * } ); + * + * schema.register( '$text', { * allowIn: '$block', - * isInline: true + * isInline: true, + * isContent: true * } ); * * They reflect typical editor content that is contained within one root, consists of several blocks @@ -1180,14 +1245,18 @@ mix( Schema, ObservableMixin ); * isBlock: true * } ); * + * The previous rule can be written in a shorter form using inheritance: + * + * schema.register( 'paragraph', { + * inheritAllFrom: '$block' + * } ); + * * Make `imageBlock` a block object, which is allowed everywhere where `$block` is. * Also, allow `src` and `alt` attributes in it: * * schema.register( 'imageBlock', { - * allowWhere: '$block', + * inheritAllFrom: '$blockObject', * allowAttributes: [ 'src', 'alt' ], - * isBlock: true, - * isObject: true * } ); * * Make `caption` allowed in `imageBlock` and make it allow all the content of `$block`s (usually, `$text`). diff --git a/packages/ckeditor5-engine/src/model/utils/deletecontent.js b/packages/ckeditor5-engine/src/model/utils/deletecontent.js index 661176327c9..3b071495dd1 100644 --- a/packages/ckeditor5-engine/src/model/utils/deletecontent.js +++ b/packages/ckeditor5-engine/src/model/utils/deletecontent.js @@ -80,6 +80,17 @@ export default function deleteContent( model, selection, options = {} ) { return; } + // Collect attributes to copy in case of autoparagraphing. + const attributesForAutoparagraph = {}; + + if ( !options.doNotAutoparagraph ) { + const selectedElement = selection.getSelectedElement(); + + if ( selectedElement ) { + Object.assign( attributesForAutoparagraph, schema.getAttributesWithProperty( selectedElement, 'copyOnReplace', true ) ); + } + } + // Get the live positions for the range adjusted to span only blocks selected from the user perspective. const [ startPosition, endPosition ] = getLivePositionsForSelectedBlocks( selRange ); @@ -114,7 +125,7 @@ export default function deleteContent( model, selection, options = {} ) { // Check if a text is allowed in the new container. If not, try to create a new paragraph (if it's allowed here). // If autoparagraphing is off, we assume that you know what you do so we leave the selection wherever it was. if ( !options.doNotAutoparagraph && shouldAutoparagraph( schema, startPosition ) ) { - insertParagraph( writer, startPosition, selection ); + insertParagraph( writer, startPosition, selection, attributesForAutoparagraph ); } startPosition.detach(); @@ -482,9 +493,11 @@ function isCrossingLimitElement( leftPos, rightPos, schema ) { return true; } -function insertParagraph( writer, position, selection ) { +function insertParagraph( writer, position, selection, attributes = {} ) { const paragraph = writer.createElement( 'paragraph' ); + writer.model.schema.setAllowedAttributes( paragraph, attributes, writer ); + writer.insert( paragraph, position ); collapseSelectionAt( writer, selection, writer.createPositionAt( paragraph, 0 ) ); diff --git a/packages/ckeditor5-engine/src/model/utils/findoptimalinsertionrange.js b/packages/ckeditor5-engine/src/model/utils/findoptimalinsertionrange.js new file mode 100644 index 00000000000..c05f45ce373 --- /dev/null +++ b/packages/ckeditor5-engine/src/model/utils/findoptimalinsertionrange.js @@ -0,0 +1,68 @@ +/** + * @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 engine/model/utils/findoptimalinsertionrange + */ + +import first from '@ckeditor/ckeditor5-utils/src/first'; + +// Returns a model range which is optimal (in terms of UX) for inserting a widget block. +// +// For instance, if a selection is in the middle of a paragraph, the collapsed range before this paragraph +// will be returned so that it is not split. If the selection is at the end of a paragraph, +// the collapsed range after this paragraph will be returned. +// +// Note: If the selection is placed in an empty block, the range in that block will be returned. If that range +// is then passed to {@link module:engine/model/model~Model#insertContent}, the block will be fully replaced +// by the inserted widget block. +// +// **Note:** Use {@link module:widget/utils#findOptimalInsertionRange} instead of this function outside engine. +// This function is only exposed to be used by {@link module:widget/utils#findOptimalInsertionRange findOptimalInsertionRange()} +// in `widget` package and inside `engine` package. +// +// @private +// @param {module:engine/model/selection~Selection|module:engine/model/documentselection~DocumentSelection} selection +// The selection based on which the insertion position should be calculated. +// @param {module:engine/model/model~Model} model Model instance. +// @param {'auto'|'before'|'after'} [place='auto'] Place where to look for optimal insertion range. +// Default value `auto` will determine itself the best position for insertion. +// Value `before` will try to find a position before selection. +// Value `after` will try to find a position after selection. +// @returns {module:engine/model/range~Range} The optimal range. +export function findOptimalInsertionRange( selection, model, place = 'auto' ) { + const selectedElement = selection.getSelectedElement(); + + if ( selectedElement && model.schema.isObject( selectedElement ) && !model.schema.isInline( selectedElement ) ) { + if ( [ 'before', 'after' ].includes( place ) ) { + return model.createRange( model.createPositionAt( selectedElement, place ) ); + } + + return model.createRangeOn( selectedElement ); + } + + const firstBlock = first( selection.getSelectedBlocks() ); + + // There are no block elements within ancestors (in the current limit element). + if ( !firstBlock ) { + return model.createRange( selection.focus ); + } + + // If inserting into an empty block – return position in that block. It will get + // replaced with the image by insertContent(). #42. + if ( firstBlock.isEmpty ) { + return model.createRange( model.createPositionAt( firstBlock, 0 ) ); + } + + const positionAfter = model.createPositionAfter( firstBlock ); + + // If selection is at the end of the block - return position after the block. + if ( selection.focus.isTouching( positionAfter ) ) { + return model.createRange( positionAfter ); + } + + // Otherwise, return position before the block. + return model.createRange( model.createPositionBefore( firstBlock ) ); +} diff --git a/packages/ckeditor5-engine/src/model/utils/insertobject.js b/packages/ckeditor5-engine/src/model/utils/insertobject.js new file mode 100644 index 00000000000..dcc64b1cc57 --- /dev/null +++ b/packages/ckeditor5-engine/src/model/utils/insertobject.js @@ -0,0 +1,173 @@ +/** + * @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 engine/model/utils/insertobject + */ + +import first from '@ckeditor/ckeditor5-utils/src/first'; +import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; + +import { findOptimalInsertionRange } from './findoptimalinsertionrange'; + +/** + * Inserts an {@glink framework/guides/deep-dive/schema#object-elements object element} at a specific position in the editor content. + * + * **Note:** Use {@link module:engine/model/model~Model#insertObject} instead of this function. + * This function is only exposed to be reusable in algorithms which change the {@link module:engine/model/model~Model#insertObject} + * method's behavior. + * + * **Note**: For more documentation and examples, see {@link module:engine/model/model~Model#insertObject}. + * + * @param {module:engine/model/model~Model} model The model in context of which the insertion + * should be performed. + * @param {module:engine/model/element~Element} object An object to be inserted into the model document. + * @param {module:engine/model/selection~Selectable} [selectable=model.document.selection] + * A selectable where the content should be inserted. If not specified, the current + * {@link module:engine/model/document~Document#selection document selection} will be used instead. + * @param {Number|'before'|'end'|'after'|'on'|'in'} placeOrOffset Specifies the exact place or offset for the insertion to take place, + * relative to `selectable`. + * @param {Object} [options] Additional options. + * @param {'auto'|'before'|'after'} [options.findOptimalPosition] An option that, when set, adjusts the insertion position (relative to + * `selectable` and `placeOrOffset`) so that the content of `selectable` is not split upon insertion (a.k.a. non-destructive insertion). + * * When `'auto'`, the algorithm will decide whether to insert the object before or after `selectable` to avoid content splitting. + * * When `'before'`, the closest position before `selectable` will be used that will not result in content splitting. + * * When `'after'`, the closest position after `selectable` will be used that will not result in content splitting. + * + * Note that this option works only for block objects. Inline objects are inserted into text and do not split blocks. + * @param {'on'|'after'} [options.setSelection] An option that, when set, moves the + * {@link module:engine/model/document~Document#selection document selection} after inserting the object. + * * When `'on'`, the document selection will be set on the inserted object. + * * When `'after'`, the document selection will move to the closest text node after the inserted object. If there is no + * such text node, a paragraph will be created and the document selection will be moved inside it. + * @returns {module:engine/model/range~Range} A range which contains all the performed changes. This is a range that, if removed, + * would return the model to the state before the insertion. If no changes were preformed by `insertObject()`, returns a range collapsed + * at the insertion position. + */ +export default function insertObject( model, object, selectable, placeOrOffset, options = {} ) { + if ( !model.schema.isObject( object ) ) { + /** + * Tried to insert an element with {@link module:engine/model/utils/insertobject insertObject()} function + * that is not defined as an object in schema. + * See {@link module:engine/model/schema~SchemaItemDefinition#isObject `SchemaItemDefinition`}. + * If you want to insert content that is not an object you might want to use + * {@link module:engine/model/utils/insertcontent insertContent()} function. + * @error insertobject-element-not-an-object + */ + throw new CKEditorError( 'insertobject-element-not-an-object', model, { object } ); + } + + // Normalize selectable to a selection instance. + let originalSelection; + + if ( !selectable ) { + originalSelection = model.document.selection; + } else if ( selectable.is( 'selection' ) ) { + originalSelection = selectable; + } else { + originalSelection = model.createSelection( selectable, placeOrOffset ); + } + + // Adjust the insertion selection. + let insertionSelection = originalSelection; + + if ( options.findOptimalPosition && model.schema.isBlock( object ) ) { + insertionSelection = model.createSelection( findOptimalInsertionRange( originalSelection, model, options.findOptimalPosition ) ); + } + + // Collect attributes to be copied on the inserted object. + const firstSelectedBlock = first( originalSelection.getSelectedBlocks() ); + const attributesToCopy = {}; + + if ( firstSelectedBlock ) { + Object.assign( attributesToCopy, model.schema.getAttributesWithProperty( firstSelectedBlock, 'copyOnReplace', true ) ); + } + + return model.change( writer => { + // Remove the selected content to find out what the parent of the inserted object would be. + // It would be removed inside model.insertContent() anyway. + if ( !insertionSelection.isCollapsed ) { + model.deleteContent( insertionSelection, { doNotAutoparagraph: true } ); + } + + let elementToInsert = object; + const insertionPositionParent = insertionSelection.anchor.parent; + + // Autoparagraphing of an inline objects. + if ( + !model.schema.checkChild( insertionPositionParent, object ) && + model.schema.checkChild( insertionPositionParent, 'paragraph' ) && + model.schema.checkChild( 'paragraph', object ) + ) { + elementToInsert = writer.createElement( 'paragraph' ); + + writer.insert( object, elementToInsert ); + } + + // Apply attributes that are allowed on the inserted object (or paragraph if autoparagraphed). + model.schema.setAllowedAttributes( elementToInsert, attributesToCopy, writer ); + + // Insert the prepared content at the optionally adjusted selection. + const affectedRange = model.insertContent( elementToInsert, insertionSelection ); + + // Nothing got inserted. + if ( affectedRange.isCollapsed ) { + return affectedRange; + } + + if ( options.setSelection ) { + updateSelection( writer, object, options.setSelection, attributesToCopy ); + } + + return affectedRange; + } ); +} + +// Updates document selection based on given `place` parameter in relation to `contextElement` element. +// +// @private +// @param {module:engine/model/writer~Writer} writer An instance of the model writer. +// @param {module:engine/model/element~Element} contextElement An element to set attributes on. +// @param {'on'|'after'} place Place where selection should be set in relation to `contextElement` element. +// Value `on` will set selection on passed `contextElement`. Value `after` will set selection after `contextElement`. +// @param {Object} attributes Attributes keys and values to set on a paragraph that this function can create when +// `place` parameter is equal to `after` but there is no element with `$text` node to set selection in. +function updateSelection( writer, contextElement, place, paragraphAttributes ) { + const model = writer.model; + + if ( place == 'after' ) { + let nextElement = contextElement.nextSibling; + + // Check whether an element next to the inserted element is defined and can contain a text. + const canSetSelection = nextElement && model.schema.checkChild( nextElement, '$text' ); + + // If the element is missing, but a paragraph could be inserted next to the element, let's add it. + if ( !canSetSelection && model.schema.checkChild( contextElement.parent, 'paragraph' ) ) { + nextElement = writer.createElement( 'paragraph' ); + + model.schema.setAllowedAttributes( nextElement, paragraphAttributes, writer ); + model.insertContent( nextElement, writer.createPositionAfter( contextElement ) ); + } + + // Put the selection inside the element, at the beginning. + if ( nextElement ) { + writer.setSelection( nextElement, 0 ); + } + } + else if ( place == 'on' ) { + writer.setSelection( contextElement, 'on' ); + } + else { + /** + * Unsupported `options.setSelection` parameter was passed + * to the {@link module:engine/model/utils/insertobject insertObject()} function. + * Check {@link module:engine/model/utils/insertobject insertObject()} API documentation for allowed + * `options.setSelection` parameter values. + * + * @error insertobject-invalid-place-parameter-value + */ + throw new CKEditorError( 'insertobject-invalid-place-parameter-value', model ); + } +} diff --git a/packages/ckeditor5-engine/src/view/attributeelement.js b/packages/ckeditor5-engine/src/view/attributeelement.js index 00493c7ecbb..bcba7f0e0c2 100644 --- a/packages/ckeditor5-engine/src/view/attributeelement.js +++ b/packages/ckeditor5-engine/src/view/attributeelement.js @@ -24,16 +24,6 @@ const DEFAULT_PRIORITY = 10; * To create a new attribute element instance use the * {@link module:engine/view/downcastwriter~DowncastWriter#createAttributeElement `DowncastWriter#createAttributeElement()`} method. * - * **Note:** Attribute elements by default can wrap {@link module:engine/view/text~Text}, - * {@link module:engine/view/emptyelement~EmptyElement}, {@link module:engine/view/uielement~UIElement}, - * {@link module:engine/view/rawelement~RawElement} and other attribute elements with higher priority. Other elements while placed inside - * an attribute element will split it (or nest in case of an `AttributeElement`). This behavior can be modified by changing - * the `isAllowedInsideAttributeElement` option while creating - * {@link module:engine/view/downcastwriter~DowncastWriter#createContainerElement}, - * {@link module:engine/view/downcastwriter~DowncastWriter#createEmptyElement}, - * {@link module:engine/view/downcastwriter~DowncastWriter#createUIElement} or - * {@link module:engine/view/downcastwriter~DowncastWriter#createRawElement}. - * * @extends module:engine/view/element~Element */ export default class AttributeElement extends Element { diff --git a/packages/ckeditor5-engine/src/view/domconverter.js b/packages/ckeditor5-engine/src/view/domconverter.js index 410f8ff9e5c..10afabedb02 100644 --- a/packages/ckeditor5-engine/src/view/domconverter.js +++ b/packages/ckeditor5-engine/src/view/domconverter.js @@ -493,7 +493,22 @@ export default class DomConverter { yield this._getBlockFiller( domDocument ); } - yield this.viewToDom( childView, domDocument, options ); + const transparentRendering = childView.is( 'element' ) && childView.getCustomProperty( 'dataPipeline:transparentRendering' ); + + if ( transparentRendering && this.renderingMode == 'data' ) { + yield* this.viewChildrenToDom( childView, domDocument, options ); + } else { + if ( transparentRendering ) { + /** + * The `dataPipeline:transparentRendering` flag is supported only in the data pipeline. + * + * @error domconverter-transparent-rendering-unsupported-in-editing-pipeline + */ + logWarning( 'domconverter-transparent-rendering-unsupported-in-editing-pipeline', { viewElement: childView } ); + } + + yield this.viewToDom( childView, domDocument, options ); + } offset++; } diff --git a/packages/ckeditor5-engine/src/view/downcastwriter.js b/packages/ckeditor5-engine/src/view/downcastwriter.js index 7cf8039ed51..e1f6f138167 100644 --- a/packages/ckeditor5-engine/src/view/downcastwriter.js +++ b/packages/ckeditor5-engine/src/view/downcastwriter.js @@ -185,10 +185,6 @@ export default class DowncastWriter { * // Set `id` of a marker element so it is not joined or merged with "normal" elements. * writer.createAttributeElement( 'span', { class: 'my-marker' }, { id: 'marker:my' } ); * - * **Note:** By default an `AttributeElement` is split by a - * {@link module:engine/view/containerelement~ContainerElement `ContainerElement`} but this behavior can be modified - * with `isAllowedInsideAttributeElement` option set while {@link #createContainerElement creating the element}. - * * @param {String} name Name of the element. * @param {Object} [attributes] Element's attributes. * @param {Object} [options] Element's options. @@ -237,7 +233,7 @@ export default class DowncastWriter { * ] ); * * // Create element with specific options. - * writer.createContainerElement( 'span', { class: 'placeholder' }, { isAllowedInsideAttributeElement: true } ); + * writer.createContainerElement( 'span', { class: 'placeholder' }, { renderUnsafeAttributes: [ 'foo' ] } ); * * @param {String} name Name of the element. * @param {Object} [attributes] Elements attributes. @@ -245,9 +241,6 @@ export default class DowncastWriter { * A node or a list of nodes to be inserted into the created element. If no children were specified, element's `options` * can be passed in this argument. * @param {Object} [options] Element's options. - * @param {Boolean} [options.isAllowedInsideAttributeElement=false] Whether an element is - * {@link module:engine/view/element~Element#isAllowedInsideAttributeElement allowed inside an AttributeElement} and can be wrapped - * with {@link module:engine/view/attributeelement~AttributeElement} by {@link module:engine/view/downcastwriter~DowncastWriter}. * @param {Array.} [options.renderUnsafeAttributes] A list of attribute names that should be rendered in the editing * pipeline even though they would normally be filtered out by unsafe attribute detection mechanisms. * @returns {module:engine/view/containerelement~ContainerElement} Created element. @@ -263,10 +256,6 @@ export default class DowncastWriter { const containerElement = new ContainerElement( this.document, name, attributes, children ); - if ( options.isAllowedInsideAttributeElement !== undefined ) { - containerElement._isAllowedInsideAttributeElement = options.isAllowedInsideAttributeElement; - } - if ( options.renderUnsafeAttributes ) { containerElement._unsafeAttributesToRender.push( ...options.renderUnsafeAttributes ); } @@ -310,9 +299,6 @@ export default class DowncastWriter { * @param {String} name Name of the element. * @param {Object} [attributes] Elements attributes. * @param {Object} [options] Element's options. - * @param {Boolean} [options.isAllowedInsideAttributeElement=true] Whether an element is - * {@link module:engine/view/element~Element#isAllowedInsideAttributeElement allowed inside an AttributeElement} and can be wrapped - * with {@link module:engine/view/attributeelement~AttributeElement} by {@link module:engine/view/downcastwriter~DowncastWriter}. * @param {Array.} [options.renderUnsafeAttributes] A list of attribute names that should be rendered in the editing * pipeline even though they would normally be filtered out by unsafe attribute detection mechanisms. * @returns {module:engine/view/emptyelement~EmptyElement} Created element. @@ -320,10 +306,6 @@ export default class DowncastWriter { createEmptyElement( name, attributes, options = {} ) { const emptyElement = new EmptyElement( this.document, name, attributes ); - if ( options.isAllowedInsideAttributeElement !== undefined ) { - emptyElement._isAllowedInsideAttributeElement = options.isAllowedInsideAttributeElement; - } - if ( options.renderUnsafeAttributes ) { emptyElement._unsafeAttributesToRender.push( ...options.renderUnsafeAttributes ); } @@ -354,23 +336,15 @@ export default class DowncastWriter { * @param {String} name The name of the element. * @param {Object} [attributes] Element attributes. * @param {Function} [renderFunction] A custom render function. - * @param {Object} [options] Element's options. - * @param {Boolean} [options.isAllowedInsideAttributeElement=true] Whether an element is - * {@link module:engine/view/element~Element#isAllowedInsideAttributeElement allowed inside an AttributeElement} and can be wrapped - * with {@link module:engine/view/attributeelement~AttributeElement} by {@link module:engine/view/downcastwriter~DowncastWriter}. * @returns {module:engine/view/uielement~UIElement} The created element. */ - createUIElement( name, attributes, renderFunction, options = {} ) { + createUIElement( name, attributes, renderFunction ) { const uiElement = new UIElement( this.document, name, attributes ); if ( renderFunction ) { uiElement.render = renderFunction; } - if ( options.isAllowedInsideAttributeElement !== undefined ) { - uiElement._isAllowedInsideAttributeElement = options.isAllowedInsideAttributeElement; - } - return uiElement; } @@ -397,9 +371,6 @@ export default class DowncastWriter { * @param {Object} [attributes] Element attributes. * @param {Function} [renderFunction] A custom render function. * @param {Object} [options] Element's options. - * @param {Boolean} [options.isAllowedInsideAttributeElement=true] Whether an element is - * {@link module:engine/view/element~Element#isAllowedInsideAttributeElement allowed inside an AttributeElement} and can be wrapped - * with {@link module:engine/view/attributeelement~AttributeElement} by {@link module:engine/view/downcastwriter~DowncastWriter}. * @param {Array.} [options.renderUnsafeAttributes] A list of attribute names that should be rendered in the editing * pipeline even though they would normally be filtered out by unsafe attribute detection mechanisms. * @returns {module:engine/view/rawelement~RawElement} The created element. @@ -409,10 +380,6 @@ export default class DowncastWriter { rawElement.render = renderFunction || ( () => {} ); - if ( options.isAllowedInsideAttributeElement !== undefined ) { - rawElement._isAllowedInsideAttributeElement = options.isAllowedInsideAttributeElement; - } - if ( options.renderUnsafeAttributes ) { rawElement._unsafeAttributesToRender.push( ...options.renderUnsafeAttributes ); } @@ -790,7 +757,7 @@ export default class DowncastWriter { // Break attributes on nodes that do exist in the model tree so they can have attributes, other elements // can't have an attribute in model and won't get wrapped with an AttributeElement while down-casted. - const breakAttributes = !( node.is( 'uiElement' ) && node.isAllowedInsideAttributeElement ); + const breakAttributes = !node.is( 'uiElement' ); if ( !lastGroup || lastGroup.breakAttributes != breakAttributes ) { groups.push( { @@ -979,16 +946,6 @@ export default class DowncastWriter { * Throws {@link module:utils/ckeditorerror~CKEditorError} `view-writer-wrap-nonselection-collapsed-range` when passed range * is collapsed and different than view selection. * - * **Note:** Attribute elements by default can wrap {@link module:engine/view/text~Text}, - * {@link module:engine/view/emptyelement~EmptyElement}, {@link module:engine/view/uielement~UIElement}, - * {@link module:engine/view/rawelement~RawElement} and other attribute elements with higher priority. Other elements while placed - * inside an attribute element will split it (or nest it in case of an `AttributeElement`). This behavior can be modified by changing - * the `isAllowedInsideAttributeElement` option while using - * {@link module:engine/view/downcastwriter~DowncastWriter#createContainerElement}, - * {@link module:engine/view/downcastwriter~DowncastWriter#createEmptyElement}, - * {@link module:engine/view/downcastwriter~DowncastWriter#createUIElement} or - * {@link module:engine/view/downcastwriter~DowncastWriter#createRawElement}. - * * @param {module:engine/view/range~Range} range Range to wrap. * @param {module:engine/view/attributeelement~AttributeElement} attribute Attribute element to use as wrapper. * @returns {module:engine/view/range~Range} range Range after wrapping, spanning over wrapping attribute element. @@ -1399,7 +1356,6 @@ export default class DowncastWriter { const child = parent.getChild( i ); const isText = child.is( '$text' ); const isAttribute = child.is( 'attributeElement' ); - const isAllowedInsideAttributeElement = child.isAllowedInsideAttributeElement; // // (In all examples, assume that `wrapElement` is `` element.) @@ -1418,7 +1374,7 @@ export default class DowncastWriter { // //

abc

-->

abc

//

abc

-->

abc

- else if ( isText || isAllowedInsideAttributeElement || ( isAttribute && shouldABeOutsideB( wrapElement, child ) ) ) { + else if ( isText || !isAttribute || shouldABeOutsideB( wrapElement, child ) ) { // Clone attribute. const newAttribute = wrapElement._clone(); @@ -1436,7 +1392,7 @@ export default class DowncastWriter { // //

abc

-->

abc

// - else if ( isAttribute ) { + else /* if ( isAttribute ) */ { this._wrapChildren( child, 0, child.childCount, wrapElement ); } diff --git a/packages/ckeditor5-engine/src/view/element.js b/packages/ckeditor5-engine/src/view/element.js index 5189f9c5d38..0a0e36ec0d2 100644 --- a/packages/ckeditor5-engine/src/view/element.js +++ b/packages/ckeditor5-engine/src/view/element.js @@ -130,15 +130,6 @@ export default class Element extends Node { */ this._customProperties = new Map(); - /** - * Whether an element is allowed inside an AttributeElement and can be wrapped with - * {@link module:engine/view/attributeelement~AttributeElement} by {@link module:engine/view/downcastwriter~DowncastWriter}. - * - * @protected - * @member {Boolean} - */ - this._isAllowedInsideAttributeElement = false; - /** * A list of attribute names that should be rendered in the editing pipeline even though filtering mechanisms * implemented in the {@link module:engine/view/domconverter~DomConverter} (for instance, @@ -175,17 +166,6 @@ export default class Element extends Node { return this._children.length === 0; } - /** - * Whether the element is allowed inside an AttributeElement and can be wrapped with - * {@link module:engine/view/attributeelement~AttributeElement} by {@link module:engine/view/downcastwriter~DowncastWriter}. - * - * @readonly - * @type {Boolean} - */ - get isAllowedInsideAttributeElement() { - return this._isAllowedInsideAttributeElement; - } - /** * Checks whether this object is of the given. * @@ -350,11 +330,6 @@ export default class Element extends Node { return false; } - // Check isAllowedInsideAttributeElement property. - if ( this.isAllowedInsideAttributeElement != otherElement.isAllowedInsideAttributeElement ) { - return false; - } - // Check number of attributes, classes and styles. if ( this._attrs.size !== otherElement._attrs.size || this._classes.size !== otherElement._classes.size || this._styles.size !== otherElement._styles.size ) { @@ -633,8 +608,6 @@ export default class Element extends Node { // is changed by e.g. toWidget() function from ckeditor5-widget. Perhaps this should be one of custom props. cloned.getFillerOffset = this.getFillerOffset; - cloned._isAllowedInsideAttributeElement = this.isAllowedInsideAttributeElement; - return cloned; } diff --git a/packages/ckeditor5-engine/src/view/emptyelement.js b/packages/ckeditor5-engine/src/view/emptyelement.js index 4ef33f99edc..02e902f3457 100644 --- a/packages/ckeditor5-engine/src/view/emptyelement.js +++ b/packages/ckeditor5-engine/src/view/emptyelement.js @@ -37,9 +37,6 @@ export default class EmptyElement extends Element { constructor( document, name, attrs, children ) { super( document, name, attrs, children ); - // Override the default of the base class. - this._isAllowedInsideAttributeElement = true; - /** * Returns `null` because filler is not needed for EmptyElements. * diff --git a/packages/ckeditor5-engine/src/view/observer/tabobserver.js b/packages/ckeditor5-engine/src/view/observer/tabobserver.js new file mode 100644 index 00000000000..231f810fd48 --- /dev/null +++ b/packages/ckeditor5-engine/src/view/observer/tabobserver.js @@ -0,0 +1,68 @@ +/** + * @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 engine/view/observer/tabobserver + */ + +import Observer from './observer'; +import BubblingEventInfo from './bubblingeventinfo'; + +import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; + +/** + * Tab observer introduces the {@link module:engine/view/document~Document#event:tab `Document#tab`} event. + * + * Note that because {@link module:engine/view/observer/tabobserver~TabObserver} is attached by the + * {@link module:engine/view/view~View} this event is available by default. + * + * @extends module:engine/view/observer/observer~Observer + */ +export default class TabObserver extends Observer { + /** + * @inheritDoc + */ + constructor( view ) { + super( view ); + + const doc = this.document; + + doc.on( 'keydown', ( evt, data ) => { + if ( + !this.isEnabled || + data.keyCode != keyCodes.tab || + data.ctrlKey + ) { + return; + } + + const event = new BubblingEventInfo( doc, 'tab', doc.selection.getFirstRange() ); + + doc.fire( event, data ); + + if ( event.stop.called ) { + evt.stop(); + } + } ); + } + + /** + * @inheritDoc + */ + observe() {} +} + +/** + * Event fired when the user presses a tab key. + * + * Introduced by {@link module:engine/view/observer/tabobserver~TabObserver}. + * + * Note that because {@link module:engine/view/observer/tabobserver~TabObserver} is attached by the + * {@link module:engine/view/view~View} this event is available by default. + * + * @event module:engine/view/document~Document#event:tab + * + * @param {module:engine/view/observer/domeventdata~DomEventData} data + */ diff --git a/packages/ckeditor5-engine/src/view/placeholder.js b/packages/ckeditor5-engine/src/view/placeholder.js index f2503eefc4f..0b80afc0da2 100644 --- a/packages/ckeditor5-engine/src/view/placeholder.js +++ b/packages/ckeditor5-engine/src/view/placeholder.js @@ -276,7 +276,7 @@ function getChildPlaceholderHostSubstitute( parent ) { if ( parent.childCount ) { const firstChild = parent.getChild( 0 ); - if ( firstChild.is( 'element' ) && !firstChild.is( 'uiElement' ) ) { + if ( firstChild.is( 'element' ) && !firstChild.is( 'uiElement' ) && !firstChild.is( 'attributeElement' ) ) { return firstChild; } } diff --git a/packages/ckeditor5-engine/src/view/rawelement.js b/packages/ckeditor5-engine/src/view/rawelement.js index b1848bb9d4c..25734862246 100644 --- a/packages/ckeditor5-engine/src/view/rawelement.js +++ b/packages/ckeditor5-engine/src/view/rawelement.js @@ -47,9 +47,6 @@ export default class RawElement extends Element { constructor( document, name, attrs, children ) { super( document, name, attrs, children ); - // Override the default of the base class. - this._isAllowedInsideAttributeElement = true; - /** * Returns `null` because filler is not needed for raw elements. * diff --git a/packages/ckeditor5-engine/src/view/uielement.js b/packages/ckeditor5-engine/src/view/uielement.js index 8030c4e5d03..b92c7d706da 100644 --- a/packages/ckeditor5-engine/src/view/uielement.js +++ b/packages/ckeditor5-engine/src/view/uielement.js @@ -50,9 +50,6 @@ export default class UIElement extends Element { constructor( document, name, attributes, children ) { super( document, name, attributes, children ); - // Override the default of the base class. - this._isAllowedInsideAttributeElement = true; - /** * Returns `null` because filler is not needed for UIElements. * diff --git a/packages/ckeditor5-engine/src/view/view.js b/packages/ckeditor5-engine/src/view/view.js index 98df272f09e..aee1de3a66a 100644 --- a/packages/ckeditor5-engine/src/view/view.js +++ b/packages/ckeditor5-engine/src/view/view.js @@ -23,6 +23,7 @@ import FocusObserver from './observer/focusobserver'; import CompositionObserver from './observer/compositionobserver'; import InputObserver from './observer/inputobserver'; import ArrowKeysObserver from './observer/arrowkeysobserver'; +import TabObserver from './observer/tabobserver'; import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin'; import mix from '@ckeditor/ckeditor5-utils/src/mix'; @@ -54,6 +55,8 @@ import env from '@ckeditor/ckeditor5-utils/src/env'; * * {@link module:engine/view/observer/keyobserver~KeyObserver}, * * {@link module:engine/view/observer/fakeselectionobserver~FakeSelectionObserver}. * * {@link module:engine/view/observer/compositionobserver~CompositionObserver}. + * * {@link module:engine/view/observer/arrowkeysobserver~ArrowKeysObserver}. + * * {@link module:engine/view/observer/tabobserver~TabObserver}. * * This class also {@link module:engine/view/view~View#attachDomRoot binds the DOM and the view elements}. * @@ -186,6 +189,7 @@ export default class View { this.addObserver( FakeSelectionObserver ); this.addObserver( CompositionObserver ); this.addObserver( ArrowKeysObserver ); + this.addObserver( TabObserver ); if ( env.isAndroid ) { this.addObserver( InputObserver ); diff --git a/packages/ckeditor5-engine/tests/conversion/downcasthelpers.js b/packages/ckeditor5-engine/tests/conversion/downcasthelpers.js index a0766219136..0db472f4268 100644 --- a/packages/ckeditor5-engine/tests/conversion/downcasthelpers.js +++ b/packages/ckeditor5-engine/tests/conversion/downcasthelpers.js @@ -137,6 +137,24 @@ describe( 'DowncastHelpers', () => { expectResult( '' ); } ); + it( 'config.view is a function that receives event data as a third argument', () => { + downcastHelpers.elementToElement( { + model: 'heading', + view: ( modelElement, { writer }, data ) => { + expect( data.item.is( 'element', 'heading' ) ).to.be.true; + expect( data.range.is( 'range' ) ).to.be.true; + + return writer.createContainerElement( 'h' + modelElement.getAttribute( 'level' ) ); + } + } ); + + model.change( writer => { + writer.insertElement( 'heading', { level: 2 }, modelRoot, 0 ); + } ); + + expectResult( '

' ); + } ); + describe( 'converting element', () => { beforeEach( () => { model.schema.register( 'simpleBlock', { @@ -929,6 +947,24 @@ describe( 'DowncastHelpers', () => { expectResult( '' ); } ); + it( 'config.view is a function that receives event data as a third argument', () => { + downcastHelpers.elementToStructure( { + model: 'heading', + view: ( modelElement, { writer }, data ) => { + expect( data.item.is( 'element', 'heading' ) ).to.be.true; + expect( data.range.is( 'range' ) ).to.be.true; + + return writer.createContainerElement( 'h' + modelElement.getAttribute( 'level' ) ); + } + } ); + + model.change( writer => { + writer.insertElement( 'heading', { level: 2 }, modelRoot, 0 ); + } ); + + expectResult( '

' ); + } ); + describe( 'converting element together with selected attributes', () => { it( 'should allow passing a list of attributes to convert and consume', () => { model.schema.register( 'simpleBlock', { @@ -2530,6 +2566,36 @@ describe( 'DowncastHelpers', () => { expect( viewToString( viewRoot ) ).to.equal( '

foobar

' ); } ); + it( 'should allow element creator to return null for unsupported elements', () => { + const modelElement = new ModelElement( 'paragraph', { style: 'bold' }, new ModelText( 'foobar', { style: 'bold' } ) ); + + downcastHelpers.attributeToElement( { + model: 'style', + view: ( modelAttributeValue, { writer }, data ) => { + expect( data.item.is( 'element' ) || data.item.is( '$textProxy' ), 'item or text proxy' ).to.be.true; + expect( data.range.is( 'range' ), 'range' ).to.be.true; + expect( data.attributeKey, 'key' ).to.equal( 'style' ); + + if ( data.item.is( '$textProxy' ) && modelAttributeValue == 'bold' ) { + return writer.createAttributeElement( 'b' ); + } + } + } ); + + model.change( writer => { + writer.insert( modelElement, modelRootStart ); + } ); + + expect( viewToString( viewRoot ), 'after insert' ).to.equal( '

foobar

' ); + + model.change( writer => { + writer.removeAttribute( 'style', writer.createRangeOn( modelElement ) ); + writer.removeAttribute( 'style', writer.createRangeIn( modelElement ) ); + } ); + + expect( viewToString( viewRoot ), 'after remove attribute' ).to.equal( '

foobar

' ); + } ); + it( 'should update range on re-wrapping attribute (#475)', () => { const modelElement = new ModelElement( 'paragraph', null, [ new ModelText( 'x' ), @@ -2826,6 +2892,28 @@ describe( 'DowncastHelpers', () => { expectResult( '' ); } ); + it( 'config.view is a function that receives event data as a third argument', () => { + downcastHelpers.attributeToAttribute( { + model: 'styled', + view: ( attributeValue, conversionApi, data ) => { + // To ensure conversion API is provided. + expect( conversionApi.writer ).to.instanceof( DowncastWriter ); + + expect( data.item.is( 'element', 'imageBlock' ) ).to.be.true; + expect( data.range.is( 'range' ) ).to.be.true; + expect( data.attributeKey ).to.equal( 'styled' ); + + return { key: 'class', value: 'styled-' + attributeValue }; + } + } ); + + model.change( writer => { + writer.insertElement( 'imageBlock', { styled: 'pull-out' }, modelRoot, 0 ); + } ); + + expectResult( '' ); + } ); + // #1587 it( 'config.view and config.model as strings in generic conversion (elements only)', () => { const consoleWarnStub = testUtils.sinon.stub( console, 'warn' ); diff --git a/packages/ckeditor5-engine/tests/conversion/mapper.js b/packages/ckeditor5-engine/tests/conversion/mapper.js index ea54c0d2684..1d8c3085b43 100644 --- a/packages/ckeditor5-engine/tests/conversion/mapper.js +++ b/packages/ckeditor5-engine/tests/conversion/mapper.js @@ -445,7 +445,7 @@ describe( 'Mapper', () => { expect( () => { mapper.toViewPosition( modelPosition ); - } ).to.throw( CKEditorError, 'mapping-view-position-parent-not-found' ); + } ).to.throw( CKEditorError, 'mapping-model-position-view-parent-not-found' ); } ); // Default algorithm tests. diff --git a/packages/ckeditor5-engine/tests/conversion/upcastdispatcher.js b/packages/ckeditor5-engine/tests/conversion/upcastdispatcher.js index 87adf1c396d..b73369fdc49 100644 --- a/packages/ckeditor5-engine/tests/conversion/upcastdispatcher.js +++ b/packages/ckeditor5-engine/tests/conversion/upcastdispatcher.js @@ -291,6 +291,36 @@ describe( 'UpcastDispatcher', () => { expect( result.getChild( 0 ).name ).to.equal( 'imageBlock' ); } ); + it( 'should not remove empty element that was created as a result of split (if marked as to keep)', () => { + const viewElement = new ViewElement( viewDocument, 'li', { id: 'foo' }, [ + new ViewElement( viewDocument, 'li', { id: 'bar' } ) + ] ); + + model.schema.register( 'li', { allowIn: '$root', allowAttributes: 'id' } ); + + dispatcher.on( 'element:li', ( evt, data, conversionApi ) => { + const writer = conversionApi.writer; + + const modelElement = writer.createElement( 'li', { id: data.viewItem.getAttribute( 'id' ) } ); + + if ( !conversionApi.safeInsert( modelElement, data.modelCursor ) ) { + return; + } + + conversionApi.convertChildren( data.viewItem, modelElement ); + conversionApi.updateConversionResult( modelElement, data ); + conversionApi.keepEmptyElement( modelElement ); + } ); + + const result = model.change( writer => dispatcher.convert( viewElement, writer ) ); + + expect( result.childCount ).to.equal( 2 ); + expect( result.getChild( 0 ).name ).to.equal( 'li' ); + expect( result.getChild( 0 ).getAttribute( 'id' ) ).to.equal( 'foo' ); + expect( result.getChild( 1 ).name ).to.equal( 'li' ); + expect( result.getChild( 1 ).getAttribute( 'id' ) ).to.equal( 'bar' ); + } ); + it( 'should extract temporary markers elements from converter element and create static markers list', () => { const viewFragment = new ViewDocumentFragment( viewDocument ); diff --git a/packages/ckeditor5-engine/tests/model/differ.js b/packages/ckeditor5-engine/tests/model/differ.js index b0a5b39de2a..fbc341b154b 100644 --- a/packages/ckeditor5-engine/tests/model/differ.js +++ b/packages/ckeditor5-engine/tests/model/differ.js @@ -60,7 +60,7 @@ describe( 'Differ', () => { ); expectChanges( [ - { type: 'insert', name: 'imageBlock', length: 1, position } + { type: 'insert', name: 'imageBlock', length: 1, position, attributes: new Map( [ [ 'src', 'foo.jpg' ] ] ) } ] ); } ); } ); @@ -180,7 +180,7 @@ describe( 'Differ', () => { // so there is also a diff for text. expectChanges( [ { type: 'attribute', range: diffRange, attributeKey: 'align', attributeOldValue: null, attributeNewValue: 'center' }, - { type: 'insert', name: '$text', length: 3, position } + { type: 'insert', name: '$text', length: 3, position, attributes: new Map( [ [ 'bold', true ] ] ) } ] ); } ); } ); @@ -295,6 +295,23 @@ describe( 'Differ', () => { } ); } ); + it( 'element with attributes', () => { + const position = new Position( root, [ 0 ] ); + const range = new Range( Position._createAt( root, 0 ), Position._createAt( root, 1 ) ); + + model.change( () => { + attribute( range, 'align', null, 'center' ); + } ); + + model.change( () => { + remove( position, 1 ); + + expectChanges( [ + { type: 'remove', name: 'paragraph', length: 1, position, attributes: new Map( [ [ 'align', 'center' ] ] ) } + ] ); + } ); + } ); + it( 'a character', () => { const position = new Position( root, [ 0, 1 ] ); @@ -319,6 +336,23 @@ describe( 'Differ', () => { } ); } ); + it( 'characters with attributes', () => { + const position = new Position( root, [ 0, 0 ] ); + const range = new Range( Position._createAt( root.getChild( 0 ), 0 ), Position._createAt( root.getChild( 0 ), 2 ) ); + + model.change( () => { + attribute( range, 'bold', null, true ); + } ); + + model.change( () => { + remove( position, 2 ); + + expectChanges( [ + { type: 'remove', name: '$text', length: 2, position, attributes: new Map( [ [ 'bold', true ] ] ) } + ] ); + } ); + } ); + it( 'multiple consecutive characters in multiple operations', () => { const position = new Position( root, [ 0, 0 ] ); @@ -2109,6 +2143,8 @@ describe( 'Differ', () => { if ( Object.prototype.hasOwnProperty.call( expected[ i ], key ) ) { if ( key == 'position' || key == 'range' ) { expect( changes[ i ][ key ].isEqual( expected[ i ][ key ] ), `item ${ i }, key "${ key }"` ).to.be.true; + } else if ( key == 'attributes' ) { + expect( changes[ i ][ key ], `item ${ i }, key "${ key }"` ).to.deep.equal( expected[ i ][ key ] ); } else { expect( changes[ i ][ key ], `item ${ i }, key "${ key }"` ).to.equal( expected[ i ][ key ] ); } diff --git a/packages/ckeditor5-engine/tests/model/model.js b/packages/ckeditor5-engine/tests/model/model.js index 0b9c66bf5b1..8e6ef4fb32e 100644 --- a/packages/ckeditor5-engine/tests/model/model.js +++ b/packages/ckeditor5-engine/tests/model/model.js @@ -35,16 +35,50 @@ describe( 'Model', () => { expect( schema.isLimit( '$root' ) ).to.be.true; } ); + it( 'registers $container to the schema', () => { + expect( schema.isRegistered( '$container' ) ).to.be.true; + expect( schema.checkChild( [ '$root' ], '$container' ) ).to.be.true; + expect( schema.checkChild( [ '$container' ], '$container' ) ).to.be.true; + expect( schema.checkChild( [ '$container' ], '$block' ) ).to.be.true; + } ); + it( 'registers $block to the schema', () => { expect( schema.isRegistered( '$block' ) ).to.be.true; expect( schema.isBlock( '$block' ) ).to.be.true; expect( schema.checkChild( [ '$root' ], '$block' ) ).to.be.true; + expect( schema.checkChild( [ '$container' ], '$block' ) ).to.be.true; + } ); + + it( 'registers $blockObject to the schema', () => { + expect( schema.isRegistered( '$blockObject' ) ).to.be.true; + expect( schema.isBlock( '$blockObject' ) ).to.be.true; + expect( schema.isObject( '$blockObject' ) ).to.be.true; + expect( schema.checkChild( [ '$root' ], '$blockObject' ) ).to.be.true; + expect( schema.checkChild( [ '$container' ], '$blockObject' ) ).to.be.true; + expect( schema.checkChild( [ '$block' ], '$blockObject' ) ).to.be.false; + } ); + + it( 'registers $inlineObject to the schema', () => { + expect( schema.isRegistered( '$inlineObject' ) ).to.be.true; + expect( schema.isInline( '$inlineObject' ) ).to.be.true; + expect( schema.isObject( '$inlineObject' ) ).to.be.true; + expect( schema.checkChild( [ '$root' ], '$inlineObject' ) ).to.be.false; + expect( schema.checkChild( [ '$container' ], '$inlineObject' ) ).to.be.false; + expect( schema.checkChild( [ '$block' ], '$inlineObject' ) ).to.be.true; + + schema.extend( '$text', { + allowAttributes: [ 'foo', 'bar' ] + } ); + + expect( schema.checkAttribute( '$inlineObject', 'foo' ) ).to.be.true; + expect( schema.checkAttribute( '$inlineObject', 'bar' ) ).to.be.true; } ); it( 'registers $text to the schema', () => { expect( schema.isRegistered( '$text' ) ).to.be.true; expect( schema.isContent( '$text' ) ).to.be.true; expect( schema.checkChild( [ '$block' ], '$text' ) ).to.be.true; + expect( schema.checkChild( [ '$container' ], '$text' ) ).to.be.false; } ); it( 'registers $clipboardHolder to the schema', () => { diff --git a/packages/ckeditor5-engine/tests/model/schema.js b/packages/ckeditor5-engine/tests/model/schema.js index fb7bb7e99aa..db69c0e0d6d 100644 --- a/packages/ckeditor5-engine/tests/model/schema.js +++ b/packages/ckeditor5-engine/tests/model/schema.js @@ -2055,6 +2055,157 @@ describe( 'Schema', () => { } ); } ); + describe( 'getAttributesWithProperty()', () => { + let model, doc, root; + + beforeEach( () => { + model = new Model(); + doc = model.document; + root = doc.createRoot(); + schema = model.schema; + + schema.register( 'paragraph', { + inheritAllFrom: '$block' + } ); + } ); + + it( 'should get an attribute with given property', () => { + schema.extend( '$text', { allowAttributes: 'a' } ); + + schema.setAttributeProperties( 'a', { + isFooable: true + } ); + + const text = new Text( 'foo', { a: 1 } ); + + root._appendChild( text ); + + const attributesWithProperty = schema.getAttributesWithProperty( root.getChild( 0 ), 'isFooable' ); + + expect( attributesWithProperty ).to.deep.equal( { a: 1 } ); + } ); + + it( 'should get attributes with given property', () => { + schema.extend( '$text', { allowAttributes: [ 'a', 'b' ] } ); + + schema.setAttributeProperties( 'a', { + isFooable: true + } ); + + schema.setAttributeProperties( 'b', { + isFooable: true + } ); + + const text = new Text( 'foo', { a: 1, b: 2 } ); + + root._appendChild( text ); + + const attributesWithProperty = schema.getAttributesWithProperty( root.getChild( 0 ), 'isFooable' ); + + expect( attributesWithProperty ).to.deep.equal( { a: 1, b: 2 } ); + } ); + + it( 'should get an attribute with given property that matches desired value', () => { + schema.extend( '$text', { allowAttributes: [ 'a' ] } ); + + schema.setAttributeProperties( 'a', { + isFooable: 'yes' + } ); + + const text = new Text( 'foo', { a: 1 } ); + + root._appendChild( text ); + + const attributesWithProperty = schema.getAttributesWithProperty( root.getChild( 0 ), 'isFooable', 'yes' ); + + expect( attributesWithProperty ).to.deep.equal( { a: 1 } ); + } ); + + it( 'should get attributes with given property that match desired value', () => { + schema.extend( '$text', { allowAttributes: [ 'a', 'b' ] } ); + + schema.setAttributeProperties( 'a', { + isFooable: 'yes' + } ); + + schema.setAttributeProperties( 'b', { + isFooable: 'yes' + } ); + + const text = new Text( 'foo', { a: 1, b: 2 } ); + + root._appendChild( text ); + + const attributesWithProperty = schema.getAttributesWithProperty( root.getChild( 0 ), 'isFooable', 'yes' ); + + expect( attributesWithProperty ).to.deep.equal( { a: 1, b: 2 } ); + } ); + + it( 'should not return an attribute if it has properties but not the one being lookied for', () => { + schema.extend( '$text', { allowAttributes: [ 'a' ] } ); + + schema.setAttributeProperties( 'a', { + isFooable: true + } ); + + const text = new Text( 'foo', { a: 1 } ); + + root._appendChild( text ); + + const attributesWithProperty = schema.getAttributesWithProperty( root.getChild( 0 ), 'isBarable' ); + + expect( attributesWithProperty ).to.deep.equal( { } ); + } ); + + it( 'should not return an attribute if it does not have given property', () => { + schema.extend( '$text', { allowAttributes: [ 'a' ] } ); + + const text = new Text( 'foo', { a: 1 } ); + + root._appendChild( text ); + + const attributesWithProperty = schema.getAttributesWithProperty( root.getChild( 0 ), 'isFooable' ); + + expect( attributesWithProperty ).to.deep.equal( { } ); + } ); + + it( 'should not return an attribute if value does not match', () => { + schema.extend( '$text', { allowAttributes: [ 'a' ] } ); + + schema.setAttributeProperties( 'a', { + isFooable: 'no' + } ); + + const text = new Text( 'foo', { a: 1 } ); + + root._appendChild( text ); + + const attributesWithProperty = schema.getAttributesWithProperty( root.getChild( 0 ), 'isFooable', 'yes' ); + + expect( attributesWithProperty ).to.deep.equal( { } ); + } ); + + it( 'should return only an attribute that matches value', () => { + schema.extend( '$text', { allowAttributes: [ 'a', 'b' ] } ); + + schema.setAttributeProperties( 'a', { + isFooable: 'no' + } ); + + schema.setAttributeProperties( 'b', { + isFooable: 'yes' + } ); + + const text = new Text( 'foo', { a: 1, b: 2 } ); + + root._appendChild( text ); + + const attributesWithProperty = schema.getAttributesWithProperty( root.getChild( 0 ), 'isFooable', 'yes' ); + + expect( attributesWithProperty ).to.deep.equal( { b: 2 } ); + } ); + } ); + describe( 'definitions compilation', () => { describe( 'allowIn cases', () => { it( 'passes $root>paragraph', () => { diff --git a/packages/ckeditor5-engine/tests/model/utils/deletecontent.js b/packages/ckeditor5-engine/tests/model/utils/deletecontent.js index 79d77b69325..c6d8c7db3ac 100644 --- a/packages/ckeditor5-engine/tests/model/utils/deletecontent.js +++ b/packages/ckeditor5-engine/tests/model/utils/deletecontent.js @@ -975,6 +975,102 @@ describe( 'DataController utils', () => { // Note that auto-paragraphing post-fixer injected a paragraph into the empty root. expect( getData( model, { rootName: 'bodyRoot' } ) ).to.equal( '[]' ); } ); + + it( 'creates a paragraph that inherits a deleted block widget attribute with copyOnReplace property', () => { + model.schema.extend( 'paragraph', { + allowAttributes: 'foo' + } ); + + model.schema.extend( '$blockObject', { + allowAttributes: 'foo' + } ); + + model.schema.setAttributeProperties( 'foo', { + copyOnReplace: true + } ); + + setData( + model, + '[]', + { rootName: 'bodyRoot' } + ); + + deleteContent( model, doc.selection ); + + expect( getData( model, { rootName: 'bodyRoot' } ) ) + .to.equal( '[]' ); + } ); + + it( 'creates a paragraph that inherits a deleted block widget attributes with copyOnReplace property', () => { + model.schema.extend( 'paragraph', { + allowAttributes: [ 'foo', 'bar' ] + } ); + + model.schema.extend( '$blockObject', { + allowAttributes: [ 'foo', 'bar' ] + } ); + + model.schema.setAttributeProperties( 'foo', { + copyOnReplace: true + } ); + + model.schema.setAttributeProperties( 'bar', { + copyOnReplace: true + } ); + + setData( + model, + '[]', + { rootName: 'bodyRoot' } + ); + + deleteContent( model, doc.selection ); + + expect( getData( model, { rootName: 'bodyRoot' } ) ) + .to.equal( '[]' ); + } ); + + it( 'creates a paragraph that does not inherit a deleted block widget attribute without copyOnReplace property', () => { + model.schema.extend( 'paragraph', { + allowAttributes: 'foo' + } ); + + model.schema.extend( '$blockObject', { + allowAttributes: 'foo' + } ); + + setData( + model, + '[]', + { rootName: 'bodyRoot' } + ); + + deleteContent( model, doc.selection ); + + expect( getData( model, { rootName: 'bodyRoot' } ) ) + .to.equal( '[]' ); + } ); + + it( 'creates a paragraph that does not inherit a deleted block widget attribute if it is not allowed on paragraph', () => { + model.schema.extend( '$blockObject', { + allowAttributes: 'foo' + } ); + + model.schema.setAttributeProperties( 'foo', { + copyOnReplace: true + } ); + + setData( + model, + '[]', + { rootName: 'bodyRoot' } + ); + + deleteContent( model, doc.selection ); + + expect( getData( model, { rootName: 'bodyRoot' } ) ) + .to.equal( '[]' ); + } ); } ); describe( 'integration with inline limit elements', () => { diff --git a/packages/ckeditor5-engine/tests/model/utils/insertobject.js b/packages/ckeditor5-engine/tests/model/utils/insertobject.js new file mode 100644 index 00000000000..51f3f1eae1c --- /dev/null +++ b/packages/ckeditor5-engine/tests/model/utils/insertobject.js @@ -0,0 +1,784 @@ +/** + * @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 Model from '../../../src/model/model'; +import insertObject from '../../../src/model/utils/insertobject'; +import { findOptimalInsertionRange } from '../../../src/model/utils/findoptimalinsertionrange'; +import Element from '../../../src/model/element'; +import Text from '../../../src/model/text'; +import { setData, getData } from '../../../src/dev-utils/model'; + +import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import { expectToThrowCKEditorError } from '@ckeditor/ckeditor5-utils/tests/_utils/utils'; + +/* global console */ + +describe( 'insertObject()', () => { + let model, doc, root, schema; + let insertContentSpy; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + model = new Model(); + doc = model.document; + root = doc.createRoot(); + schema = model.schema; + + insertContentSpy = sinon.spy( model, 'insertContent' ); + + schema.register( 'blockWidget', { + isObject: true, + inheritAllFrom: '$block', + allowIn: '$root' + } ); + + schema.register( 'inlineWidget', { + isObject: true, + allowIn: [ '$block' ] + } ); + + schema.register( 'paragraph', { + inheritAllFrom: '$block' + } ); + + model.schema.register( 'span', { allowIn: 'paragraph' } ); + + model.schema.extend( '$text', { allowIn: 'span' } ); + } ); + + describe( 'handled element types', () => { + it( 'should not throw an incorrect type of element to insert error if element is an object', () => { + const widget = new Element( 'blockWidget', [], [] ); + + expect( () => { + insertObject( model, widget ); + } ).to.not.throw( CKEditorError, /insertobject-element-not-an-object/ ); + } ); + + it( 'should throw an error if element is not an object', () => { + const paragraph = new Element( 'paragraph', [], [ new Text( 'bar' ) ] ); + + expectToThrowCKEditorError( () => insertObject( model, paragraph ), 'insertobject-element-not-an-object' ); + } ); + } ); + + describe( 'insertion selection', () => { + describe( 'with findOptimalPosition', () => { + it( 'should call insert content with selection from optimal insertion range when no selection was passed (before)', () => { + const widget = new Element( 'blockWidget', [], [] ); + + setData( model, '[]' ); + + insertObject( model, widget, undefined, undefined, { findOptimalPosition: 'before' } ); + + const selectableArg = insertContentSpy.getCall( 0 ).args[ 1 ]; + + expect( selectableArg ).to.not.equal( model.document.selection ); + expect( selectableArg.anchor.path ).to.deep.equal( [ 0 ] ); + } ); + + it( 'should call insert content with selection from optimal insertion range when no selection was passed (after)', () => { + const widget = new Element( 'blockWidget', [], [] ); + + setData( model, '[]' ); + + insertObject( model, widget, undefined, undefined, { findOptimalPosition: 'after' } ); + + const selectableArg = insertContentSpy.getCall( 0 ).args[ 1 ]; + + expect( selectableArg ).to.not.equal( model.document.selection ); + expect( selectableArg.anchor.path ).to.deep.equal( [ 1 ] ); + } ); + + it( 'should call insert content with selection from optimal insertion range when no selection was passed (auto)', () => { + const widget = new Element( 'blockWidget', [], [] ); + + setData( model, '[]' ); + + insertObject( model, widget, undefined, undefined, { findOptimalPosition: 'auto' } ); + + const selectableArg = insertContentSpy.getCall( 0 ).args[ 1 ]; + + expect( selectableArg ).to.not.equal( model.document.selection ); + expect( selectableArg.anchor.path ).to.deep.equal( [ 0 ] ); + } ); + } ); + + describe( 'without findOptimalPosition', () => { + it( 'should call insert content with model selection if called with no selectable', () => { + const widget = new Element( 'blockWidget', [], [] ); + + insertObject( model, widget ); + + const selectableArg = insertContentSpy.getCall( 0 ).args[ 1 ]; + + expect( selectableArg ).to.equal( model.document.selection ); + } ); + + it( 'should call insert content with selection it was called with', () => { + const widget = new Element( 'blockWidget', [], [] ); + + setData( model, 'FooBar' ); + + const position = model.createPositionAfter( root.getChild( 0 ) ); + const selection = model.createSelection( position ); + + insertObject( model, widget, selection ); + + const selectableArg = insertContentSpy.getCall( 0 ).args[ 1 ]; + + expect( selectableArg ).to.equal( selection ); + } ); + + it( 'should create a selection from a selectable it was called with', () => { + const widget = new Element( 'blockWidget', [], [] ); + + setData( model, 'FooBar' ); + + const position = model.createPositionAfter( root.getChild( 0 ) ); + + insertObject( model, widget, position ); + + const selectableArg = insertContentSpy.getCall( 0 ).args[ 1 ]; + + expect( selectableArg.is( 'selection' ) ).to.equal( true ); + expect( position.compareWith( selectableArg.anchor ) ).to.equal( 'same' ); + } ); + } ); + } ); + + describe( 'autoparagraphing of inserted object', () => { + it( 'should autoparagraph an element if it is not allowed in given position', () => { + const widget = new Element( 'inlineWidget', [], [] ); + + insertObject( model, widget ); + + expect( getData( model ) ).to.equalMarkup( '[]' ); + } ); + } ); + + describe( 'inheriting attributes by inserted object', () => { + beforeEach( () => { + const attributes = [ 'a', 'b' ]; + + schema.register( 'anotherBlockWidget', { + isObject: true, + inheritAllFrom: '$block', + allowIn: '$root', + allowAttributes: attributes + } ); + + model.schema.extend( 'blockWidget', { + allowAttributes: attributes + } ); + + model.schema.extend( 'inlineWidget', { + allowAttributes: attributes + } ); + + model.schema.extend( 'paragraph', { + allowAttributes: attributes + } ); + + for ( const attribute of attributes ) { + model.schema.setAttributeProperties( attribute, { + copyOnReplace: true + } ); + } + } ); + + it( 'should copy attributes on the inserted block object when inserting it in place of another', () => { + const widget = new Element( 'blockWidget', [], [] ); + + setData( model, '[]' ); + + insertObject( model, widget ); + + expect( getData( model ) ).to.equalMarkup( '[]' ); + } ); + + it( 'should not copy attributes without copyOnReplace property when inserting it in place of another', () => { + model.schema.extend( 'blockWidget', { + allowAttributes: 'c' + } ); + + model.schema.extend( 'anotherBlockWidget', { + allowAttributes: 'c' + } ); + + const widget = new Element( 'blockWidget', [], [] ); + + setData( model, '[]' ); + + insertObject( model, widget ); + + expect( getData( model ) ).to.equalMarkup( '[]' ); + } ); + + it( 'should not copy attribute if it is not allowed on inserted object', () => { + model.schema.setAttributeProperties( 'c', { + copyOnReplace: true + } ); + + model.schema.extend( 'anotherBlockWidget', { + allowAttributes: 'c' + } ); + + const widget = new Element( 'blockWidget', [], [] ); + + setData( model, '[]' ); + + insertObject( model, widget ); + + expect( getData( model ) ).to.equalMarkup( '[]' ); + } ); + + it( 'should copy attributes on inline widget', () => { + schema.extend( 'inlineWidget', { + allowIn: '$root' + } ); + + const widget = new Element( 'inlineWidget', [], [] ); + + setData( model, '[]' ); + + insertObject( model, widget ); + + expect( getData( model ) ).to.equalMarkup( '[]' ); + } ); + + it( 'should copy attributes on paragraph if inline object was autoparagraphed', () => { + const widget = new Element( 'inlineWidget', [], [] ); + + setData( model, '[]' ); + + insertObject( model, widget ); + + expect( getData( model ) ).to.equalMarkup( + '' + + '[]' + + '' + ); + } ); + } ); + + describe( 'setting selection after insertion', () => { + it( 'should create paragraph after inserted block object and set selection inside', () => { + const widget = new Element( 'blockWidget', [], [] ); + + insertObject( model, widget, undefined, undefined, { setSelection: 'after' } ); + + expect( getData( model ) ).to.equalMarkup( + '' + + '[]' + ); + } ); + + it( 'should set selection in a paragraph following inserted block object', () => { + const widget = new Element( 'blockWidget', [], [] ); + + setData( model, '[]Foo' ); + + insertObject( model, widget, undefined, undefined, { setSelection: 'after' } ); + + expect( getData( model ) ).to.equalMarkup( + '' + + '[]Foo' + ); + } ); + + it( 'should set selection after inserted inline object', () => { + const widget = new Element( 'inlineWidget', [], [] ); + + insertObject( model, widget, undefined, undefined, { setSelection: 'after' } ); + + expect( getData( model ) ).to.equalMarkup( + '' + + '[]' + + '' + ); + } ); + + it( 'should create paragraph after inserted inline object and set selection inside if it is not in container', () => { + schema.extend( 'inlineWidget', { + allowIn: '$root' + } ); + + const widget = new Element( 'inlineWidget', [], [] ); + + insertObject( model, widget, undefined, undefined, { setSelection: 'after' } ); + + expect( getData( model ) ).to.equalMarkup( + '' + + '[]' + ); + } ); + + it( 'should set selection on inserted block object', () => { + const widget = new Element( 'blockWidget', [], [] ); + + insertObject( model, widget, undefined, undefined, { setSelection: 'on' } ); + + expect( getData( model ) ).to.equalMarkup( + '[]' + ); + } ); + + it( 'should set selection on inserted inline object', () => { + const widget = new Element( 'inlineWidget', [], [] ); + + insertObject( model, widget, undefined, undefined, { setSelection: 'on' } ); + + expect( getData( model ) ).to.equalMarkup( + '' + + '[]' + + '' + ); + } ); + + it( 'should throw an error if unhandled position was passed', () => { + const widget = new Element( 'inlineWidget', [], [] ); + + expectToThrowCKEditorError( + () => insertObject( model, widget, undefined, undefined, { setSelection: 'above' } ), + 'insertobject-invalid-place-parameter-value' + ); + } ); + } ); + + describe( 'returned affected range of insert operation', () => { + it( 'should return collapsed range when object could not be inserted', () => { + const stub = testUtils.sinon.stub( console, 'warn' ); + + schema.register( 'disallowedBlockWidget', { + isObject: true + } ); + + const widget = new Element( 'disallowedBlockWidget', [], [] ); + + const affectedRange = insertObject( model, widget ); + + expect( affectedRange.isCollapsed ).to.be.true; + expect( getData( model ) ).to.equalMarkup( '[]' ); + + sinon.assert.calledWithMatch( stub, 'Cannot determine a proper selection range after insertion.' ); + } ); + + it( 'should return affected range when inserting block object', () => { + const widget = new Element( 'blockWidget', [], [] ); + + const affectedRange = insertObject( model, widget ); + + expect( affectedRange.start.path ).to.deep.equal( [ 0 ] ); + expect( affectedRange.end.path ).to.deep.equal( [ 1 ] ); + expect( getData( model ) ).to.equalMarkup( '[]' ); + } ); + } ); + + describe( 'deleting/replacing content by insertion', () => { + it( 'should replace selected block widget', () => { + setData( model, '[]' ); + + const widget = new Element( 'inlineWidget', [], [] ); + + insertObject( model, widget ); + + expect( getData( model ) ).to.equalMarkup( + '' + + '[]' + + '' + ); + } ); + + it( 'should replace selected inline widget with paragraph', () => { + setData( model, + '' + + '[]' + + '' + ); + + const widget = new Element( 'blockWidget', [], [] ); + + insertObject( model, widget ); + + expect( getData( model ) ).to.equalMarkup( '[]' ); + } ); + + it( 'should delete selection spaning multiple paragraphs when inserting block object', () => { + setData( model, + 'Fo[o' + + 'Foo' + + 'Foo' + + 'Fo]o' + ); + + const widget = new Element( 'blockWidget', [], [] ); + + insertObject( model, widget ); + + expect( getData( model ) ).to.equalMarkup( + 'Fo' + + '[]' + + 'o' + ); + } ); + + it( 'should delete selection spaning multiple paragraphs when inserting inline object and merge remaining ones', () => { + setData( model, + 'Fo[o' + + 'Foo' + + 'Foo' + + 'Fo]o' + ); + + const widget = new Element( 'inlineWidget', [], [] ); + + insertObject( model, widget ); + + expect( getData( model ) ).to.equalMarkup( + 'Fo[]o' + ); + } ); + + it( 'should delete selection spaning multiple paragraphs with nested nodes when inserting block objectx', () => { + setData( model, + 'F[ooo' + + 'Foo' + + 'Foo' + + 'Fo]o' + ); + + const widget = new Element( 'blockWidget', [], [] ); + + insertObject( model, widget ); + + expect( getData( model ) ).to.equalMarkup( + 'F' + + '[]' + + 'o' + ); + } ); + } ); + + describe( 'inserting objects to model', () => { + beforeEach( () => { + schema.register( 'block', { + inheritAllFrom: '$block' + } ); + } ); + + it( 'should not insert an object if it is not allowed in given position', () => { + schema.register( 'anotherBlockWidget', { + isObject: true + } ); + + const stub = testUtils.sinon.stub( console, 'warn' ); + const widget = new Element( 'anotherBlockWidget', [], [] ); + + model.insertObject( widget ); + + expect( getData( model ) ).to.equalMarkup( '[]' ); + + sinon.assert.calledOnce( insertContentSpy ); + sinon.assert.calledWith( insertContentSpy, widget, model.document.selection ); + sinon.assert.calledWithMatch( stub, 'Cannot determine a proper selection range after insertion.' ); + } ); + + it( 'should insert an object in an empty document', () => { + const widget = new Element( 'blockWidget', [], [] ); + + model.insertObject( widget ); + + sinon.assert.calledOnce( insertContentSpy ); + sinon.assert.calledWith( insertContentSpy, widget, model.document.selection ); + + expect( getData( model ) ).to.equalMarkup( '[]' ); + } ); + + it( 'should wrap an inline object in a paragraph', () => { + const widget = new Element( 'inlineWidget', [], [] ); + + model.insertObject( widget ); + + sinon.assert.calledOnce( insertContentSpy ); + + const insertContentCall = insertContentSpy.getCall( 0 ); + const content = insertContentCall.args[ 0 ]; + const selectable = insertContentCall.args[ 1 ]; + + expect( content.name ).to.equal( 'paragraph' ); + expect( content.getChild( 0 ) ).to.equal( widget ); + expect( selectable ).to.equal( model.document.selection ); + + expect( getData( model ) ).to.equalMarkup( + '' + + '[]' + + '' + ); + } ); + + it( 'should insert an object in place of a block', () => { + const widget = new Element( 'blockWidget', [], [] ); + + setData( model, '[Foo]' ); + + model.insertObject( widget ); + + sinon.assert.calledOnce( insertContentSpy ); + sinon.assert.calledWith( insertContentSpy, widget, model.document.selection ); + + expect( getData( model ) ).to.equalMarkup( '[]' ); + } ); + + it( 'should insert an object in given range', () => { + const widget = new Element( 'blockWidget', [], [] ); + + setData( model, + '[Foo]' + + 'Bar' + ); + + const range = model.createRangeOn( root.getChild( 1 ) ); + + model.insertObject( widget, range ); + + const insertContentCall = insertContentSpy.getCall( 0 ); + const content = insertContentCall.args[ 0 ]; + const selectable = insertContentCall.args[ 1 ]; + + sinon.assert.calledOnce( insertContentSpy ); + + expect( content ).to.equal( widget ); + expect( selectable.anchor.path ).to.deep.equal( [ 1 ] ); + expect( selectable.focus.path ).to.deep.equal( [ 2 ] ); + expect( getData( model ) ).to.equalMarkup( + '[Foo]' + + '' + ); + } ); + } ); +} ); + +describe( 'findOptimalInsertionRange()', () => { + let model, doc, schema; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + model = new Model(); + doc = model.document; + schema = model.schema; + + doc.createRoot(); + + schema.register( 'blockWidget', { + isObject: true, + inheritAllFrom: '$block', + allowIn: '$root' + } ); + + schema.register( 'inlineWidget', { + isObject: true, + allowIn: [ '$block' ] + } ); + + schema.register( 'paragraph', { + inheritAllFrom: '$block' + } ); + + schema.register( 'container', { + inheritAllFrom: '$container', + allowIn: [ '$root' ] + } ); + + model.schema.register( 'imageBlock' ); + model.schema.register( 'span' ); + + model.schema.extend( 'imageBlock', { + allowIn: '$root', + isObject: true, + isBlock: true + } ); + + model.schema.register( 'horizontalLine', { + isObject: true, + allowWhere: '$block' + } ); + + model.schema.extend( 'span', { allowIn: 'paragraph' } ); + model.schema.extend( '$text', { allowIn: 'span' } ); + } ); + + it( 'should create a range before object if block object is selected', () => { + setData( model, '[]' ); + + const selection = model.document.selection; + const optimalRange = findOptimalInsertionRange( selection, model, 'before' ); + + expect( optimalRange.isCollapsed ).to.be.true; + expect( optimalRange.start.path ).to.deep.equal( [ 0 ] ); + } ); + + it( 'should create a range after object if block object is selected', () => { + setData( model, '[]' ); + + const selection = model.document.selection; + const optimalRange = findOptimalInsertionRange( selection, model, 'after' ); + + expect( optimalRange.isCollapsed ).to.be.true; + expect( optimalRange.start.path ).to.deep.equal( [ 1 ] ); + } ); + + it( 'should create a range on object if block object is selected', () => { + setData( model, '[]' ); + + const selection = model.document.selection; + const optimalRange = findOptimalInsertionRange( selection, model, 'auto' ); + + expect( optimalRange.isCollapsed ).to.be.false; + expect( optimalRange.start.path ).to.deep.equal( [ 0 ] ); + expect( optimalRange.end.path ).to.deep.equal( [ 1 ] ); + } ); + + it( 'should create a range on object by default if block object is selected', () => { + setData( model, '[]' ); + + const selection = model.document.selection; + const optimalRange = findOptimalInsertionRange( selection, model ); + + expect( optimalRange.isCollapsed ).to.be.false; + expect( optimalRange.start.path ).to.deep.equal( [ 0 ] ); + expect( optimalRange.end.path ).to.deep.equal( [ 1 ] ); + } ); + + it( 'should create a collapsed range at the end of a selection if no block elements are selected', () => { + schema.extend( '$text', { + allowIn: [ 'container' ] + } ); + + setData( model, 'fo[obar]' ); + + const selection = model.document.selection; + const optimalRange = findOptimalInsertionRange( selection, model, 'auto' ); + + expect( optimalRange.isCollapsed ).to.be.true; + expect( optimalRange.start.path ).to.deep.equal( [ 1, 3 ] ); + } ); + + it( 'should create a range at the beginning of block', () => { + setData( model, '[]' ); + + const selection = model.document.selection; + const optimalRange = findOptimalInsertionRange( selection, model, 'auto' ); + + expect( optimalRange.isCollapsed ).to.be.true; + expect( optimalRange.start.path ).to.deep.equal( [ 0, 0 ] ); + } ); + + it( 'should create a range at the beginning of first block', () => { + setData( model, + '[' + + '' + + ']' + ); + + const selection = model.document.selection; + const optimalRange = findOptimalInsertionRange( selection, model, 'auto' ); + + expect( optimalRange.isCollapsed ).to.be.true; + expect( optimalRange.start.path ).to.deep.equal( [ 0, 0 ] ); + } ); + + it( 'returns a collapsed range after selected element', () => { + setData( model, 'x[]y' ); + + const range = findOptimalInsertionRange( doc.selection, model ); + + expect( range.start.path ).to.deep.equal( [ 1 ] ); + expect( range.end.path ).to.deep.equal( [ 2 ] ); + } ); + + it( 'returns a collapsed range before parent block if an inline object is selected', () => { + model.schema.register( 'placeholder', { + allowWhere: '$text', + isInline: true, + isObject: true + } ); + + setData( model, 'xf[]ooy' ); + + const range = findOptimalInsertionRange( doc.selection, model ); + + expect( range.start.path ).to.deep.equal( [ 1 ] ); + expect( range.end.path ).to.deep.equal( [ 1 ] ); + } ); + + it( 'should return a collapsed range inside empty block', () => { + setData( model, 'x[]y' ); + + const range = findOptimalInsertionRange( doc.selection, model ); + + expect( range.start.path ).to.deep.equal( [ 1, 0 ] ); + expect( range.end.path ).to.deep.equal( [ 1, 0 ] ); + } ); + + it( 'should return a collapsed range before block if at the beginning of that block', () => { + setData( model, 'x[]fooy' ); + + const range = findOptimalInsertionRange( doc.selection, model ); + + expect( range.start.path ).to.deep.equal( [ 1 ] ); + expect( range.end.path ).to.deep.equal( [ 1 ] ); + } ); + + it( 'should return a collapsed range before block if in the middle of that block (collapsed selection)', () => { + setData( model, 'xf[]ooy' ); + + const range = findOptimalInsertionRange( doc.selection, model ); + + expect( range.start.path ).to.deep.equal( [ 1 ] ); + expect( range.end.path ).to.deep.equal( [ 1 ] ); + } ); + + it( 'should return a collapsed range before block if in the middle of that block (non-collapsed selection)', () => { + setData( model, 'xf[o]oy' ); + + const range = findOptimalInsertionRange( doc.selection, model ); + + expect( range.start.path ).to.deep.equal( [ 1 ] ); + expect( range.end.path ).to.deep.equal( [ 1 ] ); + } ); + + it( 'should return a collapsed range after block if at the end of that block', () => { + setData( model, 'xfoo[]y' ); + + const range = findOptimalInsertionRange( doc.selection, model ); + + expect( range.start.path ).to.deep.equal( [ 2 ] ); + expect( range.end.path ).to.deep.equal( [ 2 ] ); + } ); + + // Checking if isTouching() was used. + it( 'should return a collapsed range after block if at the end of that block (deeply nested)', () => { + setData( model, 'xfoobar[]y' ); + + const range = findOptimalInsertionRange( doc.selection, model ); + + expect( range.start.path ).to.deep.equal( [ 2 ] ); + expect( range.end.path ).to.deep.equal( [ 2 ] ); + } ); + + it( 'should return selection focus if not in a block', () => { + model.schema.extend( '$text', { allowIn: '$root' } ); + setData( model, 'foo[]bar' ); + + const range = findOptimalInsertionRange( doc.selection, model ); + + expect( range.start.path ).to.deep.equal( [ 3 ] ); + expect( range.end.path ).to.deep.equal( [ 3 ] ); + } ); +} ); diff --git a/packages/ckeditor5-engine/tests/view/containerelement.js b/packages/ckeditor5-engine/tests/view/containerelement.js index 5c49c58806e..ce50ada1e72 100644 --- a/packages/ckeditor5-engine/tests/view/containerelement.js +++ b/packages/ckeditor5-engine/tests/view/containerelement.js @@ -23,7 +23,6 @@ describe( 'ContainerElement', () => { expect( el ).to.be.an.instanceof( ContainerElement ); expect( el ).to.be.an.instanceof( Element ); expect( el ).to.have.property( 'name' ).that.equals( 'p' ); - expect( el.isAllowedInsideAttributeElement ).to.be.false; } ); } ); diff --git a/packages/ckeditor5-engine/tests/view/domconverter/view-to-dom.js b/packages/ckeditor5-engine/tests/view/domconverter/view-to-dom.js index ac2d25fe839..96328229c65 100644 --- a/packages/ckeditor5-engine/tests/view/domconverter/view-to-dom.js +++ b/packages/ckeditor5-engine/tests/view/domconverter/view-to-dom.js @@ -1095,6 +1095,120 @@ describe( 'DomConverter', () => { expect( domChildren[ 1 ].tagName.toLowerCase() ).to.equal( 'b' ); expect( domChildren[ 1 ].childNodes.length ).to.equal( 0 ); } ); + + describe( 'transparentRendering custom property', () => { + it( 'should be transparent in the data pipeline', () => { + converter.renderingMode = 'data'; + converter.blockFillerMode = 'nbsp'; + + const warnStub = testUtils.sinon.stub( console, 'warn' ); + + const viewList = parse( + '' + + '' + + '' + + 'foobar' + + '' + + '' + + 'abc' + + '123' + + '' + + '' + + '' + ); + + const bogusParagraph = viewList.getChild( 0 ).getChild( 0 ).getChild( 0 ); + + bogusParagraph._setCustomProperty( 'dataPipeline:transparentRendering', true ); + + const domDivChildren = Array.from( converter.viewChildrenToDom( viewList, document ) ); + + expect( domDivChildren.length ).to.equal( 1 ); + expect( domDivChildren[ 0 ].tagName.toLowerCase() ).to.equal( 'ul' ); + + const domUlChildren = Array.from( domDivChildren[ 0 ].childNodes ); + + expect( domUlChildren.length ).to.equal( 2 ); + expect( domUlChildren[ 0 ].tagName.toLowerCase() ).to.equal( 'li' ); + expect( domUlChildren[ 1 ].tagName.toLowerCase() ).to.equal( 'li' ); + + const domUl1Children = Array.from( domUlChildren[ 0 ].childNodes ); + const domUl2Children = Array.from( domUlChildren[ 1 ].childNodes ); + + expect( domUl1Children.length ).to.equal( 2 ); + expect( domUl1Children[ 0 ].data ).to.equal( 'foo' ); + expect( domUl1Children[ 1 ].tagName.toLowerCase() ).to.equal( 'b' ); + expect( domUl1Children[ 1 ].firstChild.data ).to.equal( 'bar' ); + + expect( domUl2Children.length ).to.equal( 2 ); + expect( domUl2Children[ 0 ].tagName.toLowerCase() ).to.equal( 'p' ); + expect( domUl2Children[ 1 ].tagName.toLowerCase() ).to.equal( 'p' ); + expect( domUl2Children[ 0 ].firstChild.data ).to.equal( 'abc' ); + expect( domUl2Children[ 1 ].firstChild.data ).to.equal( '123' ); + + sinon.assert.notCalled( warnStub ); + } ); + + it( 'should not be transparent in the editing pipeline', () => { + converter.renderingMode = 'editing'; + converter.blockFillerMode = 'br'; + + const warnStub = testUtils.sinon.stub( console, 'warn' ); + + const viewList = parse( + '' + + '' + + '' + + 'foobar' + + '' + + '' + + 'abc' + + '123' + + '' + + '' + + '' + ); + + const bogusParagraph = viewList.getChild( 0 ).getChild( 0 ).getChild( 0 ); + + bogusParagraph._setCustomProperty( 'dataPipeline:transparentRendering', true ); + + const domDivChildren = Array.from( converter.viewChildrenToDom( viewList, document ) ); + + expect( domDivChildren.length ).to.equal( 1 ); + expect( domDivChildren[ 0 ].tagName.toLowerCase() ).to.equal( 'ul' ); + + const domUlChildren = Array.from( domDivChildren[ 0 ].childNodes ); + + expect( domUlChildren.length ).to.equal( 2 ); + expect( domUlChildren[ 0 ].tagName.toLowerCase() ).to.equal( 'li' ); + expect( domUlChildren[ 1 ].tagName.toLowerCase() ).to.equal( 'li' ); + + const domUl1Children = Array.from( domUlChildren[ 0 ].childNodes ); + const domUl2Children = Array.from( domUlChildren[ 1 ].childNodes ); + + expect( domUl1Children.length ).to.equal( 1 ); + expect( domUl1Children[ 0 ].tagName.toLowerCase() ).to.equal( 'p' ); + expect( domUl1Children[ 0 ].childNodes[ 0 ].data ).to.equal( 'foo' ); + expect( domUl1Children[ 0 ].childNodes[ 1 ].tagName.toLowerCase() ).to.equal( 'b' ); + expect( domUl1Children[ 0 ].childNodes[ 1 ].firstChild.data ).to.equal( 'bar' ); + + expect( domUl2Children.length ).to.equal( 2 ); + expect( domUl2Children[ 0 ].tagName.toLowerCase() ).to.equal( 'p' ); + expect( domUl2Children[ 1 ].tagName.toLowerCase() ).to.equal( 'p' ); + expect( domUl2Children[ 0 ].firstChild.data ).to.equal( 'abc' ); + expect( domUl2Children[ 1 ].firstChild.data ).to.equal( '123' ); + + sinon.assert.calledOnce( warnStub ); + sinon.assert.calledWithExactly( warnStub, + sinon.match( /^domconverter-transparent-rendering-unsupported-in-editing-pipeline/ ), + { + viewElement: bogusParagraph + }, + sinon.match.string // Link to the documentation + ); + } ); + } ); } ); describe( 'viewPositionToDom()', () => { diff --git a/packages/ckeditor5-engine/tests/view/downcastwriter/insert.js b/packages/ckeditor5-engine/tests/view/downcastwriter/insert.js index 3c7c2ba5368..fdc4eb60b5d 100644 --- a/packages/ckeditor5-engine/tests/view/downcastwriter/insert.js +++ b/packages/ckeditor5-engine/tests/view/downcastwriter/insert.js @@ -164,25 +164,6 @@ describe( 'DowncastWriter', () => { ); } ); - it( 'should break attribute on UIElement insertion (isAllowedInsideAttributeElement = false)', () => { - const { view, selection } = parse( - 'foo{}bar' - ); - - const element = new UIElement( document, 'span' ); - element._isAllowedInsideAttributeElement = false; - - const newRange = writer.insert( selection.getFirstPosition(), element ); - - expect( stringify( view.root, newRange, { showType: true, showPriority: true } ) ).to.equal( - '' + - 'foo' + - '[]' + - 'bar' + - '' - ); - } ); - it( 'should break attribute on multiple different nodes insertion', () => { testInsert( 'foo{}bar', @@ -200,8 +181,6 @@ describe( 'DowncastWriter', () => { ); const element = new ContainerElement( document, 'span', {}, 'baz' ); - element._isAllowedInsideAttributeElement = true; - const newRange = writer.insert( selection.getFirstPosition(), element ); expect( stringify( view.root, newRange, { showType: true, showPriority: true } ) ).to.equal( @@ -222,25 +201,6 @@ describe( 'DowncastWriter', () => { ); } ); - it( 'should break attribute on non inline ContainerElement insertion', () => { - const { view, selection } = parse( - 'foo{}bar' - ); - - const element = new ContainerElement( document, 'span', {}, 'baz' ); - element._isAllowedInsideAttributeElement = false; - - const newRange = writer.insert( selection.getFirstPosition(), element ); - - expect( stringify( view.root, newRange, { showType: true, showPriority: true } ) ).to.equal( - '' + - 'foo' + - '[baz]' + - 'bar' + - '' - ); - } ); - it( 'should throw when inserting Element', () => { const element = new Element( document, 'b' ); const container = new ContainerElement( document, 'p' ); diff --git a/packages/ckeditor5-engine/tests/view/downcastwriter/wrap.js b/packages/ckeditor5-engine/tests/view/downcastwriter/wrap.js index 3e0bcd484a0..4788eb16157 100644 --- a/packages/ckeditor5-engine/tests/view/downcastwriter/wrap.js +++ b/packages/ckeditor5-engine/tests/view/downcastwriter/wrap.js @@ -169,11 +169,11 @@ describe( 'DowncastWriter', () => { ); } ); - it( 'should not wrap inside nested containers', () => { + it( 'should wrap inside nested containers', () => { testWrap( '[foobarbaz]', '', - '[foobarbaz]' + '[foobarbaz]' ); } ); @@ -445,8 +445,6 @@ describe( 'DowncastWriter', () => { const element = new ContainerElement( document, 'span', {}, 'baz' ); const container = new ContainerElement( document, 'p', null, [ 'foo', element, 'bar' ] ); - element._isAllowedInsideAttributeElement = true; - const wrapAttribute = new AttributeElement( document, 'b' ); const range = Range._createFromParentsAndOffsets( container, 0, container, 3 ); const newRange = writer.wrap( range, wrapAttribute ); @@ -458,44 +456,6 @@ describe( 'DowncastWriter', () => { ); } ); - it( 'should not wrap an non-inline ContainerElement', () => { - const element = new ContainerElement( document, 'span', {}, 'baz' ); - const container = new ContainerElement( document, 'p', null, [ 'foo', element, 'bar' ] ); - - element._isAllowedInsideAttributeElement = false; - - const wrapAttribute = new AttributeElement( document, 'b' ); - const range = Range._createFromParentsAndOffsets( container, 0, container, 3 ); - const newRange = writer.wrap( range, wrapAttribute ); - - expect( stringify( container, newRange, { showType: true, showPriority: true, showAttributeElementId: true } ) ).to.equal( - '' + - '[foo' + - 'baz' + - 'bar]' + - '' - ); - } ); - - it( 'should not wrap an non-inline UIElement', () => { - const element = new UIElement( document, 'span' ); - const container = new ContainerElement( document, 'p', null, [ 'foo', element, 'bar' ] ); - - element._isAllowedInsideAttributeElement = false; - - const wrapAttribute = new AttributeElement( document, 'b' ); - const range = Range._createFromParentsAndOffsets( container, 0, container, 3 ); - const newRange = writer.wrap( range, wrapAttribute ); - - expect( stringify( container, newRange, { showType: true, showPriority: true, showAttributeElementId: true } ) ).to.equal( - '' + - '[foo' + - '' + - 'bar]' + - '' - ); - } ); - it( 'should keep stable hierarchy when wrapping with attribute with same priority', () => { testWrap( '[foo]', diff --git a/packages/ckeditor5-engine/tests/view/downcastwriter/writer.js b/packages/ckeditor5-engine/tests/view/downcastwriter/writer.js index e767d3f5fc3..28ec63519e2 100644 --- a/packages/ckeditor5-engine/tests/view/downcastwriter/writer.js +++ b/packages/ckeditor5-engine/tests/view/downcastwriter/writer.js @@ -113,7 +113,6 @@ describe( 'DowncastWriter', () => { expect( element.is( 'attributeElement' ) ).to.be.true; expect( element.name ).to.equal( 'foo' ); - expect( element.isAllowedInsideAttributeElement ).to.be.false; assertElementAttributes( element, attributes ); } ); @@ -126,7 +125,6 @@ describe( 'DowncastWriter', () => { expect( element.is( 'attributeElement' ) ).to.be.true; expect( element.name ).to.equal( 'foo' ); - expect( element.isAllowedInsideAttributeElement ).to.be.false; expect( element.priority ).to.equal( 99 ); expect( element.id ).to.equal( 'bar' ); expect( element.shouldRenderUnsafeAttribute( 'baz' ) ).to.be.true; @@ -146,20 +144,17 @@ describe( 'DowncastWriter', () => { expect( element.is( 'containerElement' ) ).to.be.true; expect( element.name ).to.equal( 'foo' ); - expect( element.isAllowedInsideAttributeElement ).to.be.false; assertElementAttributes( element, attributes ); expect( element.childCount ).to.equal( 0 ); } ); it( 'should allow to pass additional options', () => { const element = writer.createContainerElement( 'foo', attributes, { - isAllowedInsideAttributeElement: true, renderUnsafeAttributes: [ 'baz' ] } ); expect( element.is( 'containerElement' ) ).to.be.true; expect( element.name ).to.equal( 'foo' ); - expect( element.isAllowedInsideAttributeElement ).to.be.true; expect( element.shouldRenderUnsafeAttribute( 'baz' ) ).to.be.true; assertElementAttributes( element, attributes ); } ); @@ -169,7 +164,6 @@ describe( 'DowncastWriter', () => { expect( element.is( 'containerElement' ) ).to.be.true; expect( element.name ).to.equal( 'foo' ); - expect( element.isAllowedInsideAttributeElement ).to.be.false; expect( Array.from( element.getAttributes() ).length ).to.equal( 0 ); expect( element.childCount ).to.equal( 0 ); } ); @@ -180,7 +174,6 @@ describe( 'DowncastWriter', () => { expect( element.is( 'containerElement' ) ).to.be.true; expect( element.name ).to.equal( 'foo' ); - expect( element.isAllowedInsideAttributeElement ).to.be.false; expect( Array.from( element.getAttributes() ).length ).to.equal( 0 ); expect( element.childCount ).to.equal( 1 ); expect( element.getChild( 0 ) ).to.equal( child ); @@ -193,7 +186,6 @@ describe( 'DowncastWriter', () => { expect( element.is( 'containerElement' ) ).to.be.true; expect( element.name ).to.equal( 'foo' ); - expect( element.isAllowedInsideAttributeElement ).to.be.false; assertElementAttributes( element, attributes ); expect( element.childCount ).to.equal( 2 ); expect( element.getChild( 0 ) ).to.equal( first ); @@ -202,11 +194,11 @@ describe( 'DowncastWriter', () => { it( 'should create element with children attributes and allow additional options', () => { const child = writer.createEmptyElement( 'bar' ); - const element = writer.createContainerElement( 'foo', attributes, child, { isAllowedInsideAttributeElement: true } ); + const element = writer.createContainerElement( 'foo', attributes, child, { renderUnsafeAttributes: [ 'baz' ] } ); expect( element.is( 'containerElement' ) ).to.be.true; expect( element.name ).to.equal( 'foo' ); - expect( element.isAllowedInsideAttributeElement ).to.be.true; + expect( element.shouldRenderUnsafeAttribute( 'baz' ) ).to.be.true; assertElementAttributes( element, attributes ); expect( element.childCount ).to.equal( 1 ); expect( element.getChild( 0 ) ).to.equal( child ); @@ -219,7 +211,6 @@ describe( 'DowncastWriter', () => { expect( element ).to.be.instanceOf( EditableElement ); expect( element.name ).to.equal( 'foo' ); - expect( element.isAllowedInsideAttributeElement ).to.be.false; assertElementAttributes( element, attributes ); } ); @@ -238,19 +229,16 @@ describe( 'DowncastWriter', () => { expect( element.is( 'emptyElement' ) ).to.be.true; expect( element.name ).to.equal( 'foo' ); - expect( element.isAllowedInsideAttributeElement ).to.be.true; assertElementAttributes( element, attributes ); } ); it( 'should allow to pass additional options', () => { const element = writer.createEmptyElement( 'foo', attributes, { - isAllowedInsideAttributeElement: false, renderUnsafeAttributes: [ 'baz' ] } ); expect( element.is( 'emptyElement' ) ).to.be.true; expect( element.name ).to.equal( 'foo' ); - expect( element.isAllowedInsideAttributeElement ).to.be.false; expect( element.shouldRenderUnsafeAttribute( 'baz' ) ).to.be.true; assertElementAttributes( element, attributes ); } ); @@ -262,7 +250,6 @@ describe( 'DowncastWriter', () => { expect( element.is( 'uiElement' ) ).to.be.true; expect( element.name ).to.equal( 'foo' ); - expect( element.isAllowedInsideAttributeElement ).to.be.true; assertElementAttributes( element, attributes ); } ); @@ -272,18 +259,16 @@ describe( 'DowncastWriter', () => { expect( element.is( 'uiElement' ) ).to.be.true; expect( element.name ).to.equal( 'foo' ); - expect( element.isAllowedInsideAttributeElement ).to.be.true; expect( element.render ).to.equal( renderFn ); assertElementAttributes( element, attributes ); } ); it( 'should allow to pass additional options', () => { const renderFn = function() {}; - const element = writer.createUIElement( 'foo', attributes, renderFn, { isAllowedInsideAttributeElement: false } ); + const element = writer.createUIElement( 'foo', attributes, renderFn ); expect( element.is( 'uiElement' ) ).to.be.true; expect( element.name ).to.equal( 'foo' ); - expect( element.isAllowedInsideAttributeElement ).to.be.false; assertElementAttributes( element, attributes ); } ); } ); @@ -294,7 +279,6 @@ describe( 'DowncastWriter', () => { expect( element.is( 'rawElement' ) ).to.be.true; expect( element.name ).to.equal( 'foo' ); - expect( element.isAllowedInsideAttributeElement ).to.be.true; assertElementAttributes( element, attributes ); expect( element.render ).to.be.a( 'function' ); @@ -323,13 +307,11 @@ describe( 'DowncastWriter', () => { it( 'should allow to pass additional options', () => { const renderFn = function() {}; const element = writer.createRawElement( 'foo', attributes, renderFn, { - isAllowedInsideAttributeElement: false, renderUnsafeAttributes: [ 'baz' ] } ); expect( element.is( 'rawElement' ) ).to.be.true; expect( element.name ).to.equal( 'foo' ); - expect( element.isAllowedInsideAttributeElement ).to.be.false; expect( element.shouldRenderUnsafeAttribute( 'baz' ) ).to.be.true; assertElementAttributes( element, attributes ); } ); diff --git a/packages/ckeditor5-engine/tests/view/element.js b/packages/ckeditor5-engine/tests/view/element.js index 52b02f9ef58..7b1c3d1baf0 100644 --- a/packages/ckeditor5-engine/tests/view/element.js +++ b/packages/ckeditor5-engine/tests/view/element.js @@ -28,7 +28,6 @@ describe( 'Element', () => { expect( el ).to.have.property( 'name' ).that.equals( 'p' ); expect( el ).to.have.property( 'parent' ).that.is.null; expect( count( el.getAttributeKeys() ) ).to.equal( 0 ); - expect( el.isAllowedInsideAttributeElement ).to.be.false; } ); it( 'should create element with attributes as plain object', () => { @@ -37,7 +36,6 @@ describe( 'Element', () => { expect( el ).to.have.property( 'name' ).that.equals( 'p' ); expect( count( el.getAttributeKeys() ) ).to.equal( 1 ); expect( el.getAttribute( 'foo' ) ).to.equal( 'bar' ); - expect( el.isAllowedInsideAttributeElement ).to.be.false; } ); it( 'should create element with attributes as map', () => { @@ -49,7 +47,6 @@ describe( 'Element', () => { expect( el ).to.have.property( 'name' ).that.equals( 'p' ); expect( count( el.getAttributeKeys() ) ).to.equal( 1 ); expect( el.getAttribute( 'foo' ) ).to.equal( 'bar' ); - expect( el.isAllowedInsideAttributeElement ).to.be.false; } ); it( 'should stringify attributes', () => { @@ -58,7 +55,6 @@ describe( 'Element', () => { expect( el.getAttribute( 'foo' ) ).to.equal( 'true' ); expect( el.getAttribute( 'bar' ) ).to.be.undefined; expect( el.getAttribute( 'object' ) ).to.equal( '[object Object]' ); - expect( el.isAllowedInsideAttributeElement ).to.be.false; } ); it( 'should create element with children', () => { @@ -68,7 +64,6 @@ describe( 'Element', () => { expect( parent ).to.have.property( 'name' ).that.equals( 'div' ); expect( parent.childCount ).to.equal( 1 ); expect( parent.getChild( 0 ) ).to.have.property( 'name' ).that.equals( 'p' ); - expect( parent.isAllowedInsideAttributeElement ).to.be.false; } ); it( 'should move class attribute to class set ', () => { @@ -79,7 +74,6 @@ describe( 'Element', () => { expect( el._classes.has( 'one' ) ).to.be.true; expect( el._classes.has( 'two' ) ).to.be.true; expect( el._classes.has( 'three' ) ).to.be.true; - expect( el.isAllowedInsideAttributeElement ).to.be.false; } ); it( 'should move style attribute to style proxy', () => { @@ -94,7 +88,6 @@ describe( 'Element', () => { expect( el._styles.getAsString( 'two' ) ).to.equal( 'style2' ); expect( el._styles.has( 'three' ) ).to.be.true; expect( el._styles.getAsString( 'three' ) ).to.equal( 'url(http://ckeditor.com)' ); - expect( el.isAllowedInsideAttributeElement ).to.be.false; } ); } ); @@ -244,17 +237,6 @@ describe( 'Element', () => { expect( cloned.getFillerOffset ).to.equal( fm ); } ); - - it( 'should clone isAllowedInsideAttributeElement', () => { - const el = new Element( document, 'p' ); - - expect( el.isAllowedInsideAttributeElement ).to.be.false; - el._isAllowedInsideAttributeElement = true; - - const cloned = el._clone(); - - expect( cloned.isAllowedInsideAttributeElement ).to.equal( true ); - } ); } ); describe( 'isSimilar()', () => { @@ -285,13 +267,6 @@ describe( 'Element', () => { expect( el.isSimilar( other ) ).to.be.false; } ); - it( 'should return false when isAllowedInsideAttributeElement property is not the same', () => { - const other = el._clone(); - other._isAllowedInsideAttributeElement = true; - - expect( el.isSimilar( other ) ).to.be.false; - } ); - it( 'should return false when attributes are not the same', () => { const other1 = el._clone(); const other2 = el._clone(); diff --git a/packages/ckeditor5-engine/tests/view/emptyelement.js b/packages/ckeditor5-engine/tests/view/emptyelement.js index 197bed10d94..f31f920962b 100644 --- a/packages/ckeditor5-engine/tests/view/emptyelement.js +++ b/packages/ckeditor5-engine/tests/view/emptyelement.js @@ -23,14 +23,6 @@ describe( 'EmptyElement', () => { } ); } ); - describe( 'constructor()', () => { - it( 'should have the default value for isAllowedInsideAttributeElement set to true', () => { - const el = new EmptyElement( document, 'span' ); - - expect( el.isAllowedInsideAttributeElement ).to.be.true; - } ); - } ); - describe( 'is()', () => { let el; diff --git a/packages/ckeditor5-engine/tests/view/observer/tabobserver.js b/packages/ckeditor5-engine/tests/view/observer/tabobserver.js new file mode 100644 index 00000000000..1b0a5125502 --- /dev/null +++ b/packages/ckeditor5-engine/tests/view/observer/tabobserver.js @@ -0,0 +1,99 @@ +/** + * @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 + */ + +/* globals document */ + +import TabObserver from '../../../src/view/observer/tabobserver'; +import View from '../../../src/view/view'; +import createViewRoot from '../../../tests/view/_utils/createroot'; + +import { getCode } from '@ckeditor/ckeditor5-utils/src/keyboard'; + +describe( 'TabObserver', () => { + let view, viewDocument; + + beforeEach( () => { + view = new View(); + viewDocument = view.document; + view.addObserver( TabObserver ); + } ); + + it( 'can be initialized', () => { + expect( () => { + createViewRoot( viewDocument ); + view.attachDomRoot( document.createElement( 'div' ) ); + } ).to.not.throw(); + } ); + + describe( 'tab event', () => { + it( 'is fired on keydown', () => { + const spy = sinon.spy(); + + viewDocument.on( 'tab', spy ); + + viewDocument.fire( 'keydown', { + keyCode: getCode( 'Tab' ) + } ); + + expect( spy.calledOnce ).to.be.true; + } ); + + it( 'is not fired on keydown when keyCode does not match tab', () => { + const spy = sinon.spy(); + + viewDocument.on( 'tab', spy ); + + viewDocument.fire( 'keydown', { + keyCode: 1 + } ); + + expect( spy.calledOnce ).to.be.false; + } ); + + it( 'should stop keydown event when tab event is stopped', () => { + const keydownSpy = sinon.spy(); + + viewDocument.on( 'keydown', keydownSpy ); + viewDocument.on( 'tab', evt => evt.stop() ); + + viewDocument.fire( 'keydown', { + keyCode: getCode( 'Tab' ) + } ); + + sinon.assert.notCalled( keydownSpy ); + } ); + + it( 'should not stop keydown event when tab event is not stopped', () => { + const keydownSpy = sinon.spy(); + const tabSpy = sinon.spy(); + + viewDocument.on( 'keydown', keydownSpy ); + viewDocument.on( 'tab', tabSpy ); + + viewDocument.fire( 'keydown', { + keyCode: getCode( 'Tab' ) + } ); + + sinon.assert.calledOnce( keydownSpy ); + sinon.assert.calledOnce( tabSpy ); + } ); + + it( 'should not be fired when tab key is pressed with ctrl key', () => { + const keydownSpy = sinon.spy(); + const tabSpy = sinon.spy(); + + viewDocument.on( 'keydown', keydownSpy ); + viewDocument.on( 'tab', tabSpy ); + + viewDocument.fire( 'keydown', { + keyCode: getCode( 'Tab' ), + ctrlKey: true + } ); + + sinon.assert.calledOnce( keydownSpy ); + sinon.assert.notCalled( tabSpy ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-engine/tests/view/placeholder.js b/packages/ckeditor5-engine/tests/view/placeholder.js index 50d9d974cc3..6dcf7ef0115 100644 --- a/packages/ckeditor5-engine/tests/view/placeholder.js +++ b/packages/ckeditor5-engine/tests/view/placeholder.js @@ -339,6 +339,21 @@ describe( 'placeholder', () => { expect( viewRoot.getChild( 0 ).hasClass( 'ck-placeholder' ) ).to.be.false; } ); + it( 'should not set attributes/class when first child is an AttributeElement (isDirectHost=false)', () => { + setData( view, 'foo' ); + viewDocument.isFocused = false; + + enablePlaceholder( { + view, + element: viewRoot, + text: 'foo bar baz', + isDirectHost: false + } ); + + expect( viewRoot.getChild( 0 ).hasAttribute( 'data-placeholder' ) ).to.be.false; + expect( viewRoot.getChild( 0 ).hasClass( 'ck-placeholder' ) ).to.be.false; + } ); + it( 'should keep the placeholder visible when the host element is focused (keepOnFocus = true)', () => { setData( view, '
{another div}
' ); const element = viewRoot.getChild( 0 ); diff --git a/packages/ckeditor5-engine/tests/view/rawelement.js b/packages/ckeditor5-engine/tests/view/rawelement.js index e4decce82e9..032636b722e 100644 --- a/packages/ckeditor5-engine/tests/view/rawelement.js +++ b/packages/ckeditor5-engine/tests/view/rawelement.js @@ -30,7 +30,6 @@ describe( 'RawElement', () => { expect( rawElement.getStyle( 'color' ) ).to.equal( 'white' ); expect( rawElement.hasClass( 'foo' ) ).to.true; expect( rawElement.hasClass( 'bar' ) ).to.true; - expect( rawElement.isAllowedInsideAttributeElement ).to.be.true; } ); it( 'should throw if child elements are passed to constructor', () => { diff --git a/packages/ckeditor5-engine/tests/view/uielement.js b/packages/ckeditor5-engine/tests/view/uielement.js index 02b0f0ed8ab..4585bed1635 100644 --- a/packages/ckeditor5-engine/tests/view/uielement.js +++ b/packages/ckeditor5-engine/tests/view/uielement.js @@ -32,7 +32,6 @@ describe( 'UIElement', () => { expect( uiElement.getStyle( 'color' ) ).to.equal( 'white' ); expect( uiElement.hasClass( 'foo' ) ).to.true; expect( uiElement.hasClass( 'bar' ) ).to.true; - expect( uiElement.isAllowedInsideAttributeElement ).to.be.true; } ); it( 'should throw if child elements are passed to constructor', () => { diff --git a/packages/ckeditor5-engine/tests/view/view/view.js b/packages/ckeditor5-engine/tests/view/view/view.js index 0204230ce62..17ed355a7f9 100644 --- a/packages/ckeditor5-engine/tests/view/view/view.js +++ b/packages/ckeditor5-engine/tests/view/view/view.js @@ -9,11 +9,13 @@ import View from '../../../src/view/view'; import Observer from '../../../src/view/observer/observer'; import MutationObserver from '../../../src/view/observer/mutationobserver'; import KeyObserver from '../../../src/view/observer/keyobserver'; +import TabObserver from '../../../src/view/observer/tabobserver'; import InputObserver from '../../../src/view/observer/inputobserver'; import FakeSelectionObserver from '../../../src/view/observer/fakeselectionobserver'; import SelectionObserver from '../../../src/view/observer/selectionobserver'; import FocusObserver from '../../../src/view/observer/focusobserver'; import CompositionObserver from '../../../src/view/observer/compositionobserver'; +import ArrowKeysObserver from '../../../src/view/observer/arrowkeysobserver'; import ViewRange from '../../../src/view/range'; import ViewElement from '../../../src/view/element'; import ViewContainerElement from '../../../src/view/containerelement'; @@ -31,7 +33,7 @@ import env from '@ckeditor/ckeditor5-utils/src/env'; import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; describe( 'view', () => { - const DEFAULT_OBSERVERS_COUNT = 7; + const DEFAULT_OBSERVERS_COUNT = 8; let domRoot, view, viewDocument, ObserverMock, instantiated, enabled, ObserverMockGlobalCount; beforeEach( () => { @@ -85,8 +87,10 @@ describe( 'view', () => { expect( view.getObserver( SelectionObserver ) ).to.be.instanceof( SelectionObserver ); expect( view.getObserver( FocusObserver ) ).to.be.instanceof( FocusObserver ); expect( view.getObserver( KeyObserver ) ).to.be.instanceof( KeyObserver ); + expect( view.getObserver( TabObserver ) ).to.be.instanceof( TabObserver ); expect( view.getObserver( FakeSelectionObserver ) ).to.be.instanceof( FakeSelectionObserver ); expect( view.getObserver( CompositionObserver ) ).to.be.instanceof( CompositionObserver ); + expect( view.getObserver( ArrowKeysObserver ) ).to.be.instanceof( ArrowKeysObserver ); } ); it( 'should add InputObserver on Android devices', () => { diff --git a/packages/ckeditor5-enter/src/enterobserver.js b/packages/ckeditor5-enter/src/enterobserver.js index 96b4e0fb793..f6dd1a14947 100644 --- a/packages/ckeditor5-enter/src/enterobserver.js +++ b/packages/ckeditor5-enter/src/enterobserver.js @@ -13,7 +13,7 @@ import BubblingEventInfo from '@ckeditor/ckeditor5-engine/src/view/observer/bubb import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; /** - * Enter observer introduces the {@link module:engine/view/document~Document#event:enter} event. + * Enter observer introduces the {@link module:engine/view/document~Document#event:enter `Document#enter`} event. * * @extends module:engine/view/observer/observer~Observer */ diff --git a/packages/ckeditor5-horizontal-line/src/horizontallinecommand.js b/packages/ckeditor5-horizontal-line/src/horizontallinecommand.js index b65d5f43f52..7dee612f3c2 100644 --- a/packages/ckeditor5-horizontal-line/src/horizontallinecommand.js +++ b/packages/ckeditor5-horizontal-line/src/horizontallinecommand.js @@ -44,24 +44,7 @@ export default class HorizontalLineCommand extends Command { model.change( writer => { const horizontalElement = writer.createElement( 'horizontalLine' ); - model.insertContent( horizontalElement ); - - let nextElement = horizontalElement.nextSibling; - - // Check whether an element next to the inserted horizontal line is defined and can contain a text. - const canSetSelection = nextElement && model.schema.checkChild( nextElement, '$text' ); - - // If the element is missing, but a paragraph could be inserted next to the horizontal line, let's add it. - if ( !canSetSelection && model.schema.checkChild( horizontalElement.parent, 'paragraph' ) ) { - nextElement = writer.createElement( 'paragraph' ); - - model.insertContent( nextElement, writer.createPositionAfter( horizontalElement ) ); - } - - // Put the selection inside the element, at the beginning. - if ( nextElement ) { - writer.setSelection( nextElement, 0 ); - } + model.insertObject( horizontalElement, null, null, { setSelection: 'after' } ); } ); } } diff --git a/packages/ckeditor5-horizontal-line/src/horizontallineediting.js b/packages/ckeditor5-horizontal-line/src/horizontallineediting.js index 69099a9e0cc..c03740396b3 100644 --- a/packages/ckeditor5-horizontal-line/src/horizontallineediting.js +++ b/packages/ckeditor5-horizontal-line/src/horizontallineediting.js @@ -37,8 +37,7 @@ export default class HorizontalLineEditing extends Plugin { const conversion = editor.conversion; schema.register( 'horizontalLine', { - isObject: true, - allowWhere: '$block' + inheritAllFrom: '$blockObject' } ); conversion.for( 'dataDowncast' ).elementToElement( { diff --git a/packages/ckeditor5-horizontal-line/tests/horizontallinecommand.js b/packages/ckeditor5-horizontal-line/tests/horizontallinecommand.js index 40ed1eb901c..754cc83e495 100644 --- a/packages/ckeditor5-horizontal-line/tests/horizontallinecommand.js +++ b/packages/ckeditor5-horizontal-line/tests/horizontallinecommand.js @@ -300,5 +300,72 @@ describe( 'HorizontalLineCommand', () => { 'foo[]bar' ); } ); + + describe( 'inheriting attributes', () => { + beforeEach( () => { + const attributes = [ 'smart', 'pretty' ]; + + model.schema.extend( '$block', { + allowAttributes: attributes + } ); + + model.schema.extend( '$blockObject', { + allowAttributes: attributes + } ); + + for ( const attribute of attributes ) { + model.schema.setAttributeProperties( attribute, { + copyOnReplace: true + } ); + } + } ); + + it( 'should copy $block attributes on a horizontal line element when inserting it in $block', () => { + setModelData( model, '[]' ); + + command.execute(); + + expect( getModelData( model ) ).to.equalMarkup( + '' + + '[]' + ); + } ); + + it( 'should copy attributes from first selected element', () => { + setModelData( model, '[foobar]' ); + + command.execute(); + + expect( getModelData( model ) ).to.equalMarkup( + '' + + '[]' + ); + } ); + + it( 'should only copy $block attributes marked with copyOnReplace', () => { + setModelData( model, '[]' ); + + command.execute(); + + expect( getModelData( model ) ).to.equalMarkup( + '' + + '[]' + ); + } ); + + it( 'should copy attributes from object when it is selected during insertion', () => { + model.schema.register( 'object', { isObject: true, inheritAllFrom: '$blockObject' } ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'object', view: 'object' } ); + + setModelData( model, '[]' ); + + command.execute(); + + expect( getModelData( model ) ).to.equalMarkup( + '' + + '[]' + ); + } ); + } ); } ); } ); diff --git a/packages/ckeditor5-horizontal-line/tests/horizontallineediting.js b/packages/ckeditor5-horizontal-line/tests/horizontallineediting.js index 9ed0c85c756..4ee9ec9516a 100644 --- a/packages/ckeditor5-horizontal-line/tests/horizontallineediting.js +++ b/packages/ckeditor5-horizontal-line/tests/horizontallineediting.js @@ -46,6 +46,14 @@ describe( 'HorizontalLineEditing', () => { expect( model.schema.checkChild( [ '$root', '$block' ], 'horizontalLine' ) ).to.be.false; } ); + it( 'inherits attributes from $blockObject', () => { + model.schema.extend( '$blockObject', { + allowAttributes: 'foo' + } ); + + expect( model.schema.checkAttribute( 'horizontalLine', 'foo' ) ).to.be.true; + } ); + it( 'should register horizontalLine command', () => { expect( editor.commands.get( 'horizontalLine' ) ).to.be.instanceOf( HorizontalLineCommand ); } ); diff --git a/packages/ckeditor5-html-embed/src/htmlembedcommand.js b/packages/ckeditor5-html-embed/src/htmlembedcommand.js index 52bd3332003..e9c14e2c5fd 100644 --- a/packages/ckeditor5-html-embed/src/htmlembedcommand.js +++ b/packages/ckeditor5-html-embed/src/htmlembedcommand.js @@ -65,8 +65,7 @@ export default class HtmlEmbedCommand extends Command { } else { htmlEmbedElement = writer.createElement( 'rawHtml' ); - model.insertContent( htmlEmbedElement ); - writer.setSelection( htmlEmbedElement, 'on' ); + model.insertObject( htmlEmbedElement, null, null, { setSelection: 'on' } ); } writer.setAttribute( 'value', value, htmlEmbedElement ); diff --git a/packages/ckeditor5-html-embed/src/htmlembedediting.js b/packages/ckeditor5-html-embed/src/htmlembedediting.js index 3b5cf316ca1..f76ac520d83 100644 --- a/packages/ckeditor5-html-embed/src/htmlembedediting.js +++ b/packages/ckeditor5-html-embed/src/htmlembedediting.js @@ -73,8 +73,7 @@ export default class HtmlEmbedEditing extends Plugin { const schema = editor.model.schema; schema.register( 'rawHtml', { - isObject: true, - allowWhere: '$block', + inheritAllFrom: '$blockObject', allowAttributes: [ 'value' ] } ); diff --git a/packages/ckeditor5-html-embed/tests/htmlembedcommand.js b/packages/ckeditor5-html-embed/tests/htmlembedcommand.js index c7df9e7bdfc..a06c4004855 100644 --- a/packages/ckeditor5-html-embed/tests/htmlembedcommand.js +++ b/packages/ckeditor5-html-embed/tests/htmlembedcommand.js @@ -253,5 +253,68 @@ describe( 'HtmlEmbedCommand', () => { expect( model.document.getRoot().getChild( 0 ) ).to.equal( initialEmbedElement ); } ); } ); + + describe( 'inheriting attributes', () => { + beforeEach( () => { + const attributes = [ 'smart', 'pretty' ]; + + model.schema.extend( '$block', { + allowAttributes: attributes + } ); + + model.schema.extend( '$blockObject', { + allowAttributes: attributes + } ); + + for ( const attribute of attributes ) { + model.schema.setAttributeProperties( attribute, { + copyOnReplace: true + } ); + } + } ); + + it( 'should copy $block attributes on a html embed element when inserting it in $block', () => { + setModelData( model, '[]' ); + + command.execute( 'Foo.' ); + + expect( getModelData( model ) ).to.equalMarkup( + '[]' + ); + } ); + + it( 'should copy attributes from first selected element', () => { + setModelData( model, '[foobar]' ); + + command.execute( 'Foo.' ); + + expect( getModelData( model ) ).to.equalMarkup( + '[]' + ); + } ); + + it( 'should only copy $block attributes marked with copyOnReplace', () => { + setModelData( model, '[]' ); + + command.execute( 'Foo.' ); + + expect( getModelData( model ) ).to.equalMarkup( + '[]' + ); + } ); + + it( 'should copy attributes from object when it is selected during insertion', () => { + model.schema.register( 'object', { isObject: true, inheritAllFrom: '$blockObject' } ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'object', view: 'object' } ); + + setModelData( model, '[]' ); + + command.execute( 'Foo.' ); + + expect( getModelData( model ) ).to.equalMarkup( + '[]' + ); + } ); + } ); } ); } ); diff --git a/packages/ckeditor5-html-embed/tests/htmlembedediting.js b/packages/ckeditor5-html-embed/tests/htmlembedediting.js index f0d0f71cd02..52b1c69472a 100644 --- a/packages/ckeditor5-html-embed/tests/htmlembedediting.js +++ b/packages/ckeditor5-html-embed/tests/htmlembedediting.js @@ -60,6 +60,14 @@ describe( 'HtmlEmbedEditing', () => { expect( model.schema.checkChild( [ '$root', '$block' ], 'rawHtml' ) ).to.be.false; } ); + it( 'inherits attributes from $blockObject', () => { + model.schema.extend( '$blockObject', { + allowAttributes: 'foo' + } ); + + expect( model.schema.checkAttribute( 'rawHtml', 'foo' ) ).to.be.true; + } ); + it( 'should register the htmlEmbed command', () => { expect( editor.commands.get( 'htmlEmbed' ) ).to.be.instanceOf( HtmlEmbedCommand ); } ); diff --git a/packages/ckeditor5-html-support/docs/features/general-html-support.md b/packages/ckeditor5-html-support/docs/features/general-html-support.md index 8e22d1cf3fb..e29bec461f5 100644 --- a/packages/ckeditor5-html-support/docs/features/general-html-support.md +++ b/packages/ckeditor5-html-support/docs/features/general-html-support.md @@ -288,7 +288,7 @@ dataSchema.registerInlineElement( { model: 'myObjectInline', isObject: true, modelSchema: { - inheritAllFrom: '$htmlObjectInline' + inheritAllFrom: '$inlineObject' } } ); @@ -300,7 +300,7 @@ dataSchema.registerBlockElement( { model: 'myObjectBlock', isObject: true, modelSchema: { - inheritAllFrom: '$htmlObjectBlock' + inheritAllFrom: '$blockObject' } } ); diff --git a/packages/ckeditor5-html-support/src/converters.js b/packages/ckeditor5-html-support/src/converters.js index 22566ac637d..c10f6b132d7 100644 --- a/packages/ckeditor5-html-support/src/converters.js +++ b/packages/ckeditor5-html-support/src/converters.js @@ -55,10 +55,7 @@ export function toObjectWidgetConverter( editor, { view: viewName, isInline } ) class: 'html-object-embed', 'data-html-object-embed-label': widgetLabel }, - viewElement, - { - isAllowedInsideAttributeElement: isInline - } + viewElement ); return toWidget( viewContainer, writer, { widgetLabel } ); diff --git a/packages/ckeditor5-html-support/src/datafilter.js b/packages/ckeditor5-html-support/src/datafilter.js index 146f0b3223c..905428ae4a8 100644 --- a/packages/ckeditor5-html-support/src/datafilter.js +++ b/packages/ckeditor5-html-support/src/datafilter.js @@ -312,6 +312,7 @@ export default class DataFilter extends Plugin { schema.register( modelName, definition.modelSchema ); + /* istanbul ignore next: paranoid check */ if ( !viewName ) { return; } diff --git a/packages/ckeditor5-html-support/src/generalhtmlsupport.js b/packages/ckeditor5-html-support/src/generalhtmlsupport.js index d6f02478db5..7de10367d74 100644 --- a/packages/ckeditor5-html-support/src/generalhtmlsupport.js +++ b/packages/ckeditor5-html-support/src/generalhtmlsupport.js @@ -18,6 +18,7 @@ import MediaEmbedElementSupport from './integrations/mediaembed'; import ScriptElementSupport from './integrations/script'; import TableElementSupport from './integrations/table'; import StyleElementSupport from './integrations/style'; +import DocumentListElementSupport from './integrations/documentlist'; /** * The General HTML Support feature. @@ -48,7 +49,8 @@ export default class GeneralHtmlSupport extends Plugin { MediaEmbedElementSupport, ScriptElementSupport, TableElementSupport, - StyleElementSupport + StyleElementSupport, + DocumentListElementSupport ]; } diff --git a/packages/ckeditor5-html-support/src/integrations/documentlist.js b/packages/ckeditor5-html-support/src/integrations/documentlist.js new file mode 100644 index 00000000000..f7bd71045c8 --- /dev/null +++ b/packages/ckeditor5-html-support/src/integrations/documentlist.js @@ -0,0 +1,189 @@ +/** + * @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 html-support/integrations/documentlist + */ + +import { isEqual } from 'lodash-es'; +import { Plugin } from 'ckeditor5/src/core'; +import { setViewAttributes } from '../conversionutils.js'; + +import DataFilter from '../datafilter'; + +/** + * Provides the General HTML Support integration with {@link module:list/documentlist~DocumentList Document List} feature. + * + * @extends module:core/plugin~Plugin + */ +export default class DocumentListElementSupport extends Plugin { + /** + * @inheritDoc + */ + static get requires() { + return [ DataFilter ]; + } + + /** + * @inheritDoc + */ + init() { + const editor = this.editor; + + if ( !editor.plugins.has( 'DocumentListEditing' ) ) { + return; + } + + const schema = editor.model.schema; + const conversion = editor.conversion; + const dataFilter = editor.plugins.get( DataFilter ); + const documentListEditing = editor.plugins.get( 'DocumentListEditing' ); + + // Register downcast strategy. + // Note that this must be done before document list editing registers conversion in afterInit. + documentListEditing.registerDowncastStrategy( { + scope: 'item', + attributeName: 'htmlLiAttributes', + + setAttributeOnDowncast( writer, attributeValue, viewElement ) { + setViewAttributes( writer, attributeValue, viewElement ); + } + } ); + + documentListEditing.registerDowncastStrategy( { + scope: 'list', + attributeName: 'htmlListAttributes', + + setAttributeOnDowncast( writer, viewAttributes, viewElement ) { + setViewAttributes( writer, viewAttributes, viewElement ); + } + } ); + + dataFilter.on( 'register', ( evt, definition ) => { + if ( ![ 'ul', 'ol', 'li' ].includes( definition.view ) ) { + return; + } + + evt.stop(); + + // Do not register same converters twice. + if ( schema.checkAttribute( '$block', 'htmlListAttributes' ) ) { + return; + } + + schema.extend( '$block', { allowAttributes: [ 'htmlListAttributes', 'htmlLiAttributes' ] } ); + schema.extend( '$blockObject', { allowAttributes: [ 'htmlListAttributes', 'htmlLiAttributes' ] } ); + schema.extend( '$container', { allowAttributes: [ 'htmlListAttributes', 'htmlLiAttributes' ] } ); + + conversion.for( 'upcast' ).add( dispatcher => { + dispatcher.on( 'element:ul', viewToModelListAttributeConverter( 'htmlListAttributes', dataFilter ), { priority: 'low' } ); + dispatcher.on( 'element:ol', viewToModelListAttributeConverter( 'htmlListAttributes', dataFilter ), { priority: 'low' } ); + dispatcher.on( 'element:li', viewToModelListAttributeConverter( 'htmlLiAttributes', dataFilter ), { priority: 'low' } ); + } ); + } ); + + // Reset list attributes after indenting list items. + this.listenTo( editor.commands.get( 'indentList' ), 'afterExecute', ( evt, changedBlocks ) => { + editor.model.change( writer => { + for ( const node of changedBlocks ) { + // Just reset the attribute. + // If there is a previous indented list that this node should be merged into, + // the postfixer will unify all the attributes of both sub-lists. + writer.setAttribute( 'htmlListAttributes', {}, node ); + } + } ); + } ); + + // Make sure that all items in a single list (items at the same level & listType) have the same properties. + // Note: This is almost exact copy from DocumentListPropertiesEditing. + documentListEditing.on( 'postFixer', ( evt, { listNodes, writer } ) => { + const previousNodesByIndent = []; // Last seen nodes of lower indented lists. + + for ( const { node, previous } of listNodes ) { + // For the first list block there is nothing to compare with. + if ( !previous ) { + continue; + } + + const nodeIndent = node.getAttribute( 'listIndent' ); + const previousNodeIndent = previous.getAttribute( 'listIndent' ); + + let previousNodeInList = null; // It's like `previous` but has the same indent as current node. + + // Let's find previous node for the same indent. + // We're going to need that when we get back to previous indent. + if ( nodeIndent > previousNodeIndent ) { + previousNodesByIndent[ previousNodeIndent ] = previous; + } + // Restore the one for given indent. + else if ( nodeIndent < previousNodeIndent ) { + previousNodeInList = previousNodesByIndent[ nodeIndent ]; + previousNodesByIndent.length = nodeIndent; + } + // Same indent. + else { + previousNodeInList = previous; + } + + // This is a first item of a nested list. + if ( !previousNodeInList ) { + continue; + } + + if ( previousNodeInList.getAttribute( 'listType' ) == node.getAttribute( 'listType' ) ) { + const value = previousNodeInList.getAttribute( 'htmlListAttributes' ); + + if ( !isEqual( node.getAttribute( 'htmlListAttributes' ), value ) ) { + writer.setAttribute( 'htmlListAttributes', value, node ); + evt.return = true; + } + } + + if ( previousNodeInList.getAttribute( 'listItemId' ) == node.getAttribute( 'listItemId' ) ) { + const value = previousNodeInList.getAttribute( 'htmlLiAttributes' ); + + if ( !isEqual( node.getAttribute( 'htmlLiAttributes' ), value ) ) { + writer.setAttribute( 'htmlLiAttributes', value, node ); + evt.return = true; + } + } + } + } ); + } +} + +// View-to-model conversion helper preserving allowed attributes on {@link TODO} +// feature model element. +// +// @private +// @param {String} attributeName +// @param {module:html-support/datafilter~DataFilter} dataFilter +// @returns {Function} Returns a conversion callback. +function viewToModelListAttributeConverter( attributeName, dataFilter ) { + return ( evt, data, conversionApi ) => { + const viewElement = data.viewItem; + + if ( !data.modelRange ) { + Object.assign( data, conversionApi.convertChildren( data.viewItem, data.modelCursor ) ); + } + + const viewAttributes = dataFilter._consumeAllowedAttributes( viewElement, conversionApi ); + + for ( const item of data.modelRange.getItems( { shallow: true } ) ) { + // Apply only to list item blocks. + if ( !item.hasAttribute( 'listItemId' ) ) { + continue; + } + + // Set list attributes only on same level items, those nested deeper are already handled + // by the recursive conversion. + if ( item.hasAttribute( attributeName ) ) { + continue; + } + + conversionApi.writer.setAttribute( attributeName, viewAttributes || {}, item ); + } + }; +} diff --git a/packages/ckeditor5-html-support/src/schemadefinitions.js b/packages/ckeditor5-html-support/src/schemadefinitions.js index 397c28b75b1..bd0dc84fb99 100644 --- a/packages/ckeditor5-html-support/src/schemadefinitions.js +++ b/packages/ckeditor5-html-support/src/schemadefinitions.js @@ -108,14 +108,6 @@ export default { }, // Compatibility features - { - model: '$htmlSection', - modelSchema: { - allowChildren: '$block', - allowIn: [ '$root', '$htmlSection' ], - isBlock: true - } - }, { model: 'htmlP', view: 'p', @@ -127,14 +119,14 @@ export default { model: 'htmlBlockquote', view: 'blockquote', modelSchema: { - inheritAllFrom: '$htmlSection' + inheritAllFrom: '$container' } }, { model: 'htmlTable', view: 'table', modelSchema: { - allowIn: [ '$htmlSection', '$root' ], + allowWhere: '$block', isBlock: true } }, @@ -175,8 +167,7 @@ export default { model: 'htmlTr', view: 'tr', modelSchema: { - allowIn: [ 'htmlTable', 'htmlThead', 'htmlTbody' ], - isBlock: true + allowIn: [ 'htmlTable', 'htmlThead', 'htmlTbody' ] } }, // TODO can also include text. @@ -185,8 +176,7 @@ export default { view: 'td', modelSchema: { allowIn: 'htmlTr', - allowChildren: [ '$block', '$htmlSection' ], - isBlock: true + allowContentOf: '$container' } }, // TODO can also include text. @@ -195,8 +185,7 @@ export default { view: 'th', modelSchema: { allowIn: 'htmlTr', - allowChildren: [ '$block', '$htmlSection' ], - isBlock: true + allowContentOf: '$container' } }, // TODO can also include text. @@ -204,7 +193,7 @@ export default { model: 'htmlFigure', view: 'figure', modelSchema: { - inheritAllFrom: '$htmlSection', + inheritAllFrom: '$container', isBlock: true } }, @@ -223,7 +212,8 @@ export default { model: 'htmlAddress', view: 'address', modelSchema: { - inheritAllFrom: '$htmlSection' + inheritAllFrom: '$container', + isBlock: true } }, // TODO can also include text. @@ -231,7 +221,8 @@ export default { model: 'htmlAside', view: 'aside', modelSchema: { - inheritAllFrom: '$htmlSection' + inheritAllFrom: '$container', + isBlock: true } }, // TODO can also include text. @@ -239,7 +230,8 @@ export default { model: 'htmlMain', view: 'main', modelSchema: { - inheritAllFrom: '$htmlSection' + inheritAllFrom: '$container', + isBlock: true } }, // TODO can also include text. @@ -247,7 +239,8 @@ export default { model: 'htmlDetails', view: 'details', modelSchema: { - inheritAllFrom: '$htmlSection' + inheritAllFrom: '$container', + isBlock: true } }, { @@ -264,7 +257,7 @@ export default { view: 'div', paragraphLikeModel: 'htmlDivParagraph', modelSchema: { - inheritAllFrom: '$htmlSection' + inheritAllFrom: '$container' } }, // TODO can also include text. @@ -272,7 +265,8 @@ export default { model: 'htmlFieldset', view: 'fieldset', modelSchema: { - inheritAllFrom: '$htmlSection' + inheritAllFrom: '$container', + isBlock: true } }, // TODO can also include h1-h6. @@ -289,7 +283,8 @@ export default { model: 'htmlHeader', view: 'header', modelSchema: { - inheritAllFrom: '$htmlSection' + inheritAllFrom: '$container', + isBlock: true } }, // TODO can also include text. @@ -297,7 +292,8 @@ export default { model: 'htmlFooter', view: 'footer', modelSchema: { - inheritAllFrom: '$htmlSection' + inheritAllFrom: '$container', + isBlock: true } }, // TODO can also include text. @@ -305,7 +301,8 @@ export default { model: 'htmlForm', view: 'form', modelSchema: { - inheritAllFrom: '$htmlSection' + inheritAllFrom: '$container', + isBlock: true } }, { @@ -368,7 +365,7 @@ export default { { model: '$htmlList', modelSchema: { - allowWhere: '$htmlSection', + allowWhere: '$container', allowChildren: [ '$htmlList', 'htmlLi' ], isBlock: true } @@ -422,14 +419,16 @@ export default { model: 'htmlArticle', view: 'article', modelSchema: { - inheritAllFrom: '$htmlSection' + inheritAllFrom: '$container', + isBlock: true } }, { model: 'htmlSection', view: 'section', modelSchema: { - inheritAllFrom: '$htmlSection' + inheritAllFrom: '$container', + isBlock: true } }, // TODO can also include text. @@ -437,14 +436,15 @@ export default { model: 'htmlNav', view: 'nav', modelSchema: { - inheritAllFrom: '$htmlSection' + inheritAllFrom: '$container', + isBlock: true } }, { model: 'htmlDl', view: 'dl', modelSchema: { - allowIn: [ '$htmlSection', '$root' ], + allowWhere: '$container', allowChildren: [ 'htmlDt', 'htmlDd' ], isBlock: true } @@ -469,17 +469,8 @@ export default { model: 'htmlCenter', view: 'center', modelSchema: { - inheritAllFrom: '$htmlSection' - } - }, - // Objects - { - model: '$htmlObjectBlock', - isObject: true, - modelSchema: { - isObject: true, - isBlock: true, - allowWhere: '$block' + inheritAllFrom: '$container', + isBlock: true } } ], @@ -706,22 +697,12 @@ export default { }, // Objects - { - model: '$htmlObjectInline', - isObject: true, - modelSchema: { - isObject: true, - isInline: true, - allowWhere: '$text', - allowAttributesOf: '$text' - } - }, { model: 'htmlObject', view: 'object', isObject: true, modelSchema: { - inheritAllFrom: '$htmlObjectInline' + inheritAllFrom: '$inlineObject' } }, { @@ -729,7 +710,7 @@ export default { view: 'iframe', isObject: true, modelSchema: { - inheritAllFrom: '$htmlObjectInline' + inheritAllFrom: '$inlineObject' } }, { @@ -737,7 +718,7 @@ export default { view: 'input', isObject: true, modelSchema: { - inheritAllFrom: '$htmlObjectInline' + inheritAllFrom: '$inlineObject' } }, { @@ -745,7 +726,7 @@ export default { view: 'button', isObject: true, modelSchema: { - inheritAllFrom: '$htmlObjectInline' + inheritAllFrom: '$inlineObject' } }, { @@ -753,7 +734,7 @@ export default { view: 'textarea', isObject: true, modelSchema: { - inheritAllFrom: '$htmlObjectInline' + inheritAllFrom: '$inlineObject' } }, { @@ -761,7 +742,7 @@ export default { view: 'select', isObject: true, modelSchema: { - inheritAllFrom: '$htmlObjectInline' + inheritAllFrom: '$inlineObject' } }, { @@ -769,7 +750,7 @@ export default { view: 'video', isObject: true, modelSchema: { - inheritAllFrom: '$htmlObjectInline' + inheritAllFrom: '$inlineObject' } }, { @@ -777,7 +758,7 @@ export default { view: 'embed', isObject: true, modelSchema: { - inheritAllFrom: '$htmlObjectInline' + inheritAllFrom: '$inlineObject' } }, { @@ -785,7 +766,7 @@ export default { view: 'oembed', isObject: true, modelSchema: { - inheritAllFrom: '$htmlObjectInline' + inheritAllFrom: '$inlineObject' } }, { @@ -793,7 +774,7 @@ export default { view: 'audio', isObject: true, modelSchema: { - inheritAllFrom: '$htmlObjectInline' + inheritAllFrom: '$inlineObject' } }, { @@ -801,7 +782,7 @@ export default { view: 'img', isObject: true, modelSchema: { - inheritAllFrom: '$htmlObjectInline' + inheritAllFrom: '$inlineObject' } }, { @@ -809,7 +790,7 @@ export default { view: 'canvas', isObject: true, modelSchema: { - inheritAllFrom: '$htmlObjectInline' + inheritAllFrom: '$inlineObject' } }, // TODO it could be probably represented as non-object element, although it has graphical representation, @@ -819,7 +800,7 @@ export default { view: 'meter', isObject: true, modelSchema: { - inheritAllFrom: '$htmlObjectInline' + inheritAllFrom: '$inlineObject' } }, // TODO it could be probably represented as non-object element, although it has graphical representation, @@ -829,7 +810,7 @@ export default { view: 'progress', isObject: true, modelSchema: { - inheritAllFrom: '$htmlObjectInline' + inheritAllFrom: '$inlineObject' } }, { diff --git a/packages/ckeditor5-html-support/tests/datafilter.js b/packages/ckeditor5-html-support/tests/datafilter.js index d98e79c66ba..609edd0c88c 100644 --- a/packages/ckeditor5-html-support/tests/datafilter.js +++ b/packages/ckeditor5-html-support/tests/datafilter.js @@ -219,7 +219,7 @@ describe( 'DataFilter', () => { view: 'xyz', isObject: true, modelSchema: { - inheritAllFrom: '$htmlObjectBlock' + inheritAllFrom: '$blockObject' } } ); @@ -692,7 +692,7 @@ describe( 'DataFilter', () => { model: 'htmlXyz', allowChildren: 'not-exists', schema: { - inheritAllFrom: '$htmlSection' + inheritAllFrom: '$container' } } ); diff --git a/packages/ckeditor5-html-support/tests/integrations/documentlist.js b/packages/ckeditor5-html-support/tests/integrations/documentlist.js new file mode 100644 index 00000000000..7a352ed4ba4 --- /dev/null +++ b/packages/ckeditor5-html-support/tests/integrations/documentlist.js @@ -0,0 +1,705 @@ +/** + * @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 GeneralHtmlSupport from '../../src/generalhtmlsupport'; + +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import DocumentListEditing from '@ckeditor/ckeditor5-list/src/documentlist/documentlistediting'; + +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import stubUid from '@ckeditor/ckeditor5-list/tests/documentlist/_utils/uid'; + +import { getModelDataWithAttributes } from '../_utils/utils'; +import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; + +/* global document */ + +describe( 'DocumentListElementSupport', () => { + let editor, model, editorElement, dataFilter; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + editorElement = document.createElement( 'div' ); + document.body.appendChild( editorElement ); + + editor = await ClassicTestEditor + .create( editorElement, { + plugins: [ DocumentListEditing, Paragraph, GeneralHtmlSupport ] + } ); + model = editor.model; + dataFilter = editor.plugins.get( 'DataFilter' ); + + stubUid(); + } ); + + afterEach( () => { + editorElement.remove(); + + return editor.destroy(); + } ); + + describe( 'downcast', () => { + it( 'should downcast list attributes', () => { + setModelData( model, makeList( 'bulleted', 0, { attributes: { 'data-foo': 'foo', 'data-bar': 'bar' } }, [ + { text: '1.' }, + { text: '2.' }, + { text: '3.' } + ] ) ); + + expect( editor.getData() ).to.equalMarkup( + '
    ' + + '
  • 1.
  • ' + + '
  • 2.
  • ' + + '
  • 3.
  • ' + + '
' + ); + } ); + + it( 'should downcast list attributes (classes)', () => { + setModelData( model, makeList( 'bulleted', 0, { classes: [ 'foo', 'bar', 'baz' ] }, [ + { text: '1.' }, + { text: '2.' }, + { text: '3.' } + ] ) ); + + expect( editor.getData() ).to.equalMarkup( + '
    ' + + '
  • 1.
  • ' + + '
  • 2.
  • ' + + '
  • 3.
  • ' + + '
' + ); + } ); + + it( 'should downcast list attributes (styles)', () => { + setModelData( model, makeList( 'numbered', 0, { styles: { color: 'red', background: 'blue' } }, [ + { text: '1.' }, + { text: '2.' }, + { text: '3.' } + ] ) ); + + expect( editor.getData() ).to.equalMarkup( + '
    ' + + '
  1. 1.
  2. ' + + '
  3. 2.
  4. ' + + '
  5. 3.
  6. ' + + '
' + ); + } ); + + it( 'should downcast list item attributes', () => { + setModelData( model, makeList( 'bulleted', 0, null, [ + { text: '1.', attributes: { 'data-foo': 'foo' } }, + { text: '2.', attributes: { 'data-foo': 'bar' } }, + { text: '3.', attributes: { 'data-bar': 'baz' } } + ] ) ); + + expect( editor.getData() ).to.equalMarkup( + '
    ' + + '
  • 1.
  • ' + + '
  • 2.
  • ' + + '
  • 3.
  • ' + + '
' + ); + } ); + + it( 'should downcast list item attributes (classes)', () => { + setModelData( model, makeList( 'numbered', 0, null, [ + { text: '1.', classes: [ 'foo' ] }, + { text: '2.', classes: [ 'foo', 'bar' ] }, + { text: '3.', classes: [ 'baz' ] } + ] ) ); + + expect( editor.getData() ).to.equalMarkup( + '
    ' + + '
  1. 1.
  2. ' + + '
  3. 2.
  4. ' + + '
  5. 3.
  6. ' + + '
' + ); + } ); + + it( 'should downcast list item attributes (styles)', () => { + setModelData( model, makeList( 'numbered', 0, null, [ + { text: '1.', styles: { color: 'red' } }, + { text: '2.', styles: { color: 'green' } }, + { text: '3.', styles: { background: 'blue', color: 'yellow' } } + ] ) ); + + expect( editor.getData() ).to.equalMarkup( + '
    ' + + '
  1. 1.
  2. ' + + '
  3. 2.
  4. ' + + '
  5. 3.
  6. ' + + '
' + ); + } ); + + function makeList( listType, listIndent, listAttributes, elements ) { + const htmlListAttributes = listAttributes ? + `htmlListAttributes="${ JSON.stringify( listAttributes ).replaceAll( '"', '"' ) }" ` : + ''; + + return elements.map( ( element, index ) => { + const listItemAtributes = { + attributes: element.attributes, + classes: element.classes, + styles: element.styles + }; + + const htmlLiAttributes = ( listItemAtributes.attributes || listItemAtributes.classes || listItemAtributes.styles ) ? + `htmlLiAttributes="${ JSON.stringify( listItemAtributes ).replaceAll( '"', '"' ) }" ` : + ''; + + return ( + '` + + element.text + + '' + ); + } ).join( '' ); + } + } ); + + describe( 'upcast', () => { + it( 'should allow attributes', () => { + dataFilter.allowElement( /^(ul|ol)$/ ); + dataFilter.allowAttributes( { name: /^(ul|ol)$/, attributes: { 'data-foo': true } } ); + dataFilter.allowAttributes( { name: 'li', attributes: { 'data-bar': true } } ); + + editor.setData( '
  • Foo
  • Bar
' ); + + expect( getModelDataWithAttributes( model, { withoutSelection: true } ) ).to.deep.equal( { + data: + '' + + 'Foo' + + '' + + '' + + 'Bar' + + '', + attributes: { + 1: { + attributes: { 'data-bar': 'A' } + }, + 2: { + attributes: { 'data-foo': 'foo' } + }, + 3: { + attributes: { 'data-bar': 'B' } + }, + 4: { + attributes: { 'data-foo': 'foo' } + } + } + } ); + } ); + + it( 'should allow attributes (classes)', () => { + dataFilter.allowElement( /^(ul|ol)$/ ); + dataFilter.allowAttributes( { name: /^(ul|ol)$/, classes: 'foo' } ); + dataFilter.allowAttributes( { name: 'li', classes: /^(bar|baz)$/ } ); + + editor.setData( '
  1. Foo
  2. Bar
' ); + + expect( getModelDataWithAttributes( model, { withoutSelection: true } ) ).to.deep.equal( { + data: + '' + + 'Foo' + + '' + + '' + + 'Bar' + + '', + attributes: { + 1: { + classes: [ 'bar' ] + }, + 2: { + classes: [ 'foo' ] + }, + 3: { + classes: [ 'baz' ] + }, + 4: { + classes: [ 'foo' ] + } + } + } ); + } ); + + it( 'should allow attributes (styles)', () => { + dataFilter.allowElement( /^(ul|ol)$/ ); + dataFilter.allowAttributes( { name: /^(ul|ol)$/, styles: { background: 'blue' } } ); + dataFilter.allowAttributes( { name: 'li', styles: { color: /^(red|green)$/ } } ); + + editor.setData( '
  1. Foo
  2. Bar
' ); + + expect( getModelDataWithAttributes( model, { withoutSelection: true } ) ).to.deep.equal( { + data: + '' + + 'Foo' + + '' + + '' + + 'Bar' + + '', + attributes: { + 1: { + styles: { color: 'red' } + }, + 2: { + styles: { background: 'blue' } + }, + 3: { + styles: { color: 'green' } + }, + 4: { + styles: { background: 'blue' } + } + } + } ); + } ); + + it( 'should allow attributes (complex))', () => { + dataFilter.allowElement( /^(ul|ol)$/ ); + dataFilter.allowAttributes( { name: /^(ul|ol)$/, attributes: { 'data-foo': true } } ); + dataFilter.allowAttributes( { name: 'li', attributes: { 'data-bar': true } } ); + + editor.setData( + '
    ' + + '
  • ' + + '

    Foo

    ' + + '
      ' + + '
    1. Bar
    2. ' + + '
    ' + + '

    Baz

    ' + + '
  • ' + + '
' ); + + expect( getModelDataWithAttributes( model, { withoutSelection: true } ) ).to.deep.equal( { + data: + '' + + 'Foo' + + '' + + '' + + 'Bar' + + '' + + '' + + 'Baz' + + '', + attributes: { + 1: { + attributes: { 'data-bar': 'A' } + }, + 2: { + attributes: { 'data-foo': 'foo' } + }, + 3: { + attributes: { 'data-bar': 'B' } + }, + 4: { + attributes: { 'data-foo': 'bar' } + }, + 5: { + attributes: { 'data-bar': 'A' } + }, + 6: { + attributes: { 'data-foo': 'foo' } + } + } + } ); + } ); + + it( 'should allow attributes (non-list item content)', () => { + dataFilter.allowElement( /^(ul|ol|div)$/ ); + dataFilter.allowAttributes( { name: /^(ul|ol)$/, attributes: { 'data-foo': true } } ); + dataFilter.allowAttributes( { name: /^(li|div)$/, attributes: { 'data-bar': true } } ); + + model.schema.register( 'div', { inheritAllFrom: '$block' } ); + editor.conversion.elementToElement( { view: 'div', model: 'div' } ); + + editor.model.schema.addAttributeCheck( ( context, attributeName ) => { + if ( context.endsWith( 'div' ) && attributeName == 'listItemId' ) { + return false; + } + } ); + + editor.setData( '
  • Foo
    Bar
' ); + + expect( getModelDataWithAttributes( model, { withoutSelection: true } ) ).to.deep.equal( { + data: + '' + + 'Foo' + + '' + + '
Bar
', + attributes: { + 1: { + attributes: { 'data-bar': 'A' } + }, + 2: { + attributes: { 'data-foo': 'foo' } + }, + 3: { + attributes: { 'data-bar': 'B' } + } + } + } ); + } ); + + it( 'should disallow attributes', () => { + dataFilter.allowElement( /^(ul|ol)$/ ); + dataFilter.allowAttributes( { name: /^(ul|ol)$/, attributes: { 'data-foo': true } } ); + dataFilter.allowAttributes( { name: 'li', attributes: { 'data-bar': true } } ); + + dataFilter.disallowAttributes( { name: /^(ul|ol)$/, attributes: { 'data-foo': true } } ); + dataFilter.disallowAttributes( { name: 'li', attributes: { 'data-bar': true } } ); + + editor.setData( '
  • Foo
  • Bar
' ); + + expect( getModelDataWithAttributes( model, { withoutSelection: true } ) ).to.deep.equal( { + data: + '' + + 'Foo' + + '' + + '' + + 'Bar' + + '', + attributes: { + 1: {}, + 2: {}, + 3: {}, + 4: {} + } + } ); + } ); + + it( 'should disallow attributes (classes)', () => { + dataFilter.allowElement( /^(ul|ol)$/ ); + dataFilter.allowAttributes( { name: /^(ul|ol)$/, classes: 'foo' } ); + dataFilter.allowAttributes( { name: 'li', classes: /^(bar|baz)$/ } ); + + dataFilter.disallowAttributes( { name: /^(ul|ol)$/, classes: 'foo' } ); + dataFilter.disallowAttributes( { name: 'li', classes: /^(bar|baz)$/ } ); + + editor.setData( '
  1. Foo
  2. Bar
' ); + + expect( getModelDataWithAttributes( model, { withoutSelection: true } ) ).to.deep.equal( { + data: + '' + + 'Foo' + + '' + + '' + + 'Bar' + + '', + attributes: { + 1: {}, + 2: {}, + 3: {}, + 4: {} + } + } ); + } ); + + it( 'should disallow attributes (styles)', () => { + dataFilter.allowElement( /^(ul|ol)$/ ); + dataFilter.allowAttributes( { name: /^(ul|ol)$/, styles: { background: 'blue' } } ); + dataFilter.allowAttributes( { name: 'li', styles: { color: /^(red|green)$/ } } ); + + dataFilter.disallowAttributes( { name: /^(ul|ol)$/, styles: { background: 'blue' } } ); + dataFilter.disallowAttributes( { name: 'li', styles: { color: /^(red|green)$/ } } ); + + editor.setData( '
  1. Foo
  2. Bar
' ); + + expect( getModelDataWithAttributes( model, { withoutSelection: true } ) ).to.deep.equal( { + data: + '' + + 'Foo' + + '' + + '' + + 'Bar' + + '', + attributes: { + 1: {}, + 2: {}, + 3: {}, + 4: {} + } + } ); + } ); + } ); + + describe( 'post-fixer', () => { + describe( 'htmlListAttributes', () => { + it( 'should ensure that all items in a single list have the same `htmlListAttributes`', () => { + setModelData( model, + paragraph( '1.', '01', 0, 'numbered', { 'data-foo': 'A' } ) + + paragraph( '2.', '02', 0, 'numbered', { 'data-foo': 'A' } ) + + paragraph( '3.', '03', 0, 'numbered', { 'data-foo': 'B' } ) + + paragraph( '4.', '04', 0, 'numbered', { 'data-foo': 'A' } ) + + paragraph( '4.1.', '05', 1, 'bulleted', { 'data-foo': 'X' } ) + + paragraph( '4.2.', '06', 1, 'bulleted', { 'data-foo': 'Y' } ) + + paragraph( '4.3.', '07', 1, 'bulleted', { 'data-foo': 'X' } ) + + paragraph( '5.', '08', 0, 'numbered', { 'data-foo': 'C' } ) + + paragraph( 'A.', '09', 0, 'bulleted', { 'data-foo': 'B' } ) + + paragraph( 'B.', '10', 0, 'bulleted', { 'data-foo': 'C' } ) + + paragraph( 'C.', '11', 0, 'bulleted', { 'data-foo': 'B' } ) + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( unquote( + paragraph( '1.', '01', 0, 'numbered', { 'data-foo': 'A' } ) + + paragraph( '2.', '02', 0, 'numbered', { 'data-foo': 'A' } ) + + paragraph( '3.', '03', 0, 'numbered', { 'data-foo': 'A' } ) + + paragraph( '4.', '04', 0, 'numbered', { 'data-foo': 'A' } ) + + paragraph( '4.1.', '05', 1, 'bulleted', { 'data-foo': 'X' } ) + + paragraph( '4.2.', '06', 1, 'bulleted', { 'data-foo': 'X' } ) + + paragraph( '4.3.', '07', 1, 'bulleted', { 'data-foo': 'X' } ) + + paragraph( '5.', '08', 0, 'numbered', { 'data-foo': 'A' } ) + + paragraph( 'A.', '09', 0, 'bulleted', { 'data-foo': 'B' } ) + + paragraph( 'B.', '10', 0, 'bulleted', { 'data-foo': 'B' } ) + + paragraph( 'C.', '11', 0, 'bulleted', { 'data-foo': 'B' } ) + ) ); + } ); + + it( 'should ensure that all list items have the same `htmlListAttributes` after removing a block between them', () => { + setModelData( model, + paragraph( '1.', '01', 0, 'bulleted', { 'data-foo': 'A' } ) + + paragraph( '2.', '02', 0, 'bulleted', { 'data-foo': 'A' } ) + + 'Foo' + + paragraph( '3.', '03', 0, 'bulleted', { 'data-foo': 'B' } ) + + paragraph( '4.', '04', 0, 'bulleted', { 'data-foo': 'B' } ) + ); + + model.change( writer => { + writer.remove( model.document.getRoot().getChild( 2 ) ); + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( unquote( + paragraph( '1.', '01', 0, 'bulleted', { 'data-foo': 'A' } ) + + paragraph( '2.', '02', 0, 'bulleted', { 'data-foo': 'A' } ) + + paragraph( '3.', '03', 0, 'bulleted', { 'data-foo': 'A' } ) + + paragraph( '4.', '04', 0, 'bulleted', { 'data-foo': 'A' } ) + ) ); + } ); + + it( 'should restore `htmlListAttributes` attribute after it\'s changed in one of the following items', () => { + setModelData( model, + paragraph( '1.', '01', 0, 'bulleted', { 'data-foo': 'A' } ) + + paragraph( '2.', '02', 0, 'bulleted', { 'data-foo': 'A' } ) + + paragraph( '3.', '03', 0, 'bulleted', { 'data-foo': 'A' } ) + ); + + model.change( writer => { + writer.setAttribute( + 'htmlListAttributes', + { attributes: { 'data-foo': 'B' } }, + model.document.getRoot().getChild( 2 ) + ); + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( unquote( + paragraph( '1.', '01', 0, 'bulleted', { 'data-foo': 'A' } ) + + paragraph( '2.', '02', 0, 'bulleted', { 'data-foo': 'A' } ) + + paragraph( '3.', '03', 0, 'bulleted', { 'data-foo': 'A' } ) + ) ); + } ); + + it( 'should change `htmlListAttributes` attribute for all the following items after the first one is changed', () => { + setModelData( model, + paragraph( '1.', '01', 0, 'bulleted', { 'data-foo': 'A' } ) + + paragraph( '2.', '02', 0, 'bulleted', { 'data-foo': 'A' } ) + + paragraph( '3.', '03', 0, 'bulleted', { 'data-foo': 'A' } ) + ); + + model.change( writer => { + writer.setAttribute( + 'htmlListAttributes', + { attributes: { 'data-foo': 'B' } }, + model.document.getRoot().getChild( 0 ) + ); + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( unquote( + paragraph( '1.', '01', 0, 'bulleted', { 'data-foo': 'B' } ) + + paragraph( '2.', '02', 0, 'bulleted', { 'data-foo': 'B' } ) + + paragraph( '3.', '03', 0, 'bulleted', { 'data-foo': 'B' } ) + ) ); + } ); + } ); + + describe( 'htmlLiAttributes', () => { + it( 'should ensure that all blocks of single list item have the same `htmlLiAttributes`', () => { + setModelData( model, + liParagraph( 'A1.', '01', 0, 'numbered', { 'data-foo': 'A' } ) + + liParagraph( 'A2.', '01', 0, 'numbered', { 'data-foo': 'B' } ) + + liParagraph( 'A3.', '01', 0, 'numbered', { 'data-foo': 'A' } ) + + liParagraph( 'B1.', '02', 0, 'numbered', { 'data-foo': 'X' } ) + + liParagraph( 'B2.', '02', 0, 'numbered', { 'data-foo': 'Y' } ) + + liParagraph( 'B3.', '02', 0, 'numbered', { 'data-foo': 'Z' } ) + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( unquote( + liParagraph( 'A1.', '01', 0, 'numbered', { 'data-foo': 'A' } ) + + liParagraph( 'A2.', '01', 0, 'numbered', { 'data-foo': 'A' } ) + + liParagraph( 'A3.', '01', 0, 'numbered', { 'data-foo': 'A' } ) + + liParagraph( 'B1.', '02', 0, 'numbered', { 'data-foo': 'X' } ) + + liParagraph( 'B2.', '02', 0, 'numbered', { 'data-foo': 'X' } ) + + liParagraph( 'B3.', '02', 0, 'numbered', { 'data-foo': 'X' } ) + ) ); + } ); + + it( 'should restore `htmlLiAttributes` attribute after it\'s changed in one of the following items', () => { + setModelData( model, + liParagraph( '1.', '01', 0, 'numbered', { 'data-foo': 'A' } ) + + liParagraph( '2.', '01', 0, 'numbered', { 'data-foo': 'A' } ) + + liParagraph( '3.', '01', 0, 'numbered', { 'data-foo': 'A' } ) + ); + + model.change( writer => { + writer.setAttribute( + 'htmlLiAttributes', + { attributes: { 'data-foo': 'B' } }, + model.document.getRoot().getChild( 2 ) + ); + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( unquote( + liParagraph( '1.', '01', 0, 'numbered', { 'data-foo': 'A' } ) + + liParagraph( '2.', '01', 0, 'numbered', { 'data-foo': 'A' } ) + + liParagraph( '3.', '01', 0, 'numbered', { 'data-foo': 'A' } ) + ) ); + } ); + + it( 'should change `htmlLiAttributes` attribute for all the following items after the first one is changed', () => { + setModelData( model, + liParagraph( '1.', '01', 0, 'numbered', { 'data-foo': 'A' } ) + + liParagraph( '2.', '01', 0, 'numbered', { 'data-foo': 'A' } ) + + liParagraph( '3.', '01', 0, 'numbered', { 'data-foo': 'A' } ) + ); + + model.change( writer => { + writer.setAttribute( + 'htmlLiAttributes', + { attributes: { 'data-foo': 'B' } }, + model.document.getRoot().getChild( 0 ) + ); + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( unquote( + liParagraph( '1.', '01', 0, 'numbered', { 'data-foo': 'B' } ) + + liParagraph( '2.', '01', 0, 'numbered', { 'data-foo': 'B' } ) + + liParagraph( '3.', '01', 0, 'numbered', { 'data-foo': 'B' } ) + ) ); + } ); + + function liParagraph( text, id, indent, type, liAttributes ) { + const attrs = JSON.stringify( { attributes: liAttributes } ).replaceAll( '"', '"' ); + + return ( + `` + + text + + '' + ); + } + } ); + } ); + + describe( 'indenting lists', () => { + it( 'should reset `htmlListAttributes` attribute after indenting a single item', () => { + setModelData( model, + paragraph( '1.', '01', 0, 'numbered', { 'data-foo': 'foo' } ) + + paragraph( '1a.', '02', 1, 'bulleted', { 'data-foo': 'bar' } ) + + paragraph( '2.', '03', 0, 'numbered', { 'data-foo': 'foo' } ) + + paragraph( '3.[]', '04', 0, 'numbered', { 'data-foo': 'foo' } ) + + paragraph( '4.', '05', 0, 'numbered', { 'data-foo': 'foo' } ) + ); + + editor.execute( 'indentList' ); + + expect( getModelData( model ) ).to.equal( unquote( + paragraph( '1.', '01', 0, 'numbered', { 'data-foo': 'foo' } ) + + paragraph( '1a.', '02', 1, 'bulleted', { 'data-foo': 'bar' } ) + + paragraph( '2.', '03', 0, 'numbered', { 'data-foo': 'foo' } ) + + paragraph( '3.[]', '04', 1, 'numbered', undefined ) + + paragraph( '4.', '05', 0, 'numbered', { 'data-foo': 'foo' } ) + ) ); + } ); + + it( 'should reset `htmlListAttributes` attribute after indenting a few items', () => { + setModelData( model, + paragraph( '1.', '01', 0, 'bulleted', { 'data-foo': 'foo' } ) + + paragraph( '[2.', '02', 0, 'bulleted', { 'data-foo': 'foo' } ) + + paragraph( '3.]', '03', 0, 'bulleted', { 'data-foo': 'foo' } ) + ); + + editor.execute( 'indentList' ); + + expect( getModelData( model ) ).to.equal( unquote( + paragraph( '1.', '01', 0, 'bulleted', { 'data-foo': 'foo' } ) + + paragraph( '[2.', '02', 1, 'bulleted', undefined ) + + paragraph( '3.]', '03', 1, 'bulleted', undefined ) + ) ); + } ); + + it( 'should copy `htmlListAttributes` attribute after indenting a single item into previously nested list', () => { + setModelData( model, + paragraph( '1.', '01', 0, 'bulleted', { 'data-foo': 'foo' } ) + + paragraph( '1a.', '02', 1, 'bulleted', { 'data-foo': 'bar' } ) + + paragraph( '1b.', '03', 1, 'bulleted', { 'data-foo': 'bar' } ) + + paragraph( '2.[]', '04', 0, 'bulleted', { 'data-foo': 'foo' } ) + + paragraph( '3.', '05', 0, 'bulleted', { 'data-foo': 'foo' } ) + ); + + editor.execute( 'indentList' ); + + expect( getModelData( model ) ).to.equal( unquote( + paragraph( '1.', '01', 0, 'bulleted', { 'data-foo': 'foo' } ) + + paragraph( '1a.', '02', 1, 'bulleted', { 'data-foo': 'bar' } ) + + paragraph( '1b.', '03', 1, 'bulleted', { 'data-foo': 'bar' } ) + + paragraph( '2.[]', '04', 1, 'bulleted', { 'data-foo': 'bar' } ) + + paragraph( '3.', '05', 0, 'bulleted', { 'data-foo': 'foo' } ) + ) ); + } ); + + it( 'should copy `htmlListAttributes` attribute after indenting a few items into previously nested list', () => { + setModelData( model, + paragraph( '1.', '01', 0, 'bulleted', { 'data-foo': 'foo' } ) + + paragraph( '1a.', '02', 1, 'bulleted', { 'data-foo': 'bar' } ) + + paragraph( '1b.', '03', 1, 'bulleted', { 'data-foo': 'bar' } ) + + paragraph( '[2.', '04', 0, 'bulleted', { 'data-foo': 'foo' } ) + + paragraph( '3.]', '05', 0, 'bulleted', { 'data-foo': 'foo' } ) + + paragraph( '4.', '06', 0, 'bulleted', { 'data-foo': 'foo' } ) + ); + + editor.execute( 'indentList' ); + + expect( getModelData( model ) ).to.equal( unquote( + paragraph( '1.', '01', 0, 'bulleted', { 'data-foo': 'foo' } ) + + paragraph( '1a.', '02', 1, 'bulleted', { 'data-foo': 'bar' } ) + + paragraph( '1b.', '03', 1, 'bulleted', { 'data-foo': 'bar' } ) + + paragraph( '[2.', '04', 1, 'bulleted', { 'data-foo': 'bar' } ) + + paragraph( '3.]', '05', 1, 'bulleted', { 'data-foo': 'bar' } ) + + paragraph( '4.', '06', 0, 'bulleted', { 'data-foo': 'foo' } ) + ) ); + } ); + } ); + + function paragraph( text, id, indent, type, listAttributes ) { + const attrs = JSON.stringify( { attributes: listAttributes } ).replaceAll( '"', '"' ); + + return ( + `` + + text + + '' + ); + } + + function unquote( text ) { + return text.replaceAll( '"', '"' ); + } +} ); diff --git a/packages/ckeditor5-html-support/tests/manual/customelements.js b/packages/ckeditor5-html-support/tests/manual/customelements.js index 3885b0e76d4..ad01b5ac43e 100644 --- a/packages/ckeditor5-html-support/tests/manual/customelements.js +++ b/packages/ckeditor5-html-support/tests/manual/customelements.js @@ -54,7 +54,7 @@ class ExtendHTMLSupport extends Plugin { model: 'myObjectInline', isObject: true, modelSchema: { - inheritAllFrom: '$htmlObjectInline' + inheritAllFrom: '$inlineObject' } } ); @@ -66,7 +66,7 @@ class ExtendHTMLSupport extends Plugin { model: 'myObjectBlock', isObject: true, modelSchema: { - inheritAllFrom: '$htmlObjectBlock' + inheritAllFrom: '$blockObject' } } ); diff --git a/packages/ckeditor5-html-support/tests/manual/documentlist.html b/packages/ckeditor5-html-support/tests/manual/documentlist.html new file mode 100644 index 00000000000..5dc7186e511 --- /dev/null +++ b/packages/ckeditor5-html-support/tests/manual/documentlist.html @@ -0,0 +1,36 @@ +
+ + +
+ +
+
    +
  • +

    foo

    +

    bar

    +
      +
    1. x
    2. +
    3. y
    4. +
    +
  • +
+
+ + diff --git a/packages/ckeditor5-html-support/tests/manual/documentlist.js b/packages/ckeditor5-html-support/tests/manual/documentlist.js new file mode 100644 index 00000000000..b79d78b36c4 --- /dev/null +++ b/packages/ckeditor5-html-support/tests/manual/documentlist.js @@ -0,0 +1,67 @@ +/** + * @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 + */ + +/* globals console:false, window, document */ + +import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'; +import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold'; +import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic'; +import Strikethrough from '@ckeditor/ckeditor5-basic-styles/src/strikethrough'; +import DocumentListProperties from '@ckeditor/ckeditor5-list/src/documentlistproperties'; +import SourceEditing from '@ckeditor/ckeditor5-source-editing/src/sourceediting'; +import Indent from '@ckeditor/ckeditor5-indent/src/indent'; + +import GeneralHtmlSupport from '../../src/generalhtmlsupport'; + +ClassicEditor + .create( document.querySelector( '#editor' ), { + plugins: [ + Bold, + DocumentListProperties, + Essentials, + Italic, + Paragraph, + Strikethrough, + Indent, + SourceEditing, + GeneralHtmlSupport + ], + toolbar: [ + 'sourceEditing', '|', + 'numberedList', 'bulletedList', '|', + 'outdent', 'indent', '|', + 'outdent', 'indent', '|', + 'bold', 'italic', 'strikethrough' + ], + list: { + properties: { + styles: true, + startIndex: true, + reversed: true + } + }, + htmlSupport: { + allow: [ + { + name: /^.*$/, + styles: true, + attributes: true, + classes: true + } + ] + } + } ) + .then( editor => { + window.editor = editor; + } ) + .catch( err => { + console.error( err.stack ); + } ); + +document.getElementById( 'chbx-show-borders' ).addEventListener( 'change', () => { + document.body.classList.toggle( 'show-borders' ); +} ); diff --git a/packages/ckeditor5-html-support/tests/manual/documentlist.md b/packages/ckeditor5-html-support/tests/manual/documentlist.md new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/ckeditor5-html-support/tests/manual/ghs-custom-config.js b/packages/ckeditor5-html-support/tests/manual/ghs-custom-config.js index f56e8094885..95f4eb5a849 100644 --- a/packages/ckeditor5-html-support/tests/manual/ghs-custom-config.js +++ b/packages/ckeditor5-html-support/tests/manual/ghs-custom-config.js @@ -36,7 +36,7 @@ class ExtendHTMLSupport extends Plugin { view: 'xyz', model: 'htmlXyz', modelSchema: { - inheritAllFrom: '$htmlSection' + inheritAllFrom: '$container' } } ); diff --git a/packages/ckeditor5-html-support/tests/manual/objects.js b/packages/ckeditor5-html-support/tests/manual/objects.js index 2d6129dc2ec..82a689ee198 100644 --- a/packages/ckeditor5-html-support/tests/manual/objects.js +++ b/packages/ckeditor5-html-support/tests/manual/objects.js @@ -40,7 +40,7 @@ class ExtendHTMLSupport extends Plugin { view: 'xyz', isObject: true, modelSchema: { - inheritAllFrom: '$htmlObjectBlock' + inheritAllFrom: '$blockObject' } } ); diff --git a/packages/ckeditor5-image/src/image/converters.js b/packages/ckeditor5-image/src/image/converters.js index d7d166941e7..380b423b4bc 100644 --- a/packages/ckeditor5-image/src/image/converters.js +++ b/packages/ckeditor5-image/src/image/converters.js @@ -238,8 +238,7 @@ export function downcastSourcesAttribute( imageUtils ) { const pictureElement = viewWriter.createContainerElement( 'picture', null, data.attributeNewValue.map( sourceAttributes => { return viewWriter.createEmptyElement( 'source', sourceAttributes ); - } ), - { isAllowedInsideAttributeElement: true } + } ) ); // Collect all wrapping attribute elements. diff --git a/packages/ckeditor5-image/src/image/imageblockediting.js b/packages/ckeditor5-image/src/image/imageblockediting.js index c9ddef2774a..f70f94bb7c1 100644 --- a/packages/ckeditor5-image/src/image/imageblockediting.js +++ b/packages/ckeditor5-image/src/image/imageblockediting.js @@ -62,9 +62,7 @@ export default class ImageBlockEditing extends Plugin { // Converters 'alt' and 'srcset' are added in 'ImageEditing' plugin. schema.register( 'imageBlock', { - isObject: true, - isBlock: true, - allowWhere: '$block', + inheritAllFrom: '$blockObject', allowAttributes: [ 'alt', 'src', 'srcset' ] } ); diff --git a/packages/ckeditor5-image/src/image/imageinlineediting.js b/packages/ckeditor5-image/src/image/imageinlineediting.js index cc78a21247a..638cc23d3c6 100644 --- a/packages/ckeditor5-image/src/image/imageinlineediting.js +++ b/packages/ckeditor5-image/src/image/imageinlineediting.js @@ -61,10 +61,7 @@ export default class ImageInlineEditing extends Plugin { // Converters 'alt' and 'srcset' are added in 'ImageEditing' plugin. schema.register( 'imageInline', { - isObject: true, - isInline: true, - allowWhere: '$text', - allowAttributesOf: '$text', + inheritAllFrom: '$inlineObject', allowAttributes: [ 'alt', 'src', 'srcset' ] } ); diff --git a/packages/ckeditor5-image/src/image/utils.js b/packages/ckeditor5-image/src/image/utils.js index e430ac0c455..8b019900eb2 100644 --- a/packages/ckeditor5-image/src/image/utils.js +++ b/packages/ckeditor5-image/src/image/utils.js @@ -22,8 +22,7 @@ import { first } from 'ckeditor5/src/utils'; */ export function createInlineImageViewElement( writer ) { return writer.createContainerElement( 'span', { class: 'image-inline' }, - writer.createEmptyElement( 'img' ), - { isAllowedInsideAttributeElement: true } + writer.createEmptyElement( 'img' ) ); } diff --git a/packages/ckeditor5-image/src/imageutils.js b/packages/ckeditor5-image/src/imageutils.js index 436e0281fc9..3e07d79afdb 100644 --- a/packages/ckeditor5-image/src/imageutils.js +++ b/packages/ckeditor5-image/src/imageutils.js @@ -103,18 +103,15 @@ export default class ImageUtils extends Plugin { return model.change( writer => { const imageElement = writer.createElement( imageType, attributes ); - // If we want to insert a block image (for whatever reason) then we don't want to split text blocks. - // This applies only when we don't have the selectable specified (i.e., we insert multiple block images at once). - if ( !selectable && imageType != 'imageInline' ) { - selectable = findOptimalInsertionRange( selection, model ); - } - - model.insertContent( imageElement, selectable ); + model.insertObject( imageElement, selectable, null, { + setSelection: 'on', + // If we want to insert a block image (for whatever reason) then we don't want to split text blocks. + // This applies only when we don't have the selectable specified (i.e., we insert multiple block images at once). + findOptimalPosition: !selectable && imageType != 'imageInline' + } ); // Inserting an image might've failed due to schema regulations. if ( imageElement.parent ) { - writer.setSelection( imageElement, 'on' ); - return imageElement; } diff --git a/packages/ckeditor5-image/tests/image/imageblockediting.js b/packages/ckeditor5-image/tests/image/imageblockediting.js index 1f1d3f700af..65e605aab1a 100644 --- a/packages/ckeditor5-image/tests/image/imageblockediting.js +++ b/packages/ckeditor5-image/tests/image/imageblockediting.js @@ -64,6 +64,14 @@ describe( 'ImageBlockEditing', () => { expect( model.schema.checkChild( [ '$root', '$block' ], 'imageBlock' ) ).to.be.false; } ); + it( 'inherits attributes from $blockObject', () => { + model.schema.extend( '$blockObject', { + allowAttributes: 'foo' + } ); + + expect( model.schema.checkAttribute( 'imageBlock', 'foo' ) ).to.be.true; + } ); + it( 'should register ImageLoadObserver', () => { expect( view.getObserver( ImageLoadObserver ) ).to.be.instanceOf( ImageLoadObserver ); } ); diff --git a/packages/ckeditor5-image/tests/image/imagetypecommand.js b/packages/ckeditor5-image/tests/image/imagetypecommand.js index 0b28a06ef81..f7fb2e2a6ea 100644 --- a/packages/ckeditor5-image/tests/image/imagetypecommand.js +++ b/packages/ckeditor5-image/tests/image/imagetypecommand.js @@ -671,5 +671,50 @@ describe( 'ImageTypeCommand', () => { setModelData( model, `[foo]` ); } ); } ); + + describe( 'inheriting attributes', () => { + const imgSrc = '/foo.jpg'; + + beforeEach( () => { + const attributes = [ 'smart', 'pretty' ]; + + model.schema.extend( '$block', { + allowAttributes: attributes + } ); + + model.schema.extend( '$blockObject', { + allowAttributes: attributes + } ); + + for ( const attribute of attributes ) { + model.schema.setAttributeProperties( attribute, { + copyOnReplace: true + } ); + } + } ); + + it( 'should copy parent block attributes to image block', () => { + setModelData( model, + '' + + `[]` + + '' + ); + + blockCommand.execute(); + + expect( getModelData( model ) ).to.equal( `[]` ); + } ); + + it( 'should copy a block image attributes to an inline image\'s parent block', () => { + setModelData( model, `[]` ); + + inlineCommand.execute(); + + expect( getModelData( model ) ).to.equal( + '' + + `[]` + + '' ); + } ); + } ); } ); } ); diff --git a/packages/ckeditor5-image/tests/image/insertimagecommand.js b/packages/ckeditor5-image/tests/image/insertimagecommand.js index c57b4adde52..26f67cfe88f 100644 --- a/packages/ckeditor5-image/tests/image/insertimagecommand.js +++ b/packages/ckeditor5-image/tests/image/insertimagecommand.js @@ -322,5 +322,104 @@ describe( 'InsertImageCommand', () => { 'o' ); } ); + + describe( 'inheriting attributes', () => { + const imgSrc = '/foo.jpg'; + + beforeEach( () => { + const attributes = [ 'smart', 'pretty' ]; + + model.schema.extend( '$block', { + allowAttributes: attributes + } ); + + model.schema.extend( '$blockObject', { + allowAttributes: attributes + } ); + + for ( const attribute of attributes ) { + model.schema.setAttributeProperties( attribute, { + copyOnReplace: true + } ); + } + } ); + + it( 'should copy $block attributes on a block image element when inserting it in $block', () => { + setModelData( model, '[]' ); + + command.execute( { + source: { + src: imgSrc + } + } ); + + expect( getModelData( model ) ).to.equalMarkup( + '[]' + ); + } ); + + it( 'should not copy $block attributes on an inline image element when inserting it in $block', () => { + setModelData( model, 'Foo []' ); + + command.execute( { + source: { + src: imgSrc + } + } ); + + expect( getModelData( model ) ).to.equalMarkup( + '' + + 'Foo []' + + '' + ); + } ); + + it( 'should not copy attributes when inserting inline image (non-collapsed selection)', () => { + setModelData( model, '[foobar]' ); + + command.execute( { + source: { + src: imgSrc + } + } ); + + expect( getModelData( model ) ).to.equalMarkup( + '' + + '[]' + + '' + ); + } ); + + it( 'should only copy $block attributes marked with copyOnReplace', () => { + setModelData( model, '[]' ); + + command.execute( { + source: { + src: imgSrc + } + } ); + + expect( getModelData( model ) ).to.equalMarkup( + '[]' + ); + } ); + + it( 'should copy attributes from object when it is selected during insertion', () => { + model.schema.register( 'object', { isObject: true, inheritAllFrom: '$blockObject' } ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'object', view: 'object' } ); + + setModelData( model, '[]' ); + + command.execute( { + source: { + src: imgSrc + } + } ); + + expect( getModelData( model ) ).to.equalMarkup( + '[]' + ); + } ); + } ); } ); } ); diff --git a/packages/ckeditor5-link/src/linkediting.js b/packages/ckeditor5-link/src/linkediting.js index 4842c234992..e528c2b6681 100644 --- a/packages/ckeditor5-link/src/linkediting.js +++ b/packages/ckeditor5-link/src/linkediting.js @@ -193,8 +193,13 @@ export default class LinkEditing extends Plugin { editor.conversion.for( 'downcast' ).attributeToElement( { model: decorator.id, - view: ( manualDecoratorName, { writer } ) => { - if ( manualDecoratorName ) { + view: ( manualDecoratorValue, { writer, schema }, { item } ) => { + // Manual decorators for block links are handled e.g. in LinkImageEditing. + if ( !schema.isInline( item ) ) { + return; + } + + if ( manualDecoratorValue ) { const element = writer.createAttributeElement( 'a', decorator.attributes, { priority: 5 } ); if ( decorator.classes ) { @@ -209,7 +214,8 @@ export default class LinkEditing extends Plugin { return element; } - } } ); + } + } ); editor.conversion.for( 'upcast' ).elementToAttribute( { view: { diff --git a/packages/ckeditor5-link/src/utils/automaticdecorators.js b/packages/ckeditor5-link/src/utils/automaticdecorators.js index bb311142c98..594479bf945 100644 --- a/packages/ckeditor5-link/src/utils/automaticdecorators.js +++ b/packages/ckeditor5-link/src/utils/automaticdecorators.js @@ -66,6 +66,12 @@ export default class AutomaticDecorators { if ( !conversionApi.consumable.test( data.item, 'attribute:linkHref' ) ) { return; } + + // Automatic decorators for block links are handled e.g. in LinkImageEditing. + if ( !( data.item.is( 'selection' ) || conversionApi.schema.isInline( data.item ) ) ) { + return; + } + const viewWriter = conversionApi.writer; const viewSelection = viewWriter.document.selection; diff --git a/packages/ckeditor5-link/tests/linkui.js b/packages/ckeditor5-link/tests/linkui.js index 2f5007e2908..a13cd10d6e3 100644 --- a/packages/ckeditor5-link/tests/linkui.js +++ b/packages/ckeditor5-link/tests/linkui.js @@ -1050,9 +1050,7 @@ describe( 'LinkUI', () => { .elementToElement( { model: 'inlineWidget', view: ( modelItem, { writer } ) => toWidget( - writer.createContainerElement( 'inlineWidget', {}, { - isAllowedInsideAttributeElement: true - } ), + writer.createContainerElement( 'inlineWidget' ), writer, { label: 'inline widget' } ) @@ -1118,9 +1116,7 @@ describe( 'LinkUI', () => { editor.conversion.for( 'downcast' ).elementToStructure( { model: 'inlineWidget', view: ( modelItem, { writer } ) => { - const spanView = writer.createContainerElement( 'span', {}, { - isAllowedInsideAttributeElement: true - } ); + const spanView = writer.createContainerElement( 'span' ); const innerText = writer.createText( '{' + modelItem.name + '}' ); writer.insert( writer.createPositionAt( spanView, 0 ), innerText ); diff --git a/packages/ckeditor5-list/package.json b/packages/ckeditor5-list/package.json index 448c54e9549..afb1037b393 100644 --- a/packages/ckeditor5-list/package.json +++ b/packages/ckeditor5-list/package.json @@ -16,11 +16,15 @@ "@ckeditor/ckeditor5-ui": "^33.0.0" }, "devDependencies": { + "@ckeditor/ckeditor5-alignment": "^33.0.0", "@ckeditor/ckeditor5-basic-styles": "^33.0.0", "@ckeditor/ckeditor5-block-quote": "^33.0.0", "@ckeditor/ckeditor5-clipboard": "^33.0.0", + "@ckeditor/ckeditor5-cloud-services": "^33.0.0", + "@ckeditor/ckeditor5-code-block": "^33.0.0", "@ckeditor/ckeditor5-core": "^33.0.0", "@ckeditor/ckeditor5-dev-utils": "^30.0.0", + "@ckeditor/ckeditor5-easy-image": "^33.0.0", "@ckeditor/ckeditor5-editor-classic": "^33.0.0", "@ckeditor/ckeditor5-engine": "^33.0.0", "@ckeditor/ckeditor5-enter": "^33.0.0", @@ -28,15 +32,23 @@ "@ckeditor/ckeditor5-font": "^33.0.0", "@ckeditor/ckeditor5-heading": "^33.0.0", "@ckeditor/ckeditor5-highlight": "^33.0.0", + "@ckeditor/ckeditor5-horizontal-line": "^33.0.0", + "@ckeditor/ckeditor5-html-embed": "^33.0.0", + "@ckeditor/ckeditor5-html-support": "^33.0.0", + "@ckeditor/ckeditor5-image": "^33.0.0", "@ckeditor/ckeditor5-indent": "^33.0.0", "@ckeditor/ckeditor5-link": "^33.0.0", + "@ckeditor/ckeditor5-media-embed": "^33.0.0", + "@ckeditor/ckeditor5-page-break": "^33.0.0", "@ckeditor/ckeditor5-paragraph": "^33.0.0", "@ckeditor/ckeditor5-remove-format": "^33.0.0", + "@ckeditor/ckeditor5-source-editing": "^33.0.0", "@ckeditor/ckeditor5-table": "^33.0.0", "@ckeditor/ckeditor5-theme-lark": "^33.0.0", "@ckeditor/ckeditor5-typing": "^33.0.0", "@ckeditor/ckeditor5-undo": "^33.0.0", "@ckeditor/ckeditor5-utils": "^33.0.0", + "@ckeditor/ckeditor5-widget": "^33.0.0", "webpack": "^5.58.1", "webpack-cli": "^4.9.0" }, diff --git a/packages/ckeditor5-list/src/documentlist.js b/packages/ckeditor5-list/src/documentlist.js new file mode 100644 index 00000000000..d4102877402 --- /dev/null +++ b/packages/ckeditor5-list/src/documentlist.js @@ -0,0 +1,36 @@ +/** + * @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 + */ + +import { Plugin } from 'ckeditor5/src/core'; +import DocumentListEditing from './documentlist/documentlistediting'; +import ListUI from './list/listui'; + +/** + * The document list feature. + * + * This is a "glue" plugin that loads the {@link module:list/documentlist/documentlistediting~DocumentListEditing document list + * editing feature} and {@link module:list/list/listui~ListUI list UI feature}. + * + * @extends module:core/plugin~Plugin + */ +export default class DocumentList extends Plugin { + /** + * @inheritDoc + */ + static get requires() { + return [ DocumentListEditing, ListUI ]; + } + + /** + * @inheritDoc + */ + static get pluginName() { + return 'DocumentList'; + } +} diff --git a/packages/ckeditor5-list/src/documentlist/converters.js b/packages/ckeditor5-list/src/documentlist/converters.js new file mode 100644 index 00000000000..c6a717052de --- /dev/null +++ b/packages/ckeditor5-list/src/documentlist/converters.js @@ -0,0 +1,470 @@ +/** + * @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/converters + */ + +import { + getAllListItemBlocks, + getListItemBlocks, + isListItemBlock, + ListItemUid +} from './utils/model'; +import { + createListElement, + createListItemElement, + getIndent, + isListView, + isListItemView +} from './utils/view'; +import ListWalker, { iterateSiblingListBlocks } from './utils/listwalker'; +import { findAndAddListHeadToMap } from './utils/postfixers'; + +import { UpcastWriter } from 'ckeditor5/src/engine'; + +/** + * Returns the upcast converter for list items. It's supposed to work after the block converters (content inside list items) is converted. + * + * @protected + * @returns {Function} + */ +export function listItemUpcastConverter() { + return ( evt, data, conversionApi ) => { + const { writer, schema } = conversionApi; + + if ( !data.modelRange ) { + return; + } + + const items = Array.from( data.modelRange.getItems( { shallow: true } ) ) + .filter( item => schema.checkAttribute( item, 'listItemId' ) ); + + if ( !items.length ) { + return; + } + + const attributes = { + listItemId: ListItemUid.next(), + listIndent: getIndent( data.viewItem ), + listType: data.viewItem.parent && data.viewItem.parent.name == 'ol' ? 'numbered' : 'bulleted' + }; + + for ( const item of items ) { + // Set list attributes only on same level items, those nested deeper are already handled by the recursive conversion. + if ( !isListItemBlock( item ) ) { + writer.setAttributes( attributes, item ); + } + } + + if ( items.length > 1 ) { + // Make sure that list item that contain only nested list will preserve paragraph for itself: + //
    + //
  • + //

    <-- this one must be kept + //
      + //
    • + //
    + //
  • + //
+ if ( items[ 1 ].getAttribute( 'listItemId' ) != attributes.listItemId ) { + conversionApi.keepEmptyElement( items[ 0 ] ); + } + } + }; +} + +/** + * Returns the upcast converter for the `
    ` and `
      ` view elements that cleans the input view of garbage. + * This is mostly to clean whitespaces from between the `
    1. ` view elements inside the view list element, however, also + * incorrect data can be cleared if the view was incorrect. + * + * @protected + * @returns {Function} + */ +export function listUpcastCleanList() { + return ( evt, data, conversionApi ) => { + if ( !conversionApi.consumable.test( data.viewItem, { name: true } ) ) { + return; + } + + const viewWriter = new UpcastWriter( data.viewItem.document ); + + for ( const child of Array.from( data.viewItem.getChildren() ) ) { + if ( !isListItemView( child ) && !isListView( child ) ) { + viewWriter.remove( child ); + } + } + }; +} + +/** + * Returns a model document change:data event listener that triggers conversion of related items if needed. + * + * @protected + * @param {module:engine/model/model~Model} model The editor model. + * @param {module:engine/controller/editingcontroller~EditingController} editing The editing controller. + * @param {Array.} attributeNames The list of all model list attributes (including registered strategies). + * @param {module:list/documentlist/documentlistediting~DocumentListEditing} documentListEditing The document list editing plugin. + * @return {Function} + */ +export function reconvertItemsOnDataChange( model, editing, attributeNames, documentListEditing ) { + return () => { + const changes = model.document.differ.getChanges(); + const itemsToRefresh = []; + const itemToListHead = new Map(); + const changedItems = new Set(); + + for ( const entry of changes ) { + if ( entry.type == 'insert' && entry.name != '$text' ) { + findAndAddListHeadToMap( entry.position, itemToListHead ); + + // Insert of a non-list item. + if ( !entry.attributes.has( 'listItemId' ) ) { + findAndAddListHeadToMap( entry.position.getShiftedBy( entry.length ), itemToListHead ); + } else { + changedItems.add( entry.position.nodeAfter ); + } + } + // Removed list item. + else if ( entry.type == 'remove' && entry.attributes.has( 'listItemId' ) ) { + findAndAddListHeadToMap( entry.position, itemToListHead ); + } + // Changed list attribute. + else if ( entry.type == 'attribute' ) { + const item = entry.range.start.nodeAfter; + + if ( attributeNames.includes( entry.attributeKey ) ) { + findAndAddListHeadToMap( entry.range.start, itemToListHead ); + + if ( entry.attributeNewValue === null ) { + findAndAddListHeadToMap( entry.range.start.getShiftedBy( 1 ), itemToListHead ); + + // Check if paragraph should be converted from bogus to plain paragraph. + if ( doesItemParagraphRequiresRefresh( item ) ) { + itemsToRefresh.push( item ); + } + } else { + changedItems.add( item ); + } + } else if ( isListItemBlock( item ) ) { + // Some other attribute was changed on the list item, + // check if paragraph does not need to be converted to bogus or back. + if ( doesItemParagraphRequiresRefresh( item ) ) { + itemsToRefresh.push( item ); + } + } + } + } + + for ( const listHead of itemToListHead.values() ) { + itemsToRefresh.push( ...collectListItemsToRefresh( listHead, changedItems ) ); + } + + for ( const item of new Set( itemsToRefresh ) ) { + editing.reconvertItem( item ); + } + }; + + function collectListItemsToRefresh( listHead, changedItems ) { + const itemsToRefresh = []; + const visited = new Set(); + const stack = []; + + for ( const { node, previous } of iterateSiblingListBlocks( listHead, 'forward' ) ) { + if ( visited.has( node ) ) { + continue; + } + + const itemIndent = node.getAttribute( 'listIndent' ); + + // Current node is at the lower indent so trim the stack. + if ( previous && itemIndent < previous.getAttribute( 'listIndent' ) ) { + stack.length = itemIndent + 1; + } + + // Update the stack for the current indent level. + stack[ itemIndent ] = Object.fromEntries( + Array.from( node.getAttributes() ) + .filter( ( [ key ] ) => attributeNames.includes( key ) ) + ); + + // Find all blocks of the current node. + const blocks = getListItemBlocks( node, { direction: 'forward' } ); + + for ( const block of blocks ) { + visited.add( block ); + + // Check if bogus vs plain paragraph needs refresh. + if ( doesItemParagraphRequiresRefresh( block, blocks ) ) { + itemsToRefresh.push( block ); + } + // Check if wrapping with UL, OL, LIs needs refresh. + else if ( doesItemWrappingRequiresRefresh( block, stack, changedItems ) ) { + itemsToRefresh.push( block ); + } + } + } + + return itemsToRefresh; + } + + function doesItemParagraphRequiresRefresh( item, blocks ) { + if ( !item.is( 'element', 'paragraph' ) ) { + return false; + } + + const viewElement = editing.mapper.toViewElement( item ); + + if ( !viewElement ) { + return false; + } + + const useBogus = shouldUseBogusParagraph( item, attributeNames, blocks ); + + if ( useBogus && viewElement.is( 'element', 'p' ) ) { + return true; + } else if ( !useBogus && viewElement.is( 'element', 'span' ) ) { + return true; + } + + return false; + } + + function doesItemWrappingRequiresRefresh( item, stack, changedItems ) { + // Items directly affected by some "change" don't need a refresh, they will be converted by their own changes. + if ( changedItems.has( item ) ) { + return false; + } + + const viewElement = editing.mapper.toViewElement( item ); + let indent = stack.length - 1; + + // Traverse down the stack to the root to verify if all ULs, OLs, and LIs are as expected. + for ( + let element = viewElement.parent; + !element.is( 'editableElement' ); + element = element.parent + ) { + const isListItemElement = isListItemView( element ); + const isListElement = isListView( element ); + + if ( !isListElement && !isListItemElement ) { + continue; + } + + /** + * Event fired on changes detected on the model list element to verify if the view representation of a list element + * is representing those attributes. + * + * It allows triggering a re-wrapping of a list item. + * + * **Note**: For convenience this event is namespaced and could be captured as `checkAttributes:list` or `checkAttributes:item`. + * + * @protected + * @event module:list/documentlist/documentlistediting~DocumentListEditing#event:checkAttributes + * @param {module:engine/view/element~Element} viewElement + * @param {Object} modelAttributes + */ + const eventName = `checkAttributes:${ isListItemElement ? 'item' : 'list' }`; + const needsRefresh = documentListEditing.fire( eventName, { + viewElement: element, + modelAttributes: stack[ indent ] + } ); + + if ( needsRefresh ) { + break; + } + + if ( isListElement ) { + indent--; + + // Don't need to iterate further if we already know that the item is wrapped appropriately. + if ( indent < 0 ) { + return false; + } + } + } + + return true; + } +} + +/** + * Returns the list item downcast converter. + * + * @protected + * @param {Array.} attributeNames A list of attribute names that should be converted if are set. + * @param {Array.} strategies The strategies. + * @param {module:engine/model/model~Model} model The model. + * @returns {Function} + */ +export function listItemDowncastConverter( attributeNames, strategies, model ) { + const consumer = createAttributesConsumer( attributeNames ); + + return ( evt, data, conversionApi ) => { + const { writer, mapper, consumable } = conversionApi; + + const listItem = data.item; + + if ( !attributeNames.includes( data.attributeKey ) ) { + return; + } + + // Test if attributes on the converted items are not consumed. + if ( !consumer( listItem, consumable ) ) { + return; + } + + // Use positions mapping instead of mapper.toViewElement( listItem ) to find outermost view element. + // This is for cases when mapping is using inner view element like in the code blocks (pre > code). + const viewElement = findMappedViewElement( listItem, mapper, model ); + + // Unwrap element from current list wrappers. + unwrapListItemBlock( viewElement, writer ); + + // Then wrap them with the new list wrappers. + wrapListItemBlock( listItem, writer.createRangeOn( viewElement ), strategies, writer ); + }; +} + +/** + * Returns the bogus paragraph view element creator. A bogus paragraph is used if a list item contains only a single block or nested list. + * + * @protected + * @param {Array.} attributeNames The list of all model list attributes (including registered strategies). + * @param {Object} [options] + * @param {Boolean} [options.dataPipeline=false] + * @returns {Function} + */ +export function bogusParagraphCreator( attributeNames, { dataPipeline } = {} ) { + return ( modelElement, { writer } ) => { + // Convert only if a bogus paragraph should be used. + if ( !shouldUseBogusParagraph( modelElement, attributeNames ) ) { + return; + } + + const viewElement = writer.createContainerElement( 'span', { class: 'ck-list-bogus-paragraph' } ); + + if ( dataPipeline ) { + writer.setCustomProperty( 'dataPipeline:transparentRendering', true, viewElement ); + } + + return viewElement; + }; +} + +/** + * Helper for mapping mode to view elements. It's using positions mapping instead of mapper.toViewElement( element ) + * to find outermost view element. This is for cases when mapping is using inner view element like in the code blocks (pre > code). + * + * @protected + * @param {module:engine/model/element~Element} element The model element. + * @param {module:engine/conversion/mapper~Mapper} mapper The mapper instance. + * @param {module:engine/model/model~Model} model The model. + * @returns {module:engine/view/element~Element|null} + */ +export function findMappedViewElement( element, mapper, model ) { + const modelRange = model.createRangeOn( element ); + const viewRange = mapper.toViewRange( modelRange ).getTrimmed(); + + return viewRange.getContainedElement(); +} + +// Unwraps all ol, ul, and li attribute elements that are wrapping the provided view element. +function unwrapListItemBlock( viewElement, viewWriter ) { + let attributeElement = viewElement.parent; + + while ( attributeElement.is( 'attributeElement' ) && [ 'ul', 'ol', 'li' ].includes( attributeElement.name ) ) { + const parentElement = attributeElement.parent; + + viewWriter.unwrap( viewWriter.createRangeOn( viewElement ), attributeElement ); + + attributeElement = parentElement; + } +} + +// Wraps the given list item with appropriate attribute elements for ul, ol, and li. +function wrapListItemBlock( listItem, viewRange, strategies, writer ) { + if ( !listItem.hasAttribute( 'listIndent' ) ) { + return; + } + + const listItemIndent = listItem.getAttribute( 'listIndent' ); + let currentListItem = listItem; + + for ( let indent = listItemIndent; indent >= 0; indent-- ) { + const listItemViewElement = createListItemElement( writer, indent, currentListItem.getAttribute( 'listItemId' ) ); + const listViewElement = createListElement( writer, indent, currentListItem.getAttribute( 'listType' ) ); + + for ( const strategy of strategies ) { + if ( currentListItem.hasAttribute( strategy.attributeName ) ) { + strategy.setAttributeOnDowncast( + writer, + currentListItem.getAttribute( strategy.attributeName ), + strategy.scope == 'list' ? listViewElement : listItemViewElement + ); + } + } + + viewRange = writer.wrap( viewRange, listItemViewElement ); + viewRange = writer.wrap( viewRange, listViewElement ); + + if ( indent == 0 ) { + break; + } + + currentListItem = ListWalker.first( currentListItem, { lowerIndent: true } ); + + // There is no list item with lower indent, this means this is a document fragment containing + // only a part of nested list (like copy to clipboard) so we don't need to try to wrap it further. + if ( !currentListItem ) { + break; + } + } +} + +// Returns the function that is responsible for consuming attributes that are set on the model node. +function createAttributesConsumer( attributeNames ) { + return ( node, consumable ) => { + const events = []; + + // Collect all set attributes that are triggering conversion. + for ( const attributeName of attributeNames ) { + if ( node.hasAttribute( attributeName ) ) { + events.push( `attribute:${ attributeName }` ); + } + } + + if ( !events.every( event => consumable.test( node, event ) !== false ) ) { + return false; + } + + events.forEach( event => consumable.consume( node, event ) ); + + return true; + }; +} + +// Whether the given item should be rendered as a bogus paragraph. +function shouldUseBogusParagraph( item, attributeNames, blocks = getAllListItemBlocks( item ) ) { + if ( !isListItemBlock( item ) ) { + return false; + } + + for ( const attributeKey of item.getAttributeKeys() ) { + // Ignore selection attributes stored on block elements. + if ( attributeKey.startsWith( 'selection:' ) ) { + continue; + } + + // Don't use bogus paragraph if there are attributes from other features. + if ( !attributeNames.includes( attributeKey ) ) { + return false; + } + } + + return blocks.length < 2; +} diff --git a/packages/ckeditor5-list/src/documentlist/documentlistcommand.js b/packages/ckeditor5-list/src/documentlist/documentlistcommand.js new file mode 100644 index 00000000000..26c4a791cc4 --- /dev/null +++ b/packages/ckeditor5-list/src/documentlist/documentlistcommand.js @@ -0,0 +1,216 @@ +/** + * @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/documentlistcommand + */ + +import { Command } from 'ckeditor5/src/core'; +import { + splitListItemBefore, + expandListBlocksToCompleteItems, + getListItemBlocks, + getListItems, + removeListAttributes, + outdentFollowingItems, + ListItemUid, + sortBlocks, + getSelectedBlockObject, + isListItemBlock +} from './utils/model'; + +/** + * The list command. It is used by the {@link module:list/documentlist~DocumentList document list feature}. + * + * @extends module:core/command~Command + */ +export default class DocumentListCommand extends Command { + /** + * Creates an instance of the command. + * + * @param {module:core/editor/editor~Editor} editor The editor instance. + * @param {'numbered'|'bulleted'} type List type that will be handled by this command. + */ + constructor( editor, type ) { + super( editor ); + + /** + * The type of the list created by the command. + * + * @readonly + * @member {'numbered'|'bulleted'} + */ + this.type = type; + + /** + * A flag indicating whether the command is active, which means that the selection starts in a list of the same type. + * + * @observable + * @readonly + * @member {Boolean} #value + */ + } + + /** + * @inheritDoc + */ + refresh() { + this.value = this._getValue(); + this.isEnabled = this._checkEnabled(); + } + + /** + * Executes the list command. + * + * @fires execute + * @fires afterExecute + * @param {Object} [options] Command options. + * @param {Boolean} [options.forceValue] If set, it will force the command behavior. If `true`, the command will try to convert the + * selected items and potentially the neighbor elements to the proper list items. If set to `false` it will convert selected elements + * to paragraphs. If not set, the command will toggle selected elements to list items or paragraphs, depending on the selection. + */ + execute( options = {} ) { + const model = this.editor.model; + const document = model.document; + const selectedBlockObject = getSelectedBlockObject( model ); + + const blocks = Array.from( document.selection.getSelectedBlocks() ) + .filter( block => model.schema.checkAttribute( block, 'listType' ) ); + + // Whether we are turning off some items. + const turnOff = options.forceValue !== undefined ? !options.forceValue : this.value; + + model.change( writer => { + if ( turnOff ) { + const lastBlock = blocks[ blocks.length - 1 ]; + + // Split the first block from the list item. + const itemBlocks = getListItemBlocks( lastBlock, { direction: 'forward' } ); + const changedBlocks = []; + + if ( itemBlocks.length > 1 ) { + changedBlocks.push( ...splitListItemBefore( itemBlocks[ 1 ], writer ) ); + } + + // Convert list blocks to plain blocks. + changedBlocks.push( ...removeListAttributes( blocks, writer ) ); + + // Outdent items following the selected list item. + changedBlocks.push( ...outdentFollowingItems( lastBlock, writer ) ); + + this._fireAfterExecute( changedBlocks ); + } + // Turning on the list items for a collapsed selection inside a list item. + else if ( ( selectedBlockObject || document.selection.isCollapsed ) && isListItemBlock( blocks[ 0 ] ) ) { + const changedBlocks = getListItems( selectedBlockObject || blocks[ 0 ] ); + + for ( const block of changedBlocks ) { + writer.setAttribute( 'listType', this.type, block ); + } + + this._fireAfterExecute( changedBlocks ); + } + // Turning on the list items for a non-collapsed selection. + else { + const changedBlocks = []; + + for ( const block of blocks ) { + // Promote the given block to the list item. + if ( !block.hasAttribute( 'listType' ) ) { + writer.setAttributes( { + listIndent: 0, + listItemId: ListItemUid.next(), + listType: this.type + }, block ); + + changedBlocks.push( block ); + } + // Change the type of list item. + else { + for ( const node of expandListBlocksToCompleteItems( block, { withNested: false } ) ) { + if ( node.getAttribute( 'listType' ) != this.type ) { + writer.setAttribute( 'listType', this.type, node ); + changedBlocks.push( node ); + } + } + } + } + + 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 ~DocumentListCommand#execute} method, + * for example adjusting attributes of changed list items. + * + * @protected + * @event afterExecute + */ + this.fire( 'afterExecute', sortBlocks( new Set( changedBlocks ) ) ); + } + + /** + * Checks the command's {@link #value}. + * + * @private + * @returns {Boolean} The current value. + */ + _getValue() { + const selection = this.editor.model.document.selection; + const blocks = Array.from( selection.getSelectedBlocks() ); + + if ( !blocks.length ) { + return false; + } + + for ( const block of blocks ) { + if ( block.getAttribute( 'listType' ) != this.type ) { + return false; + } + } + + return true; + } + + /** + * Checks whether the command can be enabled in the current context. + * + * @private + * @returns {Boolean} Whether the command should be enabled. + */ + _checkEnabled() { + const selection = this.editor.model.document.selection; + const schema = this.editor.model.schema; + const blocks = Array.from( selection.getSelectedBlocks() ); + + if ( !blocks.length ) { + return false; + } + + // If command value is true it means that we are in list item, so the command should be enabled. + if ( this.value ) { + return true; + } + + for ( const block of blocks ) { + if ( schema.checkAttribute( block, 'listType' ) ) { + return true; + } + } + + return false; + } +} diff --git a/packages/ckeditor5-list/src/documentlist/documentlistediting.js b/packages/ckeditor5-list/src/documentlist/documentlistediting.js new file mode 100644 index 00000000000..a18682a4c27 --- /dev/null +++ b/packages/ckeditor5-list/src/documentlist/documentlistediting.js @@ -0,0 +1,664 @@ +/** + * @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/documentlistediting + */ + +import { Plugin } from 'ckeditor5/src/core'; +import { Enter } from 'ckeditor5/src/enter'; +import { Delete } from 'ckeditor5/src/typing'; +import { CKEditorError } from 'ckeditor5/src/utils'; + +import DocumentListIndentCommand from './documentlistindentcommand'; +import DocumentListCommand from './documentlistcommand'; +import DocumentListMergeCommand from './documentlistmergecommand'; +import DocumentListSplitCommand from './documentlistsplitcommand'; +import { + bogusParagraphCreator, + listItemDowncastConverter, + listItemUpcastConverter, + listUpcastCleanList, + reconvertItemsOnDataChange +} from './converters'; +import { + findAndAddListHeadToMap, + fixListIndents, + fixListItemIds +} from './utils/postfixers'; +import { + getAllListItemBlocks, + isFirstBlockOfListItem, + isLastBlockOfListItem, + isSingleListItem, + getSelectedBlockObject, + isListItemBlock +} from './utils/model'; +import { + getViewElementIdForListType, + getViewElementNameForListType +} from './utils/view'; +import ListWalker, { + iterateSiblingListBlocks, + ListBlocksIterable +} from './utils/listwalker'; + +import '../../theme/documentlist.css'; + +/** + * A list of base list model attributes. + * + * @private + */ +const LIST_BASE_ATTRIBUTES = [ 'listType', 'listIndent', 'listItemId' ]; + +/** + * The editing part of the document-list feature. It handles creating, editing and removing lists and list items. + * + * @extends module:core/plugin~Plugin + */ +export default class DocumentListEditing extends Plugin { + /** + * @inheritDoc + */ + static get pluginName() { + return 'DocumentListEditing'; + } + + /** + * @inheritDoc + */ + static get requires() { + return [ Enter, Delete ]; + } + + /** + * @inheritDoc + */ + init() { + /** + * The list of registered downcast strategies. + * + * @private + * @type {Array.} + */ + this._downcastStrategies = []; + + const editor = this.editor; + const model = editor.model; + + if ( editor.plugins.has( 'ListEditing' ) ) { + /** + * The `DocumentList` feature can not be loaded together with the `List` plugin. + * + * @error document-list-feature-conflict + * @param {String} conflictPlugin Name of the plugin. + */ + throw new CKEditorError( 'document-list-feature-conflict', this, { conflictPlugin: 'ListEditing' } ); + } + + model.schema.extend( '$container', { allowAttributes: LIST_BASE_ATTRIBUTES } ); + model.schema.extend( '$block', { allowAttributes: LIST_BASE_ATTRIBUTES } ); + model.schema.extend( '$blockObject', { allowAttributes: LIST_BASE_ATTRIBUTES } ); + + for ( const attribute of LIST_BASE_ATTRIBUTES ) { + model.schema.setAttributeProperties( attribute, { + copyOnReplace: true + } ); + } + + model.on( 'insertContent', createModelIndentPasteFixer( model ), { priority: 'high' } ); + + // Register commands. + editor.commands.add( 'numberedList', new DocumentListCommand( editor, 'numbered' ) ); + editor.commands.add( 'bulletedList', new DocumentListCommand( editor, 'bulleted' ) ); + + 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._setupDeleteIntegration(); + this._setupEnterIntegration(); + this._setupTabIntegration(); + } + + /** + * @inheritDoc + */ + afterInit() { + const editor = this.editor; + const commands = editor.commands; + const indent = commands.get( 'indent' ); + const outdent = commands.get( 'outdent' ); + + if ( indent ) { + // Priority is high due to integration with `IndentBlock` plugin. We want to indent list first and if it's not possible + // user can indent content with `IndentBlock` plugin. + indent.registerChildCommand( commands.get( 'indentList' ), { priority: 'high' } ); + } + + if ( outdent ) { + // Priority is lowest due to integration with `IndentBlock` and `IndentCode` plugins. + // First we want to allow user to outdent all indendations from other features then he can oudent list item. + outdent.registerChildCommand( commands.get( 'outdentList' ), { priority: 'lowest' } ); + } + + // Register conversion and model post-fixer after other plugins had a chance to register their attribute strategies. + this._setupModelPostFixing(); + this._setupConversion(); + } + + /** + * Registers a downcast strategy. + * + * **Note**: Strategies must be registered in the `Plugin#init()` phase so that it can be applied + * in the `DocumentListEditing#afterInit()`. + * + * @param {module:list/documentlist/documentlistediting~DowncastStrategy} strategy The downcast strategy to register. + */ + registerDowncastStrategy( strategy ) { + this._downcastStrategies.push( strategy ); + } + + /** + * Returns list of model attribute names that should affect downcast conversion. + * + * @private + */ + _getListAttributeNames() { + return [ + ...LIST_BASE_ATTRIBUTES, + ...this._downcastStrategies.map( strategy => strategy.attributeName ) + ]; + } + + /** + * 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; + + // Let the Widget plugin take care of block widgets while deleting (https://github.com/ckeditor/ckeditor5/issues/11346). + if ( getSelectedBlockObject( editor.model ) ) { + return; + } + + editor.model.change( () => { + const firstPosition = selection.getFirstPosition(); + + if ( selection.isCollapsed && data.direction == 'backward' ) { + if ( !firstPosition.isAtStart ) { + return; + } + + const positionParent = firstPosition.parent; + + if ( !isListItemBlock( positionParent ) ) { + return; + } + + const previousBlock = ListWalker.first( positionParent, { + sameAttributes: 'listType', + sameIndent: 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; + const positionParent = doc.selection.getFirstPosition().parent; + + if ( doc.selection.isCollapsed && isListItemBlock( positionParent ) && positionParent.isEmpty ) { + const isFirstBlock = isFirstBlockOfListItem( positionParent ); + const isLastBlock = isLastBlockOfListItem( positionParent ); + + // * a → * a + // * [] → [] + if ( isFirstBlock && isLastBlock ) { + editor.execute( 'outdentList' ); + + data.preventDefault(); + evt.stop(); + } + // * [] → * [] + // a → * a + else if ( isFirstBlock && !isLastBlock ) { + editor.execute( 'splitListItemAfter' ); + + data.preventDefault(); + evt.stop(); + } + // * a → * a + // [] → * [] + else if ( isLastBlock ) { + editor.execute( 'splitListItemBefore' ); + + 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( 'splitListItemBefore' ); + + // 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 ) { + splitCommand.execute(); + } + } ); + } + + /** + * Attaches a listener to the {@link module:engine/view/document~Document#event:tab} event and handles tab key and tab+shift keys + * presses in document lists. + * + * @private + */ + _setupTabIntegration() { + const editor = this.editor; + + this.listenTo( editor.editing.view.document, 'tab', ( evt, data ) => { + const commandName = data.shiftKey ? 'outdentList' : 'indentList'; + const command = this.editor.commands.get( commandName ); + + if ( command.isEnabled ) { + editor.execute( commandName ); + + data.stopPropagation(); + data.preventDefault(); + evt.stop(); + } + }, { context: 'li' } ); + } + + /** + * Registers the conversion helpers for the document-list feature. + * @private + */ + _setupConversion() { + const editor = this.editor; + const model = editor.model; + const attributeNames = this._getListAttributeNames(); + + 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' ) + .elementToElement( { + model: 'paragraph', + view: bogusParagraphCreator( attributeNames ), + converterPriority: 'high' + } ); + + editor.conversion.for( 'dataDowncast' ) + .elementToElement( { + model: 'paragraph', + view: bogusParagraphCreator( attributeNames, { dataPipeline: true } ), + converterPriority: 'high' + } ); + + editor.conversion.for( 'downcast' ) + .add( dispatcher => { + dispatcher.on( 'attribute', listItemDowncastConverter( attributeNames, this._downcastStrategies, model ) ); + } ); + + this.listenTo( model.document, 'change:data', reconvertItemsOnDataChange( model, editor.editing, attributeNames, this ) ); + + // For LI verify if an ID of the attribute element is correct. + this.on( 'checkAttributes:item', ( evt, { viewElement, modelAttributes } ) => { + if ( viewElement.id != modelAttributes.listItemId ) { + evt.return = true; + evt.stop(); + } + } ); + + // For UL and OL check if the name and ID of element is correct. + this.on( 'checkAttributes:list', ( evt, { viewElement, modelAttributes } ) => { + if ( + viewElement.name != getViewElementNameForListType( modelAttributes.listType ) || + viewElement.id != getViewElementIdForListType( modelAttributes.listType, modelAttributes.listIndent ) + ) { + evt.return = true; + evt.stop(); + } + } ); + } + + /** + * Registers model post-fixers. + * + * @private + */ + _setupModelPostFixing() { + const model = this.editor.model; + const attributeNames = this._getListAttributeNames(); + + // Register list fixing. + // First the low level handler. + model.document.registerPostFixer( writer => modelChangePostFixer( model, writer, attributeNames, this ) ); + + // Then the callbacks for the specific lists. + // The indentation fixing must be the first one... + this.on( 'postFixer', ( evt, { listNodes, writer } ) => { + evt.return = fixListIndents( listNodes, writer ) || evt.return; + }, { priority: 'high' } ); + + // ...then the item ids... and after that other fixers that rely on the correct indentation and ids. + this.on( 'postFixer', ( evt, { listNodes, writer, seenIds } ) => { + evt.return = fixListItemIds( listNodes, seenIds, writer ) || evt.return; + }, { priority: 'high' } ); + } +} + +/** + * @typedef {Object} module:list/documentlist/documentlistediting~DowncastStrategy + * @property {'list'|'item'} scope The scope of the downcast (whether it applies to LI or OL/UL). + * @property {String} attributeName The model attribute name. + * @property {Function} setAttributeOnDowncast Sets the property on the view element. + */ + +// Post-fixer that reacts to changes on document and fixes incorrect model states (invalid `listItemId` and `listIndent` values). +// +// In the example below, there is a correct list structure. +// Then the middle element is removed so the list structure will become incorrect: +// +// Item 1 +// Item 2 <--- this is removed. +// Item 3 +// +// The list structure after the middle element is removed: +// +// Item 1 +// Item 3 +// +// Should become: +// +// Item 1 +// Item 3 <--- note that indent got post-fixed. +// +// @param {module:engine/model/model~Model} model The data model. +// @param {module:engine/model/writer~Writer} writer The writer to do changes with. +// @param {Array.} attributeNames The list of all model list attributes (including registered strategies). +// @param {module:list/documentlist/documentlistediting~DocumentListEditing} documentListEditing The document list editing plugin. +// @returns {Boolean} `true` if any change has been applied, `false` otherwise. +function modelChangePostFixer( model, writer, attributeNames, documentListEditing ) { + const changes = model.document.differ.getChanges(); + const itemToListHead = new Map(); + + let applied = false; + + for ( const entry of changes ) { + if ( entry.type == 'insert' && entry.name != '$text' ) { + const item = entry.position.nodeAfter; + + // Remove attributes in case of renamed element. + if ( !model.schema.checkAttribute( item, 'listItemId' ) ) { + for ( const attributeName of Array.from( item.getAttributeKeys() ) ) { + if ( attributeNames.includes( attributeName ) ) { + writer.removeAttribute( attributeName, item ); + + applied = true; + } + } + } + + findAndAddListHeadToMap( entry.position, itemToListHead ); + + // Insert of a non-list item - check if there is a list after it. + if ( !entry.attributes.has( 'listItemId' ) ) { + findAndAddListHeadToMap( entry.position.getShiftedBy( entry.length ), itemToListHead ); + } + + // Check if there is no nested list. + for ( const { item: innerItem, previousPosition } of model.createRangeIn( item ) ) { + if ( isListItemBlock( innerItem ) ) { + findAndAddListHeadToMap( previousPosition, itemToListHead ); + } + } + } + // Removed list item or block adjacent to a list. + else if ( entry.type == 'remove' ) { + findAndAddListHeadToMap( entry.position, itemToListHead ); + } + // Changed list item indent or type. + else if ( entry.type == 'attribute' && attributeNames.includes( entry.attributeKey ) ) { + findAndAddListHeadToMap( entry.range.start, itemToListHead ); + + if ( entry.attributeNewValue === null ) { + findAndAddListHeadToMap( entry.range.start.getShiftedBy( 1 ), itemToListHead ); + } + } + } + + // Make sure that IDs are not shared by split list. + const seenIds = new Set(); + + for ( const listHead of itemToListHead.values() ) { + /** + * Event fired on changes detected on the model list element to verify if the view representation of a list element + * is representing those attributes. + * + * It allows triggering a re-wrapping of a list item. + * + * **Note**: For convenience this event is namespaced and could be captured as `checkAttributes:list` or `checkAttributes:item`. + * + * @protected + * @event module:list/documentlist/documentlistediting~DocumentListEditing#event:postFixer + * @param {module:engine/model/element~Element} listHead The head element of a list. + * @param {module:engine/model/writer~Writer} writer The writer to do changes with. + * @param {Set.} seenIds The set of already known IDs. + * @param {Object} modelAttributes + * @returns {Boolean} If a post-fixer made a change of the model tree, it should return `true`. + */ + applied = documentListEditing.fire( 'postFixer', { + listNodes: new ListBlocksIterable( listHead ), + listHead, + writer, + seenIds + } ) || applied; + } + + return applied; +} + +// A fixer for pasted content that includes list items. +// +// It fixes indentation of pasted list items so the pasted items match correctly to the context they are pasted into. +// +// Example: +// +// A +// B^ +// // At ^ paste: X +// // Y +// C +// +// Should become: +// +// A +// BX +// Y/paragraph> +// C +// +function createModelIndentPasteFixer( model ) { + return ( evt, [ content, selectable ] ) => { + // Check whether inserted content starts from a `listItem`. If it does not, it means that there are some other + // elements before it and there is no need to fix indents, because even if we insert that content into a list, + // that list will be broken. + // Note: we also need to handle singular elements because inserting item with indent 0 into 0,1,[],2 + // would create incorrect model. + const item = content.is( 'documentFragment' ) ? content.getChild( 0 ) : content; + + if ( !isListItemBlock( item ) ) { + return; + } + + let selection; + + if ( !selectable ) { + selection = model.document.selection; + } else { + selection = model.createSelection( selectable ); + } + + // Get a reference list item. Inserted list items will be fixed according to that item. + const pos = selection.getFirstPosition(); + let refItem = null; + + if ( isListItemBlock( pos.parent ) ) { + refItem = pos.parent; + } else if ( isListItemBlock( pos.nodeBefore ) ) { + refItem = pos.nodeBefore; + } + + // If there is `refItem` it means that we do insert list items into an existing list. + if ( !refItem ) { + return; + } + + // First list item in `data` has indent equal to 0 (it is a first list item). It should have indent equal + // to the indent of reference item. We have to fix the first item and all of it's children and following siblings. + // Indent of all those items has to be adjusted to reference item. + const indentChange = refItem.getAttribute( 'listIndent' ) - item.getAttribute( 'listIndent' ); + + // Fix only if there is anything to fix. + if ( indentChange <= 0 ) { + return; + } + + model.change( writer => { + // Adjust indent of all "first" list items in inserted data. + for ( const { node } of iterateSiblingListBlocks( item, 'forward' ) ) { + writer.setAttribute( 'listIndent', node.getAttribute( 'listIndent' ) + indentChange, node ); + } + } ); + }; +} + +// 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 new file mode 100644 index 00000000000..da7f2158353 --- /dev/null +++ b/packages/ckeditor5-list/src/documentlist/documentlistindentcommand.js @@ -0,0 +1,182 @@ +/** + * @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/documentlistindentcommand + */ + +import { Command } from 'ckeditor5/src/core'; +import { + expandListBlocksToCompleteItems, + indentBlocks, + isFirstBlockOfListItem, + isListItemBlock, + isSingleListItem, + outdentBlocksWithMerge, + sortBlocks, + splitListItemBefore +} from './utils/model'; +import ListWalker from './utils/listwalker'; + +/** + * The document list indent command. It is used by the {@link module:list/documentlist~DocumentList list feature}. + * + * @extends module:core/command~Command + */ +export default class DocumentListIndentCommand extends Command { + /** + * Creates an instance of the command. + * + * @param {module:core/editor/editor~Editor} editor The editor instance. + * @param {'forward'|'backward'} indentDirection The direction of indent. If it is equal to `backward`, the command + * will outdent a list item. + */ + constructor( editor, indentDirection ) { + super( editor ); + + /** + * Determines by how much the command will change the list item's indent attribute. + * + * @readonly + * @private + * @member {'forward'|'backward'} + */ + this._direction = indentDirection; + } + + /** + * @inheritDoc + */ + refresh() { + this.isEnabled = this._checkEnabled(); + } + + /** + * Indents or outdents (depending on the {@link #constructor}'s `indentDirection` parameter) selected list items. + * + * @fires execute + * @fires afterExecute + */ + execute() { + const model = this.editor.model; + const blocks = getSelectedListBlocks( model.document.selection ); + + model.change( writer => { + const changedBlocks = []; + + // Handle selection contained in the single list item and starting in the following blocks. + if ( isSingleListItem( blocks ) && !isFirstBlockOfListItem( blocks[ 0 ] ) ) { + // Allow increasing indent of following list item blocks. + if ( this._direction == 'forward' ) { + changedBlocks.push( ...indentBlocks( blocks, writer ) ); + } + + // For indent make sure that indented blocks have a new ID. + // For outdent just split blocks from the list item (give them a new IDs). + changedBlocks.push( ...splitListItemBefore( blocks[ 0 ], writer ) ); + } + // More than a single list item is selected, or the first block of list item is selected. + else { + // Now just update the attributes of blocks. + if ( this._direction == 'forward' ) { + changedBlocks.push( ...indentBlocks( blocks, writer, { expand: true } ) ); + } else { + changedBlocks.push( ...outdentBlocksWithMerge( blocks, writer ) ); + } + } + + // Align the list item type to match the previous list item (from the same list). + for ( const block of changedBlocks ) { + // This block become a plain block (for example a paragraph). + if ( !block.hasAttribute( 'listType' ) ) { + continue; + } + + const previousItemBlock = ListWalker.first( block, { sameIndent: true } ); + + if ( previousItemBlock ) { + writer.setAttribute( 'listType', previousItemBlock.getAttribute( 'listType' ), block ); + } + } + + 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 ~DocumentListIndentCommand#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() { + // Check whether any of position's ancestor is a list item. + let blocks = getSelectedListBlocks( this.editor.model.document.selection ); + let firstBlock = blocks[ 0 ]; + + // If selection is not in a list item, the command is disabled. + if ( !firstBlock ) { + return false; + } + + // If we are outdenting it is enough to be in list item. Every list item can always be outdented. + if ( this._direction == 'backward' ) { + return true; + } + + // A single block of a list item is selected, so it could be indented as a sublist. + if ( isSingleListItem( blocks ) && !isFirstBlockOfListItem( blocks[ 0 ] ) ) { + return true; + } + + blocks = expandListBlocksToCompleteItems( blocks ); + firstBlock = blocks[ 0 ]; + + // Check if there is any list item before selected items that could become a parent of selected items. + const siblingItem = ListWalker.first( firstBlock, { sameIndent: true } ); + + if ( !siblingItem ) { + return false; + } + + if ( siblingItem.getAttribute( 'listType' ) == firstBlock.getAttribute( 'listType' ) ) { + return true; + } + + return false; + } +} + +// Returns an array of selected blocks truncated to the first non list block element. +function getSelectedListBlocks( selection ) { + const blocks = Array.from( selection.getSelectedBlocks() ); + const firstNonListBlockIndex = blocks.findIndex( block => !isListItemBlock( block ) ); + + if ( firstNonListBlockIndex != -1 ) { + blocks.length = firstNonListBlockIndex; + } + + return blocks; +} + diff --git a/packages/ckeditor5-list/src/documentlist/documentlistmergecommand.js b/packages/ckeditor5-list/src/documentlist/documentlistmergecommand.js new file mode 100644 index 00000000000..51d23aa771c --- /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, + isListItemBlock +} 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 ) { + 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 ( !isListItemBlock( positionParent ) ) { + 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 ( !isListItemBlock( lastPosition.parent ) ) { + 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/documentlistsplitcommand.js b/packages/ckeditor5-list/src/documentlist/documentlistsplitcommand.js new file mode 100644 index 00000000000..f11d9f4eba8 --- /dev/null +++ b/packages/ckeditor5-list/src/documentlist/documentlistsplitcommand.js @@ -0,0 +1,114 @@ +/** + * @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/documentlistsplitcommand + */ + +import { Command } from 'ckeditor5/src/core'; +import { + isFirstBlockOfListItem, + isListItemBlock, + 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 { + /** + * Creates an instance of the command. + * + * @param {module:core/editor/editor~Editor} editor The editor instance. + * @param {'before'|'after'} direction Whether list item should be split before or after the selected block. + */ + constructor( editor, direction ) { + super( editor ); + + /** + * Whether list item should be split before or after the selected block. + * + * @readonly + * @private + * @member {'before'|'after'} + */ + this._direction = direction; + } + + /** + * @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 changedBlocks = splitListItemBefore( this._getStartBlock(), 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 selection = this.editor.model.document.selection; + const block = this._getStartBlock(); + + return selection.isCollapsed && + isListItemBlock( block ) && + !isFirstBlockOfListItem( block ); + } + + /** + * Returns the model element that is the main focus of the command (according to the current selection and command direction). + * + * @private + * @returns {module:engine/model/element~Element} + */ + _getStartBlock() { + const doc = this.editor.model.document; + const positionParent = doc.selection.getFirstPosition().parent; + + return this._direction == 'before' ? positionParent : positionParent.nextSibling; + } +} diff --git a/packages/ckeditor5-list/src/documentlist/utils/listwalker.js b/packages/ckeditor5-list/src/documentlist/utils/listwalker.js new file mode 100644 index 00000000000..0beb11eab5e --- /dev/null +++ b/packages/ckeditor5-list/src/documentlist/utils/listwalker.js @@ -0,0 +1,260 @@ +/** + * @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/utils/listwalker + */ + +import { first, toArray } from 'ckeditor5/src/utils'; +import { isListItemBlock } from './model'; + +/** + * Document list blocks iterator. + */ +export default class ListWalker { + /** + * Creates a document list iterator. + * + * @param {module:engine/model/element~Element} startElement The start list item block element. + * @param {Object} options + * @param {'forward'|'backward'} [options.direction='backward'] The iterating direction. + * @param {Boolean} [options.includeSelf=false] Whether start block should be included in the result (if it's matching other criteria). + * @param {Array.|String} [options.sameAttributes=[]] Additional attributes that must be the same for each block. + * @param {Boolean} [options.sameIndent=false] Whether blocks with the same indent level as the start block should be included + * in the result. + * @param {Boolean} [options.lowerIndent=false] Whether blocks with a lower indent level than the start block should be included + * in the result. + * @param {Boolean} [options.higherIndent=false] Whether blocks with a higher indent level than the start block should be included + * in the result. + */ + constructor( startElement, options ) { + /** + * The start list item block element. + * + * @private + * @type {module:engine/model/element~Element} + */ + this._startElement = startElement; + + /** + * The reference indent. Initialized by the indent of the start block. + * + * @private + * @type {Number} + */ + this._referenceIndent = startElement.getAttribute( 'listIndent' ); + + /** + * The iterating direction. + * + * @private + * @type {Boolean} + */ + this._isForward = options.direction == 'forward'; + + /** + * Whether start block should be included in the result (if it's matching other criteria). + * + * @private + * @type {Boolean} + */ + this._includeSelf = !!options.includeSelf; + + /** + * Additional attributes that must be the same for each block. + * + * @private + * @type {Array.} + */ + this._sameAttributes = toArray( options.sameAttributes || [] ); + + /** + * Whether blocks with the same indent level as the start block should be included in the result. + * + * @private + * @type {Boolean} + */ + this._sameIndent = !!options.sameIndent; + + /** + * Whether blocks with a lower indent level than the start block should be included in the result. + * + * @private + * @type {Boolean} + */ + this._lowerIndent = !!options.lowerIndent; + + /** + * Whether blocks with a higher indent level than the start block should be included in the result. + * + * @private + * @type {Boolean} + */ + this._higherIndent = !!options.higherIndent; + } + + /** + * Performs only first step of iteration and returns the result. + * + * @param {module:engine/model/element~Element} startElement The start list item block element. + * @param {Object} options + * @param {'forward'|'backward'} [options.direction='backward'] The iterating direction. + * @param {Boolean} [options.includeSelf=false] Whether start block should be included in the result (if it's matching other criteria). + * @param {Array.|String} [options.sameAttributes=[]] Additional attributes that must be the same for each block. + * @param {Boolean} [options.sameIndent=false] Whether blocks with the same indent level as the start block should be included + * in the result. + * @param {Boolean} [options.lowerIndent=false] Whether blocks with a lower indent level than the start block should be included + * in the result. + * @param {Boolean} [options.higherIndent=false] Whether blocks with a higher indent level than the start block should be included + * in the result. + * @returns {module:engine/model/element~Element|null} + */ + static first( startElement, options ) { + const walker = new this( startElement, options ); + const iterator = walker[ Symbol.iterator ](); + + return first( iterator ); + } + + /** + * Iterable interface. + * + * @returns {Iterable.} + */ + * [ Symbol.iterator ]() { + const nestedItems = []; + + for ( const { node } of iterateSiblingListBlocks( this._getStartNode(), this._isForward ? 'forward' : 'backward' ) ) { + const indent = node.getAttribute( 'listIndent' ); + + // Leaving a nested list. + if ( indent < this._referenceIndent ) { + // Abort searching blocks. + if ( !this._lowerIndent ) { + break; + } + + // While searching for lower indents, update the reference indent to find another parent in the next step. + this._referenceIndent = indent; + } + // Entering a nested list. + else if ( indent > this._referenceIndent ) { + // Ignore nested blocks. + if ( !this._higherIndent ) { + continue; + } + + // Collect nested blocks to verify if they are really nested, or it's a different item. + if ( !this._isForward ) { + nestedItems.push( node ); + + continue; + } + } + // Same indent level block. + else { + // Ignore same indent block. + if ( !this._sameIndent ) { + // While looking for nested blocks, stop iterating while encountering first same indent block. + if ( this._higherIndent ) { + // No more nested blocks so yield nested items. + if ( nestedItems.length ) { + yield* nestedItems; + nestedItems.length = 0; + } + + break; + } + + continue; + } + + // Abort if item has any additionally specified attribute different. + if ( this._sameAttributes.some( attr => node.getAttribute( attr ) !== this._startElement.getAttribute( attr ) ) ) { + break; + } + } + + // There is another block for the same list item so the nested items were in the same list item. + if ( nestedItems.length ) { + yield* nestedItems; + nestedItems.length = 0; + } + + yield node; + } + } + + /** + * Returns the model element to start iterating. + * + * @private + * @returns {module:engine/model/element~Element} + */ + _getStartNode() { + if ( this._includeSelf ) { + return this._startElement; + } + + return this._isForward ? + this._startElement.nextSibling : + this._startElement.previousSibling; + } +} + +/** + * Iterates sibling list blocks starting from the given node. + * + * @protected + * @param {module:engine/model/node~Node} node The model node. + * @param {'backward'|'forward'} [direction='forward'] Iteration direction. + * @returns {Iterator.} The object with `node` and `previous` + * {@link module:engine/model/element~Element blocks}. + */ +export function* iterateSiblingListBlocks( node, direction = 'forward' ) { + const isForward = direction == 'forward'; + let previous = null; + + while ( isListItemBlock( node ) ) { + yield { node, previous }; + + previous = node; + node = isForward ? node.nextSibling : node.previousSibling; + } +} + +/** + * The iterable protocol over the list elements. + * + * @protected + */ +export class ListBlocksIterable { + /** + * @param {module:engine/model/element~Element} listHead The head element of a list. + */ + constructor( listHead ) { + this._listHead = listHead; + } + + /** + * List blocks iterator. + * + * Iterates over all blocks of a list. + * + * @returns {Iterator.} + */ + [ Symbol.iterator ]() { + return iterateSiblingListBlocks( this._listHead, 'forward' ); + } +} + +/** + * Object returned by `iterateSiblingListBlocks()` when traversing a list. + * + * @protected + * @typedef {Object} module:list/documentlist/utils/listwalker~ListIteratorValue + * @property {module:engine/model/node~Node} node The current list node. + * @property {module:engine/model/node~Node} previous The previous list node. + */ diff --git a/packages/ckeditor5-list/src/documentlist/utils/model.js b/packages/ckeditor5-list/src/documentlist/utils/model.js new file mode 100644 index 00000000000..b8c60b68d70 --- /dev/null +++ b/packages/ckeditor5-list/src/documentlist/utils/model.js @@ -0,0 +1,534 @@ +/** + * @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/utils/model + */ + +import { uid, toArray } from 'ckeditor5/src/utils'; +import ListWalker, { iterateSiblingListBlocks } from './listwalker'; + +/** + * The list item ID generator. + * + * @protected + */ +export class ListItemUid { + /** + * Returns the next ID. + * + * @protected + * @returns {String} + */ + /* istanbul ignore next: static function definition */ + static next() { + return uid(); + } +} + +/** + * Returns true if the given model node is a list item block. + * + * @protected + * @param {module:engine/model/node~Node} node A model node. + * @returns {Boolean} + */ +export function isListItemBlock( node ) { + return !!node && node.is( 'element' ) && node.hasAttribute( 'listItemId' ); +} + +/** + * Returns an array with all elements that represents the same list item. + * + * It means that values for `listIndent`, and `listItemId` for all items are equal. + * + * @protected + * @param {module:engine/model/element~Element} listItem Starting list item element. + * @param {Object} [options] + * @param {Boolean} [options.higherIndent=false] Whether blocks with a higher indent level than the start block should be included + * in the result. + * @return {Array.} + */ +export function getAllListItemBlocks( listItem, options = {} ) { + return [ + ...getListItemBlocks( listItem, { ...options, direction: 'backward' } ), + ...getListItemBlocks( listItem, { ...options, direction: 'forward' } ) + ]; +} + +/** + * Returns an array with elements that represents the same list item in the specified direction. + * + * It means that values for `listIndent` and `listItemId` for all items are equal. + * + * **Note**: For backward search the provided item is not included, but for forward search it is included in the result. + * + * @protected + * @param {module:engine/model/element~Element} listItem Starting list item element. + * @param {Object} [options] + * @param {'forward'|'backward'} [options.direction='backward'] Walking direction. + * @param {Boolean} [options.higherIndent=false] Whether blocks with a higher indent level than the start block should be included + * in the result. + * @returns {Array.} + */ +export function getListItemBlocks( listItem, options = {} ) { + const isForward = options.direction == 'forward'; + + const items = Array.from( new ListWalker( listItem, { + ...options, + includeSelf: isForward, + sameIndent: true, + sameAttributes: 'listItemId' + } ) ); + + return isForward ? items : items.reverse(); +} + +/** + * Returns a list items nested inside the given list item. + * + * @protected + * @param {module:engine/model/element~Element} listItem Starting list item element. + * @returns {Array.} + */ +export function getNestedListBlocks( listItem ) { + return Array.from( new ListWalker( listItem, { + direction: 'forward', + higherIndent: true + } ) ); +} + +/** + * Returns array of all blocks/items of the same list as given block (same indent, same type and properties). + * + * @protected + * @param {module:engine/model/element~Element} listItem Starting list item element. + * @returns {Array.} + */ +export function getListItems( listItem ) { + const backwardBlocks = new ListWalker( listItem, { + sameIndent: true, + sameAttributes: 'listType' + } ); + + const forwardBlocks = new ListWalker( listItem, { + sameIndent: true, + sameAttributes: 'listType', + includeSelf: true, + direction: 'forward' + } ); + + return [ + ...Array.from( backwardBlocks ).reverse(), + ...forwardBlocks + ]; +} + +/** + * Check if the given block is the first in the list item. + * + * @protected + * @param {module:engine/model/element~Element} listBlock The list block element. + * @returns {Boolean} + */ +export function isFirstBlockOfListItem( listBlock ) { + const previousSibling = ListWalker.first( listBlock, { + sameIndent: true, + sameAttributes: 'listItemId' + } ); + + if ( !previousSibling ) { + return true; + } + + return false; +} + +/** + * Check if the given block is the last in the list item. + * + * @protected + * @param {module:engine/model/element~Element} listBlock The list block element. + * @returns {Boolean} + */ +export function isLastBlockOfListItem( listBlock ) { + const nextSibling = ListWalker.first( listBlock, { + direction: 'forward', + sameIndent: true, + sameAttributes: 'listItemId' + } ); + + if ( !nextSibling ) { + return true; + } + + return false; +} + +/** + * Expands the given list of selected blocks to include the leading and tailing blocks of partially selected list items. + * + * @protected + * @param {module:engine/model/element~Element|Array.} blocks The list of selected blocks. + * @param {Object} [options] + * @param {Boolean} [options.withNested=true] Whether should include nested list items. + * @returns {Array.} + */ +export function expandListBlocksToCompleteItems( blocks, options = {} ) { + blocks = toArray( blocks ); + + const higherIndent = options.withNested !== false; + const allBlocks = new Set(); + + for ( const block of blocks ) { + for ( const itemBlock of getAllListItemBlocks( block, { higherIndent } ) ) { + allBlocks.add( itemBlock ); + } + } + + return sortBlocks( allBlocks ); +} + +/** + * Expands the given list of selected blocks to include all the items of the lists they're in. + * + * @protected + * @param {module:engine/model/element~Element|Array.} blocks The list of selected blocks. + * @returns {Array.} + */ +export function expandListBlocksToCompleteList( blocks ) { + blocks = toArray( blocks ); + + const allBlocks = new Set(); + + for ( const block of blocks ) { + for ( const itemBlock of getListItems( block ) ) { + allBlocks.add( itemBlock ); + } + } + + return sortBlocks( allBlocks ); +} + +/** + * Splits the list item just before the provided list block. + * + * @protected + * @param {module:engine/model/element~Element} listBlock The list block element. + * @param {module:engine/model/writer~Writer} writer The model writer. + * @returns {Array.} The array of updated blocks. + */ +export function splitListItemBefore( listBlock, writer ) { + const blocks = getListItemBlocks( listBlock, { direction: 'forward' } ); + const id = ListItemUid.next(); + + for ( const block of blocks ) { + writer.setAttribute( 'listItemId', id, block ); + } + + return blocks; +} + +/** + * Merges the list item with the parent list item. + * + * @protected + * @param {module:engine/model/element~Element} listBlock The list block element. + * @param {module:engine/model/element~Element} parentBlock The list block element to merge with. + * @param {module:engine/model/writer~Writer} writer The model writer. + * @returns {Array.} The array of updated blocks. + */ +export function mergeListItemBefore( listBlock, parentBlock, writer ) { + const attributes = {}; + + for ( const [ key, value ] of parentBlock.getAttributes() ) { + if ( key.startsWith( 'list' ) ) { + attributes[ key ] = value; + } + } + + const blocks = getListItemBlocks( listBlock, { direction: 'forward' } ); + + for ( const block of blocks ) { + writer.setAttributes( attributes, block ); + } + + return blocks; +} + +/** + * Increases indentation of given list blocks. + * + * @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. + * @param {Number} [options.indentBy=1] The number of levels the indentation should change (could be negative). + */ +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 ) { + 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 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. + */ +export function outdentBlocksWithMerge( blocks, writer ) { + blocks = toArray( blocks ); + + // Expand the selected blocks to contain the whole list items. + const allBlocks = expandListBlocksToCompleteItems( blocks ); + const visited = new Set(); + + const referenceIndent = Math.min( ...allBlocks.map( block => block.getAttribute( 'listIndent' ) ) ); + const parentBlocks = new Map(); + + // Collect parent blocks before the list structure gets altered. + for ( const block of allBlocks ) { + parentBlocks.set( block, ListWalker.first( block, { lowerIndent: true } ) ); + } + + for ( const block of allBlocks ) { + if ( visited.has( block ) ) { + continue; + } + + visited.add( block ); + + const blockIndent = block.getAttribute( 'listIndent' ) - 1; + + if ( blockIndent < 0 ) { + removeListAttributes( block, writer ); + + continue; + } + + // Merge with parent list item while outdenting and indent matches reference indent. + 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. + for ( const mergedBlock of mergedBlocks ) { + visited.add( mergedBlock ); + } + + // The indent level was updated while merging so continue to next block. + if ( mergedBlocks.length ) { + continue; + } + } + + writer.setAttribute( 'listIndent', blockIndent, block ); + } + + return sortBlocks( visited ); +} + +/** + * Removes all list attributes from the given blocks. + * + * @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. + * @returns {Array.} Array of altered blocks. + */ +export function removeListAttributes( blocks, writer ) { + blocks = toArray( blocks ); + + for ( const block of blocks ) { + for ( const attributeKey of block.getAttributeKeys() ) { + if ( attributeKey.startsWith( 'list' ) ) { + writer.removeAttribute( attributeKey, block ); + } + } + } + + return blocks; +} + +/** + * Checks whether the given blocks are related to a single list item. + * + * @protected + * @param {Array.} blocks The list block elements. + * @returns {Boolean} + */ +export function isSingleListItem( blocks ) { + if ( !blocks.length ) { + return false; + } + + const firstItemId = blocks[ 0 ].getAttribute( 'listItemId' ); + + if ( !firstItemId ) { + return false; + } + + return !blocks.some( item => item.getAttribute( 'listItemId' ) != firstItemId ); +} + +/** + * Modifies the indents of list blocks following the given list block so the indentation is valid after + * the given block is no longer a list item. + * + * @protected + * @param {module:engine/model/element~Element} lastBlock The last list block that has become a non-list element. + * @param {module:engine/model/writer~Writer} writer The model writer. + * @returns {Array.} Array of altered blocks. + */ +export function outdentFollowingItems( lastBlock, writer ) { + const changedBlocks = []; + + // Start from the model item that is just after the last turned-off item. + let currentIndent = Number.POSITIVE_INFINITY; + + // Correct indent of all items after the last turned off item. + // Rules that should be followed: + // 1. All direct sub-items of turned-off item should become indent 0, because the first item after it + // will be the first item of a new list. Other items are at the same level, so should have same 0 index. + // 2. All items with indent lower than indent of turned-off item should become indent 0, because they + // should not end up as a child of any of list items that they were not children of before. + // 3. All other items should have their indent changed relatively to it's parent. + // + // For example: + // 1 * -------- + // 2 * -------- + // 3 * -------- <-- this is turned off. + // 4 * -------- <-- this has to become indent = 0, because it will be first item on a new list. + // 5 * -------- <-- this should be still be a child of item above, so indent = 1. + // 6 * -------- <-- this has to become indent = 0, because it should not be a child of any of items above. + // 7 * -------- <-- this should be still be a child of item above, so indent = 1. + // 8 * -------- <-- this has to become indent = 0. + // 9 * -------- <-- this should still be a child of item above, so indent = 1. + // 10 * -------- <-- this should still be a child of item above, so indent = 2. + // 11 * -------- <-- this should still be at the same level as item above, so indent = 2. + // 12 * -------- <-- this and all below are left unchanged. + // 13 * -------- + // 14 * -------- + // + // After turning off 3 the list becomes: + // + // 1 * -------- + // 2 * -------- + // + // 3 -------- + // + // 4 * -------- + // 5 * -------- + // 6 * -------- + // 7 * -------- + // 8 * -------- + // 9 * -------- + // 10 * -------- + // 11 * -------- + // 12 * -------- + // 13 * -------- + // 14 * -------- + // + // Thanks to this algorithm no lists are mismatched and no items get unexpected children/parent, while + // those parent-child connection which are possible to maintain are still maintained. It's worth noting + // that this is the same effect that we would be get by multiple use of outdent command. However doing + // it like this is much more efficient because it's less operation (less memory usage, easier OT) and + // less conversion (faster). + for ( const { node } of iterateSiblingListBlocks( lastBlock.nextSibling, 'forward' ) ) { + // Check each next list item, as long as its indent is higher than 0. + const indent = node.getAttribute( 'listIndent' ); + + // If the indent is 0 we are not going to change anything anyway. + if ( indent == 0 ) { + break; + } + + // 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; + } + + // Fix indent relatively to current relative indent. + // Note, that if we just changed the current relative indent, the newIndent will be equal to 0. + const newIndent = indent - currentIndent; + + writer.setAttribute( 'listIndent', newIndent, node ); + changedBlocks.push( node ); + } + + return changedBlocks; +} + +/** + * Returns the array of given blocks sorted by model indexes (document order). + * + * @protected + * @param {Iterable.} blocks The array of blocks. + * @returns {Array.} The sorted array of blocks. + */ +export function sortBlocks( blocks ) { + 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. +function mergeListItemIfNotLast( block, parentBlock, writer ) { + const parentItemBlocks = getListItemBlocks( parentBlock, { direction: 'forward' } ); + + // Merge with parent only if outdented item wasn't the last one in its parent. + // Merge: + // * a -> * a + // * [b] -> b + // c -> c + // Don't merge: + // * a -> * a + // * [b] -> * b + // * c -> * c + if ( parentItemBlocks.pop().index > block.index ) { + return mergeListItemBefore( block, parentBlock, writer ); + } + + return []; +} diff --git a/packages/ckeditor5-list/src/documentlist/utils/postfixers.js b/packages/ckeditor5-list/src/documentlist/utils/postfixers.js new file mode 100644 index 00000000000..cd8042f204e --- /dev/null +++ b/packages/ckeditor5-list/src/documentlist/utils/postfixers.js @@ -0,0 +1,138 @@ +/** + * @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/utils/postfixers + */ + +import { iterateSiblingListBlocks } from './listwalker'; +import { getListItemBlocks, isListItemBlock, ListItemUid } from './model'; + +/** + * Based on the provided positions looks for the list head and stores it in the provided map. + * + * @protected + * @param {module:engine/model/position~Position} position The search starting position. + * @param {Map.} itemToListHead The map from list item element + * to the list head element. + */ +export function findAndAddListHeadToMap( position, itemToListHead ) { + const previousNode = position.nodeBefore; + + if ( !isListItemBlock( previousNode ) ) { + const item = position.nodeAfter; + + if ( isListItemBlock( item ) ) { + itemToListHead.set( item, item ); + } + } else { + let listHead = previousNode; + + for ( { node: listHead } of iterateSiblingListBlocks( listHead, 'backward' ) ) { + if ( itemToListHead.has( listHead ) ) { + return; + } + } + + itemToListHead.set( previousNode, listHead ); + } +} + +/** + * Scans the list starting from the given list head element and fixes items' indentation. + * + * @protected + * @param {Iterable.} listNodes The iterable of list nodes. + * @param {module:engine/model/writer~Writer} writer The model writer. + * @returns {Boolean} Whether the model was modified. + */ +export function fixListIndents( listNodes, writer ) { + let maxIndent = 0; // Guards local sublist max indents that need fixing. + let prevIndent = -1; // Previous item indent. + let fixBy = null; + let applied = false; + + for ( const { node } of listNodes ) { + const itemIndent = node.getAttribute( 'listIndent' ); + + if ( itemIndent > maxIndent ) { + let newIndent; + + if ( fixBy === null ) { + fixBy = itemIndent - maxIndent; + newIndent = maxIndent; + } else { + if ( fixBy > itemIndent ) { + fixBy = itemIndent; + } + + newIndent = itemIndent - fixBy; + } + + if ( newIndent > prevIndent + 1 ) { + newIndent = prevIndent + 1; + } + + writer.setAttribute( 'listIndent', newIndent, node ); + + applied = true; + prevIndent = newIndent; + } else { + fixBy = null; + maxIndent = itemIndent + 1; + prevIndent = itemIndent; + } + } + + return applied; +} + +/** + * Scans the list starting from the given list head element and fixes items' types. + * + * @protected + * @param {Iterable.} listNodes The iterable of list nodes. + * @param {Set.} seenIds The set of already known IDs. + * @param {module:engine/model/writer~Writer} writer The model writer. + * @returns {Boolean} Whether the model was modified. + */ +export function fixListItemIds( listNodes, seenIds, writer ) { + const visited = new Set(); + let applied = false; + + for ( const { node } of listNodes ) { + if ( visited.has( node ) ) { + continue; + } + + let listType = node.getAttribute( 'listType' ); + let listItemId = node.getAttribute( 'listItemId' ); + + // Use a new ID if this one was spot earlier (even in other list). + if ( seenIds.has( listItemId ) ) { + listItemId = ListItemUid.next(); + } + + seenIds.add( listItemId ); + + for ( const block of getListItemBlocks( node, { direction: 'forward' } ) ) { + visited.add( block ); + + // Use a new ID if a block of a bigger list item has different type. + if ( block.getAttribute( 'listType' ) != listType ) { + listItemId = ListItemUid.next(); + listType = block.getAttribute( 'listType' ); + } + + if ( block.getAttribute( 'listItemId' ) != listItemId ) { + writer.setAttribute( 'listItemId', listItemId, block ); + + applied = true; + } + } + } + + return applied; +} diff --git a/packages/ckeditor5-list/src/documentlist/utils/view.js b/packages/ckeditor5-list/src/documentlist/utils/view.js new file mode 100644 index 00000000000..61621359f41 --- /dev/null +++ b/packages/ckeditor5-list/src/documentlist/utils/view.js @@ -0,0 +1,148 @@ +/** + * @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/utils/view + */ + +/** + * Checks if view element is a list type (ul or ol). + * + * @protected + * @param {module:engine/view/element~Element} viewElement + * @returns {Boolean} + */ +export function isListView( viewElement ) { + return viewElement.is( 'element', 'ol' ) || viewElement.is( 'element', 'ul' ); +} + +/** + * Checks if view element is a list item (li). + * + * @protected + * @param {module:engine/view/element~Element} viewElement + * @returns {Boolean} + */ +export function isListItemView( viewElement ) { + return viewElement.is( 'element', 'li' ); +} + +/** + * Calculates the indent value for a list item. Handles HTML compliant and non-compliant lists. + * + * Also, fixes non HTML compliant lists indents: + * + * before: fixed list: + * OL OL + * |-> LI (parent LIs: 0) |-> LI (indent: 0) + * |-> OL |-> OL + * |-> OL | + * | |-> OL | + * | |-> OL | + * | |-> LI (parent LIs: 1) |-> LI (indent: 1) + * |-> LI (parent LIs: 1) |-> LI (indent: 1) + * + * before: fixed list: + * OL OL + * |-> OL | + * |-> OL | + * |-> OL | + * |-> LI (parent LIs: 0) |-> LI (indent: 0) + * + * before: fixed list: + * OL OL + * |-> LI (parent LIs: 0) |-> LI (indent: 0) + * |-> OL |-> OL + * |-> LI (parent LIs: 0) |-> LI (indent: 1) + * + * @protected + * @param {module:engine/view/element~Element} listItem + * @returns {Number} + */ +export function getIndent( listItem ) { + let indent = 0; + let parent = listItem.parent; + + while ( parent ) { + // Each LI in the tree will result in an increased indent for HTML compliant lists. + if ( isListItemView( parent ) ) { + indent++; + } else { + // If however the list is nested in other list we should check previous sibling of any of the list elements... + const previousSibling = parent.previousSibling; + + // ...because the we might need increase its indent: + // before: fixed list: + // OL OL + // |-> LI (parent LIs: 0) |-> LI (indent: 0) + // |-> OL |-> OL + // |-> LI (parent LIs: 0) |-> LI (indent: 1) + if ( previousSibling && isListItemView( previousSibling ) ) { + indent++; + } + } + + parent = parent.parent; + } + + return indent; +} + +/** + * Creates a list attribute element (ol or ul). + * + * @protected + * @param {module:engine/view/downcastwriter~DowncastWriter} writer The downcast writer. + * @param {Number} indent The list item indent. + * @param {'bulleted'|'numbered'} type The list type. + * @returns {module:engine/view/attributeelement~AttributeElement} + */ +export function createListElement( writer, indent, type, id = getViewElementIdForListType( type, indent ) ) { + // Negative priorities so that restricted editing attribute won't wrap lists. + return writer.createAttributeElement( getViewElementNameForListType( type ), null, { + priority: 2 * indent / 100 - 100, + id + } ); +} + +/** + * Creates a list item attribute element (li). + * + * @protected + * @param {module:engine/view/downcastwriter~DowncastWriter} writer The downcast writer. + * @param {Number} indent The list item indent. + * @param {String} id The list item ID. + * @returns {module:engine/view/attributeelement~AttributeElement} + */ +export function createListItemElement( writer, indent, id ) { + // Negative priorities so that restricted editing attribute won't wrap list items. + return writer.createAttributeElement( 'li', null, { + priority: ( 2 * indent + 1 ) / 100 - 100, + id + } ); +} + +/** + * Returns a view element name for the given list type. + * + * @protected + * @param {'bulleted'|'numbered'} type The list type. + * @returns {String} + */ +export function getViewElementNameForListType( type ) { + return type == 'numbered' ? 'ol' : 'ul'; +} + +/** + * Returns a view element ID for the given list type and indent. + * + * @protected + * @param {'bulleted'|'numbered'} type The list type. + * @param {Number} indent The list indent level. + * @returns {String} + */ +export function getViewElementIdForListType( type, indent ) { + return `list-${ type }-${ indent }`; +} diff --git a/packages/ckeditor5-list/src/documentlistproperties.js b/packages/ckeditor5-list/src/documentlistproperties.js new file mode 100644 index 00000000000..07f92503bee --- /dev/null +++ b/packages/ckeditor5-list/src/documentlistproperties.js @@ -0,0 +1,37 @@ +/** + * @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/documentlistproperties + */ + +import { Plugin } from 'ckeditor5/src/core'; +import DocumentListPropertiesEditing from './documentlistproperties/documentlistpropertiesediting'; +import ListPropertiesUI from './listproperties/listpropertiesui'; + +/** + * The document list properties feature. + * + * This is a "glue" plugin that loads the + * {@link module:list/documentlistproperties/documentlistpropertiesediting~DocumentListPropertiesEditing document list properties + * editing feature} and the {@link module:list/listproperties/listpropertiesui~ListPropertiesUI list properties UI feature}. + * + * @extends module:core/plugin~Plugin + */ +export default class DocumentListProperties extends Plugin { + /** + * @inheritDoc + */ + static get requires() { + return [ DocumentListPropertiesEditing, ListPropertiesUI ]; + } + + /** + * @inheritDoc + */ + static get pluginName() { + return 'DocumentListProperties'; + } +} diff --git a/packages/ckeditor5-list/src/documentlistproperties/converters.js b/packages/ckeditor5-list/src/documentlistproperties/converters.js new file mode 100644 index 00000000000..dce1c55a208 --- /dev/null +++ b/packages/ckeditor5-list/src/documentlistproperties/converters.js @@ -0,0 +1,57 @@ +/** + * @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/documentlistproperties/converters + */ + +/** + * Returns a converter that consumes the `style`, `reversed`, and `start` attributes. + * In `style`, it searches for the `list-style-type` definition. + * If not found, the `"default"` value will be used. + * + * @protected + * @param {module:list/documentlistproperties/documentlistpropertiesediting~AttributeStrategy} strategy + * @returns {Function} + */ +export function listPropertiesUpcastConverter( strategy ) { + return ( evt, data, conversionApi ) => { + const { writer, schema, consumable } = conversionApi; + + // If there is no view consumable to consume, set the default attribute value to be able to reconvert nested lists on parent change. + // So abort converting if attribute was directly consumed. + if ( consumable.test( data.viewItem, strategy.viewConsumables ) === false ) { + return; + } + + if ( !data.modelRange ) { + Object.assign( data, conversionApi.convertChildren( data.viewItem, data.modelCursor ) ); + } + + let applied = false; + + for ( const item of data.modelRange.getItems( { shallow: true } ) ) { + if ( !schema.checkAttribute( item, strategy.attributeName ) ) { + continue; + } + + if ( !strategy.appliesToListItem( item ) ) { + continue; + } + + // Set list attributes only on same level items, those nested deeper are already handled by the recursive conversion. + if ( item.hasAttribute( strategy.attributeName ) ) { + continue; + } + + writer.setAttribute( strategy.attributeName, strategy.getAttributeOnUpcast( data.viewItem ), item ); + applied = true; + } + + if ( applied ) { + consumable.consume( data.viewItem, strategy.viewConsumables ); + } + }; +} diff --git a/packages/ckeditor5-list/src/documentlistproperties/documentlistpropertiesediting.js b/packages/ckeditor5-list/src/documentlistproperties/documentlistpropertiesediting.js new file mode 100644 index 00000000000..201c16d3990 --- /dev/null +++ b/packages/ckeditor5-list/src/documentlistproperties/documentlistpropertiesediting.js @@ -0,0 +1,338 @@ +/** + * @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/documentlistproperties/documentlistpropertiesediting + */ + +import { Plugin } from 'ckeditor5/src/core'; + +import DocumentListEditing from '../documentlist/documentlistediting'; +import DocumentListStartCommand from './documentliststartcommand'; +import DocumentListStyleCommand from './documentliststylecommand'; +import DocumentListReversedCommand from './documentlistreversedcommand'; +import { listPropertiesUpcastConverter } from './converters'; +import { getListTypeFromListStyleType } from './utils/style'; + +const DEFAULT_LIST_TYPE = 'default'; + +/** + * The document list properties engine feature. + * + * It registers the `'listStyle'`, `'listReversed'` and `'listStart'` commands if they are enabled in the configuration. + * Read more in {@link module:list/listproperties~ListPropertiesConfig}. + * + * @extends module:core/plugin~Plugin + */ +export default class DocumentListPropertiesEditing extends Plugin { + /** + * @inheritDoc + */ + static get requires() { + return [ DocumentListEditing ]; + } + + /** + * @inheritDoc + */ + static get pluginName() { + return 'DocumentListPropertiesEditing'; + } + + /** + * @inheritDoc + */ + constructor( editor ) { + super( editor ); + + editor.config.define( 'list', { + properties: { + styles: true, + startIndex: false, + reversed: false + } + } ); + } + + /** + * @inheritDoc + */ + init() { + const editor = this.editor; + const model = editor.model; + const documentListEditing = editor.plugins.get( DocumentListEditing ); + + const enabledProperties = editor.config.get( 'list.properties' ); + const strategies = createAttributeStrategies( enabledProperties ); + + for ( const strategy of strategies ) { + strategy.addCommand( editor ); + + model.schema.extend( '$container', { allowAttributes: strategy.attributeName } ); + model.schema.extend( '$block', { allowAttributes: strategy.attributeName } ); + model.schema.extend( '$blockObject', { allowAttributes: strategy.attributeName } ); + + // Register downcast strategy. + documentListEditing.registerDowncastStrategy( { + scope: 'list', + attributeName: strategy.attributeName, + + setAttributeOnDowncast( writer, attributeValue, viewElement ) { + strategy.setAttributeOnDowncast( writer, attributeValue, viewElement ); + } + } ); + } + + // Set up conversion. + editor.conversion.for( 'upcast' ).add( dispatcher => { + for ( const strategy of strategies ) { + dispatcher.on( 'element:ol', listPropertiesUpcastConverter( strategy ) ); + dispatcher.on( 'element:ul', listPropertiesUpcastConverter( strategy ) ); + } + } ); + + // Verify if the list view element (ul or ol) requires refreshing. + documentListEditing.on( 'checkAttributes:list', ( evt, { viewElement, modelAttributes } ) => { + for ( const strategy of strategies ) { + if ( strategy.getAttributeOnUpcast( viewElement ) != modelAttributes[ strategy.attributeName ] ) { + evt.return = true; + evt.stop(); + } + } + } ); + + // Reset list properties after indenting list items. + this.listenTo( editor.commands.get( 'indentList' ), 'afterExecute', ( evt, changedBlocks ) => { + model.change( writer => { + for ( const node of changedBlocks ) { + for ( const strategy of strategies ) { + if ( strategy.appliesToListItem( node ) ) { + // Just reset the attribute. + // If there is a previous indented list that this node should be merged into, + // the postfixer will unify all the attributes of both sub-lists. + writer.setAttribute( strategy.attributeName, strategy.defaultValue, node ); + } + } + } + } ); + } ); + + // Add or remove list properties attributes depending on the list type. + documentListEditing.on( 'postFixer', ( evt, { listNodes, writer } ) => { + for ( const { node } of listNodes ) { + for ( const strategy of strategies ) { + // Check if attribute is valid. + if ( strategy.hasValidAttribute( node ) ) { + continue; + } + + // Add missing default property attributes... + if ( strategy.appliesToListItem( node ) ) { + writer.setAttribute( strategy.attributeName, strategy.defaultValue, node ); + } + // ...or remove invalid property attributes. + else { + writer.removeAttribute( strategy.attributeName, node ); + } + + evt.return = true; + } + } + } ); + + // Make sure that all items in a single list (items at the same level & listType) have the same properties. + documentListEditing.on( 'postFixer', ( evt, { listNodes, writer } ) => { + const previousNodesByIndent = []; // Last seen nodes of lower indented lists. + + for ( const { node, previous } of listNodes ) { + // For the first list block there is nothing to compare with. + if ( !previous ) { + continue; + } + + const nodeIndent = node.getAttribute( 'listIndent' ); + const previousNodeIndent = previous.getAttribute( 'listIndent' ); + + let previousNodeInList = null; // It's like `previous` but has the same indent as current node. + + // Let's find previous node for the same indent. + // We're going to need that when we get back to previous indent. + if ( nodeIndent > previousNodeIndent ) { + previousNodesByIndent[ previousNodeIndent ] = previous; + } + // Restore the one for given indent. + else if ( nodeIndent < previousNodeIndent ) { + previousNodeInList = previousNodesByIndent[ nodeIndent ]; + previousNodesByIndent.length = nodeIndent; + } + // Same indent. + else { + previousNodeInList = previous; + } + + // This is a first item of a nested list. + if ( !previousNodeInList ) { + continue; + } + + // This is a first block of a list of a different type. + if ( previousNodeInList.getAttribute( 'listType' ) != node.getAttribute( 'listType' ) ) { + continue; + } + + // Copy properties from the previous one. + for ( const strategy of strategies ) { + const { attributeName } = strategy; + + if ( !strategy.appliesToListItem( node ) ) { + continue; + } + + const value = previousNodeInList.getAttribute( attributeName ); + + if ( node.getAttribute( attributeName ) != value ) { + writer.setAttribute( attributeName, value, node ); + evt.return = true; + } + } + } + } ); + } +} + +/** + * Strategy for dealing with `listItem` attributes supported by this plugin. + * + * @typedef {Object} module:list/documentlistproperties/documentlistpropertiesediting~AttributeStrategy + * @protected + * @property {String} attributeName The model attribute name. + * @property {*} defaultValue The model attribute default value. + * @property {Object} viewConsumables The view consumable as expected by + * {@link module:engine/conversion/viewconsumable~ViewConsumable#consume `ViewConsumable`}. + * @property {Function} addCommand Registers an editor command. + * @property {Function} appliesToListItem Verifies whether the strategy is applicable for the specified model element. + * @property {Function} hasValidAttribute Verifies whether the model attribute value is valid. + * @property {Function} setAttributeOnDowncast Sets the property on the view element. + * @property {Function} getAttributeOnUpcast Retrieves the property value from the view element. + */ + +// Creates an array of strategies for dealing with enabled listItem attributes. +// +// @param {Object} enabledProperties +// @param {Boolean} enabledProperties.styles +// @param {Boolean} enabledProperties.reversed +// @param {Boolean} enabledProperties.startIndex +// @returns {Array.} +function createAttributeStrategies( enabledProperties ) { + const strategies = []; + + if ( enabledProperties.styles ) { + strategies.push( { + attributeName: 'listStyle', + defaultValue: DEFAULT_LIST_TYPE, + viewConsumables: { styles: 'list-style-type' }, + + addCommand( editor ) { + editor.commands.add( 'listStyle', new DocumentListStyleCommand( editor, DEFAULT_LIST_TYPE ) ); + }, + + appliesToListItem() { + return true; + }, + + hasValidAttribute( item ) { + if ( !item.hasAttribute( 'listStyle' ) ) { + return false; + } + + const value = item.getAttribute( 'listStyle' ); + + if ( value == DEFAULT_LIST_TYPE ) { + return true; + } + + return getListTypeFromListStyleType( value ) == item.getAttribute( 'listType' ); + }, + + setAttributeOnDowncast( writer, listStyle, element ) { + if ( listStyle && listStyle !== DEFAULT_LIST_TYPE ) { + writer.setStyle( 'list-style-type', listStyle, element ); + } else { + writer.removeStyle( 'list-style-type', element ); + } + }, + + getAttributeOnUpcast( listParent ) { + return listParent.getStyle( 'list-style-type' ) || DEFAULT_LIST_TYPE; + } + } ); + } + + if ( enabledProperties.reversed ) { + strategies.push( { + attributeName: 'listReversed', + defaultValue: false, + viewConsumables: { attributes: 'reversed' }, + + addCommand( editor ) { + editor.commands.add( 'listReversed', new DocumentListReversedCommand( editor ) ); + }, + + appliesToListItem( item ) { + return item.getAttribute( 'listType' ) == 'numbered'; + }, + + hasValidAttribute( item ) { + return this.appliesToListItem( item ) == item.hasAttribute( 'listReversed' ); + }, + + setAttributeOnDowncast( writer, listReversed, element ) { + if ( listReversed ) { + writer.setAttribute( 'reversed', 'reversed', element ); + } else { + writer.removeAttribute( 'reversed', element ); + } + }, + + getAttributeOnUpcast( listParent ) { + return listParent.hasAttribute( 'reversed' ); + } + } ); + } + + if ( enabledProperties.startIndex ) { + strategies.push( { + attributeName: 'listStart', + defaultValue: 1, + viewConsumables: { attributes: 'start' }, + + addCommand( editor ) { + editor.commands.add( 'listStart', new DocumentListStartCommand( editor ) ); + }, + + appliesToListItem( item ) { + return item.getAttribute( 'listType' ) == 'numbered'; + }, + + hasValidAttribute( item ) { + return this.appliesToListItem( item ) == item.hasAttribute( 'listStart' ); + }, + + setAttributeOnDowncast( writer, listStart, element ) { + if ( listStart && listStart > 1 ) { + writer.setAttribute( 'start', listStart, element ); + } else { + writer.removeAttribute( 'start', element ); + } + }, + + getAttributeOnUpcast( listParent ) { + return listParent.getAttribute( 'start' ) || 1; + } + } ); + } + + return strategies; +} diff --git a/packages/ckeditor5-list/src/documentlistproperties/documentlistreversedcommand.js b/packages/ckeditor5-list/src/documentlistproperties/documentlistreversedcommand.js new file mode 100644 index 00000000000..9409949095f --- /dev/null +++ b/packages/ckeditor5-list/src/documentlistproperties/documentlistreversedcommand.js @@ -0,0 +1,76 @@ +/** + * @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/documentlistproperties/documentlistreversedcommand + */ + +import { Command } from 'ckeditor5/src/core'; +import { first } from 'ckeditor5/src/utils'; +import { + expandListBlocksToCompleteList, + isListItemBlock +} from '../documentlist/utils/model'; + +/** + * The list reversed command. It changes the `listReversed` attribute of the selected list items, + * letting the user to choose the order of an ordered list. + * It is used by the {@link module:list/documentlistproperties~DocumentListProperties list properties feature}. + * + * @extends module:core/command~Command + */ +export default class DocumentListReversedCommand extends Command { + /** + * @inheritDoc + */ + refresh() { + const value = this._getValue(); + + this.value = value; + this.isEnabled = value != null; + } + + /** + * Executes the command. + * + * @fires execute + * @param {Object} [options] + * @param {Boolean} [options.reversed=false] Whether the list should be reversed. + */ + execute( options = {} ) { + const model = this.editor.model; + const document = model.document; + + let blocks = Array.from( document.selection.getSelectedBlocks() ) + .filter( block => isListItemBlock( block ) && block.getAttribute( 'listType' ) == 'numbered' ); + + blocks = expandListBlocksToCompleteList( blocks ); + + model.change( writer => { + for ( const block of blocks ) { + writer.setAttribute( 'listReversed', !!options.reversed, block ); + } + } ); + } + + /** + * Checks the command's {@link #value}. + * + * @private + * @returns {Boolean|null} The current value. + */ + _getValue() { + const model = this.editor.model; + const document = model.document; + + const block = first( document.selection.getSelectedBlocks() ); + + if ( isListItemBlock( block ) && block.getAttribute( 'listType' ) == 'numbered' ) { + return block.getAttribute( 'listReversed' ); + } + + return null; + } +} diff --git a/packages/ckeditor5-list/src/documentlistproperties/documentliststartcommand.js b/packages/ckeditor5-list/src/documentlistproperties/documentliststartcommand.js new file mode 100644 index 00000000000..027523eec2c --- /dev/null +++ b/packages/ckeditor5-list/src/documentlistproperties/documentliststartcommand.js @@ -0,0 +1,76 @@ +/** + * @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/documentlistproperties/documentliststartcommand + */ + +import { Command } from 'ckeditor5/src/core'; +import { first } from 'ckeditor5/src/utils'; +import { + expandListBlocksToCompleteList, + isListItemBlock +} from '../documentlist/utils/model'; + +/** + * The list start index command. It changes the `listStart` attribute of the selected list items, + * letting the user to choose the starting point of an ordered list. + * It is used by the {@link module:list/documentlistproperties~DocumentListProperties list properties feature}. + * + * @extends module:core/command~Command + */ +export default class DocumentListStartCommand extends Command { + /** + * @inheritDoc + */ + refresh() { + const value = this._getValue(); + + this.value = value; + this.isEnabled = value != null; + } + + /** + * Executes the command. + * + * @fires execute + * @param {Object} [options] + * @param {Number} [options.startIndex=1] The list start index. + */ + execute( options = {} ) { + const model = this.editor.model; + const document = model.document; + + let blocks = Array.from( document.selection.getSelectedBlocks() ) + .filter( block => isListItemBlock( block ) && block.getAttribute( 'listType' ) == 'numbered' ); + + blocks = expandListBlocksToCompleteList( blocks ); + + model.change( writer => { + for ( const block of blocks ) { + writer.setAttribute( 'listStart', options.startIndex || 1, block ); + } + } ); + } + + /** + * Checks the command's {@link #value}. + * + * @private + * @returns {Number|null} The current value. + */ + _getValue() { + const model = this.editor.model; + const document = model.document; + + const block = first( document.selection.getSelectedBlocks() ); + + if ( block && isListItemBlock( block ) && block.getAttribute( 'listType' ) == 'numbered' ) { + return block.getAttribute( 'listStart' ); + } + + return null; + } +} diff --git a/packages/ckeditor5-list/src/documentlistproperties/documentliststylecommand.js b/packages/ckeditor5-list/src/documentlistproperties/documentliststylecommand.js new file mode 100644 index 00000000000..9f624199d3d --- /dev/null +++ b/packages/ckeditor5-list/src/documentlistproperties/documentliststylecommand.js @@ -0,0 +1,140 @@ +/** + * @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/documentlistproperties/documentliststylecommand + */ + +import { Command } from 'ckeditor5/src/core'; +import { first } from 'ckeditor5/src/utils'; +import { + expandListBlocksToCompleteList, + isListItemBlock +} from '../documentlist/utils/model'; +import { getListTypeFromListStyleType } from './utils/style'; + +/** + * The list style command. It changes `listStyle` attribute of the selected list items, + * letting the user choose styles for the list item markers. + * It is used by the {@link module:list/documentlistproperties~DocumentListProperties list properties feature}. + * + * @extends module:core/command~Command + */ +export default class DocumentListStyleCommand extends Command { + /** + * Creates an instance of the command. + * + * @param {module:core/editor/editor~Editor} editor The editor instance. + * @param {String} defaultType The list type that will be used by default if the value was not specified during + * the command execution. + */ + constructor( editor, defaultType ) { + super( editor ); + + /** + * The default type of the list style. + * + * @protected + * @member {String} + */ + this._defaultType = defaultType; + } + + /** + * @inheritDoc + */ + refresh() { + this.value = this._getValue(); + this.isEnabled = this._checkEnabled(); + } + + /** + * Executes the command. + * + * @fires execute + * @param {Object} options + * @param {String|null} [options.type] The type of the list style, e.g. `'disc'` or `'square'`. If `null` is specified, the default + * style will be applied. + */ + execute( options = {} ) { + const model = this.editor.model; + const document = model.document; + + model.change( writer => { + this._tryToConvertItemsToList( options ); + + let blocks = Array.from( document.selection.getSelectedBlocks() ) + .filter( block => block.hasAttribute( 'listType' ) ); + + if ( !blocks.length ) { + return; + } + + blocks = expandListBlocksToCompleteList( blocks ); + + for ( const block of blocks ) { + writer.setAttribute( 'listStyle', options.type || this._defaultType, block ); + } + } ); + } + + /** + * Checks the command's {@link #value}. + * + * @private + * @returns {String|null} The current value. + */ + _getValue() { + const listItem = first( this.editor.model.document.selection.getSelectedBlocks() ); + + if ( isListItemBlock( listItem ) ) { + return listItem.getAttribute( 'listStyle' ); + } + + return null; + } + + /** + * Checks whether the command can be enabled in the current context. + * + * @private + * @returns {Boolean} Whether the command should be enabled. + */ + _checkEnabled() { + const editor = this.editor; + + const numberedList = editor.commands.get( 'numberedList' ); + const bulletedList = editor.commands.get( 'bulletedList' ); + + return numberedList.isEnabled || bulletedList.isEnabled; + } + + /** + * Check if the provided list style is valid. Also change the selection to a list if it's not set yet. + * + * @private + * @param {Object} options + * @param {String|null} [options.type] The type of the list style. If `null` is specified, the function does nothing. + */ + _tryToConvertItemsToList( options ) { + if ( !options.type ) { + return; + } + + const listType = getListTypeFromListStyleType( options.type ); + + if ( !listType ) { + return; + } + + const editor = this.editor; + const commandName = listType + 'List'; + const command = editor.commands.get( commandName ); + + if ( !command.value ) { + editor.execute( commandName ); + } + } +} diff --git a/packages/ckeditor5-list/src/documentlistproperties/utils/style.js b/packages/ckeditor5-list/src/documentlistproperties/utils/style.js new file mode 100644 index 00000000000..ca08a7f5a40 --- /dev/null +++ b/packages/ckeditor5-list/src/documentlistproperties/utils/style.js @@ -0,0 +1,41 @@ +/** + * @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/documentlistproperties/utils/style +*/ + +const BULLETED_LIST_STYLE_TYPES = [ 'disc', 'circle', 'square' ]; + +// There's a lot of them (https://www.w3.org/TR/css-counter-styles-3/#typedef-counter-style). +// Let's support only those that can be selected by ListPropertiesUI. +const NUMBERED_LIST_STYLE_TYPES = [ + 'decimal', + 'decimal-leading-zero', + 'lower-roman', + 'upper-roman', + 'lower-latin', + 'upper-latin', + 'lower-alpha', + 'upper-alpha' +]; + +/** +* Checks whether the given list-style-type is supported by numbered or bulleted list. +* +* @param {String} listStyleType +* @returns {'bulleted'|'numbered'|null} +*/ +export function getListTypeFromListStyleType( listStyleType ) { + if ( BULLETED_LIST_STYLE_TYPES.includes( listStyleType ) ) { + return 'bulleted'; + } + + if ( NUMBERED_LIST_STYLE_TYPES.includes( listStyleType ) ) { + return 'numbered'; + } + + return null; +} diff --git a/packages/ckeditor5-list/src/list.js b/packages/ckeditor5-list/src/list.js index 6165892d0cc..dc1a76879c4 100644 --- a/packages/ckeditor5-list/src/list.js +++ b/packages/ckeditor5-list/src/list.js @@ -37,7 +37,8 @@ export default class List extends Plugin { } /** - * The configuration of the {@link module:list/list~List list} feature. + * The configuration of the {@link module:list/list~List list} feature + * and the {@link module:list/documentlist~DocumentList document list} feature. * * ClassicEditor * .create( editorElement, { @@ -52,7 +53,7 @@ export default class List extends Plugin { */ /** - * The configuration of the {@link module:list/list~List} feature. + * The configuration of the {@link module:list/list~List} feature and the {@link module:list/documentlist~DocumentList} feature. * * Read more in {@link module:list/list~ListConfig}. * diff --git a/packages/ckeditor5-list/src/list/listediting.js b/packages/ckeditor5-list/src/list/listediting.js index 2d9d791858a..e3bfbc7459d 100644 --- a/packages/ckeditor5-list/src/list/listediting.js +++ b/packages/ckeditor5-list/src/list/listediting.js @@ -171,19 +171,18 @@ export default class ListEditing extends Plugin { evt.stop(); }, { context: 'li' } ); - const getCommandExecuter = commandName => { - return ( data, cancel ) => { - const command = this.editor.commands.get( commandName ); - - if ( command.isEnabled ) { - this.editor.execute( commandName ); - cancel(); - } - }; - }; - - editor.keystrokes.set( 'Tab', getCommandExecuter( 'indentList' ) ); - editor.keystrokes.set( 'Shift+Tab', getCommandExecuter( 'outdentList' ) ); + this.listenTo( editor.editing.view.document, 'tab', ( evt, data ) => { + const commandName = data.shiftKey ? 'outdentList' : 'indentList'; + const command = this.editor.commands.get( commandName ); + + if ( command.isEnabled ) { + editor.execute( commandName ); + + data.stopPropagation(); + data.preventDefault(); + evt.stop(); + } + }, { context: 'li' } ); } /** diff --git a/packages/ckeditor5-list/src/listproperties.js b/packages/ckeditor5-list/src/listproperties.js index cd074e8fd0c..4592fc2d13e 100644 --- a/packages/ckeditor5-list/src/listproperties.js +++ b/packages/ckeditor5-list/src/listproperties.js @@ -36,13 +36,17 @@ export default class ListProperties extends Plugin { } /** - * The configuration of the {@link module:list/listproperties~ListProperties list properties} feature. + * The configuration of the {@link module:list/listproperties~ListProperties list properties} feature and the + * {@link module:list/documentlistproperties~DocumentListProperties document list properties} feature. * * This configuration controls the individual list properties. For instance, it enables or disables specific editor commands - * operating on lists ({@link module:list/listproperties/liststylecommand~ListStyleCommand `listStyle`}, - * {@link module:list/listproperties/liststartcommand~ListStartCommand `listStart`}, - * {@link module:list/listproperties/listreversedcommand~ListReversedCommand `listReversed`}), the look of the UI - * (`numberedList` and `bulletedList` dropdowns), and the editor data pipeline (allowed HTML attributes). + * operating on lists ({@link module:list/listproperties/liststylecommand~ListStyleCommand `'listStyle'`}, + * {@link module:list/listproperties/liststartcommand~ListStartCommand `'listStart'`}, + * {@link module:list/listproperties/listreversedcommand~ListReversedCommand `'listReversed'`}, or on the document lists + * {@link module:list/documentlistproperties/documentliststylecommand~DocumentListStyleCommand `'listStyle'`}, + * {@link module:list/documentlistproperties/documentliststartcommand~DocumentListStartCommand `'listStart'`}, + * {@link module:list/documentlistproperties/documentlistreversedcommand~DocumentListReversedCommand `'listReversed'`}), the look of the UI + * (`'numberedList'` and `'bulletedList'` dropdowns), and the editor data pipeline (allowed HTML attributes). * * ClassicEditor * .create( editorElement, { @@ -88,7 +92,8 @@ export default class ListProperties extends Plugin { */ /** - * The configuration of the {@link module:list/listproperties~ListProperties} feature. + * The configuration of the {@link module:list/listproperties~ListProperties} feature and the + * {@link module:list/documentlistproperties~DocumentListProperties document list properties} feature. * * Read more in {@link module:list/listproperties~ListPropertiesConfig}. * diff --git a/packages/ckeditor5-list/src/listproperties/listreversedcommand.js b/packages/ckeditor5-list/src/listproperties/listreversedcommand.js index 080b9398caa..05b32557a85 100644 --- a/packages/ckeditor5-list/src/listproperties/listreversedcommand.js +++ b/packages/ckeditor5-list/src/listproperties/listreversedcommand.js @@ -30,9 +30,9 @@ export default class ListReversedCommand extends Command { /** * Executes the command. * + * @fires execute * @param {Object} options * @param {Boolean} [options.reversed=false] Whether the list should be reversed. - * @protected */ execute( options = {} ) { const model = this.editor.model; diff --git a/packages/ckeditor5-list/src/listproperties/liststartcommand.js b/packages/ckeditor5-list/src/listproperties/liststartcommand.js index ec025c6a823..8fb09f83049 100644 --- a/packages/ckeditor5-list/src/listproperties/liststartcommand.js +++ b/packages/ckeditor5-list/src/listproperties/liststartcommand.js @@ -29,9 +29,9 @@ export default class ListStartCommand extends Command { /** * Executes the command. * + * @fires execute * @param {Object} options * @param {Number} [options.startIndex=1] The list start index. - * @protected */ execute( options = {} ) { const model = this.editor.model; diff --git a/packages/ckeditor5-list/src/listproperties/liststylecommand.js b/packages/ckeditor5-list/src/listproperties/liststylecommand.js index dae224ff1bb..b1f2ed7998e 100644 --- a/packages/ckeditor5-list/src/listproperties/liststylecommand.js +++ b/packages/ckeditor5-list/src/listproperties/liststylecommand.js @@ -50,10 +50,10 @@ export default class ListStyleCommand extends Command { /** * Executes the command. * + * @fires execute * @param {Object} options * @param {String|null} [options.type] The type of the list style, e.g. `'disc'` or `'square'`. If `null` is specified, the default * style will be applied. - * @protected */ execute( options = {} ) { this._tryToConvertItemsToList( options ); diff --git a/packages/ckeditor5-list/tests/documentlist/_utils-tests/uid.js b/packages/ckeditor5-list/tests/documentlist/_utils-tests/uid.js new file mode 100644 index 00000000000..59cfb2008e0 --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/_utils-tests/uid.js @@ -0,0 +1,47 @@ +/** + * @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 testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; + +import stubUid from '../_utils/uid'; +import { ListItemUid } from '../../../src/documentlist/utils/model'; + +describe( 'stubUid()', () => { + testUtils.createSinonSandbox(); + + it( 'Should start from 0', () => { + stubUid( 0 ); + + expect( ListItemUid.next() ).to.equal( '000' ); + expect( ListItemUid.next() ).to.equal( '001' ); + expect( ListItemUid.next() ).to.equal( '002' ); + expect( ListItemUid.next() ).to.equal( '003' ); + expect( ListItemUid.next() ).to.equal( '004' ); + expect( ListItemUid.next() ).to.equal( '005' ); + expect( ListItemUid.next() ).to.equal( '006' ); + expect( ListItemUid.next() ).to.equal( '007' ); + expect( ListItemUid.next() ).to.equal( '008' ); + expect( ListItemUid.next() ).to.equal( '009' ); + expect( ListItemUid.next() ).to.equal( '00a' ); + expect( ListItemUid.next() ).to.equal( '00b' ); + } ); + + it( 'Should start from 0xa00 (default)', () => { + stubUid(); + + expect( ListItemUid.next() ).to.equal( 'a00' ); + expect( ListItemUid.next() ).to.equal( 'a01' ); + expect( ListItemUid.next() ).to.equal( 'a02' ); + expect( ListItemUid.next() ).to.equal( 'a03' ); + expect( ListItemUid.next() ).to.equal( 'a04' ); + expect( ListItemUid.next() ).to.equal( 'a05' ); + expect( ListItemUid.next() ).to.equal( 'a06' ); + expect( ListItemUid.next() ).to.equal( 'a07' ); + expect( ListItemUid.next() ).to.equal( 'a08' ); + expect( ListItemUid.next() ).to.equal( 'a09' ); + expect( ListItemUid.next() ).to.equal( 'a0a' ); + expect( ListItemUid.next() ).to.equal( 'a0b' ); + } ); +} ); diff --git a/packages/ckeditor5-list/tests/documentlist/_utils-tests/utils.js b/packages/ckeditor5-list/tests/documentlist/_utils-tests/utils.js new file mode 100644 index 00000000000..87c16bba02a --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/_utils-tests/utils.js @@ -0,0 +1,925 @@ +/** + * @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 Model from '@ckeditor/ckeditor5-engine/src/model/model'; +import { parse as parseModel } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { modelList, stringifyList } from '../_utils/utils'; + +describe( 'mockList()', () => { + it( 'Single bulleted list item', () => { + expect( modelList( [ + '* foo' + ] ) ).to.equalMarkup( + 'foo' + ); + } ); + + it( 'flat list', () => { + expect( modelList( [ + '* foo', + '* bar' + ] ) ).to.equalMarkup( + 'foo' + + 'bar' + ); + } ); + + it( 'list item after plain paragraph', () => { + expect( modelList( [ + 'foo', + '* bar' + ] ) ).to.equalMarkup( + 'foo' + + 'bar' + ); + } ); + + it( 'should allow leading space in list content', () => { + expect( modelList( [ + '* foo', + ' bar', + '* baz' + ] ) ).to.equalMarkup( + ' foo' + + ' bar' + + ' baz' + ); + } ); + + it( 'list item before plain paragraph', () => { + expect( modelList( [ + '* foo', + 'bar' + ] ) ).to.equalMarkup( + 'foo' + + 'bar' + ); + } ); + + it( 'list item with multiple blocks', () => { + expect( modelList( [ + '* foo', + ' bar', + ' baz' + ] ) ).to.equalMarkup( + 'foo' + + 'bar' + + 'baz' + ); + } ); + + it( 'flat list item with multiple blocks in the first item', () => { + expect( modelList( [ + '* foo', + ' bar', + '* baz' + ] ) ).to.equalMarkup( + 'foo' + + 'bar' + + 'baz' + ); + } ); + + it( 'flat list item with multiple blocks in the last item', () => { + expect( modelList( [ + '* foo', + '* bar', + ' baz' + ] ) ).to.equalMarkup( + 'foo' + + 'bar' + + 'baz' + ); + } ); + + it( 'mixed bulleted with numbered lists', () => { + expect( modelList( [ + '* foo', + '# bar', + '* baz' + ] ) ).to.equalMarkup( + 'foo' + + 'bar' + + 'baz' + ); + } ); + + it( 'numbered lists with blocks', () => { + expect( modelList( [ + '# foo', + '# bar', + ' baz' + ] ) ).to.equalMarkup( + 'foo' + + 'bar' + + 'baz' + ); + } ); + + it( 'list with nested lists', () => { + expect( modelList( [ + '* foo', + ' * bar', + '* baz' + ] ) ).to.equalMarkup( + 'foo' + + 'bar' + + 'baz' + ); + } ); + + it( 'list with nested list inside a single list item', () => { + expect( modelList( [ + '* foo', + ' * bar', + ' baz' + ] ) ).to.equalMarkup( + 'foo' + + 'bar' + + 'baz' + ); + } ); + + it( 'list with deep nested lists', () => { + expect( modelList( [ + '* foo', + ' * bar', + ' * baz' + ] ) ).to.equalMarkup( + 'foo' + + 'bar' + + 'baz' + ); + } ); + + it( 'list with indent drop', () => { + expect( modelList( [ + '* foo', + ' * bar', + ' * baz', + ' * abc', + '* 123' + ] ) ).to.equalMarkup( + 'foo' + + 'bar' + + 'baz' + + 'abc' + + '123' + ); + } ); + + it( 'list with higher indent drop', () => { + expect( modelList( [ + '* foo', + ' * bar', + ' * baz', + '* abc', + ' * 123' + ] ) ).to.equalMarkup( + 'foo' + + 'bar' + + 'baz' + + 'abc' + + '123' + ); + } ); + + it( 'lists with plain paragraph in the middle', () => { + expect( modelList( [ + '* foo', + ' * bar', + 'baz', + '* abc', + ' * 123' + ] ) ).to.equalMarkup( + 'foo' + + 'bar' + + 'baz' + + 'abc' + + '123' + ); + } ); + + it( 'should not alter selection brackets', () => { + expect( modelList( [ + '* fo[o', + ' * bar', + ' * b]az' + ] ) ).to.equalMarkup( + 'fo[o' + + 'bar' + + 'b]az' + ); + } ); + + it( 'should allow passing custom element', () => { + expect( modelList( [ + '* foo', + '* bar', + '* baz' + ] ) ).to.equalMarkup( + 'foo' + + 'bar' + + 'baz' + ); + } ); + + 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{id:abc}', + ' bar', + '* baz' + ] ) ).to.equalMarkup( + 'foo' + + 'bar' + + 'baz' + ); + } ); + + it( 'should allow to customize the list item id (prefix)', () => { + expect( modelList( [ + '* foo', + '* {id:abc}bar', + ' baz' + ] ) ).to.equalMarkup( + 'foo' + + 'bar' + + 'baz' + ); + } ); + + it( 'should allow to customize the list item id (with prefix)', () => { + expect( modelList( [ + '* foo', + '* bar{id:abc}', + ' baz' + ] ) ).to.equalMarkup( + 'foo' + + 'bar' + + 'baz' + ); + } ); + + it( 'should not parse the custom list item ID if provided in the following block of a list item', () => { + expect( modelList( [ + '* foo', + ' {id:abc}bar', + '* baz' + ] ) ).to.equalMarkup( + 'foo' + + '{id:abc}bar' + + 'baz' + ); + } ); + + it( 'should parse the custom list style', () => { + expect( modelList( [ + '* foo {style:abc}', + ' bar', + '* baz' + ] ) ).to.equalMarkup( + 'foo' + + 'bar' + + 'baz' + ); + } ); + + it( 'should parse the custom list start', () => { + expect( modelList( [ + '* foo {start:7}', + ' bar', + '* baz' + ] ) ).to.equalMarkup( + 'foo' + + 'bar' + + 'baz' + ); + } ); + + it( 'should parse the list reversed', () => { + expect( modelList( [ + '* foo {reversed:true}', + ' bar', + '* baz' + ] ) ).to.equalMarkup( + 'foo' + + 'bar' + + 'baz' + ); + } ); + + it( 'should not parse the custom list style if provided in the following block of a list item', () => { + expect( modelList( [ + '* foo {style:123}', + ' bar {style:abc}', + '* baz' + ] ) ).to.equalMarkup( + 'foo' + + 'bar {style:abc}' + + 'baz' + ); + } ); + + it( 'should parse the custom list style of the different adjacent list type', () => { + expect( modelList( [ + '* foo {style:123}', + '* bar', + '# abc {style:789}', + '# def' + ] ) ).to.equalMarkup( + 'foo' + + 'bar' + + 'abc' + + 'def' + ); + } ); + + it( 'should not forward `style` to different list', () => { + expect( modelList( [ + '* foo {style:xyz}', + '# bar' + ] ) ).to.equalMarkup( + 'foo' + + 'bar' + ); + } ); + + it( 'should not forward `start` to different list', () => { + expect( modelList( [ + '# foo {start:7}', + '* bar' + ] ) ).to.equalMarkup( + 'foo' + + 'bar' + ); + } ); + + it( 'should not forward `reversed` to different list', () => { + expect( modelList( [ + '# foo {reversed:true}', + '* bar' + ] ) ).to.equalMarkup( + 'foo' + + 'bar' + ); + } ); + + it( 'should parse string to lines', () => { + expect( modelList( ` + * foo + * bar + # num + block + # aaa + abc + * end + ` ) ).to.equalMarkup( + 'foo' + + 'bar' + + 'num' + + 'block' + + 'aaa' + + 'abc' + + 'end' + ); + } ); + + it( 'should parse string with mixed tabs and spaces to lines', () => { + expect( modelList( ` + * foo + \x20\x20# num + \ta + \x20\tb + \x20\x20\tc + \x20\x20\x20\td + \x20\x20\x20\x20e + ` ) ).to.equalMarkup( + 'foo' + + 'num' + + 'a' + + 'b' + + 'c' + + 'd' + + 'e' + ); + } ); + + it( 'should throw when indent is invalid', () => { + expect( () => modelList( [ + '* foo', + ' bar', + ' 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()', () => { + let model; + + beforeEach( () => { + model = new Model(); + + model.schema.register( 'paragraph', { inheritAllFrom: '$block' } ); + model.schema.extend( '$container', { allowAttributes: [ 'listType', 'listIndent', 'listItemId' ] } ); + } ); + + 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' ) ); + } ); + } ); + + 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' ) ); + } ); + } ); + + 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' ) ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-list/tests/documentlist/_utils/uid.js b/packages/ckeditor5-list/tests/documentlist/_utils/uid.js new file mode 100644 index 00000000000..469452e745b --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/_utils/uid.js @@ -0,0 +1,23 @@ +/** + * @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 { ListItemUid } from '../../../src/documentlist/utils/model'; + +/** + * Mocks the `ListItemUid.next()` with sequential numbers. + * + * @param {Number} [start=0xa00] The uid start number. + */ +export default function stubUid( start = 0xa00 ) { + const seq = sequence( start ); + + sinon.stub( ListItemUid, 'next' ).callsFake( () => seq.next().value ); +} + +function* sequence( num ) { + while ( true ) { + yield ( num++ ).toString( 16 ).padStart( 3, '000' ); + } +} diff --git a/packages/ckeditor5-list/tests/documentlist/_utils/utils.js b/packages/ckeditor5-list/tests/documentlist/_utils/utils.js new file mode 100644 index 00000000000..edbfecab109 --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/_utils/utils.js @@ -0,0 +1,410 @@ +/** + * @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 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'; + +/** + * Sets the editor model according to the specified input string. + * + * @param {module:engine/model/model~Model} model + * @param {String} input + * @returns {module:engine/model/selection~Selection} The selection marked in input string. + */ +export function prepareTest( model, input ) { + const modelRoot = model.document.getRoot( 'main' ); + + // Parse data string to model. + const parsedResult = parseModel( input, model.schema, { context: [ modelRoot.name ] } ); + + // Retrieve DocumentFragment and Selection from parsed model. + const modelDocumentFragment = parsedResult.model; + const selection = parsedResult.selection; + + // Ensure no undo step is generated. + model.enqueueChange( { isUndoable: false }, writer => { + // Replace existing model in document by new one. + writer.remove( writer.createRangeIn( modelRoot ) ); + writer.insert( modelDocumentFragment, modelRoot ); + + // Clean up previous document selection. + writer.setSelection( null ); + writer.removeSelectionAttribute( model.document.selection.getAttributeKeys() ); + } ); + + const ranges = []; + + for ( const range of selection.getRanges() ) { + const start = model.createPositionFromPath( modelRoot, range.start.path ); + const end = model.createPositionFromPath( modelRoot, range.end.path ); + + ranges.push( model.createRange( start, end ) ); + } + + return model.createSelection( ranges ); +} + +/** + * Returns set of test tools for the specified editor instance. + * + * @param {module:core/editor/editor~Editor} editor + * @returns {Object} + */ +export function setupTestHelpers( editor ) { + const model = editor.model; + const modelRoot = model.document.getRoot(); + const view = editor.editing.view; + + const test = { + test( input, output, actionCallback, testUndo ) { + const callbackSelection = prepareTest( model, input ); + + const modelBefore = getModelData( model ); + const viewBefore = getViewData( view, { withoutSelection: true } ); + + test.reconvertSpy = sinon.spy( editor.editing, 'reconvertItem' ); + actionCallback( callbackSelection ); + test.reconvertSpy.restore(); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( output ); + + if ( testUndo ) { + const modelAfter = getModelData( model ); + const viewAfter = getViewData( view, { withoutSelection: true } ); + + editor.execute( 'undo' ); + + expect( getModelData( model ), 'after undo' ).to.equalMarkup( modelBefore ); + expect( getViewData( view, { withoutSelection: true } ), 'after undo' ).to.equalMarkup( viewBefore ); + + editor.execute( 'redo' ); + + expect( getModelData( model ), 'after redo' ).to.equalMarkup( modelAfter ); + expect( getViewData( view, { withoutSelection: true } ), 'after redo' ).to.equalMarkup( viewAfter ); + } + }, + + insert( input, output, testUndo = true ) { + // Cut out inserted element that is between '[' and ']' characters. + const selStart = input.indexOf( '[' ) + 1; + const selEnd = input.indexOf( ']' ); + + const item = input.substring( selStart, selEnd ); + const modelInput = input.substring( 0, selStart ) + input.substring( selEnd ); + + const actionCallback = selection => { + model.change( writer => { + writer.insert( parseModel( item, model.schema ), selection.getFirstPosition() ); + } ); + }; + + test.test( modelInput, output, actionCallback, testUndo ); + }, + + remove( input, output ) { + const actionCallback = selection => { + model.change( writer => { + writer.remove( selection.getFirstRange() ); + } ); + }; + + test.test( input, output, actionCallback ); + }, + + changeType( input, output ) { + const actionCallback = selection => { + const element = selection.getFirstPosition().nodeAfter; + const newType = element.getAttribute( 'listType' ) == 'numbered' ? 'bulleted' : 'numbered'; + + model.change( writer => { + const itemsToChange = Array.from( selection.getSelectedBlocks() ); + + for ( const item of itemsToChange ) { + writer.setAttribute( 'listType', newType, item ); + } + } ); + }; + + test.test( input, output, actionCallback ); + }, + + renameElement( input, output, testUndo = true ) { + const actionCallback = selection => { + const element = selection.getFirstPosition().nodeAfter; + + model.change( writer => { + writer.rename( element, element.name == 'paragraph' ? 'heading1' : 'paragraph' ); + } ); + }; + + test.test( input, output, actionCallback, testUndo ); + }, + + removeListAttributes( input, output, testUndo = true ) { + const actionCallback = selection => { + const element = selection.getFirstPosition().nodeAfter; + + model.change( writer => { + writer.removeAttribute( 'listItemId', element ); + writer.removeAttribute( 'listType', element ); + writer.removeAttribute( 'listIndent', element ); + } ); + }; + + test.test( input, output, actionCallback, testUndo ); + }, + + setListAttributes( newIndent, input, output ) { + const actionCallback = selection => { + const element = selection.getFirstPosition().nodeAfter; + + model.change( writer => { + writer.setAttributes( { listType: 'bulleted', listIndent: newIndent, listItemId: 'x' }, element ); + } ); + }; + + test.test( input, output, actionCallback ); + }, + + changeIndent( newIndent, input, output ) { + const actionCallback = selection => { + model.change( writer => { + writer.setAttribute( 'listIndent', newIndent, selection.getFirstRange() ); + } ); + }; + + test.test( input, output, actionCallback ); + }, + + move( input, rootOffset, output, testUndo = true ) { + const actionCallback = selection => { + model.change( writer => { + const targetPosition = writer.createPositionAt( modelRoot, rootOffset ); + + writer.move( selection.getFirstRange(), targetPosition ); + } ); + }; + + test.test( input, output, actionCallback, testUndo ); + }, + + data( input, modelData, output = input ) { + editor.setData( input ); + + expect( editor.getData(), 'output data' ).to.equalMarkup( output ); + expect( getModelData( model, { withoutSelection: true } ), 'model data' ).to.equalMarkup( modelData ); + } + }; + + return test; +} + +/** + * Returns a model representation of a document list pseudo markdown notation: + * + * modelList( [ + * '* foo', + * '* bar' + * ] ); + * + * will output: + * + * 'foo' + + * 'bar' + * + * @param {Iterable.} lines + * @param {Object} options + * @param {Boolean} [options.ignoreIdConflicts=false] Whether should not throw if ID conflict is detected. + * @returns {String} + */ +export function modelList( lines, { ignoreIdConflicts = false } = {} ) { + const items = []; + const stack = []; + const seenIds = new Set(); + + if ( !Array.isArray( lines ) ) { + lines = lines + // Remove the first and last empty lines. + .replace( /^[^\n]*\n|\n[^\n]*$/g, '' ) + // Replace tab characters with spaces. + .replace( /^[\t ]+/gm, match => match.split( '' ).reduce( ( pad, char ) => ( + pad + ( char != '\t' ? char : ' '.repeat( 4 - pad.length % 4 ) ) + ), '' ) ); + + // Find the indent of the first line. + const basePad = lines.match( /^\s*/ )[ 0 ].length; + + // Convert to array. + lines = lines.split( '\n' ).map( line => line.substring( basePad ) ); + } + + let prevIndent = -1; + + for ( const [ idx, line ] of lines.entries() ) { + let [ , pad, marker, content ] = line.match( /^((?: {2})*(?:([*#]) )?)(.*)/ ); + const listIndent = pad.length / 2 - 1; + + if ( listIndent < 0 ) { + stack.length = 0; + } else if ( prevIndent > listIndent ) { + stack.length = listIndent + 1; + } + + if ( listIndent < 0 ) { + items.push( stringifyElement( content ) ); + } else { + if ( !stack[ listIndent ] && !marker ) { + throw new Error( 'Invalid indent: ' + line ); + } + + if ( !stack[ listIndent ] || marker ) { + const props = { + listType: marker == '#' ? 'numbered' : 'bulleted', + listItemId: String( idx ).padStart( 3, '0' ) + }; + + content = content.replace( /\s*{(?:(id|style|start|reversed):)([^}]+)}\s*/g, ( match, key, value ) => { + switch ( key ) { + case 'id': + props.listItemId = value; + break; + case 'style': + props.listStyle = value; + break; + case 'start': + props.listStart = parseInt( value ); + break; + case 'reversed': + props.listReversed = value; + break; + } + + return ''; + } ); + + if ( !ignoreIdConflicts && seenIds.has( props.listItemId ) ) { + throw new Error( 'ID conflict: ' + props.listItemId ); + } + + seenIds.add( props.listItemId ); + + if ( stack[ listIndent ] && stack[ listIndent ].listType != props.listType ) { + stack[ listIndent ] = Object.assign( {}, props ); + } else { + stack[ listIndent ] = Object.assign( stack[ listIndent ] || {}, props ); + } + } + + items.push( stringifyElement( content, { listIndent, ...stack[ listIndent ] } ) ); + } + + prevIndent = listIndent; + } + + return items.join( '' ); +} + +/** + * Returns document list pseudo markdown notation for a given document fragment or element. + * + * @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( fragmentOrElement ) { + const model = new Model(); + const lines = []; + + if ( fragmentOrElement.is( 'element' ) ) { + fragmentOrElement = new DocumentFragment( [ fragmentOrElement ] ); + } + + model.change( writer => { + for ( let node = fragmentOrElement.getChild( 0 ); node; node = node.nextSibling ) { + let pad = ''; + + if ( node.hasAttribute( 'listItemId' ) ) { + const marker = node.getAttribute( 'listType' ) == 'numbered' ? '#' : '*'; + const indentSpaces = ( node.getAttribute( 'listIndent' ) + 1 ) * 2; + const isFollowing = !!ListWalker.first( node, { sameIndent: true, sameAttributes: 'listItemId' } ); + + pad = isFollowing ? ' '.repeat( indentSpaces ) : marker.padStart( indentSpaces - 1 ) + ' '; + } + + lines.push( `${ pad }${ stringifyNode( node, writer ) }` ); + } + } ); + + return lines.join( '\n' ); +} + +function stringifyNode( node, writer ) { + const fragment = writer.createDocumentFragment(); + + if ( node.is( 'element', 'paragraph' ) ) { + for ( const child of node.getChildren() ) { + writer.append( writer.cloneElement( child ), fragment ); + } + } else { + const contentNode = writer.cloneElement( node ); + + for ( const key of contentNode.getAttributeKeys() ) { + if ( key.startsWith( 'list' ) ) { + writer.removeAttribute( key, contentNode ); + } + } + + writer.append( contentNode, fragment ); + } + + return stringifyModel( fragment ); +} + +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 ) + .sort( ( [ keyA ], [ keyB ] ) => keyA.localeCompare( keyB ) ) + .map( ( [ key, value ] ) => ` ${ key }="${ value }"` ) + .join( '' ); + + return `${ selectionBefore }` + + `<${ name }${ elementAttributes }${ listAttributes }>${ content }` + + `${ selectionAfter }`; +} diff --git a/packages/ckeditor5-list/tests/documentlist/converters-changes.js b/packages/ckeditor5-list/tests/documentlist/converters-changes.js new file mode 100644 index 00000000000..6de3d3ed86d --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/converters-changes.js @@ -0,0 +1,3142 @@ +/** + * @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 IndentEditing from '@ckeditor/ckeditor5-indent/src/indentediting'; +import TableEditing from '@ckeditor/ckeditor5-table/src/tableediting'; +import CodeBlockEditing from '@ckeditor/ckeditor5-code-block/src/codeblockediting'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; + +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import { parse as parseModel } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { setupTestHelpers } from './_utils/utils'; +import stubUid from './_utils/uid'; + +describe( 'DocumentListEditing - converters - changes', () => { + let editor, model, modelDoc, modelRoot, view, test; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + editor = await VirtualTestEditor.create( { + plugins: [ Paragraph, IndentEditing, ClipboardPipeline, BoldEditing, DocumentListEditing, UndoEditing, + BlockQuoteEditing, TableEditing, HeadingEditing, CodeBlockEditing ] + } ); + + model = editor.model; + modelDoc = model.document; + modelRoot = modelDoc.getRoot(); + + view = editor.editing.view; + + model.schema.register( 'foo', { + allowWhere: '$block', + allowAttributes: [ 'listIndent', 'listType' ], + isBlock: true, + isObject: true + } ); + + // Stub `view.scrollToTheSelection` as it will fail on VirtualTestEditor without DOM. + sinon.stub( view, 'scrollToTheSelection' ).callsFake( () => {} ); + stubUid(); + + test = setupTestHelpers( editor ); + } ); + + afterEach( async () => { + await editor.destroy(); + } ); + + describe( 'flat lists', () => { + describe( 'insert', () => { + it( 'list item at the beginning of same list type', () => { + test.insert( + 'p' + + '[x]' + + 'a', + + '

      p

      ' + + '
        ' + + '
      • x
      • ' + + '
      • a
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'list item in the middle of same list type', () => { + test.insert( + 'p' + + 'a' + + '[x]' + + 'b', + + '

      p

      ' + + '
        ' + + '
      • a
      • ' + + '
      • x
      • ' + + '
      • b
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'list item at the end of same list type', () => { + test.insert( + 'p' + + 'a' + + '[x]', + + '

      p

      ' + + '
        ' + + '
      • a
      • ' + + '
      • x
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'list item at the beginning of different list type', () => { + test.insert( + 'p' + + '[x]' + + 'a', + + '

      p

      ' + + '
        ' + + '
      1. x
      2. ' + + '
      ' + + '
        ' + + '
      • a
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'list item in the middle of different list type', () => { + test.insert( + 'p' + + 'a' + + '[x]' + + 'b', + + '

      p

      ' + + '
        ' + + '
      • a
      • ' + + '
      ' + + '
        ' + + '
      1. x
      2. ' + + '
      ' + + '
        ' + + '
      • b
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'list item at the end of different list type', () => { + test.insert( + 'p' + + 'a' + + '[x]', + + '

      p

      ' + + '
        ' + + '
      • a
      • ' + + '
      ' + + '
        ' + + '
      1. x
      2. ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'element between list items', () => { + test.insert( + 'a' + + '[x]' + + 'b', + + '
        ' + + '
      • a
      • ' + + '
      ' + + '

      x

      ' + + '
        ' + + '
      • b
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'list item that is not a paragraph', () => { + test.insert( + 'p' + + 'a' + + '[x]' + + 'b', + + '

      p

      ' + + '
        ' + + '
      • a
      • ' + + '
      • x

      • ' + + '
      • b
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'new block at the start of list item', () => { + test.insert( + 'p' + + 'a' + + '[x]' + + 'b', + + '

      p

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

        x

        ' + + '

        b

        ' + + '
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 3 ) ); + } ); + + it( 'new block at the end of list item', () => { + test.insert( + 'p' + + 'a' + + '[x]' + + 'b', + + '

      p

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

        a

        ' + + '

        x

        ' + + '
      • ' + + '
      • b
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'new block at the middle of list item', () => { + test.insert( + 'p' + + 'a' + + 'x1' + + '[x]' + + 'x2' + + 'b', + + '

      p

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

        x1

        ' + + '

        x

        ' + + '

        x2

        ' + + '
      • ' + + '
      • b
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'new list item in the middle of list item', () => { + test.insert( + 'p' + + 'a' + + 'x1' + + '[y]' + + 'x2' + + 'b', + + '

      p

      ' + + '
        ' + + '
      • a
      • ' + + '
      • x1
      • ' + + '
      • y
      • ' + + '
      • x2
      • ' + + '
      • b
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 2 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 2 ) ); + expect( test.reconvertSpy.secondCall.firstArg ).to.equal( modelRoot.getChild( 4 ) ); + } ); + } ); + + describe( 'remove', () => { + it( 'remove the first list item', () => { + test.remove( + 'p' + + '[a]' + + 'b' + + 'c', + + '

      p

      ' + + '
        ' + + '
      • b
      • ' + + '
      • c
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'remove list item from the middle', () => { + test.remove( + 'p' + + 'a' + + '[b]' + + 'c', + + '

      p

      ' + + '
        ' + + '
      • a
      • ' + + '
      • c
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'remove the last list item', () => { + test.remove( + 'p' + + 'a' + + 'b' + + '[c]', + + '

      p

      ' + + '
        ' + + '
      • a
      • ' + + '
      • b
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'remove the only list item', () => { + test.remove( + 'p' + + '[x]' + + 'p', + + '

      p

      ' + + '

      p

      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'remove element from between lists of same type', () => { + test.remove( + 'p' + + 'a' + + '[x]' + + 'b' + + 'p', + + '

      p

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

      p

      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'remove element from between lists of different type', () => { + test.remove( + 'p' + + 'a' + + '[x]' + + 'b' + + 'p', + + '

      p

      ' + + '
        ' + + '
      • a
      • ' + + '
      ' + + '
        ' + + '
      1. b
      2. ' + + '
      ' + + '

      p

      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'remove the first block of a list item', () => { + test.remove( + 'p' + + 'a' + + '[b1]' + + 'b2', + + '

      p

      ' + + '
        ' + + '
      • a
      • ' + + '
      • b2
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 2 ) ); + } ); + + it( 'remove the last block of a list item', () => { + test.remove( + 'p' + + 'a1' + + '[a2]' + + 'b', + + '

      p

      ' + + '
        ' + + '
      • a1
      • ' + + '
      • b
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'remove the middke block of a list item', () => { + test.remove( + 'p' + + 'a1' + + '[a2]' + + 'a3', + + '

      p

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

        a1

        ' + + '

        a3

        ' + + '
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + } ); + + describe( 'change type', () => { + it( 'change first list item', () => { + test.changeType( + 'p' + + '[a]' + + 'b' + + 'c', + + '

      p

      ' + + '
        ' + + '
      1. a
      2. ' + + '
      ' + + '
        ' + + '
      • b
      • ' + + '
      • c
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'change middle list item', () => { + test.changeType( + 'p' + + 'a' + + '[b]' + + 'c', + + '

      p

      ' + + '
        ' + + '
      • a
      • ' + + '
      ' + + '
        ' + + '
      1. b
      2. ' + + '
      ' + + '
        ' + + '
      • c
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'change last list item', () => { + test.changeType( + 'p' + + 'a' + + 'b' + + '[c]', + + '

      p

      ' + + '
        ' + + '
      • a
      • ' + + '
      • b
      • ' + + '
      ' + + '
        ' + + '
      1. c
      2. ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'change only list item', () => { + test.changeType( + 'p' + + '[a]' + + 'p', + + '

      p

      ' + + '
        ' + + '
      1. a
      2. ' + + '
      ' + + '

      p

      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'change element at the edge of two different lists #1', () => { + test.changeType( + 'a' + + 'b' + + '[c]' + + 'd', + + '
        ' + + '
      • a
      • ' + + '
      • b
      • ' + + '
      ' + + '
        ' + + '
      1. c
      2. ' + + '
      3. d
      4. ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'change element at the edge of two different lists #2', () => { + test.changeType( + 'a' + + '[b]' + + 'c' + + 'd', + + '
        ' + + '
      1. a
      2. ' + + '
      3. b
      4. ' + + '
      ' + + '
        ' + + '
      • c
      • ' + + '
      • d
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'change multiple elements - to other type', () => { + test.changeType( + 'a' + + '[b' + + 'c]' + + 'd', + + '
        ' + + '
      • a
      • ' + + '
      ' + + '
        ' + + '
      1. b
      2. ' + + '
      3. c
      4. ' + + '
      ' + + '
        ' + + '
      • d
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'change multiple elements - to same type', () => { + test.changeType( + 'a' + + '[b' + + 'c]' + + 'd', + + '
        ' + + '
      1. a
      2. ' + + '
      3. b
      4. ' + + '
      5. c
      6. ' + + '
      7. d
      8. ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'change of the first block of a list item', () => { + test.changeType( + 'a' + + '[b1]' + + 'b2' + + 'c', + + '
        ' + + '
      • a
      • ' + + '
      ' + + '
        ' + + '
      1. b1
      2. ' + + '
      ' + + '
        ' + + '
      • b2
      • ' + + '
      • c
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 2 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + expect( test.reconvertSpy.secondCall.firstArg ).to.equal( modelRoot.getChild( 2 ) ); + } ); + + it( 'change of the last block of a list item', () => { + test.changeType( + 'a' + + 'b1' + + '[b2]' + + 'c', + + '
        ' + + '
      • a
      • ' + + '
      • b1
      • ' + + '
      ' + + '
        ' + + '
      1. b2
      2. ' + + '
      ' + + '
        ' + + '
      • c
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 2 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + expect( test.reconvertSpy.secondCall.firstArg ).to.equal( modelRoot.getChild( 2 ) ); + } ); + + it( 'change of the middle block of a list item', () => { + test.changeType( + 'a' + + 'b1' + + '[b2]' + + 'b3' + + 'c', + + '
        ' + + '
      • a
      • ' + + '
      • b1
      • ' + + '
      ' + + '
        ' + + '
      1. b2
      2. ' + + '
      ' + + '
        ' + + '
      • b3
      • ' + + '
      • c
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 3 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + expect( test.reconvertSpy.secondCall.firstArg ).to.equal( modelRoot.getChild( 2 ) ); + expect( test.reconvertSpy.thirdCall.firstArg ).to.equal( modelRoot.getChild( 3 ) ); + } ); + + it( 'change outer list type with nested blockquote', () => { + test.changeType( + '[a]' + + '
      ' + + 'b' + + 'c' + + '
      ', + + '
        ' + + '
      1. ' + + 'a' + + '
          ' + + '
        • ' + + '
          ' + + '
            ' + + '
          • ' + + 'b' + + '
              ' + + '
            • c
            • ' + + '
            ' + + '
          • ' + + '
          ' + + '
          ' + + '
        • ' + + '
        ' + + '
      2. ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'change outer list type with nested code block', () => { + test.changeType( + '[a]' + + '' + + 'abc' + + '', + + '
        ' + + '
      1. ' + + 'a' + + '
          ' + + '
        • ' + + '
          ' +
          +										'abc' +
          +									'
          ' + + '
        • ' + + '
        ' + + '
      2. ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + } ); + + describe( 'rename list item element', () => { + it( 'rename first list item', () => { + test.renameElement( + '[a]' + + 'b', + + '
        ' + + '
      • a

      • ' + + '
      • b
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'rename middle list item', () => { + test.renameElement( + 'a' + + '[b]' + + 'c', + + '
        ' + + '
      • a
      • ' + + '
      • b

      • ' + + '
      • c
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'rename last list item', () => { + test.renameElement( + 'a' + + '[b]', + + '
        ' + + '
      • a
      • ' + + '
      • b

      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'rename first list item to paragraph', () => { + test.renameElement( + '[a]' + + 'b', + + '
        ' + + '
      • a
      • ' + + '
      • b
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'rename middle list item to paragraph', () => { + test.renameElement( + 'a' + + '[b]' + + 'c', + + '
        ' + + '
      • a
      • ' + + '
      • b
      • ' + + '
      • c
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'rename last list item to paragraph', () => { + test.renameElement( + 'a' + + '[b]', + + '
        ' + + '
      • a
      • ' + + '
      • b
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'rename first block of list item', () => { + test.renameElement( + 'a' + + '[b1]' + + 'b2' + + 'c', + + '
        ' + + '
      • a
      • ' + + '
      • ' + + '

        b1

        ' + + '

        b2

        ' + + '
      • ' + + '
      • c
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'rename last block of list item', () => { + test.renameElement( + 'a' + + 'b1' + + '[b2]' + + 'c', + + '
        ' + + '
      • a
      • ' + + '
      • ' + + '

        b1

        ' + + '

        b2

        ' + + '
      • ' + + '
      • c
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'rename first block of list item to paragraph', () => { + test.renameElement( + 'a' + + '[b1]' + + 'b2' + + 'c', + + '
        ' + + '
      • a
      • ' + + '
      • ' + + '

        b1

        ' + + '

        b2

        ' + + '
      • ' + + '
      • c
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'rename last block of list item to paragraph', () => { + test.renameElement( + 'a' + + 'b1' + + '[b2]' + + 'c', + + '
        ' + + '
      • a
      • ' + + '
      • ' + + '

        b1

        ' + + '

        b2

        ' + + '
      • ' + + '
      • c
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + } ); + + describe( 'remove list item attributes', () => { + it( 'first list item', () => { + test.removeListAttributes( + '[a]' + + 'b', + + '

      a

      ' + + '
        ' + + '
      • b
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 0 ) ); + } ); + + it( 'middle list item', () => { + test.removeListAttributes( + 'a' + + '[b]' + + 'c', + + '
        ' + + '
      • a
      • ' + + '
      ' + + '

      b

      ' + + '
        ' + + '
      • c
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'last list item', () => { + test.removeListAttributes( + 'a' + + '[b]', + + '
        ' + + '
      • a
      • ' + + '
      ' + + '

      b

      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'only list item', () => { + test.removeListAttributes( + 'p' + + '[x]' + + 'p', + + '

      p

      ' + + '

      x

      ' + + '

      p

      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'on non paragraph', () => { + test.removeListAttributes( + '[a]' + + 'b', + + '

      a

      ' + + '
        ' + + '
      • b
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'first block of list item', () => { + test.removeListAttributes( + '[a1]' + + 'a2', + + '

      a1

      ' + + '
        ' + + '
      • a2
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'last block of list item', () => { + test.removeListAttributes( + 'a1' + + '[a2]', + + '
        ' + + '
      • a1
      • ' + + '
      ' + + '

      a2

      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 0 ) ); + } ); + + it( 'middle block of list item', () => { + test.removeListAttributes( + 'a1' + + '[a2]' + + 'a3', + + '
        ' + + '
      • a1
      • ' + + '
      ' + + '

      a2

      ' + + '
        ' + + '
      • a3
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 2 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 0 ) ); + expect( test.reconvertSpy.secondCall.firstArg ).to.equal( modelRoot.getChild( 2 ) ); + } ); + } ); + + describe( 'set list item attributes', () => { + it( 'only paragraph', () => { + test.setListAttributes( 0, + '[a]', + + '
        ' + + '
      • a
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 0 ) ); + } ); + + it( 'on paragraph between paragraphs', () => { + test.setListAttributes( 0, + 'x' + + '[a]' + + 'x', + + '

      x

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

      x

      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'on element before list of same type', () => { + test.setListAttributes( 0, + '[x]' + + 'a', + + '
        ' + + '
      • x
      • ' + + '
      • a
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 0 ) ); + } ); + + it( 'on element after list of same type', () => { + test.setListAttributes( 0, + 'a' + + '[x]', + + '
        ' + + '
      • a
      • ' + + '
      • x
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'on element before list of different type', () => { + test.setListAttributes( 0, + '[x]' + + 'a', + + '
        ' + + '
      • x
      • ' + + '
      ' + + '
        ' + + '
      1. a
      2. ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 0 ) ); + } ); + + it( 'on element after list of different type', () => { + test.setListAttributes( 0, + 'a' + + '[x]', + + '
        ' + + '
      1. a
      2. ' + + '
      ' + + '
        ' + + '
      • x
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'on element between lists of same type', () => { + test.setListAttributes( 0, + 'a' + + '[x]' + + 'b', + + '
        ' + + '
      • a
      • ' + + '
      • x
      • ' + + '
      • b
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'before list item with the same id', () => { + test.setListAttributes( 0, + '[x]' + + 'a' + + 'b', + + '
        ' + + '
      • ' + + '

        x

        ' + + '

        a

        ' + + '
      • ' + + '
      • b
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + + it( 'after list item with the same id', () => { + test.setListAttributes( 0, + 'a' + + '[x]' + + 'b', + + '
        ' + + '
      • ' + + '

        a

        ' + + '

        x

        ' + + '
      • ' + + '
      • b
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 0 ) ); + } ); + } ); + + describe( 'move', () => { + it( 'list item inside same list', () => { + test.move( + 'p' + + 'a' + + '[b]' + + 'c', + + 4, // Move after last item. + + '

      p

      ' + + '
        ' + + '
      • a
      • ' + + '
      • c
      • ' + + '
      • b
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'out list item from list', () => { + test.move( + 'p' + + 'a' + + '[b]' + + 'p', + + 4, // Move after second paragraph. + + '

      p

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

      p

      ' + + '
        ' + + '
      • b
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'the only list item', () => { + test.move( + 'p' + + '[a]' + + 'p', + + 3, // Move after second paragraph. + + '

      p

      ' + + '

      p

      ' + + '
        ' + + '
      • a
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'list item between two lists of same type', () => { + test.move( + 'a' + + '[b]' + + 'p' + + 'c' + + 'd', + + 4, // Move between list item "c" and list item "d'. + + '
        ' + + '
      • a
      • ' + + '
      ' + + '

      p

      ' + + '
        ' + + '
      • c
      • ' + + '
      • b
      • ' + + '
      • d
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'list item between two lists of different type', () => { + test.move( + 'a' + + '[b]' + + 'p' + + 'c' + + 'd', + + 4, // Move between list item "c" and list item "d'. + + '
        ' + + '
      • a
      • ' + + '
      ' + + '

      p

      ' + + '
        ' + + '
      1. c
      2. ' + + '
      ' + + '
        ' + + '
      • b
      • ' + + '
      ' + + '
        ' + + '
      1. d
      2. ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'element between list items', () => { + test.move( + 'a' + + 'b' + + '[p]', + + 1, // Move between list item "a" and list item "b'. + + '
        ' + + '
      • a
      • ' + + '
      ' + + '

      p

      ' + + '
        ' + + '
      • b
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + } ); + } ); + + describe( 'nested lists', () => { + describe( 'insert', () => { + describe( 'same list type', () => { + it( 'after lower indent', () => { + test.insert( + 'p' + + '1' + + '[x]', + + '

      p

      ' + + '
        ' + + '
      • ' + + '1' + + '
          ' + + '
        • x
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'after lower indent (multi block)', () => { + test.insert( + 'p' + + '1a' + + '1b' + + '[xa' + + 'xb]', + + '

      p

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

        1a

        ' + + '

        1b

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

          xa

          ' + + '

          xb

          ' + + '
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'after lower indent, before same indent', () => { + test.insert( + 'p' + + '1' + + '[x]' + + '1.1', + + '

      p

      ' + + '
        ' + + '
      • ' + + '1' + + '
          ' + + '
        • x
        • ' + + '
        • 1.1
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'after lower indent, before same indent (multi block)', () => { + test.insert( + 'p' + + '1a' + + '1b' + + '[xa' + + 'xb]' + + '1.1a' + + '1.1b', + + '

      p

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

        1a

        ' + + '

        1b

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

          xa

          ' + + '

          xb

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

          1.1a

          ' + + '

          1.1b

          ' + + '
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'after lower indent, before lower indent', () => { + test.insert( + 'p' + + '1' + + '[x]' + + '2', + + '

      p

      ' + + '
        ' + + '
      • ' + + '1' + + '
          ' + + '
        • x
        • ' + + '
        ' + + '
      • ' + + '
      • 2
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'after lower indent, before lower indent (multi block)', () => { + test.insert( + 'p' + + '1a' + + '1b' + + '[xa' + + 'xb]' + + '2a' + + '2b', + + '

      p

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

        1a

        ' + + '

        1b

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

          xa

          ' + + '

          xb

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

        2a

        ' + + '

        2b

        ' + + '
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'after same indent', () => { + test.insert( + 'p' + + '1' + + '1.1' + + '[x]', + + '

      p

      ' + + '
        ' + + '
      • ' + + '1' + + '
          ' + + '
        • 1.1
        • ' + + '
        • x
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'after same indent (multi block)', () => { + test.insert( + 'p' + + '1a' + + '1b' + + '1.1a' + + '1.1b' + + '[xa' + + 'xb]', + + '

      p

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

        1a

        ' + + '

        1b

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

          1.1a

          ' + + '

          1.1b

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

          xa

          ' + + '

          xb

          ' + + '
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'after same indent, before higher indent', () => { + test.insert( + 'p' + + '1' + + '[x]' + + '1.1', + + '

      p

      ' + + '
        ' + + '
      • 1
      • ' + + '
      • ' + + 'x' + + '
          ' + + '
        • 1.1
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 3 ) ); + } ); + + it( 'after same indent, before higher indent (multi block)', () => { + test.insert( + 'p' + + '1a' + + '1b' + + '[xa' + + 'xb]' + + '1.1a' + + '1.1b', + + '

      p

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

        1a

        ' + + '

        1b

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

        xa

        ' + + '

        xb

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

          1.1a

          ' + + '

          1.1b

          ' + + '
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 2 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 5 ) ); + expect( test.reconvertSpy.secondCall.firstArg ).to.equal( modelRoot.getChild( 6 ) ); + } ); + + it( 'after higher indent, before higher indent', () => { + test.insert( + 'p' + + '1' + + '1.1' + + '[x]' + + '1.2', + + '

      p

      ' + + '
        ' + + '
      • ' + + '1' + + '
          ' + + '
        • 1.1
        • ' + + '
        ' + + '
      • ' + + '
      • ' + + 'x' + + '
          ' + + '
        • 1.2
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 4 ) ); + } ); + + it( 'after higher indent, before higher indent( multi block)', () => { + test.insert( + 'p' + + '1' + + '1.1' + + '1.1' + + '[x' + + 'x]' + + '1.2' + + '1.2', + + '

      p

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

          1.1

          ' + + '

          1.1

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

        x

        ' + + '

        x

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

          1.2

          ' + + '

          1.2

          ' + + '
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 2 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 6 ) ); + expect( test.reconvertSpy.secondCall.firstArg ).to.equal( modelRoot.getChild( 7 ) ); + } ); + + it( 'list items with too big indent', () => { + test.insert( + 'a' + + 'b' + + '[x' + + 'x' + + 'x]' + + 'c', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • ' + + 'b' + + '
            ' + + '
          • ' + + 'x' + + '
              ' + + '
            • x
            • ' + + '
            ' + + '
          • ' + + '
          • x
          • ' + + '
          ' + + '
        • ' + + '
        • c
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'additional block before higher indent', () => { + test.insert( + 'p' + + '1' + + '[x]' + + '2', + + '

      p

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

        1

        ' + + '

        x

        ' + + '
          ' + + '
        • 2
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + } ); + + describe( 'different list type', () => { + it( 'after lower indent, before same indent', () => { + test.insert( + 'p' + + '1' + + '[x]' + + '1.1', + + '

      p

      ' + + '
        ' + + '
      • ' + + '1' + + '
          ' + + '
        1. x
        2. ' + + '
        ' + + '
          ' + + '
        • 1.1
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'after same indent', () => { + test.insert( + 'p' + + '1' + + '1.1' + + '[x]', + + '

      p

      ' + + '
        ' + + '
      • ' + + '1' + + '
          ' + + '
        • 1.1
        • ' + + '
        ' + + '
          ' + + '
        1. x
        2. ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'after same indent, before higher indent', () => { + test.insert( + 'p' + + '1' + + '[x]' + + '1.1', + + '

      p

      ' + + '
        ' + + '
      • 1
      • ' + + '
      ' + + '
        ' + + '
      1. ' + + 'x' + + '
          ' + + '
        • 1.1
        • ' + + '
        ' + + '
      2. ' + + '
      ' + ); + } ); + + it( 'after higher indent, before higher indent', () => { + test.insert( + 'p' + + '1' + + '1.1' + + '[x]' + + '1.2', + + '

      p

      ' + + '
        ' + + '
      • ' + + '1' + + '
          ' + + '
        • 1.1
        • ' + + '
        ' + + '
      • ' + + '
      ' + + '
        ' + + '
      1. ' + + 'x' + + '
          ' + + '
        • 1.2
        • ' + + '
        ' + + '
      2. ' + + '
      ' + ); + } ); + + it( 'after higher indent, in nested list, different type', () => { + test.insert( + 'a' + + 'b' + + 'c' + + '[x]', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • ' + + 'b' + + '
            ' + + '
          • c
          • ' + + '
          ' + + '
        • ' + + '
        ' + + '
          ' + + '
        1. x
        2. ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + } ); + + // This case is pretty complex but it tests various edge cases concerning splitting lists. + it( 'element between nested list items - complex', () => { + test.insert( + 'a' + + 'b' + + 'c' + + 'd' + + '[x]' + + 'e' + + 'f' + + 'g' + + 'h' + + 'i' + + 'j' + + 'p', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • ' + + 'b' + + '
            ' + + '
          • ' + + 'c' + + '
              ' + + '
            1. d
            2. ' + + '
            ' + + '
          • ' + + '
          ' + + '
        • ' + + '
        ' + + '
      • ' + + '
      ' + + '

      x

      ' + + '
        ' + + '
      1. e
      2. ' + + '
      ' + + '
        ' + + '
      • ' + + 'f' + + '
          ' + + '
        • g
        • ' + + '
        ' + + '
      • ' + + '
      • ' + + 'h' + + '
          ' + + '
        1. i
        2. ' + + '
        ' + + '
      • ' + + '
      ' + + '
        ' + + '
      1. j
      2. ' + + '
      ' + + '

      p

      ' + ); + } ); + + it( 'element before indent "hole"', () => { + test.insert( + '1' + + '1.1' + + '[x]' + + '1.1.1' + + '2', + + '
        ' + + '
      • ' + + '1' + + '
          ' + + '
        • 1.1
        • ' + + '
        ' + + '
      • ' + + '
      ' + + '

      x

      ' + + '
        ' + + '
      • 1.1.1
      • ' + + '
      • 2
      • ' + + '
      ' + ); + } ); + + it( 'two list items with mismatched types inserted in one batch', () => { + test.test( + 'a' + + 'b[]', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • b
        • ' + + '
        ' + + '
          ' + + '
        1. c
        2. ' + + '
        ' + + '
          ' + + '
        • d
        • ' + + '
        ' + + '
      • ' + + '
      ', + + () => { + const item1 = 'c'; + const item2 = 'd'; + + model.change( writer => { + writer.append( parseModel( item1, model.schema ), modelRoot ); + writer.append( parseModel( item2, model.schema ), modelRoot ); + } ); + } + ); + } ); + } ); + + describe( 'remove', () => { + it( 'the first nested item', () => { + test.remove( + 'a' + + '[b]' + + 'c', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • c
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'nested item from the middle', () => { + test.remove( + 'a' + + 'b' + + '[c]' + + 'd', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • b
        • ' + + '
        • d
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'the last nested item', () => { + test.remove( + 'a' + + 'b' + + '[c]', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • b
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'the only nested item', () => { + test.remove( + 'a' + + '[c]', + + '
        ' + + '
      • a
      • ' + + '
      ' + ); + } ); + + it( 'list item that separates two nested lists of same type', () => { + test.remove( + 'a' + + 'b' + + '[c]' + + 'd', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        1. b
        2. ' + + '
        3. d
        4. ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'list item that separates two nested lists of different type', () => { + test.remove( + 'a' + + 'b' + + '[c]' + + 'd', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        1. b
        2. ' + + '
        ' + + '
          ' + + '
        • d
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'item that has nested lists, previous item has same indent', () => { + test.remove( + 'a' + + '[b]' + + 'c' + + 'd', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • c
        • ' + + '
        • d
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'item that has nested lists, previous item has lower indent', () => { + test.remove( + 'a' + + '[b]' + + 'c' + + 'd', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • c
        • ' + + '
        • d
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'item that has nested lists, previous item has higher indent by 1', () => { + test.remove( + 'a' + + 'b' + + '[c]' + + 'd' + + 'e', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • b
        • ' + + '
        • ' + + 'd' + + '
            ' + + '
          1. e
          2. ' + + '
          ' + + '
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'item that has nested lists, previous item has higher indent by 2', () => { + test.remove( + 'a' + + 'b' + + 'c' + + '[d]' + + 'e', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • ' + + 'b' + + '
            ' + + '
          • c
          • ' + + '
          ' + + '
        • ' + + '
        • e
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'first list item that has nested list', () => { + test.remove( + '[a]' + + 'b' + + 'c', + + '
        ' + + '
      • ' + + 'b' + + '
          ' + + '
        • c
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + } ); + + describe( 'change type', () => { + it( 'list item that has nested items', () => { + test.changeType( + '[a]' + + 'b' + + 'c', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • b
        • ' + + '
        • c
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + // The change will be "prevented" by post fixer. + it( 'list item that is a nested item', () => { + test.changeType( + 'a' + + 'b' + + '[c]' + + 'd', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        1. b
        2. ' + + '
        ' + + '
          ' + + '
        • c
        • ' + + '
        ' + + '
          ' + + '
        1. d
        2. ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'changed list type at the same time as adding nested items', () => { + test.test( + 'a[]', + + '
        ' + + '
      1. ' + + 'a' + + '
          ' + + '
        • b
        • ' + + '
        • c
        • ' + + '
        ' + + '
      2. ' + + '
      ', + + () => { + const item1 = 'b'; + const item2 = 'c'; + + model.change( writer => { + writer.setAttribute( 'listType', 'numbered', modelRoot.getChild( 0 ) ); + writer.append( parseModel( item1, model.schema ), modelRoot ); + writer.append( parseModel( item2, model.schema ), modelRoot ); + } ); + } + ); + } ); + } ); + + describe( 'change indent', () => { + describe( 'same list type', () => { + it( 'indent last item of flat list', () => { + test.changeIndent( + 1, + + 'a' + + '[b]', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • b
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'indent middle item of flat list', () => { + test.changeIndent( + 1, + + 'a' + + '[b]' + + 'c', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • b
        • ' + + '
        ' + + '
      • ' + + '
      • c
      • ' + + '
      ' + ); + } ); + + it( 'indent last item in nested list', () => { + test.changeIndent( + 2, + + 'a' + + 'b' + + '[c]', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • ' + + 'b' + + '
            ' + + '
          • c
          • ' + + '
          ' + + '
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'indent middle item in nested list', () => { + test.changeIndent( + 2, + + 'a' + + 'b' + + '[c]' + + 'd', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • ' + + 'b' + + '
            ' + + '
          • c
          • ' + + '
          ' + + '
        • ' + + '
        • d
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + // Keep in mind that this test is different than "executing command on item that has nested list". + // A command is automatically indenting nested items so the hierarchy is preserved. + // Here we test conversion and the change is simple changing indent of one item. + // This may be true also for other tests in this suite, keep this in mind. + it( 'indent item that has nested list', () => { + test.changeIndent( + 1, + + 'a' + + '[b]' + + 'c', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • b
        • ' + + '
        • c
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'indent item that in view is a next sibling of item that has nested list', () => { + test.changeIndent( + 1, + + 'a' + + 'b' + + '[c]' + + 'd', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • b
        • ' + + '
        • c
        • ' + + '
        • d
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'outdent the first item of nested list', () => { + test.changeIndent( + 0, + + 'a' + + '[b]' + + 'c' + + 'd', + + '
        ' + + '
      • a
      • ' + + '
      • ' + + 'b' + + '
          ' + + '
        • c
        • ' + + '
        • d
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'outdent item from the middle of nested list', () => { + test.changeIndent( + 0, + + 'a' + + 'b' + + '[c]' + + 'd', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • b
        • ' + + '
        ' + + '
      • ' + + '
      • ' + + 'c' + + '
          ' + + '
        • d
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'outdent the last item of nested list', () => { + test.changeIndent( + 0, + + 'a' + + 'b' + + '[c]', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • b
        • ' + + '
        ' + + '
      • ' + + '
      • c
      • ' + + '
      ' + ); + } ); + + it( 'outdent the only item of nested list', () => { + test.changeIndent( + 1, + + 'a' + + 'b' + + '[c]' + + 'd', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • b
        • ' + + '
        • c
        • ' + + '
        • d
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'outdent item by two', () => { + test.changeIndent( + 0, + + 'a' + + 'b' + + '[c]' + + 'd', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • b
        • ' + + '
        ' + + '
      • ' + + '
      • c
      • ' + + '
      • d
      • ' + + '
      ' + ); + } ); + } ); + + describe( 'different list type', () => { + it( 'indent middle item of flat list', () => { + test.changeIndent( + 1, + + 'a' + + '[b]' + + 'c', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        1. b
        2. ' + + '
        ' + + '
      • ' + + '
      • c
      • ' + + '
      ' + ); + } ); + + it( 'indent item that has nested list', () => { + test.changeIndent( + 1, + + 'a' + + '[b]' + + 'c', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        1. b
        2. ' + + '
        ' + + '
          ' + + '
        • c
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'indent item that in view is a next sibling of item that has nested list #1', () => { + test.changeIndent( + 1, + + 'a' + + 'b' + + '[c]' + + 'd', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • b
        • ' + + '
        ' + + '
          ' + + '
        1. c
        2. ' + + '
        ' + + '
          ' + + '
        • d
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'outdent the first item of nested list', () => { + test.changeIndent( + 0, + + 'a' + + '[b]' + + 'c' + + 'd', + + '
        ' + + '
      • a
      • ' + + '
      • ' + + 'b' + + '
          ' + + '
        • c
        • ' + + '
        • d
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'outdent the only item of nested list', () => { + test.changeIndent( + 1, + + 'a' + + 'b' + + '[c]' + + 'd', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • b
        • ' + + '
        • c
        • ' + + '
        • d
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'outdent item by two', () => { + test.changeIndent( + 0, + + 'a' + + 'b' + + '[c]' + + 'd', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • b
        • ' + + '
        ' + + '
      • ' + + '
      ' + + '
        ' + + '
      1. c
      2. ' + + '
      ' + + '
        ' + + '
      • d
      • ' + + '
      ' + ); + } ); + } ); + } ); + + describe( 'rename list item element', () => { + it( 'rename top list item', () => { + test.renameElement( + '[a]' + + 'b', + + '
        ' + + '
      • ' + + '

        a

        ' + + '
          ' + + '
        • ' + + 'b' + + + '
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'rename nested list item', () => { + test.renameElement( + 'a' + + '[b]', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • ' + + '

          b

          ' + + '
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + } ); + + describe( 'remove list item attributes', () => { + it( 'rename nested item from the middle #1', () => { + test.removeListAttributes( + 'a' + + 'b' + + '[c]' + + 'd', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • b
        • ' + + '
        ' + + '
      • ' + + '
      ' + + '

      c

      ' + + '
        ' + + '
      • d
      • ' + + '
      ' + ); + } ); + + it( 'rename nested item from the middle #2 - nightmare example', () => { + test.removeListAttributes( + // Indents in this example should be fixed by post fixer. + // This nightmare example checks if structure of the list is kept as intact as possible. + 'a' + + 'b' + + '[c]' + + 'd' + + 'e' + + 'f' + + 'g' + + 'h' + + 'i' + + 'j' + + 'k' + + 'l' + + 'm', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • b
        • ' + + '
        ' + + '
      • ' + + '
      ' + + '

      c

      ' + + '
        ' + + '
      • d
      • ' + + '
      • ' + + 'e' + + '
          ' + + '
        • f
        • ' + + '
        ' + + '
      • ' + + '
      • ' + + 'g' + + '
          ' + + '
        • ' + + 'h' + + '
            ' + + '
          • i
          • ' + + '
          ' + + '
        • ' + + '
        ' + + '
      • ' + + '
      • ' + + 'j' + + '
          ' + + '
        • k
        • ' + + '
        ' + + '
      • ' + + '
      • ' + + 'l' + + '
          ' + + '
        • m
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'rename nested item from the middle #3 - manual test example', () => { + test.removeListAttributes( + // Indents in this example should be fixed by post fixer. + // This example checks a bug found by testing manual test. + 'a' + + 'b' + + '[c]' + + 'd' + + 'e' + + 'f' + + 'g' + + 'h' + + '' + + '' + + 'k' + + 'l', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • b
        • ' + + '
        ' + + '
      • ' + + '
      ' + + '

      c

      ' + + '
        ' + + '
      • ' + + 'd' + + '
          ' + + '
        • e
        • ' + + '
        • f
        • ' + + '
        • g
        • ' + + '
        • h
        • ' + + '
        ' + + '
      • ' + + '
      • ' + + '' + + '
          ' + + '
        • ' + + '' + + '
            ' + + '
          1. k
          2. ' + + '
          3. l
          4. ' + + '
          ' + + '
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'rename the only nested item', () => { + test.removeListAttributes( + 'a' + + '[b]', + + '
        ' + + '
      • a
      • ' + + '
      ' + + '

      b

      ' + ); + } ); + } ); + + describe( 'set list item attributes', () => { + it( 'element into first item in nested list', () => { + test.setListAttributes( + 1, + + 'a' + + '[b]', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • b
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'element into last item in nested list', () => { + test.setListAttributes( + 1, + + 'a' + + 'b' + + '[c]', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • b
        • ' + + '
        • c
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'element into a first item in deeply nested list', () => { + test.setListAttributes( + 2, + + 'a' + + 'b' + + '[c]' + + 'd', + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • ' + + 'b' + + '
            ' + + '
          • c
          • ' + + '
          ' + + '
        • ' + + '
        ' + + '
      • ' + + '
      • d
      • ' + + '
      ' + ); + } ); + } ); + + describe( 'move', () => { + // Since move is in fact remove + insert and does not event have its own converter, only a few cases will be tested here. + it( 'out nested list items', () => { + test.move( + 'a' + + '[b' + + 'c]' + + 'd' + + 'e' + + 'x', + + 6, + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • ' + + 'd' + + '
            ' + + '
          • e
          • ' + + '
          ' + + '
        • ' + + '
        ' + + '
      • ' + + '
      ' + + '

      x

      ' + + '
        ' + + '
      • ' + + 'b' + + '
          ' + + '
        • c
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'nested list items between lists of same type', () => { + test.move( + 'a' + + 'b' + + '[c' + + 'd]' + + 'e' + + 'x' + + 'f' + + 'g', + + 7, + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • ' + + 'b' + + '
            ' + + '
          • e
          • ' + + '
          ' + + '
        • ' + + '
        ' + + '
      • ' + + '
      ' + + '

      x

      ' + + '
        ' + + '
      • ' + + 'f' + + '
          ' + + '
        • ' + + 'c' + + '
            ' + + '
          • d
          • ' + + '
          ' + + '
        • ' + + '
        ' + + '
      • ' + + '
      • g
      • ' + + '
      ' + ); + } ); + + it( 'nested list items between lists of different type', () => { + test.move( + 'a' + + 'b' + + '[c' + + 'd]' + + 'e' + + 'x' + + 'f' + + 'g', + + 7, + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • ' + + 'b' + + '
            ' + + '
          • e
          • ' + + '
          ' + + '
        • ' + + '
        ' + + '
      • ' + + '
      ' + + '

      x

      ' + + '
        ' + + '
      1. ' + + 'f' + + '
          ' + + '
        • ' + + 'c' + + '
            ' + + '
          • d
          • ' + + '
          ' + + '
        • ' + + '
        ' + + '
          ' + + '
        1. g
        2. ' + + '
        ' + + '
      2. ' + + '
      ' + ); + } ); + + it( 'element between nested list', () => { + test.move( + 'a' + + 'b' + + 'c' + + 'd' + + '[x]', + + 2, + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • b
        • ' + + '
        ' + + '
      • ' + + '
      ' + + '

      x

      ' + + '
        ' + + '
      • ' + + 'c' + + '
          ' + + '
        • d
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'multiple nested list items of different types #1 - fix at start', () => { + test.move( + 'a' + + 'b' + + '[c' + + 'd' + + 'e]' + + 'f' + + 'g' + + 'h' + + 'i', + + 8, + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • b
        • ' + + '
        ' + + '
          ' + + '
        1. f
        2. ' + + '
        ' + + '
      • ' + + '
      • ' + + 'g' + + '
          ' + + '
        1. h
        2. ' + + '
        ' + + '
          ' + + '
        • c
        • ' + + '
        ' + + '
      • ' + + '
      • ' + + 'd' + + '
          ' + + '
        1. e
        2. ' + + '
        3. i
        4. ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'multiple nested list items of different types #2 - fix at end', () => { + test.move( + 'a' + + 'b' + + '[c' + + 'd' + + 'e]' + + 'f' + + 'g' + + 'h' + + 'i', + + 8, + + '
        ' + + '
      • ' + + 'a' + + '
          ' + + '
        • b
        • ' + + '
        ' + + '
          ' + + '
        1. f
        2. ' + + '
        ' + + '
      • ' + + '
      • ' + + 'g' + + '
          ' + + '
        • h
        • ' + + '
        • c
        • ' + + '
        ' + + '
      • ' + + '
      • ' + + 'd' + + '
          ' + + '
        1. e
        2. ' + + '
        ' + + '
          ' + + '
        • i
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-list/tests/documentlist/converters-data.js b/packages/ckeditor5-list/tests/documentlist/converters-data.js new file mode 100644 index 00000000000..7f3b823620b --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/converters-data.js @@ -0,0 +1,2509 @@ +/** + * @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 IndentEditing from '@ckeditor/ckeditor5-indent/src/indentediting'; +import TableEditing from '@ckeditor/ckeditor5-table/src/tableediting'; +import CodeBlockEditing from '@ckeditor/ckeditor5-code-block/src/codeblockediting'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; + +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import { setupTestHelpers } from './_utils/utils'; +import stubUid from './_utils/uid'; + +describe( 'DocumentListEditing - converters - data pipeline', () => { + let editor, model, view, test; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + editor = await VirtualTestEditor.create( { + plugins: [ Paragraph, IndentEditing, ClipboardPipeline, BoldEditing, DocumentListEditing, UndoEditing, + BlockQuoteEditing, TableEditing, HeadingEditing, CodeBlockEditing ] + } ); + + model = editor.model; + view = editor.editing.view; + + model.schema.register( 'foo', { + allowWhere: '$block', + allowAttributes: [ 'listIndent', 'listType' ], + isBlock: true, + isObject: true + } ); + + // Stub `view.scrollToTheSelection` as it will fail on VirtualTestEditor without DOM. + sinon.stub( view, 'scrollToTheSelection' ).callsFake( () => {} ); + stubUid(); + + test = setupTestHelpers( editor ); + } ); + + afterEach( async () => { + await editor.destroy(); + } ); + + describe( 'flat lists', () => { + it( 'single item', () => { + test.data( + '
      • x
      ', + 'x' + ); + } ); + + it( 'single item with spaces', () => { + test.data( + '
      •  x 
      ', + ' x ' + ); + } ); + + it( 'multiple items', () => { + test.data( + '
        ' + + '
      • a
      • ' + + '
      • b
      • ' + + '
      • c
      • ' + + '
      ', + + 'a' + + 'b' + + 'c' + ); + } ); + + it( 'single multi-block item', () => { + test.data( + '
        ' + + '
      • ' + + '

        a

        ' + + '

        b

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

        a

        ' + + '

        b

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

        c

        ' + + '

        d

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

        a

        ' + + '

        b

        ' + + '

        c

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

        d

        ' + + '

        e

        ' + + '

        f

        ' + + '

        g

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

      xxx

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

      yyy

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

      xxx

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

      yyy

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

      xxx

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

      yyy

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

        xxx

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

      c

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

      c

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

      foo

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

      foo

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

      xxx

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

      yyy

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

        foo

        ' + + '

        bar

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

      text

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

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

      text

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

      text

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

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

      text

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

      text

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

      text

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

        bar

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

        foo

        ' + + '

        bar

        ' + + '

        baz

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

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

      • ' + + '
      • Bar

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

        Foo

        ' + + '
          ' + + '
        1. Bar

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

        Foo

        ' + + '

        Bar

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

        123

        ' + + '
      2. ' + + '
      ' + + '
        ' + + '
      • ' + + '

        Foo

        ' + + '

        Bar

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

        Foo

        ' + + '

        Bar

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

        Bar

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

        Bar

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

        123

        ' + + '

        456

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

          Foo

          ' + + '

          Bar

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

        123

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

          Foo

          ' + + '

          Bar

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

        456

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

        Bar

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

        Foo

        ' + + '

        Bar

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

        Bar

        ' + + '
      • ' + + '
      • ' + + 'Foz' + + '

        Baz

        ' + + '
      • ' + + '
      ', + + 'Foo' + + 'Bar' + + 'Foz' + + 'Baz', + + '
        ' + + '
      • ' + + '

        Foo

        ' + + '

        Bar

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

        Foz

        ' + + '

        Baz

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

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

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

        Bar

        ' + + '
          ' + + '
        • ' + + '123' + + '

          456

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

        Foo

        ' + + '

        Bar

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

          123

          ' + + '

          456

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

        Bar

        ' + + '
          ' + + '
        • ' + + '123' + + '

          456

          ' + + '
        • ' + + '
        ' + + '
      2. ' + + '
      3. ' + + 'abc' + + '

        def

        ' + + '
      4. ' + + '
      ', + + 'Foo' + + 'Bar' + + '123' + + '456' + + 'abc' + + 'def', + + '
        ' + + '
      1. ' + + '

        Foo

        ' + + '

        Bar

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

          123

          ' + + '

          456

          ' + + '
        • ' + + '
        ' + + '
      2. ' + + '
      3. ' + + '

        abc

        ' + + '

        def

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

        Foo

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

        Foo

        ' + + '

        Bar

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

        Foo

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

        Foz

        ' + + 'Baz' + + '
      • ' + + '
      ', + + 'Foo' + + 'Bar' + + 'Foz' + + 'Baz', + + '
        ' + + '
      • ' + + '

        Foo

        ' + + '

        Bar

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

        Foz

        ' + + '

        Baz

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

        Bar

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

        Bar

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

        Foo

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

          123

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

        Foo

        ' + + '

        Bar

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

          123

          ' + + '

          456

          ' + + '
        • ' + + '
        ' + + '
      2. ' + + '
      ' + ); + } ); + + it( 'nested items #2', () => { + test.data( + '
        ' + + '
      1. ' + + '

        Foo

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

          123

          ' + + '456' + + '
        • ' + + '
        ' + + '
      2. ' + + '
      3. ' + + '

        abc

        ' + + 'def' + + '
      4. ' + + '
      ', + + 'Foo' + + 'Bar' + + '123' + + '456' + + 'abc' + + 'def', + + '
        ' + + '
      1. ' + + '

        Foo

        ' + + '

        Bar

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

          123

          ' + + '

          456

          ' + + '
        • ' + + '
        ' + + '
      2. ' + + '
      3. ' + + '

        abc

        ' + + '

        def

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

        Bar

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

        Foo

        ' + + '

        Bar

        ' + + '

        Baz

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

        Foo

        ' + + '

        Bar

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

        Txt

        ' + + '

        Foo

        ' + + '

        Bar

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

        Foo

        ' + + '

        Bar

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

        Foo

        ' + + '

        Bar

        ' + + '

        Text

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

        Foo

        ' + + '

        Bar

        ' + + '

        Baz

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

        Bar

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

        Bar

        ' + + '

        Baz

        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'item inline and block + item inline', () => { + test.data( + '
        ' + + '
      • ' + + 'Foo' + + '

        Bar

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

        Foo

        ' + + '

        Bar

        ' + + '
      • ' + + '
      • Baz
      • ' + + '
      ' + ); + } ); + + it( 'multiple items inline/block mix', () => { + test.data( + '
        ' + + '
      • ' + + 'Txt' + + '

        Foo

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

        Baz

        ' + + '123' + + '
      • ' + + '
      ', + + 'Txt' + + 'Foo' + + 'Bar' + + 'Baz' + + '123', + + '
        ' + + '
      • ' + + '

        Txt

        ' + + '

        Foo

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

        Bar

        ' + + '

        Baz

        ' + + '

        123

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

        Bar

        ' + + '
      • ' + + '
      • ' + + 'Baz' + + '

        123

        ' + + '456' + + '
          ' + + '
        1. ' + + 'ABC' + + '

          DEF

          ' + + '
        2. ' + + '
        3. GHI
        4. ' + + '
        ' + + '
      • ' + + '
      ', + + 'Foo' + + 'Bar' + + 'Baz' + + '123' + + '456' + + 'ABC' + + 'DEF' + + 'GHI', + + '
        ' + + '
      • ' + + '

        Foo

        ' + + '

        Bar

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

        Baz

        ' + + '

        123

        ' + + '

        456

        ' + + '
          ' + + '
        1. ' + + '

          ABC

          ' + + '

          DEF

          ' + + '
        2. ' + + '
        3. GHI
        4. ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'list with empty inline element', () => { + test.data( + '
        ' + + '
      • ' + + 'Foo' + + '

        Bar

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

        Foo

        ' + + '

        Bar

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

        foo

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

      foo

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

        foo

        ' + + '

        bar

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

      foo

      ' + + '

      bar

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

        foo

        ' + + '

        bar

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

      foo

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

        foo

        ' + + '

        bar

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

      bar

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

        foo

        ' + + '

        bar

        ' + + '

        baz

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

      bar

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

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

       

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

      text before

      ' + + '
        ' + + '
      • ' + + 'text' + + '
          ' + + '
        • inner
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'block elements wrapping nested ul - invalid blocks', () => { + test.data( + '
        ' + + '
      • ' + + 'a' + + '' + + '' + + '' + + '' + + '
        ' + + '
        ' + + '
          ' + + '
        • b
        • ' + + '
        • c' + + '
            ' + + '
          • ' + + 'd' + + '' + + '' + + '' + + '' + + '
            ' + + 'e' + + '
            ' + + '
          • ' + + '
          ' + + '
        • ' + + '
        ' + + '
        ' + + '
        ' + + 'f' + + '
      • ' + + '
      • g
      • ' + + '
      ', + + 'a' + + '' + + '' + + '' + + '' + + 'b' + + '' + + '' + + 'c' + + '' + + '' + + 'd' + + '' + + '
      ' + + '' + + '' + + 'e' + + '' + + '' + + '
      ' + + '' + + '' + + '' + + 'f' + + 'g', + + '
        ' + + '
      • ' + + '

        a

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

            d

            ' + + '
            ' + + '
            e
            ' + + '
            ' + + '
          • ' + + '
          ' + + '
        • ' + + '
        ' + + '
        ' + + '
        ' + + '

        f

        ' + + '
      • ' + + '
      • g
      • ' + + '
      ' + ); + } ); + + it( 'deeply nested block elements wrapping nested ul', () => { + test.data( + '
        ' + + '
      • ' + + 'a' + + '
        ' + + '
        ' + + '
          ' + + '
        • b
        • ' + + '
        • c' + + '
            ' + + '
          • d' + + '
            ' + + '
              ' + + '
            • e
            • ' + + '
            ' + + '
            ' + + '
          • ' + + '
          ' + + '
        • ' + + '
        ' + + '
        ' + + '
        ' + + 'f' + + '
      • ' + + '
      • g
      • ' + + '
      ', + + 'a' + + 'b' + + 'c' + + 'd' + + 'e' + + 'f' + + 'g', + + '
        ' + + '
      • ' + + '

        a

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

        f

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

      foo

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

      bar

      ', + + 'foo' + + '1' + + '1.1' + + 'bar' + ); + } ); + + it( 'bullet list simple structure multiple blocks per item', () => { + test.data( + '

      foo

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

        1

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

          1.1a

          ' + + '

          1.1b

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

        1a

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

      bar

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

      foo

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

      bar

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

      foo

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

      bar

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

      foo

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

          xxx

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

        xxx

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

        xxx

        ' + + '
      ' + + '

      bar

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

      foo

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

         

        ' + + '
          ' + + '
        1. ' + + '3.1' + + '
            ' + + '
          • ' + + '3.1.1' + + '
            1. 3.1.1.1
            ' + + '
            • 3.1.1.2
            ' + + '
          • ' + + '
          • 3.1.2
          • ' + + '
          ' + + '
        2. ' + + '
        ' + + '

        xxx

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

      bar

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

      foo

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

      a

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

        b

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

        d

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

      e

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

        foo

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

        bar

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

        foo

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

        bar

        ' + ); + } ); + + it( 'should properly listIndent when list nested in other block', () => { + test.data( + '
          ' + + '
        • ' + + 'a' + + '' + + '' + + '' + + '' + + '
          ' + + '
          ' + + '
            ' + + '
          • b
          • ' + + '
          • c' + + '
              ' + + '
            • ' + + 'd' + + '' + + '' + + '' + + '' + + '
              e
              ' + + '
            • ' + + '
            ' + + '
          • ' + + '
          ' + + '
          ' + + '
          ' + + 'f' + + '
        • ' + + '
        • g
        • ' + + '
        ', + + 'a' + + '' + + '' + + '' + + '' + + 'b' + + '' + + '' + + 'c' + + '' + + '' + + 'd' + + '' + + '
        ' + + '' + + '' + + 'e' + + '' + + '' + + '
        ' + + '' + + '' + + '' + + 'f' + + 'g', + + '
          ' + + '
        • ' + + '

          a

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

              d

              ' + + '
              ' + + '' + + '' + + '' + + '' + + '' + + '' + + '
              e
              ' + + '
              ' + + '
            • ' + + '
            ' + + '
          • ' + + '
          ' + + '
          ' + + '
          ' + + '

          f

          ' + + '
        • ' + + '
        • g
        • ' + + '
        ' + ); + } ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-list/tests/documentlist/converters.js b/packages/ckeditor5-list/tests/documentlist/converters.js new file mode 100644 index 00000000000..23aa5a479f9 --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/converters.js @@ -0,0 +1,1009 @@ +/** + * @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 ModelRange from '@ckeditor/ckeditor5-engine/src/model/range'; + +import BoldEditing from '@ckeditor/ckeditor5-basic-styles/src/bold/boldediting'; +import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting'; +import ClipboardPipeline from '@ckeditor/ckeditor5-clipboard/src/clipboardpipeline'; +import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting'; +import HeadingEditing from '@ckeditor/ckeditor5-heading/src/headingediting'; +import IndentEditing from '@ckeditor/ckeditor5-indent/src/indentediting'; +import TableEditing from '@ckeditor/ckeditor5-table/src/tableediting'; +import AlignmentEditing from '@ckeditor/ckeditor5-alignment/src/alignmentediting'; +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 { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; +import stubUid from './_utils/uid'; + +describe( 'DocumentListEditing - converters', () => { + let editor, model, modelDoc, modelRoot, view, viewDoc, viewRoot; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + editor = await VirtualTestEditor.create( { + plugins: [ Paragraph, IndentEditing, ClipboardPipeline, BoldEditing, DocumentListEditing, UndoEditing, + BlockQuoteEditing, TableEditing, HeadingEditing, AlignmentEditing ] + } ); + + model = editor.model; + modelDoc = model.document; + modelRoot = modelDoc.getRoot(); + + view = editor.editing.view; + viewDoc = view.document; + viewRoot = viewDoc.getRoot(); + + model.schema.register( 'foo', { + allowWhere: '$block', + allowAttributesOf: '$container', + isBlock: true, + isObject: true + } ); + + // Stub `view.scrollToTheSelection` as it will fail on VirtualTestEditor without DOM. + sinon.stub( view, 'scrollToTheSelection' ).callsFake( () => {} ); + stubUid(); + } ); + + afterEach( async () => { + await editor.destroy(); + } ); + + describe( 'position mapping', () => { + describe( 'flat lists', () => { + let mapper; + + beforeEach( () => { + mapper = editor.editing.mapper; + + editor.setData( + '

        a

        ' + + '
          ' + + '
        • b

        • ' + + '
        • c

        • ' + + '
        • d

        • ' + + '
        ' + + '

        e

        ' + + '
          ' + + '
        1. f

        2. ' + + '
        ' + + '

        g

        ' + ); + } ); + + /* + a + b + c + d + e + f + g + */ + + describe( 'view to model', () => { + function testList( viewPath, modelPath ) { + const viewPos = getViewPosition( viewRoot, viewPath, view ); + const modelPos = mapper.toModelPosition( viewPos ); + + expect( modelPos.root ).to.equal( modelRoot ); + expect( modelPos.path ).to.deep.equal( modelPath ); + } + + it( 'before ul --> before first list item', () => { + testList( [ 1 ], [ 1 ] ); + } ); + + it( 'before first li --> before first list item', () => { + testList( [ 1, 0 ], [ 1 ] ); + } ); + + it( 'beginning of li --> before first list item', () => { + testList( [ 1, 0, 0 ], [ 1 ] ); + } ); + + it( 'end of li --> after first list item', () => { + testList( [ 1, 0, 1 ], [ 2 ] ); + } ); + + it( 'beginning of p in li --> beginning of first list item paragraph', () => { + testList( [ 1, 0, 0, 0 ], [ 1, 0 ] ); + } ); + + it( 'end of p in li --> end of first list item paragraph', () => { + testList( [ 1, 0, 0, 1 ], [ 1, 1 ] ); + } ); + + it( 'before middle li --> before middle list item', () => { + testList( [ 1, 1 ], [ 2 ] ); + } ); + + it( 'before last li --> before last list item', () => { + testList( [ 1, 2 ], [ 3 ] ); + } ); + + it( 'after last li --> after last list item / before paragraph', () => { + testList( [ 1, 3 ], [ 4 ] ); + } ); + + it( 'after ul --> after last list item / before paragraph', () => { + testList( [ 2 ], [ 4 ] ); + } ); + + it( 'before ol --> before numbered list item', () => { + testList( [ 3 ], [ 5 ] ); + } ); + + it( 'before only li --> before numbered list item', () => { + testList( [ 3, 0 ], [ 5 ] ); + } ); + + it( 'after only li --> after numbered list item', () => { + testList( [ 3, 1 ], [ 6 ] ); + } ); + + it( 'after ol --> after numbered list item', () => { + testList( [ 4 ], [ 6 ] ); + } ); + } ); + + describe( 'model to view', () => { + function testList( modelPath, viewPath ) { + const modelPos = model.createPositionFromPath( modelRoot, modelPath ); + const viewPos = mapper.toViewPosition( modelPos ); + + expect( viewPos.root ).to.equal( viewRoot ); + expect( getViewPath( viewPos ) ).to.deep.equal( viewPath ); + } + + it( 'before first list item --> before ul', () => { + testList( [ 1 ], [ 1 ] ); + } ); + + it( 'beginning of first list item --> beginning of `b` text node', () => { + testList( [ 1, 0 ], [ 1, 0, 0, 0, 0 ] ); + } ); + + it( 'end of first list item --> end of `b` text node', () => { + testList( [ 1, 1 ], [ 1, 0, 0, 0, 1 ] ); + } ); + + it( 'before middle list item --> before middle li', () => { + testList( [ 2 ], [ 1, 1 ] ); + } ); + + it( 'before last list item --> before last li', () => { + testList( [ 3 ], [ 1, 2 ] ); + } ); + + it( 'after last list item --> after ul', () => { + testList( [ 4 ], [ 2 ] ); + } ); + + it( 'before numbered list item --> before ol', () => { + testList( [ 5 ], [ 3 ] ); + } ); + + it( 'after numbered list item --> after ol', () => { + testList( [ 6 ], [ 4 ] ); + } ); + } ); + } ); + + describe( 'nested lists', () => { + let mapper; + + beforeEach( () => { + mapper = editor.editing.mapper; + + editor.setData( + '
          ' + + '
        • a

        • ' + + '
        • ' + + '

          bbb

          ' + + '
            ' + + '
          1. c

          2. ' + + '
          3. d

          4. ' + + '
          5. e

          6. ' + + '
          7. ' + + '

            ' + + '
              ' + + '
            • g

            • ' + + '
            • h

            • ' + + '
            • i

            • ' + + '
            ' + + '
          8. ' + + '
          9. j

          10. ' + + '
          ' + + '
        • ' + + '
        • k

        • ' + + '
        ' + ); + } ); + + /* + a + bbb + c + d + e + + g + h + i + j + k + */ + + describe( 'view to model', () => { + function testList( viewPath, modelPath ) { + const viewPos = getViewPosition( viewRoot, viewPath, view ); + const modelPos = mapper.toModelPosition( viewPos ); + + expect( modelPos.root ).to.equal( modelRoot ); + expect( modelPos.path ).to.deep.equal( modelPath ); + } + + it( 'before ul#1 --> before listItem "a"', () => { + testList( [ 0 ], [ 0 ] ); + } ); + + it( 'before li "a" --> before listItem "a"', () => { + testList( [ 0, 0 ], [ 0 ] ); + } ); + + it( 'before "a" paragraph --> beginning of listItem "a"', () => { + testList( [ 0, 0, 0 ], [ 0 ] ); + } ); + + it( 'before "a" --> beginning of listItem "a"', () => { + testList( [ 0, 0, 0, 0 ], [ 0, 0 ] ); + } ); + + it( 'after "a" --> end of listItem "a"', () => { + testList( [ 0, 0, 0, 1 ], [ 0, 1 ] ); + } ); + + it( 'after "a" paragraph --> end of listItem "a"', () => { + testList( [ 0, 0, 1 ], [ 1 ] ); + } ); + + it( 'before li "bbb" --> before listItem "bbb"', () => { + testList( [ 0, 1 ], [ 1 ] ); + } ); + + it( 'before "bbb" paragraph --> beginning of listItem "bbb"', () => { + testList( [ 0, 1, 0 ], [ 1 ] ); + } ); + + it( 'before "bbb" --> beginning of listItem "bbb"', () => { + testList( [ 0, 1, 0, 0 ], [ 1, 0 ] ); + } ); + + it( 'after "bbb" --> end of listItem "bbb"', () => { + testList( [ 0, 1, 0, 1 ], [ 1, 3 ] ); + } ); + + it( 'after "bbb" paragraph --> end of listItem "bbb"', () => { + testList( [ 0, 1, 1 ], [ 2 ] ); + } ); + + it( 'before li "c" --> before listItem "c"', () => { + testList( [ 0, 1, 1, 0 ], [ 2 ] ); + } ); + + it( 'before "c" paragraph --> beginning of listItem "c"', () => { + testList( [ 0, 1, 1, 0, 0 ], [ 2 ] ); + } ); + + it( 'before "c" --> beginning of listItem "c"', () => { + testList( [ 0, 1, 1, 0, 0, 0 ], [ 2, 0 ] ); + } ); + + it( 'after "c" --> end of listItem "c"', () => { + testList( [ 0, 1, 1, 0, 0, 1 ], [ 2, 1 ] ); + } ); + + it( 'after "c" paragraph --> end of listItem "c"', () => { + testList( [ 0, 1, 1, 0, 1 ], [ 3 ] ); + } ); + + it( 'before li "d" --> before listItem "d"', () => { + testList( [ 0, 1, 1, 1 ], [ 3 ] ); + } ); + + it( 'before li "e" --> before listItem "e"', () => { + testList( [ 0, 1, 1, 2 ], [ 4 ] ); + } ); + + it( 'before "empty" li --> before "empty" listItem', () => { + testList( [ 0, 1, 1, 3 ], [ 5 ] ); + } ); + + it( 'before ul#2 --> inside "empty" listItem', () => { + testList( [ 0, 1, 1, 3, 0, 0 ], [ 5, 0 ] ); + } ); + + it( 'before li "g" --> before listItem "g"', () => { + testList( [ 0, 1, 1, 3, 1, 0, 0 ], [ 6 ] ); + } ); + + it( 'before li "h" --> before listItem "h"', () => { + testList( [ 0, 1, 1, 3, 1, 1 ], [ 7 ] ); + } ); + + it( 'before li "i" --> before listItem "i"', () => { + testList( [ 0, 1, 1, 3, 1, 2 ], [ 8 ] ); + } ); + + it( 'after li "i" --> before listItem "j"', () => { + testList( [ 0, 1, 1, 3, 1, 3 ], [ 9 ] ); + } ); + + it( 'after ul#2 --> before listItem "j"', () => { + testList( [ 0, 1, 1, 3, 2 ], [ 9 ] ); + } ); + + it( 'before li "j" --> before listItem "j"', () => { + testList( [ 0, 1, 1, 4 ], [ 9 ] ); + } ); + + it( 'after li "j" --> before listItem "k"', () => { + testList( [ 0, 1, 1, 5 ], [ 10 ] ); + } ); + + it( 'end of li "bbb" --> before listItem "k"', () => { + testList( [ 0, 1, 2 ], [ 10 ] ); + } ); + + it( 'before li "k" --> before listItem "k"', () => { + testList( [ 0, 2 ], [ 10 ] ); + } ); + + it( 'after li "k" --> after listItem "k"', () => { + testList( [ 0, 3 ], [ 11 ] ); + } ); + + it( 'after ul --> after listItem "k"', () => { + testList( [ 1 ], [ 11 ] ); + } ); + } ); + + describe( 'model to view', () => { + function testList( modelPath, viewPath ) { + const modelPos = model.createPositionFromPath( modelRoot, modelPath ); + const viewPos = mapper.toViewPosition( modelPos ); + + expect( viewPos.root ).to.equal( viewRoot ); + expect( getViewPath( viewPos ) ).to.deep.equal( viewPath ); + } + + it( 'before listItem "a" --> before ul', () => { + testList( [ 0 ], [ 0 ] ); + } ); + + it( 'beginning of listItem "a" --> beginning of "a" text node', () => { + testList( [ 0, 0 ], [ 0, 0, 0, 0, 0 ] ); + } ); + + it( 'end of listItem "a" --> end of "a" text node', () => { + testList( [ 0, 1 ], [ 0, 0, 0, 0, 1 ] ); + } ); + + it( 'before listItem "bbb" --> before li "bbb"', () => { + testList( [ 1 ], [ 0, 1 ] ); + } ); + + it( 'beginning of listItem "bbb" --> beginning of "bbb" text node', () => { + testList( [ 1, 0 ], [ 0, 1, 0, 0, 0 ] ); + } ); + + it( 'end of listItem "bbb" --> end of "bbb" text node', () => { + testList( [ 1, 3 ], [ 0, 1, 0, 0, 3 ] ); + } ); + + it( 'before listItem "c" --> before li "c"', () => { + testList( [ 2 ], [ 0, 1, 1 ] ); + } ); + + it( 'beginning of listItem "c" --> beginning of "c" text node', () => { + testList( [ 2, 0 ], [ 0, 1, 1, 0, 0, 0, 0 ] ); + } ); + + it( 'end of listItem "c" --> end of "c" text node', () => { + testList( [ 2, 1 ], [ 0, 1, 1, 0, 0, 0, 1 ] ); + } ); + + it( 'before listItem "d" --> before li "d"', () => { + testList( [ 3 ], [ 0, 1, 1, 1 ] ); + } ); + + it( 'before listItem "e" --> before li "e"', () => { + testList( [ 4 ], [ 0, 1, 1, 2 ] ); + } ); + + it( 'before "empty" listItem --> before "empty" li', () => { + testList( [ 5 ], [ 0, 1, 1, 3 ] ); + } ); + + it( 'inside "empty" listItem --> before ul', () => { + testList( [ 5, 0 ], [ 0, 1, 1, 3, 0, 0 ] ); + } ); + + it( 'before listItem "g" --> before li "g"', () => { + testList( [ 6 ], [ 0, 1, 1, 3, 1 ] ); + } ); + + it( 'before listItem "h" --> before li "h"', () => { + testList( [ 7 ], [ 0, 1, 1, 3, 1, 1 ] ); + } ); + + it( 'before listItem "i" --> before li "i"', () => { + testList( [ 8 ], [ 0, 1, 1, 3, 1, 2 ] ); + } ); + + it( 'before listItem "j" --> before li "j"', () => { + testList( [ 9 ], [ 0, 1, 1, 4 ] ); + } ); + + it( 'before listItem "k" --> before li "k"', () => { + testList( [ 10 ], [ 0, 2 ] ); + } ); + + it( 'after listItem "k" --> after ul', () => { + testList( [ 11 ], [ 1 ] ); + } ); + } ); + } ); + } ); + + describe( 'other', () => { + describe( 'bogus paragraph', () => { + it( 'should refresh bogus paragraph on setting attribute from a different feature', () => { + setModelData( model, + 'a' + + 'b' + ); + + expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( + '
          ' + + '
        • a
        • ' + + '
        • b
        • ' + + '
        ' + ); + + const spy = sinon.spy( editor.editing, 'reconvertItem' ); + + model.change( writer => { + writer.setAttribute( 'alignment', 'right', modelRoot.getChild( 0 ) ); + } ); + + expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( + '
          ' + + '
        • a

        • ' + + '
        • b
        • ' + + '
        ' + ); + + expect( editor.getData() ).to.equal( + '
          ' + + '
        • a

        • ' + + '
        • b
        • ' + + '
        ' + ); + + expect( spy.calledOnce ).to.be.true; + } ); + + it( 'should not refresh bogus paragraph on setting selection attribute in an empty block', () => { + setModelData( model, + '' + + 'b' + ); + + expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( + '
          ' + + '
        • ' + + '
        • b
        • ' + + '
        ' + ); + + const spy = sinon.spy( editor.editing, 'reconvertItem' ); + + model.change( writer => { + writer.setSelectionAttribute( 'bold', true ); + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '' + + 'b' + ); + + expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( + '
          ' + + '
        • ' + + '
        • b
        • ' + + '
        ' + ); + + expect( editor.getData() ).to.equal( + '
          ' + + '
        •  
        • ' + + '
        • b
        • ' + + '
        ' + ); + + expect( spy.notCalled ).to.be.true; + } ); + + it( 'should not refresh bogus paragraph on setting attribute from a different feature on non-item element', () => { + setModelData( model, + 'a' + + 'b' + ); + + expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( + '

        a

        ' + + '
          ' + + '
        • b
        • ' + + '
        ' + ); + + const spy = sinon.spy( editor.editing, 'reconvertItem' ); + + model.change( writer => { + writer.setAttribute( 'alignment', 'right', modelRoot.getChild( 0 ) ); + } ); + + expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( + '

        a

        ' + + '
          ' + + '
        • b
        • ' + + '
        ' + ); + + expect( editor.getData() ).to.equal( + '

        a

        ' + + '
          ' + + '
        • b
        • ' + + '
        ' + ); + + expect( spy.notCalled ).to.be.true; + } ); + + describe( 'consuming', () => { + it( 'model bogus paragraph converter should not fire if change was already consumed', () => { + editor.conversion.for( 'downcast' ) + .elementToElement( { + model: 'paragraph', + view: 'div', + converterPriority: 'highest' + } ); + + const input = parseModel( + 'foo', + model.schema + ); + + model.change( writer => { + writer.remove( writer.createRangeIn( modelRoot ) ); + writer.insert( input, modelRoot, 0 ); + } ); + + expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( + '
        • foo
        ' + ); + + expect( editor.getData() ).to.equal( + '
        • foo
        ' + ); + } ); + } ); + } ); + + describe( 'consuming', () => { + it( 'model change indent converter should not fire if change was already consumed', () => { + editor.editing.downcastDispatcher.on( 'attribute:listIndent', ( evt, data, conversionApi ) => { + conversionApi.consumable.consume( data.item, 'attribute:listIndent' ); + }, { priority: 'highest' } ); + + setModelData( model, + 'a' + + 'b' + ); + + model.change( writer => { + writer.setAttribute( 'listIndent', 1, modelRoot.getChild( 1 ) ); + } ); + + expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( + '
          ' + + '
        • a
        • ' + + '
        • b
        • ' + + '
        ' + ); + } ); + + it( 'view li converter should not fire if change was already consumed', () => { + editor.data.upcastDispatcher.on( 'element:li', ( evt, data, conversionApi ) => { + conversionApi.consumable.consume( data.viewItem, { name: true } ); + }, { priority: 'highest' } ); + + editor.setData( '

        ' ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( '' ); + } ); + + it( 'view li converter should not set list attributes if change was already consumed to some non listable element', () => { + model.schema.addAttributeCheck( ( context, attributeName ) => { + if ( context.endsWith( 'heading1' ) && attributeName == 'listItemId' ) { + return false; + } + } ); + + editor.conversion.for( 'upcast' ).elementToElement( { view: 'li', model: 'heading1', converterPriority: 'highest' } ); + + editor.setData( '
        ' ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( '' ); + } ); + + it( 'view ul converter should not fire if change was already consumed', () => { + editor.data.upcastDispatcher.on( 'element:ul', ( evt, data, conversionApi ) => { + conversionApi.consumable.consume( data.viewItem, { name: true } ); + }, { priority: 'highest' } ); + + editor.setData( '

        ' ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( '' ); + } ); + + it( 'view converter should pass model range in data.modelRange', () => { + editor.data.upcastDispatcher.on( 'element:ul', ( evt, data ) => { + expect( data.modelRange ).to.be.instanceof( ModelRange ); + }, { priority: 'lowest' } ); + + editor.setData( '
        • Foo
        • Bar
        ' ); + } ); + } ); + + describe( 'UIElement', () => { + it( 'ul and ol should not be inserted before ui element - change indent of the second list item', () => { + editor.setData( + '
          ' + + '
        • Foo
        • ' + + '
        • Bar
        • ' + + '
        ' + ); + + // Append ui element at the end of first
      1. (inside the bogus paragraph). + view.change( writer => { + const firstChild = viewDoc.getRoot().getChild( 0 ).getChild( 0 ).getChild( 0 ); + + writer.insert( writer.createPositionAt( firstChild, 'end' ), writer.createUIElement( 'span' ) ); + } ); + + expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( + '
          ' + + '
        • Foo
        • ' + + '
        • Bar
        • ' + + '
        ' + ); + + model.change( writer => { + // Change indent of the second list item. + writer.setAttribute( 'listIndent', 1, modelRoot.getChild( 1 ) ); + } ); + + // Check if the new
          was added at correct position. + expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( + '
            ' + + '
          • ' + + 'Foo' + + '
              ' + + '
            • Bar
            • ' + + '
            ' + + '
          • ' + + '
          ' + ); + } ); + + it( 'ul and ol should not be inserted before ui element - remove second list item', () => { + editor.setData( + '
            ' + + '
          • Foo
          • ' + + '
          • ' + + 'Bar' + + '
              ' + + '
            • Xxx
            • ' + + '
            • Yyy
            • ' + + '
            ' + + '
          • ' + + '
          ' + ); + + // Append ui element at the end of first
        • (inside the bogus paragraph). + view.change( writer => { + const firstChild = viewDoc.getRoot().getChild( 0 ).getChild( 0 ).getChild( 0 ); + + writer.insert( writer.createPositionAt( firstChild, 'end' ), writer.createUIElement( 'span' ) ); + } ); + + expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( + '
            ' + + '
          • Foo
          • ' + + '
          • ' + + 'Bar' + + '
              ' + + '
            • Xxx
            • ' + + '
            • Yyy
            • ' + + '
            ' + + '
          • ' + + '
          ' + ); + + model.change( writer => { + // Remove second list item. Expect that its sub-list will be moved to first list item. + writer.remove( modelRoot.getChild( 1 ) ); + } ); + + // Check if the
            was added at correct position. + expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( + '
              ' + + '
            • ' + + 'Foo' + + '
                ' + + '
              • Xxx
              • ' + + '
              • Yyy
              • ' + + '
              ' + + '
            • ' + + '
            ' + ); + } ); + + describe( 'remove converter should properly handle ui elements', () => { + let liFoo, liBar; + + beforeEach( () => { + editor.setData( '
            • Foo
            • Bar
            ' ); + + liFoo = modelRoot.getChild( 0 ); + liBar = modelRoot.getChild( 1 ); + } ); + + it( 'ui element before
              ', () => { + view.change( writer => { + // Append ui element before
                . + writer.insert( writer.createPositionAt( viewRoot, 0 ), writer.createUIElement( 'span' ) ); + } ); + + model.change( writer => { + writer.remove( liFoo ); + } ); + + expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( + '' + + '
                  ' + + '
                • Bar
                • ' + + '
                ' + ); + } ); + + it( 'ui element before first
              • ', () => { + view.change( writer => { + // Append ui element before . + writer.insert( writer.createPositionAt( viewRoot.getChild( 0 ), 0 ), writer.createUIElement( 'span' ) ); + } ); + + model.change( writer => { + writer.remove( liFoo ); + } ); + + expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( + '
                  ' + + '' + + '
                • Bar
                • ' + + '
                ' + ); + } ); + + it( 'ui element in the middle of list', () => { + view.change( writer => { + // Append ui element after
              • . + writer.insert( writer.createPositionAt( viewRoot.getChild( 0 ), 'end' ), writer.createUIElement( 'span' ) ); + } ); + + model.change( writer => { + writer.remove( liBar ); + } ); + + expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( + '
                  ' + + '
                • Foo
                • ' + + '' + + '
                ' + ); + } ); + } ); + } ); + + it( 'outdent outer list item with nested blockquote', () => { + setModelData( model, + 'a' + + 'b1' + + 'b2' + + '
                ' + + 'd' + + 'e' + + '
                ' + ); + + const spy = sinon.spy( editor.editing, 'reconvertItem' ); + + model.change( writer => { + writer.setAttribute( 'listIndent', 1, modelRoot.getChild( 3 ) ); + writer.setAttribute( 'listItemId', 'b', modelRoot.getChild( 3 ) ); + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + 'a' + + 'b1' + + 'b2' + + '
                ' + + 'd' + + 'e' + + '
                ' + ); + + expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( + '
                  ' + + '
                • ' + + 'a' + + '
                    ' + + '
                  • ' + + '

                    b1

                    ' + + '

                    b2

                    ' + + '
                    ' + + '
                      ' + + '
                    • d
                    • ' + + '
                    • e
                    • ' + + '
                    ' + + '
                    ' + + '
                  • ' + + '
                  ' + + '
                • ' + + '
                ' + ); + + expect( spy.callCount ).to.equal( 0 ); + } ); + } ); + + describe( 'schema checking and parent splitting', () => { + beforeEach( () => { + // Since this part of test tests only view->model conversion editing pipeline is not necessary. + editor.editing.destroy(); + } ); + + it( 'list should be not converted when modelCursor and its ancestors disallow to insert list', () => { + model.document.createRoot( '$title', 'title' ); + + model.schema.register( '$title', { + disallow: '$block', + allow: 'inline' + } ); + + editor.data.set( { title: '
                • foo
                ' } ); + + expect( getModelData( model, { rootName: 'title', withoutSelection: true } ) ).to.equal( '' ); + } ); + + it( 'should split parent element when one of modelCursor ancestors allows to insert list - in the middle', () => { + editor.conversion.for( 'upcast' ).elementToElement( { view: 'div', model: 'div' } ); + model.schema.register( 'div', { inheritAllFrom: '$block' } ); + + editor.setData( + '
                ' + + 'abc' + + '
                  ' + + '
                • foo
                • ' + + '
                ' + + 'def' + + '
                ' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '
                abc
                ' + + 'foo' + + '
                def
                ' + ); + } ); + + it( 'should split parent element when one of modelCursor ancestors allows to insert list - at the end', () => { + editor.conversion.for( 'upcast' ).elementToElement( { view: 'div', model: 'div' } ); + model.schema.register( 'div', { inheritAllFrom: '$block' } ); + + editor.setData( + '
                ' + + 'abc' + + '
                  ' + + '
                • foo
                • ' + + '
                ' + + '
                ' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + '
                abc
                ' + + 'foo' + ); + } ); + + it( 'should split parent element when one of modelCursor ancestors allows to insert list - at the beginning', () => { + editor.conversion.for( 'upcast' ).elementToElement( { view: 'div', model: 'div' } ); + model.schema.register( 'div', { inheritAllFrom: '$block' } ); + + editor.setData( + '
                ' + + '
                  ' + + '
                • foo
                • ' + + '
                ' + + 'def' + + '
                ' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + 'foo' + + '
                def
                ' + ); + } ); + + // https://github.com/ckeditor/ckeditor5-list/issues/121 + it( 'should correctly set data.modelCursor', () => { + editor.setData( + '
                  ' + + '
                • a
                • ' + + '
                • b
                • ' + + '
                ' + + 'c' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( + 'a' + + 'b' + + 'c' + ); + } ); + } ); + + function getViewPosition( root, path, view ) { + let parent = root; + + while ( path.length > 1 ) { + parent = parent.getChild( path.shift() ); + } + + return view.createPositionAt( parent, path[ 0 ] ); + } + + function getViewPath( position ) { + const path = [ position.offset ]; + let parent = position.parent; + + while ( parent.parent ) { + path.unshift( parent.index ); + parent = parent.parent; + } + + return path; + } +} ); diff --git a/packages/ckeditor5-list/tests/documentlist/documentlistcommand.js b/packages/ckeditor5-list/tests/documentlist/documentlistcommand.js new file mode 100644 index 00000000000..7812255c1c4 --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/documentlistcommand.js @@ -0,0 +1,1478 @@ +/** + * @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 DocumentListCommand from '../../src/documentlist/documentlistcommand'; +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( 'DocumentListCommand', () => { + let editor, command, model, doc, root, changedBlocks; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + editor = new Editor(); + + await editor.initPlugins(); + + 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' ] } ); + model.schema.extend( '$block', { allowAttributes: [ 'listType', 'listIndent', 'listItemId' ] } ); + model.schema.extend( '$blockObject', { allowAttributes: [ 'listType', 'listIndent', 'listItemId' ] } ); + + stubUid(); + } ); + + describe( 'bulleted', () => { + beforeEach( () => { + command = new DocumentListCommand( editor, 'bulleted' ); + + command.on( 'afterExecute', ( evt, data ) => { + changedBlocks = data; + } ); + } ); + + afterEach( () => { + command.destroy(); + } ); + + describe( 'constructor()', () => { + it( 'should create list command with given type and value set to false', () => { + setData( model, '[]' ); + + expect( command.type ).to.equal( 'bulleted' ); + expect( command.value ).to.be.false; + } ); + } ); + + describe( 'value', () => { + it( 'should be false if first position in selection is not in a list item', () => { + setData( model, modelList( [ + '0[]', + '* 1' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be false if first position in selection is in a list item of different type', () => { + setData( model, modelList( [ + '# 0[]', + '# 1' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be false if any of selected blocks is not a list item (non-list after list)', () => { + setData( model, modelList( [ + '* [0', + '1]' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be false if any of selected blocks is not a list item (non-list before list)', () => { + setData( model, modelList( [ + '[0', + '* 1]' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be false if any of selected blocks is not a list item (non-list between lists)', () => { + setData( model, modelList( [ + '* [0', + '1', + '* 2]' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be false if any of selected blocks is not a same type list item', () => { + setData( model, modelList( [ + '* [0', + '# 1]' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be false if there is no blocks in the selection', () => { + model.schema.register( 'table', { + allowWhere: '$block', + allowAttributesOf: '$container', + isObject: true, + isBlock: true + } ); + + model.schema.register( 'tableCell', { + allowContentOf: '$container', + allowIn: 'table', + isLimit: true, + isSelectable: true + } ); + + setData( model, '[]
                ' ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be true if first position in selection is in a list item of same type', () => { + setData( model, modelList( [ + '* 0[]', + '* 1' + ] ) ); + + expect( command.value ).to.be.true; + } ); + + it( 'should be true if first position in selection is in a following block of the list item', () => { + setData( model, modelList( [ + '* 0', + ' 1[]' + ] ) ); + + expect( command.value ).to.be.true; + } ); + } ); + + describe( 'isEnabled', () => { + it( 'should be true if entire selection is in a list', () => { + setData( model, modelList( [ '* [a]' ] ) ); + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if entire selection is in a block which can be turned into a list', () => { + setData( model, '[a]' ); + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if any of the selected blocks allows list attributes (the last element does not allow)', () => { + model.schema.register( 'heading1', { inheritAllFrom: '$block' } ); + model.schema.addAttributeCheck( ( ctx, attributeName ) => { + if ( ctx.endsWith( 'heading1' ) && attributeName === 'listType' ) { + return false; + } + } ); + + setData( model, + '[a' + + 'b]' + ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if any of the selected blocks allows list attributes (the first element does not allow)', () => { + model.schema.register( 'heading1', { inheritAllFrom: '$block' } ); + model.schema.addAttributeCheck( ( ctx, attributeName ) => { + if ( ctx.endsWith( 'heading1' ) && attributeName === 'listType' ) { + return false; + } + } ); + + setData( model, + '[a' + + 'b]' + ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false if all of the selected blocks do not allow list attributes', () => { + model.schema.register( 'heading1', { inheritAllFrom: '$block' } ); + model.schema.addAttributeCheck( ( ctx, attributeName ) => { + if ( ctx.endsWith( 'heading1' ) && attributeName === 'listType' ) { + return false; + } + } ); + + setData( model, + 'a[]' + + 'b' + ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false if there is no blocks in the selection', () => { + model.schema.register( 'table', { + allowWhere: '$block', + allowAttributesOf: '$container', + isObject: true, + isBlock: true + } ); + + model.schema.register( 'tableCell', { + allowContentOf: '$container', + allowIn: 'table', + isLimit: true, + isSelectable: true + } ); + + setData( model, '[]
                ' ); + + expect( command.isEnabled ).to.be.false; + } ); + } ); + + describe( 'execute()', () => { + it( 'should use parent batch', () => { + setData( model, '[0]' ); + + model.change( writer => { + expect( writer.batch.operations.length, 'before' ).to.equal( 0 ); + + command.execute(); + + expect( writer.batch.operations.length, 'after' ).to.be.above( 0 ); + } ); + } ); + + describe( 'options.forceValue', () => { + it( 'should force converting into the list if the `options.forceValue` is set to `true`', () => { + setData( model, modelList( [ + 'fo[]o' + ] ) ); + + command.execute( { forceValue: true } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* fo[]o {id:a00}' + ] ) ); + } ); + + it( 'should not modify list item if not needed if the list if the `options.forceValue` is set to `true`', () => { + setData( model, modelList( [ + '* fo[]o' + ] ) ); + + command.execute( { forceValue: true } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* fo[]o' + ] ) ); + } ); + + it( 'should force converting into the paragraph if the `options.forceValue` is set to `false`', () => { + setData( model, modelList( [ + '* fo[]o' + ] ) ); + + command.execute( { forceValue: false } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'fo[]o' + ] ) ); + } ); + + it( 'should not modify list item if not needed if the `options.forceValue` is set to `false`', () => { + setData( model, modelList( [ + 'fo[]o' + ] ) ); + + command.execute( { forceValue: false } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'fo[]o' + ] ) ); + } ); + } ); + + describe( 'when turning on', () => { + it( 'should turn the closest block into a list item', () => { + setData( model, 'fo[]o' ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* fo[]o {id:a00}' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ) + ] ); + } ); + + it( 'should change the type of an existing (closest) list item', () => { + setData( model, modelList( [ + '# fo[]o' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* fo[]o' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ) + ] ); + } ); + + it( 'should make a list items from multiple paragraphs', () => { + setData( model, modelList( [ + 'fo[o', + 'ba]r' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* fo[o {id:a00}', + '* ba]r {id:a01}' + ] ) ); + + expect( changedBlocks.length ).to.equal( 2 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ), + root.getChild( 1 ) + ] ); + } ); + + it( 'should make a list items from multiple paragraphs mixed with list items', () => { + setData( model, modelList( [ + 'a', + '[b', + '* c', + 'd]', + 'e' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'a', + '* [b {id:a00}', + '* c', + '* d] {id:a01}', + 'e' + ] ) ); + + expect( changedBlocks.length ).to.equal( 2 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 1 ), + root.getChild( 3 ) + ] ); + } ); + + it( 'should change type of the whole list items if only some blocks of a list item are selected', () => { + setData( model, modelList( [ + '# a', + ' [b', + 'c', + '# d]', + ' e', + '# f' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* a', + ' [b', + '* c {id:a00}', + '* d]', + ' e', + '# f' + ] ) ); + + expect( changedBlocks.length ).to.equal( 5 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ), + root.getChild( 1 ), + root.getChild( 2 ), + root.getChild( 3 ), + root.getChild( 4 ) + ] ); + } ); + + it( 'should not change type of nested list if parent is selected', () => { + setData( model, modelList( [ + '# [a', + '# b]', + ' # c', + '# d' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* [a', + '* b]', + ' # c', + '# d' + ] ) ); + + expect( changedBlocks.length ).to.equal( 2 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ), + root.getChild( 1 ) + ] ); + } ); + + it( 'should change the type of the whole list if the selection is collapsed (bulleted lists at the boundaries)', () => { + setData( model, modelList( [ + '* a', + '# b[]', + ' # c', + '# d', + '* e' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* a', + '* b[]', + ' # c', + '* d', + '* e' + ] ) ); + + expect( changedBlocks.length ).to.equal( 2 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 1 ), + root.getChild( 3 ) + ] ); + } ); + + it( 'should change the type of the whole list if the selection is collapsed (paragraphs at the boundaries)', () => { + setData( model, modelList( [ + 'a', + '# b', + ' c[]', + ' # d', + ' e', + '# f', + 'g' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'a', + '* b', + ' c[]', + ' # d', + ' e', + '* f', + 'g' + ] ) ); + + expect( changedBlocks.length ).to.equal( 4 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 1 ), + root.getChild( 2 ), + root.getChild( 4 ), + root.getChild( 5 ) + ] ); + } ); + } ); + + describe( 'when turning off', () => { + it( 'should strip the list attributes from the closest list item (single list item)', () => { + setData( model, modelList( [ + '* fo[]o' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'fo[]o' + ] ) ); + + expect( changedBlocks.length ).to.equal( 1 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ) + ] ); + } ); + + it( 'should strip the list attributes from the closest item (multiple list items, selection in first item)', () => { + setData( model, modelList( [ + '* f[]oo', + '* bar', + '* baz' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'f[]oo', + '* bar', + '* baz' + ] ) ); + + expect( changedBlocks.length ).to.equal( 1 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ) + ] ); + } ); + + it( 'should strip the list attributes from the closest item (multiple list items, selection in the middle item)', () => { + setData( model, modelList( [ + '* foo', + '* b[]ar', + '* baz' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* foo', + 'b[]ar', + '* baz' + ] ) ); + + expect( changedBlocks.length ).to.equal( 1 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 1 ) + ] ); + } ); + + it( 'should strip the list attributes from the closest item (multiple list items, selection in the last item)', () => { + setData( model, modelList( [ + '* foo', + '* bar', + '* b[]az' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* foo', + '* bar', + 'b[]az' + ] ) ); + + expect( changedBlocks.length ).to.equal( 1 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 2 ) + ] ); + } ); + + describe( 'with nested lists inside', () => { + it( 'should strip the list attributes from the closest item and decrease indent of children (first item)', () => { + setData( model, modelList( [ + '* f[]oo', + ' * bar', + ' * baz', + ' * qux' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'f[]oo', + '* bar', + '* baz', + ' * qux' + ] ) ); + + expect( changedBlocks.length ).to.equal( 4 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ), + root.getChild( 1 ), + root.getChild( 2 ), + root.getChild( 3 ) + ] ); + } ); + + it( 'should strip the list attributes from the closest item and decrease indent of children (middle item)', () => { + setData( model, modelList( [ + '* foo', + '* b[]ar', + ' * baz', + ' * qux' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* foo', + 'b[]ar', + '* baz', + ' * qux' + ] ) ); + + expect( changedBlocks.length ).to.equal( 3 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 1 ), + root.getChild( 2 ), + root.getChild( 3 ) + ] ); + } ); + + it( 'should strip the list attributes from the selected items and decrease indent of nested list', () => { + setData( model, modelList( [ + '0', + '* 1', + ' * 2', + ' * 3[]', // <- this is turned off. + ' * 4', // <- this has to become indent = 0, because it will be first item on a new list. + ' * 5', // <- this should be still be a child of item above, so indent = 1. + ' * 6', // <- this has to become indent = 0, because it should not be a child of any of items above. + ' * 7', // <- this should be still be a child of item above, so indent = 1. + ' * 8', // <- this has to become indent = 0. + ' * 9', // <- this should still be a child of item above, so indent = 1. + ' * 10', // <- this should still be a child of item above, so indent = 2. + ' * 11', // <- this should still be at the same level as item above, so indent = 2. + '* 12', // <- this and all below are left unchanged. + ' * 13', + ' * 14' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '0', + '* 1', + ' * 2', + '3[]', + '* 4', + ' * 5', + '* 6', + ' * 7', + '* 8', + ' * 9', + ' * 10', + ' * 11', + '* 12', + ' * 13', + ' * 14' + ] ) ); + + expect( changedBlocks.length ).to.equal( 9 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 3 ), + root.getChild( 4 ), + root.getChild( 5 ), + root.getChild( 6 ), + root.getChild( 7 ), + root.getChild( 8 ), + root.getChild( 9 ), + root.getChild( 10 ), + root.getChild( 11 ) + ] ); + } ); + } ); + + describe( 'with blocks inside list items', () => { + it( 'should strip the list attributes from the first list item block', () => { + setData( model, modelList( [ + '* fo[]o', + ' bar', + ' baz' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'fo[]o', + '* bar {id:a00}', + ' baz' + ] ) ); + + expect( changedBlocks.length ).to.equal( 3 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ), + root.getChild( 1 ), + root.getChild( 2 ) + ] ); + } ); + + it( 'should strip the list attributes from the middle list item block', () => { + setData( model, modelList( [ + '* foo', + ' ba[]r', + ' baz' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* foo', + 'ba[]r', + '* baz {id:a00}' + ] ) ); + + expect( changedBlocks.length ).to.equal( 2 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 1 ), + root.getChild( 2 ) + ] ); + } ); + + it( 'should strip the list attributes from blocks with nested list', () => { + setData( model, modelList( [ + '* a[]', + ' b', + ' * c', + ' d', + ' * e', + ' f', + '* g' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'a[]', + '* b {id:a00}', + ' * c', + ' d', + ' * e', + ' f', + '* g' + ] ) ); + + expect( changedBlocks.length ).to.equal( 3 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ), + root.getChild( 1 ), + root.getChild( 5 ) + ] ); + } ); + } ); + } ); + } ); + } ); + + describe( 'numbered', () => { + beforeEach( () => { + command = new DocumentListCommand( editor, 'numbered' ); + + command.on( 'afterExecute', ( evt, data ) => { + changedBlocks = data; + } ); + } ); + + afterEach( () => { + command.destroy(); + } ); + + describe( 'constructor()', () => { + it( 'should create list command with given type and value set to false', () => { + setData( model, '[]' ); + + expect( command.type ).to.equal( 'numbered' ); + expect( command.value ).to.be.false; + } ); + } ); + + describe( 'value', () => { + it( 'should be false if first position in selection is not in a list item', () => { + setData( model, modelList( [ + '0[]', + '# 1' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be false if first position in selection is in a list item of different type', () => { + setData( model, modelList( [ + '* 0[]', + '* 1' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be false if any of selected blocks is not a list item (non-list after list)', () => { + setData( model, modelList( [ + '# [0', + '1]' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be false if any of selected blocks is not a list item (non-list before list)', () => { + setData( model, modelList( [ + '[0', + '# 1]' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be false if any of selected blocks is not a list item (non-list between lists)', () => { + setData( model, modelList( [ + '# [0', + '1', + '# 2]' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be false if any of selected blocks is not a same type list item', () => { + setData( model, modelList( [ + '# [0', + '* 1]' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be false if there is no blocks in the selection', () => { + model.schema.register( 'table', { + allowWhere: '$block', + allowAttributesOf: '$container', + isObject: true, + isBlock: true + } ); + + model.schema.register( 'tableCell', { + allowContentOf: '$container', + allowIn: 'table', + isLimit: true, + isSelectable: true + } ); + + setData( model, '[]
                ' ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be true if first position in selection is in a list item of same type', () => { + setData( model, modelList( [ + '# 0[]', + '# 1' + ] ) ); + + expect( command.value ).to.be.true; + } ); + + it( 'should be true if first position in selection is in a following block of the list item', () => { + setData( model, modelList( [ + '# 0', + ' 1[]' + ] ) ); + + expect( command.value ).to.be.true; + } ); + } ); + + describe( 'isEnabled', () => { + it( 'should be true if entire selection is in a list', () => { + setData( model, modelList( [ '# [a]' ] ) ); + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if entire selection is in a block which can be turned into a list', () => { + setData( model, '[a]' ); + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if any of the selected blocks allows list attributes (the last element does not allow)', () => { + model.schema.register( 'heading1', { inheritAllFrom: '$block' } ); + model.schema.addAttributeCheck( ( ctx, attributeName ) => { + if ( ctx.endsWith( 'heading1' ) && attributeName === 'listType' ) { + return false; + } + } ); + + setData( model, + '[a' + + 'b]' + ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if any of the selected blocks allows list attributes (the first element does not allow)', () => { + model.schema.register( 'heading1', { inheritAllFrom: '$block' } ); + model.schema.addAttributeCheck( ( ctx, attributeName ) => { + if ( ctx.endsWith( 'heading1' ) && attributeName === 'listType' ) { + return false; + } + } ); + + setData( model, + '[a' + + 'b]' + ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false if all of the selected blocks do not allow list attributes', () => { + model.schema.register( 'heading1', { inheritAllFrom: '$block' } ); + model.schema.addAttributeCheck( ( ctx, attributeName ) => { + if ( ctx.endsWith( 'heading1' ) && attributeName === 'listType' ) { + return false; + } + } ); + + setData( model, + 'a[]' + + 'b' + ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false if there is no blocks in the selection', () => { + model.schema.register( 'table', { + allowWhere: '$block', + allowAttributesOf: '$container', + isObject: true, + isBlock: true + } ); + + model.schema.register( 'tableCell', { + allowContentOf: '$container', + allowIn: 'table', + isLimit: true, + isSelectable: true + } ); + + setData( model, '[]
                ' ); + + expect( command.isEnabled ).to.be.false; + } ); + } ); + + describe( 'execute()', () => { + it( 'should use parent batch', () => { + setData( model, '[0]' ); + + model.change( writer => { + expect( writer.batch.operations.length, 'before' ).to.equal( 0 ); + + command.execute(); + + expect( writer.batch.operations.length, 'after' ).to.be.above( 0 ); + } ); + } ); + + describe( 'options.forceValue', () => { + it( 'should force converting into the list if the `options.forceValue` is set to `true`', () => { + setData( model, modelList( [ + 'fo[]o' + ] ) ); + + command.execute( { forceValue: true } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# fo[]o {id:a00}' + ] ) ); + } ); + + it( 'should not modify list item if not needed if the list if the `options.forceValue` is set to `true`', () => { + setData( model, modelList( [ + '# fo[]o' + ] ) ); + + command.execute( { forceValue: true } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# fo[]o' + ] ) ); + } ); + + it( 'should force converting into the paragraph if the `options.forceValue` is set to `false`', () => { + setData( model, modelList( [ + '# fo[]o' + ] ) ); + + command.execute( { forceValue: false } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'fo[]o' + ] ) ); + } ); + + it( 'should not modify list item if not needed if the `options.forceValue` is set to `false`', () => { + setData( model, modelList( [ + 'fo[]o' + ] ) ); + + command.execute( { forceValue: false } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'fo[]o' + ] ) ); + } ); + } ); + + describe( 'when turning on', () => { + it( 'should turn the closest block into a list item', () => { + setData( model, 'fo[]o' ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# fo[]o {id:a00}' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ) + ] ); + } ); + + it( 'should change the type of an existing (closest) list item', () => { + setData( model, modelList( [ + '* fo[]o' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# fo[]o' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ) + ] ); + } ); + + it( 'should make a list items from multiple paragraphs', () => { + setData( model, modelList( [ + 'fo[o', + 'ba]r' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# fo[o {id:a00}', + '# ba]r {id:a01}' + ] ) ); + + expect( changedBlocks.length ).to.equal( 2 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ), + root.getChild( 1 ) + ] ); + } ); + + it( 'should make a list items from multiple paragraphs mixed with list items', () => { + setData( model, modelList( [ + 'a', + '[b', + '# c', + 'd]', + 'e' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'a', + '# [b {id:a00}', + '# c', + '# d] {id:a01}', + 'e' + ] ) ); + + expect( changedBlocks.length ).to.equal( 2 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 1 ), + root.getChild( 3 ) + ] ); + } ); + + it( 'should change type of the whole list items if only some blocks of a list item are selected', () => { + setData( model, modelList( [ + '* a', + ' [b', + 'c', + '* d]', + ' e', + '* f' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# a', + ' [b', + '# c {id:a00}', + '# d]', + ' e', + '* f' + ] ) ); + + expect( changedBlocks.length ).to.equal( 5 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ), + root.getChild( 1 ), + root.getChild( 2 ), + root.getChild( 3 ), + root.getChild( 4 ) + ] ); + } ); + + it( 'should not change type of nested list if parent is selected', () => { + setData( model, modelList( [ + '* [a', + '* b]', + ' * c', + '* d' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# [a', + '# b]', + ' * c', + '* d' + ] ) ); + + expect( changedBlocks.length ).to.equal( 2 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ), + root.getChild( 1 ) + ] ); + } ); + + it( 'should change the type of the whole list if the selection is collapsed (bulleted lists at the boundaries)', () => { + setData( model, modelList( [ + '# a', + '* b[]', + ' * c', + '* d', + '# e' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# a', + '# b[]', + ' * c', + '# d', + '# e' + ] ) ); + + expect( changedBlocks.length ).to.equal( 2 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 1 ), + root.getChild( 3 ) + ] ); + } ); + + it( 'should change the type of the whole list if the selection is collapsed (paragraphs at the boundaries)', () => { + setData( model, modelList( [ + 'a', + '* b', + ' c[]', + ' * d', + ' e', + '* f', + 'g' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'a', + '# b', + ' c[]', + ' * d', + ' e', + '# f', + 'g' + ] ) ); + + expect( changedBlocks.length ).to.equal( 4 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 1 ), + root.getChild( 2 ), + root.getChild( 4 ), + root.getChild( 5 ) + ] ); + } ); + } ); + + describe( 'when turning off', () => { + it( 'should strip the list attributes from the closest list item (single list item)', () => { + setData( model, modelList( [ + '# fo[]o' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'fo[]o' + ] ) ); + + expect( changedBlocks.length ).to.equal( 1 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ) + ] ); + } ); + + it( 'should strip the list attributes from the closest item (multiple list items, selection in first item)', () => { + setData( model, modelList( [ + '# f[]oo', + '# bar', + '# baz' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'f[]oo', + '# bar', + '# baz' + ] ) ); + + expect( changedBlocks.length ).to.equal( 1 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ) + ] ); + } ); + + it( 'should strip the list attributes from the closest item (multiple list items, selection in the middle item)', () => { + setData( model, modelList( [ + '# foo', + '# b[]ar', + '# baz' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# foo', + 'b[]ar', + '# baz' + ] ) ); + + expect( changedBlocks.length ).to.equal( 1 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 1 ) + ] ); + } ); + + it( 'should strip the list attributes from the closest item (multiple list items, selection in the last item)', () => { + setData( model, modelList( [ + '# foo', + '# bar', + '# b[]az' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# foo', + '# bar', + 'b[]az' + ] ) ); + + expect( changedBlocks.length ).to.equal( 1 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 2 ) + ] ); + } ); + + describe( 'with nested lists inside', () => { + it( 'should strip the list attributes from the closest item and decrease indent of children (first item)', () => { + setData( model, modelList( [ + '# f[]oo', + ' # bar', + ' # baz', + ' # qux' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'f[]oo', + '# bar', + '# baz', + ' # qux' + ] ) ); + + expect( changedBlocks.length ).to.equal( 4 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ), + root.getChild( 1 ), + root.getChild( 2 ), + root.getChild( 3 ) + ] ); + } ); + + it( 'should strip the list attributes from the closest item and decrease indent of children (middle item)', () => { + setData( model, modelList( [ + '# foo', + '# b[]ar', + ' # baz', + ' # qux' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# foo', + 'b[]ar', + '# baz', + ' # qux' + ] ) ); + + expect( changedBlocks.length ).to.equal( 3 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 1 ), + root.getChild( 2 ), + root.getChild( 3 ) + ] ); + } ); + + it( 'should strip the list attributes from the selected items and decrease indent of nested list', () => { + setData( model, modelList( [ + '0', + '# 1', + ' # 2', + ' # 3[]', // <- this is turned off. + ' # 4', // <- this has to become indent = 0, because it will be first item on a new list. + ' # 5', // <- this should be still be a child of item above, so indent = 1. + ' # 6', // <- this has to become indent = 0, because it should not be a child of any of items above. + ' # 7', // <- this should be still be a child of item above, so indent = 1. + ' # 8', // <- this has to become indent = 0. + ' # 9', // <- this should still be a child of item above, so indent = 1. + ' # 10', // <- this should still be a child of item above, so indent = 2. + ' # 11', // <- this should still be at the same level as item above, so indent = 2. + '# 12', // <- this and all below are left unchanged. + ' # 13', + ' # 14' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '0', + '# 1', + ' # 2', + '3[]', + '# 4', + ' # 5', + '# 6', + ' # 7', + '# 8', + ' # 9', + ' # 10', + ' # 11', + '# 12', + ' # 13', + ' # 14' + ] ) ); + + expect( changedBlocks.length ).to.equal( 9 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 3 ), + root.getChild( 4 ), + root.getChild( 5 ), + root.getChild( 6 ), + root.getChild( 7 ), + root.getChild( 8 ), + root.getChild( 9 ), + root.getChild( 10 ), + root.getChild( 11 ) + ] ); + } ); + } ); + + describe( 'with blocks inside list items', () => { + it( 'should strip the list attributes from the first list item block', () => { + setData( model, modelList( [ + '# fo[]o', + ' bar', + ' baz' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'fo[]o', + '# bar {id:a00}', + ' baz' + ] ) ); + + expect( changedBlocks.length ).to.equal( 3 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ), + root.getChild( 1 ), + root.getChild( 2 ) + ] ); + } ); + + it( 'should strip the list attributes from the middle list item block', () => { + setData( model, modelList( [ + '# foo', + ' ba[]r', + ' baz' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# foo', + 'ba[]r', + '# baz {id:a00}' + ] ) ); + + expect( changedBlocks.length ).to.equal( 2 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 1 ), + root.getChild( 2 ) + ] ); + } ); + + it( 'should strip the list attributes from blocks with nested list', () => { + setData( model, modelList( [ + '# a[]', + ' b', + ' * c', + ' d', + ' * e', + ' f', + '# g' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'a[]', + '# b {id:a00}', + ' * c', + ' d', + ' * e', + ' f', + '# g' + ] ) ); + + expect( changedBlocks.length ).to.equal( 3 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ), + root.getChild( 1 ), + root.getChild( 5 ) + ] ); + } ); + } ); + } ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-list/tests/documentlist/documentlistediting.js b/packages/ckeditor5-list/tests/documentlist/documentlistediting.js new file mode 100644 index 00000000000..797358327a4 --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/documentlistediting.js @@ -0,0 +1,793 @@ +/** + * @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 IndentEditing from '@ckeditor/ckeditor5-indent/src/indentediting'; +import TableEditing from '@ckeditor/ckeditor5-table/src/tableediting'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; +import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; +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 { getData as getViewData } 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 { modelList, prepareTest } from './_utils/utils'; + +describe( 'DocumentListEditing', () => { + 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(); + } ); + + it( 'should have pluginName', () => { + expect( DocumentListEditing.pluginName ).to.equal( 'DocumentListEditing' ); + } ); + + it( 'should be loaded', () => { + expect( editor.plugins.get( DocumentListEditing ) ).to.be.instanceOf( DocumentListEditing ); + } ); + + it( 'should throw if loaded alongside ListEditing plugin', async () => { + let caughtError; + + try { + await VirtualTestEditor.create( { plugins: [ DocumentListEditing, ListEditing ] } ); + } catch ( error ) { + caughtError = error; + } + + expect( caughtError ).to.instanceof( CKEditorError ); + expect( caughtError.message ) + .match( /^document-list-feature-conflict/ ); + } ); + + it( 'should set proper schema rules', () => { + expect( model.schema.checkAttribute( [ '$root', 'paragraph' ], 'listItemId' ) ).to.be.true; + expect( model.schema.checkAttribute( [ '$root', 'paragraph' ], 'listIndent' ) ).to.be.true; + expect( model.schema.checkAttribute( [ '$root', 'paragraph' ], 'listType' ) ).to.be.true; + expect( model.schema.checkAttribute( [ '$root', 'heading1' ], 'listItemId' ) ).to.be.true; + expect( model.schema.checkAttribute( [ '$root', 'heading1' ], 'listIndent' ) ).to.be.true; + expect( model.schema.checkAttribute( [ '$root', 'heading1' ], 'listType' ) ).to.be.true; + expect( model.schema.checkAttribute( [ '$root', 'blockQuote' ], 'listItemId' ) ).to.be.true; + expect( model.schema.checkAttribute( [ '$root', 'blockQuote' ], 'listIndent' ) ).to.be.true; + expect( model.schema.checkAttribute( [ '$root', 'blockQuote' ], 'listType' ) ).to.be.true; + expect( model.schema.checkAttribute( [ '$root', 'table' ], 'listItemId' ) ).to.be.true; + expect( model.schema.checkAttribute( [ '$root', 'table' ], 'listIndent' ) ).to.be.true; + expect( model.schema.checkAttribute( [ '$root', 'table' ], 'listType' ) ).to.be.true; + expect( model.schema.checkAttribute( [ '$root', 'tableCell' ], 'listItemId' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'tableCell' ], 'listIndent' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'tableCell' ], 'listType' ) ).to.be.false; + } ); + + describe( 'commands', () => { + it( 'should register indent list command', () => { + const command = editor.commands.get( 'indentList' ); + + expect( command ).to.be.instanceOf( DocumentListIndentCommand ); + } ); + + it( 'should register outdent list command', () => { + const command = editor.commands.get( 'outdentList' ); + + expect( command ).to.be.instanceOf( DocumentListIndentCommand ); + } ); + + it( 'should register the splitListItemBefore command', () => { + const command = editor.commands.get( 'splitListItemBefore' ); + + expect( command ).to.be.instanceOf( DocumentListSplitCommand ); + } ); + + it( 'should register the splitListItemAfter command', () => { + const command = editor.commands.get( 'splitListItemAfter' ); + + expect( command ).to.be.instanceOf( DocumentListSplitCommand ); + } ); + + it( 'should add indent list command to indent command', async () => { + const editor = await VirtualTestEditor.create( { + plugins: [ Paragraph, IndentEditing, DocumentListEditing ] + } ); + + const indentListCommand = editor.commands.get( 'indentList' ); + const indentCommand = editor.commands.get( 'indent' ); + + const spy = sinon.stub( indentListCommand, 'execute' ); + + indentListCommand.isEnabled = true; + indentCommand.execute(); + + sinon.assert.calledOnce( spy ); + + await editor.destroy(); + } ); + + it( 'should add outdent list command to outdent command', async () => { + const editor = await VirtualTestEditor.create( { + plugins: [ Paragraph, IndentEditing, DocumentListEditing ] + } ); + + const outdentListCommand = editor.commands.get( 'outdentList' ); + const outdentCommand = editor.commands.get( 'outdent' ); + + const spy = sinon.stub( outdentListCommand, 'execute' ); + + outdentListCommand.isEnabled = true; + outdentCommand.execute(); + + sinon.assert.calledOnce( spy ); + + await editor.destroy(); + } ); + } ); + + describe( 'post fixer', () => { + describe( 'insert', () => { + function testList( input, inserted, output ) { + const selection = prepareTest( model, input ); + + model.change( () => { + model.change( writer => { + writer.insert( parseModel( inserted, model.schema ), selection.getFirstPosition() ); + } ); + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( output ); + } + + it( 'element before nested list', () => { + testList( + 'a' + + 'b' + + '[]' + + 'd' + + 'e' + + 'f', + + 'x', + + 'a' + + 'b' + + 'x' + + 'd' + + 'e' + + 'f' + ); + } ); + + it( 'list item before nested list', () => { + testList( + 'a' + + 'b' + + '[]' + + 'd' + + 'e' + + 'f', + + 'x', + + 'a' + + 'b' + + 'x' + + 'd' + + 'e' + + 'f' + ); + } ); + + it( 'multiple list items with too big indent', () => { + testList( + 'a' + + 'b' + + '[]' + + 'c', + + 'x' + + 'x' + + 'x', + + 'a' + + 'b' + + 'x' + + 'x' + + 'x' + + 'c' + ); + } ); + + it( 'item with different type - top level list', () => { + testList( + 'a' + + 'b' + + '[]' + + 'c', + + 'x', + + 'a' + + 'b' + + 'x' + + 'c' + ); + } ); + + it( 'multiple items with different type - nested list', () => { + testList( + 'a' + + 'b' + + '[]' + + 'c', + + 'x' + + 'x', + + 'a' + + 'b' + + 'x' + + 'x' + + 'c' + ); + } ); + + it( 'item with different type, in nested list, after nested list', () => { + testList( + 'a' + + 'b' + + 'c' + + '[]', + + 'x', + + 'a' + + 'b' + + 'c' + + 'x' + ); + } ); + + it( 'two list items with mismatched types inserted in one batch', () => { + const input = + 'a' + + 'b'; + + const output = + 'a' + + 'b' + + 'c' + + 'd'; + + setModelData( model, input ); + + const item1 = 'c'; + const item2 = 'd'; + + model.change( writer => { + writer.append( parseModel( item1, model.schema ), modelRoot ); + writer.append( parseModel( item2, model.schema ), modelRoot ); + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( output ); + } ); + + it( 'paragraph between list item blocks', () => { + const input = + 'a' + + 'b' + + 'c' + + 'd'; + + const output = + 'a' + + 'x' + + 'x' + + 'b' + + 'c' + + 'd'; + + setModelData( model, input ); + + const item = 'xx'; + + model.change( writer => { + writer.insert( parseModel( item, model.schema ), modelRoot, 1 ); + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( output ); + } ); + } ); + + describe( 'remove', () => { + function testList( input, output ) { + const selection = prepareTest( model, input ); + + model.change( writer => { + writer.remove( selection.getFirstRange() ); + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( output ); + } + + it( 'first list item', () => { + testList( + '[a]' + + 'b' + + 'c', + + 'b' + + 'c' + ); + } ); + + it( 'first list item of nested list', () => { + testList( + 'a' + + '[b]' + + 'c' + + 'd' + + 'e' + + 'f', + + 'a' + + 'c' + + 'd' + + 'e' + + 'f' + ); + } ); + + it( 'selection over two different nested lists of same indent', () => { + testList( + 'a' + + 'b' + + '[c' + + 'd' + + 'e]' + + 'f', + + 'a' + + 'b' + + 'f' + ); + } ); + } ); + + describe( 'move', () => { + function testList( input, offset, output ) { + const selection = prepareTest( model, input ); + + model.change( writer => { + const targetPosition = writer.createPositionAt( modelRoot, offset ); + + writer.move( selection.getFirstRange(), targetPosition ); + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( output ); + } + + it( 'nested list item out of list structure', () => { + testList( + 'a' + + '[b' + + 'c]' + + 'd' + + 'e' + + 'x', + + 6, + + 'a' + + 'd' + + 'e' + + 'x' + + 'b' + + 'c' + ); + } ); + + it( 'list items between lists', () => { + testList( + 'a' + + 'b' + + '[c' + + 'd]' + + 'e' + + 'x' + + 'f' + + 'g', + + 7, + + 'a' + + 'b' + + 'e' + + 'x' + + 'f' + + 'c' + + 'd' + + 'g' + ); + } ); + + it( 'element in between nested list items', () => { + testList( + 'a' + + 'b' + + 'c' + + 'd' + + '[x]', + + 2, + + 'a' + + 'b' + + 'x' + + 'c' + + 'd' + ); + } ); + + it( 'multiple nested list items of different types #1 - fix at start', () => { + testList( + 'a' + + 'b' + + '[c' + + 'd' + + 'e]' + + 'f' + + 'g' + + 'h' + + 'i', + + 8, + + 'a' + + 'b' + + 'f' + + 'g' + + 'h' + + 'c' + + 'd' + + 'e' + + 'i' + ); + } ); + + it( 'multiple nested list items of different types #2 - fix at end', () => { + testList( + 'a' + + 'b' + + '[c' + + 'd' + + 'e]' + + 'f' + + 'g' + + 'h' + + 'i', + + 8, + + 'a' + + 'b' + + 'f' + + 'g' + + 'h' + + 'c' + + 'd' + + 'e' + + 'i' + ); + } ); + + // #78. + it( 'move out of container', () => { + testList( + '
                ' + + 'a' + + 'b' + + 'c' + + 'd' + + '[e]' + + '
                ', + + 0, + + 'e' + + '
                ' + + 'a' + + 'b' + + 'c' + + 'd' + + '
                ' + ); + } ); + } ); + + describe( 'rename', () => { + it( 'to element that does not allow list attributes', () => { + const modelBefore = + 'a' + + 'b' + + '[c]' + + 'd' + + 'e' + + 'f' + + 'g' + + 'h' + + 'i'; + + const expectedModel = + 'a' + + 'b' + + 'c' + + 'd' + + 'e' + + 'f' + + 'g' + + 'h' + + 'i'; + + const selection = prepareTest( model, modelBefore ); + + model.change( writer => { + writer.rename( selection.getFirstPosition().nodeAfter, 'nonListable' ); + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( expectedModel ); + } ); + } ); + + describe( 'changing list attributes', () => { + it( 'remove list attributes', () => { + const modelBefore = + 'a' + + 'b' + + '[c]' + + 'd' + + 'e' + + 'f' + + 'g' + + 'h' + + 'i'; + + const expectedModel = + 'a' + + 'b' + + 'c' + + 'd' + + 'e' + + 'f' + + 'g' + + 'h' + + 'i'; + + const selection = prepareTest( model, modelBefore ); + const element = selection.getFirstPosition().nodeAfter; + + model.change( writer => { + writer.removeAttribute( 'listItemId', element ); + writer.removeAttribute( 'listIndent', element ); + writer.removeAttribute( 'listType', element ); + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( expectedModel ); + } ); + + it( 'add list attributes', () => { + const modelBefore = + 'a' + + 'b' + + '[c]' + + 'd' + + 'e' + + 'f' + + 'g'; + + const expectedModel = + 'a' + + 'b' + + 'c' + + 'd' + + 'e' + + 'f' + + 'g'; + + const selection = prepareTest( model, modelBefore ); + const element = selection.getFirstPosition().nodeAfter; + + model.change( writer => { + writer.setAttribute( 'listItemId', 'c', element ); + writer.setAttribute( 'listIndent', 2, element ); + writer.setAttribute( 'listType', 'bulleted', element ); + writer.setAttribute( 'listIndent', 2, element.nextSibling ); + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( expectedModel ); + } ); + + it( 'middle block indent', () => { + const modelBefore = + 'a' + + '[b]' + + 'c'; + + const expectedModel = + 'a' + + 'b' + + 'c'; + + const selection = prepareTest( model, modelBefore ); + const element = selection.getFirstPosition().nodeAfter; + + model.change( writer => { + writer.setAttribute( 'listIndent', 1, element ); + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( expectedModel ); + } ); + + it( 'middle blocks indent', () => { + const modelBefore = + 'a' + + '[b' + + 'c]' + + 'd'; + + const expectedModel = + 'a' + + 'b' + + 'c' + + 'd'; + + const selection = prepareTest( model, modelBefore ); + + model.change( writer => { + for ( const item of selection.getFirstRange( 0 ).getItems( { shallow: true } ) ) { + writer.setAttribute( 'listIndent', 1, item ); + } + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( expectedModel ); + } ); + + it( 'middle block outdent', () => { + const modelBefore = + 'a' + + '[b]' + + 'c'; + + const expectedModel = + 'a' + + 'b' + + 'c'; + + const selection = prepareTest( model, modelBefore ); + const element = selection.getFirstPosition().nodeAfter; + + model.change( writer => { + writer.setAttribute( 'listIndent', 0, element ); + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( expectedModel ); + } ); + } ); + } ); +} ); + +describe( 'DocumentListEditing - registerDowncastStrategy()', () => { + let editor, model, view; + + afterEach( async () => { + await editor.destroy(); + } ); + + it( 'should allow registering strategy for list elements', async () => { + await createEditor( class CustomPlugin extends Plugin { + init() { + this.editor.plugins.get( 'DocumentListEditing' ).registerDowncastStrategy( { + scope: 'list', + attributeName: 'someFoo', + + setAttributeOnDowncast( writer, attributeValue, viewElement ) { + writer.setAttribute( 'data-foo', attributeValue, viewElement ); + } + } ); + } + } ); + + setModelData( model, modelList( ` + * foo + * bar + ` ) ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
                  ' + + '
                • foo
                • ' + + '
                • bar
                • ' + + '
                ' + ); + } ); + + it( 'should allow registering strategy for list items elements', async () => { + await createEditor( class CustomPlugin extends Plugin { + init() { + this.editor.plugins.get( 'DocumentListEditing' ).registerDowncastStrategy( { + scope: 'item', + attributeName: 'someFoo', + + setAttributeOnDowncast( writer, attributeValue, viewElement ) { + writer.setAttribute( 'data-foo', attributeValue, viewElement ); + } + } ); + } + } ); + + setModelData( model, modelList( ` + * foo + * bar + ` ) ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
                  ' + + '
                • foo
                • ' + + '
                • bar
                • ' + + '
                ' + ); + } ); + + async function createEditor( extraPlugin ) { + editor = await VirtualTestEditor.create( { + plugins: [ Paragraph, DocumentListEditing, UndoEditing, extraPlugin ] + } ); + + model = editor.model; + view = editor.editing.view; + } +} ); diff --git a/packages/ckeditor5-list/tests/documentlist/documentlistindentcommand.js b/packages/ckeditor5-list/tests/documentlist/documentlistindentcommand.js new file mode 100644 index 00000000000..7116be0991e --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/documentlistindentcommand.js @@ -0,0 +1,1193 @@ +/** + * @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 DocumentListIndentCommand from '../../src/documentlist/documentlistindentcommand'; +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( 'DocumentListIndentCommand', () => { + let editor, model, doc, root; + + 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' ] } ); + model.schema.extend( '$block', { allowAttributes: [ 'listType', 'listIndent', 'listItemId' ] } ); + model.schema.extend( '$blockObject', { allowAttributes: [ 'listType', 'listIndent', 'listItemId' ] } ); + } ); + + describe( 'forward (indent)', () => { + let command; + + beforeEach( () => { + command = new DocumentListIndentCommand( editor, 'forward' ); + } ); + + afterEach( () => { + command.destroy(); + } ); + + describe( 'isEnabled', () => { + describe( 'single block per list item', () => { + it( 'should be true if selection starts in list item', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + ' * []5', + '* 6' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false if selection starts in first list item', () => { + setData( model, modelList( [ + '* []0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + ' * 5', + '* 6' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false if selection starts in first list item at given indent', () => { + setData( model, modelList( [ + '* 0', + ' * 1', + '* 2', + ' * []3', + ' * 4' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false if selection starts in first list item (different list type)', () => { + setData( model, modelList( [ + '* 0', + ' * 1', + '# 2', + ' * 3', + '* []4' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false if selection is in first list item with different type than previous list', () => { + setData( model, modelList( [ + '* 0', + '# []1' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false if selection starts in a list item that has higher indent than it\'s previous sibling', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' * []2', + ' * 3', + ' * 4', + ' * 5', + '* 6' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false if selection starts before a list item', () => { + setData( model, modelList( [ + '[]0', + '* 1', + '* 2' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + } ); + + describe( 'multiple blocks per list item', () => { + it( 'should be true if selection starts in the first block of list item', () => { + setData( model, modelList( [ + '* 0', + '* []1', + ' 2', + ' 3' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if selection starts in the second block of list item', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' []2', + ' 3' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if selection starts in the last block of list item', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' 2', + ' []3' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false if selection starts in first list item', () => { + setData( model, modelList( [ + '* []0', + ' 1' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false if selection starts in the first list item at given indent', () => { + setData( model, modelList( [ + '* 0', + ' * []1', + ' 2' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false if selection is in first list item with different type than previous list', () => { + setData( model, modelList( [ + '* 0', + ' 1', + '# []2', + ' 3' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + describe( 'multiple list items selection', () => { + it( 'should be true if selection starts in the middle block of list item and spans multiple items', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' [2', + '* 3]', + ' 4' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + } ); + } ); + } ); + + describe( 'execute()', () => { + describe( 'single block per list item', () => { + it( 'should use parent batch', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + ' * []5', + '* 6' + ] ) ); + + model.change( writer => { + expect( writer.batch.operations.length ).to.equal( 0 ); + + command.execute(); + + expect( writer.batch.operations.length ).to.be.above( 0 ); + } ); + } ); + + it( 'should increment indent attribute by 1', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + ' * []5', + '* 6' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + ' * []5', + '* 6' + ] ) ); + } ); + + it( 'should increment indent of all sub-items of indented item', () => { + setData( model, modelList( [ + '* 0', + '* []1', + ' * 2', + ' * 3', + ' * 4', + ' * 5', + '* 6' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * []1', + ' * 2', + ' * 3', + ' * 4', + ' * 5', + '* 6' + ] ) ); + } ); + + describe( 'mixed list types', () => { + it( 'should not change list item type if the indented list item is the first one in the nested list (bulleted)', () => { + setData( model, modelList( [ + '* 0', + '* 1[]', + ' # 2', + '* 3' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * 1[]', + ' # 2', + '* 3' + ] ) ); + } ); + + it( 'should not change list item type if the indented list item is the first one in the nested list (numbered)', () => { + setData( model, modelList( [ + '# 0', + '# 1[]', + ' * 2', + '# 3' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# 0', + ' # 1[]', + ' * 2', + '# 3' + ] ) ); + } ); + + it( 'should adjust list type to the previous list item (numbered)', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' # 2', + '* []3' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + ' # 2', + ' # []3' + ] ) ); + } ); + + it( 'should not change list item type if the indented list item is the first one in the nested list', () => { + setData( model, modelList( [ + '* 0', + '* []1', + ' # 2', + ' * 3', + ' # 4' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * []1', + ' # 2', + ' * 3', + ' # 4' + ] ) ); + } ); + + it( 'should not change list item type if the first item in the nested list (has more items)', () => { + setData( model, modelList( [ + '* 0', + '* []1', + ' # 2', + ' * 3', + ' # 4', + '* 5', + ' # 6' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * []1', + ' # 2', + ' * 3', + ' # 4', + '* 5', + ' # 6' + ] ) ); + } ); + } ); + + describe( 'non-collapsed selection', () => { + it( 'should increment indent of all selected item when multiple items are selected', () => { + setData( model, modelList( [ + '* 0', + '* [1', + ' * 2', + ' * 3]', + ' * 4', + ' * 5', + '* 6' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * [1', + ' * 2', + ' * 3]', + ' * 4', + ' * 5', + '* 6' + ] ) ); + } ); + + describe( 'mixed list types', () => { + it( 'should not change list types for the first list items', () => { + setData( model, modelList( [ + '* 0', + '* [1', + ' # 2]', + ' * 3' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * [1', + ' # 2]', + ' * 3' + ] ) ); + } ); + + it( 'should not change list types for the first list items (with nested lists)', () => { + setData( model, modelList( [ + '* 0', + '* [1', + ' # 2]', + '* 3' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * [1', + ' # 2]', + '* 3' + ] ) ); + } ); + + it( 'should align the list type if become a part of other list (bulleted)', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' # 2', + '* [3', + '* 4]' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + ' # 2', + ' # [3', + ' # 4]' + ] ) ); + } ); + + it( 'should align the list type if become a part of other list (numbered)', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' # 2', + ' # [3', + '* 4]' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + ' # 2', + ' # [3', + ' # 4]' + ] ) ); + } ); + + it( 'should align the list type (bigger structure)', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' # 2', + ' * 3', + ' * [4', + ' # 5', + ' * 6', + ' # 7]' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + ' # 2', + ' * 3', + ' * [4', + ' * 5', + ' * 6', + ' * 7]' + ] ) ); + } ); + } ); + } ); + + it( 'should fire "afterExecute" event after finish all operations with all changed items', done => { + setData( model, modelList( [ + '* 0', + '* []1', + ' * 2', + ' * 3', + ' * 4', + ' * 5', + '* 6' + ] ) ); + + command.on( 'afterExecute', ( evt, data ) => { + expect( data ).to.deep.equal( [ + root.getChild( 1 ), + root.getChild( 2 ), + root.getChild( 3 ), + root.getChild( 4 ), + root.getChild( 5 ) + ] ); + + done(); + } ); + + command.execute(); + } ); + } ); + + describe( 'multiple blocks per list item', () => { + it( 'should change indent of all blocks of a list item', () => { + setData( model, modelList( [ + '* 0', + '* []1', + ' 2', + ' 3', + '* 4' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * []1', + ' 2', + ' 3', + '* 4' + ] ) ); + } ); + + it( 'should change indent (with new ID) if the following block of bigger list item is selected', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' []2', + ' 3', + '* 4' + ] ) ); + + stubUid(); + command.isEnabled = true; + command.execute(); + + expect( getData( model ) ).to.equalMarkup( + '0' + + '1' + + '[]2' + + '3' + + '4' + ); + } ); + + it( 'should increment indent of all sub-items of indented item', () => { + setData( model, modelList( [ + '* 0', + '* []1', + ' * 2', + ' * 3', + ' 4', + ' * 5', + ' 6', + '* 7' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * []1', + ' * 2', + ' * 3', + ' 4', + ' * 5', + ' 6', + '* 7' + ] ) ); + } ); + + it( 'should increment indent of all sub-items of indented item (at end of list item)', () => { + setData( model, modelList( [ + '* 0', + '* []1', + ' 2', + ' * 3', + ' * 4', + ' 5', + ' * 6', + '* 7' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * []1', + ' 2', + ' * 3', + ' * 4', + ' 5', + ' * 6', + '* 7' + ] ) ); + } ); + + it( 'should increment indent of all selected list items when multiple items are selected partially', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' [2', + '* 3]', + ' 4', + '* 5' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * 1', + ' [2', + ' * 3]', + ' 4', + '* 5' + ] ) ); + } ); + + it( 'should not increment indent of items from the following list even if it was selected', () => { + setData( model, modelList( [ + '* 0', + '* [1', + '2', + '* 3]', + '* 4' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * [1', + '2', + '* 3]', + '* 4' + ] ) ); + } ); + + it( 'should fire "afterExecute" event after finish all operations with all changed items', done => { + setData( model, modelList( [ + '* 0', + '* []1', + ' * 2', + ' * 3', + ' * 4', + ' * 5', + '* 6' + ] ) ); + + command.on( 'afterExecute', ( evt, data ) => { + expect( data ).to.deep.equal( [ + root.getChild( 1 ), + root.getChild( 2 ), + root.getChild( 3 ), + root.getChild( 4 ), + root.getChild( 5 ) + ] ); + + done(); + } ); + + command.execute(); + } ); + + it( 'should align the list item type after indenting a following block of a list item (numbered)', () => { + setData( model, modelList( [ + '* 0', + ' # 1', + ' * 2', + ' 3[]', + '* 4' + ] ) ); + + stubUid(); + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' # 1', + ' * 2', + ' * 3[] {id:a00}', + '* 4' + ] ) ); + } ); + + it( 'should align the list item type after indenting a following block of a list item (bulleted)', () => { + setData( model, modelList( [ + '# 0', + ' * 1', + ' # 2', + ' 3[]', + '# 4' + ] ) ); + + stubUid(); + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# 0', + ' * 1', + ' # 2', + ' # 3[] {id:a00}', + '# 4' + ] ) ); + } ); + + it( 'should align the list item type after indenting a following block of a list item (bigger structure)', () => { + setData( model, modelList( [ + '* 0', + ' # 1', + ' * 2', + ' 3', + ' 4[]' + ] ) ); + + stubUid(); + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' # 1', + ' * 2', + ' 3', + ' # 4[] {id:a00}' + ] ) ); + } ); + + it( 'should align the list item type after indenting the last block of a list item', () => { + setData( model, modelList( [ + '* 0', + ' # 1', + ' 2[]' + ] ) ); + + stubUid(); + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' # 1', + ' # 2[] {id:a00}' + ] ) ); + } ); + } ); + } ); + } ); + + describe( 'backward (outdent)', () => { + let command; + + beforeEach( () => { + command = new DocumentListIndentCommand( editor, 'backward' ); + } ); + + afterEach( () => { + command.destroy(); + } ); + + describe( 'isEnabled', () => { + it( 'should be true if selection starts in list item', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + ' * []5', + '* 6' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if selection starts in first list item', () => { + setData( model, modelList( [ + '* []0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + ' * 5', + '* 6' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if selection starts in a list item that has higher indent than it\'s previous sibling', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' * []2', + ' * 3', + ' * 4', + ' * 5', + '* 6' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false if selection starts before a list', () => { + setData( model, modelList( [ + '[0', + '* 1]', + ' * 2' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be true with selection in the middle block of a list item', () => { + setData( model, modelList( [ + '* 0', + ' []1', + ' 2' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true with selection in the last block of a list item', () => { + setData( model, modelList( [ + '* 0', + ' 1', + ' []2' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + } ); + + describe( 'execute()', () => { + it( 'should decrement indent attribute by 1 (if it is higher than 0)', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + ' * []5', + '* 6' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + '* []5', + '* 6' + ] ) ); + } ); + + it( 'should remove list attributes (if indent is less than to 0)', () => { + setData( model, modelList( [ + '* []0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + ' * 5', + '* 6' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '[]0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + ' * 5', + '* 6' + ] ) ); + } ); + + it( 'should decrement indent of all sub-items of outdented item', () => { + setData( model, modelList( [ + '* 0', + '* []1', + ' * 2', + ' * 3', + ' * 4', + ' * 5', + '* 6' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '[]1', + '* 2', + ' * 3', + ' * 4', + '* 5', + '* 6' + ] ) ); + } ); + + it( 'should outdent all selected item when multiple items are selected', () => { + setData( model, modelList( [ + '* 0', + '* [1', + ' * 2', + ' * 3]', + ' * 4', + ' * 5', + '* 6' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '[1', + '* 2', + ' * 3]', + ' * 4', + '* 5', + '* 6' + ] ) ); + } ); + + it( 'should outdent all blocks of partly selected item when multiple items are selected', () => { + setData( model, modelList( [ + '* 0', + ' * 1', + ' [2', + ' * 3]', + ' 4', + ' * 5', + '* 6' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + ' [2', + ' * 3]', + ' 4', + ' * 5', + '* 6' + ] ) ); + } ); + + it( 'should split list item if selection is in the following list item block', () => { + setData( model, modelList( [ + '* 0', + ' []1', + ' 2', + '* 3' + ] ) ); + + stubUid(); + command.execute(); + + expect( getData( model ) ).to.equalMarkup( + '0' + + '[]1' + + '2' + + '3' + ); + } ); + + it( 'should split list item if selection is in the last list item block', () => { + setData( model, modelList( [ + '* 0', + ' 1', + ' []2', + '* 3' + ] ) ); + + stubUid(); + command.execute(); + + expect( getData( model ) ).to.equalMarkup( + '0' + + '1' + + '[]2' + + '3' + ); + } ); + + it( 'should merge item if parent has more following blocks', () => { + setData( model, modelList( [ + '* 0', + ' * []1', + ' 2' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' []1', + ' 2' + ] ) ); + } ); + + it( 'should not merge item if parent has no more following blocks', () => { + setData( model, modelList( [ + '* 0', + ' * []1', + '* 2' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* []1', + '* 2' + ] ) ); + } ); + + it( 'should handle higher indent drop between items', () => { + setData( model, modelList( [ + '* 0', + ' * 1', + ' * 2', + ' * [3', + ' * 4]', + ' * 5' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * 1', + ' * 2', + ' * [3', + '* 4]', + ' * 5' + ] ) ); + } ); + + it( 'should align a list item type after outdenting item', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' # 2[]', + '* 3' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + '* 2[]', + '* 3' + ] ) ); + } ); + + it( 'should align a list item type after outdenting the last list item', () => { + setData( model, modelList( [ + '# 0', + ' * 1', + ' * 2[]', + '# 3' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# 0', + ' * 1', + '# 2[]', + '# 3' + ] ) ); + } ); + + it( 'should align the list item type after the more indented item', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' # 2', + ' * 3', + ' # 4[]' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + ' # 2', + ' * 3', + '* 4[]' + ] ) ); + } ); + + it( 'should outdent the whole nested list (and align appropriate list item types)', () => { + setData( model, modelList( [ + '* 0', + ' # []1', + ' # 2', + ' * 3', + ' # 4', + '* 5', + ' # 6' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* []1', + ' # 2', + ' * 3', + ' # 4', + '* 5', + ' # 6' + ] ) ); + } ); + + it( 'should align list item typed after outdenting a bigger structure', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' # 2', + ' * 3', + ' # [4', + ' * 5', + ' # 6', + ' * 7]' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + ' # 2', + ' * 3', + ' * [4', + ' # 5', + ' # 6', + ' # 7]' + ] ) ); + } ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-list/tests/documentlist/documentlistmergecommand.js b/packages/ckeditor5-list/tests/documentlist/documentlistmergecommand.js new file mode 100644 index 00000000000..e38b1b78b9d --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/documentlistmergecommand.js @@ -0,0 +1,3050 @@ +/** + * @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.extend( '$block', { allowAttributes: [ 'listType', 'listIndent', 'listItemId' ] } ); + model.schema.extend( '$blockObject', { 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/documentlistsplitcommand.js b/packages/ckeditor5-list/tests/documentlist/documentlistsplitcommand.js new file mode 100644 index 00000000000..94d54076060 --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/documentlistsplitcommand.js @@ -0,0 +1,491 @@ +/** + * @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 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' ] } ); + model.schema.extend( '$block', { allowAttributes: [ 'listType', 'listIndent', 'listItemId' ] } ); + model.schema.extend( '$blockObject', { allowAttributes: [ 'listType', 'listIndent', 'listItemId' ] } ); + + stubUid(); + } ); + + afterEach( () => { + command.destroy(); + } ); + + describe( 'split before', () => { + beforeEach( () => { + command = new DocumentListSplitCommand( editor, 'before' ); + + command.on( 'afterExecute', ( evt, data ) => { + changedBlocks = data; + } ); + } ); + + 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 ) + ] ); + } ); + } ); + } ); + + describe( 'split after', () => { + beforeEach( () => { + command = new DocumentListSplitCommand( editor, 'after' ); + + command.on( 'afterExecute', ( evt, data ) => { + changedBlocks = data; + } ); + } ); + + 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 empty block of a list item not followed by another block', () => { + setData( model, modelList( [ + '* []' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false if selection is in the first block of a list item not followed by another block', () => { + setData( model, modelList( [ + '* a[]' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be true if selection is collapsed in a block followed by another block in the same 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[]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 first block followed by another', () => { + 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 a middle block of the list item', () => { + setData( model, modelList( [ + '* a', + ' []', + ' c' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* a', + ' []', + '* c {id:a00}' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 2 ) + ] ); + } ); + + it( 'should create another list item when the selection in a middle block of the list item (followed by another)', () => { + setData( model, modelList( [ + '* a', + ' []', + ' c', + '* ' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* a', + ' []', + '* c {id:a00}', + '* ' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 2 ) + ] ); + } ); + + it( 'should create another list item in a nested structure', () => { + setData( model, modelList( [ + '* a', + ' b', + ' * a[]', + ' b' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* a', + ' b', + ' * a[]', + ' * b {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[]', + ' * e {id:a00}' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + 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[]', + ' * e {id:a00}', + ' * f', + '* g' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 4 ) + ] ); + } ); + } ); + } ); +} ); 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..5ed448088fa --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/integrations/delete.js @@ -0,0 +1,6679 @@ +/** + * @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, + isBlock: true, + allowIn: '$root', + allowAttributesOf: '$container' + } ); + + editor.conversion.for( 'downcast' ).elementToElement( { + model: 'blockWidget', + view: ( modelItem, { writer } ) => { + return toWidget( writer.createContainerElement( 'blockwidget', { class: 'block-widget' } ), writer ); + } + } ); + + editor.model.schema.register( 'inlineWidget', { + isObject: true, + isInline: true, + allowWhere: '$text', + allowAttributesOf: '$text' + } ); + + // The view element has no children. + editor.conversion.for( 'downcast' ).elementToElement( { + model: 'inlineWidget', + view: ( modelItem, { writer } ) => toWidget( + writer.createContainerElement( 'inlinewidget', { class: 'inline-widget' } ), writer, { label: 'inline widget' } + ) + } ); + + stubUid(); + + 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( 'should delete a block widget and keep the selection in the same item block', () => { + runTest( { + input: [ + '* a', + ' []' + ], + expected: [ + '* a', + ' []' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should delete a block widget and keep the selection in the same block (multiple blocks)', () => { + runTest( { + input: [ + '* a', + ' []', + ' b' + ], + expected: [ + '* a', + ' []', + ' b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should delete a block widget and keep the selection in the same block (nested item follows)', () => { + runTest( { + input: [ + '* a', + ' []', + ' * b' + ], + expected: [ + '* a', + ' []', + ' * b {id:002}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + it( 'should delete a block widget and keep the selection in the 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 delete a block widget andkeep the selection in the same list item (multiple blocks)', () => { + 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 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( 'should remove a block widget and keep the selection in the same block', () => { + runTest( { + input: [ + '* a', + ' []', + ' b' + ], + expected: [ + '* a', + ' []', + ' b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + 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( 'should remove a block widget surrounded by block containing inline images at boundaries', () => { + runTest( { + input: [ + '* a', + ' []', + ' b' + ], + expected: [ + '* a', + ' []', + ' b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + // #11346. + it( 'should remove a widget that is a list item', () => { + runTest( { + input: [ + '* a', + '* []', + '* b' + ], + expected: [ + '* a', + '* []', + '* b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + changedBlocks: [] + } ); + } ); + + // #11346. + it( 'should remove a widget that is a list item in a nested structure', () => { + runTest( { + input: [ + '* a', + ' * aa', + ' * []', + ' * ac', + '* b' + ], + expected: [ + '* a', + ' * aa', + ' * []', + ' * ac', + '* b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0, + mergeBackward: 0, + mergeForward: 0 + }, + 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/integrations/image.js b/packages/ckeditor5-list/tests/documentlist/integrations/image.js new file mode 100644 index 00000000000..3a36d29eda5 --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/integrations/image.js @@ -0,0 +1,222 @@ +/** + * @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 stubUid from '../_utils/uid'; +import { modelList } from '../_utils/utils'; + +import Image from '@ckeditor/ckeditor5-image/src/image'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import { Paragraph } from 'ckeditor5/src/paragraph'; +import { + getData as getModelData, + setData as setModelData +} from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; + +describe( 'image plugin integration', () => { + let element; + let editor, model; + + const imgSrc = 'foo/bar.jpg'; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + element = document.createElement( 'div' ); + document.body.appendChild( element ); + + editor = await ClassicTestEditor.create( element, { + plugins: [ + Paragraph, DocumentListEditing, Image + ] + } ); + + model = editor.model; + + stubUid(); + } ); + + afterEach( async () => { + element.remove(); + + await editor.destroy(); + } ); + + describe( 'changing image type', () => { + let blockCommand, inlineCommand; + + beforeEach( () => { + blockCommand = editor.commands.get( 'imageTypeBlock' ); + inlineCommand = editor.commands.get( 'imageTypeInline' ); + } ); + + describe( 'inline image to block image', () => { + it( 'should replace an inline image with a block image', () => { + setModelData( model, modelList( [ + `* []` + ] ) ); + + blockCommand.execute(); + + expect( getModelData( model ) ).to.equal( + `[]` + ); + } ); + + it( 'should create a block image below paragraph when an inline image is at the end of a block', () => { + setModelData( model, modelList( [ + `* Foo[]` + ] ) ); + + blockCommand.execute(); + + expect( getModelData( model ) ).to.equal( + 'Foo' + + `[]` + ); + } ); + + it( 'should create a block imaage below paragraph when an inline image is at the start of a block', () => { + setModelData( model, modelList( [ + `* []Foo` + ] ) ); + + blockCommand.execute(); + + expect( getModelData( model ) ).to.equal( + `[]` + + 'Foo' + ); + } ); + + it( 'should split paragraph in two when an inline image is in the middle of a block', () => { + setModelData( model, modelList( [ + `* Fo[]oo` + ] ) ); + + blockCommand.execute(); + + expect( getModelData( model ) ).to.equal( + 'Fo' + + `[]` + + 'oo' + ); + } ); + + it( 'should replace an inline image with a paragraphed inline image when an image is in a block', () => { + setModelData( model, modelList( [ + '* Foo', + ` []` + ] ) ); + + blockCommand.execute(); + + expect( getModelData( model ) ).to.equal( + 'Foo' + + `[]` + ); + } ); + + it( 'should split an image after paragraph and create an image block if image inline is at the end', () => { + setModelData( model, modelList( [ + '* Foo', + ` Bar []` + ] ) ); + + blockCommand.execute(); + + expect( getModelData( model ) ).to.equal( + 'Foo' + + 'Bar ' + + `[]` + ); + } ); + + it( 'should split an image before paragraph and create an image block if image inline is at the start', () => { + setModelData( model, modelList( [ + '* Foo', + ` [] Bar` + ] ) ); + + blockCommand.execute(); + + expect( getModelData( model ) ).to.equal( + 'Foo' + + `[]` + + ' Bar' + ); + } ); + + it( 'should split a paragraph into two and insert a block image between', () => { + setModelData( model, modelList( [ + '* Foo', + ` Bar [] Yar` + ] ) ); + + blockCommand.execute(); + + expect( getModelData( model ) ).to.equal( + 'Foo' + + 'Bar ' + + `[]` + + ' Yar' + ); + } ); + } ); + + describe( 'block image to inline image', () => { + it( 'should change image block to inline block when an image is a first item in a list', () => { + setModelData( model, modelList( [ + `* []` + ] ) ); + + inlineCommand.execute(); + + expect( getModelData( model ) ).to.equal( + '' + + `[]` + + '' + ); + } ); + + it( 'should change image block to inline block when an image is a block item in a list', () => { + setModelData( model, modelList( [ + '* Foo', + ` []` + ] ) ); + + inlineCommand.execute(); + + expect( getModelData( model ) ).to.equal( + 'Foo' + + '' + + `[]` + + '' + ); + } ); + + it( 'should change image block to inline block when an image is not a last block item in a list', () => { + setModelData( model, modelList( [ + '* Foo', + ` []`, + ' Bar' + ] ) ); + + inlineCommand.execute(); + + expect( getModelData( model ) ).to.equal( + 'Foo' + + '' + + `[]` + + '' + + 'Bar' + ); + } ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-list/tests/documentlist/integrations/indentmulticommand.js b/packages/ckeditor5-list/tests/documentlist/integrations/indentmulticommand.js new file mode 100644 index 00000000000..4b10a715d08 --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/integrations/indentmulticommand.js @@ -0,0 +1,1527 @@ +/** + * @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 stubUid from '../_utils/uid'; +import { modelList } from '../_utils/utils'; + +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import IndentEditing from '@ckeditor/ckeditor5-indent/src/indentediting'; +import IndentBlock from '@ckeditor/ckeditor5-indent/src/indentblock'; +import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting'; +import CodeBlockEditing from '@ckeditor/ckeditor5-code-block/src/codeblockediting'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import { Paragraph } from 'ckeditor5/src/paragraph'; +import { + getData as getModelData, + setData as setModelData +} from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; + +describe( 'Indent MultiCommand integrations', () => { + const blocksChangedByCommands = []; + + let element; + let editor, model; + let indentListcommand, outdentListcommand, + commandSpies; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + element = document.createElement( 'div' ); + document.body.appendChild( element ); + + editor = await ClassicTestEditor.create( element, { + plugins: [ + Paragraph, CodeBlockEditing, DocumentListEditing, IndentEditing, IndentBlock, + BlockQuoteEditing + ] + } ); + + model = editor.model; + + stubUid(); + + indentListcommand = editor.commands.get( 'indentList' ); + outdentListcommand = editor.commands.get( 'outdentList' ); + + commandSpies = { + indentList: sinon.spy( indentListcommand, 'execute' ), + outdentList: sinon.spy( outdentListcommand, 'execute' ) + }; + + blocksChangedByCommands.length = 0; + + indentListcommand.on( 'afterExecute', ( evt, data ) => { + blocksChangedByCommands.push( ...data ); + } ); + + outdentListcommand.on( 'afterExecute', ( evt, data ) => { + blocksChangedByCommands.push( ...data ); + } ); + } ); + + afterEach( async () => { + element.remove(); + + await editor.destroy(); + } ); + + describe( 'list with indent block', () => { + beforeEach( () => { + const indentBlockCommand = editor.commands.get( 'indentBlock' ); + const outdentBlockCommand = editor.commands.get( 'outdentBlock' ); + + commandSpies.indentBlock = sinon.spy( indentBlockCommand, 'execute' ); + commandSpies.outdentBlock = sinon.spy( outdentBlockCommand, 'execute' ); + } ); + + describe( 'indent command', () => { + describe( 'collapsed selection', () => { + it( 'should execute the indentBlock command if cannot indent a list item (start of a list)', () => { + runTest( { + input: [ + '* A[]' + ], + expected: [ + '* A[]' + ], + commandName: 'indent', + executedCommands: { + outdentList: 0, + indentList: 0, + outdentBlock: 0, + indentBlock: 1 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'should execute the indentBlock command if cannot indent list item a (nested list item)', () => { + runTest( { + input: [ + '* A', + ' * B[]' + ], + expected: [ + '* A', + ' * B[]' + ], + commandName: 'indent', + executedCommands: { + outdentList: 0, + indentList: 0, + outdentBlock: 0, + indentBlock: 1 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'should execute the indentBlock command if cannot indent a list item (start of a different list)', () => { + runTest( { + input: [ + '* A', + '# B[]' + ], + expected: [ + '* A', + '# B[]' + ], + commandName: 'indent', + executedCommands: { + outdentList: 0, + indentList: 0, + outdentBlock: 0, + indentBlock: 1 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'should execute the indentBlock command if a list item can\'t be indented (list item after block)', () => { + runTest( { + input: [ + '* A', + ' B', + ' * C[]' + ], + expected: [ + '* A', + ' B', + ' * C[]' + ], + commandName: 'indent', + executedCommands: { + outdentList: 0, + indentList: 0, + outdentBlock: 0, + indentBlock: 1 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'should indent a list item if preceded by a list item with the same indent', () => { + runTest( { + input: [ + '* A', + '* B[]' + ], + expected: [ + '* A', + ' * B[]' + ], + commandName: 'indent', + executedCommands: { + outdentList: 0, + indentList: 1, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should indent a list item if preceded by a list item with higher indent', () => { + runTest( { + input: [ + '* A', + ' * B', + '* C[]' + ], + expected: [ + '* A', + ' * B', + ' * C[]' + ], + commandName: 'indent', + executedCommands: { + outdentList: 0, + indentList: 1, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 2 ] + } ); + } ); + + it( 'should indent a list item block', () => { + runTest( { + input: [ + '* A', + ' B[]' + ], + expected: [ + '* A', + ' * B[] {id:a00}' + ], + commandName: 'indent', + executedCommands: { + outdentList: 0, + indentList: 1, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should indent only a selected list item block', () => { + runTest( { + input: [ + '* A', + ' B[]', + ' C' + ], + expected: [ + '* A', + ' * B[] {id:a00}', + ' C' + ], + commandName: 'indent', + executedCommands: { + outdentList: 0, + indentList: 1, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should indent a list item with its blocks', () => { + runTest( { + input: [ + '* A', + '* B[]', + ' C' + ], + expected: [ + '* A', + ' * B[]', + ' C' + ], + commandName: 'indent', + executedCommands: { + outdentList: 0, + indentList: 1, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should indent a list item with its blocks and nested list items', () => { + runTest( { + input: [ + '* A', + '* B[]', + ' C', + ' * D' + ], + expected: [ + '* A', + ' * B[]', + ' C', + ' * D' + ], + commandName: 'indent', + executedCommands: { + outdentList: 0, + indentList: 1, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 1, 2, 3 ] + } ); + } ); + + it( 'should indent a list item with its blocks and nested multi-block list items', () => { + runTest( { + input: [ + '* A', + '* B[]', + ' C', + ' * D', + ' E' + ], + expected: [ + '* A', + ' * B[]', + ' C', + ' * D', + ' E' + ], + commandName: 'indent', + executedCommands: { + outdentList: 0, + indentList: 1, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 1, 2, 3, 4 ] + } ); + } ); + } ); + + describe( 'non-collapsed selection', () => { + it( 'should indent all selected list items', () => { + runTest( { + input: [ + '* A', + '* [B', + '* C', + '* D]' + ], + expected: [ + '* A', + ' * [B', + ' * C', + ' * D]' + ], + commandName: 'indent', + executedCommands: { + outdentList: 0, + indentList: 1, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 1, 2, 3 ] + } ); + } ); + + it( 'should indent all selected list items with blocks', () => { + runTest( { + input: [ + '* A', + '* [B', + ' C', + '* D]' + ], + expected: [ + '* A', + ' * [B', + ' C', + ' * D]' + ], + commandName: 'indent', + executedCommands: { + outdentList: 0, + indentList: 1, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 1, 2, 3 ] + } ); + } ); + + it( 'should indent blocks to the same list item', () => { + runTest( { + input: [ + '* A', + ' [B', + ' C]' + ], + expected: [ + '* A', + ' * [B {id:a00}', + ' C]' + ], + commandName: 'indent', + executedCommands: { + outdentList: 0, + indentList: 1, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should indent when a selection spans a block and a list item', () => { + runTest( { + input: [ + '* 0', + '* 1', + ' [2', + '* 3]', + ' 4' + ], + expected: [ + '* 0', + ' * 1', + ' [2', + ' * 3]', + ' 4' + ], + commandName: 'indent', + executedCommands: { + outdentList: 0, + indentList: 1, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 1, 2, 3, 4 ] + } ); + } ); + + it( 'should execute the indentBlock command when all selected items cannot be indented (start of a list)', () => { + runTest( { + input: [ + '* [A', + ' B', + ' C]' + ], + expected: [ + '* [A', + ' B', + ' C]' + ], + commandName: 'indent', + executedCommands: { + outdentList: 0, + indentList: 0, + outdentBlock: 0, + indentBlock: 1 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'should execute the indentBlock command if any of selected blocks can\'t be indented', () => { + runTest( { + input: [ + '* A', + ' * [B', + ' C]' + + ], + expected: [ + '* A', + ' * [B', + ' C]' + ], + commandName: 'indent', + executedCommands: { + outdentList: 0, + indentList: 0, + outdentBlock: 0, + indentBlock: 1 + }, + changedBlocks: [ ] + } ); + } ); + } ); + } ); + + describe( 'outdent command', () => { + describe( 'collapsed selection', () => { + it( 'no command should be executed when outside list', () => { + runTest( { + input: [ + 'A[]' + ], + expected: [ + 'A[]' + ], + commandName: 'outdent', + eventStopped: false, + executedCommands: { + outdentList: 0, + indentList: 0, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'should outdent the list item and delete the list', () => { + runTest( { + input: [ + '* A[]' + ], + expected: [ + 'A[]' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 1, + indentList: 0, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should outdent the list item and make the next item first in the list', () => { + runTest( { + input: [ + '* A[]', + '* B' + ], + expected: [ + 'A[]', + '* B' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 1, + indentList: 0, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should outdent the list item and split the list into two lists', () => { + runTest( { + input: [ + '* A', + '* B[]', + '* C' + ], + expected: [ + '* A', + 'B[]', + '* C' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 1, + indentList: 0, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should outdent the list item, split the list into two lists and fix the indent', () => { + runTest( { + input: [ + '* A', + '* B[]', + ' * C' + ], + expected: [ + '* A', + 'B[]', + '* C' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 1, + indentList: 0, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should outdent the list item, split the list into two lists and fix indent of the multi-block list item', () => { + runTest( { + input: [ + '* A', + '* B[]', + ' * C', + ' D', + ' * E' + ], + expected: [ + '* A', + 'B[]', + '* C', + ' D', + ' * E' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 1, + indentList: 0, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 1, 2, 3, 4 ] + } ); + } ); + + it( 'should outdent the list item', () => { + runTest( { + input: [ + '* A', + ' * B[]' + ], + expected: [ + '* A', + '* B[]' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 1, + indentList: 0, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should outdent the other type of the list item and take it out of the list', () => { + runTest( { + input: [ + '* A', + '# B[]' + ], + expected: [ + '* A', + 'B[]' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 1, + indentList: 0, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should outdent the child list item', () => { + runTest( { + input: [ + '* A', + ' * B[]' + ], + expected: [ + '* A', + '* B[]' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 1, + indentList: 0, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should outdent the child list item of the multi-block list item', () => { + runTest( { + input: [ + '* A', + ' B', + ' * C[]' + ], + expected: [ + '* A', + ' B', + '* C[]' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 1, + indentList: 0, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 2 ] + } ); + } ); + + it( 'should outdent nth child list item', () => { + runTest( { + input: [ + '* A', + ' * B', + ' * C[]' + ], + expected: [ + '* A', + ' * B', + '* C[]' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 1, + indentList: 0, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 2 ] + } ); + } ); + + it( 'should outdent the block to a list item and keep following blocks', () => { + runTest( { + input: [ + '* A', + ' B[]', + ' C', + ' D' + ], + expected: [ + '* A', + '* B[] {id:a00}', + ' C', + ' D' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 1, + indentList: 0, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 1, 2, 3 ] + } ); + } ); + + it( 'should outdent the list item which should inherit following list items on the same indent', () => { + runTest( { + input: [ + '* A', + ' * B[]', + ' * C' + ], + expected: [ + '* A', + '* B[]', + ' * C' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 1, + indentList: 0, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should outdent the list item with blocks out of list with blocks', () => { + runTest( { + input: [ + '* A', + '* B[]', + ' C' + ], + expected: [ + '* A', + 'B[]', + 'C' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 1, + indentList: 0, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should outdent the list item out of list with blocks and fix remaining list items indent', () => { + runTest( { + input: [ + '* A', + '* B[]', + ' C', + ' * D', + ' E' + ], + expected: [ + '* A', + 'B[]', + 'C', + '* D', + ' E' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 1, + indentList: 0, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 1, 2, 3, 4 ] + } ); + } ); + + it( 'should outdent a multi-indented block once if it is in a list item', () => { + runTest( { + input: [ + '* A[]' + ], + expected: [ + '* A[]' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 0, + indentList: 0, + outdentBlock: 1, + indentBlock: 0 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'should outdent indented block if it is in a list item', () => { + runTest( { + input: [ + '* A[]' + ], + expected: [ + '* A[]' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 0, + indentList: 0, + outdentBlock: 1, + indentBlock: 0 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'should outdent indented block if it is in a list item block', () => { + runTest( { + input: [ + '* A', + ' B[]' + ], + expected: [ + '* A', + ' B[]' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 0, + indentList: 0, + outdentBlock: 1, + indentBlock: 0 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'should outdent indented block if it is in indented list item', () => { + runTest( { + input: [ + '* A', + ' * B[]' + ], + expected: [ + '* A', + ' * B[]' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 0, + indentList: 0, + outdentBlock: 1, + indentBlock: 0 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'should outdent indented block if it is in indented list item block', () => { + runTest( { + input: [ + '* A', + ' * B', + ' C[]' + ], + expected: [ + '* A', + ' * B', + ' C[]' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 0, + indentList: 0, + outdentBlock: 1, + indentBlock: 0 + }, + changedBlocks: [ ] + } ); + } ); + } ); + + describe( 'non-collapsed selection', () => { + it( 'should outdent list items if a selection is below list', () => { + runTest( { + input: [ + '* [A', + '* B', + 'text]' + ], + expected: [ + '[A', + 'B', + 'text]' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 1, + indentList: 0, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should not outdent if a selection is above list', () => { + runTest( { + input: [ + '[text', + '* A', + '* B]' + ], + expected: [ + '[text', + '* A', + '* B]' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 0, + indentList: 0, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'should not outdent if a selection spans many lists', () => { + runTest( { + input: [ + '* [A', + 'text', + '* B]' + ], + expected: [ + '[A', + 'text', + '* B]' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 1, + indentList: 0, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should outdent the flat list', () => { + runTest( { + input: [ + '* [A', + '* B', + '* C]' + ], + expected: [ + '[A', + 'B', + 'C]' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 1, + indentList: 0, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 0, 1, 2 ] + } ); + } ); + + it( 'should outdent the nested list', () => { + runTest( { + input: [ + '* [A', + ' * B', + '* C', + '* D]' + ], + expected: [ + '[A', + '* B', + 'C', + 'D]' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 1, + indentList: 0, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 0, 1, 2, 3 ] + } ); + } ); + + it( 'should outdent the multi-block list', () => { + runTest( { + input: [ + '* [A', + ' B', + '* C', + ' * D', + '* E', + ' F]' + ], + expected: [ + '[A', + 'B', + 'C', + '* D', + 'E', + 'F]' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 1, + indentList: 0, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 0, 1, 2, 3, 4, 5 ] + } ); + } ); + + it( 'should outdenst the list item nested items', () => { + runTest( { + input: [ + '* A', + ' * [B', + ' * C]' + ], + expected: [ + '* A', + '* [B', + ' * C]' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 1, + indentList: 0, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should outdent item blocks to seperate list item', () => { + runTest( { + input: [ + '* A', + ' [B', + ' C]' + ], + expected: [ + '* A', + '* [B {id:a00}', + ' C]' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 1, + indentList: 0, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should outdent the list item, the nestesd list item and the block', () => { + runTest( { + input: [ + '* A', + ' * [B', + ' C]' + ], + expected: [ + 'A', + '* [B', + 'C]' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 1, + indentList: 0, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 0, 1, 2 ] + } ); + } ); + + it( 'should outdent indented block if a selection starts at indented block and ends below', () => { + runTest( { + input: [ + '* A', + ' * [B', + '* C]' + ], + expected: [ + '* A', + ' * [B', + '* C]' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 0, + indentList: 0, + outdentBlock: 1, + indentBlock: 0 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'should outdent list itemsif a selection starts above indented code block and ends at it', () => { + runTest( { + input: [ + '* [A', + ' * B]', + '* C' + ], + expected: [ + '[A', + '* B]', + '* C' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 1, + indentList: 0, + outdentBlock: 0, + indentBlock: 0 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + } ); + } ); + } ); + + describe( 'code block in a list', () => { + beforeEach( () => { + const indentCodeBlockCommand = editor.commands.get( 'indentCodeBlock' ); + const outdentCodeBlockcommand = editor.commands.get( 'outdentCodeBlock' ); + + commandSpies.indentCodeBlock = sinon.spy( indentCodeBlockCommand, 'execute' ); + commandSpies.outdentCodeBlock = sinon.spy( outdentCodeBlockcommand, 'execute' ); + } ); + + describe( 'indent command', () => { + it( 'should indent the code block when in a list item that cannot be indented', () => { + runTest( { + input: [ + '* []foo' + ], + expected: [ + '* []foo' + ], + commandName: 'indent', + eventStopped: true, + executedCommands: { + outdentList: 0, + indentList: 0, + outdentCodeBlock: 0, + indentCodeBlock: 1 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'should indent the code block when in a list item that can be indented', () => { + runTest( { + input: [ + '* foo', + '* []bar' + ], + expected: [ + '* foo', + '* []bar' + ], + commandName: 'indent', + executedCommands: { + outdentList: 0, + indentList: 0, + outdentCodeBlock: 0, + indentCodeBlock: 1 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'should indent the code block when in a list item block', () => { + runTest( { + input: [ + '* foo', + ' []foo' + ], + expected: [ + '* foo', + ' []foo' + ], + commandName: 'indent', + executedCommands: { + outdentList: 0, + indentList: 0, + outdentCodeBlock: 0, + indentCodeBlock: 1 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'should not indent a code block when multiple items are selected', () => { + runTest( { + input: [ + '* f[oo', + ' foo', + '* ba]r' + ], + expected: [ + '* f[oo', + ' foo', + '* ba]r' + ], + commandName: 'indent', + executedCommands: { + outdentList: 0, + indentList: 0, + outdentCodeBlock: 0, + indentCodeBlock: 0 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'should indent list items when selection spans a code block', () => { + runTest( { + input: [ + '* foo', + '* b[ar', + ' foo', + '* ya]r' + ], + expected: [ + '* foo', + ' * b[ar', + ' foo', + ' * ya]r' + ], + commandName: 'indent', + executedCommands: { + outdentList: 0, + indentList: 1, + outdentCodeBlock: 0, + indentCodeBlock: 0 + }, + changedBlocks: [ 1, 2, 3 ] + } ); + } ); + + it( 'should indent the list item when selection starts above and ends at codeblock', () => { + runTest( { + input: [ + '* foo', + '* b[ar', + ' fo]o' + ], + expected: [ + '* foo', + ' * b[ar {id:001}', + ' fo]o' + ], + commandName: 'indent', + executedCommands: { + outdentList: 0, + indentList: 1, + outdentCodeBlock: 0, + indentCodeBlock: 0 + }, + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should indent the code block when selection starts at a code block and ends below', () => { + runTest( { + input: [ + '* foo', + '* ba[r', + '* yar]' + ], + expected: [ + '* foo', + '* ba[r', + '* yar]' + ], + commandName: 'indent', + executedCommands: { + outdentList: 0, + indentList: 0, + outdentCodeBlock: 0, + indentCodeBlock: 1 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'should indent the code block when a selection starts at a code block and ends outside list', () => { + runTest( { + input: [ + '* foo', + '* ba[r', + '* yar', + 'tar]' + ], + expected: [ + '* foo', + '* ba[r', + '* yar', + 'tar]' + ], + commandName: 'indent', + executedCommands: { + outdentList: 0, + indentList: 0, + outdentCodeBlock: 0, + indentCodeBlock: 1 + }, + changedBlocks: [ ] + } ); + } ); + } ); + + describe( 'outdent command', () => { + it( 'should outdent the code block', () => { + const customSetModelData = () => { + setModelData( + model, + modelList( [ + '* []foo' + ] ) ); + + model.change( writer => { + writer.insertText( ' ', model.document.getRoot().getChild( 0 ) ); + } ); + }; + + runTest( { + expected: [ + '* []foo' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 0, + indentList: 0, + outdentCodeBlock: 1, + indentCodeBlock: 0 + }, + changedBlocks: [ ], + customSetModelData + } ); + } ); + + it( 'should outdent the list item if a code block does not have an indent', () => { + runTest( { + input: [ + '* []foo' + ], + expected: [ + '[]foo' + ], + commandName: 'outdent', + eventStopped: true, + executedCommands: { + outdentList: 1, + indentList: 0, + outdentCodeBlock: 0, + indentCodeBlock: 0 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should outdent list items if a selection starts before a code block and ends at a code block', () => { + const customSetModelData = () => { + setModelData( + model, + modelList( [ + '* foo', + '* b[ar', + '* y]ar' + ] ) ); + + model.change( writer => { + writer.insertText( ' ', model.document.getRoot().getChild( 2 ) ); + } ); + }; + + runTest( { + expected: [ + '* foo', + 'b[ar', + ' y]ar' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 1, + indentList: 0, + outdentCodeBlock: 0, + indentCodeBlock: 0 + }, + changedBlocks: [ 1, 2 ], + customSetModelData + } ); + } ); + + it( 'should outdent the code block if a selection starts at a code block and ends after it', () => { + const customSetModelData = () => { + setModelData( + model, + modelList( [ + '* foo', + '* b[ar', + '* y]ar' + ] ) ); + + model.change( writer => { + writer.insertText( ' ', model.document.getRoot().getChild( 1 ) ); + } ); + }; + + runTest( { + expected: [ + '* foo', + '* b[ar', + '* y]ar' + ], + commandName: 'outdent', + executedCommands: { + outdentList: 0, + indentList: 0, + outdentCodeBlock: 1, + indentCodeBlock: 0 + }, + changedBlocks: [ ], + customSetModelData + } ); + } ); + } ); + } ); + + // @param {Iterable.} input + // @param {Iterable.} expected + // @param {String} commandName Name of a command to execute. + // @param {Object.} executedCommands Numbers of command executions. + // @param {Array.} changedBlocks Indexes of changed blocks. + // @param {Function} customSetModelData Function to alter how model data is set. + function runTest( { input, expected, commandName, executedCommands = {}, changedBlocks = [], customSetModelData } ) { + if ( customSetModelData ) { + customSetModelData(); + } else { + setModelData( model, modelList( input ) ); + } + + editor.commands.get( commandName ).execute(); + + expect( getModelData( model ) ).to.equalMarkup( modelList( expected ) ); + + 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/insertcontent.js b/packages/ckeditor5-list/tests/documentlist/integrations/insertcontent.js new file mode 100644 index 00000000000..54be9e583d0 --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/integrations/insertcontent.js @@ -0,0 +1,1106 @@ +/** + * @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 stubUid from '../_utils/uid'; +import { modelList } from '../_utils/utils'; + +import IndentEditing from '@ckeditor/ckeditor5-indent/src/indentediting'; +import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting'; +import MediaEmbedEditing from '@ckeditor/ckeditor5-media-embed/src/mediaembedediting'; +import CodeBlockEditing from '@ckeditor/ckeditor5-code-block/src/codeblockediting'; +import Table from '@ckeditor/ckeditor5-table/src/table'; +import Image from '@ckeditor/ckeditor5-image/src/image'; +import HtmlEmbed from '@ckeditor/ckeditor5-html-embed/src/htmlembed'; +import PageBreak from '@ckeditor/ckeditor5-page-break/src/pagebreak'; +import HorizontalLine from '@ckeditor/ckeditor5-horizontal-line/src/horizontalline'; +import Widget from '@ckeditor/ckeditor5-widget/src/widget'; +import WidgetTypeAround from '@ckeditor/ckeditor5-widget/src/widgettypearound/widgettypearound'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import { Paragraph } from 'ckeditor5/src/paragraph'; +import { modelTable } from '@ckeditor/ckeditor5-table/tests/_utils/utils'; +import { + getData as getModelData, + setData as setModelData +} from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; + +describe( 'Inserting widgets in document lists', () => { + let element; + let editor, model, modelRoot; + let insertCommand; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + element = document.createElement( 'div' ); + document.body.appendChild( element ); + + editor = await ClassicTestEditor.create( element, { + plugins: [ + Paragraph, CodeBlockEditing, DocumentListEditing, IndentEditing, BlockQuoteEditing, MediaEmbedEditing, + Table, Image, HtmlEmbed, PageBreak, HorizontalLine, Widget + ] + } ); + + model = editor.model; + modelRoot = editor.model.document.getRoot(); + + stubUid(); + } ); + + afterEach( async () => { + element.remove(); + + await editor.destroy(); + } ); + + describe( 'inserting table', () => { + beforeEach( () => { + insertCommand = () => { + editor.execute( 'insertTable', { rows: 1, columns: 2 } ); + }; + } ); + + it( 'should replace an empty list item with a table as a list item', () => { + runTest( { + input: [ + '* []' + ], + expected: [ + '* ' + modelTable( [ + [ '[]', '' ] + ] ) + ] + } ); + } ); + + it( 'should insert a table as a first block of a list item if selection is at the beginning of text', () => { + runTest( { + input: [ + '* []Foo' + ], + expected: [ + '* ' + modelTable( [ + [ '[]', '' ] + ] ), + ' Foo' + ] + } ); + } ); + + it( 'should insert a table as the next block of a list item when a selection is at the end of text', () => { + runTest( { + input: [ + '* Foo[]' + ], + expected: [ + '* Foo', + ' ' + modelTable( [ + [ '[]', '' ] + ] ) + ] + } ); + } ); + + it( 'should insert a table in place of an empty block as a list item block', () => { + runTest( { + input: [ + '* Foo', + ' []' + ], + expected: [ + '* Foo', + ' ' + modelTable( [ + [ '[]', '' ] + ] ) + ] + } ); + } ); + + it( 'should insert a table after a block if selection was at the end of a text', () => { + runTest( { + input: [ + '* Foo', + ' Bar[]' + ], + expected: [ + '* Foo', + ' Bar', + ' ' + modelTable( [ + [ '[]', '' ] + ] ) + ] + } ); + } ); + + it( 'should insert a table before a block if selection was at the start of a text', () => { + runTest( { + input: [ + '* Foo', + ' []Bar' + ], + expected: [ + '* Foo', + ' ' + modelTable( [ + [ '[]', '' ] + ] ), + ' Bar' + ] + } ); + } ); + + it( 'should insert a table before a non-collapsed selection as a list item', () => { + runTest( { + input: [ + '* [Foo', + '* Bar', + '* Yar]' + ], + expected: [ + '* ' + modelTable( [ + [ '[]', '' ] + ] ), + ' Foo', + '* Bar {id:001}', + '* Yar {id:002}' + ] + } ); + } ); + + it( 'should insert a table before a non-collapsed selection as a list item when selection is in the middle', () => { + runTest( { + input: [ + '* Fo[oo', + '* Bar', + '* Yar]' + ], + expected: [ + '* ' + modelTable( [ + [ '[]', '' ] + ] ), + ' Fooo', + '* Bar {id:001}', + '* Yar {id:002}' + ] + } ); + } ); + + it( 'should insert a table before a non-collapsed selection as a list item when selection is closer to the end of text', () => { + runTest( { + input: [ + '* Foo[o', + '* Bar', + '* Yar]' + ], + expected: [ + '* ' + modelTable( [ + [ '[]', '' ] + ] ), + ' Fooo', + '* Bar {id:001}', + '* Yar {id:002}' + ] + } ); + } ); + + it( 'should insert a table as a next block of an indented list item if selection is at the end', () => { + runTest( { + input: [ + '* Foo', + ' * Bar[]' + ], + expected: [ + '* Foo', + ' * Bar', + ' ' + modelTable( [ + [ '[]', '' ] + ] ) + ] + } ); + } ); + + it( 'should insert a table as a first block of an indented list item if selection is at the beginning', () => { + runTest( { + input: [ + '* Foo', + ' * []Bar' + ], + expected: [ + '* Foo', + ' * ' + modelTable( [ + [ '[]', '' ] + ] ), + ' Bar' + ] + } ); + } ); + + it( 'should insert a table as a first block of an indented list item if selection is not at the end', () => { + runTest( { + input: [ + '* Foo', + ' * Ba[]r' + ], + expected: [ + '* Foo', + ' * ' + modelTable( [ + [ '[]', '' ] + ] ), + ' Bar' + ] + } ); + } ); + } ); + + describe( 'inserting media', () => { + beforeEach( () => { + insertCommand = () => { + editor.execute( 'mediaEmbed', '' ); + }; + } ); + + it( 'should replace an empty list item with a media as a list item', () => { + runTest( { + input: [ + '* []' + ], + expected: '[]' + } ); + } ); + + it( 'should insert a media as a first block of a list item if selection is at the beginning of text', () => { + runTest( { + input: [ + '* []Foo' + ], + expected: '[]' + + 'Foo' + } ); + } ); + + it( 'should insert a media as the next block of a list item when a selection is at the end of text', () => { + runTest( { + input: [ + '* Foo[]' + ], + expected: 'Foo' + + '[]' + } ); + } ); + + it( 'should insert a media in place of an empty block as a list item block', () => { + runTest( { + input: [ + '* Foo', + ' []' + ], + expected: 'Foo' + + '[]' + } ); + } ); + + it( 'should insert a media after a block if selection was at the end of a text', () => { + runTest( { + input: [ + '* Foo', + ' Bar[]' + ], + expected: 'Foo' + + 'Bar' + + '[]' + } ); + } ); + + it( 'should insert a media before a block if selection was at the start of a text', () => { + runTest( { + input: [ + '* Foo', + ' []Bar' + ], + expected: 'Foo' + + '[]' + + 'Bar' + } ); + } ); + + it( 'should insert a media before a non-collapsed selection as a list item', () => { + runTest( { + input: [ + '* [Foo', + '* Bar', + '* Yar]' + ], + expected: '[]' + + 'Foo' + + 'Bar' + + 'Yar' + } ); + } ); + + it( 'should insert a media before a non-collapsed selection as a list item when selection is in the middle', () => { + runTest( { + input: [ + '* Fo[oo', + '* Bar', + '* Yar]' + ], + expected: '[]' + + 'Fooo' + + 'Bar' + + 'Yar' + } ); + } ); + + it( 'should insert a media before a non-collapsed selection as a list item when selection is closer to the end of text', () => { + runTest( { + input: [ + '* Foo[o', + '* Bar', + '* Yar]' + ], + expected: '[]' + + 'Fooo' + + 'Bar' + + 'Yar' + } ); + } ); + + it( 'should insert a media as a first block of an indended list item if selection is at the beginning', () => { + runTest( { + input: [ + '* Foo', + ' * []Bar' + ], + expected: 'Foo' + + '[]' + + 'Bar' + } ); + } ); + + it( 'should insert a media as a first block of an indended list item if selection is not at the end', () => { + runTest( { + input: [ + '* Foo', + ' * Ba[]r' + ], + expected: 'Foo' + + '[]' + + 'Bar' + } ); + } ); + + it( 'should insert a media as a next block of an indended list item if selection is at the end', () => { + runTest( { + input: [ + '* Foo', + ' * Bar[]' + ], + expected: 'Foo' + + 'Bar' + + '[]' + } ); + } ); + } ); + + describe( 'inserting image', () => { + beforeEach( () => { + insertCommand = () => { + editor.execute( 'insertImage', { source: '' } ); + }; + } ); + + it( 'should replace an empty list item with a block image as a list item', () => { + runTest( { + input: [ + '* []' + ], + expected: '[]' + } ); + } ); + + it( 'should insert an inline image inside paragraph if selection is at the beginning of text', () => { + runTest( { + input: [ + '* []Foo' + ], + expected: '' + + '[]' + + 'Foo' + } ); + } ); + + it( 'should insert an inline image inside paragraph when a selection is at the end of text', () => { + runTest( { + input: [ + '* Foo[]' + ], + expected: 'Foo' + + '[]' + + '' + } ); + } ); + + it( 'should insert a block image in place of an empty block as a list item block', () => { + runTest( { + input: [ + '* Foo', + ' []' + ], + expected: 'Foo' + + '[]' + } ); + } ); + + it( 'should insert an inline image inside a paragraph if selection was at the end of a text', () => { + runTest( { + input: [ + '* Foo', + ' Bar[]' + ], + expected: 'Foo' + + 'Bar' + + '[]' + + '' + } ); + } ); + + it( 'should insert an inline image inside a paragraph if selection was at the start of a text', () => { + runTest( { + input: [ + '* Foo', + ' []Bar' + ], + expected: 'Foo' + + '' + + '[]' + + 'Bar' + } ); + } ); + + it( 'should insert an inline image in place of a list if a non-collapsed selection spans entire list', () => { + runTest( { + input: [ + '* [Foo', + '* Bar', + '* Yar]' + ], + expected: '[]' + } ); + } ); + + it( 'should insert an inline image at the end of paragraph when selection is in the middle', () => { + runTest( { + input: [ + '* Fo[oo', + '* Bar', + '* Yar]' + ], + expected: 'Fo' + + '[]' + + '' + } ); + } ); + + it( 'should insert an inline image at the end of paragraph when selection is closer to the end of text', () => { + runTest( { + input: [ + '* Foo[o', + '* Bar', + '* Yar]' + ], + expected: 'Foo' + + '[]' + + '' + } ); + } ); + + it( 'should insert an inline image inside an indented list item', () => { + runTest( { + input: [ + '* Foo', + ' * Bar[]' + ], + expected: 'Foo' + + 'Bar' + + '[]' + + '' + } ); + } ); + + it( 'should replace an indented empty paragraph with an indented block image', () => { + runTest( { + input: [ + '* Foo', + ' * []' + ], + expected: 'Foo' + + '[]' + } ); + } ); + } ); + + describe( 'inserting page break', () => { + beforeEach( () => { + insertCommand = () => { + editor.execute( 'pageBreak' ); + }; + } ); + + it( 'should replace an empty list item with a page break as a list item', () => { + runTest( { + input: [ + '* []' + ], + expected: '' + + '[]' + } ); + } ); + + it( 'should insert a page break as a first block of a list item if selection is at the beginning of text', () => { + runTest( { + input: [ + '* []Foo' + ], + expected: '' + + '[]Foo' + } ); + } ); + + it( 'should insert a page break as the next block of a list item when a selection is at the end of text', () => { + runTest( { + input: [ + '* Foo[]' + ], + expected: 'Foo' + + '' + + '[]' + } ); + } ); + + it( 'should insert a page break in place of an empty block as a list item block', () => { + runTest( { + input: [ + '* Foo', + ' []' + ], + expected: 'Foo' + + '' + + '[]' + } ); + } ); + + it( 'should insert a page break embed after a block if selection was at the end of a text', () => { + runTest( { + input: [ + '* Foo', + ' Bar[]' + ], + expected: 'Foo' + + 'Bar' + + '' + + '[]' + } ); + } ); + + it( 'should insert a page break before a block if selection was at the start of a text', () => { + runTest( { + input: [ + '* Foo', + ' []Bar' + ], + expected: 'Foo' + + '' + + '[]Bar' + } ); + } ); + + it( 'should insert a page break as a list item in place of a list when whole list is selected', () => { + runTest( { + input: [ + '* [Foo', + '* Bar', + '* Yar]' + ], + expected: '' + + '[]' + } ); + } ); + + it( 'should insert a page break before a non-collapsed selection as a list item when selection is in the middle', () => { + runTest( { + input: [ + '* Fo[oo', + '* Bar', + '* Yar]' + ], + expected: 'Fo' + + '' + + '[]' + } ); + } ); + + it( 'should insert a page break before a non-collapsed selection as a list item when selection is closer to the end', () => { + runTest( { + input: [ + '* Foo[o', + '* Bar', + '* Yar]' + ], + expected: 'Foo' + + '' + + '[]' + } ); + } ); + + it( 'should insert a page break as a first block in an indented list item when selection is at the start', () => { + runTest( { + input: [ + '* Foo', + ' * []Bar' + ], + expected: 'Foo' + + '' + + '[]Bar' + } ); + } ); + + it( 'should insert a page break as a second block in an indented list item and split text', () => { + runTest( { + input: [ + '* Foo', + ' * Ba[]r' + ], + expected: 'Foo' + + 'Ba' + + '' + + '[]r' + } ); + } ); + + it( 'should insert a page break as a second block in an indented list item and add a paragraph after', () => { + runTest( { + input: [ + '* Foo', + ' * Bar[]' + ], + expected: 'Foo' + + 'Bar' + + '' + + '[]' + } ); + } ); + } ); + + describe( 'inserting horizontal line', () => { + beforeEach( () => { + insertCommand = () => { + editor.execute( 'horizontalLine' ); + }; + } ); + + it( 'should replace an empty list item with a horizontal line as a list item', () => { + runTest( { + input: [ + '* []' + ], + expected: '' + + '[]' + } ); + } ); + + it( 'should insert a horizontal line as a first block of a list item if selection is at the beginning of text', () => { + runTest( { + input: [ + '* []Foo' + ], + expected: '' + + '[]Foo' + } ); + } ); + + it( 'should insert a horizontal line as the next block of a list item when a selection is at the end of text', () => { + runTest( { + input: [ + '* Foo[]' + ], + expected: 'Foo' + + '' + + '[]' + } ); + } ); + + it( 'should insert a horizontal line in place of an empty block as a list item block', () => { + runTest( { + input: [ + '* Foo', + ' []' + ], + expected: 'Foo' + + '' + + '[]' + } ); + } ); + + it( 'should insert a horizontal line embed after a block if selection was at the end of a text', () => { + runTest( { + input: [ + '* Foo', + ' Bar[]' + ], + expected: 'Foo' + + 'Bar' + + '' + + '[]' + } ); + } ); + + it( 'should insert a horizontal line before a block if selection was at the start of a text', () => { + runTest( { + input: [ + '* Foo', + ' []Bar' + ], + expected: 'Foo' + + '' + + '[]Bar' + } ); + } ); + + it( 'should insert a horizontal line in place of a list when whole list is selected', () => { + runTest( { + input: [ + '* [Foo', + '* Bar', + '* Yar]' + ], + expected: '' + + '[]' + } ); + } ); + + it( 'should insert a horizontal line before a non-collapsed selection as a list item when selection is in the middle', () => { + runTest( { + input: [ + '* Fo[oo', + '* Bar', + '* Yar]' + ], + expected: 'Fo' + + '' + + '[]' + } ); + } ); + + it( 'should insert a horizontal line before a non-collapsed selection as a list item when selection is closer to the end', () => { + runTest( { + input: [ + '* Foo[o', + '* Bar', + '* Yar]' + ], + expected: 'Foo' + + '' + + '[]' + } ); + } ); + + it( 'should insert a horizontal line as a first block in an indented list item when selection is at the start', () => { + runTest( { + input: [ + '* Foo', + ' * []Bar' + ], + expected: 'Foo' + + '' + + '[]Bar' + } ); + } ); + + it( 'should insert a horizontal line as a second block in an indented list item and split text', () => { + runTest( { + input: [ + '* Foo', + ' * Ba[]r' + ], + expected: 'Foo' + + 'Ba' + + '' + + '[]r' + } ); + } ); + + it( 'should insert a horizontal line as a second block in an indented list item and add a paragraph after', () => { + runTest( { + input: [ + '* Foo', + ' * Bar[]' + ], + expected: 'Foo' + + 'Bar' + + '' + + '[]' + } ); + } ); + } ); + + describe( 'inserting HTML block', () => { + beforeEach( () => { + insertCommand = () => { + editor.execute( 'htmlEmbed' ); + }; + } ); + + it( 'should replace an empty list item with a HTML embed as a list item', () => { + runTest( { + input: [ + '* []' + ], + expected: '[]' + } ); + } ); + + it( 'should insert a HTML embed as a first block of a list item if selection is at the beginning of text', () => { + runTest( { + input: [ + '* []Foo' + ], + expected: '[]' + + 'Foo' + } ); + } ); + + it( 'should insert a HTML embed as the next block of a list item when a selection is at the end of text', () => { + runTest( { + input: [ + '* Foo[]' + ], + expected: 'Foo' + + '[]' + } ); + } ); + + it( 'should insert a HTML embed in place of an empty block as a list item block', () => { + runTest( { + input: [ + '* Foo', + ' []' + ], + expected: 'Foo' + + '[]' + } ); + } ); + + it( 'should insert a HTML embed after a block if selection was at the end of a text', () => { + runTest( { + input: [ + '* Foo', + ' Bar[]' + ], + expected: 'Foo' + + 'Bar' + + '[]' + } ); + } ); + + it( 'should insert a HTML embed before a block if selection was at the start of a text', () => { + runTest( { + input: [ + '* Foo', + ' []Bar' + ], + expected: 'Foo' + + '[]' + + 'Bar' + } ); + } ); + + it( 'should insert a HTML embed in place of a list as a first list item when whole list is selected', () => { + runTest( { + input: [ + '* [Foo', + '* Bar', + '* Yar]' + ], + expected: '[]' + } ); + } ); + + it( 'should insert a HTML embed before a non-collapsed selection as a list item when selection is in the middle', () => { + runTest( { + input: [ + '* Fo[oo', + '* Bar', + '* Yar]' + ], + expected: 'Fo' + + '[]' + } ); + } ); + + it( 'should insert a HTML embed before a non-collapsed selection as a list item when selection is closer to the end', () => { + runTest( { + input: [ + '* Foo[o', + '* Bar', + '* Yar]' + ], + expected: 'Foo' + + '[]' + } ); + } ); + + it( 'should insert a HTML embed as a first block in an indented list item when selection is at the start', () => { + runTest( { + input: [ + '* Foo', + ' * []Bar' + ], + expected: 'Foo' + + '[]' + + 'Bar' + } ); + } ); + + it( 'should insert a HTML embed as a second block in an indented list item and split the text', () => { + runTest( { + input: [ + '* Foo', + ' * Ba[]r' + ], + expected: 'Foo' + + 'Ba' + + '[]' + + 'r' + } ); + } ); + + it( 'should insert a HTML embed after selection in an indented list item', () => { + runTest( { + input: [ + '* Foo', + ' * Bar[]' + ], + expected: 'Foo' + + 'Bar' + + '[]' + } ); + } ); + } ); + + describe( 'inserting paragraphs with widget type around', () => { + beforeEach( () => { + const plugin = editor.plugins.get( WidgetTypeAround ); + + insertCommand = ( position, widgetPosition ) => { + plugin._insertParagraph( modelRoot.getChild( widgetPosition ), position ); + }; + } ); + + it( 'should insert a paragraph before an image block as a first block of a list item', () => { + setModelData( model, modelList( [ + '* ' + ] ) ); + + insertCommand( 'before', 0 ); + + const expectedModel = '[]' + + ''; + + expect( getModelData( model ) ).to.equalMarkup( expectedModel ); + } ); + + it( 'should insert a paragraph after an image block as a second block', () => { + setModelData( model, modelList( [ + '* ' + ] ) ); + + insertCommand( 'after', 0 ); + + const expectedModel = '' + + '[]'; + + expect( getModelData( model ) ).to.equalMarkup( expectedModel ); + } ); + + it( 'should insert a paragraph before an image block as a second block of a list item', () => { + setModelData( model, modelList( [ + '* foo', + ' []' + ] ) ); + + insertCommand( 'before', 1 ); + + const expectedModel = 'foo' + + '[]' + + ''; + + expect( getModelData( model ) ).to.equalMarkup( expectedModel ); + } ); + + it( 'should insert a paragraph after an image block as a third block of a list item', () => { + setModelData( model, modelList( [ + '* Foo', + ' ' + ] ) ); + + insertCommand( 'after', 1 ); + + const expectedModel = 'Foo' + + '' + + '[]'; + + expect( getModelData( model ) ).to.equalMarkup( expectedModel ); + } ); + + it( 'should insert a paragraph before an image block in and indented list item', () => { + setModelData( model, modelList( [ + '* foo', + ' * []' + ] ) ); + + insertCommand( 'before', 1 ); + + const expectedModel = 'foo' + + '[]' + + ''; + + expect( getModelData( model ) ).to.equalMarkup( expectedModel ); + } ); + + it( 'should insert a paragraph after an image block in and indented list item', () => { + setModelData( model, modelList( [ + '* foo', + ' * []' + ] ) ); + + insertCommand( 'after', 1 ); + + const expectedModel = 'foo' + + '' + + '[]'; + + expect( getModelData( model ) ).to.equalMarkup( expectedModel ); + } ); + } ); + + // @param {Iterable.} input + // @param {Iterable.} expected + function runTest( { input, expected } ) { + setModelData( model, modelList( input ) ); + + insertCommand(); + + let expectedModel = expected; + + if ( Array.isArray( expected ) ) { + expectedModel = modelList( expected ); + } + + expect( getModelData( model ) ).to.equalMarkup( expectedModel ); + } +} ); diff --git a/packages/ckeditor5-list/tests/documentlist/integrations/tabobservers.js b/packages/ckeditor5-list/tests/documentlist/integrations/tabobservers.js new file mode 100644 index 00000000000..0a2142e5a6c --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/integrations/tabobservers.js @@ -0,0 +1,1640 @@ +/** + * @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 stubUid from '../_utils/uid'; +import { modelList } from '../_utils/utils'; + +import IndentEditing from '@ckeditor/ckeditor5-indent/src/indentediting'; +import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting'; +import Table from '@ckeditor/ckeditor5-table/src/table'; +import CodeBlockEditing from '@ckeditor/ckeditor5-code-block/src/codeblockediting'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; +import { Paragraph } from 'ckeditor5/src/paragraph'; +import { modelTable } from '@ckeditor/ckeditor5-table/tests/_utils/utils'; +import { + getData as getModelData, + setData as setModelData +} from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { DomEventData } from '@ckeditor/ckeditor5-engine'; + +describe( 'DocumentListEditing integrations: tab key', () => { + const blocksChangedByCommands = []; + + let element; + let editor, model, view; + let eventInfo, tabDomEventData, shiftTabDomEventData; + let indentListcommand, outdentListcommand; + let commandSpies; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + element = document.createElement( 'div' ); + document.body.appendChild( element ); + + editor = await ClassicTestEditor.create( element, { + plugins: [ + Paragraph, CodeBlockEditing, DocumentListEditing, IndentEditing, BlockQuoteEditing, Table + ] + } ); + + model = editor.model; + + view = editor.editing.view; + + stubUid(); + + eventInfo = new EventInfo( view.document, 'tab' ); + + tabDomEventData = new DomEventData( view.document, { + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + } ); + + shiftTabDomEventData = new DomEventData( view.document, { + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }, { shiftKey: true } ); + + indentListcommand = editor.commands.get( 'indentList' ); + outdentListcommand = editor.commands.get( 'outdentList' ); + + commandSpies = { + indentList: sinon.spy( indentListcommand, 'execute' ), + outdentList: sinon.spy( outdentListcommand, 'execute' ) + }; + + blocksChangedByCommands.length = 0; + + indentListcommand.on( 'afterExecute', ( evt, data ) => { + blocksChangedByCommands.push( ...data ); + } ); + + outdentListcommand.on( 'afterExecute', ( evt, data ) => { + blocksChangedByCommands.push( ...data ); + } ); + } ); + + afterEach( async () => { + element.remove(); + + await editor.destroy(); + } ); + + describe( 'list tab key handling', () => { + describe( 'collapsed selection', () => { + it( 'shouldn\'t indent first list of a list', () => { + runTest( { + input: [ + '* A[]' + ], + expected: [ + '* A[]' + ], + domEventData: tabDomEventData, + eventStopped: false, + executedCommands: { + outdentList: 0, + indentList: 0 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'shouldn\'t indent list item if proceeded by list item of lower indent', () => { + runTest( { + input: [ + '* A', + ' * B[]' + ], + expected: [ + '* A', + ' * B[]' + ], + domEventData: tabDomEventData, + eventStopped: false, + executedCommands: { + outdentList: 0, + indentList: 0 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'shouldn\'t indent list item if proceeded by different item list type', () => { + runTest( { + input: [ + '* A', + '# B[]' + ], + expected: [ + '* A', + '# B[]' + ], + domEventData: tabDomEventData, + eventStopped: false, + executedCommands: { + outdentList: 0, + indentList: 0 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'shouldn\'t indent list item if proceeded by a list item block of lower indent', () => { + runTest( { + input: [ + '* A', + ' B', + ' * C[]' + ], + expected: [ + '* A', + ' B', + ' * C[]' + ], + domEventData: tabDomEventData, + eventStopped: false, + executedCommands: { + outdentList: 0, + indentList: 0 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'should indent list item if proceeded by list item with same indent', () => { + runTest( { + input: [ + '* A', + '* B[]' + ], + expected: [ + '* A', + ' * B[]' + ], + domEventData: tabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 0, + indentList: 1 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should indent list item if proceeded by list item with higher indent', () => { + runTest( { + input: [ + '* A', + ' * B', + '* C[]' + ], + expected: [ + '* A', + ' * B', + ' * C[]' + ], + domEventData: tabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 0, + indentList: 1 + }, + changedBlocks: [ 2 ] + } ); + } ); + + it( 'should indent list item block', () => { + runTest( { + input: [ + '* A', + ' B[]' + ], + expected: [ + '* A', + ' * B[] {id:a00}' + ], + domEventData: tabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 0, + indentList: 1 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should indent only selected list item block', () => { + runTest( { + input: [ + '* A', + ' B[]', + ' C' + ], + expected: [ + '* A', + ' * B[] {id:a00}', + ' C' + ], + domEventData: tabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 0, + indentList: 1 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should indent list item with it\'s blocks', () => { + runTest( { + input: [ + '* A', + '* B[]', + ' C' + ], + expected: [ + '* A', + ' * B[]', + ' C' + ], + domEventData: tabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 0, + indentList: 1 + }, + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should indent list item with it\'s blocks and nested list items', () => { + runTest( { + input: [ + '* A', + '* B[]', + ' C', + ' * D' + ], + expected: [ + '* A', + ' * B[]', + ' C', + ' * D' + ], + domEventData: tabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 0, + indentList: 1 + }, + changedBlocks: [ 1, 2, 3 ] + } ); + } ); + + it( 'should indent list item with it\'s blocks and nested multi-block list items', () => { + runTest( { + input: [ + '* A', + '* B[]', + ' C', + ' * D', + ' E' + ], + expected: [ + '* A', + ' * B[]', + ' C', + ' * D', + ' E' + ], + domEventData: tabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 0, + indentList: 1 + }, + changedBlocks: [ 1, 2, 3, 4 ] + } ); + } ); + } ); + + describe( 'non-collapsed selection', () => { + it( 'should indent all selected list items', () => { + runTest( { + input: [ + '* A', + '* [B', + '* C', + '* D]' + ], + expected: [ + '* A', + ' * [B', + ' * C', + ' * D]' + ], + domEventData: tabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 0, + indentList: 1 + }, + changedBlocks: [ 1, 2, 3 ] + } ); + } ); + + it( 'should indent all selected list items with blocks', () => { + runTest( { + input: [ + '* A', + '* [B', + ' C', + '* D]' + ], + expected: [ + '* A', + ' * [B', + ' C', + ' * D]' + ], + domEventData: tabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 0, + indentList: 1 + }, + changedBlocks: [ 1, 2, 3 ] + } ); + } ); + + it( 'should indent blocks to the same list item', () => { + runTest( { + input: [ + '* A', + ' [B', + ' C]' + ], + expected: [ + '* A', + ' * [B {id:a00}', + ' C]' + ], + domEventData: tabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 0, + indentList: 1 + }, + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should indent selection starts in the middle block of list item and spans multiple items', () => { + runTest( { + input: [ + '* 0', + '* 1', + ' [2', + '* 3]', + ' 4' + ], + expected: [ + '* 0', + ' * 1', + ' [2', + ' * 3]', + ' 4' + ], + domEventData: tabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 0, + indentList: 1 + }, + changedBlocks: [ 1, 2, 3, 4 ] + } ); + } ); + + it( 'shouldn\'t indent if at least one item cannot be indented (start of a list)', () => { + runTest( { + input: [ + '* [A', + ' B', + ' C]' + ], + expected: [ + '* [A', + ' B', + ' C]' + ], + domEventData: tabDomEventData, + eventStopped: false, + executedCommands: { + outdentList: 0, + indentList: 0 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'shouldn\'t indent if at least one item cannot be indented (list item with max indent)', () => { + runTest( { + input: [ + '* A', + ' * [B', + ' C]' + ], + expected: [ + '* A', + ' * [B', + ' C]' + ], + domEventData: tabDomEventData, + eventStopped: false, + executedCommands: { + outdentList: 0, + indentList: 0 + }, + changedBlocks: [ ] + } ); + } ); + } ); + } ); + + describe( 'list tab + shift keys handling', () => { + describe( 'collapsed selection', () => { + it( 'document list listener should not capture event', () => { + runTest( { + input: [ + 'A[]' + ], + expected: [ + 'A[]' + ], + domEventData: shiftTabDomEventData, + eventStopped: false, + executedCommands: { + outdentList: 0, + indentList: 0 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'should outdent list item and delete list', () => { + runTest( { + input: [ + '* A[]' + ], + expected: [ + 'A[]' + ], + domEventData: shiftTabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 1, + indentList: 0 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should outdent list item and make next item first in a list', () => { + runTest( { + input: [ + '* A[]', + '* B' + ], + expected: [ + 'A[]', + '* B' + ], + domEventData: shiftTabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 1, + indentList: 0 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should outdent list item and split list into two lists', () => { + runTest( { + input: [ + '* A', + '* B[]', + '* C' + ], + expected: [ + '* A', + 'B[]', + '* C' + ], + domEventData: shiftTabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 1, + indentList: 0 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should outdent list item, split list into two lists and fix indent', () => { + runTest( { + input: [ + '* A', + '* B[]', + ' * C' + ], + expected: [ + '* A', + 'B[]', + '* C' + ], + domEventData: shiftTabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 1, + indentList: 0 + }, + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should outdent list item, split list into two lists and fix indent of multi-block list item', () => { + runTest( { + input: [ + '* A', + '* B[]', + ' * C', + ' D', + ' * E' + ], + expected: [ + '* A', + 'B[]', + '* C', + ' D', + ' * E' + ], + domEventData: shiftTabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 1, + indentList: 0 + }, + changedBlocks: [ 1, 2, 3, 4 ] + } ); + } ); + + it( 'should outdent list item', () => { + runTest( { + input: [ + '* A', + ' * B[]' + ], + expected: [ + '* A', + '* B[]' + ], + domEventData: shiftTabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 1, + indentList: 0 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should outdent other type of list item and take it out of list', () => { + runTest( { + input: [ + '* A', + '# B[]' + ], + expected: [ + '* A', + 'B[]' + ], + domEventData: shiftTabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 1, + indentList: 0 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should outdent child list item', () => { + runTest( { + input: [ + '* A', + ' * B[]' + ], + expected: [ + '* A', + '* B[]' + ], + domEventData: shiftTabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 1, + indentList: 0 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should outdent child list item of multi-block list item', () => { + runTest( { + input: [ + '* A', + ' B', + ' * C[]' + ], + expected: [ + '* A', + ' B', + '* C[]' + ], + domEventData: shiftTabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 1, + indentList: 0 + }, + changedBlocks: [ 2 ] + } ); + } ); + + it( 'should outdent nth child list item', () => { + runTest( { + input: [ + '* A', + ' * B', + ' * C[]' + ], + expected: [ + '* A', + ' * B', + '* C[]' + ], + domEventData: shiftTabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 1, + indentList: 0 + }, + changedBlocks: [ 2 ] + } ); + } ); + + it( 'should outdent block to list item and keep following blocks', () => { + runTest( { + input: [ + '* A', + ' B[]', + ' C', + ' D' + ], + expected: [ + '* A', + '* B[] {id:a00}', + ' C', + ' D' + ], + domEventData: shiftTabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 1, + indentList: 0 + }, + changedBlocks: [ 1, 2, 3 ] + } ); + } ); + + it( 'should outdent list item which should inherit following list items on same indent', () => { + runTest( { + input: [ + '* A', + ' * B[]', + ' * C' + ], + expected: [ + '* A', + '* B[]', + ' * C' + ], + domEventData: shiftTabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 1, + indentList: 0 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should outdent list out of list with blocks', () => { + runTest( { + input: [ + '* A', + '* B[]', + ' C' + ], + expected: [ + '* A', + 'B[]', + 'C' + ], + domEventData: shiftTabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 1, + indentList: 0 + }, + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should outdent list item out of list with blocks and fix remaining list items indent', () => { + runTest( { + input: [ + '* A', + '* B[]', + ' C', + ' * D', + ' E' + ], + expected: [ + '* A', + 'B[]', + 'C', + '* D', + ' E' + ], + domEventData: shiftTabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 1, + indentList: 0 + }, + changedBlocks: [ 1, 2, 3, 4 ] + } ); + } ); + } ); + + describe( 'non-collapsed selection', () => { + it( 'should outdent if selection is below list', () => { + runTest( { + input: [ + '* [A', + '* B', + 'text]' + ], + expected: [ + '[A', + 'B', + 'text]' + ], + domEventData: shiftTabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 1, + indentList: 0 + }, + changedBlocks: [ 0, 1 ] + } ); + } ); + + it( 'should not outdent if selection is above list', () => { + runTest( { + input: [ + '[text', + '* A', + '* B]' + ], + expected: [ + '[text', + '* A', + '* B]' + ], + domEventData: shiftTabDomEventData, + eventStopped: false, + executedCommands: { + outdentList: 0, + indentList: 0 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'should not outdent if selection spans many lists', () => { + runTest( { + input: [ + '* [A', + 'text', + '* B]' + ], + expected: [ + '[A', + 'text', + '* B]' + ], + domEventData: shiftTabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 1, + indentList: 0 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should outdent flat list', () => { + runTest( { + input: [ + '* [A', + '* B', + '* C]' + ], + expected: [ + '[A', + 'B', + 'C]' + ], + domEventData: shiftTabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 1, + indentList: 0 + }, + changedBlocks: [ 0, 1, 2 ] + } ); + } ); + + it( 'should outdent nested list', () => { + runTest( { + input: [ + '* [A', + ' * B', + '* C', + '* D]' + ], + expected: [ + '[A', + '* B', + 'C', + 'D]' + ], + domEventData: shiftTabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 1, + indentList: 0 + }, + changedBlocks: [ 0, 1, 2, 3 ] + } ); + } ); + + it( 'should outdent multi-block list', () => { + runTest( { + input: [ + '* [A', + ' B', + '* C', + ' * D', + '* E', + ' F]' + ], + expected: [ + '[A', + 'B', + 'C', + '* D', + 'E', + 'F]' + ], + domEventData: shiftTabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 1, + indentList: 0 + }, + changedBlocks: [ 0, 1, 2, 3, 4, 5 ] + } ); + } ); + + it( 'should outdenst list item nested items', () => { + runTest( { + input: [ + '* A', + ' * [B', + ' * C]' + ], + expected: [ + '* A', + '* [B', + ' * C]' + ], + domEventData: shiftTabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 1, + indentList: 0 + }, + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should outdent item blocks to seperate list item', () => { + runTest( { + input: [ + '* A', + ' [B', + ' C]' + ], + expected: [ + '* A', + '* [B {id:a00}', + ' C]' + ], + domEventData: shiftTabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 1, + indentList: 0 + }, + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should outdent list item, nestesd list item and a block', () => { + runTest( { + input: [ + '* A', + ' * [B', + ' C]' + ], + expected: [ + 'A', + '* [B', + 'C]' + ], + domEventData: shiftTabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 1, + indentList: 0 + }, + changedBlocks: [ 0, 1, 2 ] + } ); + } ); + } ); + } ); + + describe( 'code block integration with list', () => { + beforeEach( () => { + const indentCodeBlockCommand = editor.commands.get( 'indentCodeBlock' ); + const outdentCodeBlockcommand = editor.commands.get( 'outdentCodeBlock' ); + + commandSpies.indentCodeBlock = sinon.spy( indentCodeBlockCommand, 'execute' ); + commandSpies.outdentCodeBlock = sinon.spy( outdentCodeBlockcommand, 'execute' ); + } ); + + describe( 'tab key handling', () => { + it( 'should indent code block when in a list item that cannot be indented', () => { + runTest( { + input: [ + '* []foo' + ], + expected: [ + '* []foo' + ], + domEventData: tabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 0, + indentList: 0, + outdentCodeBlock: 0, + indentCodeBlock: 1 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'should indent code block when in a list item that can be indented', () => { + runTest( { + input: [ + '* foo', + '* []bar' + ], + expected: [ + '* foo', + '* []bar' + ], + domEventData: tabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 0, + indentList: 0, + outdentCodeBlock: 0, + indentCodeBlock: 1 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'should indent a code block when in a list item block', () => { + runTest( { + input: [ + '* foo', + ' []foo' + ], + expected: [ + '* foo', + ' []foo' + ], + domEventData: tabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 0, + indentList: 0, + outdentCodeBlock: 0, + indentCodeBlock: 1 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'should not indent a code block when multiple items are selected', () => { + runTest( { + input: [ + '* f[oo', + ' foo', + '* ba]r' + ], + expected: [ + '* f[oo', + ' foo', + '* ba]r' + ], + domEventData: tabDomEventData, + eventStopped: false, + executedCommands: { + outdentList: 0, + indentList: 0, + outdentCodeBlock: 0, + indentCodeBlock: 0 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'should indent list items when selection spans code block in the middle', () => { + runTest( { + input: [ + '* foo', + '* b[ar', + ' foo', + '* ya]r' + ], + expected: [ + '* foo', + ' * b[ar', + ' foo', + ' * ya]r' + ], + domEventData: tabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 0, + indentList: 1, + outdentCodeBlock: 0, + indentCodeBlock: 0 + }, + changedBlocks: [ 1, 2, 3 ] + } ); + } ); + + it( 'should indent list item when selection starts above and ends at codeblock', () => { + runTest( { + input: [ + '* foo', + '* b[ar', + ' fo]o' + ], + expected: [ + '* foo', + ' * b[ar {id:001}', + ' fo]o' + ], + domEventData: tabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 0, + indentList: 1, + outdentCodeBlock: 0, + indentCodeBlock: 0 + }, + changedBlocks: [ 1, 2 ] + } ); + } ); + + it( 'should indent code block when selection starts at code block and ends below', () => { + runTest( { + input: [ + '* foo', + '* ba[r', + '* yar]' + ], + expected: [ + '* foo', + '* ba[r', + '* yar]' + ], + domEventData: tabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 0, + indentList: 0, + outdentCodeBlock: 0, + indentCodeBlock: 1 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'should indent code block when selection starts at code block and ends outside list', () => { + runTest( { + input: [ + '* foo', + '* ba[r', + '* yar', + 'tar]' + ], + expected: [ + '* foo', + '* ba[r', + '* yar', + 'tar]' + ], + domEventData: tabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 0, + indentList: 0, + outdentCodeBlock: 0, + indentCodeBlock: 1 + }, + changedBlocks: [ ] + } ); + } ); + } ); + + describe( 'tab + shift keys handling', () => { + it( 'should outdent code block', () => { + const customSetModelData = () => { + setModelData( + model, + modelList( [ + '* []foo' + ] ) ); + + model.change( writer => { + writer.insertText( ' ', model.document.getRoot().getChild( 0 ) ); + } ); + }; + + runTest( { + expected: [ + '* []foo' + ], + domEventData: shiftTabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 0, + indentList: 0, + outdentCodeBlock: 1, + indentCodeBlock: 0 + }, + changedBlocks: [ ], + customSetModelData + } ); + } ); + + it( 'should outdent list item if a code block does not have indent', () => { + runTest( { + input: [ + '* []foo' + ], + expected: [ + '[]foo' + ], + domEventData: shiftTabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 1, + indentList: 0, + outdentCodeBlock: 0, + indentCodeBlock: 0 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should outdent list items if a selection starts before code block and ends at a code block', () => { + const customSetModelData = () => { + setModelData( + model, + modelList( [ + '* foo', + '* b[ar', + '* y]ar' + ] ) ); + + model.change( writer => { + writer.insertText( ' ', model.document.getRoot().getChild( 2 ) ); + } ); + }; + + runTest( { + expected: [ + '* foo', + 'b[ar', + ' y]ar' + ], + domEventData: shiftTabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 1, + indentList: 0, + outdentCodeBlock: 0, + indentCodeBlock: 0 + }, + changedBlocks: [ 1, 2 ], + customSetModelData + } ); + } ); + + it( 'should outdent a code block if a selection starts at a code block and ends after it', () => { + const customSetModelData = () => { + setModelData( + model, + modelList( [ + '* foo', + '* b[ar', + '* y]ar' + ] ) ); + + model.change( writer => { + writer.insertText( ' ', model.document.getRoot().getChild( 1 ) ); + } ); + }; + + runTest( { + expected: [ + '* foo', + '* b[ar', + '* y]ar' + ], + domEventData: shiftTabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 0, + indentList: 0, + outdentCodeBlock: 1, + indentCodeBlock: 0 + }, + changedBlocks: [ ], + customSetModelData + } ); + } ); + } ); + } ); + + describe( 'table integration with list', () => { + it( 'TableKeyboard tab observer should capture tab press event when in a cell', () => { + const inputTable = modelTable( [ + [ '[foo]', 'bar' ] + ] ); + + const outputTable = modelTable( [ + [ 'foo', '[bar]' ] + ] ); + + runTest( { + input: [ + '* ' + inputTable + ], + expected: [ + '* ' + outputTable + ], + domEventData: tabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 0, + indentList: 0 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'TableKeyboard tab observer should capture tab press event with shift when in a cell', () => { + const inputTable = modelTable( [ + [ 'foo', '[bar]' ] + ] ); + + const outputTable = modelTable( [ + [ '[foo]', 'bar' ] + ] ); + + runTest( { + input: [ + '* ' + inputTable + ], + expected: [ + '* ' + outputTable + ], + domEventData: shiftTabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 0, + indentList: 0 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'TableKeyboard tab observer should capture tab press event with shift when whole table is selected', () => { + const inputTable = modelTable( [ + [ 'foo', 'bar' ] + ] ); + + const outputTable = modelTable( [ + [ '[foo]', 'bar' ] + ] ); + + runTest( { + input: [ + '* [' + inputTable + ']' + ], + expected: [ + '* ' + outputTable + ], + domEventData: tabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 0, + indentList: 0 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'should indent list items if selection spans a table', () => { + const inputTable = modelTable( [ + [ 'foo', 'bar' ] + ] ); + + const outputTable = modelTable( [ + [ 'foo', 'bar' ] + ] ); + + runTest( { + input: [ + '* foo', + '* ba[r', + '* ' + inputTable, + '* ya]r' + ], + expected: [ + '* foo', + ' * ba[r', + ' * ' + outputTable, + ' * ya]r' + ], + domEventData: tabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 0, + indentList: 1 + }, + changedBlocks: [ 1, 2, 3 ] + } ); + } ); + + it( 'nothing should happen if selection spans a table but one of the list items cannot be indented', () => { + const inputTable = modelTable( [ + [ 'foo', 'bar' ] + ] ); + + const outputTable = modelTable( [ + [ 'foo', 'bar' ] + ] ); + + runTest( { + input: [ + '* fo[o', + '* bar', + '* ' + inputTable, + '* ya]r' + ], + expected: [ + '* fo[o', + '* bar', + '* ' + outputTable, + '* ya]r' + ], + domEventData: tabDomEventData, + eventStopped: false, + executedCommands: { + outdentList: 0, + indentList: 0 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'should outdent list items if a selection spans table', () => { + const inputTable = modelTable( [ + [ 'foo', 'bar' ] + ] ); + + const outputTable = modelTable( [ + [ 'foo', 'bar' ] + ] ); + + runTest( { + input: [ + '* foo', + '* ba[r', + '* ' + inputTable, + '* ya]r' + ], + expected: [ + '* foo', + 'ba[r', + outputTable, + 'ya]r' + ], + domEventData: shiftTabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 1, + indentList: 0 + }, + changedBlocks: [ 1, 2, 3 ] + } ); + } ); + + it( 'table listener should capture event when list cannot be indented', () => { + const innerList = modelList( [ + '* A[]' + ] ); + + const innerListOutput = modelList( [ + '* A {id:a00}' + ] ); + + const inputTable = modelTable( [ + [ innerList, 'bar' ] + ] ); + + const outputTable = modelTable( [ + [ innerListOutput, '[bar]' ] + ] ); + + runTest( { + input: [ + '* ' + inputTable + ], + expected: [ + '* ' + outputTable + ], + domEventData: tabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 0, + indentList: 0 + }, + changedBlocks: [ ] + } ); + } ); + + it( 'should indent list item in a table', () => { + const innerList = modelList( [ + '* A', + '* B[]' + ] ); + + const innerListOutput = modelList( [ + '* A {id:a00}', + ' * B[]' + ] ); + + const inputTable = modelTable( [ + [ innerList, 'bar' ] + ] ); + + const outputTable = modelTable( [ + [ innerListOutput, 'bar' ] + ] ); + + runTest( { + input: [ + '* ' + inputTable + ], + expected: [ + '* ' + outputTable + ], + domEventData: tabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 0, + indentList: 1 + }, + changedBlocks: [ 1 ] + } ); + } ); + + it( 'should outdent list item in a table', () => { + const innerList = modelList( [ + '* A[]' + ] ); + + const inputTable = modelTable( [ + [ innerList, 'bar' ] + ] ); + + const outputTable = modelTable( [ + [ 'A[]', 'bar' ] + ] ); + + runTest( { + input: [ + '* ' + inputTable + ], + expected: [ + '* ' + outputTable + ], + domEventData: shiftTabDomEventData, + eventStopped: true, + executedCommands: { + outdentList: 1, + indentList: 0 + }, + changedBlocks: [ 0 ] + } ); + } ); + } ); + + // @param {Iterable.} input + // @param {Iterable.} expected + // @param {module:engine/view/observer/domeventdata~DomEventData} domEventData + // @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. + // @param {Function} customSetModelData Function to alter how model data is set. + function runTest( { input, expected, domEventData, eventStopped, executedCommands = {}, changedBlocks = [], customSetModelData } ) { + if ( customSetModelData ) { + customSetModelData(); + } else { + setModelData( model, modelList( input ) ); + } + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( expected ) ); + + if ( typeof eventStopped === 'object' ) { + expect( domEventData.domEvent.stopPropagation.called ).to.equal( eventStopped.stopPropagation, 'stopPropagation() call' ); + 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.stopPropagation.callCount ).to.equal( eventStopped ? 1 : 0, 'stopPropagation() call' ); + 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/utils/listwalker.js b/packages/ckeditor5-list/tests/documentlist/utils/listwalker.js new file mode 100644 index 00000000000..29542bf5d55 --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/utils/listwalker.js @@ -0,0 +1,894 @@ +/** + * @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 ListWalker from '../../../src/documentlist/utils/listwalker'; +import { modelList } from '../_utils/utils'; + +import Model from '@ckeditor/ckeditor5-engine/src/model/model'; +import { parse as parseModel } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; + +describe( 'DocumentList - utils - ListWalker', () => { + let model, schema; + + beforeEach( () => { + model = new Model(); + schema = model.schema; + + schema.register( 'paragraph', { inheritAllFrom: '$block' } ); + schema.register( 'blockQuote', { inheritAllFrom: '$container' } ); + schema.extend( '$container', { allowAttributes: [ 'listType', 'listIndent', 'listItemId' ] } ); + } ); + + it( 'should return no blocks (sameIndent = false, lowerIndent = false, higherIndent = false)', () => { + const input = modelList( [ + '* 0', + '* 1', + '* 2' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 0 ), { + direction: 'forward', + includeSelf: true + // sameIndent: false -> default + // lowerIndent: false -> default + // higherIndent: false -> default + + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 0 ); + } ); + + describe( 'same level iterating (sameIndent = true)', () => { + it( 'should iterate on nodes with `listItemId` attribute', () => { + const input = modelList( [ + '* 0', + '* 1', + '* 2' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 0 ), { + direction: 'forward', + sameIndent: true, + includeSelf: true + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 3 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 0 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 1 ) ); + expect( blocks[ 2 ] ).to.equal( fragment.getChild( 2 ) ); + } ); + + it( 'should stop iterating on first node without `listItemId` attribute', () => { + const input = modelList( [ + '* 0', + '* 1', + '2' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 0 ), { + direction: 'forward', + sameIndent: true, + includeSelf: true + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 2 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 0 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 1 ) ); + } ); + + it( 'should not iterate over nodes without `listItemId` attribute', () => { + const input = modelList( [ + 'x', + '* 0', + '* 1' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 0 ), { + direction: 'forward', + sameIndent: true, + includeSelf: true + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 0 ); + } ); + + it( 'should skip start block (includeSelf = false, direction = forward)', () => { + const input = modelList( [ + '* 0', + '* 1', + '* 2' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 0 ), { + direction: 'forward', + sameIndent: true + // includeSelf: false -> default + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 2 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 1 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 2 ) ); + } ); + + it( 'should skip start block (includeSelf = false, direction = backward)', () => { + const input = modelList( [ + '* 0', + '* 1', + '* 2' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 2 ), { + direction: 'backward', + sameIndent: true + // includeSelf: false -> default + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 2 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 1 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 0 ) ); + } ); + + it( 'should return items with the same ID', () => { + const input = modelList( [ + '* 0', + ' 1', + '* 2' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 0 ), { + direction: 'forward', + sameIndent: true, + includeSelf: true, + sameAttributes: 'listItemId' + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 2 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 0 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 1 ) ); + } ); + + it( 'should return items of the same type', () => { + const input = modelList( [ + '* 0', + '* 1', + '# 2' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 0 ), { + direction: 'forward', + sameIndent: true, + includeSelf: true, + sameAttributes: [ 'listType' ] + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 2 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 0 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 1 ) ); + } ); + + it( 'should return items of the same additional attributes (single specified)', () => { + const input = modelList( [ + '* 0 {style:abc}', + '* 1 {start:5}', + '* 2 {style:xyz}' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 0 ), { + direction: 'forward', + sameIndent: true, + includeSelf: true, + sameAttributes: [ 'listStyle' ] + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 2 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 0 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 1 ) ); + } ); + + it( 'should return items of the same additional attributes (multiple specified)', () => { + const input = modelList( [ + '* 0 {style:abc}', + '* 1 {start:5}', + '* 2 {reversed:true}', + '* 3 {style:xyz}' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 0 ), { + direction: 'forward', + sameIndent: true, + includeSelf: true, + sameAttributes: [ 'listStyle', 'listReversed' ] + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 2 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 0 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 1 ) ); + } ); + + it( 'should return items while iterating over a nested list', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' * 2', + '* 3' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 1 ), { + direction: 'forward', + sameIndent: true, + includeSelf: true + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 2 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 1 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 2 ) ); + } ); + + it( 'should skip nested items (higherIndent = false)', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' * 2', + '* 3' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 0 ), { + direction: 'forward', + sameIndent: true, + includeSelf: true + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 2 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 0 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 3 ) ); + } ); + + it( 'should include nested items (higherIndent = true)', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' * 2', + ' * 3', + ' * 4', + '* 5' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 1 ), { + direction: 'forward', + sameIndent: true, + higherIndent: true, + includeSelf: true + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 4 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 1 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 2 ) ); + expect( blocks[ 2 ] ).to.equal( fragment.getChild( 3 ) ); + expect( blocks[ 3 ] ).to.equal( fragment.getChild( 4 ) ); + } ); + + it( 'should include nested items (higherIndent = true, sameItemId = true, forward)', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' * 2', + ' * 3', + ' 4', + ' * 5' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 1 ), { + direction: 'forward', + sameIndent: true, + higherIndent: true, + includeSelf: true, + sameAttributes: 'listItemId' + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 4 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 1 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 2 ) ); + expect( blocks[ 2 ] ).to.equal( fragment.getChild( 3 ) ); + expect( blocks[ 3 ] ).to.equal( fragment.getChild( 4 ) ); + } ); + + it( 'should include nested items (higherIndent = true, sameItemId = true, backward)', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' * 2', + ' * 3', + ' 4', + ' * 5' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 4 ), { + direction: 'backward', + sameIndent: true, + higherIndent: true, + includeSelf: true, + sameAttributes: 'listItemId' + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 4 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 4 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 3 ) ); + expect( blocks[ 2 ] ).to.equal( fragment.getChild( 2 ) ); + expect( blocks[ 3 ] ).to.equal( fragment.getChild( 1 ) ); + } ); + + it( 'should not include nested items from other item (higherIndent = true, sameItemId = true, backward)', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' * 2', + ' * 3', + ' * 4', + ' * 5' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 4 ), { + direction: 'backward', + sameIndent: true, + higherIndent: true, + includeSelf: true, + sameAttributes: 'listItemId' + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 1 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 4 ) ); + } ); + + it( 'should return all list blocks (higherIndent = true, sameIndent = true, lowerIndent = true)', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' * 2', + ' * 3', + ' * 4', + '* 5' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 1 ), { + direction: 'forward', + sameIndent: true, + lowerIndent: true, + higherIndent: true, + includeSelf: true + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 5 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 1 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 2 ) ); + expect( blocks[ 2 ] ).to.equal( fragment.getChild( 3 ) ); + expect( blocks[ 3 ] ).to.equal( fragment.getChild( 4 ) ); + expect( blocks[ 4 ] ).to.equal( fragment.getChild( 5 ) ); + } ); + + describe( 'first()', () => { + it( 'should return first sibling block', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' * 2', + ' * 3', + ' * 4', + '* 5' + ] ); + + const fragment = parseModel( input, schema ); + const block = ListWalker.first( fragment.getChild( 2 ), { + direction: 'forward', + sameIndent: true + } ); + + expect( block ).to.equal( fragment.getChild( 3 ) ); + } ); + + it( 'should return first block on the same indent level (forward)', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' * 2', + ' * 3', + ' * 4', + '* 5' + ] ); + + const fragment = parseModel( input, schema ); + const block = ListWalker.first( fragment.getChild( 1 ), { + direction: 'forward', + sameIndent: true + } ); + + expect( block ).to.equal( fragment.getChild( 4 ) ); + } ); + + it( 'should return first block on the same indent level (backward)', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' * 2', + ' * 3', + ' * 4', + '* 5' + ] ); + + const fragment = parseModel( input, schema ); + const block = ListWalker.first( fragment.getChild( 4 ), { + direction: 'backward', + sameIndent: true + } ); + + expect( block ).to.equal( fragment.getChild( 1 ) ); + } ); + } ); + } ); + + describe( 'nested level iterating (higherIndent = true )', () => { + it( 'should return nested list blocks (higherIndent = true, sameIndent = false)', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' * 2', + ' * 3', + ' * 4', + '* 5' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 1 ), { + direction: 'forward', + higherIndent: true + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 2 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 2 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 3 ) ); + } ); + + it( 'should return all nested blocks (higherIndent = true, sameIndent = false)', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' * 2', + ' * 3', + ' * 4', + '* 5' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 0 ), { + direction: 'forward', + higherIndent: true + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 4 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 1 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 2 ) ); + expect( blocks[ 2 ] ).to.equal( fragment.getChild( 3 ) ); + expect( blocks[ 3 ] ).to.equal( fragment.getChild( 4 ) ); + } ); + + it( 'should return all nested blocks (higherIndent = true, sameIndent = false, backward)', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' * 2', + ' * 3', + ' * 4', + '* 5' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 5 ), { + direction: 'backward', + higherIndent: true + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 4 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 4 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 3 ) ); + expect( blocks[ 2 ] ).to.equal( fragment.getChild( 2 ) ); + expect( blocks[ 3 ] ).to.equal( fragment.getChild( 1 ) ); + } ); + + it( 'should return nested blocks next to the start element', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' * 2', + '* 3', + ' * 4', + ' * 5' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 0 ), { + direction: 'forward', + higherIndent: true + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 2 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 1 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 2 ) ); + } ); + + it( 'should return nested blocks next to the start element (backward)', () => { + const input = modelList( [ + '* 0', + ' * 1', + '* 2', + ' * 3', + ' * 4', + '* 5' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 5 ), { + direction: 'backward', + higherIndent: true + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 2 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 4 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 3 ) ); + } ); + + it( 'should return nothing there is no nested sibling', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' * 2', + '* 3' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 1 ), { + direction: 'forward', + higherIndent: true + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 0 ); + } ); + + it( 'should return nothing there is no nested sibling (backward)', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' * 2', + '* 3' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 2 ), { + direction: 'backward', + higherIndent: true + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 0 ); + } ); + + it( 'should return nothing if a the end of nested list', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' * 2', + '* 3' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 2 ), { + direction: 'forward', + higherIndent: true + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 0 ); + } ); + + it( 'should return nothing if a the start of nested list (backward)', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' * 2', + '* 3' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 1 ), { + direction: 'backward', + higherIndent: true + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 0 ); + } ); + + describe( 'first()', () => { + it( 'should return nested sibling block', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' * 2', + ' * 3', + ' * 4', + '* 5' + ] ); + + const fragment = parseModel( input, schema ); + const block = ListWalker.first( fragment.getChild( 1 ), { + direction: 'forward', + higherIndent: true + } ); + + expect( block ).to.equal( fragment.getChild( 2 ) ); + } ); + + it( 'should return nested sibling block (backward)', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' * 2', + ' * 3', + ' * 4', + '* 5' + ] ); + + const fragment = parseModel( input, schema ); + const block = ListWalker.first( fragment.getChild( 4 ), { + direction: 'backward', + higherIndent: true + } ); + + expect( block ).to.equal( fragment.getChild( 3 ) ); + } ); + } ); + } ); + + describe( 'parent level iterating (lowerIndent = true )', () => { + it( 'should return nothing if at the start of top level list (backward)', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' * 2', + '* 3' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 0 ), { + direction: 'backward', + lowerIndent: true + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 0 ); + } ); + + it( 'should return nothing if at top level list (backward)', () => { + const input = modelList( [ + '* 0', + '* 1', + '* 2', + '* 3' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 1 ), { + direction: 'backward', + lowerIndent: true + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 0 ); + } ); + + it( 'should return nothing if at top level list (forward)', () => { + const input = modelList( [ + '* 0', + '* 1', + '* 2', + '* 3' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 1 ), { + direction: 'forward', + lowerIndent: true + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 0 ); + } ); + + it( 'should return parent block if at the first block of nested list (backward)', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' * 2', + '* 3' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 1 ), { + direction: 'backward', + lowerIndent: true + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 1 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 0 ) ); + } ); + + it( 'should return parent block if at the following block of nested list (backward)', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' * 2', + '* 3' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 2 ), { + direction: 'backward', + lowerIndent: true + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 1 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 0 ) ); + } ); + + it( 'should return parent block even when there is a nested list (backward)', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' * 2', + ' * 3', + ' * 4', + '* 5' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 4 ), { + direction: 'backward', + lowerIndent: true + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 1 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 0 ) ); + } ); + + it( 'should return parent block even when there is a nested list (forward)', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' * 2', + ' * 3', + ' * 4', + '* 5' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 1 ), { + direction: 'forward', + lowerIndent: true + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 1 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 5 ) ); + } ); + + it( 'should return parent blocks (backward)', () => { + const input = modelList( [ + '* 0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + '* 5' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 4 ), { + direction: 'backward', + lowerIndent: true + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 3 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 3 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 2 ) ); + expect( blocks[ 2 ] ).to.equal( fragment.getChild( 1 ) ); + } ); + + it( 'should return parent blocks (forward)', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' * 2', + ' * 3', + ' * 4', + ' * 5', + '* 6' + ] ); + + const fragment = parseModel( input, schema ); + const walker = new ListWalker( fragment.getChild( 3 ), { + direction: 'forward', + lowerIndent: true + } ); + const blocks = Array.from( walker ); + + expect( blocks.length ).to.equal( 2 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 4 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 6 ) ); + } ); + + describe( 'first()', () => { + it( 'should return nested sibling block', () => { + const input = modelList( [ + '* 0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + '* 5' + ] ); + + const fragment = parseModel( input, schema ); + const block = ListWalker.first( fragment.getChild( 4 ), { + direction: 'backward', + lowerIndent: true + } ); + + expect( block ).to.equal( fragment.getChild( 1 ) ); + } ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-list/tests/documentlist/utils/model.js b/packages/ckeditor5-list/tests/documentlist/utils/model.js new file mode 100644 index 00000000000..8fdd40f7102 --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/utils/model.js @@ -0,0 +1,1775 @@ +/** + * @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 { + expandListBlocksToCompleteItems, + expandListBlocksToCompleteList, + getAllListItemBlocks, + getListItemBlocks, + getListItems, + getNestedListBlocks, + indentBlocks, + isFirstBlockOfListItem, + isLastBlockOfListItem, + isSingleListItem, + ListItemUid, + mergeListItemBefore, + outdentBlocksWithMerge, + outdentFollowingItems, + removeListAttributes, + splitListItemBefore +} from '../../../src/documentlist/utils/model'; +import { modelList } from '../_utils/utils'; +import stubUid from '../_utils/uid'; + +import Model from '@ckeditor/ckeditor5-engine/src/model/model'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import { stringify as stringifyModel, parse as parseModel } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; + +describe( 'DocumentList - utils - model', () => { + let model, schema; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + model = new Model(); + schema = model.schema; + + schema.register( 'paragraph', { inheritAllFrom: '$block' } ); + schema.register( 'blockQuote', { inheritAllFrom: '$container' } ); + schema.extend( '$container', { allowAttributes: [ 'listType', 'listIndent', 'listItemId' ] } ); + } ); + + describe( 'ListItemUid.next()', () => { + it( 'should generate UIDs', () => { + stubUid( 0 ); + + expect( ListItemUid.next() ).to.equal( '000' ); + expect( ListItemUid.next() ).to.equal( '001' ); + expect( ListItemUid.next() ).to.equal( '002' ); + expect( ListItemUid.next() ).to.equal( '003' ); + expect( ListItemUid.next() ).to.equal( '004' ); + expect( ListItemUid.next() ).to.equal( '005' ); + } ); + } ); + + describe( 'getAllListItemBlocks()', () => { + it( 'should return a single item if it meets conditions', () => { + const input = modelList( [ + 'foo', + '* 0.', + '* 1.', + 'bar' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 1 ); + const foundElements = getAllListItemBlocks( listItem ); + + expect( foundElements.length ).to.equal( 1 ); + expect( foundElements[ 0 ] ).to.be.equal( listItem ); + } ); + + it( 'should return a items if started looking from the first list item block', () => { + const input = modelList( [ + 'foo', + '* 0a.', + ' 1b.', + ' 1c.', + '* 2.', + 'bar' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 1 ); + const foundElements = getAllListItemBlocks( listItem ); + + expect( foundElements.length ).to.equal( 3 ); + expect( foundElements[ 0 ] ).to.be.equal( listItem ); + expect( foundElements[ 1 ] ).to.be.equal( fragment.getChild( 2 ) ); + expect( foundElements[ 2 ] ).to.be.equal( fragment.getChild( 3 ) ); + } ); + + it( 'should return a items if started looking from the last list item block', () => { + const input = modelList( [ + 'foo', + '* 0a.', + ' 1b.', + ' 1c.', + '* 2.', + 'bar' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 3 ); + const foundElements = getAllListItemBlocks( listItem ); + + expect( foundElements.length ).to.equal( 3 ); + expect( foundElements[ 0 ] ).to.be.equal( fragment.getChild( 1 ) ); + expect( foundElements[ 1 ] ).to.be.equal( fragment.getChild( 2 ) ); + expect( foundElements[ 2 ] ).to.be.equal( listItem ); + } ); + + it( 'should return a items if started looking from the middle list item block', () => { + const input = modelList( [ + 'foo', + '* 0a.', + ' 1b.', + ' 1c.', + '* 2.', + 'bar' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 2 ); + const foundElements = getAllListItemBlocks( listItem ); + + expect( foundElements.length ).to.equal( 3 ); + expect( foundElements[ 0 ] ).to.be.equal( fragment.getChild( 1 ) ); + expect( foundElements[ 1 ] ).to.be.equal( listItem ); + expect( foundElements[ 2 ] ).to.be.equal( fragment.getChild( 3 ) ); + } ); + + it( 'should ignore nested list blocks', () => { + const input = modelList( [ + 'foo', + '* a', + '* b1', + ' * b1.c', + ' b2', + ' * b2.d', + ' b3', + '* e', + 'bar' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 4 ); + const foundElements = getAllListItemBlocks( listItem ); + + expect( foundElements.length ).to.equal( 3 ); + expect( foundElements[ 0 ] ).to.be.equal( fragment.getChild( 2 ) ); + expect( foundElements[ 1 ] ).to.be.equal( listItem ); + expect( foundElements[ 2 ] ).to.be.equal( fragment.getChild( 6 ) ); + } ); + } ); + + describe( 'getListItemBlocks()', () => { + it( 'should return a single item if it meets conditions', () => { + const input = modelList( [ + 'foo', + '* 0.', + '* 1.', + 'bar' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 1 ); + const backwardElements = getListItemBlocks( listItem, { direction: 'backward' } ); + const forwardElements = getListItemBlocks( listItem, { direction: 'forward' } ); + + expect( backwardElements.length ).to.equal( 0 ); + expect( forwardElements.length ).to.equal( 1 ); + expect( forwardElements[ 0 ] ).to.be.equal( listItem ); + } ); + + it( 'should return a items if started looking from the first list item block', () => { + const input = modelList( [ + 'foo', + '* 0a.', + ' 1b.', + ' 1c.', + '* 2.', + 'bar' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 1 ); + const backwardElements = getListItemBlocks( listItem, { direction: 'backward' } ); + const forwardElements = getListItemBlocks( listItem, { direction: 'forward' } ); + + expect( backwardElements.length ).to.equal( 0 ); + expect( forwardElements.length ).to.equal( 3 ); + expect( forwardElements[ 0 ] ).to.be.equal( listItem ); + expect( forwardElements[ 1 ] ).to.be.equal( fragment.getChild( 2 ) ); + expect( forwardElements[ 2 ] ).to.be.equal( fragment.getChild( 3 ) ); + } ); + + it( 'should return a items if started looking from the last list item block', () => { + const input = modelList( [ + 'foo', + '* 0a.', + ' 1b.', + ' 1c.', + '* 2.', + 'bar' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 3 ); + const backwardElements = getListItemBlocks( listItem, { direction: 'backward' } ); + const forwardElements = getListItemBlocks( listItem, { direction: 'forward' } ); + + expect( backwardElements.length ).to.equal( 2 ); + expect( backwardElements[ 0 ] ).to.be.equal( fragment.getChild( 1 ) ); + expect( backwardElements[ 1 ] ).to.be.equal( fragment.getChild( 2 ) ); + + expect( forwardElements.length ).to.equal( 1 ); + expect( forwardElements[ 0 ] ).to.be.equal( listItem ); + } ); + + it( 'should return a items if started looking from the middle list item block', () => { + const input = modelList( [ + 'foo', + '* 0a.', + ' 1b.', + ' 1c.', + '* 2.', + 'bar' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 2 ); + const backwardElements = getListItemBlocks( listItem, { direction: 'backward' } ); + const forwardElements = getListItemBlocks( listItem, { direction: 'forward' } ); + + expect( backwardElements.length ).to.equal( 1 ); + expect( backwardElements[ 0 ] ).to.be.equal( fragment.getChild( 1 ) ); + + expect( forwardElements.length ).to.equal( 2 ); + expect( forwardElements[ 0 ] ).to.be.equal( listItem ); + expect( forwardElements[ 1 ] ).to.be.equal( fragment.getChild( 3 ) ); + } ); + + it( 'should ignore nested list blocks', () => { + const input = modelList( [ + 'foo', + '* a', + '* b1', + ' * b1.c', + ' b2', + ' * b2.d', + ' b3', + '* e', + 'bar' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 4 ); + const backwardElements = getListItemBlocks( listItem, { direction: 'backward' } ); + const forwardElements = getListItemBlocks( listItem, { direction: 'forward' } ); + + expect( backwardElements.length ).to.equal( 1 ); + expect( backwardElements[ 0 ] ).to.be.equal( fragment.getChild( 2 ) ); + + expect( forwardElements.length ).to.equal( 2 ); + expect( forwardElements[ 0 ] ).to.be.equal( listItem ); + expect( forwardElements[ 1 ] ).to.be.equal( fragment.getChild( 6 ) ); + } ); + + it( 'should break if exited nested list', () => { + const input = modelList( [ + 'foo', + '* a', + ' * b', + ' b', + '* c', + 'bar' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 2 ); + const backwardElements = getListItemBlocks( listItem, { direction: 'backward' } ); + const forwardElements = getListItemBlocks( listItem, { direction: 'forward' } ); + + expect( backwardElements.length ).to.equal( 0 ); + + expect( forwardElements.length ).to.equal( 2 ); + expect( forwardElements[ 0 ] ).to.be.equal( listItem ); + expect( forwardElements[ 1 ] ).to.be.equal( fragment.getChild( 3 ) ); + } ); + + it( 'should search backward by default', () => { + const input = modelList( [ + 'foo', + '* a', + '* b', + ' b', + '* c', + 'bar' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 3 ); + const backwardElements = getListItemBlocks( listItem ); + + expect( backwardElements.length ).to.equal( 1 ); + expect( backwardElements[ 0 ] ).to.equal( fragment.getChild( 2 ) ); + } ); + } ); + + describe( 'getNestedListBlocks()', () => { + it( 'should return empty array if there is no nested blocks', () => { + const input = modelList( [ + '* a', + '* b' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 0 ); + const blocks = getNestedListBlocks( listItem ); + + expect( blocks.length ).to.equal( 0 ); + } ); + + it( 'should return blocks that have a greater indent than the given item', () => { + const input = modelList( [ + '* a', + ' * b', + ' * c', + ' * d', + '* e' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 0 ); + const blocks = getNestedListBlocks( listItem ); + + expect( blocks.length ).to.equal( 3 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 1 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 2 ) ); + expect( blocks[ 2 ] ).to.equal( fragment.getChild( 3 ) ); + } ); + + it( 'should return blocks that have a greater indent than the given item (nested one)', () => { + const input = modelList( [ + '* a', + ' * b', + ' * c', + ' * d', + '* e' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 1 ); + const blocks = getNestedListBlocks( listItem ); + + expect( blocks.length ).to.equal( 2 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 2 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 3 ) ); + } ); + + it( 'should not include items from other subtrees', () => { + const input = modelList( [ + '* a', + ' * b', + ' * c', + '* d', + ' * e' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 0 ); + const blocks = getNestedListBlocks( listItem ); + + expect( blocks.length ).to.equal( 2 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 1 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 2 ) ); + } ); + } ); + + describe( 'getListItems()', () => { + it( 'should return all list items for a single flat list (when given the first list item)', () => { + const input = modelList( [ + '0', + '* 1', + '* 2', + '* 3', + '4' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 1 ); + + expect( getListItems( listItem ) ).to.deep.equal( [ + fragment.getChild( 1 ), + fragment.getChild( 2 ), + fragment.getChild( 3 ) + ] ); + } ); + + it( 'should return all list items for a single flat list (when given the last list item)', () => { + const input = modelList( [ + '0', + '* 1', + '* 2', + '* 3', + '4' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 3 ); + + expect( getListItems( listItem ) ).to.deep.equal( [ + fragment.getChild( 1 ), + fragment.getChild( 2 ), + fragment.getChild( 3 ) + ] ); + } ); + + it( 'should return all list items for a single flat list (when given the middle list item)', () => { + const input = modelList( [ + '0', + '* 1', + '* 2', + '* 3', + '4' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 2 ); + + expect( getListItems( listItem ) ).to.deep.equal( [ + fragment.getChild( 1 ), + fragment.getChild( 2 ), + fragment.getChild( 3 ) + ] ); + } ); + + it( 'should return all list items for a nested list', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' * 2', + ' * 3', + '* 4' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 2 ); + + expect( getListItems( listItem ) ).to.deep.equal( [ + fragment.getChild( 1 ), + fragment.getChild( 2 ), + fragment.getChild( 3 ) + ] ); + } ); + + it( 'should return all list items of the same type', () => { + const input = modelList( [ + '# 0', + '* 1', + '* 2', + '* 3', + '# 4' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 2 ); + + expect( getListItems( listItem ) ).to.deep.equal( [ + fragment.getChild( 1 ), + fragment.getChild( 2 ), + fragment.getChild( 3 ) + ] ); + } ); + + it( 'should return all list items and ignore nested lists', () => { + const input = modelList( [ + '0', + '* 1', + ' * 2', + '* 3', + '4' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 1 ); + + expect( getListItems( listItem ) ).to.deep.equal( [ + fragment.getChild( 1 ), + fragment.getChild( 3 ) + ] ); + } ); + + it( 'should return all list items with following blocks belonging to the same item', () => { + const input = modelList( [ + '0', + '* 1', + ' 2', + '* 3', + ' 4', + '5' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 2 ); + + expect( getListItems( listItem ) ).to.deep.equal( [ + fragment.getChild( 1 ), + fragment.getChild( 2 ), + fragment.getChild( 3 ), + fragment.getChild( 4 ) + ] ); + } ); + } ); + + describe( 'isFirstBlockOfListItem()', () => { + it( 'should return true for the first list item', () => { + const input = modelList( [ + '* a', + '* b' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 0 ); + + expect( isFirstBlockOfListItem( listItem ) ).to.be.true; + } ); + + it( 'should return true for the second list item', () => { + const input = modelList( [ + '* a', + '* b' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 1 ); + + expect( isFirstBlockOfListItem( listItem ) ).to.be.true; + } ); + + it( 'should return false for the second block of list item', () => { + const input = modelList( [ + '* a', + ' b' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 1 ); + + expect( isFirstBlockOfListItem( listItem ) ).to.be.false; + } ); + + it( 'should return true if the previous block has lower indent', () => { + const input = modelList( [ + '* a', + ' * b' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 1 ); + + expect( isFirstBlockOfListItem( listItem ) ).to.be.true; + } ); + + it( 'should return false if the previous block has higher indent but it is a part of bigger list item', () => { + const input = modelList( [ + '* a', + ' * b', + ' c' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 2 ); + + expect( isFirstBlockOfListItem( listItem ) ).to.be.false; + } ); + } ); + + describe( 'isLastBlockOfListItem()', () => { + it( 'should return true for the last list item', () => { + const input = modelList( [ + '* a', + '* b' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 1 ); + + expect( isLastBlockOfListItem( listItem ) ).to.be.true; + } ); + + it( 'should return true for the first list item', () => { + const input = modelList( [ + '* a', + '* b' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 0 ); + + expect( isLastBlockOfListItem( listItem ) ).to.be.true; + } ); + + it( 'should return false for the first block of list item', () => { + const input = modelList( [ + '* a', + ' b' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 0 ); + + expect( isLastBlockOfListItem( listItem ) ).to.be.false; + } ); + + it( 'should return true if the next block has lower indent', () => { + const input = modelList( [ + '* a', + ' * b', + '* c' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 1 ); + + expect( isLastBlockOfListItem( listItem ) ).to.be.true; + } ); + + it( 'should return false if the next block has higher indent but it is a part of bigger list item', () => { + const input = modelList( [ + '* a', + ' * b', + ' c' + ] ); + + const fragment = parseModel( input, schema ); + const listItem = fragment.getChild( 0 ); + + expect( isLastBlockOfListItem( listItem ) ).to.be.false; + } ); + } ); + + describe( 'expandListBlocksToCompleteItems()', () => { + it( 'should not modify list for a single block of a single-block list item', () => { + const input = modelList( [ + '* a', + '* b', + '* c', + '* d' + ] ); + + const fragment = parseModel( input, schema ); + let blocks = [ + fragment.getChild( 0 ) + ]; + + blocks = expandListBlocksToCompleteItems( blocks ); + + expect( blocks.length ).to.equal( 1 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 0 ) ); + } ); + + it( 'should include all blocks for single list item', () => { + const input = modelList( [ + '* 0', + ' 1', + ' 2' + ] ); + + const fragment = parseModel( input, schema ); + let blocks = [ + fragment.getChild( 0 ) + ]; + + blocks = expandListBlocksToCompleteItems( blocks ); + + expect( blocks.length ).to.equal( 3 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 0 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 1 ) ); + expect( blocks[ 2 ] ).to.equal( fragment.getChild( 2 ) ); + } ); + + it( 'should include all blocks for only first list item block', () => { + const input = modelList( [ + '* 0', + '* 1', + ' 2', + ' 3', + '* 3' + ] ); + + const fragment = parseModel( input, schema ); + let blocks = [ + fragment.getChild( 1 ) + ]; + + blocks = expandListBlocksToCompleteItems( blocks ); + + expect( blocks.length ).to.equal( 3 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 1 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 2 ) ); + expect( blocks[ 2 ] ).to.equal( fragment.getChild( 3 ) ); + } ); + + it( 'should include all blocks for only last list item block', () => { + const input = modelList( [ + '* 0', + '* 1', + ' 2', + ' 3', + '* 3' + ] ); + + const fragment = parseModel( input, schema ); + let blocks = [ + fragment.getChild( 3 ) + ]; + + blocks = expandListBlocksToCompleteItems( blocks ); + + expect( blocks.length ).to.equal( 3 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 1 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 2 ) ); + expect( blocks[ 2 ] ).to.equal( fragment.getChild( 3 ) ); + } ); + + it( 'should include all blocks for only middle list item block', () => { + const input = modelList( [ + '* 0', + '* 1', + ' 2', + ' 3', + '* 3' + ] ); + + const fragment = parseModel( input, schema ); + let blocks = [ + fragment.getChild( 2 ) + ]; + + blocks = expandListBlocksToCompleteItems( blocks ); + + expect( blocks.length ).to.equal( 3 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 1 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 2 ) ); + expect( blocks[ 2 ] ).to.equal( fragment.getChild( 3 ) ); + } ); + + it( 'should include all blocks in nested list item', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' 2', + ' 3', + '* 3' + ] ); + + const fragment = parseModel( input, schema ); + let blocks = [ + fragment.getChild( 2 ) + ]; + + blocks = expandListBlocksToCompleteItems( blocks ); + + expect( blocks.length ).to.equal( 3 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 1 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 2 ) ); + expect( blocks[ 2 ] ).to.equal( fragment.getChild( 3 ) ); + } ); + + it( 'should include all blocks including nested items (start from first item)', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' 2' + ] ); + + const fragment = parseModel( input, schema ); + let blocks = [ + fragment.getChild( 0 ) + ]; + + blocks = expandListBlocksToCompleteItems( blocks ); + + expect( blocks.length ).to.equal( 3 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 0 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 1 ) ); + expect( blocks[ 2 ] ).to.equal( fragment.getChild( 2 ) ); + } ); + + it( 'should include all blocks including nested items (start from last item)', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' 2' + ] ); + + const fragment = parseModel( input, schema ); + let blocks = [ + fragment.getChild( 2 ) + ]; + + blocks = expandListBlocksToCompleteItems( blocks ); + + expect( blocks.length ).to.equal( 3 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 0 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 1 ) ); + expect( blocks[ 2 ] ).to.equal( fragment.getChild( 2 ) ); + } ); + + it( 'should expand first and last items', () => { + const input = modelList( [ + '* x', + '* 0', + ' 1', + '* 2', + ' 3', + '* y' + ] ); + + const fragment = parseModel( input, schema ); + let blocks = [ + fragment.getChild( 2 ), + fragment.getChild( 3 ) + ]; + + blocks = expandListBlocksToCompleteItems( blocks ); + + expect( blocks.length ).to.equal( 4 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 1 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 2 ) ); + expect( blocks[ 2 ] ).to.equal( fragment.getChild( 3 ) ); + expect( blocks[ 3 ] ).to.equal( fragment.getChild( 4 ) ); + } ); + + it( 'should not include nested items from other item', () => { + const input = modelList( [ + '* 0', + ' * 1', + '* 2', + ' * 3', + '* 4' + ] ); + + const fragment = parseModel( input, schema ); + let blocks = [ + fragment.getChild( 2 ) + ]; + + blocks = expandListBlocksToCompleteItems( blocks ); + + expect( blocks.length ).to.equal( 2 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 2 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 3 ) ); + } ); + + it( 'should include all blocks even if not at the same indent level from the edge block', () => { + const fragment = parseModel( modelList( [ + '* 0', + ' * 1', + ' * 2', + ' 3', + ' * 4', + ' * 5', + ' 6', + ' * 7' + ] ), schema ); + + let blocks = [ + fragment.getChild( 2 ), + fragment.getChild( 3 ), + fragment.getChild( 4 ), + fragment.getChild( 5 ) + ]; + + blocks = expandListBlocksToCompleteItems( blocks ); + + expect( blocks.length ).to.equal( 6 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 1 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 2 ) ); + expect( blocks[ 2 ] ).to.equal( fragment.getChild( 3 ) ); + expect( blocks[ 3 ] ).to.equal( fragment.getChild( 4 ) ); + expect( blocks[ 4 ] ).to.equal( fragment.getChild( 5 ) ); + expect( blocks[ 5 ] ).to.equal( fragment.getChild( 6 ) ); + } ); + } ); + + describe( 'expandListBlocksToCompleteList()', () => { + it( 'should not include anything (no blocks given)', () => { + let blocks = []; + + blocks = expandListBlocksToCompleteList( blocks, [ 'listType' ] ); + + expect( blocks.length ).to.equal( 0 ); + } ); + + it( 'should include all list items (single item given)', () => { + const input = modelList( [ + '* a', + '* b', // <-- + '* c', + '* d' + ] ); + + const fragment = parseModel( input, schema ); + let blocks = [ + fragment.getChild( 1 ) + ]; + + blocks = expandListBlocksToCompleteList( blocks, [ 'listType' ] ); + + expect( blocks.length ).to.equal( 4 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 0 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 1 ) ); + expect( blocks[ 2 ] ).to.equal( fragment.getChild( 2 ) ); + expect( blocks[ 3 ] ).to.equal( fragment.getChild( 3 ) ); + } ); + + it( 'should include all list item (two items given)', () => { + const input = modelList( [ + '* a', + '* b', // <-- + '* c', + '* d' // <-- + ] ); + + const fragment = parseModel( input, schema ); + let blocks = [ + fragment.getChild( 1 ), + fragment.getChild( 3 ) + ]; + + blocks = expandListBlocksToCompleteList( blocks, [ 'listType' ] ); + + expect( blocks.length ).to.equal( 4 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 0 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 1 ) ); + expect( blocks[ 2 ] ).to.equal( fragment.getChild( 2 ) ); + expect( blocks[ 3 ] ).to.equal( fragment.getChild( 3 ) ); + } ); + + it( 'should include all list item (part of list item given)', () => { + const input = modelList( [ + '* a', + '* b', + ' c', // <-- + '* d', + ' e' + ] ); + + const fragment = parseModel( input, schema ); + let blocks = [ + fragment.getChild( 2 ) + ]; + + blocks = expandListBlocksToCompleteList( blocks, [ 'listType' ] ); + + expect( blocks.length ).to.equal( 5 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 0 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 1 ) ); + expect( blocks[ 2 ] ).to.equal( fragment.getChild( 2 ) ); + expect( blocks[ 3 ] ).to.equal( fragment.getChild( 3 ) ); + expect( blocks[ 4 ] ).to.equal( fragment.getChild( 4 ) ); + } ); + + it( 'should include all list item of nested list', () => { + const input = modelList( [ + '* a', + '* b', + ' # b1', + ' # b2', // <-- + ' # b3', + '* c', + '* d' + ] ); + + const fragment = parseModel( input, schema ); + let blocks = [ + fragment.getChild( 3 ) + ]; + + blocks = expandListBlocksToCompleteList( blocks, [ 'listType' ] ); + + expect( blocks.length ).to.equal( 3 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 2 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 3 ) ); + expect( blocks[ 2 ] ).to.equal( fragment.getChild( 4 ) ); + } ); + + it( 'should include all list item from many lists', () => { + const input = modelList( [ + '* a', + '* b', + ' # b1', // <-- + ' * b1a', // <-- + ' * b1b', + ' # b1b1', + ' * b1c', + ' # b2', + ' # b3', + '* c', + '* d' + ] ); + + const fragment = parseModel( input, schema ); + let blocks = [ + fragment.getChild( 2 ), + fragment.getChild( 3 ) + ]; + + blocks = expandListBlocksToCompleteList( blocks, [ 'listType' ] ); + + expect( blocks.length ).to.equal( 6 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 2 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 3 ) ); + expect( blocks[ 2 ] ).to.equal( fragment.getChild( 4 ) ); + expect( blocks[ 3 ] ).to.equal( fragment.getChild( 6 ) ); + expect( blocks[ 4 ] ).to.equal( fragment.getChild( 7 ) ); + expect( blocks[ 5 ] ).to.equal( fragment.getChild( 8 ) ); + } ); + + it( 'should not include any item from other list', () => { + const input = modelList( [ + '* 1a', + '* 1b', + '# 2a', + '# 2b', // <-- + '# 2c', + '* 3a', + '* 3b' + ] ); + + const fragment = parseModel( input, schema ); + let blocks = [ + fragment.getChild( 3 ) + ]; + + blocks = expandListBlocksToCompleteList( blocks, [ 'listType' ] ); + + expect( blocks.length ).to.equal( 3 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 2 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 3 ) ); + expect( blocks[ 2 ] ).to.equal( fragment.getChild( 4 ) ); + } ); + + it( 'should not include any item that is not a list', () => { + const input = modelList( [ + '1a' + + 'Foo' + + '2a' + + '2b' + // This one. + '2c' + + 'Bar' + + '3a' + ] ); + + const fragment = parseModel( input, schema ); + let blocks = [ + fragment.getChild( 3 ) + ]; + + blocks = expandListBlocksToCompleteList( blocks, [ 'listType' ] ); + + expect( blocks.length ).to.equal( 3 ); + expect( blocks[ 0 ] ).to.equal( fragment.getChild( 2 ) ); + expect( blocks[ 1 ] ).to.equal( fragment.getChild( 3 ) ); + expect( blocks[ 2 ] ).to.equal( fragment.getChild( 4 ) ); + } ); + } ); + + describe( 'splitListItemBefore()', () => { + it( 'should replace all blocks ids for first block given', () => { + const input = modelList( [ + '* a', + ' b', + ' c' + ] ); + + const fragment = parseModel( input, schema ); + + stubUid(); + model.change( writer => splitListItemBefore( fragment.getChild( 0 ), writer ) ); + + expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ + '* a{id:a00}', + ' b', + ' c' + ] ) ); + } ); + + it( 'should replace blocks ids for second block given', () => { + const input = modelList( [ + '* a', + ' b', + ' c' + ] ); + + const fragment = parseModel( input, schema ); + + stubUid(); + model.change( writer => splitListItemBefore( fragment.getChild( 1 ), writer ) ); + + expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ + '* a', + '* b{id:a00}', + ' c' + ] ) ); + } ); + + it( 'should not modify other items', () => { + const input = modelList( [ + '* x', + '* a', + ' b', + ' c', + '* y' + ] ); + + const fragment = parseModel( input, schema ); + + stubUid(); + model.change( writer => splitListItemBefore( fragment.getChild( 2 ), writer ) ); + + expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ + '* x', + '* a', + '* b{id:a00}', + ' c', + '* y' + ] ) ); + } ); + + it( 'should not modify nested items', () => { + const input = modelList( [ + '* a', + ' b', + ' * c', + ' d' + ] ); + + const fragment = parseModel( input, schema ); + + stubUid(); + model.change( writer => splitListItemBefore( fragment.getChild( 1 ), writer ) ); + + expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ + '* a', + '* b{id:a00}', + ' * c', + ' d' + ] ) ); + } ); + + it( 'should not modify parent items', () => { + const input = modelList( [ + '* a', + ' * b', + ' c', + ' d', + ' e' + ] ); + + const fragment = parseModel( input, schema ); + + stubUid(); + model.change( writer => splitListItemBefore( fragment.getChild( 2 ), writer ) ); + + expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ + '* a', + ' * b', + ' * c{id:a00}', + ' d', + ' e' + ] ) ); + } ); + } ); + + describe( 'mergeListItemBefore()', () => { + it( 'should apply parent list attributes to the given list block', () => { + const input = modelList( [ + '* 0', + ' # 1', + '* 2' + ] ); + + const fragment = parseModel( input, schema ); + let changedBlocks; + + model.change( writer => { + changedBlocks = mergeListItemBefore( fragment.getChild( 1 ), fragment.getChild( 0 ), writer ); + } ); + + expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ + '* 0', + ' 1', + '* 2' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + fragment.getChild( 1 ) + ] ); + } ); + + it( 'should apply parent list attributes to the given list block and all blocks of the same item', () => { + const input = modelList( [ + '* 0', + ' # 1', + ' 2', + '* 3' + ] ); + + const fragment = parseModel( input, schema ); + let changedBlocks; + + model.change( writer => { + changedBlocks = mergeListItemBefore( fragment.getChild( 1 ), fragment.getChild( 0 ), writer ); + } ); + + expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ + '* 0', + ' 1', + ' 2', + '* 3' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + fragment.getChild( 1 ), + fragment.getChild( 2 ) + ] ); + } ); + + it( 'should not apply non-list attributes', () => { + const input = modelList( [ + '* 0', + ' * 1', + '* 2' + ] ); + + const fragment = parseModel( input, schema ); + let changedBlocks; + + model.change( writer => { + changedBlocks = mergeListItemBefore( fragment.getChild( 1 ), fragment.getChild( 0 ), writer ); + } ); + + expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ + '* 0', + ' 1', + '* 2' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + fragment.getChild( 1 ) + ] ); + } ); + } ); + + describe( 'indentBlocks()', () => { + describe( 'indentBy = 1', () => { + it( 'flat items', () => { + const input = modelList( [ + '* a', + ' b', + '* c', + ' d' + ] ); + + const fragment = parseModel( input, schema ); + const blocks = [ + fragment.getChild( 2 ), + fragment.getChild( 3 ) + ]; + + stubUid(); + + model.change( writer => indentBlocks( blocks, writer ) ); + + expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ + '* a', + ' b', + ' * c', + ' d' + ] ) ); + } ); + + 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( '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 ); + } ); + + 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 ); + } ); + + 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 ); + } ); + + 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', + ' * 1', + ' 2', + ' 3', + '* 4' + ] ); + + const fragment = parseModel( input, schema ); + const blocks = [ + fragment.getChild( 1 ) + ]; + + let changedBlocks; + + model.change( writer => { + changedBlocks = outdentBlocksWithMerge( blocks, writer ); + } ); + + expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ + '* 0', + ' 1', + ' 2', + ' 3', + '* 4' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + fragment.getChild( 1 ), + fragment.getChild( 2 ) + ] ); + } ); + + it( 'should not merge nested items to the parent item if nested block is the last block of parent list item', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' 2', + '* 3', + '* 4' + ] ); + + const fragment = parseModel( input, schema ); + const blocks = [ + fragment.getChild( 1 ) + ]; + + let changedBlocks; + + model.change( writer => { + changedBlocks = outdentBlocksWithMerge( blocks, writer ); + } ); + + expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + ' 2', + '* 3', + '* 4' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + fragment.getChild( 1 ), + fragment.getChild( 2 ) + ] ); + } ); + + it( 'should merge nested items but not deeper nested lists', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' * 2', + ' * 3', + '* 4' + ] ); + + const fragment = parseModel( input, schema ); + const blocks = [ + fragment.getChild( 1 ) + ]; + + let changedBlocks; + + model.change( writer => { + changedBlocks = outdentBlocksWithMerge( blocks, writer ); + } ); + + expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + ' * 2', + ' * 3', + '* 4' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + fragment.getChild( 1 ), + fragment.getChild( 2 ), + fragment.getChild( 3 ) + ] ); + } ); + } ); + + describe( 'removeListAttributes()', () => { + it( 'should remove all list attributes on a given blocks', () => { + const input = modelList( [ + '* 0', + '* 1', + ' * 2', + ' 3', + ' * 4', + '* 5' + ] ); + + const fragment = parseModel( input, schema ); + const blocks = [ + fragment.getChild( 2 ), + fragment.getChild( 3 ), + fragment.getChild( 4 ) + ]; + + let changedBlocks; + + model.change( writer => { + changedBlocks = removeListAttributes( blocks, writer ); + } ); + + expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + '2', + '3', + '4', + '* 5' + ] ) ); + + expect( changedBlocks ).to.deep.equal( blocks ); + } ); + + it( 'should not remove non-list attributes', () => { + const input = modelList( [ + '* 0', + '* 1', + ' * 2', + ' 3', + ' * 4', + '* 5' + ] ); + + const fragment = parseModel( input, schema ); + const blocks = [ + fragment.getChild( 2 ), + fragment.getChild( 3 ), + fragment.getChild( 4 ) + ]; + + let changedBlocks; + + model.change( writer => { + changedBlocks = removeListAttributes( blocks, writer ); + } ); + + expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + '2', + '3', + '4', + '* 5' + ] ) ); + + expect( changedBlocks ).to.deep.equal( blocks ); + } ); + } ); + + describe( 'isSingleListItem()', () => { + it( 'should return false if no blocks are given', () => { + expect( isSingleListItem( [] ) ).to.be.false; + } ); + + it( 'should return false if first block is not a list item', () => { + const input = modelList( [ + '0', + '1' + ] ); + + const fragment = parseModel( input, schema ); + const blocks = [ + fragment.getChild( 1 ) + ]; + + expect( isSingleListItem( blocks ) ).to.be.false; + } ); + + it( 'should return false if any block has a different ID', () => { + const input = modelList( [ + '* 0', + ' 1', + '* 2' + ] ); + + const fragment = parseModel( input, schema ); + const blocks = [ + fragment.getChild( 0 ), + fragment.getChild( 1 ), + fragment.getChild( 2 ) + ]; + + expect( isSingleListItem( blocks ) ).to.be.false; + } ); + + it( 'should return true if all block has the same ID', () => { + const input = modelList( [ + '* 0', + ' 1', + '* 2' + ] ); + + const fragment = parseModel( input, schema ); + const blocks = [ + fragment.getChild( 0 ), + fragment.getChild( 1 ) + ]; + + expect( isSingleListItem( blocks ) ).to.be.true; + } ); + } ); + + describe( 'outdentFollowingItems()', () => { + it( 'should outdent all items and keep nesting structure where possible', () => { + const input = modelList( [ + '0', + '* 1', + ' * 2', + ' * 3', // <- this is turned off. + ' * 4', // <- this has to become indent = 0, because it will be first item on a new list. + ' * 5', // <- this should be still be a child of item above, so indent = 1. + ' * 6', // <- this has to become indent = 0, because it should not be a child of any of items above. + ' * 7', // <- this should be still be a child of item above, so indent = 1. + ' * 8', // <- this has to become indent = 0. + ' * 9', // <- this should still be a child of item above, so indent = 1. + ' * 10', // <- this should still be a child of item above, so indent = 2. + ' * 11', // <- this should still be at the same level as item above, so indent = 2. + '* 12', // <- this and all below are left unchanged. + ' * 13', + ' * 14' + ] ); + + const fragment = parseModel( input, schema ); + let changedBlocks; + + model.change( writer => { + changedBlocks = outdentFollowingItems( fragment.getChild( 3 ), writer ); + } ); + + expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ + '0', + '* 1', + ' * 2', + ' * 3', + '* 4', + ' * 5', + '* 6', + ' * 7', + '* 8', + ' * 9', + ' * 10', + ' * 11', + '* 12', + ' * 13', + ' * 14' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + fragment.getChild( 4 ), + fragment.getChild( 5 ), + fragment.getChild( 6 ), + fragment.getChild( 7 ), + fragment.getChild( 8 ), + fragment.getChild( 9 ), + fragment.getChild( 10 ), + fragment.getChild( 11 ) + ] ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-list/tests/documentlist/utils/postfixers.js b/packages/ckeditor5-list/tests/documentlist/utils/postfixers.js new file mode 100644 index 00000000000..3de9fbdd6f5 --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/utils/postfixers.js @@ -0,0 +1,580 @@ +/** + * @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 { + findAndAddListHeadToMap, + fixListIndents, + fixListItemIds +} from '../../../src/documentlist/utils/postfixers'; +import { + iterateSiblingListBlocks +} from '../../../src/documentlist/utils/listwalker'; +import stubUid from '../_utils/uid'; +import { modelList } from '../_utils/utils'; + +import Model from '@ckeditor/ckeditor5-engine/src/model/model'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import { stringify as stringifyModel, parse as parseModel } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; + +describe( 'DocumentList - utils - postfixers', () => { + let model, schema; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + model = new Model(); + schema = model.schema; + + schema.register( 'paragraph', { inheritAllFrom: '$block' } ); + schema.register( 'blockQuote', { inheritAllFrom: '$container' } ); + schema.extend( '$container', { allowAttributes: [ 'listType', 'listIndent', 'listItemId' ] } ); + } ); + + describe( 'findAndAddListHeadToMap()', () => { + it( 'should find list that starts just after the given position', () => { + const input = modelList( [ + 'foo', + '* a', + '* b' + ] ); + + const fragment = parseModel( input, schema ); + const position = model.createPositionAt( fragment, 1 ); + const itemToListHead = new Map(); + + findAndAddListHeadToMap( position, itemToListHead ); + + const heads = Array.from( itemToListHead.values() ); + + expect( heads.length ).to.equal( 1 ); + expect( heads[ 0 ] ).to.equal( fragment.getChild( 1 ) ); + } ); + + it( 'should find list that starts just before the given position', () => { + const input = modelList( [ + 'foo', + '* a', + '* b' + ] ); + + const fragment = parseModel( input, schema ); + const position = model.createPositionAt( fragment, 2 ); + const itemToListHead = new Map(); + + findAndAddListHeadToMap( position, itemToListHead ); + + const heads = Array.from( itemToListHead.values() ); + + expect( heads.length ).to.equal( 1 ); + expect( heads[ 0 ] ).to.equal( fragment.getChild( 1 ) ); + } ); + + it( 'should find list that ends just before the given position', () => { + const input = modelList( [ + 'foo', + '* a', + '* b' + ] ); + + const fragment = parseModel( input, schema ); + const position = model.createPositionAt( fragment, 3 ); + const itemToListHead = new Map(); + + findAndAddListHeadToMap( position, itemToListHead ); + + const heads = Array.from( itemToListHead.values() ); + + expect( heads.length ).to.equal( 1 ); + expect( heads[ 0 ] ).to.equal( fragment.getChild( 1 ) ); + } ); + + it( 'should reuse data from map if first item was previously mapped to head', () => { + const input = modelList( [ + 'foo', + '* a', + '* b', + '* c' + ] ); + + const fragment = parseModel( input, schema ); + const position = model.createPositionAt( fragment, 3 ); + const itemToListHead = new Map(); + + itemToListHead.set( fragment.getChild( 2 ), fragment.getChild( 1 ) ); + + findAndAddListHeadToMap( position, itemToListHead ); + + const heads = Array.from( itemToListHead.values() ); + + expect( heads.length ).to.equal( 1 ); + expect( heads[ 0 ] ).to.equal( fragment.getChild( 1 ) ); + } ); + + it( 'should reuse data from map if found some item that was previously mapped to head', () => { + const input = modelList( [ + 'foo', + '* a', + '* b', + '* c' + ] ); + + const fragment = parseModel( input, schema ); + const position = model.createPositionAt( fragment, 4 ); + const itemToListHead = new Map(); + + itemToListHead.set( fragment.getChild( 2 ), fragment.getChild( 1 ) ); + + findAndAddListHeadToMap( position, itemToListHead ); + + const heads = Array.from( itemToListHead.values() ); + + expect( heads.length ).to.equal( 1 ); + expect( heads[ 0 ] ).to.equal( fragment.getChild( 1 ) ); + } ); + + it( 'should not mix 2 lists separated by some non-list element', () => { + const input = modelList( [ + '* a', + 'foo', + '* b', + '* c' + ] ); + + const fragment = parseModel( input, schema ); + const position = model.createPositionAt( fragment, 4 ); + const itemToListHead = new Map(); + + findAndAddListHeadToMap( position, itemToListHead ); + + const heads = Array.from( itemToListHead.values() ); + + expect( heads.length ).to.equal( 1 ); + expect( heads[ 0 ] ).to.equal( fragment.getChild( 2 ) ); + } ); + + it( 'should find list head even for mixed indents, ids, and types', () => { + const input = modelList( [ + 'foo', + '* a', + ' a', + ' # b', + '* c' + ] ); + + const fragment = parseModel( input, schema ); + const position = model.createPositionAt( fragment, 5 ); + const itemToListHead = new Map(); + + findAndAddListHeadToMap( position, itemToListHead ); + + const heads = Array.from( itemToListHead.values() ); + + expect( heads.length ).to.equal( 1 ); + expect( heads[ 0 ] ).to.equal( fragment.getChild( 1 ) ); + } ); + + it( 'should not find a list if position is between plain paragraphs', () => { + const input = modelList( [ + '* a', + '* b', + 'foo', + 'bar', + '* c', + '* d' + ] ); + + const fragment = parseModel( input, schema ); + const position = model.createPositionAt( fragment, 3 ); + const itemToListHead = new Map(); + + findAndAddListHeadToMap( position, itemToListHead ); + + const heads = Array.from( itemToListHead.values() ); + + expect( heads.length ).to.equal( 0 ); + } ); + } ); + + describe( 'fixListIndents()', () => { + it( 'should fix indentation of first list item', () => { + const input = modelList( [ + 'foo', + ' * a' + ] ); + + const fragment = parseModel( input, schema ); + + model.change( writer => { + fixListIndents( iterateSiblingListBlocks( fragment.getChild( 1 ) ), writer ); + } ); + + expect( stringifyModel( fragment ) ).to.equal( modelList( [ + 'foo', + '* a' + ] ) ); + } ); + + it( 'should fix indentation of to deep nested items', () => { + const input = modelList( [ + '* a', + ' * b', + ' * c' + ] ); + + const fragment = parseModel( input, schema ); + + model.change( writer => { + fixListIndents( iterateSiblingListBlocks( fragment.getChild( 0 ) ), writer ); + } ); + + expect( stringifyModel( fragment ) ).to.equal( modelList( [ + '* a', + ' * b', + ' * c' + ] ) ); + } ); + + it( 'should not affect properly indented items after fixed item', () => { + const input = modelList( [ + '* a', + ' * b', + ' * c' + ] ); + + const fragment = parseModel( input, schema ); + + model.change( writer => { + fixListIndents( iterateSiblingListBlocks( fragment.getChild( 0 ) ), writer ); + } ); + + expect( stringifyModel( fragment ) ).to.equal( modelList( [ + '* a', + ' * b', + ' * c' + ] ) ); + } ); + + it( 'should fix rapid indent spikes', () => { + const input = modelList( [ + ' * a', + ' * b', + ' * c' + ] ); + + const fragment = parseModel( input, schema ); + + model.change( writer => { + fixListIndents( iterateSiblingListBlocks( fragment.getChild( 0 ) ), writer ); + } ); + + expect( stringifyModel( fragment ) ).to.equal( modelList( [ + '* a', + '* b', + ' * c' + ] ) ); + } ); + + it( 'should fix rapid indent spikes after some item', () => { + const input = modelList( [ + ' * a', + ' * b', + ' * c', + ' * d' + ] ); + + const fragment = parseModel( input, schema ); + + model.change( writer => { + fixListIndents( iterateSiblingListBlocks( fragment.getChild( 0 ) ), writer ); + } ); + + expect( stringifyModel( fragment ) ).to.equal( modelList( [ + '* a', + ' * b', + ' * c', + ' * d' + ] ) ); + } ); + + it( 'should fix indentation keeping the relative indentations', () => { + const input = modelList( [ + ' * a', + ' * b', + ' * c', + ' * d', + ' * e', + ' * f', + ' * g' + ] ); + + const fragment = parseModel( input, schema ); + + model.change( writer => { + fixListIndents( iterateSiblingListBlocks( fragment.getChild( 0 ) ), writer ); + } ); + + expect( stringifyModel( fragment ) ).to.equal( modelList( [ + '* a', + ' * b', + ' * c', + ' * d', + ' * e', + ' * f', + '* g' + ] ) ); + } ); + + it( 'should flatten the leading indentation spike', () => { + const input = modelList( [ + ' # e', + ' * f', + ' * g', + ' * h', + ' # i', + '# j' + ] ); + + const fragment = parseModel( input, schema ); + + model.change( writer => { + fixListIndents( iterateSiblingListBlocks( fragment.getChild( 0 ) ), writer ); + } ); + + expect( stringifyModel( fragment ) ).to.equal( modelList( [ + '# e', + '* f', + ' * g', + '* h', + ' # i', + '# j' + ] ) ); + } ); + + it( 'list nested in blockquote', () => { + const input = + 'foo' + + '
                ' + + modelList( [ + ' * foo', + ' * bar' + ] ) + + '
                '; + + const fragment = parseModel( input, schema ); + + model.change( writer => { + fixListIndents( iterateSiblingListBlocks( fragment.getChild( 1 ).getChild( 0 ) ), writer ); + } ); + + expect( stringifyModel( fragment ) ).to.equal( + 'foo' + + '
                ' + + modelList( [ + '* foo', + '* bar' + ] ) + + '
                ' + ); + } ); + } ); + + describe( 'fixListItemIds()', () => { + it( 'should update nested item ID', () => { + const input = modelList( [ + '* 0', + ' * 1' + ] ); + + const fragment = parseModel( input, model.schema ); + const seenIds = new Set(); + + stubUid(); + + model.change( writer => { + fixListItemIds( iterateSiblingListBlocks( fragment.getChild( 0 ) ), seenIds, writer ); + } ); + + expect( stringifyModel( fragment ) ).to.equal( modelList( [ + '* 0', + ' * 1' + ] ) ); + } ); + + it( 'should update nested item ID (middle element of bigger list item)', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' 2' + ] ); + + const fragment = parseModel( input, model.schema ); + const seenIds = new Set(); + + stubUid(); + + model.change( writer => { + fixListItemIds( iterateSiblingListBlocks( fragment.getChild( 0 ) ), seenIds, writer ); + } ); + + expect( stringifyModel( fragment ) ).to.equal( modelList( [ + '* 0', + ' * 1', + ' 2' + ] ) ); + } ); + + it( 'should use same new ID if multiple items were indented', () => { + const input = modelList( [ + '* 0', + ' * 1', + ' 2' + ] ); + + const fragment = parseModel( input, model.schema ); + const seenIds = new Set(); + + stubUid(); + + model.change( writer => { + fixListItemIds( iterateSiblingListBlocks( fragment.getChild( 0 ) ), seenIds, writer ); + } ); + + expect( stringifyModel( fragment ) ).to.equal( modelList( [ + '* 0', + ' * 1', + ' 2' + ] ) ); + } ); + + it( 'should update item ID if middle item of bigger block changed type', () => { + const input = modelList( [ + '* 0 {id:a}', + '# 1 {id:a}', + '* 2 {id:a}' + ], { ignoreIdConflicts: true } ); + + const fragment = parseModel( input, model.schema ); + const seenIds = new Set(); + + stubUid(); + + model.change( writer => { + fixListItemIds( iterateSiblingListBlocks( fragment.getChild( 0 ) ), seenIds, writer ); + } ); + + expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ + '* 0 {id:a}', + '# 1 {id:a00}', + '* 2 {id:a01}' + ] ) ); + } ); + + it( 'should use same new ID if multiple items changed type', () => { + const input = modelList( [ + '* 0 {id:a}', + '# 1 {id:a}', + '# 2 {id:a}' + ], { ignoreIdConflicts: true } ); + + const fragment = parseModel( input, model.schema ); + const seenIds = new Set(); + + stubUid(); + + model.change( writer => { + fixListItemIds( iterateSiblingListBlocks( fragment.getChild( 0 ) ), seenIds, writer ); + } ); + + expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ + '* 0 {id:a}', + '# 1 {id:a00}', + ' 2' + ] ) ); + } ); + + it( 'should fix ids of list with nested lists', () => { + const input = modelList( [ + '* 0 {id:a}', + '# 1 {id:a}', + ' * 2 {id:b}', + '# 3 {id:a}' + ], { ignoreIdConflicts: true } ); + + const fragment = parseModel( input, model.schema ); + const seenIds = new Set(); + + stubUid(); + + model.change( writer => { + fixListItemIds( iterateSiblingListBlocks( fragment.getChild( 0 ) ), seenIds, writer ); + } ); + + expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ + '* 0 {id:a}', + '# 1 {id:a00}', + ' * 2 {id:b}', + ' 3' + ] ) ); + } ); + + it( 'should fix ids of list with altered types of multiple items of a single bigger list item', () => { + const input = modelList( [ + '* 0{id:a}', + ' 1', + '# 2{id:a}', + ' 3', + '* 4{id:a}', + ' 5', + '# 6{id:a}', + ' 7' + ], { ignoreIdConflicts: true } ); + + const fragment = parseModel( input, model.schema ); + const seenIds = new Set(); + + stubUid(); + + model.change( writer => { + fixListItemIds( iterateSiblingListBlocks( fragment.getChild( 0 ) ), seenIds, writer ); + } ); + + expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ + '* 0{id:a}', + ' 1', + '# 2{id:a00}', + ' 3', + '* 4{id:a01}', + ' 5', + '# 6{id:a02}', + ' 7' + ] ) ); + } ); + + it( 'should use new ID if some ID was spot before in the other list', () => { + const input = modelList( [ + '* 0{id:a}', + ' * 1{id:b}', + ' 2' + ] ); + + const fragment = parseModel( input, model.schema ); + const seenIds = new Set(); + + stubUid(); + + seenIds.add( 'b' ); + + model.change( writer => { + fixListItemIds( iterateSiblingListBlocks( fragment.getChild( 0 ) ), seenIds, writer ); + } ); + + expect( stringifyModel( fragment ) ).to.equalMarkup( modelList( [ + '* 0{id:a}', + ' * 1{id:a00}', + ' 2' + ] ) ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-list/tests/documentlist/utils/view.js b/packages/ckeditor5-list/tests/documentlist/utils/view.js new file mode 100644 index 00000000000..93bd2e32af8 --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/utils/view.js @@ -0,0 +1,317 @@ +/** + * @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 { + createListElement, + createListItemElement, + getIndent, + getViewElementIdForListType, + getViewElementNameForListType, + isListItemView, + isListView +} from '../../../src/documentlist/utils/view'; + +import UpcastWriter from '@ckeditor/ckeditor5-engine/src/view/upcastwriter'; +import DowncastWriter from '@ckeditor/ckeditor5-engine/src/view/downcastwriter'; +import StylesProcessor from '@ckeditor/ckeditor5-engine/src/view/stylesmap'; +import Document from '@ckeditor/ckeditor5-engine/src/view/document'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import { parse as parseView } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; + +describe( 'DocumentList - utils - view', () => { + let viewUpcastWriter, viewDowncastWriter; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + const viewDocument = new Document( new StylesProcessor() ); + + viewUpcastWriter = new UpcastWriter( viewDocument ); + viewDowncastWriter = new DowncastWriter( viewDocument ); + } ); + + describe( 'isListView()', () => { + it( 'should return true for UL element', () => { + expect( isListView( viewUpcastWriter.createElement( 'ul' ) ) ).to.be.true; + } ); + + it( 'should return true for OL element', () => { + expect( isListView( viewUpcastWriter.createElement( 'ol' ) ) ).to.be.true; + } ); + + it( 'should return false for LI element', () => { + expect( isListView( viewUpcastWriter.createElement( 'li' ) ) ).to.be.false; + } ); + + it( 'should return false for other elements', () => { + expect( isListView( viewUpcastWriter.createElement( 'a' ) ) ).to.be.false; + expect( isListView( viewUpcastWriter.createElement( 'p' ) ) ).to.be.false; + expect( isListView( viewUpcastWriter.createElement( 'div' ) ) ).to.be.false; + } ); + } ); + + describe( 'isListItemView()', () => { + it( 'should return true for LI element', () => { + expect( isListItemView( viewUpcastWriter.createElement( 'li' ) ) ).to.be.true; + } ); + + it( 'should return false for UL element', () => { + expect( isListItemView( viewUpcastWriter.createElement( 'ul' ) ) ).to.be.false; + } ); + + it( 'should return false for OL element', () => { + expect( isListItemView( viewUpcastWriter.createElement( 'ol' ) ) ).to.be.false; + } ); + + it( 'should return false for other elements', () => { + expect( isListItemView( viewUpcastWriter.createElement( 'a' ) ) ).to.be.false; + expect( isListItemView( viewUpcastWriter.createElement( 'p' ) ) ).to.be.false; + expect( isListItemView( viewUpcastWriter.createElement( 'div' ) ) ).to.be.false; + } ); + } ); + + describe( 'getIndent()', () => { + it( 'should return 0 for flat list', () => { + const viewElement = parseView( + '
                  ' + + '
                • a
                • ' + + '
                • b
                • ' + + '
                ' + ); + + expect( getIndent( viewElement.getChild( 0 ) ) ).to.equal( 0 ); + expect( getIndent( viewElement.getChild( 1 ) ) ).to.equal( 0 ); + } ); + + it( 'should return 1 for first level nested items', () => { + const viewElement = parseView( + '
                  ' + + '
                • ' + + '
                    ' + + '
                  • a
                  • ' + + '
                  • b
                  • ' + + '
                  ' + + '
                • ' + + '
                • ' + + '
                    ' + + '
                  1. c
                  2. ' + + '
                  3. d
                  4. ' + + '
                  ' + + '
                • ' + + '
                ' + ); + + expect( getIndent( viewElement.getChild( 0 ).getChild( 0 ).getChild( 0 ) ) ).to.equal( 1 ); + expect( getIndent( viewElement.getChild( 0 ).getChild( 0 ).getChild( 1 ) ) ).to.equal( 1 ); + expect( getIndent( viewElement.getChild( 1 ).getChild( 0 ).getChild( 0 ) ) ).to.equal( 1 ); + expect( getIndent( viewElement.getChild( 1 ).getChild( 0 ).getChild( 1 ) ) ).to.equal( 1 ); + } ); + + it( 'should ignore container elements', () => { + const viewElement = parseView( + '
                  ' + + '
                • ' + + '
                  ' + + '
                    ' + + '
                  • a
                  • ' + + '
                  • b
                  • ' + + '
                  ' + + '
                  ' + + '
                • ' + + '
                • ' + + '
                    ' + + '
                  • c
                  • ' + + '
                  • d
                  • ' + + '
                  ' + + '
                • ' + + '
                ' + ); + + expect( getIndent( viewElement.getChild( 0 ).getChild( 0 ).getChild( 0 ).getChild( 0 ) ) ).to.equal( 1 ); + expect( getIndent( viewElement.getChild( 0 ).getChild( 0 ).getChild( 0 ).getChild( 1 ) ) ).to.equal( 1 ); + expect( getIndent( viewElement.getChild( 1 ).getChild( 0 ).getChild( 0 ) ) ).to.equal( 1 ); + expect( getIndent( viewElement.getChild( 1 ).getChild( 0 ).getChild( 1 ) ) ).to.equal( 1 ); + } ); + + it( 'should handle deep nesting', () => { + const viewElement = parseView( + '
                  ' + + '
                • ' + + '
                    ' + + '
                  1. ' + + '
                      ' + + '
                    • a
                    • ' + + '
                    • b
                    • ' + + '
                    ' + + '
                  2. ' + + '
                  ' + + '
                • ' + + '
                ' + ); + + const innerList = viewElement.getChild( 0 ).getChild( 0 ).getChild( 0 ).getChild( 0 ); + + expect( getIndent( innerList.getChild( 0 ) ) ).to.equal( 2 ); + expect( getIndent( innerList.getChild( 1 ) ) ).to.equal( 2 ); + } ); + + it( 'should ignore superfluous OLs', () => { + const viewElement = parseView( + '
                  ' + + '
                • ' + + '
                    ' + + '
                      ' + + '
                        ' + + '
                          ' + + '
                        1. a
                        2. ' + + '
                        ' + + '
                      ' + + '
                    ' + + '
                  1. b
                  2. ' + + '
                  ' + + '
                • ' + + '
                ' + ); + + const innerList = viewElement.getChild( 0 ).getChild( 0 ).getChild( 0 ).getChild( 0 ).getChild( 0 ); + + expect( getIndent( innerList.getChild( 0 ) ) ).to.equal( 1 ); + expect( getIndent( viewElement.getChild( 0 ).getChild( 0 ).getChild( 1 ) ) ).to.equal( 1 ); + } ); + + it( 'should handle broken structure', () => { + const viewElement = parseView( + '
                  ' + + '
                • a
                • ' + + '
                    ' + + '
                  • b
                  • ' + + '
                  ' + + '
                ' + ); + + expect( getIndent( viewElement.getChild( 0 ) ) ).to.equal( 0 ); + expect( getIndent( viewElement.getChild( 1 ).getChild( 0 ) ) ).to.equal( 1 ); + } ); + + it( 'should handle broken deeper structure', () => { + const viewElement = parseView( + '
                  ' + + '
                • a
                • ' + + '
                    ' + + '
                  1. b
                  2. ' + + '
                      ' + + '
                    • c
                    • ' + + '
                    ' + + '
                  ' + + '
                ' + ); + + expect( getIndent( viewElement.getChild( 0 ) ) ).to.equal( 0 ); + expect( getIndent( viewElement.getChild( 1 ).getChild( 0 ) ) ).to.equal( 1 ); + expect( getIndent( viewElement.getChild( 1 ).getChild( 1 ).getChild( 0 ) ) ).to.equal( 2 ); + } ); + } ); + + describe( 'createListElement()', () => { + it( 'should create an attribute element for numbered list', () => { + const element = createListElement( viewDowncastWriter, 0, 'numbered' ); + + expect( element.is( 'attributeElement', 'ol' ) ).to.be.true; + } ); + + it( 'should create an attribute element for bulleted list', () => { + const element = createListElement( viewDowncastWriter, 0, 'bulleted' ); + + expect( element.is( 'attributeElement', 'ul' ) ).to.be.true; + } ); + + it( 'should create an attribute element OL for other list types', () => { + const element = createListElement( viewDowncastWriter, 0, 'something' ); + + expect( element.is( 'attributeElement', 'ul' ) ).to.be.true; + } ); + + it( 'should use priority related to indent', () => { + let previousPriority = Number.NEGATIVE_INFINITY; + + for ( let i = 0; i < 20; i++ ) { + const element = createListElement( viewDowncastWriter, i, 'abc' ); + + expect( element.priority ).to.be.greaterThan( previousPriority ); + expect( element.priority ).to.be.lessThan( 80 ); + + previousPriority = element.priority; + } + } ); + } ); + + describe( 'createListItemElement()', () => { + it( 'should create an attribute element with given ID', () => { + const element = createListItemElement( viewDowncastWriter, 0, 'abc' ); + + expect( element.is( 'attributeElement', 'li' ) ).to.be.true; + expect( element.id ).to.equal( 'abc' ); + } ); + + it( 'should use priority related to indent', () => { + let previousPriority = Number.NEGATIVE_INFINITY; + + for ( let i = 0; i < 20; i++ ) { + const element = createListItemElement( viewDowncastWriter, i, 'abc' ); + + expect( element.priority ).to.be.greaterThan( previousPriority ); + expect( element.priority ).to.be.lessThan( 80 ); + + previousPriority = element.priority; + } + } ); + + it( 'priorities of LI and UL should interleave between nesting levels', () => { + let previousPriority = Number.NEGATIVE_INFINITY; + + for ( let i = 0; i < 20; i++ ) { + const listElement = createListElement( viewDowncastWriter, i, 'abc', '123' ); + const listItemElement = createListItemElement( viewDowncastWriter, i, 'aaaa' ); + + expect( listElement.priority ).to.be.greaterThan( previousPriority ); + expect( listElement.priority ).to.be.lessThan( 80 ); + + previousPriority = listElement.priority; + + expect( listItemElement.priority ).to.be.greaterThan( previousPriority ); + expect( listItemElement.priority ).to.be.lessThan( 80 ); + + previousPriority = listItemElement.priority; + } + } ); + } ); + + describe( 'getViewElementNameForListType()', () => { + it( 'should return "ol" for numbered type', () => { + expect( getViewElementNameForListType( 'numbered' ) ).to.equal( 'ol' ); + } ); + + it( 'should return "ul" for bulleted type', () => { + expect( getViewElementNameForListType( 'bulleted' ) ).to.equal( 'ul' ); + } ); + + it( 'should return "ul" for other types', () => { + expect( getViewElementNameForListType( 'foo' ) ).to.equal( 'ul' ); + expect( getViewElementNameForListType( 'bar' ) ).to.equal( 'ul' ); + expect( getViewElementNameForListType( 'sth' ) ).to.equal( 'ul' ); + } ); + } ); + + describe( 'getViewElementIdForListType()', () => { + it( 'should generate view element ID for the given list type and indent', () => { + expect( getViewElementIdForListType( 'bulleted', 0 ) ).to.equal( 'list-bulleted-0' ); + expect( getViewElementIdForListType( 'bulleted', 1 ) ).to.equal( 'list-bulleted-1' ); + expect( getViewElementIdForListType( 'bulleted', 2 ) ).to.equal( 'list-bulleted-2' ); + expect( getViewElementIdForListType( 'numbered', 0 ) ).to.equal( 'list-numbered-0' ); + expect( getViewElementIdForListType( 'numbered', 1 ) ).to.equal( 'list-numbered-1' ); + expect( getViewElementIdForListType( 'numbered', 2 ) ).to.equal( 'list-numbered-2' ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-list/tests/documentlistproperties/converters.js b/packages/ckeditor5-list/tests/documentlistproperties/converters.js new file mode 100644 index 00000000000..f1d8f993e8f --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlistproperties/converters.js @@ -0,0 +1,2597 @@ +/** + * @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 BoldEditing from '@ckeditor/ckeditor5-basic-styles/src/bold/boldediting'; +import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting'; +import ClipboardPipeline from '@ckeditor/ckeditor5-clipboard/src/clipboardpipeline'; +import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting'; +import HeadingEditing from '@ckeditor/ckeditor5-heading/src/headingediting'; +import IndentEditing from '@ckeditor/ckeditor5-indent/src/indentediting'; +import TableEditing from '@ckeditor/ckeditor5-table/src/tableediting'; +import AlignmentEditing from '@ckeditor/ckeditor5-alignment/src/alignmentediting'; +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 { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; +import stubUid from '../documentlist/_utils/uid'; +import DocumentListPropertiesEditing from '../../src/documentlistproperties/documentlistpropertiesediting'; +import { modelList, setupTestHelpers } from '../documentlist/_utils/utils'; + +describe( 'DocumentListPropertiesEditing - converters', () => { + let editor, model, modelDoc, modelRoot, view, test; + + testUtils.createSinonSandbox(); + + describe( 'list style', () => { + beforeEach( () => setupEditor( { + list: { + properties: { + styles: true, + startIndex: false, + reversed: false + } + } + } ) ); + + afterEach( async () => { + await editor.destroy(); + } ); + + describe( 'data pipeline', () => { + beforeEach( () => { + stubUid( 0 ); + } ); + + it( 'should convert single list (type: bulleted)', () => { + test.data( + '
                  ' + + '
                • Foo
                • ' + + '
                • Bar
                • ' + + '
                ', + + modelList( ` + * Foo {style:default} + * Bar + ` ) + ); + } ); + + it( 'should convert single list (type: numbered)', () => { + test.data( + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ', + + modelList( ` + # Foo {style:default} + # Bar + ` ) + ); + } ); + + it( 'should convert single list (type: bulleted, style: circle)', () => { + test.data( + '
                  ' + + '
                • Foo
                • ' + + '
                • Bar
                • ' + + '
                ', + + modelList( ` + * Foo {style:circle} + * Bar + ` ) + ); + } ); + + it( 'should convert single list (type: numbered, style: upper-alpha)', () => { + test.data( + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ', + + modelList( ` + # Foo {style:upper-alpha} + # Bar + ` ) + ); + } ); + + it( 'should convert mixed lists', () => { + test.data( + '
                  ' + + '
                1. OL 1
                2. ' + + '
                3. OL 2
                4. ' + + '
                ' + + '
                  ' + + '
                • UL 1
                • ' + + '
                • UL 2
                • ' + + '
                ', + + modelList( ` + # OL 1 {style:upper-alpha} + # OL 2 + * UL 1 {style:circle} + * UL 2 + ` ) + ); + } ); + + it( 'should convert nested and mixed lists', () => { + test.data( + '
                  ' + + '
                1. OL 1
                2. ' + + '
                3. OL 2' + + '
                    ' + + '
                  • UL 1
                  • ' + + '
                  • UL 2
                  • ' + + '
                  ' + + '
                4. ' + + '
                5. OL 3
                6. ' + + '
                ', + + modelList( ` + # OL 1 {id:000} {style:upper-alpha} + # OL 2 {id:003} + * UL 1 {id:001} {style:circle} + * UL 2 {id:002} + # OL 3 {id:004} + ` ) + ); + } ); + + it( 'should convert when the list is in the middle of the content', () => { + test.data( + '

                Paragraph.

                ' + + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ' + + '

                Paragraph.

                ', + + modelList( ` + Paragraph. + # Foo {id:000} {style:upper-alpha} + # Bar {id:001} + Paragraph. + ` ) + ); + } ); + + it( 'should convert style on a nested list', () => { + test.data( + '
                  ' + + '
                • ' + + 'cd' + + '
                    ' + + '
                  1. efg
                  2. ' + + '
                  ' + + '
                • ' + + '
                ', + + modelList( ` + * cd {id:001} {style:default} + # efg {id:000} {style:upper-alpha} + ` ) + ); + } ); + + it( 'view ol converter should not fire if change was already consumed', () => { + editor.data.upcastDispatcher.on( 'element:ol', ( evt, data, conversionApi ) => { + conversionApi.consumable.consume( data.viewItem, { styles: 'list-style-type' } ); + }, { priority: 'highest' } ); + + test.data( + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ', + + modelList( ` + # Foo {style:default} + # Bar + ` ), + + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ' + ); + } ); + + it( 'view ul converter should not fire if change was already consumed', () => { + editor.data.upcastDispatcher.on( 'element:ul', ( evt, data, conversionApi ) => { + conversionApi.consumable.consume( data.viewItem, { styles: 'list-style-type' } ); + }, { priority: 'highest' } ); + + test.data( + '
                  ' + + '
                • Foo
                • ' + + '
                • Bar
                • ' + + '
                ', + + modelList( ` + * Foo {style:default} + * Bar + ` ), + + '
                  ' + + '
                • Foo
                • ' + + '
                • Bar
                • ' + + '
                ' + ); + } ); + + it( 'should use modeRange provided from higher priority converter', () => { + editor.data.upcastDispatcher.on( 'element:ol', ( evt, data, conversionApi ) => { + const { modelRange, modelCursor } = conversionApi.convertChildren( data.viewItem, data.modelCursor ); + + data.modelRange = modelRange; + data.modelCursor = modelCursor; + }, { priority: 'highest' } ); + + test.data( + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ', + + modelList( ` + # Foo {style:upper-alpha} + # Bar + ` ), + + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ' + ); + } ); + + it( 'should not apply attribute on elements that does not accept it', () => { + model.schema.register( 'block', { + allowWhere: '$block', + allowContentOf: '$block' + } ); + editor.conversion.elementToElement( { view: 'div', model: 'block' } ); + + test.data( + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. x
                4. ' + + '
                5. Bar
                6. ' + + '
                ', + + modelList( ` + # Foo {style:upper-alpha} + x + # Bar {style:upper-alpha} + ` ), + + '
                  ' + + '
                1. Foo
                2. ' + + '
                ' + + '
                x
                ' + + '
                  ' + + '
                1. Bar
                2. ' + + '
                ' + ); + } ); + + it( 'should not consume attribute while upcasting if not applied', () => { + const spy = sinon.spy(); + + model.schema.addAttributeCheck( ( ctx, attributeName ) => attributeName != 'listStyle' ); + editor.conversion.for( 'upcast' ).add( + dispatcher => dispatcher.on( 'element:ol', ( evt, data, conversionApi ) => { + expect( conversionApi.consumable.test( data.viewItem, { styles: 'list-style-type' } ) ).to.be.true; + spy(); + }, { priority: 'lowest' } ) + ); + + test.data( + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ', + + modelList( ` + # Foo {style:default} + # Bar + ` ), + + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ' + ); + + expect( spy.calledOnce ).to.be.true; + } ); + + describe( 'list conversion with surrounding text nodes', () => { + it( 'should convert a list if raw text is before the list', () => { + test.data( + 'Foo' + + '
                • Bar
                ', + + modelList( ` + Foo + * Bar {id:000} {style:square} + ` ), + + '

                Foo

                ' + + '
                • Bar
                ' + ); + } ); + + it( 'should convert a list if raw text is after the list', () => { + test.data( + '
                • Foo
                ' + + 'Bar', + + modelList( ` + * Foo {style:square} + Bar + ` ), + + '
                • Foo
                ' + + '

                Bar

                ' + ); + } ); + + it( 'should convert a list if it is surrounded by two text nodes', () => { + test.data( + 'Foo' + + '
                • Bar
                ' + + 'Baz', + + modelList( ` + Foo + * Bar {id:000} {style:square} + Baz + ` ), + + '

                Foo

                ' + + '
                • Bar
                ' + + '

                Baz

                ' + ); + } ); + } ); + + describe( 'copy and getSelectedContent()', () => { + it( 'should be able to downcast part of a nested list', () => { + setModelData( model, modelList( ` + * A + * [B1 {style:circle} + B2 + * C1] {style:square} + 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, modelList( ` + * A + * B1 {style:circle} + B2 + * [C1 {style:square} + 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( 'editing pipeline', () => { + describe( 'insert', () => { + it( 'should convert single list (type: bulleted, style: default)', () => { + test.insert( + modelList( ` + x + * [Foo {style:default} + * Bar] + ` ), + + '

                x

                ' + + '
                  ' + + '
                • Foo
                • ' + + '
                • Bar
                • ' + + '
                ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'should convert single list (type: bulleted, style: circle)', () => { + test.insert( + modelList( ` + x + * [Foo {style:circle} + * Bar] + ` ), + + '

                x

                ' + + '
                  ' + + '
                • Foo
                • ' + + '
                • Bar
                • ' + + '
                ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'should convert nested bulleted list (main: circle, nested: disc)', () => { + test.insert( + modelList( ` + x + * [Foo 1 {style:circle} + * Bar 1 {style:disc} + * Bar 2 + * Foo 2 + * Foo 3] + ` ), + + '

                x

                ' + + '
                  ' + + '
                • ' + + 'Foo 1' + + '
                    ' + + '
                  • Bar 1
                  • ' + + '
                  • Bar 2
                  • ' + + '
                  ' + + '
                • ' + + '
                • Foo 2
                • ' + + '
                • Foo 3
                • ' + + '
                ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'should convert properly nested list styles', () => { + // ■ Level 0 + // ▶ Level 0.1 + // ○ Level 0.1.1 + // ▶ Level 0.2 + // ○ Level 0.2.1 + test.insert( + modelList( ` + x + * [Level 0 {style:default} + * Level 0.1 {style:default} + * Level 0.1.1 {style:circle} + * Level 0.2 + * Level 0.2.1] {style:circle} + ` ), + + '

                x

                ' + + '
                  ' + + '
                • Level 0' + + '
                    ' + + '
                  • Level 0.1' + + '
                      ' + + '
                    • Level 0.1.1
                    • ' + + '
                    ' + + '
                  • ' + + '
                  • Level 0.2' + + '
                      ' + + '
                    • Level 0.2.1
                    • ' + + '
                    ' + + '
                  • ' + + '
                  ' + + '
                • ' + + '
                ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'insert with attributes in a specific order', () => { + test.insert( + modelList( ` + p + [a + b + c] + ` ), + + '

                p

                ' + + '
                  ' + + '
                • a
                • ' + + '
                • b
                • ' + + '
                • c
                • ' + + '
                ' + ); + } ); + } ); + + describe( 'remove', () => { + it( 'remove a list item', () => { + test.remove( + 'p' + + '[a]' + + 'b' + + 'c', + + '

                p

                ' + + '
                  ' + + '
                • b
                • ' + + '
                • c
                • ' + + '
                ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + } ); + + describe( 'set list style', () => { + it( 'on a flat list', () => { + const input = modelList( ` + * [a + * b] + ` ); + + const output = + '
                  ' + + '
                • a
                • ' + + '
                • b
                • ' + + '
                '; + + test.test( input, output, selection => { + model.change( writer => { + writer.setAttribute( 'listStyle', 'circle', selection.getFirstRange() ); + } ); + } ); + } ); + + it( 'on a list with nested lists', () => { + const input = modelList( ` + * [a {style:default} + * b {style:default} + * c] + ` ); + + const output = + '
                  ' + + '
                • a' + + '
                    ' + + '
                  • b
                  • ' + + '
                  ' + + '
                • ' + + '
                • c
                • ' + + '
                '; + + test.test( input, output, selection => { + model.change( writer => { + for ( const item of selection.getFirstRange().getItems( { shallow: true } ) ) { + if ( item.getAttribute( 'listIndent' ) == 0 ) { + writer.setAttribute( 'listStyle', 'circle', item ); + } + } + } ); + } ); + } ); + } ); + + describe( 'remove list style', () => { + it( 'on a flat list', () => { + const input = modelList( ` + * [a {style:foo} + * b] + ` ); + + const output = + '
                  ' + + '
                • a
                • ' + + '
                • b
                • ' + + '
                '; + + test.test( input, output, selection => { + model.change( writer => { + writer.removeAttribute( 'listStyle', selection.getFirstRange() ); + } ); + } ); + } ); + + it( 'on a list with nested lists', () => { + const input = modelList( ` + * [a {style:square} + * b {style:disc} + * c] + ` ); + + const output = + '
                  ' + + '
                • ' + + 'a' + + '
                    ' + + '
                  • b
                  • ' + + '
                  ' + + '
                • ' + + '
                • c
                • ' + + '
                '; + + test.test( input, output, selection => { + model.change( writer => { + for ( const item of selection.getFirstRange().getItems( { shallow: true } ) ) { + if ( item.getAttribute( 'listIndent' ) == 0 ) { + writer.removeAttribute( 'listStyle', item ); + } + } + } ); + } ); + } ); + + it( 'and all other list attributes', () => { + const input = modelList( ` + * [a {style:foo} + b] + ` ); + + const output = + '

                a

                ' + + '

                b

                '; + + test.test( input, output, selection => { + model.change( writer => { + writer.removeAttribute( 'listStyle', selection.getFirstRange() ); + writer.removeAttribute( 'listIndent', selection.getFirstRange() ); + writer.removeAttribute( 'listItemId', selection.getFirstRange() ); + writer.removeAttribute( 'listType', selection.getFirstRange() ); + } ); + } ); + } ); + } ); + + describe( 'change list style', () => { + it( 'on a flat list', () => { + const input = modelList( ` + * [a {style:disc} + * b] + ` ); + + const output = + '
                  ' + + '
                • a
                • ' + + '
                • b
                • ' + + '
                '; + + test.test( input, output, selection => { + model.change( writer => { + writer.setAttribute( 'listStyle', 'circle', selection.getFirstRange() ); + } ); + } ); + } ); + + it( 'on a list with nested lists', () => { + const input = modelList( ` + * [a {style:square} + * b {style:disc} + * c] + ` ); + + const output = + '
                  ' + + '
                • ' + + 'a' + + '
                    ' + + '
                  • b
                  • ' + + '
                  ' + + '
                • ' + + '
                • c
                • ' + + '
                '; + + test.test( input, output, selection => { + model.change( writer => { + for ( const item of selection.getFirstRange().getItems( { shallow: true } ) ) { + if ( item.getAttribute( 'listIndent' ) == 0 ) { + writer.setAttribute( 'listStyle', 'circle', item ); + } + } + } ); + } ); + } ); + } ); + + describe( 'change list type', () => { + it( 'on a flat list', () => { + const input = modelList( ` + * [a {style:circle} + * b] + ` ); + + const output = + '
                  ' + + '
                1. a
                2. ' + + '
                3. b
                4. ' + + '
                '; + + test.test( input, output, selection => { + model.change( writer => { + writer.setAttribute( 'listType', 'numbered', selection.getFirstRange() ); + } ); + } ); + } ); + + it( 'on a list with nested lists', () => { + const input = modelList( ` + * [a {style:circle} + * b {style:disc} + * c] + ` ); + + const output = + '
                  ' + + '
                1. ' + + 'a' + + '
                    ' + + '
                  • b
                  • ' + + '
                  ' + + '
                2. ' + + '
                3. c
                4. ' + + '
                '; + + test.test( input, output, selection => { + model.change( writer => { + for ( const item of selection.getFirstRange().getItems( { shallow: true } ) ) { + if ( item.getAttribute( 'listIndent' ) == 0 ) { + writer.setAttribute( 'listType', 'numbered', item ); + } + } + } ); + } ); + } ); + } ); + + describe( 'change list indent', () => { + it( 'should update list attribute elements', () => { + const input = modelList( [ + '* a', + '* [b', + ' # c] {style:upper-roman}' + ] ); + + const output = + '
                  ' + + '
                • ' + + 'a' + + '
                    ' + + '
                  • ' + + 'b' + + '
                      ' + + '
                    1. c
                    2. ' + + '
                    ' + + '
                  • ' + + '
                  ' + + '
                • ' + + '
                '; + + test.test( input, output, selection => { + model.change( writer => { + for ( const item of selection.getFirstRange().getItems( { shallow: true } ) ) { + writer.setAttribute( 'listIndent', item.getAttribute( 'listIndent' ) + 1, item ); + } + } ); + } ); + } ); + } ); + + describe( 'consuming', () => { + it( 'should not convert attribute if it was already consumed', () => { + editor.editing.downcastDispatcher.on( 'attribute:listStyle', ( evt, data, conversionApi ) => { + conversionApi.consumable.consume( data.item, evt.name ); + }, { priority: 'highest' } ); + + setModelData( model, + 'a' + ); + + model.change( writer => { + writer.setAttribute( 'listStyle', 'circle', modelRoot.getChild( 0 ) ); + } ); + + expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( + '
                  ' + + '
                • a
                • ' + + '
                ' + ); + } ); + } ); + } ); + } ); + + describe( 'list reversed', () => { + beforeEach( () => setupEditor( { + list: { + properties: { + styles: false, + startIndex: false, + reversed: true + } + } + } ) ); + + afterEach( async () => { + await editor.destroy(); + } ); + + describe( 'data pipeline', () => { + beforeEach( () => { + stubUid( 0 ); + } ); + + it( 'should convert single list (type: bulleted)', () => { + test.data( + '
                  ' + + '
                • Foo
                • ' + + '
                • Bar
                • ' + + '
                ', + + modelList( ` + * Foo + * Bar + ` ) + ); + } ); + + it( 'should convert single list (type: numbered)', () => { + test.data( + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ', + + modelList( ` + # Foo {reversed:false} + # Bar + ` ) + ); + } ); + + it( 'should not convert on bulleted single list (type: bulleted)', () => { + test.data( + '
                  ' + + '
                • Foo
                • ' + + '
                • Bar
                • ' + + '
                ', + + modelList( ` + * Foo + * Bar + ` ), + + '
                  ' + + '
                • Foo
                • ' + + '
                • Bar
                • ' + + '
                ' + ); + } ); + + it( 'should convert single list (type: numbered, reversed)', () => { + test.data( + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ', + + modelList( ` + # Foo {reversed:true} + # Bar + ` ) + ); + } ); + + it( 'should convert when the list is in the middle of the content', () => { + test.data( + '

                Paragraph.

                ' + + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ' + + '

                Paragraph.

                ', + + modelList( ` + Paragraph. + # Foo {id:000} {reversed:true} + # Bar {id:001} + Paragraph. + ` ), + + '

                Paragraph.

                ' + + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ' + + '

                Paragraph.

                ' + ); + } ); + + it( 'should convert on a nested list (in bulleted list)', () => { + test.data( + '
                  ' + + '
                • ' + + 'cd' + + '
                    ' + + '
                  1. efg
                  2. ' + + '
                  ' + + '
                • ' + + '
                ', + + modelList( ` + * cd {id:001} + # efg {id:000} {reversed:true} + ` ) + ); + } ); + + it( 'should convert on a nested list (in numbered list)', () => { + test.data( + '
                  ' + + '
                1. ' + + 'cd' + + '
                    ' + + '
                  1. efg
                  2. ' + + '
                  ' + + '
                2. ' + + '
                ', + + modelList( ` + # cd {id:001} {reversed:false} + # efg {id:000} {reversed:true} + ` ) + ); + } ); + + it( 'view ol converter should not fire if change was already consumed', () => { + editor.data.upcastDispatcher.on( 'element:ol', ( evt, data, conversionApi ) => { + conversionApi.consumable.consume( data.viewItem, { attributes: 'reversed' } ); + }, { priority: 'highest' } ); + + test.data( + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ', + + modelList( ` + # Foo {reversed:false} + # Bar + ` ), + + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ' + ); + } ); + + it( 'should use modeRange provided from higher priority converter', () => { + editor.data.upcastDispatcher.on( 'element:ol', ( evt, data, conversionApi ) => { + const { modelRange, modelCursor } = conversionApi.convertChildren( data.viewItem, data.modelCursor ); + + data.modelRange = modelRange; + data.modelCursor = modelCursor; + }, { priority: 'highest' } ); + + test.data( + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ', + + modelList( ` + # Foo {reversed:true} + # Bar + ` ) + ); + } ); + + it( 'should not apply attribute on elements that does not accept it', () => { + model.schema.register( 'block', { + allowWhere: '$block', + allowContentOf: '$block' + } ); + editor.conversion.elementToElement( { view: 'div', model: 'block' } ); + + test.data( + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. x
                4. ' + + '
                5. Bar
                6. ' + + '
                ', + + modelList( ` + # Foo {reversed:true} + x + # Bar {reversed:true} + ` ), + + '
                  ' + + '
                1. Foo
                2. ' + + '
                ' + + '
                x
                ' + + '
                  ' + + '
                1. Bar
                2. ' + + '
                ' + ); + } ); + + it( 'should not consume attribute while upcasting if not applied', () => { + const spy = sinon.spy(); + + model.schema.addAttributeCheck( ( ctx, attributeName ) => attributeName != 'listReversed' ); + editor.conversion.for( 'upcast' ).add( + dispatcher => dispatcher.on( 'element:ol', ( evt, data, conversionApi ) => { + expect( conversionApi.consumable.test( data.viewItem, { attributes: 'reversed' } ) ).to.be.true; + spy(); + }, { priority: 'lowest' } ) + ); + + test.data( + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ', + + modelList( ` + # Foo {reversed:false} + # Bar + ` ), + + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ' + ); + + expect( spy.calledOnce ).to.be.true; + } ); + + describe( 'copy and getSelectedContent()', () => { + it( 'should be able to downcast part of a nested list', () => { + setModelData( model, modelList( ` + # A + # [B1 {reversed:true} + B2 + # C1] {reversed:false} + 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( + '
                  ' + + '
                1. ' + + '

                  B1

                  ' + + '

                  B2

                  ' + + '
                    ' + + '
                  1. C1
                  2. ' + + '
                  ' + + '
                2. ' + + '
                ' + ); + } ); + + it( 'should be able to downcast part of a deep nested list', () => { + setModelData( model, modelList( ` + # A + # B1 {reversed:true} + B2 + # [C1 {reversed:true} + 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( + '
                  ' + + '
                1. ' + + '

                  C1

                  ' + + '

                  C2

                  ' + + '
                2. ' + + '
                ' + ); + } ); + } ); + } ); + + describe( 'editing pipeline', () => { + describe( 'insert', () => { + it( 'should convert single list (type: numbered, reversed: false)', () => { + test.insert( + modelList( ` + x + # [Foo {reversed:false} + # Bar] + ` ), + + '

                x

                ' + + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'should convert single list (type: numbered, reversed:true)', () => { + test.insert( + modelList( ` + x + # [Foo {reversed:true} + # Bar] + ` ), + + '

                x

                ' + + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'should convert nested numbered list', () => { + test.insert( + modelList( ` + x + # [Foo 1 {reversed:false} + # Bar 1 {reversed:true} + # Bar 2 + # Foo 2 + # Foo 3] + ` ), + + '

                x

                ' + + '
                  ' + + '
                1. ' + + 'Foo 1' + + '
                    ' + + '
                  1. Bar 1
                  2. ' + + '
                  3. Bar 2
                  4. ' + + '
                  ' + + '
                2. ' + + '
                3. Foo 2
                4. ' + + '
                5. Foo 3
                6. ' + + '
                ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'should convert properly nested list', () => { + // ■ Level 0 + // ▶ Level 0.1 + // ○ Level 0.1.1 + // ▶ Level 0.2 + // ○ Level 0.2.1 + test.insert( + modelList( ` + x + # [Level 0 {reversed:false} + # Level 0.1 {reversed:false} + # Level 0.1.1 {reversed:true} + # Level 0.2 + # Level 0.2.1] {reversed:true} + ` ), + + '

                x

                ' + + '
                  ' + + '
                1. Level 0' + + '
                    ' + + '
                  1. Level 0.1' + + '
                      ' + + '
                    1. Level 0.1.1
                    2. ' + + '
                    ' + + '
                  2. ' + + '
                  3. Level 0.2' + + '
                      ' + + '
                    1. Level 0.2.1
                    2. ' + + '
                    ' + + '
                  4. ' + + '
                  ' + + '
                2. ' + + '
                ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'should unwrap list item only if it was really wrapped (there was no wrapper for the reversed:false)', () => { + test.insert( + modelList( ` + x + * [cd + # efg] {reversed:true} + ` ), + + '

                x

                ' + + '
                  ' + + '
                • ' + + 'cd' + + '
                    ' + + '
                  1. efg
                  2. ' + + '
                  ' + + '
                • ' + + '
                ' + ); + } ); + } ); + + describe( 'remove', () => { + it( 'remove a list item', () => { + test.remove( + modelList( ` + p + # [a] {reversed:true} + # b + # c + ` ), + + '

                p

                ' + + '
                  ' + + '
                1. b
                2. ' + + '
                3. c
                4. ' + + '
                ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + } ); + + describe( 'set list reversed', () => { + it( 'on a flat list', () => { + const input = modelList( ` + # [a + # b] + ` ); + + const output = + '
                  ' + + '
                1. a
                2. ' + + '
                3. b
                4. ' + + '
                '; + + test.test( input, output, selection => { + model.change( writer => { + writer.setAttribute( 'listReversed', true, selection.getFirstRange() ); + } ); + } ); + } ); + + it( 'on a list with nested lists', () => { + const input = modelList( ` + # [a {reversed:false} + # b {reversed:false} + # c] + ` ); + + const output = + '
                  ' + + '
                1. a' + + '
                    ' + + '
                  1. b
                  2. ' + + '
                  ' + + '
                2. ' + + '
                3. c
                4. ' + + '
                '; + + test.test( input, output, selection => { + model.change( writer => { + for ( const item of selection.getFirstRange().getItems( { shallow: true } ) ) { + if ( item.getAttribute( 'listIndent' ) == 0 ) { + writer.setAttribute( 'listReversed', true, item ); + } + } + } ); + } ); + } ); + } ); + + describe( 'remove list reversed', () => { + it( 'on a flat list', () => { + const input = modelList( ` + # [a {reversed:true} + # b] + ` ); + + const output = + '
                  ' + + '
                1. a
                2. ' + + '
                3. b
                4. ' + + '
                '; + + test.test( input, output, selection => { + model.change( writer => { + writer.removeAttribute( 'listReversed', selection.getFirstRange() ); + } ); + } ); + } ); + + it( 'on a list with nested lists', () => { + const input = modelList( ` + # [a {reversed:true} + # b {reversed:true} + # c] + ` ); + + const output = + '
                  ' + + '
                1. ' + + 'a' + + '
                    ' + + '
                  1. b
                  2. ' + + '
                  ' + + '
                2. ' + + '
                3. c
                4. ' + + '
                '; + + test.test( input, output, selection => { + model.change( writer => { + for ( const item of selection.getFirstRange().getItems( { shallow: true } ) ) { + if ( item.getAttribute( 'listIndent' ) == 0 ) { + writer.removeAttribute( 'listReversed', item ); + } + } + } ); + } ); + } ); + } ); + + describe( 'change list type', () => { + it( 'on a flat list', () => { + const input = modelList( ` + * [a + * b] + ` ); + + const output = + '
                  ' + + '
                1. a
                2. ' + + '
                3. b
                4. ' + + '
                '; + + test.test( input, output, selection => { + model.change( writer => { + writer.setAttribute( 'listType', 'numbered', selection.getFirstRange() ); + } ); + } ); + } ); + + it( 'on a list with nested lists', () => { + const input = modelList( ` + * [a + # b {reversed:true} + * c] + ` ); + + const output = + '
                  ' + + '
                1. ' + + 'a' + + '
                    ' + + '
                  1. b
                  2. ' + + '
                  ' + + '
                2. ' + + '
                3. c
                4. ' + + '
                '; + + test.test( input, output, selection => { + model.change( writer => { + for ( const item of selection.getFirstRange().getItems( { shallow: true } ) ) { + if ( item.getAttribute( 'listIndent' ) == 0 ) { + writer.setAttribute( 'listType', 'numbered', item ); + } + } + } ); + } ); + } ); + } ); + + describe( 'change list indent', () => { + it( 'should update list attribute elements', () => { + const input = modelList( [ + '* a', + '* [b', + ' # c] {reversed:true}' + ] ); + + const output = + '
                  ' + + '
                • ' + + 'a' + + '
                    ' + + '
                  • ' + + 'b' + + '
                      ' + + '
                    1. c
                    2. ' + + '
                    ' + + '
                  • ' + + '
                  ' + + '
                • ' + + '
                '; + + test.test( input, output, selection => { + model.change( writer => { + for ( const item of selection.getFirstRange().getItems( { shallow: true } ) ) { + writer.setAttribute( 'listIndent', item.getAttribute( 'listIndent' ) + 1, item ); + } + } ); + } ); + } ); + } ); + + describe( 'consuming', () => { + it( 'should not convert attribute if it was already consumed', () => { + editor.editing.downcastDispatcher.on( 'attribute:listReversed', ( evt, data, conversionApi ) => { + conversionApi.consumable.consume( data.item, evt.name ); + }, { priority: 'highest' } ); + + setModelData( model, + 'a' + ); + + model.change( writer => { + writer.setAttribute( 'listReversed', true, modelRoot.getChild( 0 ) ); + } ); + + expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( + '
                  ' + + '
                1. a
                2. ' + + '
                ' + ); + } ); + } ); + } ); + } ); + + describe( 'list start index', () => { + beforeEach( () => setupEditor( { + list: { + properties: { + styles: false, + startIndex: true, + reversed: false + } + } + } ) ); + + afterEach( async () => { + await editor.destroy(); + } ); + + describe( 'data pipeline', () => { + beforeEach( () => { + stubUid( 0 ); + } ); + + it( 'should convert single list (type: bulleted)', () => { + test.data( + '
                  ' + + '
                • Foo
                • ' + + '
                • Bar
                • ' + + '
                ', + + modelList( ` + * Foo + * Bar + ` ) + ); + } ); + + it( 'should convert single list (type: numbered)', () => { + test.data( + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ', + + modelList( ` + # Foo {start:1} + # Bar + ` ) + ); + } ); + + it( 'should not convert on bulleted single list (type: bulleted)', () => { + test.data( + '
                  ' + + '
                • Foo
                • ' + + '
                • Bar
                • ' + + '
                ', + + modelList( ` + * Foo + * Bar + ` ), + + '
                  ' + + '
                • Foo
                • ' + + '
                • Bar
                • ' + + '
                ' + ); + } ); + + it( 'should convert single list (type: numbered, start: 5)', () => { + test.data( + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ', + + modelList( ` + # Foo {start:5} + # Bar + ` ) + ); + } ); + + it( 'should convert when the list is in the middle of the content', () => { + test.data( + '

                Paragraph.

                ' + + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ' + + '

                Paragraph.

                ', + + modelList( ` + Paragraph. + # Foo {id:000} {start:5} + # Bar {id:001} + Paragraph. + ` ) + ); + } ); + + it( 'should convert on a nested list', () => { + test.data( + '
                  ' + + '
                • ' + + 'cd' + + '
                    ' + + '
                  1. efg
                  2. ' + + '
                  ' + + '
                • ' + + '
                ', + + modelList( ` + * cd {id:001} + # efg {id:000} {start:3} + ` ) + ); + } ); + + it( 'should convert on a nested list (same type)', () => { + test.data( + '
                  ' + + '
                1. ' + + 'cd' + + '
                    ' + + '
                  1. efg
                  2. ' + + '
                  ' + + '
                2. ' + + '
                ', + + modelList( ` + # cd {id:001} {start:1} + # efg {id:000} {start:7} + ` ) + ); + } ); + + it( 'view ol converter should not fire if change was already consumed', () => { + editor.data.upcastDispatcher.on( 'element:ol', ( evt, data, conversionApi ) => { + conversionApi.consumable.consume( data.viewItem, { attributes: 'start' } ); + }, { priority: 'highest' } ); + + test.data( + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ', + + modelList( ` + # Foo {start:1} + # Bar + ` ), + + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ' + ); + } ); + + it( 'should use modeRange provided from higher priority converter', () => { + editor.data.upcastDispatcher.on( 'element:ol', ( evt, data, conversionApi ) => { + const { modelRange, modelCursor } = conversionApi.convertChildren( data.viewItem, data.modelCursor ); + + data.modelRange = modelRange; + data.modelCursor = modelCursor; + }, { priority: 'highest' } ); + + test.data( + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ', + + modelList( ` + # Foo {start:3} + # Bar + ` ) + ); + } ); + + it( 'should not apply attribute on elements that does not accept it', () => { + model.schema.register( 'block', { + allowWhere: '$block', + allowContentOf: '$block' + } ); + editor.conversion.elementToElement( { view: 'div', model: 'block' } ); + + test.data( + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. x
                4. ' + + '
                5. Bar
                6. ' + + '
                ', + + modelList( ` + # Foo {start:2} + x + # Bar {start:2} + ` ), + + '
                  ' + + '
                1. Foo
                2. ' + + '
                ' + + '
                x
                ' + + '
                  ' + + '
                1. Bar
                2. ' + + '
                ' + ); + } ); + + it( 'should not consume attribute while upcasting if not applied', () => { + const spy = sinon.spy(); + + model.schema.addAttributeCheck( ( ctx, attributeName ) => attributeName != 'listStart' ); + editor.conversion.for( 'upcast' ).add( + dispatcher => dispatcher.on( 'element:ol', ( evt, data, conversionApi ) => { + expect( conversionApi.consumable.test( data.viewItem, { attributes: 'start' } ) ).to.be.true; + spy(); + }, { priority: 'lowest' } ) + ); + + test.data( + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ', + + modelList( ` + # Foo {start:1} + # Bar + ` ), + + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ' + ); + + expect( spy.calledOnce ).to.be.true; + } ); + + describe( 'copy and getSelectedContent()', () => { + it( 'should be able to downcast part of a nested list', () => { + setModelData( model, modelList( ` + # A + # [B1 {start:4} + B2 + # C1] {start:1} + 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( + '
                  ' + + '
                1. ' + + '

                  B1

                  ' + + '

                  B2

                  ' + + '
                    ' + + '
                  1. C1
                  2. ' + + '
                  ' + + '
                2. ' + + '
                ' + ); + } ); + + it( 'should be able to downcast part of a deep nested list', () => { + setModelData( model, modelList( ` + # A + # B1 {start:4} + B2 + # [C1 {start:7} + 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( + '
                  ' + + '
                1. ' + + '

                  C1

                  ' + + '

                  C2

                  ' + + '
                2. ' + + '
                ' + ); + } ); + } ); + } ); + + describe( 'editing pipeline', () => { + describe( 'insert', () => { + it( 'should convert single list (type: numbered, start: 1)', () => { + test.insert( + modelList( ` + x + # [Foo {start:1} + # Bar] + ` ), + + '

                x

                ' + + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'should convert single list (type: numbered, start:5)', () => { + test.insert( + modelList( ` + x + # [Foo {start:5} + # Bar] + ` ), + + '

                x

                ' + + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'should convert nested numbered list', () => { + test.insert( + modelList( ` + x + # [Foo 1 {start:1} + # Bar 1 {start:7} + # Bar 2 + # Foo 2 + # Foo 3] + ` ), + + '

                x

                ' + + '
                  ' + + '
                1. ' + + 'Foo 1' + + '
                    ' + + '
                  1. Bar 1
                  2. ' + + '
                  3. Bar 2
                  4. ' + + '
                  ' + + '
                2. ' + + '
                3. Foo 2
                4. ' + + '
                5. Foo 3
                6. ' + + '
                ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'should convert properly nested list', () => { + // ■ Level 0 + // ▶ Level 0.1 + // ○ Level 0.1.1 + // ▶ Level 0.2 + // ○ Level 0.2.1 + test.insert( + modelList( ` + x + # [Level 0 {start:1} + # Level 0.1 {start:1} + # Level 0.1.1 {start:3} + # Level 0.2 + # Level 0.2.1] {start:12} + ` ), + + '

                x

                ' + + '
                  ' + + '
                1. Level 0' + + '
                    ' + + '
                  1. Level 0.1' + + '
                      ' + + '
                    1. Level 0.1.1
                    2. ' + + '
                    ' + + '
                  2. ' + + '
                  3. Level 0.2' + + '
                      ' + + '
                    1. Level 0.2.1
                    2. ' + + '
                    ' + + '
                  4. ' + + '
                  ' + + '
                2. ' + + '
                ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'should unwrap list item only if it was really wrapped (there was no wrapper for the start:1)', () => { + test.insert( + modelList( ` + x + * [cd + # efg] {start:5} + ` ), + + '

                x

                ' + + '
                  ' + + '
                • ' + + 'cd' + + '
                    ' + + '
                  1. efg
                  2. ' + + '
                  ' + + '
                • ' + + '
                ' + ); + } ); + } ); + + describe( 'remove', () => { + it( 'remove a list item', () => { + test.remove( + modelList( ` + p + # [a] {start:6} + # b + # c + ` ), + + '

                p

                ' + + '
                  ' + + '
                1. b
                2. ' + + '
                3. c
                4. ' + + '
                ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + } ); + + describe( 'set list reversed', () => { + it( 'on a flat list', () => { + const input = modelList( ` + # [a + # b] + ` ); + + const output = + '
                  ' + + '
                1. a
                2. ' + + '
                3. b
                4. ' + + '
                '; + + test.test( input, output, selection => { + model.change( writer => { + writer.setAttribute( 'listStart', 2, selection.getFirstRange() ); + } ); + } ); + } ); + + it( 'on a list with nested lists', () => { + const input = modelList( ` + # [a {start:1} + # b {start:1} + # c] + ` ); + + const output = + '
                  ' + + '
                1. a' + + '
                    ' + + '
                  1. b
                  2. ' + + '
                  ' + + '
                2. ' + + '
                3. c
                4. ' + + '
                '; + + test.test( input, output, selection => { + model.change( writer => { + for ( const item of selection.getFirstRange().getItems( { shallow: true } ) ) { + if ( item.getAttribute( 'listIndent' ) == 0 ) { + writer.setAttribute( 'listStart', 6, item ); + } + } + } ); + } ); + } ); + } ); + + describe( 'change list start index', () => { + it( 'on a flat list', () => { + const input = modelList( ` + # [a {start:2} + # b] + ` ); + + const output = + '
                  ' + + '
                1. a
                2. ' + + '
                3. b
                4. ' + + '
                '; + + test.test( input, output, selection => { + model.change( writer => { + writer.setAttribute( 'listStart', 6, selection.getFirstRange() ); + } ); + } ); + } ); + + it( 'on a list with nested lists', () => { + const input = modelList( ` + # [a {start:2} + # b {start:4} + # c] + ` ); + + const output = + '
                  ' + + '
                1. ' + + 'a' + + '
                    ' + + '
                  1. b
                  2. ' + + '
                  ' + + '
                2. ' + + '
                3. c
                4. ' + + '
                '; + + test.test( input, output, selection => { + model.change( writer => { + for ( const item of selection.getFirstRange().getItems( { shallow: true } ) ) { + if ( item.getAttribute( 'listIndent' ) == 0 ) { + writer.setAttribute( 'listStart', 11, item ); + } + } + } ); + } ); + } ); + } ); + + describe( 'change list type', () => { + it( 'on a flat list', () => { + const input = modelList( ` + # [a {start:2} + # b] + ` ); + + const output = + '
                  ' + + '
                • a
                • ' + + '
                • b
                • ' + + '
                '; + + test.test( input, output, selection => { + model.change( writer => { + writer.setAttribute( 'listType', 'bulleted', selection.getFirstRange() ); + } ); + } ); + } ); + + it( 'on a list with nested lists', () => { + const input = modelList( ` + # [a {start:2} + # b {start:5} + # c] + ` ); + + const output = + '
                  ' + + '
                • ' + + 'a' + + '
                    ' + + '
                  1. b
                  2. ' + + '
                  ' + + '
                • ' + + '
                • c
                • ' + + '
                '; + + test.test( input, output, selection => { + model.change( writer => { + for ( const item of selection.getFirstRange().getItems( { shallow: true } ) ) { + if ( item.getAttribute( 'listIndent' ) == 0 ) { + writer.setAttribute( 'listType', 'bulleted', item ); + } + } + } ); + } ); + } ); + } ); + + describe( 'change list indent', () => { + it( 'should update list attribute elements', () => { + const input = modelList( [ + '* a', + '* [b', + ' # c] {start:4}' + ] ); + + const output = + '
                  ' + + '
                • ' + + 'a' + + '
                    ' + + '
                  • ' + + 'b' + + '
                      ' + + '
                    1. c
                    2. ' + + '
                    ' + + '
                  • ' + + '
                  ' + + '
                • ' + + '
                '; + + test.test( input, output, selection => { + model.change( writer => { + for ( const item of selection.getFirstRange().getItems( { shallow: true } ) ) { + writer.setAttribute( 'listIndent', item.getAttribute( 'listIndent' ) + 1, item ); + } + } ); + } ); + } ); + } ); + + describe( 'consuming', () => { + it( 'should not convert attribute if it was already consumed', () => { + editor.editing.downcastDispatcher.on( 'attribute:listStart', ( evt, data, conversionApi ) => { + conversionApi.consumable.consume( data.item, evt.name ); + }, { priority: 'highest' } ); + + setModelData( model, + 'a' + ); + + model.change( writer => { + writer.setAttribute( 'listStart', 4, modelRoot.getChild( 0 ) ); + } ); + + expect( getViewData( editor.editing.view, { withoutSelection: true } ) ).to.equal( + '
                  ' + + '
                1. a
                2. ' + + '
                ' + ); + } ); + } ); + } ); + } ); + + describe( 'mixed properties', () => { + beforeEach( () => setupEditor( { + list: { + properties: { + styles: true, + startIndex: true, + reversed: true + } + } + } ) ); + + afterEach( async () => { + await editor.destroy(); + } ); + + describe( 'data pipeline', () => { + beforeEach( () => { + stubUid( 0 ); + } ); + + it( 'should convert single list (type: bulleted)', () => { + test.data( + '
                  ' + + '
                • Foo
                • ' + + '
                • Bar
                • ' + + '
                ', + + modelList( ` + * Foo {style:default} + * Bar + ` ) + ); + } ); + + it( 'should convert single list (type: numbered)', () => { + test.data( + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ', + + modelList( ` + # Foo {style:default} {start:1} {reversed:false} + # Bar + ` ) + ); + } ); + + it( 'should not convert list start on bulleted single list (type: bulleted)', () => { + test.data( + '
                  ' + + '
                • Foo
                • ' + + '
                • Bar
                • ' + + '
                ', + + modelList( ` + * Foo {style:default} + * Bar + ` ), + + '
                  ' + + '
                • Foo
                • ' + + '
                • Bar
                • ' + + '
                ' + ); + } ); + + it( 'should not convert list reversed on bulleted single list (type: bulleted)', () => { + test.data( + '
                  ' + + '
                • Foo
                • ' + + '
                • Bar
                • ' + + '
                ', + + modelList( ` + * Foo {style:default} + * Bar + ` ), + + '
                  ' + + '
                • Foo
                • ' + + '
                • Bar
                • ' + + '
                ' + ); + } ); + + it( 'should convert single list (type: numbered, styled, reversed, start: 5)', () => { + test.data( + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ', + + modelList( ` + # Foo {style:lower-alpha} {start:5} {reversed:true} + # Bar + ` ) + ); + } ); + + it( 'should convert when the list is in the middle of the content', () => { + test.data( + '

                Paragraph.

                ' + + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ' + + '

                Paragraph.

                ', + + modelList( ` + Paragraph. + # Foo {id:000} {style:lower-alpha} {start:5} {reversed:true} + # Bar {id:001} + Paragraph. + ` ) + ); + } ); + + it( 'should convert on a nested list', () => { + test.data( + '
                  ' + + '
                • ' + + 'cd' + + '
                    ' + + '
                  1. efg
                  2. ' + + '
                  ' + + '
                • ' + + '
                ', + + modelList( ` + * cd {id:001} {style:default} + # efg {id:000} {style:lower-alpha} {start:5} {reversed:true} + ` ) + ); + } ); + + it( 'should convert on a nested list (same type)', () => { + test.data( + '
                  ' + + '
                1. ' + + 'cd' + + '
                    ' + + '
                  1. efg
                  2. ' + + '
                  ' + + '
                2. ' + + '
                ', + + modelList( ` + # cd {id:001} {style:default} {start:1} {reversed:false} + # efg {id:000} {style:lower-alpha} {start:5} {reversed:true} + ` ) + ); + } ); + } ); + + describe( 'editing pipeline', () => { + describe( 'insert', () => { + it( 'should convert single list (type: numbered, start: 1, reversed:false, style:default)', () => { + test.insert( + modelList( ` + x + # [Foo {start:1} {reversed:false} {style:default} + # Bar] + ` ), + + '

                x

                ' + + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'should convert single list (type: numbered, start:5, reversed:true, style:lower-alpha)', () => { + test.insert( + modelList( ` + x + # [Foo {start:5} {reversed:true} {style:lower-alpha} + # Bar] + ` ), + + '

                x

                ' + + '
                  ' + + '
                1. Foo
                2. ' + + '
                3. Bar
                4. ' + + '
                ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + + it( 'should convert nested numbered list', () => { + test.insert( + modelList( ` + x + # [Foo 1 {start:1} {reversed:true} {style:lower-alpha} + # Bar 1 {start:7} {reversed:false} {style:upper-alpha} + # Bar 2 + # Foo 2 + # Foo 3] + ` ), + + '

                x

                ' + + '
                  ' + + '
                1. ' + + 'Foo 1' + + '
                    ' + + '
                  1. Bar 1
                  2. ' + + '
                  3. Bar 2
                  4. ' + + '
                  ' + + '
                2. ' + + '
                3. Foo 2
                4. ' + + '
                5. Foo 3
                6. ' + + '
                ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + } ); + + describe( 'remove', () => { + it( 'remove a list item', () => { + test.remove( + modelList( ` + p + # [a] {start:6} {reversed:true} {style:lower-alpha} + # b + # c + ` ), + + '

                p

                ' + + '
                  ' + + '
                1. b
                2. ' + + '
                3. c
                4. ' + + '
                ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 0 ); + } ); + } ); + + describe( 'set list properties', () => { + it( 'list start on list with defined style', () => { + const input = modelList( ` + # [a {style:lower-alpha} + # b] + ` ); + + const output = + '
                  ' + + '
                1. a
                2. ' + + '
                3. b
                4. ' + + '
                '; + + test.test( input, output, selection => { + model.change( writer => { + writer.setAttribute( 'listStart', 2, selection.getFirstRange() ); + } ); + } ); + } ); + + it( 'list start on list with defined style and reversed', () => { + const input = modelList( ` + # [a {style:lower-alpha} {reversed:true} + # b] + ` ); + + const output = + '
                  ' + + '
                1. a
                2. ' + + '
                3. b
                4. ' + + '
                '; + + test.test( input, output, selection => { + model.change( writer => { + writer.setAttribute( 'listStart', 2, selection.getFirstRange() ); + } ); + } ); + } ); + + it( 'list start and reversed on list with defined style', () => { + const input = modelList( ` + # [a {style:lower-alpha} + # b] + ` ); + + const output = + '
                  ' + + '
                1. a
                2. ' + + '
                3. b
                4. ' + + '
                '; + + test.test( input, output, selection => { + model.change( writer => { + writer.setAttribute( 'listStart', 2, selection.getFirstRange() ); + writer.setAttribute( 'listReversed', true, selection.getFirstRange() ); + } ); + } ); + } ); + } ); + + describe( 'change list property value', () => { + it( 'change of list start', () => { + const input = modelList( ` + # [a {style:lower-alpha} {start:4} + # b] + ` ); + + const output = + '
                  ' + + '
                1. a
                2. ' + + '
                3. b
                4. ' + + '
                '; + + test.test( input, output, selection => { + model.change( writer => { + writer.setAttribute( 'listStart', 2, selection.getFirstRange() ); + } ); + } ); + } ); + + it( 'list start and reversed', () => { + const input = modelList( ` + # [a {style:lower-alpha} {reversed:false} {start:6} + # b] + ` ); + + const output = + '
                  ' + + '
                1. a
                2. ' + + '
                3. b
                4. ' + + '
                '; + + test.test( input, output, selection => { + model.change( writer => { + writer.setAttribute( 'listStart', 2, selection.getFirstRange() ); + writer.setAttribute( 'listReversed', true, selection.getFirstRange() ); + } ); + } ); + } ); + + it( 'list start, reversed, and style', () => { + const input = modelList( ` + # [a {style:lower-alpha} {reversed:false} {start:3} + # b] + ` ); + + const output = + '
                  ' + + '
                1. a
                2. ' + + '
                3. b
                4. ' + + '
                '; + + test.test( input, output, selection => { + model.change( writer => { + writer.setAttribute( 'listStart', 2, selection.getFirstRange() ); + writer.setAttribute( 'listReversed', true, selection.getFirstRange() ); + writer.setAttribute( 'listStyle', 'upper-alpha', selection.getFirstRange() ); + } ); + } ); + } ); + } ); + + describe( 'change list type', () => { + it( 'to numbered', () => { + const input = modelList( ` + * [a {style:default} + * b] + ` ); + + const output = + '
                  ' + + '
                1. a
                2. ' + + '
                3. b
                4. ' + + '
                '; + + test.test( input, output, selection => { + model.change( writer => { + writer.setAttribute( 'listType', 'numbered', selection.getFirstRange() ); + } ); + } ); + } ); + + it( 'to bulleted', () => { + const input = modelList( ` + # [a {start:2} {style:lower-alpha} {reversed:true} + # b] + ` ); + + const output = + '
                  ' + + '
                • a
                • ' + + '
                • b
                • ' + + '
                '; + + test.test( input, output, selection => { + model.change( writer => { + writer.setAttribute( 'listType', 'bulleted', selection.getFirstRange() ); + } ); + } ); + } ); + } ); + + describe( 'change list indent', () => { + it( 'should update list attribute elements', () => { + const input = modelList( [ + '* a', + '* [b', + ' # c] {start:4} {reversed:true} {style:lower-alpha}' + ] ); + + const output = + '
                  ' + + '
                • ' + + 'a' + + '
                    ' + + '
                  • ' + + 'b' + + '
                      ' + + '
                    1. c
                    2. ' + + '
                    ' + + '
                  • ' + + '
                  ' + + '
                • ' + + '
                '; + + test.test( input, output, selection => { + model.change( writer => { + for ( const item of selection.getFirstRange().getItems( { shallow: true } ) ) { + writer.setAttribute( 'listIndent', item.getAttribute( 'listIndent' ) + 1, item ); + } + } ); + } ); + } ); + } ); + } ); + } ); + + async function setupEditor( config = {} ) { + editor = await VirtualTestEditor.create( { + plugins: [ Paragraph, IndentEditing, ClipboardPipeline, BoldEditing, DocumentListPropertiesEditing, UndoEditing, + BlockQuoteEditing, TableEditing, HeadingEditing, AlignmentEditing ], + ...config + } ); + + model = editor.model; + modelDoc = model.document; + modelRoot = modelDoc.getRoot(); + + view = editor.editing.view; + + model.schema.register( 'foo', { + allowWhere: '$block', + allowAttributesOf: '$container', + isBlock: true, + isObject: true + } ); + + // Stub `view.scrollToTheSelection` as it will fail on VirtualTestEditor without DOM. + sinon.stub( view, 'scrollToTheSelection' ).callsFake( () => {} ); + + test = setupTestHelpers( editor ); + } +} ); diff --git a/packages/ckeditor5-list/tests/documentlistproperties/documentlistpropertiesediting.js b/packages/ckeditor5-list/tests/documentlistproperties/documentlistpropertiesediting.js new file mode 100644 index 00000000000..094c68d5f16 --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlistproperties/documentlistpropertiesediting.js @@ -0,0 +1,667 @@ +/** + * @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 VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting'; +import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import DocumentListPropertiesEditing from '../../src/documentlistproperties/documentlistpropertiesediting'; +import { modelList } from '../documentlist/_utils/utils'; + +describe( 'DocumentListPropertiesEditing', () => { + let editor, model; + + it( 'should have pluginName', () => { + expect( DocumentListPropertiesEditing.pluginName ).to.equal( 'DocumentListPropertiesEditing' ); + } ); + + describe( 'config', () => { + beforeEach( async () => { + editor = await VirtualTestEditor.create( { + plugins: [ DocumentListPropertiesEditing ] + } ); + } ); + + afterEach( () => { + return editor.destroy(); + } ); + + it( 'should have default values', () => { + expect( editor.config.get( 'list' ) ).to.deep.equal( { + properties: { + styles: true, + startIndex: false, + reversed: false + } + } ); + } ); + + it( 'should be loaded', () => { + expect( editor.plugins.get( DocumentListPropertiesEditing ) ).to.be.instanceOf( DocumentListPropertiesEditing ); + } ); + } ); + + describe( 'listStyle', () => { + beforeEach( async () => { + editor = await VirtualTestEditor.create( { + plugins: [ Paragraph, DocumentListPropertiesEditing, UndoEditing ], + list: { + properties: { styles: true, startIndex: false, reversed: false } + } + } ); + + model = editor.model; + } ); + + afterEach( () => { + return editor.destroy(); + } ); + + describe( 'schema rules', () => { + it( 'should allow set `listStyle` on the `paragraph`', () => { + expect( model.schema.checkAttribute( [ '$root', 'paragraph' ], 'listStyle' ) ).to.be.true; + } ); + + it( 'should not allow set `listReversed` on the `paragraph`', () => { + expect( model.schema.checkAttribute( [ '$root', 'paragraph' ], 'listReversed' ) ).to.be.false; + } ); + + it( 'should not allow set `listStart` on the `paragraph`', () => { + expect( model.schema.checkAttribute( [ '$root', 'paragraph' ], 'listStart' ) ).to.be.false; + } ); + } ); + + describe( 'post-fixer', () => { + it( 'should ensure that all item in a single list have the same `listStyle` attribute', () => { + setData( model, modelList( ` + * 1. {style:circle} + * 2. + * 3. {style:square} + * 4. + # 4.1. {style:default} + # 4.2. {style:upper-roman} + # 4.3. {style:decimal} + # 4.3.1. {style:decimal} + # 4.3.2. {style:upper-roman} + * 5. {style:disc} + ` ) ); + + expect( getData( model, { withoutSelection: true } ) ).to.equalMarkup( modelList( ` + * 1. {style:circle} + * 2. + * 3. + * 4. + # 4.1. {style:default} + # 4.2. + # 4.3. + # 4.3.1. {style:decimal} + # 4.3.2. + * 5. + ` ) ); + } ); + + it( 'should ensure that all list item have the same `listStyle` after removing a block between them', () => { + setData( model, + '1.' + + '2.' + + 'Foo' + + '3.' + + '4.' + ); + + model.change( writer => { + writer.remove( model.document.getRoot().getChild( 2 ) ); + } ); + + expect( getData( model, { withoutSelection: true } ) ).to.equalMarkup( + '1.' + + '2.' + + '3.' + + '4.' + ); + } ); + + it( 'should restore `listStyle` attribute after it\'s changed in one of the following items', () => { + setData( model, modelList( ` + # 1. {style:upper-roman} + # 2. + # 3. + ` ) ); + + model.change( writer => { + writer.setAttribute( 'listStyle', 'decimal', model.document.getRoot().getChild( 2 ) ); + } ); + + expect( getData( model, { withoutSelection: true } ) ).to.equalMarkup( modelList( ` + # 1. {style:upper-roman} + # 2. + # 3. + ` ) ); + } ); + + it( 'should change `listStyle` attribute for all the following items after the first one is changed', () => { + setData( model, modelList( ` + # 1. {style:upper-roman} + # 2. + # 3. + ` ) ); + + model.change( writer => { + writer.setAttribute( 'listStyle', 'decimal', model.document.getRoot().getChild( 0 ) ); + } ); + + expect( getData( model, { withoutSelection: true } ) ).to.equalMarkup( modelList( ` + # 1. {style:decimal} + # 2. + # 3. + ` ) ); + } ); + } ); + + describe( 'indenting lists', () => { + it( 'should reset `listStyle` attribute after indenting a single item', () => { + setData( model, modelList( ` + * 1. {style:circle} + * 1a. {style:square} + * 2. + * 3.[] + * 4. + ` ) ); + + editor.execute( 'indentList' ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + * 1. {style:circle} + * 1a. {style:square} + * 2. + * 3.[] {style:default} + * 4. + ` ) ); + } ); + + it( 'should reset `listStyle` attribute after indenting a few items', () => { + setData( model, modelList( ` + # 1. {style:decimal} + # [2. + # 3.] + ` ) ); + + editor.execute( 'indentList' ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + # 1. {style:decimal} + # [2. {style:default} + # 3.] + ` ) ); + } ); + + it( 'should copy `listStyle` attribute after indenting a single item into previously nested list', () => { + setData( model, modelList( ` + * 1. {style:circle} + * 1a. {style:square} + * 1b. + * 2.[] + * 3. + ` ) ); + + editor.execute( 'indentList' ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + * 1. {style:circle} + * 1a. {style:square} + * 1b. + * 2.[] + * 3. + ` ) ); + } ); + + it( 'should copy `listStyle` attribute after indenting a few items into previously nested list', () => { + setData( model, modelList( ` + * 1. {style:circle} + * 1a. {style:square} + * 1b. + * [2. + * 3.] + * 4. + ` ) ); + + editor.execute( 'indentList' ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + * 1. {style:circle} + * 1a. {style:square} + * 1b. + * [2. + * 3.] + * 4. + ` ) ); + } ); + } ); + } ); + + describe( 'listReversed', () => { + beforeEach( async () => { + editor = await VirtualTestEditor.create( { + plugins: [ Paragraph, DocumentListPropertiesEditing, UndoEditing ], + list: { + properties: { styles: false, startIndex: false, reversed: true } + } + } ); + + model = editor.model; + } ); + + afterEach( () => { + return editor.destroy(); + } ); + + describe( 'schema rules', () => { + it( 'should not allow set `listStyle` on the `paragraph`', () => { + expect( model.schema.checkAttribute( [ '$root', 'paragraph' ], 'listStyle' ) ).to.be.false; + } ); + + it( 'should not allow set `listReversed` on the `paragraph`', () => { + expect( model.schema.checkAttribute( [ '$root', 'paragraph' ], 'listReversed' ) ).to.be.true; + } ); + + it( 'should allow set `listStart` on the `paragraph`', () => { + expect( model.schema.checkAttribute( [ '$root', 'paragraph' ], 'listStart' ) ).to.be.false; + } ); + } ); + + describe( 'post-fixer', () => { + it( 'should ensure that all item in a single list have the same `listReversed` attribute', () => { + setData( model, modelList( ` + # 1. {reversed:true} + # 2. + # 3. {reversed:false} + # 4. + # 4.1. {reversed:false} + # 4.2. {reversed:true} + # 4.3. {reversed:false} + # 4.3.1. {reversed:true} + # 4.3.2. {reversed:false} + # 5. {reversed:true} + ` ) ); + + expect( getData( model, { withoutSelection: true } ) ).to.equalMarkup( modelList( ` + # 1. {reversed:true} + # 2. + # 3. + # 4. + # 4.1. {reversed:false} + # 4.2. + # 4.3. + # 4.3.1. {reversed:true} + # 4.3.2. + # 5. + ` ) ); + } ); + + it( 'should ensure that all list item have the same `listReversed` after removing a block between them', () => { + setData( model, + '1.' + + '2.' + + 'Foo' + + '3.' + + '4.' + ); + + model.change( writer => { + writer.remove( model.document.getRoot().getChild( 2 ) ); + } ); + + expect( getData( model, { withoutSelection: true } ) ).to.equalMarkup( + '1.' + + '2.' + + '3.' + + '4.' + ); + } ); + + it( 'should restore `listReversed` attribute after it\'s changed in one of the following items', () => { + setData( model, modelList( ` + # 1. {reversed:true} + # 2. + # 3. + ` ) ); + + model.change( writer => { + writer.setAttribute( 'listReversed', false, model.document.getRoot().getChild( 2 ) ); + } ); + + expect( getData( model, { withoutSelection: true } ) ).to.equalMarkup( modelList( ` + # 1. {reversed:true} + # 2. + # 3. + ` ) ); + } ); + + it( 'should change `listReversed` attribute for all the following items after the first one is changed', () => { + setData( model, modelList( ` + # 1. {reversed:false} + # 2. + # 3. + ` ) ); + + model.change( writer => { + writer.setAttribute( 'listReversed', true, model.document.getRoot().getChild( 0 ) ); + } ); + + expect( getData( model, { withoutSelection: true } ) ).to.equalMarkup( modelList( ` + # 1. {reversed:true} + # 2. + # 3. + ` ) ); + } ); + } ); + + describe( 'indenting lists', () => { + it( 'should reset `listReversed` attribute after indenting a single item', () => { + setData( model, modelList( ` + # 1. {reversed:true} + # 1a. {reversed:true} + # 2. + # 3.[] + # 4. + ` ) ); + + editor.execute( 'indentList' ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + # 1. {reversed:true} + # 1a. {reversed:true} + # 2. + # 3.[] {reversed:false} + # 4. + ` ) ); + } ); + + it( 'should reset `listReversed` attribute after indenting a few items', () => { + setData( model, modelList( ` + # 1. {reversed:true} + # [2. + # 3.] + ` ) ); + + editor.execute( 'indentList' ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + # 1. {reversed:true} + # [2. {reversed:false} + # 3.] + ` ) ); + } ); + + it( 'should copy `listReversed` attribute after indenting a single item into previously nested list', () => { + setData( model, modelList( ` + # 1. {reversed:false} + # 1a. {reversed:true} + # 1b. + # 2.[] + # 3. + ` ) ); + + editor.execute( 'indentList' ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + # 1. {reversed:false} + # 1a. {reversed:true} + # 1b. + # 2.[] + # 3. + ` ) ); + } ); + + it( 'should copy `listReversed` attribute after indenting a few items into previously nested list', () => { + setData( model, modelList( ` + # 1. {reversed:false} + # 1a. {reversed:true} + # 1b. + # [2. + # 3.] + # 4. + ` ) ); + + editor.execute( 'indentList' ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + # 1. {reversed:false} + # 1a. {reversed:true} + # 1b. + # [2. + # 3.] + # 4. + ` ) ); + } ); + + it( 'should not do anything with bulleted lists', () => { + setData( model, modelList( ` + * 1. + * 2.[] + ` ) ); + + editor.execute( 'indentList' ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + * 1. + * 2.[] + ` ) ); + } ); + } ); + } ); + + describe( 'listStart', () => { + beforeEach( async () => { + editor = await VirtualTestEditor.create( { + plugins: [ Paragraph, DocumentListPropertiesEditing, UndoEditing ], + list: { + properties: { styles: false, startIndex: true, reversed: false } + } + } ); + + model = editor.model; + } ); + + afterEach( () => { + return editor.destroy(); + } ); + + describe( 'schema rules', () => { + it( 'should allow set `listStyle` on the `paragraph`', () => { + expect( model.schema.checkAttribute( [ '$root', 'paragraph' ], 'listStyle' ) ).to.be.false; + } ); + + it( 'should not allow set `listReversed` on the `paragraph`', () => { + expect( model.schema.checkAttribute( [ '$root', 'paragraph' ], 'listReversed' ) ).to.be.false; + } ); + + it( 'should not allow set `listStart` on the `paragraph`', () => { + expect( model.schema.checkAttribute( [ '$root', 'paragraph' ], 'listStart' ) ).to.be.true; + } ); + } ); + + describe( 'post-fixer', () => { + it( 'should ensure that all item in a single list have the same `listStart` attribute', () => { + setData( model, modelList( ` + # 1. {start:2} + # 2. + # 3. {start:5} + # 4. + # 4.1. {start:3} + # 4.2. {start:7} + # 4.3. {start:1} + # 4.3.1. {start:42} + # 4.3.2. {start:1} + # 5. {start:8} + ` ) ); + + expect( getData( model, { withoutSelection: true } ) ).to.equalMarkup( modelList( ` + # 1. {start:2} + # 2. + # 3. + # 4. + # 4.1. {start:3} + # 4.2. + # 4.3. + # 4.3.1. {start:42} + # 4.3.2. + # 5. + ` ) ); + } ); + + it( 'should ensure that all list item have the same `listStart` after removing a block between them', () => { + setData( model, + '1.' + + '2.' + + 'Foo' + + '3.' + + '4.' + ); + + model.change( writer => { + writer.remove( model.document.getRoot().getChild( 2 ) ); + } ); + + expect( getData( model, { withoutSelection: true } ) ).to.equalMarkup( + '1.' + + '2.' + + '3.' + + '4.' + ); + } ); + + it( 'should restore `listStart` attribute after it\'s changed in one of the following items', () => { + setData( model, modelList( ` + # 1. {start:2} + # 2. + # 3. + ` ) ); + + model.change( writer => { + writer.setAttribute( 'listStart', 5, model.document.getRoot().getChild( 2 ) ); + } ); + + expect( getData( model, { withoutSelection: true } ) ).to.equalMarkup( modelList( ` + # 1. {start:2} + # 2. + # 3. + ` ) ); + } ); + + it( 'should change `listStart` attribute for all the following items after the first one is changed', () => { + setData( model, modelList( ` + # 1. {start:2} + # 2. + # 3. + ` ) ); + + model.change( writer => { + writer.setAttribute( 'listStart', 5, model.document.getRoot().getChild( 0 ) ); + } ); + + expect( getData( model, { withoutSelection: true } ) ).to.equalMarkup( modelList( ` + # 1. {start:5} + # 2. + # 3. + ` ) ); + } ); + } ); + + describe( 'indenting lists', () => { + it( 'should reset `listStart` attribute after indenting a single item', () => { + setData( model, modelList( ` + # 1. {start:5} + # 1a. {start:3} + # 2. + # 3.[] + # 4. + ` ) ); + + editor.execute( 'indentList' ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + # 1. {start:5} + # 1a. {start:3} + # 2. + # 3.[] {start:1} + # 4. + ` ) ); + } ); + + it( 'should reset `listStart` attribute after indenting a few items', () => { + setData( model, modelList( ` + # 1. {start:2} + # [2. + # 3.] + ` ) ); + + editor.execute( 'indentList' ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + # 1. {start:2} + # [2. {start:1} + # 3.] + ` ) ); + } ); + + it( 'should copy `listStart` attribute after indenting a single item into previously nested list', () => { + setData( model, modelList( ` + # 1. {start:3} + # 1a. {start:7} + # 1b. + # 2.[] + # 3. + ` ) ); + + editor.execute( 'indentList' ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + # 1. {start:3} + # 1a. {start:7} + # 1b. + # 2.[] + # 3. + ` ) ); + } ); + + it( 'should copy `listStart` attribute after indenting a few items into previously nested list', () => { + setData( model, modelList( ` + # 1. {start:42} + # 1a. {start:2} + # 1b. + # [2. + # 3.] + # 4. + ` ) ); + + editor.execute( 'indentList' ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + # 1. {start:42} + # 1a. {start:2} + # 1b. + # [2. + # 3.] + # 4. + ` ) ); + } ); + + it( 'should not do anything with bulleted lists', () => { + setData( model, modelList( ` + * 1. + * 2.[] + ` ) ); + + editor.execute( 'indentList' ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + * 1. + * 2.[] + ` ) ); + } ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-list/tests/documentlistproperties/documentlistreversedcommand.js b/packages/ckeditor5-list/tests/documentlistproperties/documentlistreversedcommand.js new file mode 100644 index 00000000000..df3d6097355 --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlistproperties/documentlistreversedcommand.js @@ -0,0 +1,406 @@ +/** + * @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 Editor from '@ckeditor/ckeditor5-core/src/editor/editor'; +import Model from '@ckeditor/ckeditor5-engine/src/model/model'; +import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; + +import DocumentListReversedCommand from '../../src/documentlistproperties/documentlistreversedcommand'; +import { modelList } from '../documentlist/_utils/utils'; + +describe( 'DocumentListReversedCommand', () => { + let editor, model, listReversedCommand; + + beforeEach( async () => { + editor = new Editor(); + + await editor.initPlugins(); + + editor.model = new Model(); + + model = editor.model; + model.document.createRoot(); + + model.schema.register( 'paragraph', { inheritAllFrom: '$block' } ); + model.schema.register( 'blockQuote', { inheritAllFrom: '$container' } ); + model.schema.extend( '$container', { allowAttributes: [ 'listType', 'listIndent', 'listItemId', 'listReversed' ] } ); + model.schema.extend( '$block', { allowAttributes: [ 'listType', 'listIndent', 'listItemId', 'listReversed' ] } ); + model.schema.extend( '$blockObject', { allowAttributes: [ 'listType', 'listIndent', 'listItemId', 'listReversed' ] } ); + + model.schema.register( 'blockWidget', { + isObject: true, + isBlock: true, + allowIn: '$root', + allowAttributesOf: '$container' + } ); + + listReversedCommand = new DocumentListReversedCommand( editor ); + + editor.commands.add( 'listReversed', listReversedCommand ); + } ); + + describe( '#isEnabled', () => { + it( 'should be false if selected a paragraph', () => { + setData( model, modelList( [ 'Foo[]' ] ) ); + + expect( listReversedCommand.isEnabled ).to.be.false; + } ); + + it( 'should be false if selection starts in a paragraph and ends in a list item', () => { + setData( model, modelList( ` + Fo[o + # Bar] {reversed:true} + ` ) ); + + expect( listReversedCommand.isEnabled ).to.be.false; + } ); + + it( 'should be false if selection is inside a listItem (listType: bulleted)', () => { + setData( model, modelList( [ '* Foo[]' ] ) ); + + expect( listReversedCommand.isEnabled ).to.be.false; + } ); + + it( 'should be true if selection is inside a listItem (collapsed selection)', () => { + setData( model, modelList( [ '# Foo[] {reversed:true}' ] ) ); + + expect( listReversedCommand.isEnabled ).to.be.true; + } ); + + it( 'should be true if selection is inside a listItem (non-collapsed selection)', () => { + setData( model, modelList( [ '# [Foo] {reversed:false}' ] ) ); + + expect( listReversedCommand.isEnabled ).to.be.true; + } ); + + it( 'should be true attribute if selected more elements in the same list', () => { + setData( model, modelList( ` + # [1. {reversed:true} + # 2.] + # 3. + ` ) ); + + expect( listReversedCommand.isEnabled ).to.be.true; + } ); + } ); + + describe( '#value', () => { + it( 'should return null if selected a paragraph', () => { + setData( model, modelList( [ 'Foo' ] ) ); + + expect( listReversedCommand.value ).to.equal( null ); + } ); + + it( 'should return null if selection starts in a paragraph and ends in a list item', () => { + setData( model, modelList( ` + Fo[o + # Bar] + ` ) ); + + expect( listReversedCommand.value ).to.equal( null ); + } ); + + it( 'should return null if selection is inside a listItem (listType: bulleted)', () => { + setData( model, modelList( [ '* Foo[]' ] ) ); + + expect( listReversedCommand.value ).to.be.null; + } ); + + it( 'should return the value of `listReversed` attribute if selection is inside a list item (collapsed selection)', () => { + setData( model, modelList( [ '# Foo[] {reversed:true}' ] ) ); + + expect( listReversedCommand.value ).to.be.true; + + setData( model, modelList( [ '# Foo[] {reversed:false}' ] ) ); + + expect( listReversedCommand.value ).to.be.false; + } ); + + it( 'should return the value of `listReversed` attribute if selection is inside a list item (non-collapsed selection)', () => { + setData( model, modelList( [ '# [Foo] {reversed:false}' ] ) ); + + expect( listReversedCommand.value ).to.be.false; + + setData( model, modelList( [ '# [Foo] {reversed:true}' ] ) ); + + expect( listReversedCommand.value ).to.be.true; + } ); + + it( 'should return the value of `listReversed` attribute if selected more elements in the same list', () => { + setData( model, modelList( ` + # [1. {reversed:true} + # 2.] + # 3. + ` ) ); + + expect( listReversedCommand.value ).to.be.true; + } ); + + it( 'should return the value of `listReversed` attribute for the selection inside a nested list', () => { + setData( model, modelList( ` + # 1. {reversed:false} + # 1.1.[] {reversed:true} + # 2. + ` ) ); + + expect( listReversedCommand.value ).to.be.true; + } ); + + it( 'should return the value of `listReversed` attribute from a list where the selection starts (selection over nested list)', + () => { + setData( model, modelList( ` + # 1. {reversed:false} + # 1.1.[ {reversed:true} + # 2.] + ` ) ); + + expect( listReversedCommand.value ).to.be.true; + } + ); + } ); + + describe( 'execute()', () => { + it( 'should set the `listReversed` attribute for collapsed selection', () => { + setData( model, modelList( [ '# 1.[] {reversed:false}' ] ) ); + + listReversedCommand.execute( { reversed: true } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ '# 1.[] {reversed:true}' ] ) ); + + listReversedCommand.execute( { reversed: false } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ '# 1.[] {reversed:false}' ] ) ); + } ); + + it( 'should set the `listReversed` attribute for non-collapsed selection', () => { + setData( model, modelList( [ '# [1.] {reversed:false}' ] ) ); + + listReversedCommand.execute( { reversed: true } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ '# [1.] {reversed:true}' ] ) ); + + listReversedCommand.execute( { reversed: false } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ '# [1.] {reversed:false}' ] ) ); + } ); + + it( 'should set the `listReversed` attribute for all the same list items (collapsed selection)', () => { + setData( model, modelList( ` + # 1. {reversed:false} + # 2.[] + # 3. + ` ) ); + + listReversedCommand.execute( { reversed: true } ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + # 1. {reversed:true} + # 2.[] + # 3. + ` ) ); + } ); + + it( 'should set the `listReversed` attribute for all the same list items and ignores nested lists (collapsed selection)', () => { + setData( model, modelList( ` + # 1.[] {reversed:false} + # 2. + # 2.1. {reversed:false} + # 2.2 + # 3. + # 3.1. {reversed:true} + ` ) ); + + listReversedCommand.execute( { reversed: true } ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + # 1.[] {reversed:true} + # 2. + # 2.1. {reversed:false} + # 2.2 + # 3. + # 3.1. {reversed:true} + ` ) ); + } ); + + it( 'should set the `listReversed` attribute for all the same list items (block widget selected)', () => { + setData( model, modelList( ` + # Foo. {reversed:false} + # [] + # Bar. + ` ) ); + + listReversedCommand.execute( { reversed: true } ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + # Foo. {reversed:true} + # [] + # Bar. + ` ) ); + } ); + + it( 'should set the `listReversed` attribute for all the same list items and ignores "parent" list (selection in nested list)', + () => { + setData( model, modelList( ` + # 1. {reversed:true} + # 2. + # 2.1.[] {reversed:true} + # 2.2. + # 3. + # 3.1. {reversed:true} + ` ) ); + + listReversedCommand.execute( { reversed: false } ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + # 1. {reversed:true} + # 2. + # 2.1.[] {reversed:false} + # 2.2. + # 3. + # 3.1. {reversed:true} + ` ) ); + } + ); + + it( 'should stop searching for the list items when spotted non-listItem element', () => { + setData( model, modelList( ` + Foo. + # 1.[] {reversed:true} + # 2. + # 3. + ` ) ); + + listReversedCommand.execute( { reversed: false } ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + Foo. + # 1.[] {reversed:false} + # 2. + # 3. + ` ) ); + } ); + + it( 'should stop searching for the list items when spotted listItem with different `listType` attribute', () => { + setData( model, modelList( ` + Foo. + # 1.[] {reversed:false} + # 2. + * 1. + ` ) ); + + listReversedCommand.execute( { reversed: true } ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + Foo. + # 1.[] {reversed:true} + # 2. + * 1. + ` ) ); + } ); + + it( 'should set the `listReversed` attribute for selected items (non-collapsed selection)', () => { + setData( model, modelList( ` + # 1. {reversed:false} + # 2a. + [2b. + 2c. + # 3a]. + 3b. + # 4. + ` ) ); + + listReversedCommand.execute( { reversed: true } ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + # 1. {reversed:true} + # 2a. + [2b. + 2c. + # 3a]. + 3b. + # 4. + ` ) ); + } ); + + it( 'should set the `listReversed` attribute for all blocks in the list item (non-collapsed selection)', () => { + setData( model, modelList( ` + # 1. {reversed:true} + # 2. + [3]. + # 4. + ` ) ); + + listReversedCommand.execute( { reversed: false } ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + # 1. {reversed:false} + # 2. + [3]. + # 4. + ` ) ); + } ); + + it( 'should set the `listReversed` attribute for selected items including nested lists (non-collapsed selection)', () => { + // [x] = items that should be updated. + // All list items that belong to the same lists that selected items should be updated. + // "2." is the most outer list (listIndent=0) + // "2.1" a child list of the "2." element (listIndent=1) + // "2.1.1" a child list of the "2.1" element (listIndent=2) + // + // [x] ■ 1. + // [x] ■ [2. + // [x] ○ 2.1. + // [X] ▶ 2.1.1.] + // [x] ▶ 2.1.2. + // [x] ○ 2.2. + // [x] ■ 3. + // [ ] ○ 3.1. + // [ ] ▶ 3.1.1. + // + // "3.1" is not selected and this list should not be updated. + setData( model, modelList( ` + # 1. {reversed:false} + # [2. + # 2.1. {reversed:false} + # 2.1.1.] {reversed:false} + # 2.1.2. + # 2.2. + # 3. + # 3.1. {reversed:false} + # 3.1.1. {reversed:false} + ` ) ); + + listReversedCommand.execute( { reversed: true } ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + # 1. {reversed:true} + # [2. + # 2.1. {reversed:true} + # 2.1.1.] {reversed:true} + # 2.1.2. + # 2.2. + # 3. + # 3.1. {reversed:false} + # 3.1.1. {reversed:false} + ` ) ); + } ); + + it( 'should use `false` value if not specified (no options passed)', () => { + setData( model, modelList( [ '# 1.[] {reversed:true}' ] ) ); + + listReversedCommand.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ '# 1.[] {reversed:false}' ] ) ); + } ); + + it( 'should use `false` value if not specified (passed an empty object)', () => { + setData( model, modelList( [ '# 1.[] {reversed:true}' ] ) ); + + listReversedCommand.execute( {} ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ '# 1.[] {reversed:false}' ] ) ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-list/tests/documentlistproperties/documentliststartcommand.js b/packages/ckeditor5-list/tests/documentlistproperties/documentliststartcommand.js new file mode 100644 index 00000000000..dcf7d381f12 --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlistproperties/documentliststartcommand.js @@ -0,0 +1,386 @@ +/** + * @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 Editor from '@ckeditor/ckeditor5-core/src/editor/editor'; +import Model from '@ckeditor/ckeditor5-engine/src/model/model'; +import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; + +import DocumentListStartCommand from '../../src/documentlistproperties/documentliststartcommand'; +import { modelList } from '../documentlist/_utils/utils'; + +describe( 'DocumentListStartCommand', () => { + let editor, model, listStartCommand; + + beforeEach( async () => { + editor = new Editor(); + + await editor.initPlugins(); + + editor.model = new Model(); + + model = editor.model; + model.document.createRoot(); + + model.schema.register( 'paragraph', { inheritAllFrom: '$block' } ); + model.schema.register( 'blockQuote', { inheritAllFrom: '$container' } ); + model.schema.extend( '$container', { allowAttributes: [ 'listType', 'listIndent', 'listItemId', 'listStart' ] } ); + model.schema.extend( '$block', { allowAttributes: [ 'listType', 'listIndent', 'listItemId', 'listStart' ] } ); + model.schema.extend( '$blockObject', { allowAttributes: [ 'listType', 'listIndent', 'listItemId', 'listStart' ] } ); + + model.schema.register( 'blockWidget', { + isObject: true, + isBlock: true, + allowIn: '$root', + allowAttributesOf: '$container' + } ); + + listStartCommand = new DocumentListStartCommand( editor ); + + editor.commands.add( 'listStart', listStartCommand ); + } ); + + describe( '#isEnabled', () => { + it( 'should be false if selected a paragraph', () => { + setData( model, modelList( [ 'Foo[]' ] ) ); + + expect( listStartCommand.isEnabled ).to.be.false; + } ); + + it( 'should be false if selection starts in a paragraph and ends in a list item', () => { + setData( model, modelList( ` + Fo[o + # Bar] {start:1} + ` ) ); + + expect( listStartCommand.isEnabled ).to.be.false; + } ); + + it( 'should be false if selection is inside a listItem (listType: bulleted)', () => { + setData( model, modelList( [ '* Foo[]' ] ) ); + + expect( listStartCommand.isEnabled ).to.be.false; + } ); + + it( 'should be true if selection is inside a listItem (collapsed selection)', () => { + setData( model, modelList( [ '# Foo[] {start:2}' ] ) ); + + expect( listStartCommand.isEnabled ).to.be.true; + } ); + + it( 'should be true if selection is inside a listItem (non-collapsed selection)', () => { + setData( model, modelList( [ '# [Foo] {start:1}' ] ) ); + + expect( listStartCommand.isEnabled ).to.be.true; + } ); + + it( 'should be true attribute if selected more elements in the same list', () => { + setData( model, modelList( ` + # [1. {start:3} + # 2.] + # 3. + ` ) ); + + expect( listStartCommand.isEnabled ).to.be.true; + } ); + } ); + + describe( '#value', () => { + it( 'should return null if selected a paragraph', () => { + setData( model, modelList( [ 'Foo' ] ) ); + + expect( listStartCommand.value ).to.equal( null ); + } ); + + it( 'should return null if selection starts in a paragraph and ends in a list item', () => { + setData( model, modelList( ` + Fo[o + * Bar] + ` ) ); + + expect( listStartCommand.value ).to.equal( null ); + } ); + + it( 'should return null if selection is inside a listItem (listType: bulleted)', () => { + setData( model, modelList( [ '* Foo[]' ] ) ); + + expect( listStartCommand.value ).to.be.null; + } ); + + it( 'should return the value of `listStart` attribute if selection is inside a list item (collapsed selection)', () => { + setData( model, modelList( [ '# Foo[] {start:2}' ] ) ); + + expect( listStartCommand.value ).to.equal( 2 ); + } ); + + it( 'should return the value of `listStart` attribute if selection is inside a list item (non-collapsed selection)', () => { + setData( model, modelList( [ '# [Foo] {start:3}' ] ) ); + + expect( listStartCommand.value ).to.equal( 3 ); + } ); + + it( 'should return the value of `listStart` attribute if selected more elements in the same list', () => { + setData( model, modelList( ` + # [1. {start:3} + # 2.] + # 3. + ` ) ); + + expect( listStartCommand.value ).to.equal( 3 ); + } ); + + it( 'should return the value of `listStart` attribute for the selection inside a nested list', () => { + setData( model, modelList( ` + # 1. {start:2} + # 1.1.[] {start:3} + # 2. + ` ) ); + + expect( listStartCommand.value ).to.equal( 3 ); + } ); + + it( 'should return the value of `listStart` attribute from a list where the selection starts (selection over nested list)', () => { + setData( model, modelList( ` + # 1. {start:2} + # 1.1.[ {start:3} + # 2.] + ` ) ); + + expect( listStartCommand.value ).to.equal( 3 ); + } ); + } ); + + describe( 'execute()', () => { + it( 'should set the `listStart` attribute for collapsed selection', () => { + setData( model, modelList( [ '# 1.[] {start:1}' ] ) ); + + listStartCommand.execute( { startIndex: 5 } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ '# 1.[] {start:5}' ] ) ); + } ); + + it( 'should set the `listStart` attribute for non-collapsed selection', () => { + setData( model, modelList( [ '# [1.] {start:2}' ] ) ); + + listStartCommand.execute( { startIndex: 5 } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ '# [1.] {start:5}' ] ) ); + } ); + + it( 'should set the `listStart` attribute for all the same list items (collapsed selection)', () => { + setData( model, modelList( ` + # 1. {start:7} + # 2.[] + # 3. + ` ) ); + + listStartCommand.execute( { startIndex: 5 } ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + # 1. {start:5} + # 2.[] + # 3. + ` ) ); + } ); + + it( 'should set the `listStart` attribute for all the same list items and ignores nested lists (collapsed selection)', () => { + setData( model, modelList( ` + # 1.[] {start:2} + # 2. + # 2.1. {start:3} + # 2.2 + # 3. + # 3.1. {start:4} + ` ) ); + + listStartCommand.execute( { startIndex: 5 } ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + # 1.[] {start:5} + # 2. + # 2.1. {start:3} + # 2.2 + # 3. + # 3.1. {start:4} + ` ) ); + } ); + + it( 'should set the `listStart` attribute for all the same list items (block widget selected)', () => { + setData( model, modelList( ` + # Foo. {start:1} + # [] + # Bar. + ` ) ); + + listStartCommand.execute( { startIndex: 5 } ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + # Foo. {start:5} + # [] + # Bar. + ` ) ); + } ); + + it( 'should set the `listStart` attribute for all the same list items and ignores "parent" list (selection in nested list)', () => { + setData( model, modelList( ` + # 1. {start:1} + # 2. + # 2.1.[] {start:2} + # 2.2. + # 3. + # 3.1. {start:3} + ` ) ); + + listStartCommand.execute( { startIndex: 5 } ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + # 1. {start:1} + # 2. + # 2.1.[] {start:5} + # 2.2. + # 3. + # 3.1. {start:3} + ` ) ); + } ); + + it( 'should stop searching for the list items when spotted non-listItem element', () => { + setData( model, modelList( ` + Foo. + # 1.[] {start:2} + # 2. + # 3. + ` ) ); + + listStartCommand.execute( { startIndex: 5 } ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + Foo. + # 1.[] {start:5} + # 2. + # 3. + ` ) ); + } ); + + it( 'should stop searching for the list items when spotted listItem with different listType attribute', () => { + setData( model, modelList( ` + Foo. + # 1.[] {start:2} + # 2. + * 1. + ` ) ); + + listStartCommand.execute( { startIndex: 5 } ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + Foo. + # 1.[] {start:5} + # 2. + * 1. + ` ) ); + } ); + + it( 'should set the `listStart` attribute for selected items (non-collapsed selection)', () => { + setData( model, modelList( ` + # 1. {start:7} + # 2a. + [2b. + 2c. + # 3a]. + 3b. + # 4. + ` ) ); + + listStartCommand.execute( { startIndex: 5 } ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + # 1. {start:5} + # 2a. + [2b. + 2c. + # 3a]. + 3b. + # 4. + ` ) ); + } ); + + it( 'should set the `listStart` attribute for all blocks in the list item (non-collapsed selection)', () => { + setData( model, modelList( ` + # 1. {start:2} + # 2. + [3]. + # 4. + ` ) ); + + listStartCommand.execute( { startIndex: 5 } ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + # 1. {start:5} + # 2. + [3]. + # 4. + ` ) ); + } ); + + it( 'should set the `listStart` attribute for selected items including nested lists (non-collapsed selection)', () => { + // [x] = items that should be updated. + // All list items that belong to the same lists that selected items should be updated. + // "2." is the most outer list (listIndent=0) + // "2.1" a child list of the "2." element (listIndent=1) + // "2.1.1" a child list of the "2.1" element (listIndent=2) + // + // [x] ■ 1. + // [x] ■ [2. + // [x] ○ 2.1. + // [X] ▶ 2.1.1.] + // [x] ▶ 2.1.2. + // [x] ○ 2.2. + // [x] ■ 3. + // [ ] ○ 3.1. + // [ ] ▶ 3.1.1. + // + // "3.1" is not selected and this list should not be updated. + setData( model, modelList( ` + # 1. {start:1} + # [2. + # 2.1. {start:2} + # 2.1.1.] {start:3} + # 2.1.2. + # 2.2. + # 3. + # 3.1. {start:4} + # 3.1.1. {start:5} + ` ) ); + + listStartCommand.execute( { startIndex: 7 } ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + # 1. {start:7} + # [2. + # 2.1. {start:7} + # 2.1.1.] {start:7} + # 2.1.2. + # 2.2. + # 3. + # 3.1. {start:4} + # 3.1.1. {start:5} + ` ) ); + } ); + + it( 'should use `1` value if not specified (no options passed)', () => { + setData( model, modelList( [ '# 1.[] {start:2}' ] ) ); + + listStartCommand.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ '# 1.[] {start:1}' ] ) ); + } ); + + it( 'should use `1` value if not specified (passed an empty object)', () => { + setData( model, modelList( [ '# 1.[] {start:2}' ] ) ); + + listStartCommand.execute( {} ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ '# 1.[] {start:1}' ] ) ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-list/tests/documentlistproperties/documentliststylecommand.js b/packages/ckeditor5-list/tests/documentlistproperties/documentliststylecommand.js new file mode 100644 index 00000000000..7d7f10f11fa --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlistproperties/documentliststylecommand.js @@ -0,0 +1,519 @@ +/** + * @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 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'; + +import DocumentListCommand from '../../src/documentlist/documentlistcommand'; +import DocumentListStyleCommand from '../../src/documentlistproperties/documentliststylecommand'; +import stubUid from '../documentlist/_utils/uid'; +import { modelList } from '../documentlist/_utils/utils'; + +describe( 'DocumentListStyleCommand', () => { + let editor, model, bulletedListCommand, numberedListCommand, listStyleCommand; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + editor = new Editor(); + + await editor.initPlugins(); + + editor.model = new Model(); + + model = editor.model; + model.document.createRoot(); + + model.schema.register( 'paragraph', { inheritAllFrom: '$block' } ); + model.schema.register( 'blockQuote', { inheritAllFrom: '$container' } ); + model.schema.extend( '$container', { allowAttributes: [ 'listType', 'listIndent', 'listItemId', 'listStyle' ] } ); + model.schema.extend( '$block', { allowAttributes: [ 'listType', 'listIndent', 'listItemId', 'listStyle' ] } ); + model.schema.extend( '$blockObject', { allowAttributes: [ 'listType', 'listIndent', 'listItemId', 'listStyle' ] } ); + + model.schema.register( 'blockWidget', { + isObject: true, + isBlock: true, + allowIn: '$root', + allowAttributesOf: '$container' + } ); + + bulletedListCommand = new DocumentListCommand( editor, 'bulleted' ); + numberedListCommand = new DocumentListCommand( editor, 'numbered' ); + listStyleCommand = new DocumentListStyleCommand( editor, 'default' ); + + editor.commands.add( 'numberedList', numberedListCommand ); + editor.commands.add( 'bulletedList', bulletedListCommand ); + editor.commands.add( 'listStyle', bulletedListCommand ); + + stubUid(); + } ); + + describe( '#isEnabled', () => { + it( 'should be true if bulletedList or numberedList is enabled', () => { + bulletedListCommand.isEnabled = true; + numberedListCommand.isEnabled = false; + listStyleCommand.refresh(); + + expect( listStyleCommand.isEnabled ).to.equal( true ); + + bulletedListCommand.isEnabled = false; + numberedListCommand.isEnabled = true; + listStyleCommand.refresh(); + + expect( listStyleCommand.isEnabled ).to.equal( true ); + } ); + + it( 'should be false if bulletedList and numberedList are disabled', () => { + bulletedListCommand.isEnabled = false; + numberedListCommand.isEnabled = false; + + listStyleCommand.refresh(); + + expect( listStyleCommand.isEnabled ).to.equal( false ); + } ); + } ); + + describe( '#value', () => { + it( 'should return null if selected a paragraph', () => { + setData( model, 'Foo[]' ); + + expect( listStyleCommand.value ).to.equal( null ); + } ); + + it( 'should return null if selection starts in a paragraph and ends in a list item', () => { + setData( model, modelList( ` + Fo[o + * Bar] + ` ) ); + + expect( listStyleCommand.value ).to.equal( null ); + } ); + + it( 'should return the value of `listStyle` attribute if selection is inside a list item (collapsed selection)', () => { + setData( model, modelList( [ '* Foo[] {style:circle}' ] ) ); + + expect( listStyleCommand.value ).to.equal( 'circle' ); + } ); + + it( 'should return the value of `listStyle` attribute if selection is inside a list item (non-collapsed selection)', () => { + setData( model, modelList( [ '* [Foo] {style:square}' ] ) ); + + expect( listStyleCommand.value ).to.equal( 'square' ); + } ); + + it( 'should return the value of `listStyle` attribute if selected more elements in the same list', () => { + setData( model, modelList( ` + * [1. {style:square} + * 2.] + * 3. + ` ) ); + + expect( listStyleCommand.value ).to.equal( 'square' ); + } ); + + it( 'should return the value of `listStyle` attribute for the selection inside a nested list', () => { + setData( model, modelList( ` + * 1. {style:square} + * 1.1.[] {style:disc} + * 2. + ` ) ); + + expect( listStyleCommand.value ).to.equal( 'disc' ); + } ); + + it( 'should return the value of `listStyle` attribute from a list where the selection starts (selection over nested list)', () => { + setData( model, modelList( ` + * 1. {style:square} + * 1.1.[ {style:disc} + * 2.] + ` ) ); + + expect( listStyleCommand.value ).to.equal( 'disc' ); + } ); + } ); + + describe( 'execute()', () => { + it( 'should set the `listStyle` attribute for collapsed selection', () => { + setData( model, modelList( [ '* 1.[] {style:square}' ] ) ); + + listStyleCommand.execute( { type: 'circle' } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ '* 1.[] {style:circle}' ] ) ); + } ); + + it( 'should set the `listStyle` attribute for non-collapsed selection', () => { + setData( model, modelList( [ '* [1.] {style:disc}' ] ) ); + + listStyleCommand.execute( { type: 'circle' } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ '* [1.] {style:circle}' ] ) ); + } ); + + it( 'should set the `listStyle` attribute for all the same list items (collapsed selection)', () => { + setData( model, modelList( ` + * 1. {style:square} + * 2.[] + * 3. + ` ) ); + + listStyleCommand.execute( { type: 'circle' } ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + * 1. {style:circle} + * 2.[] + * 3. + ` ) ); + } ); + + it( 'should set the `listStyle` attribute for all the same list items and ignores nested lists (collapsed selection)', () => { + setData( model, modelList( ` + * 1.[] {style:square} + * 2. + * 2.1. {style:disc} + * 2.2 + * 3. + * 3.1. {style:disc} + ` ) ); + + listStyleCommand.execute( { type: 'circle' } ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + * 1.[] {style:circle} + * 2. + * 2.1. {style:disc} + * 2.2 + * 3. + * 3.1. {style:disc} + ` ) ); + } ); + + it( 'should set the `listStyle` attribute for all the same list items (block widget selected)', () => { + setData( model, modelList( ` + * Foo. {style:default} + * [] + * Bar. + ` ) ); + + listStyleCommand.execute( { type: 'circle' } ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + * Foo. {style:circle} + * [] + * Bar. + ` ) ); + } ); + + it( 'should set the `listStyle` attribute for all the same list items and ignores "parent" list (selection in nested list)', () => { + setData( model, modelList( ` + * 1. {style:square} + * 2. + * 2.1.[] {style:square} + * 2.2. + * 3. + * 3.1. {style:square} + ` ) ); + + listStyleCommand.execute( { type: 'disc' } ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + * 1. {style:square} + * 2. + * 2.1.[] {style:disc} + * 2.2. + * 3. + * 3.1. {style:square} + ` ) ); + } ); + + it( 'should stop searching for the list items when spotted non-listItem element', () => { + setData( model, modelList( ` + Foo. + * 1.[] {style:default} + * 2. + * 3. + ` ) ); + + listStyleCommand.execute( { type: 'circle' } ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + Foo. + * 1.[] {style:circle} + * 2. + * 3. + ` ) ); + } ); + + it( 'should stop searching for the list items when spotted listItem with different listType attribute', () => { + setData( model, modelList( ` + Foo. + * 1.[] {style:default} + * 2. + # 1. {style:default} + ` ) ); + + listStyleCommand.execute( { type: 'circle' } ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + Foo. + * 1.[] {style:circle} + * 2. + # 1. {style:default} + ` ) ); + } ); + + it( 'should set the `listStyle` attribute for selected items (non-collapsed selection)', () => { + setData( model, modelList( ` + * 1. {style:disc} + * 2a. + [2b. + 2c. + * 3a]. + 3b. + * 4. + ` ) ); + + listStyleCommand.execute( { type: 'circle' } ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + * 1. {style:circle} + * 2a. + [2b. + 2c. + * 3a]. + 3b. + * 4. + ` ) ); + } ); + + it( 'should set the `listStyle` attribute for all blocks in the list item (non-collapsed selection)', () => { + setData( model, modelList( ` + * 1. {style:disc} + * [2. + * 3]. + * 4. + ` ) ); + + listStyleCommand.execute( { type: 'circle' } ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + * 1. {style:circle} + * [2. + * 3]. + * 4. + ` ) ); + } ); + + it( 'should set the `listStyle` attribute for selected items including nested lists (non-collapsed selection)', () => { + // [x] = items that should be updated. + // All list items that belong to the same lists that selected items should be updated. + // "2." is the most outer list (listIndent=0) + // "2.1" a child list of the "2." element (listIndent=1) + // "2.1.1" a child list of the "2.1" element (listIndent=2) + // + // [x] ■ 1. + // [x] ■ [2. + // [x] ○ 2.1. + // [X] ▶ 2.1.1.] + // [x] ▶ 2.1.2. + // [x] ○ 2.2. + // [x] ■ 3. + // [ ] ○ 3.1. + // [ ] ▶ 3.1.1. + // + // "3.1" is not selected and this list should not be updated. + setData( model, modelList( ` + * 1. {style:square} + * [2. + * 2.1. {style:circle} + * 2.1.1.] {style:square} + * 2.1.2. + * 2.2. + * 3. + * 3.1. {style:square} + * 3.1.1. {style:square} + ` ) ); + + listStyleCommand.execute( { type: 'disc' } ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + * 1. {style:disc} + * [2. + * 2.1. {style:disc} + * 2.1.1.] {style:disc} + * 2.1.2. + * 2.2. + * 3. + * 3.1. {style:square} + * 3.1.1. {style:square} + ` ) ); + } ); + + it( 'should use default type if not specified (no options passed)', () => { + setData( model, modelList( [ '* 1.[] {style:circle}' ] ) ); + + listStyleCommand.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ '* 1.[] {style:default}' ] ) ); + } ); + + it( 'should use default type if not specified (passed an empty object)', () => { + setData( model, modelList( [ '* 1.[] {style:circle}' ] ) ); + + listStyleCommand.execute( {} ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ '* 1.[] {style:default}' ] ) ); + } ); + + it( 'should use default type if not specified (passed null as value)', () => { + setData( model, modelList( [ '* 1.[] {style:circle}' ] ) ); + + listStyleCommand.execute( { type: null } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ '* 1.[] {style:default}' ] ) ); + } ); + + it( 'should create a list if no listItem found in the selection (circle, non-collapsed selection)', () => { + setData( model, modelList( ` + [Foo. + Bar.] + ` ) ); + + const listCommand = editor.commands.get( 'bulletedList' ); + const spy = sinon.spy( listCommand, 'execute' ); + const createdBatches = new Set(); + + model.on( 'applyOperation', ( evt, args ) => { + const operation = args[ 0 ]; + + createdBatches.add( operation.batch ); + } ); + + listStyleCommand.execute( { type: 'circle' } ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + * [Foo. {style:circle} {id:a00} + * Bar.] {id:a01} + ` ) ); + + expect( spy.called ).to.be.true; + expect( createdBatches.size ).to.equal( 1 ); + + spy.restore(); + } ); + + it( 'should create a list if no listItem found in the selection (square, collapsed selection)', () => { + setData( model, modelList( ` + Fo[]o. + Bar. + ` ) ); + + const listCommand = editor.commands.get( 'bulletedList' ); + const spy = sinon.spy( listCommand, 'execute' ); + const createdBatches = new Set(); + + model.on( 'applyOperation', ( evt, args ) => { + const operation = args[ 0 ]; + + createdBatches.add( operation.batch ); + } ); + + listStyleCommand.execute( { type: 'circle' } ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + * Fo[]o. {id:a00} {style:circle} + Bar. + ` ) ); + + expect( spy.called ).to.be.true; + expect( createdBatches.size ).to.equal( 1 ); + + spy.restore(); + } ); + + it( 'should create a list if no listItem found in the selection (decimal, non-collapsed selection)', () => { + setData( model, modelList( ` + [Foo. + Bar.] + ` ) ); + + const listCommand = editor.commands.get( 'numberedList' ); + const spy = sinon.spy( listCommand, 'execute' ); + const createdBatches = new Set(); + + model.on( 'applyOperation', ( evt, args ) => { + const operation = args[ 0 ]; + + createdBatches.add( operation.batch ); + } ); + + listStyleCommand.execute( { type: 'decimal' } ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + # [Foo. {id:a00} {style:decimal} + # Bar.] {id:a01} + ` ) ); + + expect( spy.called ).to.be.true; + expect( createdBatches.size ).to.equal( 1 ); + + spy.restore(); + } ); + + it( 'should create a list if no listItem found in the selection (upper-roman, collapsed selection)', () => { + setData( model, modelList( ` + Fo[]o. + Bar. + ` ) ); + + const listCommand = editor.commands.get( 'numberedList' ); + const spy = sinon.spy( listCommand, 'execute' ); + const createdBatches = new Set(); + + model.on( 'applyOperation', ( evt, args ) => { + const operation = args[ 0 ]; + + createdBatches.add( operation.batch ); + } ); + + listStyleCommand.execute( { type: 'upper-roman' } ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + # Fo[]o. {id:a00} {style:upper-roman} + Bar. + ` ) ); + + expect( spy.called ).to.be.true; + expect( createdBatches.size ).to.equal( 1 ); + + spy.restore(); + } ); + + it( 'should not update anything if no listItem found in the selection (default style)', () => { + setData( model, modelList( ` + Foo.[] + ` ) ); + + listStyleCommand.execute( { type: 'default' } ); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + Foo.[] + ` ) ); + } ); + + it( 'should not update anything if no listItem found in the selection (style no specified)', () => { + setData( model, modelList( ` + Foo.[] + ` ) ); + + listStyleCommand.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( ` + Foo.[] + ` ) ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-list/tests/documentlistproperties/utils/style.js b/packages/ckeditor5-list/tests/documentlistproperties/utils/style.js new file mode 100644 index 00000000000..5a511b03299 --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlistproperties/utils/style.js @@ -0,0 +1,30 @@ +/** + * @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 { getListTypeFromListStyleType } from '../../../src/documentlistproperties/utils/style'; + +describe( 'DocumentListProperties - utils - style', () => { + describe( 'getListTypeFromListStyleType()', () => { + const testData = [ + [ 'decimal', 'numbered' ], + [ 'decimal-leading-zero', 'numbered' ], + [ 'lower-roman', 'numbered' ], + [ 'upper-roman', 'numbered' ], + [ 'lower-latin', 'numbered' ], + [ 'upper-latin', 'numbered' ], + [ 'disc', 'bulleted' ], + [ 'circle', 'bulleted' ], + [ 'square', 'bulleted' ], + [ 'default', null ], + [ 'style-type-that-is-not-possibly-supported-by-css', null ] + ]; + + for ( const [ style, type ] of testData ) { + it( `shoud return "${ type }" for "${ style }" style`, () => { + expect( getListTypeFromListStyleType( style ) ).to.equal( type ); + } ); + } + } ); +} ); diff --git a/packages/ckeditor5-list/tests/list/listediting.js b/packages/ckeditor5-list/tests/list/listediting.js index 76581dd5201..84554a83474 100644 --- a/packages/ckeditor5-list/tests/list/listediting.js +++ b/packages/ckeditor5-list/tests/list/listediting.js @@ -22,8 +22,10 @@ import IndentEditing from '@ckeditor/ckeditor5-indent/src/indentediting'; import { getCode } from '@ckeditor/ckeditor5-utils/src/keyboard'; import TableEditing from '@ckeditor/ckeditor5-table/src/tableediting'; +import TableKeyboard from '@ckeditor/ckeditor5-table/src/tablekeyboard'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import { modelTable } from '@ckeditor/ckeditor5-table/tests/_utils/utils'; describe( 'ListEditing', () => { let editor, model, modelDoc, modelRoot, view, viewDoc, viewRoot; @@ -34,7 +36,7 @@ describe( 'ListEditing', () => { return VirtualTestEditor .create( { plugins: [ Paragraph, IndentEditing, ClipboardPipeline, BoldEditing, ListEditing, UndoEditing, BlockQuoteEditing, - TableEditing ] + TableEditing, TableKeyboard ] } ) .then( newEditor => { editor = newEditor; @@ -375,7 +377,7 @@ describe( 'ListEditing', () => { } ); it( 'should execute outdentList command on Shift+Tab keystroke', () => { - domEvtDataStub.keyCode += getCode( 'Shift' ); + domEvtDataStub.shiftKey = true; setModelData( model, @@ -416,6 +418,103 @@ describe( 'ListEditing', () => { sinon.assert.notCalled( domEvtDataStub.preventDefault ); sinon.assert.notCalled( domEvtDataStub.stopPropagation ); } ); + + it( 'should execute list indent command when in a li context and nested in an element that also listens to Tab', () => { + const listInputModel = 'foo' + + '[]bar'; + + const listOutputModel = 'foo' + + '[]bar'; + + const input = modelTable( [ + [ listInputModel, 'bar' ] + ] ); + + const output = modelTable( [ + [ listOutputModel, 'bar' ] + ] ); + + setModelData( model, input ); + + editor.editing.view.document.fire( 'keydown', domEvtDataStub ); + + sinon.assert.calledWithExactly( editor.execute, 'indentList' ); + sinon.assert.calledOnce( domEvtDataStub.preventDefault ); + sinon.assert.calledOnce( domEvtDataStub.stopPropagation ); + expect( getModelData( model ) ).to.equalMarkup( output ); + } ); + + it( 'should execute list outdent command when in a li context and nested in an element that also listens to Tab', () => { + const listInputModel = 'foo' + + '[]bar'; + + const listOutputModel = 'foo' + + '[]bar'; + + const input = modelTable( [ + [ listInputModel, 'bar' ] + ] ); + + const output = modelTable( [ + [ listOutputModel, 'bar' ] + ] ); + + setModelData( model, input ); + + domEvtDataStub.shiftKey = true; + + editor.editing.view.document.fire( 'keydown', domEvtDataStub ); + + sinon.assert.calledWithExactly( editor.execute, 'outdentList' ); + sinon.assert.calledOnce( domEvtDataStub.preventDefault ); + sinon.assert.calledOnce( domEvtDataStub.stopPropagation ); + expect( getModelData( model ) ).to.equalMarkup( output ); + } ); + + it( 'should not capture event when list cannot be indented and allow other listeners to capture it', () => { + const listInputModel = 'bar[]'; + const listOutputModel = 'bar'; + + const input = modelTable( [ + [ 'foo', listInputModel ] + ] ); + + const output = modelTable( [ + [ 'foo', listOutputModel ], + [ '[]', '' ] + ] ); + + setModelData( model, input ); + + editor.editing.view.document.fire( 'keydown', domEvtDataStub ); + + sinon.assert.neverCalledWith( editor.execute, 'indentList' ); + sinon.assert.neverCalledWith( editor.execute, 'outdentList' ); + sinon.assert.calledOnce( domEvtDataStub.preventDefault ); + sinon.assert.calledOnce( domEvtDataStub.stopPropagation ); + expect( getModelData( model ) ).to.equalMarkup( output ); + } ); + + it( 'should not capture event when not in a list and should allow other listeners to capture it', () => { + const input = modelTable( [ + [ 'foo', 'bar[]' ] + ] ); + + const output = modelTable( [ + [ 'foo', 'bar' ], + [ '[]', '' ] + ] ); + + setModelData( model, input ); + + editor.editing.view.document.fire( 'keydown', domEvtDataStub ); + + sinon.assert.neverCalledWith( editor.execute, 'indentList' ); + sinon.assert.neverCalledWith( editor.execute, 'outdentList' ); + sinon.assert.calledOnce( domEvtDataStub.preventDefault ); + sinon.assert.calledOnce( domEvtDataStub.stopPropagation ); + expect( getModelData( model ) ).to.equalMarkup( output ); + } ); } ); describe( 'flat lists', () => { diff --git a/packages/ckeditor5-list/tests/listproperties/liststylecommand.js b/packages/ckeditor5-list/tests/listproperties/liststylecommand.js index c5eb112204c..998fa67151f 100644 --- a/packages/ckeditor5-list/tests/listproperties/liststylecommand.js +++ b/packages/ckeditor5-list/tests/listproperties/liststylecommand.js @@ -45,7 +45,7 @@ describe( 'ListStyleCommand', () => { expect( listStyleCommand.isEnabled ).to.equal( true ); } ); - it( 'should be false if bulletedList and numberedList are enabled', () => { + it( 'should be false if bulletedList and numberedList are disabled', () => { bulletedListCommand.isEnabled = false; numberedListCommand.isEnabled = false; diff --git a/packages/ckeditor5-list/tests/manual/documentlist-properties-all.html b/packages/ckeditor5-list/tests/manual/documentlist-properties-all.html new file mode 100644 index 00000000000..67eb57eaea7 --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/documentlist-properties-all.html @@ -0,0 +1,47 @@ +
                + + +
                + +
                +
                  +
                1. A numbered list with lower-roman numbering that starts at 3.
                2. +
                3. Item with multiple blocks...
                  ...and this is the 3rd block of it.
                4. +
                5. This is a list item with two paragraphs.

                  And here is the last one.

                6. +
                +
                  +
                • +

                  This is a multi block item of a square bulleted list.

                  +
                  +

                  And there is a block widget above.

                  +
                • +
                • +

                  Other item with multi blocks...

                  +

                  ...and nested list:

                  +
                    +
                  1. It's numbered, lower-alpha that starts at 3...

                  2. +
                  3. ...and it is reversed.

                  4. +
                  +

                  Here is also a block after a nested list.

                  +
                • +
                +
                + + diff --git a/packages/ckeditor5-list/tests/manual/documentlist-properties-all.js b/packages/ckeditor5-list/tests/manual/documentlist-properties-all.js new file mode 100644 index 00000000000..786d6c4a734 --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/documentlist-properties-all.js @@ -0,0 +1,123 @@ +/** + * @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 + */ + +/* globals console, window, document */ + +import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'; +import Alignment from '@ckeditor/ckeditor5-alignment/src/alignment'; +import AutoImage from '@ckeditor/ckeditor5-image/src/autoimage'; +import CodeBlock from '@ckeditor/ckeditor5-code-block/src/codeblock'; +import EasyImage from '@ckeditor/ckeditor5-easy-image/src/easyimage'; +import HorizontalLine from '@ckeditor/ckeditor5-horizontal-line/src/horizontalline'; +import HtmlEmbed from '@ckeditor/ckeditor5-html-embed/src/htmlembed'; +import HtmlComment from '@ckeditor/ckeditor5-html-support/src/htmlcomment'; +import ImageResize from '@ckeditor/ckeditor5-image/src/imageresize'; +import LinkImage from '@ckeditor/ckeditor5-link/src/linkimage'; +import PageBreak from '@ckeditor/ckeditor5-page-break/src/pagebreak'; +import SourceEditing from '@ckeditor/ckeditor5-source-editing/src/sourceediting'; +import TableCaption from '@ckeditor/ckeditor5-table/src/tablecaption'; +import CloudServices from '@ckeditor/ckeditor5-cloud-services/src/cloudservices'; +import ImageUpload from '@ckeditor/ckeditor5-image/src/imageupload'; +import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials'; +import BlockQuote from '@ckeditor/ckeditor5-block-quote/src/blockquote'; +import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold'; +import Heading from '@ckeditor/ckeditor5-heading/src/heading'; +import Image from '@ckeditor/ckeditor5-image/src/image'; +import ImageCaption from '@ckeditor/ckeditor5-image/src/imagecaption'; +import ImageStyle from '@ckeditor/ckeditor5-image/src/imagestyle'; +import ImageToolbar from '@ckeditor/ckeditor5-image/src/imagetoolbar'; +import Indent from '@ckeditor/ckeditor5-indent/src/indent'; +import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic'; +import Link from '@ckeditor/ckeditor5-link/src/link'; +import MediaEmbed from '@ckeditor/ckeditor5-media-embed/src/mediaembed'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import Table from '@ckeditor/ckeditor5-table/src/table'; +import TableToolbar from '@ckeditor/ckeditor5-table/src/tabletoolbar'; + +import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config'; + +import DocumentList from '../../src/documentlist'; +import DocumentListProperties from '../../src/documentlistproperties'; + +ClassicEditor + .create( document.querySelector( '#editor' ), { + ...( { + plugins: [ + Essentials, BlockQuote, Bold, Heading, Image, ImageCaption, ImageStyle, ImageToolbar, Indent, Italic, Link, + MediaEmbed, Paragraph, Table, TableToolbar, CodeBlock, TableCaption, EasyImage, ImageResize, LinkImage, + AutoImage, HtmlEmbed, HtmlComment, Alignment, PageBreak, HorizontalLine, ImageUpload, + CloudServices, SourceEditing, DocumentList, DocumentListProperties + ], + toolbar: [ + 'sourceEditing', '|', + 'numberedList', 'bulletedList', '|', + 'outdent', 'indent', '|', + 'heading', '|', + 'bold', 'italic', 'link', '|', + 'blockQuote', 'uploadImage', 'insertTable', 'mediaEmbed', 'codeBlock', '|', + 'htmlEmbed', '|', + 'alignment', '|', + 'pageBreak', 'horizontalLine', '|', + 'undo', 'redo' + ], + cloudServices: CS_CONFIG, + table: { + contentToolbar: [ + 'tableColumn', 'tableRow', 'mergeTableCells', 'toggleTableCaption' + ] + }, + image: { + styles: [ + 'alignCenter', + 'alignLeft', + 'alignRight' + ], + resizeOptions: [ + { + name: 'resizeImage:original', + label: 'Original size', + value: null + }, + { + name: 'resizeImage:50', + label: '50%', + value: '50' + }, + { + name: 'resizeImage:75', + label: '75%', + value: '75' + } + ], + toolbar: [ + 'imageTextAlternative', 'toggleImageCaption', '|', + 'imageStyle:inline', 'imageStyle:wrapText', 'imageStyle:breakText', 'imageStyle:side', '|', + 'resizeImage' + ] + }, + placeholder: 'Type the content here!', + htmlEmbed: { + showPreviews: true, + sanitizeHtml: html => ( { html, hasChange: false } ) + } + } ), + list: { + properties: { + styles: true, + startIndex: true, + reversed: true + } + } + } ) + .then( editor => { + window.editor = editor; + } ) + .catch( err => { + console.error( err.stack ); + } ); + +document.getElementById( 'chbx-show-borders' ).addEventListener( 'change', () => { + document.body.classList.toggle( 'show-borders' ); +} ); diff --git a/packages/ckeditor5-list/tests/manual/documentlist-properties-all.md b/packages/ckeditor5-list/tests/manual/documentlist-properties-all.md new file mode 100644 index 00000000000..2152f0ca9b7 --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/documentlist-properties-all.md @@ -0,0 +1,8 @@ +# List properties feature + +This is a single editor instance with all `DocumentListProperties` enabled; +* list style; +* list start (for numbered lists); +* list reversed (for numbered lists). + +Border colors: Blue for `ul` and `ol`, red for `li`. diff --git a/packages/ckeditor5-list/tests/manual/documentlist-properties.html b/packages/ckeditor5-list/tests/manual/documentlist-properties.html new file mode 100644 index 00000000000..fcdefd07c35 --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/documentlist-properties.html @@ -0,0 +1,133 @@ +

                With styles and other properties

                + +

                Styles + Start index + Reversed

                +
                +

                Ordered list

                +
                  +
                1. First item
                2. +
                3. Second item
                4. +
                5. Third item
                6. +
                +

                Unordered list

                +
                  +
                • First item
                • +
                • Second item
                • +
                • Third item
                • +
                +
                + +

                Styles + Start index

                +
                +

                Ordered list

                +
                  +
                1. First item
                2. +
                3. Second item
                4. +
                5. Third item
                6. +
                +

                Unordered list

                +
                  +
                • First item
                • +
                • Second item
                • +
                • Third item
                • +
                +
                + +

                Styles + Reversed

                +
                +

                Ordered list

                +
                  +
                1. First item
                2. +
                3. Second item
                4. +
                5. Third item
                6. +
                +

                Unordered list

                +
                  +
                • First item
                • +
                • Second item
                • +
                • Third item
                • +
                +
                + +

                Without styles, just extra properties

                + +

                Start index + Reversed

                +
                +

                Ordered list

                +
                  +
                1. First item
                2. +
                3. Second item
                4. +
                5. Third item
                6. +
                +

                Unordered list

                +
                  +
                • First item
                • +
                • Second item
                • +
                • Third item
                • +
                +
                + +

                Start index

                +
                +

                Ordered list

                +
                  +
                1. First item
                2. +
                3. Second item
                4. +
                5. Third item
                6. +
                +

                Unordered list

                +
                  +
                • First item
                • +
                • Second item
                • +
                • Third item
                • +
                +
                + +

                Reversed

                +
                +

                Ordered list

                +
                  +
                1. First item
                2. +
                3. Second item
                4. +
                5. Third item
                6. +
                +

                Unordered list

                +
                  +
                • First item
                • +
                • Second item
                • +
                • Third item
                • +
                +
                + +

                Just styles

                + +
                +

                Ordered list

                +
                  +
                1. First item
                2. +
                3. Second item
                4. +
                5. Third item
                6. +
                +

                Unordered list

                +
                  +
                • First item
                • +
                • Second item
                • +
                • Third item
                • +
                +
                + +

                No properties enabled

                + +
                +

                Ordered list

                +
                  +
                1. First item
                2. +
                3. Second item
                4. +
                5. Third item
                6. +
                +

                Unordered list

                +
                  +
                • First item
                • +
                • Second item
                • +
                • Third item
                • +
                +
                diff --git a/packages/ckeditor5-list/tests/manual/documentlist-properties.js b/packages/ckeditor5-list/tests/manual/documentlist-properties.js new file mode 100644 index 00000000000..ad1bda1cfad --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/documentlist-properties.js @@ -0,0 +1,169 @@ +/** + * @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 + */ + +/* globals console, window, document, CKEditorInspector */ + +import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'; +import Alignment from '@ckeditor/ckeditor5-alignment/src/alignment'; +import AutoImage from '@ckeditor/ckeditor5-image/src/autoimage'; +import CodeBlock from '@ckeditor/ckeditor5-code-block/src/codeblock'; +import EasyImage from '@ckeditor/ckeditor5-easy-image/src/easyimage'; +import HorizontalLine from '@ckeditor/ckeditor5-horizontal-line/src/horizontalline'; +import HtmlEmbed from '@ckeditor/ckeditor5-html-embed/src/htmlembed'; +import HtmlComment from '@ckeditor/ckeditor5-html-support/src/htmlcomment'; +import ImageResize from '@ckeditor/ckeditor5-image/src/imageresize'; +import LinkImage from '@ckeditor/ckeditor5-link/src/linkimage'; +import PageBreak from '@ckeditor/ckeditor5-page-break/src/pagebreak'; +import SourceEditing from '@ckeditor/ckeditor5-source-editing/src/sourceediting'; +import TableCaption from '@ckeditor/ckeditor5-table/src/tablecaption'; +import CloudServices from '@ckeditor/ckeditor5-cloud-services/src/cloudservices'; +import ImageUpload from '@ckeditor/ckeditor5-image/src/imageupload'; +import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials'; +import BlockQuote from '@ckeditor/ckeditor5-block-quote/src/blockquote'; +import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold'; +import Heading from '@ckeditor/ckeditor5-heading/src/heading'; +import Image from '@ckeditor/ckeditor5-image/src/image'; +import ImageCaption from '@ckeditor/ckeditor5-image/src/imagecaption'; +import ImageStyle from '@ckeditor/ckeditor5-image/src/imagestyle'; +import ImageToolbar from '@ckeditor/ckeditor5-image/src/imagetoolbar'; +import Indent from '@ckeditor/ckeditor5-indent/src/indent'; +import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic'; +import Link from '@ckeditor/ckeditor5-link/src/link'; +import MediaEmbed from '@ckeditor/ckeditor5-media-embed/src/mediaembed'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import Table from '@ckeditor/ckeditor5-table/src/table'; +import TableToolbar from '@ckeditor/ckeditor5-table/src/tabletoolbar'; + +import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config'; + +import DocumentList from '../../src/documentlist'; +import DocumentListProperties from '../../src/documentlistproperties'; + +const config = { + plugins: [ + Essentials, BlockQuote, Bold, Heading, Image, ImageCaption, ImageStyle, ImageToolbar, Indent, Italic, Link, + MediaEmbed, Paragraph, Table, TableToolbar, CodeBlock, TableCaption, EasyImage, ImageResize, LinkImage, + AutoImage, HtmlEmbed, HtmlComment, Alignment, PageBreak, HorizontalLine, ImageUpload, + CloudServices, SourceEditing, DocumentList, DocumentListProperties + ], + toolbar: [ + 'sourceEditing', '|', + 'numberedList', 'bulletedList', + 'outdent', 'indent', '|', + 'heading', '|', + 'bold', 'italic', 'link', '|', + 'blockQuote', 'uploadImage', 'insertTable', 'mediaEmbed', 'codeBlock', '|', + 'htmlEmbed', '|', + 'alignment', '|', + 'pageBreak', 'horizontalLine', '|', + 'undo', 'redo' + ], + cloudServices: CS_CONFIG, + table: { + contentToolbar: [ + 'tableColumn', 'tableRow', 'mergeTableCells', 'toggleTableCaption' + ] + }, + image: { + styles: [ + 'alignCenter', + 'alignLeft', + 'alignRight' + ], + resizeOptions: [ + { + name: 'resizeImage:original', + label: 'Original size', + value: null + }, + { + name: 'resizeImage:50', + label: '50%', + value: '50' + }, + { + name: 'resizeImage:75', + label: '75%', + value: '75' + } + ], + toolbar: [ + 'imageTextAlternative', 'toggleImageCaption', '|', + 'imageStyle:inline', 'imageStyle:wrapText', 'imageStyle:breakText', 'imageStyle:side', '|', + 'resizeImage' + ] + }, + placeholder: 'Type the content here!', + htmlEmbed: { + showPreviews: true, + sanitizeHtml: html => ( { html, hasChange: false } ) + } +}; + +function createEditor( idSuffix, properties ) { + ClassicEditor + .create( document.querySelector( '#editor-' + idSuffix ), { + ...config, + list: { + properties + } + } ) + .then( editor => { + window[ 'editor_' + idSuffix ] = editor; + + CKEditorInspector.attach( { [ idSuffix ]: editor } ); + } ) + .catch( err => { + console.error( err.stack ); + } ); +} + +createEditor( 'all', { + styles: true, + startIndex: true, + reversed: true +} ); + +createEditor( 'style-start', { + styles: true, + startIndex: true, + reversed: false +} ); + +createEditor( 'style-reversed', { + styles: true, + startIndex: false, + reversed: true +} ); + +createEditor( 'start-reversed', { + styles: false, + startIndex: true, + reversed: true +} ); + +createEditor( 'start', { + styles: false, + startIndex: true, + reversed: false +} ); + +createEditor( 'reversed', { + styles: false, + startIndex: false, + reversed: true +} ); + +createEditor( 'style', { + styles: true, + startIndex: false, + reversed: false +} ); + +createEditor( 'none', { + styles: false, + startIndex: false, + reversed: false +} ); diff --git a/packages/ckeditor5-list/tests/manual/documentlist-properties.md b/packages/ckeditor5-list/tests/manual/documentlist-properties.md new file mode 100644 index 00000000000..bb4b2fbad26 --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/documentlist-properties.md @@ -0,0 +1,20 @@ +# List properties feature + +Several editors were configured in this manual test. + +## Basics + +1. Open the numbered list dropdown in each editor. +2. Make sure the UI of the dropdown matches the description of the editor. +3. Open the bulleted list dropdown and make sure it always looks the same. + +**Note**: When list styles are disabled, the bulleted list dropdown should become a simple button. + +## Accessibility + +1. In each editor, focus the editing root and hit (Fn+)Alt+F10. +2. Hit arrow down when the numbered list dropdown is highlighted. +3. Hit arrow down (or up) again to focus the first (last) item in the dropdown. +4. Navigate using Tab across the UI. +5. Make sure the navigation works both ways by using Shift+Tab. +6. Make sure you can enter numbered list properties when collapsed. diff --git a/packages/ckeditor5-list/tests/manual/documentlist.html b/packages/ckeditor5-list/tests/manual/documentlist.html new file mode 100644 index 00000000000..5db1966a822 --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/documentlist.html @@ -0,0 +1,124 @@ +
                + + +
                + +
                + a +
                  +
                • +
                    +
                  • +

                    b

                    +
                  • +
                  +
                • +
                +

                Document lists

                +
                  +
                • +

                  First

                  +

                  Second

                  +

                  Third

                  +
                • +
                • barSecond list item
                • +
                • Another list item
                • +
                • Jet another list item
                • +
                • +

                  2 First

                  +

                  2 Second

                  +

                  2 Third

                  +
                • +
                • +

                  First

                  +
                    +
                  • Nested a
                  • +
                  • Nested b
                  • +
                  • +

                    Nested multi a

                    +

                    Nested multi b

                    +
                  • +
                  +
                • +
                • + before horizontal line +
                  + after horizontal line +
                • +
                • + before page break +
                   
                  + after page break +
                • +
                • + before image +
                  + bar +
                  + after image +
                • +
                • + before image with caption +
                  + bar +
                  Caption
                  +
                  + after image with caption +
                • +
                • +

                  Heading 1

                  +

                  Text

                  +

                  Text

                  +
                • +
                • Heading 2

                • +
                • Heading 3

                • +
                • + before block quote +
                  +

                  Quote

                  +
                    +
                  • List in quote
                  • +
                  +

                  End of quote

                  +
                  + after block quote +
                • +
                • + before table + + + + + + +
                  FooBar
                  +
                  • 1
                  • 2
                  +
                  + 123 +
                  + after table +
                • +
                • +
                  abc
                  +
                • +
                +
                + + diff --git a/packages/ckeditor5-list/tests/manual/documentlist.js b/packages/ckeditor5-list/tests/manual/documentlist.js new file mode 100644 index 00000000000..4df9cec5aa9 --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/documentlist.js @@ -0,0 +1,114 @@ +/** + * @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 + */ + +/* globals console, window, document */ + +import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'; +import Alignment from '@ckeditor/ckeditor5-alignment/src/alignment'; +import AutoImage from '@ckeditor/ckeditor5-image/src/autoimage'; +import CodeBlock from '@ckeditor/ckeditor5-code-block/src/codeblock'; +import EasyImage from '@ckeditor/ckeditor5-easy-image/src/easyimage'; +import HorizontalLine from '@ckeditor/ckeditor5-horizontal-line/src/horizontalline'; +import HtmlEmbed from '@ckeditor/ckeditor5-html-embed/src/htmlembed'; +import HtmlComment from '@ckeditor/ckeditor5-html-support/src/htmlcomment'; +import ImageResize from '@ckeditor/ckeditor5-image/src/imageresize'; +import LinkImage from '@ckeditor/ckeditor5-link/src/linkimage'; +import PageBreak from '@ckeditor/ckeditor5-page-break/src/pagebreak'; +import SourceEditing from '@ckeditor/ckeditor5-source-editing/src/sourceediting'; +import TableCaption from '@ckeditor/ckeditor5-table/src/tablecaption'; +import CloudServices from '@ckeditor/ckeditor5-cloud-services/src/cloudservices'; +import ImageUpload from '@ckeditor/ckeditor5-image/src/imageupload'; +import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials'; +import BlockQuote from '@ckeditor/ckeditor5-block-quote/src/blockquote'; +import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold'; +import Heading from '@ckeditor/ckeditor5-heading/src/heading'; +import Image from '@ckeditor/ckeditor5-image/src/image'; +import ImageCaption from '@ckeditor/ckeditor5-image/src/imagecaption'; +import ImageStyle from '@ckeditor/ckeditor5-image/src/imagestyle'; +import ImageToolbar from '@ckeditor/ckeditor5-image/src/imagetoolbar'; +import Indent from '@ckeditor/ckeditor5-indent/src/indent'; +import IndentBlock from '@ckeditor/ckeditor5-indent/src/indentblock'; +import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic'; +import Link from '@ckeditor/ckeditor5-link/src/link'; +import MediaEmbed from '@ckeditor/ckeditor5-media-embed/src/mediaembed'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import Table from '@ckeditor/ckeditor5-table/src/table'; +import TableToolbar from '@ckeditor/ckeditor5-table/src/tabletoolbar'; + +import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config'; + +import DocumentList from '../../src/documentlist'; + +ClassicEditor + .create( document.querySelector( '#editor' ), { + plugins: [ + Essentials, BlockQuote, Bold, Heading, Image, ImageCaption, ImageStyle, ImageToolbar, Indent, IndentBlock, Italic, Link, + MediaEmbed, Paragraph, Table, TableToolbar, CodeBlock, TableCaption, EasyImage, ImageResize, LinkImage, + AutoImage, HtmlEmbed, HtmlComment, Alignment, PageBreak, HorizontalLine, ImageUpload, + CloudServices, SourceEditing, DocumentList + ], + toolbar: [ + 'sourceEditing', '|', + 'numberedList', 'bulletedList', + 'outdent', 'indent', '|', + 'heading', '|', + 'bold', 'italic', 'link', '|', + 'blockQuote', 'uploadImage', 'insertTable', 'mediaEmbed', 'codeBlock', '|', + 'htmlEmbed', '|', + 'alignment', '|', + 'pageBreak', 'horizontalLine', '|', + 'undo', 'redo' + ], + cloudServices: CS_CONFIG, + table: { + contentToolbar: [ + 'tableColumn', 'tableRow', 'mergeTableCells', 'toggleTableCaption' + ] + }, + image: { + styles: [ + 'alignCenter', + 'alignLeft', + 'alignRight' + ], + resizeOptions: [ + { + name: 'resizeImage:original', + label: 'Original size', + value: null + }, + { + name: 'resizeImage:50', + label: '50%', + value: '50' + }, + { + name: 'resizeImage:75', + label: '75%', + value: '75' + } + ], + toolbar: [ + 'imageTextAlternative', 'toggleImageCaption', '|', + 'imageStyle:inline', 'imageStyle:wrapText', 'imageStyle:breakText', 'imageStyle:side', '|', + 'resizeImage' + ] + }, + placeholder: 'Type the content here!', + htmlEmbed: { + showPreviews: true, + sanitizeHtml: html => ( { html, hasChange: false } ) + } + } ) + .then( editor => { + window.editor = editor; + } ) + .catch( err => { + console.error( err.stack ); + } ); + +document.getElementById( 'chbx-show-borders' ).addEventListener( 'change', () => { + document.body.classList.toggle( 'show-borders' ); +} ); diff --git a/packages/ckeditor5-list/tests/manual/documentlist.md b/packages/ckeditor5-list/tests/manual/documentlist.md new file mode 100644 index 00000000000..a93659d24e7 --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/documentlist.md @@ -0,0 +1,10 @@ +## Document List + +The basic document list feature. + +Supported features: +* list type (bulleted, numbered); +* list indentation; +* merging/splitting list items. + +Border colors: Blue for `ul` and `ol`, red for `li`. diff --git a/packages/ckeditor5-list/tests/manual/listmocking.html b/packages/ckeditor5-list/tests/manual/listmocking.html new file mode 100644 index 00000000000..c369169a17f --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/listmocking.html @@ -0,0 +1,105 @@ + + +

                Input

                +
                + + + + + +
                + +
                + + + + + + + + +
                + +
                +
                + +

                Output

                +
                + +
                +
                
                diff --git a/packages/ckeditor5-list/tests/manual/listmocking.js b/packages/ckeditor5-list/tests/manual/listmocking.js
                new file mode 100644
                index 00000000000..43ad932d6b6
                --- /dev/null
                +++ b/packages/ckeditor5-list/tests/manual/listmocking.js
                @@ -0,0 +1,217 @@
                +/**
                + * @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
                + */
                +
                +/* globals console, window, document */
                +
                +import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
                +import Enter from '@ckeditor/ckeditor5-enter/src/enter';
                +import Typing from '@ckeditor/ckeditor5-typing/src/typing';
                +import Heading from '@ckeditor/ckeditor5-heading/src/heading';
                +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 Table from '@ckeditor/ckeditor5-table/src/table';
                +import TableToolbar from '@ckeditor/ckeditor5-table/src/tabletoolbar';
                +import { toWidget } from '@ckeditor/ckeditor5-widget/src/utils';
                +import {
                +	parse as parseModel,
                +	setData as setModelData,
                +	getData as getModelData
                +} from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
                +
                +import { modelList, stringifyList } from '../documentlist/_utils/utils';
                +import DocumentList from '../../src/documentlist';
                +
                +ClassicEditor
                +	.create( document.querySelector( '#editor' ), {
                +		plugins: [ Enter, Typing, Heading, Paragraph, Undo, Clipboard, DocumentList, Indent, Widget, Table, TableToolbar ],
                +		toolbar: [ 'heading', '|', 'bulletedList', 'numberedList', 'outdent', 'indent', '|', 'insertTable', '|', 'undo', 'redo' ],
                +		table: {
                +			contentToolbar: [
                +				'tableColumn', 'tableRow', 'mergeTableCells', 'toggleTableCaption'
                +			]
                +		}
                +	} )
                +	.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' +
                +			'D\n' +
                +			'E\n' +
                +			'F';
                +
                +		document.getElementById( 'data-input' ).value = model;
                +		document.getElementById( 'btn-process-input' ).click();
                +	} )
                +	.catch( err => {
                +		console.error( err.stack );
                +	} );
                +
                +const copyOutput = async () => {
                +	if ( !window.navigator.clipboard ) {
                +		console.warn( 'Cannot copy output. Clipboard API requires HTTPS or localhost.' );
                +		return;
                +	}
                +
                +	const output = document.getElementById( 'data-output' ).innerText;
                +
                +	await window.navigator.clipboard.writeText( output );
                +
                +	const copyButton = document.getElementById( 'btn-copy-output' );
                +	const label = document.createElement( 'span' );
                +
                +	label.id = 'btn-copy-label';
                +	label.innerText = 'Copied!';
                +
                +	copyButton.appendChild( label );
                +
                +	window.setTimeout( () => {
                +		label.className = 'hide';
                +	}
                +	, 0 );
                +
                +	window.setTimeout( () => {
                +		label.remove();
                +	}, 1000 );
                +};
                +
                +const getListModelWithNewLines = stringifiedModel => {
                +	return stringifiedModel.replace( /<\/(paragraph|heading\d)>/g, '\n' );
                +};
                +
                +const setModelDataFromAscii = () => {
                +	const asciiList = document.getElementById( 'data-input' ).value;
                +	const modelDataArray = asciiList.replace( /^[^']*'|'[^']*$/gm, '' ).split( '\n' );
                +
                +	const editorModelString = modelList( modelDataArray );
                +
                +	setModelData( window.editor.model, editorModelString );
                +	document.getElementById( 'data-output' ).innerText = getListModelWithNewLines( editorModelString );
                +};
                +
                +const createAsciiListCodeSnippet = stringifiedAsciiList => {
                +	const asciiList = stringifiedAsciiList.split( '\n' );
                +
                +	const asciiListToInsertInArray = asciiList.map( ( element, index ) => {
                +		if ( index === asciiList.length - 1 ) {
                +			return `'${ element }'`;
                +		}
                +
                +		return `'${ element }',`;
                +	} );
                +
                +	const asciiListCodeSnippet = 'modelList( [\n\t' +
                +		asciiListToInsertInArray.join( '\n\t' ) +
                +		'\n] );';
                +
                +	return asciiListCodeSnippet;
                +};
                +
                +const setAsciiListFromModel = () => {
                +	const editorModelString = document.getElementById( 'data-input' ).value;
                +	const cleanedEditorModelString = editorModelString.replace( /^[^']*'|'[^']*$|\n|\r/gm, '' );
                +
                +	const editorModel = parseModel( cleanedEditorModelString, window.editor.model.schema );
                +	const asciiListCodeSnippet = createAsciiListCodeSnippet( stringifyList( editorModel ) );
                +
                +	document.getElementById( 'data-output' ).innerText = asciiListCodeSnippet;
                +	setModelData( window.editor.model, cleanedEditorModelString );
                +};
                +
                +const processInput = () => {
                +	const dataType = document.querySelector( 'input[name="input-type"]:checked' ).value;
                +
                +	if ( dataType === 'model' ) {
                +		setAsciiListFromModel();
                +	}
                +
                +	if ( dataType === 'ascii' ) {
                +		setModelDataFromAscii();
                +	}
                +
                +	window.editor.focus();
                +
                +	if ( document.getElementById( 'chbx-should-copy' ).checked ) {
                +		copyOutput();
                +	}
                +};
                +
                +const processEditorModel = () => {
                +	const dataType = document.querySelector( 'input[name="input-type"]:checked' ).value;
                +
                +	if ( dataType === 'model' ) {
                +		const editorModelStringWithNewLines = getListModelWithNewLines( getModelData( window.editor.model, { withoutSelection: true } ) );
                +
                +		document.getElementById( 'data-input' ).value = editorModelStringWithNewLines;
                +	}
                +
                +	if ( dataType === 'ascii' ) {
                +		const stringifiedEditorModel = getModelData( window.editor.model, { withoutSelection: true } );
                +		const editorModel = parseModel( stringifiedEditorModel, window.editor.model.schema );
                +
                +		document.getElementById( 'data-input' ).value = createAsciiListCodeSnippet( stringifyList( editorModel ) );
                +	}
                +
                +	processInput();
                +};
                +
                +const onPaste = () => {
                +	if ( document.getElementById( 'chbx-process-on-paste' ).checked ) {
                +		window.setTimeout( processInput, 0 );
                +	}
                +};
                +
                +const onHighlightChange = () => {
                +	document.querySelector( '.ck-editor' ).classList.toggle( 'highlight-lists' );
                +};
                +
                +document.getElementById( 'btn-process-input' ).addEventListener( 'click', processInput );
                +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', onHighlightChange );
                +
                diff --git a/packages/ckeditor5-list/tests/manual/listmocking.md b/packages/ckeditor5-list/tests/manual/listmocking.md
                new file mode 100644
                index 00000000000..72f2a685043
                --- /dev/null
                +++ b/packages/ckeditor5-list/tests/manual/listmocking.md
                @@ -0,0 +1,35 @@
                +## Description
                +Main purpose of this tool is to process editor's model to ASCII art that can be used in automatic tests so they are more readable.
                +It also allows to process ASCII art back to model data. You can provide your own editor's model/ASCII art to the input and parse it or you can create list in an editor and get model/ASCII from it.
                +
                +### ASCII Tree
                +
                +```
                +* A
                +  B
                +  # C{id:50}
                +    # D
                +* E
                +* F
                +
                +* - bulleted list
                +# - numbered list
                +---
                +{id:fixedId} - force given id as listItemId
                +attribute in model.
                +---
                +Each indentation is two spaces before list
                +type.
                +```
                +
                +## Input
                +Input should be valid editor's model or an ASCII art created in this tool. Processing function tries to be a little bit smart (naively) and cleans input so it can be copied and pasted from code - it will get rid of spaces, new lines and other characters not allowed in model.
                +
                +## Editor
                +Editor allows to inspect how the processed data renders in the editor. You can also create your list in the editor and create model/ASCII from it with 'Process editor model' button.
                +
                +## Output
                +### When input is model
                +It should create ASCII tree as a code ready to be pasted in tests.
                +### When input is ASCII
                +It should create correct editor's model.
                diff --git a/packages/ckeditor5-list/tests/manual/sample.jpg b/packages/ckeditor5-list/tests/manual/sample.jpg
                new file mode 100644
                index 00000000000..b77d07e7bff
                Binary files /dev/null and b/packages/ckeditor5-list/tests/manual/sample.jpg differ
                diff --git a/packages/ckeditor5-list/theme/documentlist.css b/packages/ckeditor5-list/theme/documentlist.css
                new file mode 100644
                index 00000000000..e156479eb22
                --- /dev/null
                +++ b/packages/ckeditor5-list/theme/documentlist.css
                @@ -0,0 +1,8 @@
                +/*
                + * 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 {
                +	display: block;
                +}
                diff --git a/packages/ckeditor5-media-embed/src/automediaembed.js b/packages/ckeditor5-media-embed/src/automediaembed.js
                index 29aaec2d39a..e8626ad35ef 100644
                --- a/packages/ckeditor5-media-embed/src/automediaembed.js
                +++ b/packages/ckeditor5-media-embed/src/automediaembed.js
                @@ -170,7 +170,7 @@ export default class AutoMediaEmbed extends Plugin {
                 					insertionPosition = this._positionToInsert;
                 				}
                 
                -				insertMedia( editor.model, url, insertionPosition );
                +				insertMedia( editor.model, url, insertionPosition, false );
                 
                 				this._positionToInsert.detach();
                 				this._positionToInsert = null;
                diff --git a/packages/ckeditor5-media-embed/src/mediaembedcommand.js b/packages/ckeditor5-media-embed/src/mediaembedcommand.js
                index c0e591546ce..a42c0e122ca 100644
                --- a/packages/ckeditor5-media-embed/src/mediaembedcommand.js
                +++ b/packages/ckeditor5-media-embed/src/mediaembedcommand.js
                @@ -55,7 +55,7 @@ export default class MediaEmbedCommand extends Command {
                 				writer.setAttribute( 'url', url, selectedMedia );
                 			} );
                 		} else {
                -			insertMedia( model, url, findOptimalInsertionRange( selection, model ) );
                +			insertMedia( model, url, selection, true );
                 		}
                 	}
                 }
                diff --git a/packages/ckeditor5-media-embed/src/mediaembedediting.js b/packages/ckeditor5-media-embed/src/mediaembedediting.js
                index e1aefc1c046..3938e8d9a96 100644
                --- a/packages/ckeditor5-media-embed/src/mediaembedediting.js
                +++ b/packages/ckeditor5-media-embed/src/mediaembedediting.js
                @@ -177,9 +177,7 @@ export default class MediaEmbedEditing extends Plugin {
                 
                 		// Configure the schema.
                 		schema.register( 'media', {
                -			isObject: true,
                -			isBlock: true,
                -			allowWhere: '$block',
                +			inheritAllFrom: '$blockObject',
                 			allowAttributes: [ 'url' ]
                 		} );
                 
                diff --git a/packages/ckeditor5-media-embed/src/utils.js b/packages/ckeditor5-media-embed/src/utils.js
                index 2ee10b832dc..9af7f481da7 100644
                --- a/packages/ckeditor5-media-embed/src/utils.js
                +++ b/packages/ckeditor5-media-embed/src/utils.js
                @@ -107,13 +107,16 @@ export function getSelectedMediaModelWidget( selection ) {
                  * @param {module:engine/model/range~Range} [insertRange] The range to insert the media. If not specified,
                  * the default behavior of {@link module:engine/model/model~Model#insertContent `model.insertContent()`} will
                  * be applied.
                + * @param {Boolean} findOptimalPosition If true it will try to find optimal position to insert media without breaking content
                + * in which a selection is.
                  */
                -export function insertMedia( model, url, insertRange ) {
                +export function insertMedia( model, url, selectable, findOptimalPosition ) {
                 	model.change( writer => {
                 		const mediaElement = writer.createElement( 'media', { url } );
                 
                -		model.insertContent( mediaElement, insertRange );
                -
                -		writer.setSelection( mediaElement, 'on' );
                +		model.insertObject( mediaElement, selectable, null, {
                +			setSelection: 'on',
                +			findOptimalPosition
                +		} );
                 	} );
                 }
                diff --git a/packages/ckeditor5-media-embed/tests/insertmediacommand.js b/packages/ckeditor5-media-embed/tests/insertmediacommand.js
                index bf860efae57..13c9479d6eb 100644
                --- a/packages/ckeditor5-media-embed/tests/insertmediacommand.js
                +++ b/packages/ckeditor5-media-embed/tests/insertmediacommand.js
                @@ -156,5 +156,66 @@ describe( 'MediaEmbedCommand', () => {
                 				'

                foo

                []

                bar

                ' ); } ); + + describe( 'inheriting attributes', () => { + beforeEach( () => { + const attributes = [ 'smart', 'pretty' ]; + + model.schema.extend( '$block', { + allowAttributes: attributes + } ); + + model.schema.extend( '$blockObject', { + allowAttributes: attributes + } ); + + for ( const attribute of attributes ) { + model.schema.setAttributeProperties( attribute, { + copyOnReplace: true + } ); + } + } ); + + it( 'should copy $block attributes on a media element when inserting it in $block', () => { + setData( model, '

                []

                ' ); + + command.execute( 'http://cksource.com' ); + + expect( getData( model ) ).to.equalMarkup( '[]' ); + } ); + + it( 'should copy attributes from first selected element', () => { + setData( model, '

                [foo

                bar]

                ' ); + + command.execute( 'http://cksource.com' ); + + expect( getData( model ) ).to.equalMarkup( + '[]' + + '

                foo

                ' + + '

                bar

                ' + ); + } ); + + it( 'should only copy $block attributes marked with copyOnReplace', () => { + setData( model, '

                []

                ' ); + + command.execute( 'http://cksource.com' ); + + expect( getData( model ) ).to.equalMarkup( '[]' ); + } ); + + it( 'should copy attributes from object when it is selected during insertion', () => { + model.schema.register( 'object', { isObject: true, inheritAllFrom: '$blockObject' } ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'object', view: 'object' } ); + + setData( model, '

                foo

                []

                bar

                ' ); + + command.execute( 'http://cksource.com' ); + + expect( getData( model ) ).to.equalMarkup( + '

                foo

                []

                bar

                ' + ); + } ); + } ); } ); } ); diff --git a/packages/ckeditor5-media-embed/tests/mediaembedediting.js b/packages/ckeditor5-media-embed/tests/mediaembedediting.js index 9b174ee72ac..96a0666e7a9 100644 --- a/packages/ckeditor5-media-embed/tests/mediaembedediting.js +++ b/packages/ckeditor5-media-embed/tests/mediaembedediting.js @@ -495,6 +495,19 @@ describe( 'MediaEmbedEditing', () => { } ); } ); + it( 'inherits attributes from $blockObject', () => { + return createTestEditor() + .then( newEditor => { + model = newEditor.model; + + model.schema.extend( '$blockObject', { + allowAttributes: 'foo' + } ); + + expect( model.schema.checkAttribute( 'media', 'foo' ) ).to.be.true; + } ); + } ); + describe( 'conversion in the data pipeline', () => { describe( 'elementName#o-embed', () => { beforeEach( () => { diff --git a/packages/ckeditor5-page-break/src/pagebreakcommand.js b/packages/ckeditor5-page-break/src/pagebreakcommand.js index 255ea3646ee..278ae9414df 100644 --- a/packages/ckeditor5-page-break/src/pagebreakcommand.js +++ b/packages/ckeditor5-page-break/src/pagebreakcommand.js @@ -44,24 +44,9 @@ export default class PageBreakCommand extends Command { model.change( writer => { const pageBreakElement = writer.createElement( 'pageBreak' ); - model.insertContent( pageBreakElement ); - - let nextElement = pageBreakElement.nextSibling; - - // Check whether an element next to the inserted page break is defined and can contain a text. - const canSetSelection = nextElement && model.schema.checkChild( nextElement, '$text' ); - - // If the element is missing, but a paragraph could be inserted next to the page break, let's add it. - if ( !canSetSelection && model.schema.checkChild( pageBreakElement.parent, 'paragraph' ) ) { - nextElement = writer.createElement( 'paragraph' ); - - model.insertContent( nextElement, writer.createPositionAfter( pageBreakElement ) ); - } - - // Put the selection inside the element, at the beginning. - if ( nextElement ) { - writer.setSelection( nextElement, 0 ); - } + model.insertObject( pageBreakElement, null, null, { + setSelection: 'after' + } ); } ); } } diff --git a/packages/ckeditor5-page-break/src/pagebreakediting.js b/packages/ckeditor5-page-break/src/pagebreakediting.js index aeb74b42bfb..5d8056d9dd8 100644 --- a/packages/ckeditor5-page-break/src/pagebreakediting.js +++ b/packages/ckeditor5-page-break/src/pagebreakediting.js @@ -37,8 +37,7 @@ export default class PageBreakEditing extends Plugin { const conversion = editor.conversion; schema.register( 'pageBreak', { - isObject: true, - allowWhere: '$block' + inheritAllFrom: '$blockObject' } ); conversion.for( 'dataDowncast' ).elementToStructure( { diff --git a/packages/ckeditor5-page-break/tests/pagebreakcommand.js b/packages/ckeditor5-page-break/tests/pagebreakcommand.js index 13b4894c97c..a625f89050a 100644 --- a/packages/ckeditor5-page-break/tests/pagebreakcommand.js +++ b/packages/ckeditor5-page-break/tests/pagebreakcommand.js @@ -298,5 +298,72 @@ describe( 'PageBreakCommand', () => { 'foo[]bar' ); } ); + + describe( 'inheriting attributes', () => { + beforeEach( () => { + const attributes = [ 'smart', 'pretty' ]; + + model.schema.extend( '$block', { + allowAttributes: attributes + } ); + + model.schema.extend( '$blockObject', { + allowAttributes: attributes + } ); + + for ( const attribute of attributes ) { + model.schema.setAttributeProperties( attribute, { + copyOnReplace: true + } ); + } + } ); + + it( 'should copy $block attributes on a page break element when inserting it in $block', () => { + setModelData( model, '[]' ); + + command.execute(); + + expect( getModelData( model ) ).to.equalMarkup( + '' + + '[]' + ); + } ); + + it( 'should copy attributes from first selected element', () => { + setModelData( model, '[foobar]' ); + + command.execute(); + + expect( getModelData( model ) ).to.equalMarkup( + '' + + '[]' + ); + } ); + + it( 'should only copy $block attributes marked with copyOnReplace', () => { + setModelData( model, '[]' ); + + command.execute(); + + expect( getModelData( model ) ).to.equalMarkup( + '' + + '[]' + ); + } ); + + it( 'should copy attributes from object when it is selected during insertion', () => { + model.schema.register( 'object', { isObject: true, inheritAllFrom: '$blockObject' } ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'object', view: 'object' } ); + + setModelData( model, '[]' ); + + command.execute(); + + expect( getModelData( model ) ).to.equalMarkup( + '' + + '[]' + ); + } ); + } ); } ); } ); diff --git a/packages/ckeditor5-page-break/tests/pagebreakediting.js b/packages/ckeditor5-page-break/tests/pagebreakediting.js index 11fdc864c28..8860e7e23d0 100644 --- a/packages/ckeditor5-page-break/tests/pagebreakediting.js +++ b/packages/ckeditor5-page-break/tests/pagebreakediting.js @@ -46,6 +46,14 @@ describe( 'PageBreakEditing', () => { expect( model.schema.checkChild( [ '$root', '$block' ], 'pageBreak' ) ).to.be.false; } ); + it( 'inherits attributes from $blockObject', () => { + model.schema.extend( '$blockObject', { + allowAttributes: 'foo' + } ); + + expect( model.schema.checkAttribute( 'pageBreak', 'foo' ) ).to.be.true; + } ); + it( 'should register pageBreak command', () => { expect( editor.commands.get( 'pageBreak' ) ).to.be.instanceOf( PageBreakCommand ); } ); diff --git a/packages/ckeditor5-paragraph/src/insertparagraphcommand.js b/packages/ckeditor5-paragraph/src/insertparagraphcommand.js index c45297b0caf..1cf6417860a 100644 --- a/packages/ckeditor5-paragraph/src/insertparagraphcommand.js +++ b/packages/ckeditor5-paragraph/src/insertparagraphcommand.js @@ -33,15 +33,22 @@ export default class InsertParagraphCommand extends Command { * @param {Object} options Options for the executed command. * @param {module:engine/model/position~Position} options.position The model position at which * the new paragraph will be inserted. + * @param {Object} attributes Attributes keys and values to set on a inserted paragraph * @fires execute */ execute( options ) { const model = this.editor.model; + const attributes = options.attributes; + let position = options.position; model.change( writer => { const paragraph = writer.createElement( 'paragraph' ); + if ( attributes ) { + model.schema.setAllowedAttributes( paragraph, attributes, writer ); + } + if ( !model.schema.checkChild( position.parent, paragraph ) ) { const allowedParent = model.schema.findAllowedParent( position, paragraph ); diff --git a/packages/ckeditor5-paragraph/tests/insertparagraphcommand.js b/packages/ckeditor5-paragraph/tests/insertparagraphcommand.js index 6b6c6fc7ff5..506d007074c 100644 --- a/packages/ckeditor5-paragraph/tests/insertparagraphcommand.js +++ b/packages/ckeditor5-paragraph/tests/insertparagraphcommand.js @@ -78,6 +78,51 @@ describe( 'InsertParagraphCommand', () => { expect( getData( model ) ).to.equal( 'foo[]' ); } ); + it( 'should insert a paragraph with given attribute', () => { + model.schema.extend( 'paragraph', { + allowAttributes: 'foo' + } ); + + setData( model, 'foo[]' ); + + command.execute( { + position: model.createPositionAfter( root.getChild( 0 ) ), + attributes: { foo: true } + } ); + + expect( getData( model ) ).to.equal( 'foo[]' ); + } ); + + it( 'should insert a paragraph with given attributes', () => { + model.schema.extend( 'paragraph', { + allowAttributes: [ 'foo', 'bar' ] + } ); + + setData( model, 'foo[]' ); + + command.execute( { + position: model.createPositionAfter( root.getChild( 0 ) ), + attributes: { foo: true, bar: true } + } ); + + expect( getData( model ) ).to.equal( 'foo[]' ); + } ); + + it( 'should insert a paragraph with given attributes but discard disallowed ones', () => { + model.schema.extend( 'paragraph', { + allowAttributes: [ 'foo', 'bar' ] + } ); + + setData( model, 'foo[]' ); + + command.execute( { + position: model.createPositionAfter( root.getChild( 0 ) ), + attributes: { foo: true, bar: true, yar: true } + } ); + + expect( getData( model ) ).to.equal( 'foo[]' ); + } ); + describe( 'interation with existing paragraphs in the content', () => { it( 'should insert a paragraph before another paragraph', () => { setData( model, 'foo[]' ); diff --git a/packages/ckeditor5-source-editing/src/utils/formathtml.js b/packages/ckeditor5-source-editing/src/utils/formathtml.js index bdde3dd6d0d..348179cc272 100644 --- a/packages/ckeditor5-source-editing/src/utils/formathtml.js +++ b/packages/ckeditor5-source-editing/src/utils/formathtml.js @@ -25,6 +25,7 @@ export function formatHtml( input ) { // The list is partially based on https://developer.mozilla.org/en-US/docs/Web/HTML/Block-level_elements that contains // a full list of HTML block-level elements. // A void element is an element that cannot have any child - https://html.spec.whatwg.org/multipage/syntax.html#void-elements. + // Note that
                 element is not listed on this list to avoid breaking whitespace formatting.
                 	const elementsToFormat = [
                 		{ name: 'address', isVoid: false },
                 		{ name: 'article', isVoid: false },
                @@ -57,7 +58,6 @@ export function formatHtml( input ) {
                 		{ name: 'nav', isVoid: false },
                 		{ name: 'ol', isVoid: false },
                 		{ name: 'p', isVoid: false },
                -		{ name: 'pre', isVoid: false },
                 		{ name: 'section', isVoid: false },
                 		{ name: 'table', isVoid: false },
                 		{ name: 'tbody', isVoid: false },
                diff --git a/packages/ckeditor5-source-editing/tests/utils/formathtml.js b/packages/ckeditor5-source-editing/tests/utils/formathtml.js
                index a8c53e166c1..2aa70289373 100644
                --- a/packages/ckeditor5-source-editing/tests/utils/formathtml.js
                +++ b/packages/ckeditor5-source-editing/tests/utils/formathtml.js
                @@ -195,6 +195,20 @@ describe( 'SourceEditing utils', () => {
                 			expect( formatHtml( source ) ).to.equal( sourceFormatted );
                 		} );
                 
                +		it( 'should not format pre blocks', () => {
                +			const source = '' +
                +				'
                ' + + '
                abc
                ' + + '
                '; + + const sourceFormatted = '' + + '
                \n' + + '
                abc
                \n' + + '
                '; + + expect( formatHtml( source ) ).to.equal( sourceFormatted ); + } ); + it( 'should keep all attributes unchanged', () => { const source = '' + '

                ' + diff --git a/packages/ckeditor5-table/src/commands/inserttablecommand.js b/packages/ckeditor5-table/src/commands/inserttablecommand.js index ab2000dce61..ad026f90f72 100644 --- a/packages/ckeditor5-table/src/commands/inserttablecommand.js +++ b/packages/ckeditor5-table/src/commands/inserttablecommand.js @@ -8,7 +8,6 @@ */ import { Command } from 'ckeditor5/src/core'; -import { findOptimalInsertionRange } from 'ckeditor5/src/widget'; /** * The insert table command. @@ -51,12 +50,9 @@ export default class InsertTableCommand extends Command { */ execute( options = {} ) { const model = this.editor.model; - const selection = model.document.selection; const tableUtils = this.editor.plugins.get( 'TableUtils' ); const config = this.editor.config.get( 'table' ); - const insertionRange = findOptimalInsertionRange( selection, model ); - const defaultRows = config.defaultHeadings.rows; const defaultColumns = config.defaultHeadings.columns; @@ -71,7 +67,7 @@ export default class InsertTableCommand extends Command { model.change( writer => { const table = tableUtils.createTable( writer, options ); - model.insertContent( table, insertionRange ); + model.insertObject( table, null, null, { findOptimalPosition: 'auto' } ); writer.setSelection( writer.createPositionAt( table.getNodeByPath( [ 0, 0, 0 ] ), 0 ) ); } ); diff --git a/packages/ckeditor5-table/src/tableediting.js b/packages/ckeditor5-table/src/tableediting.js index f5a619c16a5..c2e3e5a6628 100644 --- a/packages/ckeditor5-table/src/tableediting.js +++ b/packages/ckeditor5-table/src/tableediting.js @@ -65,10 +65,8 @@ export default class TableEditing extends Plugin { const tableUtils = editor.plugins.get( TableUtils ); schema.register( 'table', { - allowWhere: '$block', - allowAttributes: [ 'headingRows', 'headingColumns' ], - isObject: true, - isBlock: true + inheritAllFrom: '$blockObject', + allowAttributes: [ 'headingRows', 'headingColumns' ] } ); schema.register( 'tableRow', { @@ -77,8 +75,8 @@ export default class TableEditing extends Plugin { } ); schema.register( 'tableCell', { + allowContentOf: '$container', allowIn: 'tableRow', - allowChildren: '$block', allowAttributes: [ 'colspan', 'rowspan' ], isLimit: true, isSelectable: true diff --git a/packages/ckeditor5-table/src/tablekeyboard.js b/packages/ckeditor5-table/src/tablekeyboard.js index 37ea17b672a..91e335c4988 100644 --- a/packages/ckeditor5-table/src/tablekeyboard.js +++ b/packages/ckeditor5-table/src/tablekeyboard.js @@ -42,23 +42,20 @@ export default class TableKeyboard extends Plugin { const view = this.editor.editing.view; const viewDocument = view.document; - // Handle Tab key navigation. - this.editor.keystrokes.set( 'Tab', ( ...args ) => this._handleTabOnSelectedTable( ...args ), { priority: 'low' } ); - this.editor.keystrokes.set( 'Tab', this._getTabHandler( true ), { priority: 'low' } ); - this.editor.keystrokes.set( 'Shift+Tab', this._getTabHandler( false ), { priority: 'low' } ); - this.listenTo( viewDocument, 'arrowKey', ( ...args ) => this._onArrowKey( ...args ), { context: 'table' } ); + this.listenTo( viewDocument, 'tab', ( ...args ) => this._handleTabOnSelectedTable( ...args ), { context: 'figure' } ); + this.listenTo( viewDocument, 'tab', ( ...args ) => this._handleTab( ...args ), { context: [ 'th', 'td' ] } ); } /** - * Handles {@link module:engine/view/document~Document#event:keydown keydown} events for the Tab key executed + * Handles {@link module:engine/view/document~Document#event:tab tab} events for the Tab key executed * when the table widget is selected. * * @private - * @param {module:engine/view/observer/keyobserver~KeyEventData} data Key event data. - * @param {Function} cancel The stop/stopPropagation/preventDefault function. + * @param {module:engine/view/observer/bubblingeventinfo~BubblingEventInfo} bubblingEventInfo + * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData */ - _handleTabOnSelectedTable( data, cancel ) { + _handleTabOnSelectedTable( bubblingEventInfo, domEventData ) { const editor = this.editor; const selection = editor.model.document.selection; const selectedElement = selection.getSelectedElement(); @@ -67,7 +64,9 @@ export default class TableKeyboard extends Plugin { return; } - cancel(); + domEventData.preventDefault(); + domEventData.stopPropagation(); + bubblingEventInfo.stop(); editor.model.change( writer => { writer.setSelection( writer.createRangeIn( selectedElement.getChild( 0 ).getChild( 0 ) ) ); @@ -75,87 +74,90 @@ export default class TableKeyboard extends Plugin { } /** - * Returns a handler for {@link module:engine/view/document~Document#event:keydown keydown} events for the Tab key executed + * Handles {@link module:engine/view/document~Document#event:tab tab} events for the Tab key executed * inside table cells. * * @private - * @param {Boolean} isForward Whether this handler will move the selection to the next or the previous cell. + * @param {module:engine/view/observer/bubblingeventinfo~BubblingEventInfo} bubblingEventInfo + * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData */ - _getTabHandler( isForward ) { + _handleTab( bubblingEventInfo, domEventData ) { const editor = this.editor; const tableUtils = this.editor.plugins.get( TableUtils ); - return ( domEventData, cancel ) => { - const selection = editor.model.document.selection; - let tableCell = tableUtils.getTableCellsContainingSelection( selection )[ 0 ]; + const selection = editor.model.document.selection; + const isForward = !domEventData.shiftKey; - if ( !tableCell ) { - tableCell = this.editor.plugins.get( 'TableSelection' ).getFocusCell(); - } + let tableCell = tableUtils.getTableCellsContainingSelection( selection )[ 0 ]; - if ( !tableCell ) { - return; - } + if ( !tableCell ) { + tableCell = this.editor.plugins.get( 'TableSelection' ).getFocusCell(); + } - cancel(); + if ( !tableCell ) { + return; + } - const tableRow = tableCell.parent; - const table = tableRow.parent; + domEventData.preventDefault(); + domEventData.stopPropagation(); + bubblingEventInfo.stop(); - const currentRowIndex = table.getChildIndex( tableRow ); - const currentCellIndex = tableRow.getChildIndex( tableCell ); + const tableRow = tableCell.parent; + const table = tableRow.parent; - const isFirstCellInRow = currentCellIndex === 0; + const currentRowIndex = table.getChildIndex( tableRow ); + const currentCellIndex = tableRow.getChildIndex( tableCell ); - if ( !isForward && isFirstCellInRow && currentRowIndex === 0 ) { - // Set the selection over the whole table if the selection was in the first table cell. - editor.model.change( writer => { - writer.setSelection( writer.createRangeOn( table ) ); - } ); + const isFirstCellInRow = currentCellIndex === 0; - return; - } + if ( !isForward && isFirstCellInRow && currentRowIndex === 0 ) { + // Set the selection over the whole table if the selection was in the first table cell. + editor.model.change( writer => { + writer.setSelection( writer.createRangeOn( table ) ); + } ); - const isLastCellInRow = currentCellIndex === tableRow.childCount - 1; - const isLastRow = currentRowIndex === tableUtils.getRows( table ) - 1; + return; + } - if ( isForward && isLastRow && isLastCellInRow ) { - editor.execute( 'insertTableRowBelow' ); + const isLastCellInRow = currentCellIndex === tableRow.childCount - 1; + const isLastRow = currentRowIndex === tableUtils.getRows( table ) - 1; - // Check if the command actually added a row. If `insertTableRowBelow` execution didn't add a row (because it was disabled - // or it got overwritten) set the selection over the whole table to mirror the first cell case. - if ( currentRowIndex === tableUtils.getRows( table ) - 1 ) { - editor.model.change( writer => { - writer.setSelection( writer.createRangeOn( table ) ); - } ); + if ( isForward && isLastRow && isLastCellInRow ) { + editor.execute( 'insertTableRowBelow' ); - return; - } + // Check if the command actually added a row. If `insertTableRowBelow` execution didn't add a row (because it was disabled + // or it got overwritten) set the selection over the whole table to mirror the first cell case. + if ( currentRowIndex === tableUtils.getRows( table ) - 1 ) { + editor.model.change( writer => { + writer.setSelection( writer.createRangeOn( table ) ); + } ); + + return; } + } - let cellToFocus; + let cellToFocus; - // Move to the first cell in the next row. - if ( isForward && isLastCellInRow ) { - const nextRow = table.getChild( currentRowIndex + 1 ); + // Move to the first cell in the next row. + if ( isForward && isLastCellInRow ) { + const nextRow = table.getChild( currentRowIndex + 1 ); - cellToFocus = nextRow.getChild( 0 ); - } - // Move to the last cell in the previous row. - else if ( !isForward && isFirstCellInRow ) { - const previousRow = table.getChild( currentRowIndex - 1 ); + cellToFocus = nextRow.getChild( 0 ); + } + // Move to the last cell in the previous row. + else if ( !isForward && isFirstCellInRow ) { + const previousRow = table.getChild( currentRowIndex - 1 ); - cellToFocus = previousRow.getChild( previousRow.childCount - 1 ); - } - // Move to the next/previous cell. - else { - cellToFocus = tableRow.getChild( currentCellIndex + ( isForward ? 1 : -1 ) ); - } + cellToFocus = previousRow.getChild( previousRow.childCount - 1 ); + } + // Move to the next/previous cell. + else { + cellToFocus = tableRow.getChild( currentCellIndex + ( isForward ? 1 : -1 ) ); + } - editor.model.change( writer => { - writer.setSelection( writer.createRangeIn( cellToFocus ) ); - } ); - }; + editor.model.change( writer => { + writer.setSelection( writer.createRangeIn( cellToFocus ) ); + } ); } /** diff --git a/packages/ckeditor5-table/tests/commands/inserttablecommand.js b/packages/ckeditor5-table/tests/commands/inserttablecommand.js index 622ab0c216f..8080d8deffc 100644 --- a/packages/ckeditor5-table/tests/commands/inserttablecommand.js +++ b/packages/ckeditor5-table/tests/commands/inserttablecommand.js @@ -356,5 +356,100 @@ describe( 'InsertTableCommand', () => { await editor.destroy(); } ); } ); + + describe( 'inheriting attributes', () => { + let editor; + let model, command; + + beforeEach( async () => { + editor = await ModelTestEditor + .create( { + plugins: [ Paragraph, TableEditing ], + table: { + defaultHeadings: { rows: 1 } + } + } ); + + model = editor.model; + command = new InsertTableCommand( editor ); + + const attributes = [ 'smart', 'pretty' ]; + + model.schema.extend( '$block', { + allowAttributes: attributes + } ); + + model.schema.extend( '$blockObject', { + allowAttributes: attributes + } ); + + for ( const attribute of attributes ) { + model.schema.setAttributeProperties( attribute, { + copyOnReplace: true + } ); + } + } ); + + afterEach( async () => { + await editor.destroy(); + } ); + + it( 'should copy $block attributes on a table element when inserting it in $block', async () => { + setData( model, '[]' ); + + command.execute( { rows: 2, columns: 2 } ); + + expect( getData( model ) ).to.equal( + modelTable( [ + [ '[]', '' ], + [ '', '' ] + ], { headingRows: 1, pretty: true, smart: true } ) + ); + } ); + + it( 'should copy attributes from first selected element', () => { + setData( model, '[foobar]' ); + + command.execute( { rows: 2, columns: 2 } ); + + expect( getData( model ) ).to.equal( + modelTable( [ + [ '[]', '' ], + [ '', '' ] + ], { headingRows: 1, pretty: true } ) + + 'foo' + + 'bar' + ); + } ); + + it( 'should only copy $block attributes marked with copyOnReplace', () => { + setData( model, '[]' ); + + command.execute( { rows: 2, columns: 2 } ); + + expect( getData( model ) ).to.equal( + modelTable( [ + [ '[]', '' ], + [ '', '' ] + ], { headingRows: 1, pretty: true, smart: true } ) + ); + } ); + + it( 'should copy attributes from object when it is selected during insertion', () => { + model.schema.register( 'object', { isObject: true, inheritAllFrom: '$blockObject' } ); + editor.conversion.for( 'downcast' ).elementToElement( { model: 'object', view: 'object' } ); + + setData( model, '[]' ); + + command.execute( { rows: 2, columns: 2 } ); + + expect( getData( model ) ).to.equal( + modelTable( [ + [ '[]', '' ], + [ '', '' ] + ], { headingRows: 1, pretty: true, smart: true } ) + ); + } ); + } ); } ); } ); diff --git a/packages/ckeditor5-table/tests/tableediting.js b/packages/ckeditor5-table/tests/tableediting.js index 67eff71deb2..e05b4c48bac 100644 --- a/packages/ckeditor5-table/tests/tableediting.js +++ b/packages/ckeditor5-table/tests/tableediting.js @@ -84,6 +84,14 @@ describe( 'TableEditing', () => { expect( model.schema.checkChild( [ '$root', 'table', 'tableRow', 'tableCell' ], 'imageBlock' ) ).to.be.true; } ); + it( 'inherits attributes from $blockObject', () => { + model.schema.extend( '$blockObject', { + allowAttributes: 'foo' + } ); + + expect( model.schema.checkAttribute( 'table', 'foo' ) ).to.be.true; + } ); + it( 'adds insertTable command', () => { expect( editor.commands.get( 'insertTable' ) ).to.be.instanceOf( InsertTableCommand ); } ); diff --git a/packages/ckeditor5-table/tests/tablekeyboard.js b/packages/ckeditor5-table/tests/tablekeyboard.js index 0ca6d8a480a..7a770ed6445 100644 --- a/packages/ckeditor5-table/tests/tablekeyboard.js +++ b/packages/ckeditor5-table/tests/tablekeyboard.js @@ -61,7 +61,8 @@ describe( 'TableKeyboard', () => { domEvtDataStub = { keyCode: getCode( 'Tab' ), preventDefault: sinon.spy(), - stopPropagation: sinon.spy() + stopPropagation: sinon.spy(), + domTarget: global.document.body }; } ); @@ -256,24 +257,207 @@ describe( 'TableKeyboard', () => { ] ) ); } ); - it( 'should listen with the lower priority than its children', () => { - // Cancel TAB event. - editor.keystrokes.set( 'Tab', ( data, cancel ) => cancel() ); - + it( 'should handle tab press when in table cell and create a new row', () => { setModelData( model, modelTable( [ - [ '11[]', '12' ] + [ '11', '12[]' ] ] ) ); - editor.editing.view.document.fire( 'keydown', domEvtDataStub ); + editor.editing.view.document.fire( 'tab', domEvtDataStub ); sinon.assert.calledOnce( domEvtDataStub.preventDefault ); sinon.assert.calledOnce( domEvtDataStub.stopPropagation ); expect( getModelData( model ) ).to.equalMarkup( modelTable( [ - [ '11[]', '12' ] + [ '11', '12' ], + [ '[]', '' ] ] ) ); } ); + it( 'should handle tab press when in table header and create a new row', () => { + setModelData( model, + modelTable( + [ + [ '11', '12[]' ] + ], + { + headingRows: 1 + } + ) ); + + editor.editing.view.document.fire( 'tab', domEvtDataStub ); + + sinon.assert.calledOnce( domEvtDataStub.preventDefault ); + sinon.assert.calledOnce( domEvtDataStub.stopPropagation ); + + expect( getModelData( model ) ).to.equalMarkup( modelTable( [ + [ '11', '12' ], + [ '[]', '' ] + ], { headingRows: 1 } ) ); + } ); + + it( 'should not handle tab if it was handled by a listener with higher priority', () => { + setModelData( model, + modelTable( + [ + [ '11', '12[]' ] + ], + { + headingRows: 1 + } + ) ); + + editor.editing.view.document.on( + 'tab', + ( bubblingEventInfo, domEventData ) => { + domEventData.preventDefault(); + domEventData.stopPropagation(); + bubblingEventInfo.stop(); + }, + { + context: [ 'th', 'td' ], + priority: 'high' + } + ); + + editor.editing.view.document.fire( 'tab', domEvtDataStub ); + + sinon.assert.calledOnce( domEvtDataStub.preventDefault ); + sinon.assert.calledOnce( domEvtDataStub.stopPropagation ); + + expect( getModelData( model ) ).to.equalMarkup( modelTable( [ + [ '11', '12[]' ] + ], { headingRows: 1 } ) ); + } ); + + it( 'should handle event over other listeners with lower priority', () => { + const lowerPriorityListenerSpy = sinon.spy(); + + setModelData( model, modelTable( + [ + [ '11', '12[]' ] + ], + { + headingRows: 1 + } + ) ); + + editor.editing.view.document.on( + 'tab', + lowerPriorityListenerSpy, + { + context: [ 'th', 'td' ], + priority: 'low' + } + ); + + editor.editing.view.document.fire( 'tab', domEvtDataStub ); + + sinon.assert.calledOnce( domEvtDataStub.preventDefault ); + sinon.assert.calledOnce( domEvtDataStub.stopPropagation ); + sinon.assert.notCalled( lowerPriorityListenerSpy ); + + expect( getModelData( model ) ).to.equalMarkup( modelTable( + [ + [ '11', '12' ], + [ '[]', '' ] + ], + { + headingRows: 1 + } + ) ); + } ); + + it( 'should select whole next table cell if selection is in table header', () => { + const innerTable = modelTable( [ + [ '' ] + ] ); + + setModelData( model, + modelTable( + [ + [ innerTable + '[]A', innerTable + 'B' ], + [ 'C', 'D' ] + ], + { + headingColumns: 1 + } + ) ); + + editor.editing.view.document.fire( 'tab', domEvtDataStub ); + + sinon.assert.calledOnce( domEvtDataStub.preventDefault ); + sinon.assert.calledOnce( domEvtDataStub.stopPropagation ); + + expect( getModelData( model ) ).to.equalMarkup( modelTable( [ + [ innerTable + 'A', '[' + innerTable + 'B]' ], + [ 'C', 'D' ] + ], { headingColumns: 1 } ) ); + } ); + + it( 'should select whole next table cell if selection is in table data cell', () => { + const innerTable = modelTable( [ + [ '' ] + ] ); + + setModelData( model, + modelTable( + [ + [ innerTable + 'A', innerTable + 'B[]' ], + [ 'C', 'D' ] + ], + { + headingColumns: 1 + } + ) ); + + editor.editing.view.document.fire( 'tab', domEvtDataStub ); + + sinon.assert.calledOnce( domEvtDataStub.preventDefault ); + sinon.assert.calledOnce( domEvtDataStub.stopPropagation ); + + expect( getModelData( model ) ).to.equalMarkup( modelTable( [ + [ innerTable + 'A', innerTable + 'B' ], + [ '[C]', 'D' ] + ], { headingColumns: 1 } ) ); + } ); + + it( 'tab handler should execute at target and create a new cell in table header', () => { + const innerTable = modelTable( [ + [ 'A[]' ] + ] ); + + const innerTableOutput = modelTable( [ + [ 'A' ], + [ '[]' ] + ] ); + + setModelData( model, modelTable( + [ + [ innerTable, 'B' ], + [ 'C', 'D' ] + ], + { + headingColumns: 1 + } + ) ); + + editor.editing.view.document.fire( 'tab', domEvtDataStub ); + + sinon.assert.calledOnce( domEvtDataStub.preventDefault ); + sinon.assert.calledOnce( domEvtDataStub.stopPropagation ); + + expect( getModelData( model ) ).to.equalMarkup( + modelTable( + [ + [ innerTableOutput, 'B' ], + [ 'C', 'D' ] + ], + { + headingColumns: 1 + } + ) ); + } ); + describe( 'on table widget selected', () => { beforeEach( () => { editor.model.schema.register( 'block', { @@ -310,7 +494,7 @@ describe( 'TableKeyboard', () => { it( 'shouldn\'t do anything on other blocks', () => { const spy = sinon.spy(); - editor.editing.view.document.on( 'keydown', spy ); + editor.editing.view.document.on( 'tab', spy ); setModelData( model, '[foo]' ); @@ -324,6 +508,63 @@ describe( 'TableKeyboard', () => { // Should not cancel event. sinon.assert.calledOnce( spy ); } ); + + it( 'table tab handler for selected table should not capture event if selection is not a table', () => { + editor.conversion.elementToElement( { + model: 'fakeFigure', + view: 'figure' + } ); + + model.schema.register( 'fakeFigure', { + inheritAllFrom: '$blockObject' + } ); + + setModelData( model, '[]' ); + + editor.editing.view.document.fire( 'keydown', domEvtDataStub ); + + sinon.assert.notCalled( domEvtDataStub.preventDefault ); + sinon.assert.notCalled( domEvtDataStub.stopPropagation ); + expect( getModelData( model ) ).to.equalMarkup( '[]' ); + } ); + + it( 'table tab handler for td should not capture event if selection is not in a tableCell', () => { + editor.conversion.elementToElement( { + model: 'fakeTableCell', + view: 'td' + } ); + + model.schema.register( 'fakeTableCell', { + inheritAllFrom: '$blockObject' + } ); + + setModelData( model, '[]' ); + + editor.editing.view.document.fire( 'keydown', domEvtDataStub ); + + sinon.assert.notCalled( domEvtDataStub.preventDefault ); + sinon.assert.notCalled( domEvtDataStub.stopPropagation ); + expect( getModelData( model ) ).to.equalMarkup( '[]' ); + } ); + + it( 'table tab handler for th should not capture event if selection is not in a tableCell marked as a header', () => { + editor.conversion.elementToElement( { + model: 'fakeTableHeader', + view: 'th' + } ); + + model.schema.register( 'fakeTableHeader', { + inheritAllFrom: '$blockObject' + } ); + + setModelData( model, '[]' ); + + editor.editing.view.document.fire( 'keydown', domEvtDataStub ); + + sinon.assert.notCalled( domEvtDataStub.preventDefault ); + sinon.assert.notCalled( domEvtDataStub.stopPropagation ); + expect( getModelData( model ) ).to.equalMarkup( '[]' ); + } ); } ); } ); @@ -2802,7 +3043,7 @@ describe( 'TableKeyboard', () => { ] ) ); } ); - it( 'should not move the caret if it\'s 2 characters before the last space in the line next to last one', () => { + it( 'should not move the caret if its 2 characters before the last space in the line next to last one', () => { setModelData( model, modelTable( [ [ '00', '01', '02' ], [ '10', text.substring( 0, text.length - 2 ) + '[]od word word word', '12' ], diff --git a/packages/ckeditor5-utils/src/emittermixin.js b/packages/ckeditor5-utils/src/emittermixin.js index 4546f66a9e0..10124fa21e3 100644 --- a/packages/ckeditor5-utils/src/emittermixin.js +++ b/packages/ckeditor5-utils/src/emittermixin.js @@ -10,6 +10,7 @@ import EventInfo from './eventinfo'; import uid from './uid'; import priorities from './priorities'; +import insertToPriorityArray from './inserttopriorityarray'; // To check if component is loaded more than once. import './version'; @@ -298,21 +299,7 @@ const EmitterMixin = { // Add the callback to all callbacks list. for ( const callbacks of lists ) { // Add the callback to the list in the right priority position. - let added = false; - - for ( let i = 0; i < callbacks.length; i++ ) { - if ( callbacks[ i ].priority < priority ) { - callbacks.splice( i, 0, callbackDefinition ); - added = true; - - break; - } - } - - // Add at the end, if right place was not found. - if ( !added ) { - callbacks.push( callbackDefinition ); - } + insertToPriorityArray( callbacks, callbackDefinition ); } }, diff --git a/packages/ckeditor5-utils/src/inserttopriorityarray.js b/packages/ckeditor5-utils/src/inserttopriorityarray.js new file mode 100644 index 00000000000..c546cfa7d9f --- /dev/null +++ b/packages/ckeditor5-utils/src/inserttopriorityarray.js @@ -0,0 +1,42 @@ +/** + * @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 priorities from './priorities'; + +/** + * @module utils/inserttopriorityarray + */ + +/** + * The priority object descriptor. + * + * const objectWithPriority = { + * priority: 'high' + * } + * + * @typedef {Object} module:utils/inserttopriorityarray~ObjectWithPriority + * + * @property {module:utils/priorities~PriorityString|Number} priority Priority of the object. + */ + +/** + * Inserts any object with priority at correct index by priority so registered objects are always sorted from highest to lowest priority. + * + * @param {Array.} objects Array of objects with priority to insert object to. + * @param {module:utils/inserttopriorityarray~ObjectWithPriority} objectToInsert Object with `priority` property. + */ +export default function insertToPriorityArray( objects, objectToInsert ) { + const priority = priorities.get( objectToInsert.priority ); + + for ( let i = 0; i < objects.length; i++ ) { + if ( priorities.get( objects[ i ].priority ) < priority ) { + objects.splice( i, 0, objectToInsert ); + + return; + } + } + + objects.push( objectToInsert ); +} diff --git a/packages/ckeditor5-utils/tests/insertbypriority.js b/packages/ckeditor5-utils/tests/insertbypriority.js new file mode 100644 index 00000000000..901497ea5c4 --- /dev/null +++ b/packages/ckeditor5-utils/tests/insertbypriority.js @@ -0,0 +1,106 @@ +/** + * @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 insertToPriorityArray from '../src/inserttopriorityarray'; + +describe( 'insertToPriorityArray()', () => { + let objectsWithPriority; + + beforeEach( () => { + objectsWithPriority = []; + } ); + + it( 'should insert only object to array', () => { + const objectA = { priority: 'normal' }; + + const expectedOutput = [ objectA ]; + + insertToPriorityArray( objectsWithPriority, objectA ); + + expect( objectsWithPriority ).to.deep.equal( expectedOutput ); + } ); + + it( 'should place object with highest priority at the first index of an array', () => { + const objectA = { priority: 'high' }; + const objectB = { priority: 'low' }; + + const expectedOutput = [ objectA, objectB ]; + + insertToPriorityArray( objectsWithPriority, objectA ); + insertToPriorityArray( objectsWithPriority, objectB ); + + expect( objectsWithPriority ).to.deep.equal( expectedOutput ); + } ); + + it( 'should place object with highest priority at the first index of an array even if inserted later', () => { + const objectA = { priority: 'high' }; + const objectB = { priority: 'low' }; + + const expectedOutput = [ objectA, objectB ]; + + insertToPriorityArray( objectsWithPriority, objectA ); + insertToPriorityArray( objectsWithPriority, objectB ); + + expect( objectsWithPriority ).to.deep.equal( expectedOutput ); + } ); + + it( 'should correctly insert items by priority', () => { + const objectA = { priority: 'high' }; + const objectB = { priority: 'lowest' }; + const objectC = { priority: 'highest' }; + const objectD = { priority: 'normal' }; + const objectE = { priority: 'low' }; + + const expectedOutput = [ objectC, objectA, objectD, objectE, objectB ]; + + insertToPriorityArray( objectsWithPriority, objectA ); + insertToPriorityArray( objectsWithPriority, objectB ); + insertToPriorityArray( objectsWithPriority, objectC ); + insertToPriorityArray( objectsWithPriority, objectD ); + insertToPriorityArray( objectsWithPriority, objectE ); + + expect( objectsWithPriority ).to.deep.equal( expectedOutput ); + } ); + + it( 'should place first inserted object at the first index of an array when there are multiple highest priority objects', () => { + const objectA = { priority: 'highest' }; + const objectB = { priority: 'highest' }; + + const expectedOutput = [ objectA, objectB ]; + + insertToPriorityArray( objectsWithPriority, objectA ); + insertToPriorityArray( objectsWithPriority, objectB ); + + expect( objectsWithPriority ).to.deep.equal( expectedOutput ); + } ); + + it( 'first inserted object of given priority should be closest to start of an array', () => { + const objectA = { priority: 'highest' }; + const objectB = { priority: 'low' }; + const objectC = { priority: 'low' }; + + const expectedOutput = [ objectA, objectB, objectC ]; + + insertToPriorityArray( objectsWithPriority, objectA ); + insertToPriorityArray( objectsWithPriority, objectB ); + insertToPriorityArray( objectsWithPriority, objectC ); + + expect( objectsWithPriority ).to.deep.equal( expectedOutput ); + } ); + + it( 'should place object with lowest priorirty at the end of an array', () => { + const objectA = { priority: 'highest' }; + const objectB = { priority: 'high' }; + const objectC = { priority: 'low' }; + + const expectedOutput = [ objectA, objectB, objectC ]; + + insertToPriorityArray( objectsWithPriority, objectA ); + insertToPriorityArray( objectsWithPriority, objectB ); + insertToPriorityArray( objectsWithPriority, objectC ); + + expect( objectsWithPriority ).to.deep.equal( expectedOutput ); + } ); +} ); diff --git a/packages/ckeditor5-widget/src/utils.js b/packages/ckeditor5-widget/src/utils.js index 89f32157abd..9fe9b911bcf 100644 --- a/packages/ckeditor5-widget/src/utils.js +++ b/packages/ckeditor5-widget/src/utils.js @@ -9,6 +9,9 @@ import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; import toArray from '@ckeditor/ckeditor5-utils/src/toarray'; +import { + findOptimalInsertionRange as engineFindOptimalInsertionRange +} from '@ckeditor/ckeditor5-engine/src/model/utils/findoptimalinsertionrange'; import HighlightStack from './highlightstack'; import { getTypeAroundFakeCaretPosition } from './widgettypearound/utils'; @@ -307,33 +310,9 @@ export function findOptimalInsertionRange( selection, model ) { if ( typeAroundFakeCaretPosition ) { return model.createRange( model.createPositionAt( selectedElement, typeAroundFakeCaretPosition ) ); } - - if ( model.schema.isObject( selectedElement ) && !model.schema.isInline( selectedElement ) ) { - return model.createRangeOn( selectedElement ); - } - } - - const firstBlock = selection.getSelectedBlocks().next().value; - - if ( firstBlock ) { - // If inserting into an empty block – return position in that block. It will get - // replaced with the image by insertContent(). #42. - if ( firstBlock.isEmpty ) { - return model.createRange( model.createPositionAt( firstBlock, 0 ) ); - } - - const positionAfter = model.createPositionAfter( firstBlock ); - - // If selection is at the end of the block - return position after the block. - if ( selection.focus.isTouching( positionAfter ) ) { - return model.createRange( positionAfter ); - } - - // Otherwise return position before the block. - return model.createRange( model.createPositionBefore( firstBlock ) ); } - return model.createRange( selection.focus ); + return engineFindOptimalInsertionRange( selection, model ); } /** diff --git a/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js b/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js index 517800ccda5..bc30e78c5b6 100644 --- a/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js +++ b/packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js @@ -121,6 +121,7 @@ export default class WidgetTypeAround extends Plugin { this._enableTypeAroundFakeCaretActivationUsingKeyboardArrows(); this._enableDeleteIntegration(); this._enableInsertContentIntegration(); + this._enableInsertObjectIntegration(); this._enableDeleteContentIntegration(); } @@ -145,8 +146,11 @@ export default class WidgetTypeAround extends Plugin { const editor = this.editor; const editingView = editor.editing.view; + const attributesToCopy = editor.model.schema.getAttributesWithProperty( widgetModelElement, 'copyOnReplace', true ); + editor.execute( 'insertParagraph', { - position: editor.model.createPositionAt( widgetModelElement, position ) + position: editor.model.createPositionAt( widgetModelElement, position ), + attributes: attributesToCopy } ); editingView.focus(); @@ -779,6 +783,37 @@ export default class WidgetTypeAround extends Plugin { }, { priority: 'high' } ); } + /** + * Attaches the {@link module:engine/model/model~Model#event:insertObject} event listener that modifies `options.findOptimalPosition` + * parameter to position of fake caret in relation to selected element to reflect user's intent of desired insertion position. + * + * The object is inserted according to the `widget-type-around` selection attribute (see {@link #_handleArrowKeyPress}). + * + * @private + */ + _enableInsertObjectIntegration() { + const editor = this.editor; + const model = this.editor.model; + const documentSelection = model.document.selection; + + this._listenToIfEnabled( editor.model, 'insertObject', ( evt, args ) => { + const [ , selectable, , options = {} ] = args; + + if ( selectable && !selectable.is( 'documentSelection' ) ) { + return; + } + + const typeAroundFakeCaretPosition = getTypeAroundFakeCaretPosition( documentSelection ); + + if ( !typeAroundFakeCaretPosition ) { + return; + } + + options.findOptimalPosition = typeAroundFakeCaretPosition; + args[ 3 ] = options; + }, { priority: 'high' } ); + } + /** * Attaches the {@link module:engine/model/model~Model#event:deleteContent} event listener to block the event when the fake * caret is active. diff --git a/packages/ckeditor5-widget/tests/manual/inline-widget.js b/packages/ckeditor5-widget/tests/manual/inline-widget.js index 24ddbb8a3fd..d7df909c5a3 100644 --- a/packages/ckeditor5-widget/tests/manual/inline-widget.js +++ b/packages/ckeditor5-widget/tests/manual/inline-widget.js @@ -75,7 +75,7 @@ class InlineWidget extends Plugin { this._createToolbarButton(); function createPlaceholderView( modelItem, { writer } ) { - const widgetElement = writer.createContainerElement( 'placeholder', null, { isAllowedInsideAttributeElement: true } ); + const widgetElement = writer.createContainerElement( 'placeholder' ); const viewText = writer.createText( '{' + modelItem.getAttribute( 'type' ) + '}' ); writer.insert( writer.createPositionAt( widgetElement, 0 ), viewText ); diff --git a/packages/ckeditor5-widget/tests/widget.js b/packages/ckeditor5-widget/tests/widget.js index 19ea08dc4df..83f420cc7ea 100644 --- a/packages/ckeditor5-widget/tests/widget.js +++ b/packages/ckeditor5-widget/tests/widget.js @@ -91,7 +91,7 @@ describe( 'Widget', () => { editor.conversion.for( 'downcast' ) .elementToElement( { model: 'inline', view: ( modelItem, { writer } ) => { - return writer.createContainerElement( 'figure', null, { isAllowedInsideAttributeElement: true } ); + return writer.createContainerElement( 'figure' ); } } ) .elementToElement( { model: 'imageBlock', view: 'img' } ) .elementToElement( { model: 'blockQuote', view: 'blockquote' } ) @@ -109,7 +109,7 @@ describe( 'Widget', () => { .elementToElement( { model: 'inline-widget', view: ( modelItem, { writer } ) => { - const span = writer.createContainerElement( 'span', null, { isAllowedInsideAttributeElement: true } ); + const span = writer.createContainerElement( 'span' ); return toWidget( span, writer ); } diff --git a/packages/ckeditor5-widget/tests/widgetresize.js b/packages/ckeditor5-widget/tests/widgetresize.js index 86fec2a5e21..d323a90b3c5 100644 --- a/packages/ckeditor5-widget/tests/widgetresize.js +++ b/packages/ckeditor5-widget/tests/widgetresize.js @@ -516,7 +516,7 @@ describe( 'WidgetResize', () => { .elementToElement( { model: 'inline-widget', view: ( modelItem, { writer } ) => { - const span = writer.createContainerElement( 'span', null, { isAllowedInsideAttributeElement: true } ); + const span = writer.createContainerElement( 'span' ); return toWidget( span, writer ); } diff --git a/packages/ckeditor5-widget/tests/widgettypearound/widgettypearound.js b/packages/ckeditor5-widget/tests/widgettypearound/widgettypearound.js index 3df1bea09e3..236511865b6 100644 --- a/packages/ckeditor5-widget/tests/widgettypearound/widgettypearound.js +++ b/packages/ckeditor5-widget/tests/widgettypearound/widgettypearound.js @@ -145,6 +145,82 @@ describe( 'WidgetTypeAround', () => { sinon.assert.calledOnce( spy ); } ); + + it( 'should inherit attributes from widget that have copyOnReplace property', () => { + editor.model.schema.extend( 'paragraph', { + allowAttributes: 'a' + } ); + + editor.model.schema.extend( '$blockObject', { + allowAttributes: 'a' + } ); + + editor.model.schema.setAttributeProperties( 'a', { + copyOnReplace: true + } ); + + setModelData( editor.model, '[]' ); + + plugin._insertParagraph( modelRoot.getChild( 0 ), 'before' ); + + const spyExecutePosition = executeSpy.firstCall.args[ 1 ].position; + const positionBeforeWidget = editor.model.createPositionBefore( modelRoot.getChild( 0 ) ); + + sinon.assert.calledOnce( executeSpy ); + sinon.assert.calledWith( executeSpy, 'insertParagraph' ); + + expect( spyExecutePosition.isEqual( positionBeforeWidget ) ).to.be.true; + + expect( getModelData( editor.model ) ).to.equal( '[]' ); + } ); + + it( 'should not copy attribute if it has copyOnReplace property but it is not allowed on paragraph', () => { + editor.model.schema.extend( '$blockObject', { + allowAttributes: 'a' + } ); + + editor.model.schema.setAttributeProperties( 'a', { + copyOnReplace: true + } ); + + setModelData( editor.model, '[]' ); + + plugin._insertParagraph( modelRoot.getChild( 0 ), 'before' ); + + const spyExecutePosition = executeSpy.firstCall.args[ 1 ].position; + const positionBeforeWidget = editor.model.createPositionBefore( modelRoot.getChild( 0 ) ); + + sinon.assert.calledOnce( executeSpy ); + sinon.assert.calledWith( executeSpy, 'insertParagraph' ); + + expect( spyExecutePosition.isEqual( positionBeforeWidget ) ).to.be.true; + + expect( getModelData( editor.model ) ).to.equal( '[]' ); + } ); + + it( 'should not copy attribute if it has not got copyOnReplace attribute', () => { + editor.model.schema.extend( 'paragraph', { + allowAttributes: 'a' + } ); + + editor.model.schema.extend( '$blockObject', { + allowAttributes: 'a' + } ); + + setModelData( editor.model, '[]' ); + + plugin._insertParagraph( modelRoot.getChild( 0 ), 'before' ); + + const spyExecutePosition = executeSpy.firstCall.args[ 1 ].position; + const positionBeforeWidget = editor.model.createPositionBefore( modelRoot.getChild( 0 ) ); + + sinon.assert.calledOnce( executeSpy ); + sinon.assert.calledWith( executeSpy, 'insertParagraph' ); + + expect( spyExecutePosition.isEqual( positionBeforeWidget ) ).to.be.true; + + expect( getModelData( editor.model ) ).to.equal( '[]' ); + } ); } ); describe( 'UI to type around view widgets', () => { @@ -1710,6 +1786,82 @@ describe( 'WidgetTypeAround', () => { } } ); + describe( 'Model#insertObject() integration', () => { + let model, modelSelection; + + beforeEach( () => { + model = editor.model; + modelSelection = model.document.selection; + } ); + + it( 'should not alter insertObject\'s findOptimalPosition parameter other than the document selection', () => { + setModelData( editor.model, 'foo[]baz' ); + + const batchSet = setupBatchWatch(); + const selection = model.createSelection( modelSelection ); + + model.change( writer => { + writer.setSelectionAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE, 'before' ); + model.insertObject( createObject(), selection ); + } ); + + expect( getModelData( model ) ).to.equal( 'foo[]baz' ); + expect( batchSet.size ).to.be.equal( 1 ); + } ); + + it( 'should not alter insertObject when the "fake caret" is not active', () => { + setModelData( editor.model, 'foo[]baz' ); + + const batchSet = setupBatchWatch(); + + expect( modelSelection.getAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE ) ).to.be.undefined; + + model.insertObject( createObject() ); + + expect( getModelData( model ) ).to.equal( 'foo[]baz' ); + expect( modelSelection.getAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE ) ).to.be.undefined; + expect( batchSet.size ).to.be.equal( 1 ); + } ); + + it( 'should alter insertObject\'s findOptimalPosition when the fake carret is active', () => { + setModelData( editor.model, '[]' ); + + const batchSet = setupBatchWatch(); + const insertObjectSpy = sinon.spy( model, 'insertObject' ); + + model.change( writer => { + writer.setSelectionAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE, 'after' ); + } ); + + model.insertObject( createObject(), undefined, undefined, { setSelection: 'on', findOptimalPosition: 'before' } ); + + expect( getModelData( model ) ).to.equal( '[]' ); + expect( modelSelection.getAttribute( TYPE_AROUND_SELECTION_ATTRIBUTE ) ).to.be.undefined; + expect( insertObjectSpy.firstCall.args[ 3 ].findOptimalPosition ).to.equal( 'after' ); + expect( batchSet.size ).to.be.equal( 1 ); + } ); + + function createObject( ) { + return model.change( writer => { + const object = writer.createElement( 'blockWidget' ); + + return object; + } ); + } + + function setupBatchWatch() { + const createdBatches = new Set(); + + model.on( 'applyOperation', ( evt, [ operation ] ) => { + if ( operation.isDocumentOperation ) { + createdBatches.add( operation.batch ); + } + } ); + + return createdBatches; + } + } ); + describe( 'Model#deleteContent() integration', () => { let model, modelSelection;