} 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(
+ ''
+ );
+
+ 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(
+ '' +
+ '- ' +
+ '' +
+ '
' +
+ '- ' +
+ '
' +
+ '- c
' +
+ '- d
' +
+ '
' +
+ ' ' +
+ '
'
+ );
+
+ 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(
+ '' +
+ '- ' +
+ '' +
+ '
' +
+ '- ' +
+ '' +
+ '
' +
+ '
'
+ );
+
+ 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(
+ '' +
+ '- ' +
+ '
' +
+ '- ' +
+ '' +
+ '
' +
+ '
' +
+ ' ' +
+ '
'
+ );
+
+ 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(
+ '' +
+ '- ' +
+ '
' +
+ '' +
+ '' +
+ '' +
+ '- a
' +
+ '
' +
+ '
' +
+ '
' +
+ '- b
' +
+ '
' +
+ ' ' +
+ '
'
+ );
+
+ 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(
+ ''
+ );
+
+ 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
' +
+ '' +
+ '- b
' +
+ '' +
+ '
' +
+ '
'
+ );
+
+ 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(
+ '' +
+ '- Foo
' +
+ '- Bar
' +
+ '
',
+
+ 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(
+ '' +
+ '- Foo
' +
+ '- Bar
' +
+ '
',
+
+ modelList( `
+ # Foo {style:upper-alpha}
+ # Bar
+ ` )
+ );
+ } );
+
+ it( 'should convert mixed lists', () => {
+ test.data(
+ '' +
+ '- OL 1
' +
+ '- OL 2
' +
+ '
' +
+ '' +
+ '- 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(
+ '' +
+ '- OL 1
' +
+ '- OL 2' +
+ '
' +
+ '- UL 1
' +
+ '- UL 2
' +
+ '
' +
+ ' ' +
+ '- OL 3
' +
+ '
',
+
+ 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.
' +
+ '' +
+ '- Foo
' +
+ '- Bar
' +
+ '
' +
+ 'Paragraph.
',
+
+ modelList( `
+ Paragraph.
+ # Foo {id:000} {style:upper-alpha}
+ # Bar {id:001}
+ Paragraph.
+ ` )
+ );
+ } );
+
+ it( 'should convert style on a nested list', () => {
+ test.data(
+ '' +
+ '- ' +
+ 'cd' +
+ '
' +
+ '- efg
' +
+ '
' +
+ ' ' +
+ '
',
+
+ 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(
+ '' +
+ '- Foo
' +
+ '- Bar
' +
+ '
',
+
+ modelList( `
+ # Foo {style:default}
+ # Bar
+ ` ),
+
+ '' +
+ '- Foo
' +
+ '- Bar
' +
+ '
'
+ );
+ } );
+
+ 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(
+ '' +
+ '- Foo
' +
+ '- Bar
' +
+ '
',
+
+ modelList( `
+ # Foo {style:upper-alpha}
+ # Bar
+ ` ),
+
+ '' +
+ '- Foo
' +
+ '- 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(
+ '' +
+ '- Foo
' +
+ 'x
' +
+ '- Bar
' +
+ '
',
+
+ modelList( `
+ # Foo {style:upper-alpha}
+ x
+ # Bar {style:upper-alpha}
+ ` ),
+
+ '' +
+ '- Foo
' +
+ '
' +
+ 'x
' +
+ '' +
+ '- Bar
' +
+ '
'
+ );
+ } );
+
+ 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(
+ '' +
+ '- Foo
' +
+ '- Bar
' +
+ '
',
+
+ modelList( `
+ # Foo {style:default}
+ # Bar
+ ` ),
+
+ '' +
+ '- Foo
' +
+ '- Bar
' +
+ '
'
+ );
+
+ 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' +
+ '',
+
+ modelList( `
+ Foo
+ * Bar {id:000} {style:square}
+ ` ),
+
+ 'Foo
' +
+ ''
+ );
+ } );
+
+ it( 'should convert a list if raw text is after the list', () => {
+ test.data(
+ '' +
+ 'Bar',
+
+ modelList( `
+ * Foo {style:square}
+ Bar
+ ` ),
+
+ '' +
+ 'Bar
'
+ );
+ } );
+
+ it( 'should convert a list if it is surrounded by two text nodes', () => {
+ test.data(
+ 'Foo' +
+ '' +
+ 'Baz',
+
+ modelList( `
+ Foo
+ * Bar {id:000} {style:square}
+ Baz
+ ` ),
+
+ 'Foo
' +
+ '' +
+ '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
' +
+ '' +
+ ' ' +
+ '
'
+ );
+ } );
+
+ 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
' +
+ ''
+ );
+
+ expect( test.reconvertSpy.callCount ).to.equal( 0 );
+ } );
+ } );
+
+ describe( 'set list style', () => {
+ it( 'on a flat list', () => {
+ const input = modelList( `
+ * [a
+ * b]
+ ` );
+
+ const output =
+ '';
+
+ 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' +
+ '' +
+ '
' +
+ '- 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 =
+ '';
+
+ 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' +
+ '' +
+ '
' +
+ '- 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 =
+ '';
+
+ 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' +
+ '' +
+ '
' +
+ '- 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 =
+ '' +
+ '- a
' +
+ '- b
' +
+ '
';
+
+ 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 =
+ '' +
+ '- ' +
+ 'a' +
+ '' +
+ '
' +
+ '- 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', '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' +
+ '
' +
+ '- c
' +
+ '
' +
+ ' ' +
+ '
' +
+ ' ' +
+ '
';
+
+ 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(
+ ''
+ );
+ } );
+ } );
+ } );
+ } );
+
+ 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(
+ '' +
+ '- Foo
' +
+ '- Bar
' +
+ '
',
+
+ 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(
+ '' +
+ '- Foo
' +
+ '- Bar
' +
+ '
',
+
+ modelList( `
+ # Foo {reversed:true}
+ # Bar
+ ` )
+ );
+ } );
+
+ it( 'should convert when the list is in the middle of the content', () => {
+ test.data(
+ 'Paragraph.
' +
+ '' +
+ '- Foo
' +
+ '- Bar
' +
+ '
' +
+ 'Paragraph.
',
+
+ modelList( `
+ Paragraph.
+ # Foo {id:000} {reversed:true}
+ # Bar {id:001}
+ Paragraph.
+ ` ),
+
+ 'Paragraph.
' +
+ '' +
+ '- Foo
' +
+ '- Bar
' +
+ '
' +
+ 'Paragraph.
'
+ );
+ } );
+
+ it( 'should convert on a nested list (in bulleted list)', () => {
+ test.data(
+ '' +
+ '- ' +
+ 'cd' +
+ '
' +
+ '- efg
' +
+ '
' +
+ ' ' +
+ '
',
+
+ modelList( `
+ * cd {id:001}
+ # efg {id:000} {reversed:true}
+ ` )
+ );
+ } );
+
+ it( 'should convert on a nested list (in numbered list)', () => {
+ test.data(
+ '' +
+ '- ' +
+ 'cd' +
+ '
' +
+ '- efg
' +
+ '
' +
+ ' ' +
+ '
',
+
+ 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(
+ '' +
+ '- Foo
' +
+ '- Bar
' +
+ '
',
+
+ modelList( `
+ # Foo {reversed:false}
+ # 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(
+ '' +
+ '- Foo
' +
+ '- Bar
' +
+ '
',
+
+ 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(
+ '' +
+ '- Foo
' +
+ 'x
' +
+ '- Bar
' +
+ '
',
+
+ modelList( `
+ # Foo {reversed:true}
+ x
+ # Bar {reversed:true}
+ ` ),
+
+ '' +
+ '- Foo
' +
+ '
' +
+ 'x
' +
+ '' +
+ '- Bar
' +
+ '
'
+ );
+ } );
+
+ 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(
+ '' +
+ '- Foo
' +
+ '- Bar
' +
+ '
',
+
+ modelList( `
+ # Foo {reversed:false}
+ # Bar
+ ` ),
+
+ '' +
+ '- Foo
' +
+ '- Bar
' +
+ '
'
+ );
+
+ 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(
+ '' +
+ '- ' +
+ '
B1
' +
+ 'B2
' +
+ '' +
+ '- C1
' +
+ '
' +
+ ' ' +
+ '
'
+ );
+ } );
+
+ 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(
+ '' +
+ '- ' +
+ '
C1
' +
+ 'C2
' +
+ ' ' +
+ '
'
+ );
+ } );
+ } );
+ } );
+
+ describe( 'editing pipeline', () => {
+ describe( 'insert', () => {
+ it( 'should convert single list (type: numbered, reversed: false)', () => {
+ test.insert(
+ modelList( `
+ x
+ # [Foo {reversed:false}
+ # Bar]
+ ` ),
+
+ 'x
' +
+ '' +
+ '- Foo
' +
+ '- Bar
' +
+ '
'
+ );
+
+ 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
' +
+ '' +
+ '- Foo
' +
+ '- Bar
' +
+ '
'
+ );
+
+ 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
' +
+ '' +
+ '- ' +
+ 'Foo 1' +
+ '
' +
+ '- Bar 1
' +
+ '- Bar 2
' +
+ '
' +
+ ' ' +
+ '- Foo 2
' +
+ '- Foo 3
' +
+ '
'
+ );
+
+ 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
' +
+ '' +
+ '- Level 0' +
+ '
' +
+ '- Level 0.1' +
+ '
' +
+ '- Level 0.1.1
' +
+ '
' +
+ ' ' +
+ '- Level 0.2' +
+ '
' +
+ '- Level 0.2.1
' +
+ '
' +
+ ' ' +
+ '
' +
+ ' ' +
+ '
'
+ );
+
+ 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' +
+ '
' +
+ '- efg
' +
+ '
' +
+ ' ' +
+ '
'
+ );
+ } );
+ } );
+
+ describe( 'remove', () => {
+ it( 'remove a list item', () => {
+ test.remove(
+ modelList( `
+ p
+ # [a] {reversed:true}
+ # b
+ # c
+ ` ),
+
+ 'p
' +
+ '' +
+ '- b
' +
+ '- c
' +
+ '
'
+ );
+
+ expect( test.reconvertSpy.callCount ).to.equal( 0 );
+ } );
+ } );
+
+ describe( 'set list reversed', () => {
+ it( 'on a flat list', () => {
+ const input = modelList( `
+ # [a
+ # b]
+ ` );
+
+ const output =
+ '' +
+ '- a
' +
+ '- b
' +
+ '
';
+
+ 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 =
+ '' +
+ '- 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( 'listReversed', true, item );
+ }
+ }
+ } );
+ } );
+ } );
+ } );
+
+ describe( 'remove list reversed', () => {
+ it( 'on a flat list', () => {
+ const input = modelList( `
+ # [a {reversed:true}
+ # b]
+ ` );
+
+ const output =
+ '' +
+ '- a
' +
+ '- b
' +
+ '
';
+
+ 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 =
+ '' +
+ '- ' +
+ '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( 'listReversed', item );
+ }
+ }
+ } );
+ } );
+ } );
+ } );
+
+ describe( 'change list type', () => {
+ it( 'on a flat list', () => {
+ const input = modelList( `
+ * [a
+ * b]
+ ` );
+
+ const output =
+ '' +
+ '- a
' +
+ '- b
' +
+ '
';
+
+ 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 =
+ '' +
+ '- ' +
+ '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( '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' +
+ '
' +
+ '- c
' +
+ '
' +
+ ' ' +
+ '
' +
+ ' ' +
+ '
';
+
+ 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(
+ '' +
+ '- a
' +
+ '
'
+ );
+ } );
+ } );
+ } );
+ } );
+
+ 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(
+ '' +
+ '- Foo
' +
+ '- Bar
' +
+ '
',
+
+ 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(
+ '' +
+ '- Foo
' +
+ '- Bar
' +
+ '
',
+
+ modelList( `
+ # Foo {start:5}
+ # Bar
+ ` )
+ );
+ } );
+
+ it( 'should convert when the list is in the middle of the content', () => {
+ test.data(
+ 'Paragraph.
' +
+ '' +
+ '- Foo
' +
+ '- Bar
' +
+ '
' +
+ 'Paragraph.
',
+
+ modelList( `
+ Paragraph.
+ # Foo {id:000} {start:5}
+ # Bar {id:001}
+ Paragraph.
+ ` )
+ );
+ } );
+
+ it( 'should convert on a nested list', () => {
+ test.data(
+ '' +
+ '- ' +
+ 'cd' +
+ '
' +
+ '- efg
' +
+ '
' +
+ ' ' +
+ '
',
+
+ modelList( `
+ * cd {id:001}
+ # efg {id:000} {start:3}
+ ` )
+ );
+ } );
+
+ it( 'should convert on a nested list (same type)', () => {
+ test.data(
+ '' +
+ '- ' +
+ 'cd' +
+ '
' +
+ '- efg
' +
+ '
' +
+ ' ' +
+ '
',
+
+ 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(
+ '' +
+ '- Foo
' +
+ '- Bar
' +
+ '
',
+
+ modelList( `
+ # Foo {start:1}
+ # 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(
+ '' +
+ '- Foo
' +
+ '- Bar
' +
+ '
',
+
+ 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(
+ '' +
+ '- Foo
' +
+ 'x
' +
+ '- Bar
' +
+ '
',
+
+ modelList( `
+ # Foo {start:2}
+ x
+ # Bar {start:2}
+ ` ),
+
+ '' +
+ '- Foo
' +
+ '
' +
+ 'x
' +
+ '' +
+ '- Bar
' +
+ '
'
+ );
+ } );
+
+ 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(
+ '' +
+ '- Foo
' +
+ '- Bar
' +
+ '
',
+
+ modelList( `
+ # Foo {start:1}
+ # Bar
+ ` ),
+
+ '' +
+ '- Foo
' +
+ '- Bar
' +
+ '
'
+ );
+
+ 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(
+ '' +
+ '- ' +
+ '
B1
' +
+ 'B2
' +
+ '' +
+ '- C1
' +
+ '
' +
+ ' ' +
+ '
'
+ );
+ } );
+
+ 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(
+ '' +
+ '- ' +
+ '
C1
' +
+ 'C2
' +
+ ' ' +
+ '
'
+ );
+ } );
+ } );
+ } );
+
+ describe( 'editing pipeline', () => {
+ describe( 'insert', () => {
+ it( 'should convert single list (type: numbered, start: 1)', () => {
+ test.insert(
+ modelList( `
+ x
+ # [Foo {start:1}
+ # Bar]
+ ` ),
+
+ 'x
' +
+ '' +
+ '- Foo
' +
+ '- Bar
' +
+ '
'
+ );
+
+ 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
' +
+ '' +
+ '- Foo
' +
+ '- Bar
' +
+ '
'
+ );
+
+ 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
' +
+ '' +
+ '- ' +
+ 'Foo 1' +
+ '
' +
+ '- Bar 1
' +
+ '- Bar 2
' +
+ '
' +
+ ' ' +
+ '- Foo 2
' +
+ '- Foo 3
' +
+ '
'
+ );
+
+ 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
' +
+ '' +
+ '- Level 0' +
+ '
' +
+ '- Level 0.1' +
+ '
' +
+ '- Level 0.1.1
' +
+ '
' +
+ ' ' +
+ '- Level 0.2' +
+ '
' +
+ '- Level 0.2.1
' +
+ '
' +
+ ' ' +
+ '
' +
+ ' ' +
+ '
'
+ );
+
+ 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' +
+ '
' +
+ '- efg
' +
+ '
' +
+ ' ' +
+ '
'
+ );
+ } );
+ } );
+
+ describe( 'remove', () => {
+ it( 'remove a list item', () => {
+ test.remove(
+ modelList( `
+ p
+ # [a] {start:6}
+ # b
+ # c
+ ` ),
+
+ 'p
' +
+ '' +
+ '- b
' +
+ '- c
' +
+ '
'
+ );
+
+ expect( test.reconvertSpy.callCount ).to.equal( 0 );
+ } );
+ } );
+
+ describe( 'set list reversed', () => {
+ it( 'on a flat list', () => {
+ const input = modelList( `
+ # [a
+ # b]
+ ` );
+
+ const output =
+ '' +
+ '- a
' +
+ '- b
' +
+ '
';
+
+ 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 =
+ '' +
+ '- 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( 'listStart', 6, item );
+ }
+ }
+ } );
+ } );
+ } );
+ } );
+
+ describe( 'change list start index', () => {
+ 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( 'listStart', 6, selection.getFirstRange() );
+ } );
+ } );
+ } );
+
+ it( 'on a list with nested lists', () => {
+ const input = modelList( `
+ # [a {start:2}
+ # b {start:4}
+ # 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( 'listStart', 11, item );
+ }
+ }
+ } );
+ } );
+ } );
+ } );
+
+ describe( 'change list type', () => {
+ it( 'on a flat list', () => {
+ const input = modelList( `
+ # [a {start:2}
+ # b]
+ ` );
+
+ const output =
+ '';
+
+ 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' +
+ '
' +
+ '- 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( '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' +
+ '
' +
+ '- c
' +
+ '
' +
+ ' ' +
+ '
' +
+ ' ' +
+ '
';
+
+ 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(
+ '' +
+ '- a
' +
+ '
'
+ );
+ } );
+ } );
+ } );
+ } );
+
+ 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(
+ '' +
+ '- Foo
' +
+ '- Bar
' +
+ '
',
+
+ 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(
+ '' +
+ '- Foo
' +
+ '- Bar
' +
+ '
',
+
+ 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.
' +
+ '' +
+ '- Foo
' +
+ '- Bar
' +
+ '
' +
+ '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' +
+ '
' +
+ '- efg
' +
+ '
' +
+ ' ' +
+ '
',
+
+ 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(
+ '' +
+ '- ' +
+ 'cd' +
+ '
' +
+ '- efg
' +
+ '
' +
+ ' ' +
+ '
',
+
+ 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
' +
+ '' +
+ '- Foo
' +
+ '- Bar
' +
+ '
'
+ );
+
+ 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
' +
+ '' +
+ '- Foo
' +
+ '- Bar
' +
+ '
'
+ );
+
+ 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
' +
+ '' +
+ '- ' +
+ 'Foo 1' +
+ '
' +
+ '- Bar 1
' +
+ '- Bar 2
' +
+ '
' +
+ ' ' +
+ '- Foo 2
' +
+ '- Foo 3
' +
+ '
'
+ );
+
+ 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
' +
+ '' +
+ '- b
' +
+ '- c
' +
+ '
'
+ );
+
+ 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 =
+ '' +
+ '- a
' +
+ '- b
' +
+ '
';
+
+ 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 =
+ '' +
+ '- a
' +
+ '- b
' +
+ '
';
+
+ 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 =
+ '' +
+ '- a
' +
+ '- b
' +
+ '
';
+
+ 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 =
+ '' +
+ '- a
' +
+ '- b
' +
+ '
';
+
+ 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 =
+ '' +
+ '- a
' +
+ '- b
' +
+ '
';
+
+ 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 =
+ '' +
+ '- a
' +
+ '- b
' +
+ '
';
+
+ 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 =
+ '' +
+ '- a
' +
+ '- b
' +
+ '
';
+
+ 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 =
+ '';
+
+ 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' +
+ '
' +
+ '- c
' +
+ '
' +
+ ' ' +
+ '
' +
+ ' ' +
+ '
';
+
+ 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 @@
+
+
+
+
+
+
+
+ - A numbered list with lower-roman numbering that starts at 3.
+ - Item with multiple blocks...
...and this is the 3rd block of it.
+ This is a list item with two paragraphs.
And here is the last one.
+
+
+ -
+
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:
+
+ It's numbered, lower-alpha that starts at 3...
+ ...and it is reversed.
+
+ 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
+
+ - First item
+ - Second item
+ - Third item
+
+
Unordered list
+
+ - First item
+ - Second item
+ - Third item
+
+
+
+Styles + Start index
+
+
Ordered list
+
+ - First item
+ - Second item
+ - Third item
+
+
Unordered list
+
+ - First item
+ - Second item
+ - Third item
+
+
+
+Styles + Reversed
+
+
Ordered list
+
+ - First item
+ - Second item
+ - Third item
+
+
Unordered list
+
+ - First item
+ - Second item
+ - Third item
+
+
+
+Without styles, just extra properties
+
+Start index + Reversed
+
+
Ordered list
+
+ - First item
+ - Second item
+ - Third item
+
+
Unordered list
+
+ - First item
+ - Second item
+ - Third item
+
+
+
+Start index
+
+
Ordered list
+
+ - First item
+ - Second item
+ - Third item
+
+
Unordered list
+
+ - First item
+ - Second item
+ - Third item
+
+
+
+Reversed
+
+
Ordered list
+
+ - First item
+ - Second item
+ - Third item
+
+
Unordered list
+
+ - First item
+ - Second item
+ - Third item
+
+
+
+Just styles
+
+
+
Ordered list
+
+ - First item
+ - Second item
+ - Third item
+
+
Unordered list
+
+ - First item
+ - Second item
+ - Third item
+
+
+
+No properties enabled
+
+
+
Ordered list
+
+ - First item
+ - Second item
+ - Third item
+
+
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
+
+
Document lists
+
+ -
+
First
+ Second
+ Third
+
+ - Second 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
+
+ after image
+
+ -
+ before image with caption
+
+ after image with caption
+
+ -
+
Heading 1
+ Text
+ Text
+
+ Heading 2
+ Heading 3
+ -
+ before block quote
+
+ Quote
+
+ End of quote
+
+ after block quote
+
+ -
+ before table
+
+ Foo | Bar |
+
+
+
+ |
+
+ 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, '$1>\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;