From cabc90b9ccf698fca3680d124c14c0a1e683804f Mon Sep 17 00:00:00 2001 From: Arkadiusz Filipczak Date: Thu, 10 Aug 2023 13:11:12 +0200 Subject: [PATCH 01/20] Initial implementation of simple document lists. --- .../src/documentlist/converters.ts | 4 +- .../src/documentlist/documentlistediting.ts | 33 +- .../documentlistpropertiesediting.ts | 11 +- packages/ckeditor5-list/src/listconfig.ts | 7 + .../documentlistediting-single-block.js | 349 ++++++++++++++++++ .../tests/documentlist/documentlistediting.js | 9 + .../documentlistpropertiesediting.js | 10 +- 7 files changed, 401 insertions(+), 22 deletions(-) create mode 100644 packages/ckeditor5-list/tests/documentlist/documentlistediting-single-block.js diff --git a/packages/ckeditor5-list/src/documentlist/converters.ts b/packages/ckeditor5-list/src/documentlist/converters.ts index 751e4307b71..7d1461f90d8 100644 --- a/packages/ckeditor5-list/src/documentlist/converters.ts +++ b/packages/ckeditor5-list/src/documentlist/converters.ts @@ -361,11 +361,11 @@ export function listItemDowncastConverter( */ export function bogusParagraphCreator( attributeNames: Array, - { dataPipeline }: { dataPipeline?: boolean } = {} + { dataPipeline, multiBlock }: { dataPipeline?: boolean; multiBlock: boolean } ): ElementCreatorFunction { return ( modelElement, { writer } ) => { // Convert only if a bogus paragraph should be used. - if ( !shouldUseBogusParagraph( modelElement, attributeNames ) ) { + if ( multiBlock && !shouldUseBogusParagraph( modelElement, attributeNames ) ) { return null; } diff --git a/packages/ckeditor5-list/src/documentlist/documentlistediting.ts b/packages/ckeditor5-list/src/documentlist/documentlistediting.ts index 3046084f42f..34db4cd8af2 100644 --- a/packages/ckeditor5-list/src/documentlist/documentlistediting.ts +++ b/packages/ckeditor5-list/src/documentlist/documentlistediting.ts @@ -9,6 +9,7 @@ import { Plugin, + type Editor, type MultiCommand } from 'ckeditor5/src/core'; @@ -107,12 +108,22 @@ export default class DocumentListEditing extends Plugin { return [ Enter, Delete, DocumentListUtils ] as const; } + /** + * @inheritDoc + */ + constructor( editor: Editor ) { + super( editor ); + + editor.config.define( 'list.multiBlock', true ); + } + /** * @inheritDoc */ public init(): void { const editor = this.editor; const model = editor.model; + const multiBlock = editor.config.get( 'list.multiBlock' )!; if ( editor.plugins.has( 'ListEditing' ) ) { /** @@ -124,9 +135,13 @@ export default class DocumentListEditing extends Plugin { throw new CKEditorError( 'document-list-feature-conflict', this, { conflictPlugin: 'ListEditing' } ); } - model.schema.extend( '$container', { allowAttributes: LIST_BASE_ATTRIBUTES } ); - model.schema.extend( '$block', { allowAttributes: LIST_BASE_ATTRIBUTES } ); - model.schema.extend( '$blockObject', { allowAttributes: LIST_BASE_ATTRIBUTES } ); + if ( multiBlock ) { + model.schema.extend( '$container', { allowAttributes: LIST_BASE_ATTRIBUTES } ); + model.schema.extend( '$block', { allowAttributes: LIST_BASE_ATTRIBUTES } ); + model.schema.extend( '$blockObject', { allowAttributes: LIST_BASE_ATTRIBUTES } ); + } else { + model.schema.register( 'listItem', { allowAttributes: LIST_BASE_ATTRIBUTES, inheritAllFrom: '$block' } ); + } for ( const attribute of LIST_BASE_ATTRIBUTES ) { model.schema.setAttributeProperties( attribute, { @@ -389,9 +404,11 @@ export default class DocumentListEditing extends Plugin { const editor = this.editor; const model = editor.model; const attributeNames = this._getListAttributeNames(); + const multiBlock = editor.config.get( 'list.multiBlock' )!; + const elementName = multiBlock ? 'paragraph' : 'listItem'; editor.conversion.for( 'upcast' ) - .elementToElement( { view: 'li', model: 'paragraph' } ) + .elementToElement( { view: 'li', model: elementName } ) .add( dispatcher => { dispatcher.on( 'element:li', listItemUpcastConverter() ); dispatcher.on( 'element:ul', listUpcastCleanList(), { priority: 'high' } ); @@ -400,15 +417,15 @@ export default class DocumentListEditing extends Plugin { editor.conversion.for( 'editingDowncast' ) .elementToElement( { - model: 'paragraph', - view: bogusParagraphCreator( attributeNames ), + model: elementName, + view: bogusParagraphCreator( attributeNames, { multiBlock } ), converterPriority: 'high' } ); editor.conversion.for( 'dataDowncast' ) .elementToElement( { - model: 'paragraph', - view: bogusParagraphCreator( attributeNames, { dataPipeline: true } ), + model: elementName, + view: bogusParagraphCreator( attributeNames, { dataPipeline: true, multiBlock } ), converterPriority: 'high' } ); diff --git a/packages/ckeditor5-list/src/documentlistproperties/documentlistpropertiesediting.ts b/packages/ckeditor5-list/src/documentlistproperties/documentlistpropertiesediting.ts index 8d6dabe3de7..207e55c3c03 100644 --- a/packages/ckeditor5-list/src/documentlistproperties/documentlistpropertiesediting.ts +++ b/packages/ckeditor5-list/src/documentlistproperties/documentlistpropertiesediting.ts @@ -68,12 +68,10 @@ export default class DocumentListPropertiesEditing extends Plugin { constructor( editor: Editor ) { super( editor ); - editor.config.define( 'list', { - properties: { - styles: true, - startIndex: false, - reversed: false - } + editor.config.define( 'list.properties', { + styles: true, + startIndex: false, + reversed: false } ); } @@ -91,6 +89,7 @@ export default class DocumentListPropertiesEditing extends Plugin { for ( const strategy of strategies ) { strategy.addCommand( editor ); + // TODO model.schema.extend( '$container', { allowAttributes: strategy.attributeName } ); model.schema.extend( '$block', { allowAttributes: strategy.attributeName } ); model.schema.extend( '$blockObject', { allowAttributes: strategy.attributeName } ); diff --git a/packages/ckeditor5-list/src/listconfig.ts b/packages/ckeditor5-list/src/listconfig.ts index 2ff7dea0ca8..852399254ad 100644 --- a/packages/ckeditor5-list/src/listconfig.ts +++ b/packages/ckeditor5-list/src/listconfig.ts @@ -33,6 +33,13 @@ export interface ListConfig { * Read more in {@link module:list/listconfig~ListPropertiesConfig}. */ properties?: ListPropertiesConfig; + + /** + * Allows multiple blocks in single list item. + * + * @default true + */ + multiBlock?: boolean; } /** diff --git a/packages/ckeditor5-list/tests/documentlist/documentlistediting-single-block.js b/packages/ckeditor5-list/tests/documentlist/documentlistediting-single-block.js new file mode 100644 index 00000000000..c660fd195c3 --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/documentlistediting-single-block.js @@ -0,0 +1,349 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import DocumentListEditing from '../../src/documentlist/documentlistediting'; + +import BoldEditing from '@ckeditor/ckeditor5-basic-styles/src/bold/boldediting'; +import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting'; +import ClipboardPipeline from '@ckeditor/ckeditor5-clipboard/src/clipboardpipeline'; +import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting'; +import HeadingEditing from '@ckeditor/ckeditor5-heading/src/headingediting'; +import IndentEditing from '@ckeditor/ckeditor5-indent/src/indentediting'; +import TableEditing from '@ckeditor/ckeditor5-table/src/tableediting'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; +import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; + +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import { getData as getModelData, parse as parseModel, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; + +import ListEditing from '../../src/list/listediting'; +import DocumentListIndentCommand from '../../src/documentlist/documentlistindentcommand'; +import DocumentListSplitCommand from '../../src/documentlist/documentlistsplitcommand'; + +import stubUid from './_utils/uid'; +import { modelList, prepareTest } from './_utils/utils'; + +describe( 'DocumentListEditing (multiBlock=false)', () => { + let editor, model, modelDoc, modelRoot, view; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + editor = await VirtualTestEditor.create( { + list: { + multiBlock: false + }, + plugins: [ Paragraph, ClipboardPipeline, BoldEditing, DocumentListEditing, UndoEditing, + BlockQuoteEditing, TableEditing, HeadingEditing ] + } ); + + model = editor.model; + modelDoc = model.document; + modelRoot = modelDoc.getRoot(); + + view = editor.editing.view; + + model.schema.extend( 'paragraph', { + allowAttributes: 'foo' + } ); + + // Stub `view.scrollToTheSelection` as it will fail on VirtualTestEditor without DOM. + sinon.stub( view, 'scrollToTheSelection' ).callsFake( () => {} ); + stubUid(); + } ); + + afterEach( async () => { + await editor.destroy(); + } ); + + it( 'should set proper schema rules', () => { + expect( model.schema.checkAttribute( [ '$root', 'listItem' ], 'listItemId' ) ).to.be.true; + expect( model.schema.checkAttribute( [ '$root', 'listItem' ], 'listIndent' ) ).to.be.true; + expect( model.schema.checkAttribute( [ '$root', 'listItem' ], 'listType' ) ).to.be.true; + expect( model.schema.checkAttribute( [ '$root', 'paragraph' ], 'listItemId' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'paragraph' ], 'listIndent' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'paragraph' ], 'listType' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'heading1' ], 'listItemId' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'heading1' ], 'listIndent' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'heading1' ], 'listType' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'blockQuote' ], 'listItemId' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'blockQuote' ], 'listIndent' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'blockQuote' ], 'listType' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'table' ], 'listItemId' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'table' ], 'listIndent' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'table' ], 'listType' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'tableCell' ], 'listItemId' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'tableCell' ], 'listIndent' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'tableCell' ], 'listType' ) ).to.be.false; + } ); + + describe( 'commands', () => { + it( 'should register indent list command', () => { + const command = editor.commands.get( 'indentList' ); + + expect( command ).to.be.instanceOf( DocumentListIndentCommand ); + } ); + + it( 'should register outdent list command', () => { + const command = editor.commands.get( 'outdentList' ); + + expect( command ).to.be.instanceOf( DocumentListIndentCommand ); + } ); + + it( 'should register the splitListItemBefore command', () => { + const command = editor.commands.get( 'splitListItemBefore' ); + + expect( command ).to.be.instanceOf( DocumentListSplitCommand ); + } ); + + it( 'should register the splitListItemAfter command', () => { + const command = editor.commands.get( 'splitListItemAfter' ); + + expect( command ).to.be.instanceOf( DocumentListSplitCommand ); + } ); + + it( 'should add indent list command to indent command', async () => { + const editor = await VirtualTestEditor.create( { + plugins: [ Paragraph, IndentEditing, DocumentListEditing ] + } ); + + const indentListCommand = editor.commands.get( 'indentList' ); + const indentCommand = editor.commands.get( 'indent' ); + + const spy = sinon.stub( indentListCommand, 'execute' ); + + indentListCommand.isEnabled = true; + indentCommand.execute(); + + sinon.assert.calledOnce( spy ); + + await editor.destroy(); + } ); + + it( 'should add outdent list command to outdent command', async () => { + const editor = await VirtualTestEditor.create( { + plugins: [ Paragraph, IndentEditing, DocumentListEditing ] + } ); + + const outdentListCommand = editor.commands.get( 'outdentList' ); + const outdentCommand = editor.commands.get( 'outdent' ); + + const spy = sinon.stub( outdentListCommand, 'execute' ); + + outdentListCommand.isEnabled = true; + outdentCommand.execute(); + + sinon.assert.calledOnce( spy ); + + await editor.destroy(); + } ); + } ); + + describe( 'post fixer', () => { + describe( 'insert', () => { + // TODO: insert the same listItemId and check it's fixed. + } ); + + describe( 'move', () => { + // eslint-disable-next-line no-unused-vars + function testList( input, offset, output ) { + const selection = prepareTest( model, input ); + + model.change( writer => { + const targetPosition = writer.createPositionAt( modelRoot, offset ); + + writer.move( selection.getFirstRange(), targetPosition ); + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( output ); + } + + // TODO: insert the same listItemId and check it's fixed. + } ); + + describe( 'rename', () => { + it( 'to element that does not allow list attributes', () => { + const modelBefore = + 'a' + + 'b' + + '[c]' + + 'd' + + 'e' + + 'f' + + 'g' + + 'h' + + 'i'; + + const expectedModel = + 'a' + + 'b' + + 'c' + + 'd' + + 'e' + + 'f' + + 'g' + + 'h' + + 'i'; + + const selection = prepareTest( model, modelBefore ); + + model.change( writer => { + writer.rename( selection.getFirstPosition().nodeAfter, 'paragraph' ); + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( expectedModel ); + } ); + } ); + + describe( 'changing list attributes', () => { + it( 'remove list attributes', () => { + const modelBefore = + 'a' + + 'b' + + '[c]' + + 'd' + + 'e' + + 'f' + + 'g' + + 'h' + + 'i'; + + const expectedModel = + 'a' + + 'b' + + // TODO + 'c' + + 'd' + + 'e' + + 'f' + + 'g' + + 'h' + + 'i'; + + const selection = prepareTest( model, modelBefore ); + const element = selection.getFirstPosition().nodeAfter; + + model.change( writer => { + writer.removeAttribute( 'listItemId', element ); + writer.removeAttribute( 'listIndent', element ); + writer.removeAttribute( 'listType', element ); + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( expectedModel ); + } ); + + // TODO: What to do? + it.skip( 'add list attributes', () => { + const modelBefore = + 'a' + + 'b' + + '[c]' + + 'd' + + 'e' + + 'f' + + 'g'; + + const expectedModel = + 'a' + + 'b' + + 'c' + + 'd' + + 'e' + + 'f' + + 'g'; + + const selection = prepareTest( model, modelBefore ); + const element = selection.getFirstPosition().nodeAfter; + + model.change( writer => { + writer.setAttribute( 'listItemId', 'c', element ); + writer.setAttribute( 'listIndent', 2, element ); + writer.setAttribute( 'listType', 'bulleted', element ); + writer.setAttribute( 'listIndent', 2, element.nextSibling ); + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( expectedModel ); + } ); + } ); + } ); + + describe( 'multiBlock = false', () => { + + } ); +} ); + +describe( 'DocumentListEditing - registerDowncastStrategy()', () => { + let editor, model, view; + + afterEach( async () => { + await editor.destroy(); + } ); + + it( 'should allow registering strategy for list elements', async () => { + await createEditor( class CustomPlugin extends Plugin { + init() { + this.editor.plugins.get( 'DocumentListEditing' ).registerDowncastStrategy( { + scope: 'list', + attributeName: 'someFoo', + + setAttributeOnDowncast( writer, attributeValue, viewElement ) { + writer.setAttribute( 'data-foo', attributeValue, viewElement ); + } + } ); + } + } ); + + setModelData( model, modelList( ` + * foo + * bar + ` ) ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
    ' + + '
  • foo
  • ' + + '
  • bar
  • ' + + '
' + ); + } ); + + it( 'should allow registering strategy for list items elements', async () => { + await createEditor( class CustomPlugin extends Plugin { + init() { + this.editor.plugins.get( 'DocumentListEditing' ).registerDowncastStrategy( { + scope: 'item', + attributeName: 'someFoo', + + setAttributeOnDowncast( writer, attributeValue, viewElement ) { + writer.setAttribute( 'data-foo', attributeValue, viewElement ); + } + } ); + } + } ); + + setModelData( model, modelList( ` + * foo + * bar + ` ) ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
    ' + + '
  • foo
  • ' + + '
  • bar
  • ' + + '
' + ); + } ); + + async function createEditor( extraPlugin ) { + editor = await VirtualTestEditor.create( { + plugins: [ extraPlugin, Paragraph, DocumentListEditing, UndoEditing ] + } ); + + model = editor.model; + view = editor.editing.view; + } +} ); diff --git a/packages/ckeditor5-list/tests/documentlist/documentlistediting.js b/packages/ckeditor5-list/tests/documentlist/documentlistediting.js index a03eb25ebec..afe268db1ac 100644 --- a/packages/ckeditor5-list/tests/documentlist/documentlistediting.js +++ b/packages/ckeditor5-list/tests/documentlist/documentlistediting.js @@ -75,6 +75,12 @@ describe( 'DocumentListEditing', () => { expect( editor.plugins.get( DocumentListEditing ) ).to.be.instanceOf( DocumentListEditing ); } ); + it( 'should define config', () => { + expect( editor.config.get( 'list' ) ).to.deep.equal( { + multiBlock: true + } ); + } ); + it( 'should throw if loaded alongside ListEditing plugin', async () => { let caughtError; @@ -90,6 +96,9 @@ describe( 'DocumentListEditing', () => { } ); it( 'should set proper schema rules', () => { + expect( model.schema.checkAttribute( [ '$root', 'listItem' ], 'listItemId' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'listItem' ], 'listIndent' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', 'listItem' ], 'listType' ) ).to.be.false; expect( model.schema.checkAttribute( [ '$root', 'paragraph' ], 'listItemId' ) ).to.be.true; expect( model.schema.checkAttribute( [ '$root', 'paragraph' ], 'listIndent' ) ).to.be.true; expect( model.schema.checkAttribute( [ '$root', 'paragraph' ], 'listType' ) ).to.be.true; diff --git a/packages/ckeditor5-list/tests/documentlistproperties/documentlistpropertiesediting.js b/packages/ckeditor5-list/tests/documentlistproperties/documentlistpropertiesediting.js index 60008d1e7d3..0553872e5f4 100644 --- a/packages/ckeditor5-list/tests/documentlistproperties/documentlistpropertiesediting.js +++ b/packages/ckeditor5-list/tests/documentlistproperties/documentlistpropertiesediting.js @@ -33,12 +33,10 @@ describe( 'DocumentListPropertiesEditing', () => { } ); it( 'should have default values', () => { - expect( editor.config.get( 'list' ) ).to.deep.equal( { - properties: { - styles: true, - startIndex: false, - reversed: false - } + expect( editor.config.get( 'list.properties' ) ).to.deep.equal( { + styles: true, + startIndex: false, + reversed: false } ); } ); From fb91687b48846fbc41351333f613ac1f38c05299 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Thu, 10 Aug 2023 13:24:53 +0200 Subject: [PATCH 02/20] Added manual test for simple document lists. --- .../tests/manual/documentlist-simple.html | 22 ++++++++ .../tests/manual/documentlist-simple.js | 32 +++++++++++ .../tests/manual/documentlist-simple.md | 53 +++++++++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 packages/ckeditor5-list/tests/manual/documentlist-simple.html create mode 100644 packages/ckeditor5-list/tests/manual/documentlist-simple.js create mode 100644 packages/ckeditor5-list/tests/manual/documentlist-simple.md diff --git a/packages/ckeditor5-list/tests/manual/documentlist-simple.html b/packages/ckeditor5-list/tests/manual/documentlist-simple.html new file mode 100644 index 00000000000..bc4914dc981 --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/documentlist-simple.html @@ -0,0 +1,22 @@ +
+

This is a test for list feature.

+

Some more text for testing.

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

Paragraph.

+

Another testing paragraph.

+
    +
  1. Numbered list item 1
  2. +
+
    +
  • Another bullet list
  • +
+
diff --git a/packages/ckeditor5-list/tests/manual/documentlist-simple.js b/packages/ckeditor5-list/tests/manual/documentlist-simple.js new file mode 100644 index 00000000000..7fcc063936f --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/documentlist-simple.js @@ -0,0 +1,32 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals console, window, document */ + +import { ClassicEditor } from '@ckeditor/ckeditor5-editor-classic'; +import { Enter } from '@ckeditor/ckeditor5-enter'; +import { Typing } from '@ckeditor/ckeditor5-typing'; +import { Heading } from '@ckeditor/ckeditor5-heading'; +import { Paragraph } from '@ckeditor/ckeditor5-paragraph'; +import { Undo } from '@ckeditor/ckeditor5-undo'; +import { Clipboard } from '@ckeditor/ckeditor5-clipboard'; +import { Indent } from '@ckeditor/ckeditor5-indent'; + +import DocumentList from '../../src/documentlist'; + +ClassicEditor + .create( document.querySelector( '#editor' ), { + plugins: [ Enter, Typing, Heading, Paragraph, Undo, DocumentList, Indent, Clipboard ], + toolbar: [ 'heading', '|', 'bulletedList', 'numberedList', '|', 'outdent', 'indent', '|', 'undo', 'redo' ], + list: { + multiBlock: false + } + } ) + .then( editor => { + window.editor = editor; + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/packages/ckeditor5-list/tests/manual/documentlist-simple.md b/packages/ckeditor5-list/tests/manual/documentlist-simple.md new file mode 100644 index 00000000000..d58bd9c2c16 --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/documentlist-simple.md @@ -0,0 +1,53 @@ +### Loading + +1. The data should be loaded with: + * two paragraphs, + * bulleted list with eight items, + * two paragraphs, + * numbered list with one item, + * bullet list with one item. +2. Toolbar should have two buttons: for bullet and for numbered list. + +### Testing + +After each step test undo (whole stack) -> redo (whole stack) -> undo (whole stack). + +Creating: + +1. Convert first paragraph to list item +2. Create empty paragraph and convert to list item +3. Enter in the middle of item +4. Enter at the start of item +5. Enter at the end of item + +Removing: + +1. Delete all contents from list item and then the list item +2. Press enter in empty list item +3. Click on highlighted button ("turn off" list feature) +4. Do it for first, second and last list item + +Changing type: + +1. Change type from bulleted to numbered +2. Do it for first, second and last item +3. Do it for multiple items at once + +Merging: + +1. Convert paragraph before list to same type of list +2. Convert paragraph after list to same type of list +3. Convert paragraph before list to different type of list +4. Convert paragraph after list to different type of list +5. Convert first paragraph to bulleted list, then convert second paragraph to bulleted list +6. Convert multiple items and paragraphs at once + +Selection deletion. Make selection between items and press delete button: + +1. two items from the same list +2. all items in a list +3. paragraph before list and second item of list +4. paragraph after list and one-but-last item of list +5. two paragraphs that have list between them +6. two items from different lists of same type +7. two items from different lists of different type From 23fbfa614b2f392f65796a7df4a64241d39c6c17 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Thu, 10 Aug 2023 14:09:44 +0200 Subject: [PATCH 03/20] The DocumentListCommand supports simple lists. --- .../src/documentlist/documentlistcommand.ts | 23 +++++++++++++++---- .../src/documentlist/utils/model.ts | 17 ++++++++++++-- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/packages/ckeditor5-list/src/documentlist/documentlistcommand.ts b/packages/ckeditor5-list/src/documentlist/documentlistcommand.ts index e9b51287f56..8987f879e3b 100644 --- a/packages/ckeditor5-list/src/documentlist/documentlistcommand.ts +++ b/packages/ckeditor5-list/src/documentlist/documentlistcommand.ts @@ -19,7 +19,8 @@ import { ListItemUid, sortBlocks, getSelectedBlockObject, - isListItemBlock + isListItemBlock, + checkCanBecomeSimpleListItem } from './utils/model'; /** @@ -75,7 +76,7 @@ export default class DocumentListCommand extends Command { const selectedBlockObject = getSelectedBlockObject( model ); const blocks = Array.from( document.selection.getSelectedBlocks() ) - .filter( block => model.schema.checkAttribute( block, 'listType' ) ); + .filter( block => model.schema.checkAttribute( block, 'listType' ) || checkCanBecomeSimpleListItem( block, model.schema ) ); // Whether we are turning off some items. const turnOff = options.forceValue !== undefined ? !options.forceValue : this.value; @@ -92,7 +93,14 @@ export default class DocumentListCommand extends Command { changedBlocks.push( ...splitListItemBefore( itemBlocks[ 1 ], writer ) ); } - // Convert list blocks to plain blocks. + // Convert simple list items to plain paragraphs. + for ( const block of blocks ) { + if ( block.is( 'element', 'listItem' ) ) { + writer.rename( block, 'paragraph' ); + } + } + + // Strip list attributes. changedBlocks.push( ...removeListAttributes( blocks, writer ) ); // Outdent items following the selected list item. @@ -100,7 +108,7 @@ export default class DocumentListCommand extends Command { this._fireAfterExecute( changedBlocks ); } - // Turning on the list items for a collapsed selection inside a list item. + // Changing type of list items for a collapsed selection inside a list item. else if ( ( selectedBlockObject || document.selection.isCollapsed ) && isListItemBlock( blocks[ 0 ] ) ) { const changedBlocks = getListItems( selectedBlockObject || blocks[ 0 ] ); @@ -117,6 +125,11 @@ export default class DocumentListCommand extends Command { for ( const block of blocks ) { // Promote the given block to the list item. if ( !block.hasAttribute( 'listType' ) ) { + // Rename block to a simple list item if this option is enabled. + if ( !block.is( 'element', 'listItem' ) && checkCanBecomeSimpleListItem( block, model.schema ) ) { + writer.rename( block, 'listItem' ); + } + writer.setAttributes( { listIndent: 0, listItemId: ListItemUid.next(), @@ -192,7 +205,7 @@ export default class DocumentListCommand extends Command { } for ( const block of blocks ) { - if ( schema.checkAttribute( block, 'listType' ) ) { + if ( schema.checkAttribute( block, 'listType' ) || checkCanBecomeSimpleListItem( block, schema ) ) { return true; } } diff --git a/packages/ckeditor5-list/src/documentlist/utils/model.ts b/packages/ckeditor5-list/src/documentlist/utils/model.ts index b9a34845488..c895681a1c9 100644 --- a/packages/ckeditor5-list/src/documentlist/utils/model.ts +++ b/packages/ckeditor5-list/src/documentlist/utils/model.ts @@ -13,7 +13,8 @@ import type { Model, Node, Writer, - Item + Item, + Schema } from 'ckeditor5/src/engine'; import { uid, toArray, type ArrayOrItem } from 'ckeditor5/src/utils'; @@ -545,7 +546,19 @@ export function getSelectedBlockObject( model: Model ): Element | null { return null; } -// Merges a given block to the given parent block if parent is a list item and there is no more blocks in the same item. +/** + * Checks whether the given block can be replaced by a listItem. + * + * @param block A block to be tested. + * @param schema The schema of the document. + */ +export function checkCanBecomeSimpleListItem( block: Element, schema: Schema ): boolean { + return schema.checkChild( block.parent as Element, 'listItem' ) && !schema.isObject( block ); +} + +/** + * Merges a given block to the given parent block if parent is a list item and there is no more blocks in the same item. + */ function mergeListItemIfNotLast( block: ListElement, parentBlock: ListElement, From 020ec329634a23740379c51d0b6e39ba0bafc785 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Thu, 10 Aug 2023 14:33:06 +0200 Subject: [PATCH 04/20] Outdenting simple list properly converts listItem to paragraphs. --- .../src/documentlist/documentlistcommand.ts | 7 ------- .../ckeditor5-list/src/documentlist/utils/model.ts | 10 ++++++++++ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/ckeditor5-list/src/documentlist/documentlistcommand.ts b/packages/ckeditor5-list/src/documentlist/documentlistcommand.ts index 8987f879e3b..069ed80e764 100644 --- a/packages/ckeditor5-list/src/documentlist/documentlistcommand.ts +++ b/packages/ckeditor5-list/src/documentlist/documentlistcommand.ts @@ -93,13 +93,6 @@ export default class DocumentListCommand extends Command { changedBlocks.push( ...splitListItemBefore( itemBlocks[ 1 ], writer ) ); } - // Convert simple list items to plain paragraphs. - for ( const block of blocks ) { - if ( block.is( 'element', 'listItem' ) ) { - writer.rename( block, 'paragraph' ); - } - } - // Strip list attributes. changedBlocks.push( ...removeListAttributes( blocks, writer ) ); diff --git a/packages/ckeditor5-list/src/documentlist/utils/model.ts b/packages/ckeditor5-list/src/documentlist/utils/model.ts index c895681a1c9..6d36d039edf 100644 --- a/packages/ckeditor5-list/src/documentlist/utils/model.ts +++ b/packages/ckeditor5-list/src/documentlist/utils/model.ts @@ -393,6 +393,14 @@ export function removeListAttributes( ): Array { blocks = toArray( blocks ); + // Convert simple list items to plain paragraphs. + for ( const block of blocks ) { + if ( block.is( 'element', 'listItem' ) ) { + writer.rename( block, 'paragraph' ); + } + } + + // Remove list attributes. for ( const block of blocks ) { for ( const attributeKey of block.getAttributeKeys() ) { if ( attributeKey.startsWith( 'list' ) ) { @@ -549,6 +557,8 @@ export function getSelectedBlockObject( model: Model ): Element | null { /** * Checks whether the given block can be replaced by a listItem. * + * Note that this is possible only when multiBlock = false option is set in feature config. + * * @param block A block to be tested. * @param schema The schema of the document. */ From e4aadc87a9462048b758cc0d34abe861c79dcf9b Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Thu, 10 Aug 2023 15:26:56 +0200 Subject: [PATCH 05/20] List item merging should be disabled in simple lists mode. --- .../src/documentlist/documentlistediting.ts | 16 +++++++++------- .../src/documentlist/documentlistmergecommand.ts | 2 +- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/ckeditor5-list/src/documentlist/documentlistediting.ts b/packages/ckeditor5-list/src/documentlist/documentlistediting.ts index 34db4cd8af2..68080c04637 100644 --- a/packages/ckeditor5-list/src/documentlist/documentlistediting.ts +++ b/packages/ckeditor5-list/src/documentlist/documentlistediting.ts @@ -156,12 +156,14 @@ export default class DocumentListEditing extends Plugin { editor.commands.add( 'indentList', new DocumentListIndentCommand( editor, 'forward' ) ); editor.commands.add( 'outdentList', new DocumentListIndentCommand( editor, 'backward' ) ); - editor.commands.add( 'mergeListItemBackward', new DocumentListMergeCommand( editor, 'backward' ) ); - editor.commands.add( 'mergeListItemForward', new DocumentListMergeCommand( editor, 'forward' ) ); - editor.commands.add( 'splitListItemBefore', new DocumentListSplitCommand( editor, 'before' ) ); editor.commands.add( 'splitListItemAfter', new DocumentListSplitCommand( editor, 'after' ) ); + if ( multiBlock ) { + editor.commands.add( 'mergeListItemBackward', new DocumentListMergeCommand( editor, 'backward' ) ); + editor.commands.add( 'mergeListItemForward', new DocumentListMergeCommand( editor, 'forward' ) ); + } + this._setupDeleteIntegration(); this._setupEnterIntegration(); this._setupTabIntegration(); @@ -222,8 +224,8 @@ export default class DocumentListEditing extends Plugin { */ private _setupDeleteIntegration() { const editor = this.editor; - const mergeBackwardCommand: DocumentListMergeCommand = editor.commands.get( 'mergeListItemBackward' )!; - const mergeForwardCommand: DocumentListMergeCommand = editor.commands.get( 'mergeListItemForward' )!; + const mergeBackwardCommand: DocumentListMergeCommand | undefined = editor.commands.get( 'mergeListItemBackward' ); + const mergeForwardCommand: DocumentListMergeCommand | undefined = editor.commands.get( 'mergeListItemForward' ); this.listenTo( editor.editing.view.document, 'delete', ( evt, data ) => { const selection = editor.model.document.selection; @@ -262,7 +264,7 @@ export default class DocumentListEditing extends Plugin { } // Merge block with previous one (on the block level or on the content level). else { - if ( !mergeBackwardCommand.isEnabled ) { + if ( !mergeBackwardCommand || !mergeBackwardCommand.isEnabled ) { return; } @@ -281,7 +283,7 @@ export default class DocumentListEditing extends Plugin { return; } - if ( !mergeForwardCommand.isEnabled ) { + if ( !mergeForwardCommand || !mergeForwardCommand.isEnabled ) { return; } diff --git a/packages/ckeditor5-list/src/documentlist/documentlistmergecommand.ts b/packages/ckeditor5-list/src/documentlist/documentlistmergecommand.ts index bb221e00765..2c78c6e3b59 100644 --- a/packages/ckeditor5-list/src/documentlist/documentlistmergecommand.ts +++ b/packages/ckeditor5-list/src/documentlist/documentlistmergecommand.ts @@ -106,7 +106,7 @@ export default class DocumentListMergeCommand extends Command { // Check if the element after it was in the same list item and adjust it if needed. const nextSibling = lastElementAfterDelete.nextSibling; - changedBlocks.push( lastElementAfterDelete as any ); + changedBlocks.push( lastElementAfterDelete as Element ); if ( nextSibling && nextSibling !== lastElement && nextSibling.getAttribute( 'listItemId' ) == lastElementId ) { changedBlocks.push( ...mergeListItemBefore( nextSibling, lastElementAfterDelete, writer ) ); From ec18e9b76c6f17cb18cb79e2e74278d1bcc28684 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Thu, 10 Aug 2023 15:39:02 +0200 Subject: [PATCH 06/20] Introduce $listItem as generic item for schema list attributes. --- .../src/integrations/documentlist.ts | 4 +--- .../src/documentlist/documentlistediting.ts | 13 +++++++++---- .../documentlistpropertiesediting.ts | 5 +---- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/ckeditor5-html-support/src/integrations/documentlist.ts b/packages/ckeditor5-html-support/src/integrations/documentlist.ts index e7c304286fa..b48839f9797 100644 --- a/packages/ckeditor5-html-support/src/integrations/documentlist.ts +++ b/packages/ckeditor5-html-support/src/integrations/documentlist.ts @@ -89,9 +89,7 @@ export default class DocumentListElementSupport extends Plugin { const allowAttributes = viewElements.map( element => getHtmlAttributeName( element ) ); - schema.extend( '$block', { allowAttributes } ); - schema.extend( '$blockObject', { allowAttributes } ); - schema.extend( '$container', { allowAttributes } ); + schema.extend( '$listItem', { allowAttributes } ); conversion.for( 'upcast' ).add( dispatcher => { dispatcher.on( diff --git a/packages/ckeditor5-list/src/documentlist/documentlistediting.ts b/packages/ckeditor5-list/src/documentlist/documentlistediting.ts index 68080c04637..e4596f5869c 100644 --- a/packages/ckeditor5-list/src/documentlist/documentlistediting.ts +++ b/packages/ckeditor5-list/src/documentlist/documentlistediting.ts @@ -135,12 +135,17 @@ export default class DocumentListEditing extends Plugin { throw new CKEditorError( 'document-list-feature-conflict', this, { conflictPlugin: 'ListEditing' } ); } + model.schema.register( '$listItem', { allowAttributes: LIST_BASE_ATTRIBUTES } ); + if ( multiBlock ) { - model.schema.extend( '$container', { allowAttributes: LIST_BASE_ATTRIBUTES } ); - model.schema.extend( '$block', { allowAttributes: LIST_BASE_ATTRIBUTES } ); - model.schema.extend( '$blockObject', { allowAttributes: LIST_BASE_ATTRIBUTES } ); + model.schema.extend( '$container', { allowAttributesOf: '$listItem' } ); + model.schema.extend( '$block', { allowAttributesOf: '$listItem' } ); + model.schema.extend( '$blockObject', { allowAttributesOf: '$listItem' } ); } else { - model.schema.register( 'listItem', { allowAttributes: LIST_BASE_ATTRIBUTES, inheritAllFrom: '$block' } ); + model.schema.register( 'listItem', { + inheritAllFrom: '$block', + allowAttributesOf: '$listItem' + } ); } for ( const attribute of LIST_BASE_ATTRIBUTES ) { diff --git a/packages/ckeditor5-list/src/documentlistproperties/documentlistpropertiesediting.ts b/packages/ckeditor5-list/src/documentlistproperties/documentlistpropertiesediting.ts index 207e55c3c03..309e695f34d 100644 --- a/packages/ckeditor5-list/src/documentlistproperties/documentlistpropertiesediting.ts +++ b/packages/ckeditor5-list/src/documentlistproperties/documentlistpropertiesediting.ts @@ -89,10 +89,7 @@ export default class DocumentListPropertiesEditing extends Plugin { for ( const strategy of strategies ) { strategy.addCommand( editor ); - // TODO - model.schema.extend( '$container', { allowAttributes: strategy.attributeName } ); - model.schema.extend( '$block', { allowAttributes: strategy.attributeName } ); - model.schema.extend( '$blockObject', { allowAttributes: strategy.attributeName } ); + model.schema.extend( '$listItem', { allowAttributes: strategy.attributeName } ); // Register downcast strategy. documentListEditing.registerDowncastStrategy( { From 786b5cc2aa25a27177c847368de1ecbbf03e9522 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Thu, 10 Aug 2023 16:06:43 +0200 Subject: [PATCH 07/20] Paragraph inside a simple list should not break the list. --- .../ckeditor5-list/src/documentlist/converters.ts | 2 +- .../src/documentlist/documentlistediting.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/ckeditor5-list/src/documentlist/converters.ts b/packages/ckeditor5-list/src/documentlist/converters.ts index 7d1461f90d8..cb7e7f2a2db 100644 --- a/packages/ckeditor5-list/src/documentlist/converters.ts +++ b/packages/ckeditor5-list/src/documentlist/converters.ts @@ -80,7 +80,7 @@ export function listItemUpcastConverter(): GetCallback { for ( const item of items ) { // Set list attributes only on same level items, those nested deeper are already handled by the recursive conversion. - if ( !isListItemBlock( item ) ) { + if ( !item.hasAttribute( 'listItemId' ) ) { writer.setAttributes( attributes, item ); } } diff --git a/packages/ckeditor5-list/src/documentlist/documentlistediting.ts b/packages/ckeditor5-list/src/documentlist/documentlistediting.ts index e4596f5869c..3a68347fa29 100644 --- a/packages/ckeditor5-list/src/documentlist/documentlistediting.ts +++ b/packages/ckeditor5-list/src/documentlist/documentlistediting.ts @@ -416,6 +416,21 @@ export default class DocumentListEditing extends Plugin { editor.conversion.for( 'upcast' ) .elementToElement( { view: 'li', model: elementName } ) + // Convert paragraph to the list item block (without list type defined yet). + // This is important to properly handle simple lists so that paragraphs inside a list item won't break the list item. + //
  • <-- converted to listItem + //

    <-- should be also converted to listItem, so it won't split and replace the listItem generated from the above li. + .elementToElement( { + view: 'p', + model: ( viewElement, { writer } ) => { + if ( viewElement.parent && viewElement.parent.is( 'element', 'li' ) ) { + return writer.createElement( elementName ); + } + + return null; + }, + converterPriority: 'high' + } ) .add( dispatcher => { dispatcher.on( 'element:li', listItemUpcastConverter() ); dispatcher.on( 'element:ul', listUpcastCleanList(), { priority: 'high' } ); From 931f6fdf3dce49c79d6c4ebf07bd95ce2058a17e Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Thu, 10 Aug 2023 16:17:36 +0200 Subject: [PATCH 08/20] The post-fixer should ensure that all list items have unique IDs in simple lists. --- .../src/documentlist/utils/postfixers.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/ckeditor5-list/src/documentlist/utils/postfixers.ts b/packages/ckeditor5-list/src/documentlist/utils/postfixers.ts index 34ccfb64f3a..c398f5517cc 100644 --- a/packages/ckeditor5-list/src/documentlist/utils/postfixers.ts +++ b/packages/ckeditor5-list/src/documentlist/utils/postfixers.ts @@ -135,6 +135,17 @@ export function fixListItemIds( seenIds.add( listItemId ); + // Make sure that all items in a simple list have unique IDs. + if ( node.is( 'element', 'listItem' ) ) { + if ( node.getAttribute( 'listItemId' ) != listItemId ) { + writer.setAttribute( 'listItemId', listItemId, node ); + + applied = true; + } + + continue; + } + for ( const block of getListItemBlocks( node, { direction: 'forward' } ) ) { visited.add( block ); From b294177b261270aa13c06de74e630f9ee4a848f4 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Thu, 17 Aug 2023 15:19:23 +0200 Subject: [PATCH 09/20] Simple list item block should be downcasted to a paragraph when there are some attributes on it. --- packages/ckeditor5-engine/src/view/renderer.ts | 2 +- .../ckeditor5-list/src/documentlist/converters.ts | 4 ++-- .../src/documentlist/documentlistediting.ts | 12 ++++++++++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/ckeditor5-engine/src/view/renderer.ts b/packages/ckeditor5-engine/src/view/renderer.ts index 3a8ce273b2c..81ab96bef7f 100644 --- a/packages/ckeditor5-engine/src/view/renderer.ts +++ b/packages/ckeditor5-engine/src/view/renderer.ts @@ -357,7 +357,7 @@ export default class Renderer extends ObservableMixin() { // // Converting live list to an array to make the list static. const actualDomChildren = Array.from( - this.domConverter.mapViewToDom( viewElement )!.childNodes + domElement.childNodes ); const expectedDomChildren = Array.from( this.domConverter.viewChildrenToDom( viewElement, { withChildren: false } ) diff --git a/packages/ckeditor5-list/src/documentlist/converters.ts b/packages/ckeditor5-list/src/documentlist/converters.ts index cb7e7f2a2db..3a134d18382 100644 --- a/packages/ckeditor5-list/src/documentlist/converters.ts +++ b/packages/ckeditor5-list/src/documentlist/converters.ts @@ -361,11 +361,11 @@ export function listItemDowncastConverter( */ export function bogusParagraphCreator( attributeNames: Array, - { dataPipeline, multiBlock }: { dataPipeline?: boolean; multiBlock: boolean } + { dataPipeline }: { dataPipeline?: boolean } = {} ): ElementCreatorFunction { return ( modelElement, { writer } ) => { // Convert only if a bogus paragraph should be used. - if ( multiBlock && !shouldUseBogusParagraph( modelElement, attributeNames ) ) { + if ( !shouldUseBogusParagraph( modelElement, attributeNames ) ) { return null; } diff --git a/packages/ckeditor5-list/src/documentlist/documentlistediting.ts b/packages/ckeditor5-list/src/documentlist/documentlistediting.ts index 3a68347fa29..256d78c65ed 100644 --- a/packages/ckeditor5-list/src/documentlist/documentlistediting.ts +++ b/packages/ckeditor5-list/src/documentlist/documentlistediting.ts @@ -437,17 +437,25 @@ export default class DocumentListEditing extends Plugin { dispatcher.on( 'element:ol', listUpcastCleanList(), { priority: 'high' } ); } ); + if ( !multiBlock ) { + editor.conversion.for( 'downcast' ) + .elementToElement( { + model: 'listItem', + view: 'p' + } ); + } + editor.conversion.for( 'editingDowncast' ) .elementToElement( { model: elementName, - view: bogusParagraphCreator( attributeNames, { multiBlock } ), + view: bogusParagraphCreator( attributeNames ), converterPriority: 'high' } ); editor.conversion.for( 'dataDowncast' ) .elementToElement( { model: elementName, - view: bogusParagraphCreator( attributeNames, { dataPipeline: true, multiBlock } ), + view: bogusParagraphCreator( attributeNames, { dataPipeline: true } ), converterPriority: 'high' } ); From 61a77e8d5bc5debea08dc968ed19a50c7bcdeeea Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Thu, 21 Sep 2023 15:02:20 +0200 Subject: [PATCH 10/20] Fixes after merge. --- .../ckeditor5-list/src/documentlist/documentlistediting.ts | 4 ++-- packages/ckeditor5-list/tests/manual/documentlist-simple.js | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/ckeditor5-list/src/documentlist/documentlistediting.ts b/packages/ckeditor5-list/src/documentlist/documentlistediting.ts index 9bb998ed15a..dd5d3e5546e 100644 --- a/packages/ckeditor5-list/src/documentlist/documentlistediting.ts +++ b/packages/ckeditor5-list/src/documentlist/documentlistediting.ts @@ -416,9 +416,9 @@ export default class DocumentListEditing extends Plugin { const elementName = multiBlock ? 'paragraph' : 'listItem'; editor.conversion.for( 'upcast' ) - // Convert
  • to a generic paragraph so the content of
  • is always inside a block. + // Convert
  • to a generic paragraph (or listItem element) so the content of
  • is always inside a block. // Setting the listType attribute to let other features (to-do list) know that this is part of a list item. - // This is important to properly handle simple lists so that paragraphs inside a list item won't break the list item. + // This is also important to properly handle simple lists so that paragraphs inside a list item won't break the list item. //
  • <-- converted to listItem //

    <-- should be also converted to listItem, so it won't split and replace the listItem generated from the above li. .elementToElement( { diff --git a/packages/ckeditor5-list/tests/manual/documentlist-simple.js b/packages/ckeditor5-list/tests/manual/documentlist-simple.js index 7fcc063936f..62c79d219f4 100644 --- a/packages/ckeditor5-list/tests/manual/documentlist-simple.js +++ b/packages/ckeditor5-list/tests/manual/documentlist-simple.js @@ -15,11 +15,12 @@ import { Clipboard } from '@ckeditor/ckeditor5-clipboard'; import { Indent } from '@ckeditor/ckeditor5-indent'; import DocumentList from '../../src/documentlist'; +import TodoDocumentList from '../../src/tododocumentlist'; ClassicEditor .create( document.querySelector( '#editor' ), { - plugins: [ Enter, Typing, Heading, Paragraph, Undo, DocumentList, Indent, Clipboard ], - toolbar: [ 'heading', '|', 'bulletedList', 'numberedList', '|', 'outdent', 'indent', '|', 'undo', 'redo' ], + plugins: [ Enter, Typing, Heading, Paragraph, Undo, DocumentList, TodoDocumentList, Indent, Clipboard ], + toolbar: [ 'heading', '|', 'bulletedList', 'numberedList', 'todoList', '|', 'outdent', 'indent', '|', 'undo', 'redo' ], list: { multiBlock: false } From efd32c5c8dd1cbc4633781fcfa7bfb11a4efd7c8 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Thu, 21 Sep 2023 15:07:49 +0200 Subject: [PATCH 11/20] Fixes after merge. --- .../src/tododocumentlist/tododocumentlistediting.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/ckeditor5-list/src/tododocumentlist/tododocumentlistediting.ts b/packages/ckeditor5-list/src/tododocumentlist/tododocumentlistediting.ts index 570facf16fc..782b1fd95fd 100644 --- a/packages/ckeditor5-list/src/tododocumentlist/tododocumentlistediting.ts +++ b/packages/ckeditor5-list/src/tododocumentlist/tododocumentlistediting.ts @@ -79,9 +79,7 @@ export default class TodoDocumentListEditing extends Plugin { editing.view.addObserver( TodoCheckboxChangeObserver ); - model.schema.extend( '$container', { allowAttributes: 'todoListChecked' } ); - model.schema.extend( '$block', { allowAttributes: 'todoListChecked' } ); - model.schema.extend( '$blockObject', { allowAttributes: 'todoListChecked' } ); + model.schema.extend( '$listItem', { allowAttributes: 'todoListChecked' } ); model.schema.addAttributeCheck( ( context, attributeName ) => { const item = context.last; From a4dd502f88b19471cafffb957f05914fa7bcc8d3 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Thu, 21 Sep 2023 15:57:32 +0200 Subject: [PATCH 12/20] Updated checks for bogus paragraphs (and listItem elements). --- .../src/documentlist/converters.ts | 2 +- .../src/documentlist/documentlistcommand.ts | 8 ++--- .../src/documentlist/documentlistediting.ts | 4 +-- .../src/documentlist/utils/model.ts | 4 +-- .../tododocumentlistediting.ts | 6 ++-- .../tests/manual/documentlist-simple.html | 10 ++++++ .../tests/manual/documentlist-simple.js | 35 +++++++++++++++++-- 7 files changed, 56 insertions(+), 13 deletions(-) diff --git a/packages/ckeditor5-list/src/documentlist/converters.ts b/packages/ckeditor5-list/src/documentlist/converters.ts index 2497cc7b2b9..1e5f18b29a1 100644 --- a/packages/ckeditor5-list/src/documentlist/converters.ts +++ b/packages/ckeditor5-list/src/documentlist/converters.ts @@ -269,7 +269,7 @@ export function reconvertItemsOnDataChange( return true; } - if ( !item.is( 'element', 'paragraph' ) ) { + if ( !item.is( 'element', 'paragraph' ) && !item.is( 'element', 'listItem' ) ) { return false; } diff --git a/packages/ckeditor5-list/src/documentlist/documentlistcommand.ts b/packages/ckeditor5-list/src/documentlist/documentlistcommand.ts index ed9c552c6ed..0b1f4aa8c9f 100644 --- a/packages/ckeditor5-list/src/documentlist/documentlistcommand.ts +++ b/packages/ckeditor5-list/src/documentlist/documentlistcommand.ts @@ -20,7 +20,7 @@ import { sortBlocks, getSelectedBlockObject, isListItemBlock, - checkCanBecomeSimpleListItem + canBecomeSimpleListItem } from './utils/model'; /** @@ -76,7 +76,7 @@ export default class DocumentListCommand extends Command { const selectedBlockObject = getSelectedBlockObject( model ); const blocks = Array.from( document.selection.getSelectedBlocks() ) - .filter( block => model.schema.checkAttribute( block, 'listType' ) || checkCanBecomeSimpleListItem( block, model.schema ) ); + .filter( block => model.schema.checkAttribute( block, 'listType' ) || canBecomeSimpleListItem( block, model.schema ) ); // Whether we are turning off some items. const turnOff = options.forceValue !== undefined ? !options.forceValue : this.value; @@ -119,7 +119,7 @@ export default class DocumentListCommand extends Command { // Promote the given block to the list item. if ( !block.hasAttribute( 'listType' ) ) { // Rename block to a simple list item if this option is enabled. - if ( !block.is( 'element', 'listItem' ) && checkCanBecomeSimpleListItem( block, model.schema ) ) { + if ( !block.is( 'element', 'listItem' ) && canBecomeSimpleListItem( block, model.schema ) ) { writer.rename( block, 'listItem' ); } @@ -200,7 +200,7 @@ export default class DocumentListCommand extends Command { } for ( const block of blocks ) { - if ( schema.checkAttribute( block, 'listType' ) || checkCanBecomeSimpleListItem( block, schema ) ) { + if ( schema.checkAttribute( block, 'listType' ) || canBecomeSimpleListItem( block, schema ) ) { return true; } } diff --git a/packages/ckeditor5-list/src/documentlist/documentlistediting.ts b/packages/ckeditor5-list/src/documentlist/documentlistediting.ts index dd5d3e5546e..ebd5c5e0bb8 100644 --- a/packages/ckeditor5-list/src/documentlist/documentlistediting.ts +++ b/packages/ckeditor5-list/src/documentlist/documentlistediting.ts @@ -124,7 +124,7 @@ export default class DocumentListEditing extends Plugin { public init(): void { const editor = this.editor; const model = editor.model; - const multiBlock = editor.config.get( 'list.multiBlock' )!; + const multiBlock = editor.config.get( 'list.multiBlock' ); if ( editor.plugins.has( 'ListEditing' ) ) { /** @@ -412,7 +412,7 @@ export default class DocumentListEditing extends Plugin { const editor = this.editor; const model = editor.model; const attributeNames = this.getListAttributeNames(); - const multiBlock = editor.config.get( 'list.multiBlock' )!; + const multiBlock = editor.config.get( 'list.multiBlock' ); const elementName = multiBlock ? 'paragraph' : 'listItem'; editor.conversion.for( 'upcast' ) diff --git a/packages/ckeditor5-list/src/documentlist/utils/model.ts b/packages/ckeditor5-list/src/documentlist/utils/model.ts index ba7ff334356..b1ccba25539 100644 --- a/packages/ckeditor5-list/src/documentlist/utils/model.ts +++ b/packages/ckeditor5-list/src/documentlist/utils/model.ts @@ -562,8 +562,8 @@ export function getSelectedBlockObject( model: Model ): Element | null { * @param block A block to be tested. * @param schema The schema of the document. */ -export function checkCanBecomeSimpleListItem( block: Element, schema: Schema ): boolean { - return schema.checkChild( block.parent as Element, 'listItem' ) && !schema.isObject( block ); +export function canBecomeSimpleListItem( block: Element, schema: Schema ): boolean { + return schema.checkChild( block.parent as Element, 'listItem' ) && schema.checkChild( block, '$text' ) && !schema.isObject( block ); } /** diff --git a/packages/ckeditor5-list/src/tododocumentlist/tododocumentlistediting.ts b/packages/ckeditor5-list/src/tododocumentlist/tododocumentlistediting.ts index 782b1fd95fd..ff0a8d917d5 100644 --- a/packages/ckeditor5-list/src/tododocumentlist/tododocumentlistediting.ts +++ b/packages/ckeditor5-list/src/tododocumentlist/tododocumentlistediting.ts @@ -73,6 +73,8 @@ export default class TodoDocumentListEditing extends Plugin { const model = editor.model; const editing = editor.editing; const documentListEditing = editor.plugins.get( DocumentListEditing ); + const multiBlock = editor.config.get( 'list.multiBlock' ); + const elementName = multiBlock ? 'paragraph' : 'listItem'; editor.commands.add( 'todoList', new DocumentListCommand( editor, 'todo' ) ); editor.commands.add( 'checkTodoList', new CheckTodoDocumentListCommand( editor ) ); @@ -113,7 +115,7 @@ export default class TodoDocumentListEditing extends Plugin { } ); editor.conversion.for( 'downcast' ).elementToElement( { - model: 'paragraph', + model: elementName, view: ( element, { writer } ) => { if ( isDescriptionBlock( element, documentListEditing.getListAttributeNames() ) ) { return writer.createContainerElement( 'span', { class: 'todo-list__label__description' } ); @@ -443,7 +445,7 @@ function attributeUpcastConsumingConverter( matcherPattern: MatcherPattern ): Ge * Returns true if the given list item block should be converted as a description block of a to-do list item. */ function isDescriptionBlock( modelElement: Element, listAttributeNames: Array ): boolean { - return modelElement.is( 'element', 'paragraph' ) && + return ( modelElement.is( 'element', 'paragraph' ) || modelElement.is( 'element', 'listItem' ) ) && modelElement.getAttribute( 'listType' ) == 'todo' && isFirstBlockOfListItem( modelElement ) && hasOnlyListAttributes( modelElement, listAttributeNames ); diff --git a/packages/ckeditor5-list/tests/manual/documentlist-simple.html b/packages/ckeditor5-list/tests/manual/documentlist-simple.html index bc4914dc981..903a1ba3810 100644 --- a/packages/ckeditor5-list/tests/manual/documentlist-simple.html +++ b/packages/ckeditor5-list/tests/manual/documentlist-simple.html @@ -20,3 +20,13 @@
  • Another bullet list
  • + +

    Editor content preview

    +
    + + diff --git a/packages/ckeditor5-list/tests/manual/documentlist-simple.js b/packages/ckeditor5-list/tests/manual/documentlist-simple.js index 62c79d219f4..313ae356395 100644 --- a/packages/ckeditor5-list/tests/manual/documentlist-simple.js +++ b/packages/ckeditor5-list/tests/manual/documentlist-simple.js @@ -13,20 +13,51 @@ import { Paragraph } from '@ckeditor/ckeditor5-paragraph'; import { Undo } from '@ckeditor/ckeditor5-undo'; import { Clipboard } from '@ckeditor/ckeditor5-clipboard'; import { Indent } from '@ckeditor/ckeditor5-indent'; +import { Alignment } from '@ckeditor/ckeditor5-alignment'; +import { SourceEditing } from '@ckeditor/ckeditor5-source-editing'; +import { GeneralHtmlSupport } from '@ckeditor/ckeditor5-html-support'; import DocumentList from '../../src/documentlist'; import TodoDocumentList from '../../src/tododocumentlist'; ClassicEditor .create( document.querySelector( '#editor' ), { - plugins: [ Enter, Typing, Heading, Paragraph, Undo, DocumentList, TodoDocumentList, Indent, Clipboard ], - toolbar: [ 'heading', '|', 'bulletedList', 'numberedList', 'todoList', '|', 'outdent', 'indent', '|', 'undo', 'redo' ], + plugins: [ + Enter, Typing, Heading, Paragraph, Undo, DocumentList, TodoDocumentList, Indent, Clipboard, Alignment, SourceEditing, + GeneralHtmlSupport + ], + toolbar: [ + 'heading', '|', + 'bulletedList', 'numberedList', 'todoList', '|', + 'outdent', 'indent', '|', + 'alignment', '|', + 'undo', 'redo', '|', + 'sourceEditing' + ], list: { multiBlock: false + }, + htmlSupport: { + allow: [ + { + name: /./, + styles: true, + attributes: true, + classes: true + } + ] } } ) .then( editor => { window.editor = editor; + + const contentPreviewBox = document.getElementById( 'preview' ); + + contentPreviewBox.innerHTML = editor.getData(); + + editor.model.document.on( 'change:data', () => { + contentPreviewBox.innerHTML = editor.getData(); + } ); } ) .catch( err => { console.error( err.stack ); From ef3b189b46f33e946a2b61c354940fd6ccdb8b5e Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Thu, 21 Sep 2023 16:12:05 +0200 Subject: [PATCH 13/20] Updated manual test. --- packages/ckeditor5-list/tests/manual/documentlist-simple.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ckeditor5-list/tests/manual/documentlist-simple.js b/packages/ckeditor5-list/tests/manual/documentlist-simple.js index 313ae356395..a5e05d02cf5 100644 --- a/packages/ckeditor5-list/tests/manual/documentlist-simple.js +++ b/packages/ckeditor5-list/tests/manual/documentlist-simple.js @@ -16,6 +16,7 @@ import { Indent } from '@ckeditor/ckeditor5-indent'; import { Alignment } from '@ckeditor/ckeditor5-alignment'; import { SourceEditing } from '@ckeditor/ckeditor5-source-editing'; import { GeneralHtmlSupport } from '@ckeditor/ckeditor5-html-support'; +import { Autoformat } from '@ckeditor/ckeditor5-autoformat'; import DocumentList from '../../src/documentlist'; import TodoDocumentList from '../../src/tododocumentlist'; @@ -24,7 +25,7 @@ ClassicEditor .create( document.querySelector( '#editor' ), { plugins: [ Enter, Typing, Heading, Paragraph, Undo, DocumentList, TodoDocumentList, Indent, Clipboard, Alignment, SourceEditing, - GeneralHtmlSupport + GeneralHtmlSupport, Autoformat ], toolbar: [ 'heading', '|', From f745b544b3da69b034611b38144602eab72cd70e Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Thu, 21 Sep 2023 18:42:21 +0200 Subject: [PATCH 14/20] Added post-fixer. Adding tests. --- .../src/documentlist/documentlistediting.ts | 14 ++ .../documentlistediting-single-block.js | 159 +++++------------- 2 files changed, 60 insertions(+), 113 deletions(-) diff --git a/packages/ckeditor5-list/src/documentlist/documentlistediting.ts b/packages/ckeditor5-list/src/documentlist/documentlistediting.ts index ebd5c5e0bb8..6601ce84f30 100644 --- a/packages/ckeditor5-list/src/documentlist/documentlistediting.ts +++ b/packages/ckeditor5-list/src/documentlist/documentlistediting.ts @@ -684,6 +684,7 @@ function modelChangePostFixer( ) { const changes = model.document.differ.getChanges(); const itemToListHead = new Map(); + const multiBlock = documentListEditing.editor.config.get( 'list.multiBlock' ); let applied = false; @@ -728,6 +729,19 @@ function modelChangePostFixer( findAndAddListHeadToMap( entry.range.start.getShiftedBy( 1 ), itemToListHead ); } } + + // Make sure that there is no left over listItem element without attributes or a block with list attributes that is not a listItem. + if ( !multiBlock && entry.type == 'attribute' && LIST_BASE_ATTRIBUTES.includes( entry.attributeKey ) ) { + const element = entry.range.start.nodeAfter!; + + if ( entry.attributeNewValue === null && element && element.is( 'element', 'listItem' ) ) { + writer.rename( element, 'paragraph' ); + applied = true; + } else if ( entry.attributeOldValue === null && element && element.is( 'element' ) && element.name != 'listItem' ) { + writer.rename( element, 'listItem' ); + applied = true; + } + } } // Make sure that IDs are not shared by split list. diff --git a/packages/ckeditor5-list/tests/documentlist/documentlistediting-single-block.js b/packages/ckeditor5-list/tests/documentlist/documentlistediting-single-block.js index c660fd195c3..64fa5360a43 100644 --- a/packages/ckeditor5-list/tests/documentlist/documentlistediting-single-block.js +++ b/packages/ckeditor5-list/tests/documentlist/documentlistediting-single-block.js @@ -13,23 +13,19 @@ import HeadingEditing from '@ckeditor/ckeditor5-heading/src/headingediting'; import IndentEditing from '@ckeditor/ckeditor5-indent/src/indentediting'; import TableEditing from '@ckeditor/ckeditor5-table/src/tableediting'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; -import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; -import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; -import { getData as getModelData, parse as parseModel, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; -import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; +import { getData as getModelData, parse as parseModel } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; -import ListEditing from '../../src/list/listediting'; import DocumentListIndentCommand from '../../src/documentlist/documentlistindentcommand'; import DocumentListSplitCommand from '../../src/documentlist/documentlistsplitcommand'; import stubUid from './_utils/uid'; -import { modelList, prepareTest } from './_utils/utils'; +import { prepareTest } from './_utils/utils'; describe( 'DocumentListEditing (multiBlock=false)', () => { - let editor, model, modelDoc, modelRoot, view; + let editor, model, view; testUtils.createSinonSandbox(); @@ -43,9 +39,6 @@ describe( 'DocumentListEditing (multiBlock=false)', () => { } ); model = editor.model; - modelDoc = model.document; - modelRoot = modelDoc.getRoot(); - view = editor.editing.view; model.schema.extend( 'paragraph', { @@ -146,24 +139,41 @@ describe( 'DocumentListEditing (multiBlock=false)', () => { describe( 'post fixer', () => { describe( 'insert', () => { - // TODO: insert the same listItemId and check it's fixed. - } ); - - describe( 'move', () => { - // eslint-disable-next-line no-unused-vars - function testList( input, offset, output ) { + function testList( input, inserted, output ) { const selection = prepareTest( model, input ); - model.change( writer => { - const targetPosition = writer.createPositionAt( modelRoot, offset ); - - writer.move( selection.getFirstRange(), targetPosition ); + model.change( () => { + model.change( writer => { + writer.insert( parseModel( inserted, model.schema ), selection.getFirstPosition() ); + } ); } ); expect( getModelData( model, { withoutSelection: true } ) ).to.equal( output ); } - // TODO: insert the same listItemId and check it's fixed. + it( 'should make sure that all list items have a unique IDs (insert after)', () => { + testList( + 'a' + + '[]', + + 'x', + + 'a' + + 'x' + ); + } ); + + it( 'should make sure that all list items have a unique IDs (insert before)', () => { + testList( + '[]' + + 'a', + + 'x', + + 'x' + + 'a' + ); + } ); } ); describe( 'rename', () => { @@ -216,8 +226,7 @@ describe( 'DocumentListEditing (multiBlock=false)', () => { const expectedModel = 'a' + 'b' + - // TODO - 'c' + + 'c' + 'd' + 'e' + 'f' + @@ -237,25 +246,24 @@ describe( 'DocumentListEditing (multiBlock=false)', () => { expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( expectedModel ); } ); - // TODO: What to do? - it.skip( 'add list attributes', () => { + it( 'add list attributes', () => { const modelBefore = - 'a' + - 'b' + + 'a' + + 'b' + '[c]' + - 'd' + - 'e' + - 'f' + - 'g'; + 'd' + + 'e' + + 'f' + + 'g'; const expectedModel = - 'a' + - 'b' + - 'c' + - 'd' + - 'e' + - 'f' + - 'g'; + 'a' + + 'b' + + 'c' + + 'd' + + 'e' + + 'f' + + 'g'; const selection = prepareTest( model, modelBefore ); const element = selection.getFirstPosition().nodeAfter; @@ -271,79 +279,4 @@ describe( 'DocumentListEditing (multiBlock=false)', () => { } ); } ); } ); - - describe( 'multiBlock = false', () => { - - } ); -} ); - -describe( 'DocumentListEditing - registerDowncastStrategy()', () => { - let editor, model, view; - - afterEach( async () => { - await editor.destroy(); - } ); - - it( 'should allow registering strategy for list elements', async () => { - await createEditor( class CustomPlugin extends Plugin { - init() { - this.editor.plugins.get( 'DocumentListEditing' ).registerDowncastStrategy( { - scope: 'list', - attributeName: 'someFoo', - - setAttributeOnDowncast( writer, attributeValue, viewElement ) { - writer.setAttribute( 'data-foo', attributeValue, viewElement ); - } - } ); - } - } ); - - setModelData( model, modelList( ` - * foo - * bar - ` ) ); - - expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( - '
      ' + - '
    • foo
    • ' + - '
    • bar
    • ' + - '
    ' - ); - } ); - - it( 'should allow registering strategy for list items elements', async () => { - await createEditor( class CustomPlugin extends Plugin { - init() { - this.editor.plugins.get( 'DocumentListEditing' ).registerDowncastStrategy( { - scope: 'item', - attributeName: 'someFoo', - - setAttributeOnDowncast( writer, attributeValue, viewElement ) { - writer.setAttribute( 'data-foo', attributeValue, viewElement ); - } - } ); - } - } ); - - setModelData( model, modelList( ` - * foo - * bar - ` ) ); - - expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( - '
      ' + - '
    • foo
    • ' + - '
    • bar
    • ' + - '
    ' - ); - } ); - - async function createEditor( extraPlugin ) { - editor = await VirtualTestEditor.create( { - plugins: [ extraPlugin, Paragraph, DocumentListEditing, UndoEditing ] - } ); - - model = editor.model; - view = editor.editing.view; - } } ); From 3df9bf447f4c34af6abd7b0cb7adb29ee68aac3a Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Fri, 22 Sep 2023 18:13:54 +0200 Subject: [PATCH 15/20] Adding tests. --- .../converters-data-single-block.js | 1939 +++++++++++++++++ .../documentlistediting-single-block.js | 123 +- 2 files changed, 2060 insertions(+), 2 deletions(-) create mode 100644 packages/ckeditor5-list/tests/documentlist/converters-data-single-block.js diff --git a/packages/ckeditor5-list/tests/documentlist/converters-data-single-block.js b/packages/ckeditor5-list/tests/documentlist/converters-data-single-block.js new file mode 100644 index 00000000000..2c35c4de7b4 --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/converters-data-single-block.js @@ -0,0 +1,1939 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import DocumentListEditing from '../../src/documentlist/documentlistediting'; + +import BoldEditing from '@ckeditor/ckeditor5-basic-styles/src/bold/boldediting'; +import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting'; +import ClipboardPipeline from '@ckeditor/ckeditor5-clipboard/src/clipboardpipeline'; +import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting'; +import HeadingEditing from '@ckeditor/ckeditor5-heading/src/headingediting'; +import IndentEditing from '@ckeditor/ckeditor5-indent/src/indentediting'; +import TableEditing from '@ckeditor/ckeditor5-table/src/tableediting'; +import CodeBlockEditing from '@ckeditor/ckeditor5-code-block/src/codeblockediting'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; + +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import { setupTestHelpers } from './_utils/utils'; +import stubUid from './_utils/uid'; + +describe( 'DocumentListEditing (multiBlock=false) - converters - data pipeline', () => { + let editor, model, view, test; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + editor = await VirtualTestEditor.create( { + plugins: [ Paragraph, IndentEditing, ClipboardPipeline, BoldEditing, DocumentListEditing, UndoEditing, + BlockQuoteEditing, TableEditing, HeadingEditing, CodeBlockEditing ], + list: { + multiBlock: false + } + } ); + + model = editor.model; + view = editor.editing.view; + + model.schema.register( 'foo', { + allowWhere: '$block', + allowAttributes: [ 'listIndent', 'listType' ], + isBlock: true, + isObject: true + } ); + + // Stub `view.scrollToTheSelection` as it will fail on VirtualTestEditor without DOM. + sinon.stub( view, 'scrollToTheSelection' ).callsFake( () => {} ); + stubUid(); + + test = setupTestHelpers( editor ); + } ); + + afterEach( async () => { + await editor.destroy(); + } ); + + describe( 'flat lists', () => { + it( 'single item', () => { + test.data( + '
    • x
    ', + 'x' + ); + } ); + + it( 'single item with spaces', () => { + test.data( + '
    •  x 
    ', + ' x ' + ); + } ); + + it( 'multiple items', () => { + test.data( + '
      ' + + '
    • a
    • ' + + '
    • b
    • ' + + '
    • c
    • ' + + '
    ', + + 'a' + + 'b' + + 'c' + ); + } ); + + it( 'single multi-block item', () => { + test.data( + '
      ' + + '
    • ' + + '

      a

      ' + + '

      b

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

      a

      ' + + '

      b

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

      c

      ' + + '

      d

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

    xxx

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

    yyy

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

    xxx

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

    yyy

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

    xxx

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

    yyy

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

      xxx

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

    c

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

    c

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

    foo

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

    foo

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

    xxx

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

    yyy

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

      foo

      ' + + '

      bar

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

    foo

    ' + + '

    bar

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

    text

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

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

    text

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

    text

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

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

    text

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

    text

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

    text

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

      bar

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

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

    • ' + + '
    • Bar

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

      Foo

      ' + + '
        ' + + '
      1. Bar

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

      Foo

      ' + + '

      Bar

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

    Foo

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

      Bar

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

      123

      ' + + '

      456

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

        Foo

        ' + + '

        Bar

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

    Foo

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

      123

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

        Foo

        ' + + '

        Bar

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

      456

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

    Foo

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

      Bar

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

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

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

      Foo

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

      Foo

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

      Foz

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

      Bar

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

      Bar

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

      Bar

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

      Foo

      ' + + '

      Bar

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

      Foo

      ' + + '

      Bar

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

      Foo

      ' + + '

      Bar

      ' + + '

      Baz

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

      foo

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

    foo

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

      foo

      ' + + '

      bar

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

    foo

    ' + + '

    bar

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

      foo

      ' + + '

      bar

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

    foo

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

      foo

      ' + + '

      bar

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

    bar

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

      foo

      ' + + '

      bar

      ' + + '

      baz

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

    bar

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

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

     

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

    text before

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

    foo

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

    bar

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

    foo

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

    bar

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

    foo

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

    bar

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

    foo

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

        xxx

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

      xxx

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

      xxx

      ' + + '
    ' + + '

    bar

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

    foo

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

    bar

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

    foo

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

    a

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

    e

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

      foo

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

      bar

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

      foo

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

      bar

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

      bar

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

       bar

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

        foo

        ' + + '

        123

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

      bar

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

       bar

      ' + ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-list/tests/documentlist/documentlistediting-single-block.js b/packages/ckeditor5-list/tests/documentlist/documentlistediting-single-block.js index 64fa5360a43..98a21d7ec00 100644 --- a/packages/ckeditor5-list/tests/documentlist/documentlistediting-single-block.js +++ b/packages/ckeditor5-list/tests/documentlist/documentlistediting-single-block.js @@ -13,10 +13,12 @@ import HeadingEditing from '@ckeditor/ckeditor5-heading/src/headingediting'; import IndentEditing from '@ckeditor/ckeditor5-indent/src/indentediting'; import TableEditing from '@ckeditor/ckeditor5-table/src/tableediting'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import AlignmentEditing from '@ckeditor/ckeditor5-alignment/src/alignmentediting'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; -import { getData as getModelData, parse as parseModel } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { getData as getModelData, setData as setModelData, parse as parseModel } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; import DocumentListIndentCommand from '../../src/documentlist/documentlistindentcommand'; import DocumentListSplitCommand from '../../src/documentlist/documentlistsplitcommand'; @@ -35,7 +37,7 @@ describe( 'DocumentListEditing (multiBlock=false)', () => { multiBlock: false }, plugins: [ Paragraph, ClipboardPipeline, BoldEditing, DocumentListEditing, UndoEditing, - BlockQuoteEditing, TableEditing, HeadingEditing ] + BlockQuoteEditing, TableEditing, HeadingEditing, AlignmentEditing ] } ); model = editor.model; @@ -279,4 +281,121 @@ describe( 'DocumentListEditing (multiBlock=false)', () => { } ); } ); } ); + + describe( 'upcast', () => { + it( 'should split multi block to a separate list items', () => { + editor.setData( + '
        ' + + '
      • ' + + '

        foo

        ' + + '

        bar

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

          foo

          ' + + '

          bar

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

        foo

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

          a

          ' + + '

          b

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

        bar

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

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

      • ' + + '
      ' + ); + } ); + + it( 'should refresh item after removing non-list attribute', () => { + setModelData( model, + 'foo' + ); + + editor.execute( 'alignment', { value: 'left' } ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
        ' + + '
      • foo
      • ' + + '
      ' + ); + } ); + } ); } ); From cb7ce2ccbb46e7a3f73c21ec9ce41c9365d30083 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Sat, 23 Sep 2023 12:43:39 +0200 Subject: [PATCH 16/20] Adding tests. --- .../tests/documentlist/_utils-tests/utils.js | 38 + .../tests/documentlist/_utils/utils.js | 6 +- .../documentlistcommand-single-block.js | 1093 +++++++++++++++++ 3 files changed, 1135 insertions(+), 2 deletions(-) create mode 100644 packages/ckeditor5-list/tests/documentlist/documentlistcommand-single-block.js diff --git a/packages/ckeditor5-list/tests/documentlist/_utils-tests/utils.js b/packages/ckeditor5-list/tests/documentlist/_utils-tests/utils.js index 4de11cdcfac..239aabfc019 100644 --- a/packages/ckeditor5-list/tests/documentlist/_utils-tests/utils.js +++ b/packages/ckeditor5-list/tests/documentlist/_utils-tests/utils.js @@ -524,6 +524,24 @@ describe( 'mockList()', () => { '* bar {id:000}' ] ) ).to.throw( Error, 'ID conflict: 000' ); } ); + + it( 'should allow using different default block', () => { + modelList.defaultBlock = 'listItem'; + + expect( modelList( ` + text + * foo + # bar + # baz + ` ) ).to.equalMarkup( + 'text' + + 'foo' + + 'bar' + + 'baz' + ); + + modelList.defaultBlock = 'paragraph'; + } ); } ); describe( 'stringifyList()', () => { @@ -921,5 +939,25 @@ describe( 'stringifyList()', () => { ' # 0' ].join( '\n' ) ); } ); + + it( 'should allow using different default block', () => { + modelList.defaultBlock = 'listItem'; + model.schema.register( 'listItem', { inheritAllFrom: '$block' } ); + + const input = parseModel( + 'a' + + 'b' + + 'c', + model.schema + ); + + expect( stringifyList( input ) ).to.equalMarkup( [ + '* a', + '# b', + '# c' + ].join( '\n' ) ); + + modelList.defaultBlock = 'paragraph'; + } ); } ); } ); diff --git a/packages/ckeditor5-list/tests/documentlist/_utils/utils.js b/packages/ckeditor5-list/tests/documentlist/_utils/utils.js index 5c15f135b73..75e82280dac 100644 --- a/packages/ckeditor5-list/tests/documentlist/_utils/utils.js +++ b/packages/ckeditor5-list/tests/documentlist/_utils/utils.js @@ -312,6 +312,8 @@ export function modelList( lines, { ignoreIdConflicts = false } = {} ) { return items.join( '' ); } +modelList.defaultBlock = 'paragraph'; + /** * Returns document list pseudo markdown notation for a given document fragment or element. * @@ -349,7 +351,7 @@ export function stringifyList( fragmentOrElement ) { function stringifyNode( node, writer ) { const fragment = writer.createDocumentFragment(); - if ( node.is( 'element', 'paragraph' ) ) { + if ( node.is( 'element', modelList.defaultBlock ) ) { for ( const child of node.getChildren() ) { writer.append( writer.cloneElement( child ), fragment ); } @@ -369,7 +371,7 @@ function stringifyNode( node, writer ) { } function stringifyElement( content, listAttributes = {} ) { - let name = 'paragraph'; + let name = listAttributes.listItemId ? modelList.defaultBlock : 'paragraph'; let elementAttributes = ''; let selectionBefore = ''; let selectionAfter = ''; diff --git a/packages/ckeditor5-list/tests/documentlist/documentlistcommand-single-block.js b/packages/ckeditor5-list/tests/documentlist/documentlistcommand-single-block.js new file mode 100644 index 00000000000..c0e641bafdb --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/documentlistcommand-single-block.js @@ -0,0 +1,1093 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import DocumentListCommand from '../../src/documentlist/documentlistcommand'; +import DocumentListEditing from '../../src/documentlist/documentlistediting'; + +import stubUid from './_utils/uid'; +import { modelList } from './_utils/utils'; + +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; + +describe( 'DocumentListCommand (multiBlock=false)', () => { + let editor, command, model, root, changedBlocks; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + editor = await VirtualTestEditor.create( { + plugins: [ Paragraph, DocumentListEditing ], + list: { + multiBlock: false + } + } ); + + model = editor.model; + root = model.document.getRoot(); + + stubUid(); + modelList.defaultBlock = 'listItem'; + } ); + + afterEach( async () => { + modelList.defaultBlock = 'paragraph'; + await editor.destroy(); + } ); + + describe( 'bulleted', () => { + beforeEach( () => { + command = new DocumentListCommand( editor, 'bulleted' ); + + command.on( 'afterExecute', ( evt, data ) => { + changedBlocks = data; + } ); + } ); + + afterEach( () => { + command.destroy(); + } ); + + describe( 'constructor()', () => { + it( 'should create list command with given type and value set to false', () => { + setData( model, '[]' ); + + expect( command.type ).to.equal( 'bulleted' ); + expect( command.value ).to.be.false; + } ); + } ); + + describe( 'value', () => { + it( 'should be false if first position in selection is not in a list item', () => { + setData( model, modelList( [ + '0[]', + '* 1' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be false if first position in selection is in a list item of different type', () => { + setData( model, modelList( [ + '# 0[]', + '# 1' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be false if any of selected blocks is not a list item (non-list after list)', () => { + setData( model, modelList( [ + '* [0', + '1]' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be false if any of selected blocks is not a list item (non-list before list)', () => { + setData( model, modelList( [ + '[0', + '* 1]' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be false if any of selected blocks is not a list item (non-list between lists)', () => { + setData( model, modelList( [ + '* [0', + '1', + '* 2]' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be false if any of selected blocks is not a same type list item', () => { + setData( model, modelList( [ + '* [0', + '# 1]' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be true if first position in selection is in a list item of same type', () => { + setData( model, modelList( [ + '* 0[]', + '* 1' + ] ) ); + + expect( command.value ).to.be.true; + } ); + + it( 'should be true if first position in selection is in a following block of the list item', () => { + setData( model, modelList( [ + '* 0', + ' 1[]' + ] ) ); + + expect( command.value ).to.be.true; + } ); + } ); + + describe( 'isEnabled', () => { + it( 'should be true if entire selection is in a list', () => { + setData( model, modelList( [ '* [a]' ] ) ); + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if entire selection is in a block which can be turned into a list', () => { + setData( model, '[a]' ); + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if any of the selected blocks can be converted to a list (the last element does not allow)', () => { + model.schema.register( 'block', { inheritAllFrom: '$blockObject' } ); + editor.conversion.elementToElement( { model: 'block', view: 'div' } ); + + setData( model, + '[a' + + ']' + ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if any of the selected blocks can be converted to a list (the first element does not allow)', () => { + model.schema.register( 'block', { inheritAllFrom: '$blockObject' } ); + editor.conversion.elementToElement( { model: 'block', view: 'div' } ); + + setData( model, + '[' + + 'b]' + ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false if all of the selected blocks can not be converted to a list', () => { + model.schema.register( 'block', { inheritAllFrom: '$blockObject' } ); + editor.conversion.elementToElement( { model: 'block', view: 'div' } ); + + setData( model, + '[]' + + 'b' + ); + + expect( command.isEnabled ).to.be.false; + } ); + } ); + + describe( 'execute()', () => { + it( 'should use parent batch', () => { + setData( model, '[0]' ); + + model.change( writer => { + expect( writer.batch.operations.length, 'before' ).to.equal( 0 ); + + command.execute(); + + expect( writer.batch.operations.length, 'after' ).to.be.above( 0 ); + } ); + } ); + + describe( 'options.forceValue', () => { + it( 'should force converting into the list if the `options.forceValue` is set to `true`', () => { + setData( model, modelList( [ + 'fo[]o' + ] ) ); + + command.execute( { forceValue: true } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* fo[]o {id:a00}' + ] ) ); + } ); + + it( 'should not modify list item if not needed if the list if the `options.forceValue` is set to `true`', () => { + setData( model, modelList( [ + '* fo[]o' + ] ) ); + + command.execute( { forceValue: true } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* fo[]o' + ] ) ); + } ); + + it( 'should force converting into the paragraph if the `options.forceValue` is set to `false`', () => { + setData( model, modelList( [ + '* fo[]o' + ] ) ); + + command.execute( { forceValue: false } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'fo[]o' + ] ) ); + } ); + + it( 'should not modify list item if not needed if the `options.forceValue` is set to `false`', () => { + setData( model, modelList( [ + 'fo[]o' + ] ) ); + + command.execute( { forceValue: false } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'fo[]o' + ] ) ); + } ); + } ); + + describe( 'when turning on', () => { + it( 'should turn the closest block into a list item', () => { + setData( model, 'fo[]o' ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* fo[]o {id:a00}' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ) + ] ); + } ); + + it( 'should change the type of an existing (closest) list item', () => { + setData( model, modelList( [ + '# fo[]o' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* fo[]o' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ) + ] ); + } ); + + it( 'should make a list items from multiple paragraphs', () => { + setData( model, modelList( [ + 'fo[o', + 'ba]r' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* fo[o {id:a00}', + '* ba]r {id:a01}' + ] ) ); + + expect( changedBlocks.length ).to.equal( 2 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ), + root.getChild( 1 ) + ] ); + } ); + + it( 'should make a list items from multiple paragraphs mixed with list items', () => { + setData( model, modelList( [ + 'a', + '[b', + '* c', + 'd]', + 'e' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'a', + '* [b {id:a00}', + '* c', + '* d] {id:a01}', + 'e' + ] ) ); + + expect( changedBlocks.length ).to.equal( 2 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 1 ), + root.getChild( 3 ) + ] ); + } ); + + it( 'should not change type of nested list if parent is selected', () => { + setData( model, modelList( [ + '# [a', + '# b]', + ' # c', + '# d' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* [a', + '* b]', + ' # c', + '# d' + ] ) ); + + expect( changedBlocks.length ).to.equal( 2 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ), + root.getChild( 1 ) + ] ); + } ); + + it( 'should change the type of the whole list if the selection is collapsed (bulleted lists at the boundaries)', () => { + setData( model, modelList( [ + '* a', + '# b[]', + ' # c', + '# d', + '* e' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* a', + '* b[]', + ' # c', + '* d', + '* e' + ] ) ); + + expect( changedBlocks.length ).to.equal( 2 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 1 ), + root.getChild( 3 ) + ] ); + } ); + } ); + + describe( 'when turning off', () => { + it( 'should strip the list attributes from the closest list item (single list item)', () => { + setData( model, modelList( [ + '* fo[]o' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'fo[]o' + ] ) ); + + expect( changedBlocks.length ).to.equal( 1 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ) + ] ); + } ); + + it( 'should strip the list attributes from the closest item (multiple list items, selection in first item)', () => { + setData( model, modelList( [ + '* f[]oo', + '* bar', + '* baz' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'f[]oo', + '* bar', + '* baz' + ] ) ); + + expect( changedBlocks.length ).to.equal( 1 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ) + ] ); + } ); + + it( 'should strip the list attributes from the closest item (multiple list items, selection in the middle item)', () => { + setData( model, modelList( [ + '* foo', + '* b[]ar', + '* baz' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* foo', + 'b[]ar', + '* baz' + ] ) ); + + expect( changedBlocks.length ).to.equal( 1 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 1 ) + ] ); + } ); + + it( 'should strip the list attributes from the closest item (multiple list items, selection in the last item)', () => { + setData( model, modelList( [ + '* foo', + '* bar', + '* b[]az' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* foo', + '* bar', + 'b[]az' + ] ) ); + + expect( changedBlocks.length ).to.equal( 1 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 2 ) + ] ); + } ); + + describe( 'with nested lists inside', () => { + it( 'should strip the list attributes from the closest item and decrease indent of children (first item)', () => { + setData( model, modelList( [ + '* f[]oo', + ' * bar', + ' * baz', + ' * qux' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'f[]oo', + '* bar', + '* baz', + ' * qux' + ] ) ); + + expect( changedBlocks.length ).to.equal( 4 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ), + root.getChild( 1 ), + root.getChild( 2 ), + root.getChild( 3 ) + ] ); + } ); + + it( 'should strip the list attributes from the closest item and decrease indent of children (middle item)', () => { + setData( model, modelList( [ + '* foo', + '* b[]ar', + ' * baz', + ' * qux' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* foo', + 'b[]ar', + '* baz', + ' * qux' + ] ) ); + + expect( changedBlocks.length ).to.equal( 3 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 1 ), + root.getChild( 2 ), + root.getChild( 3 ) + ] ); + } ); + + it( 'should strip the list attributes from the selected items and decrease indent of nested list', () => { + setData( model, modelList( [ + '0', + '* 1', + ' * 2', + ' * 3[]', // <- this is turned off. + ' * 4', // <- this has to become indent = 0, because it will be first item on a new list. + ' * 5', // <- this should be still be a child of item above, so indent = 1. + ' * 6', // <- this has to become indent = 0, because it should not be a child of any of items above. + ' * 7', // <- this should be still be a child of item above, so indent = 1. + ' * 8', // <- this has to become indent = 0. + ' * 9', // <- this should still be a child of item above, so indent = 1. + ' * 10', // <- this should still be a child of item above, so indent = 2. + ' * 11', // <- this should still be at the same level as item above, so indent = 2. + '* 12', // <- this and all below are left unchanged. + ' * 13', + ' * 14' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '0', + '* 1', + ' * 2', + '3[]', + '* 4', + ' * 5', + '* 6', + ' * 7', + '* 8', + ' * 9', + ' * 10', + ' * 11', + '* 12', + ' * 13', + ' * 14' + ] ) ); + + expect( changedBlocks.length ).to.equal( 9 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 3 ), + root.getChild( 4 ), + root.getChild( 5 ), + root.getChild( 6 ), + root.getChild( 7 ), + root.getChild( 8 ), + root.getChild( 9 ), + root.getChild( 10 ), + root.getChild( 11 ) + ] ); + } ); + } ); + } ); + } ); + } ); + + describe( 'numbered', () => { + beforeEach( () => { + command = new DocumentListCommand( editor, 'numbered' ); + + command.on( 'afterExecute', ( evt, data ) => { + changedBlocks = data; + } ); + } ); + + afterEach( () => { + command.destroy(); + } ); + + describe( 'constructor()', () => { + it( 'should create list command with given type and value set to false', () => { + setData( model, '[]' ); + + expect( command.type ).to.equal( 'numbered' ); + expect( command.value ).to.be.false; + } ); + } ); + + describe( 'value', () => { + it( 'should be false if first position in selection is not in a list item', () => { + setData( model, modelList( [ + '0[]', + '# 1' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be false if first position in selection is in a list item of different type', () => { + setData( model, modelList( [ + '* 0[]', + '* 1' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be false if any of selected blocks is not a list item (non-list after list)', () => { + setData( model, modelList( [ + '# [0', + '1]' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be false if any of selected blocks is not a list item (non-list before list)', () => { + setData( model, modelList( [ + '[0', + '# 1]' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be false if any of selected blocks is not a list item (non-list between lists)', () => { + setData( model, modelList( [ + '# [0', + '1', + '# 2]' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be false if any of selected blocks is not a same type list item', () => { + setData( model, modelList( [ + '# [0', + '* 1]' + ] ) ); + + expect( command.value ).to.be.false; + } ); + + it( 'should be true if first position in selection is in a list item of same type', () => { + setData( model, modelList( [ + '# 0[]', + '# 1' + ] ) ); + + expect( command.value ).to.be.true; + } ); + + it( 'should be true if first position in selection is in a following block of the list item', () => { + setData( model, modelList( [ + '# 0', + ' 1[]' + ] ) ); + + expect( command.value ).to.be.true; + } ); + } ); + + describe( 'isEnabled', () => { + it( 'should be true if entire selection is in a list', () => { + setData( model, modelList( [ '# [a]' ] ) ); + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if entire selection is in a block which can be turned into a list', () => { + setData( model, '[a]' ); + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if any of the selected blocks can be converted into a list (the last element does not allow)', () => { + model.schema.register( 'block', { inheritAllFrom: '$blockObject' } ); + editor.conversion.elementToElement( { model: 'block', view: 'div' } ); + + setData( model, + '[a' + + ']' + ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if any of the selected blocks acan be converted into a list (the first element does not allow)', () => { + model.schema.register( 'block', { inheritAllFrom: '$blockObject' } ); + editor.conversion.elementToElement( { model: 'block', view: 'div' } ); + + setData( model, + '[' + + 'b]' + ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false if all of the selected blocks do not allow converting to a list', () => { + model.schema.register( 'block', { inheritAllFrom: '$blockObject' } ); + editor.conversion.elementToElement( { model: 'block', view: 'div' } ); + + setData( model, + '[]' + + 'b' + ); + + expect( command.isEnabled ).to.be.false; + } ); + } ); + + describe( 'execute()', () => { + it( 'should use parent batch', () => { + setData( model, '[0]' ); + + model.change( writer => { + expect( writer.batch.operations.length, 'before' ).to.equal( 0 ); + + command.execute(); + + expect( writer.batch.operations.length, 'after' ).to.be.above( 0 ); + } ); + } ); + + describe( 'options.forceValue', () => { + it( 'should force converting into the list if the `options.forceValue` is set to `true`', () => { + setData( model, modelList( [ + 'fo[]o' + ] ) ); + + command.execute( { forceValue: true } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# fo[]o {id:a00}' + ] ) ); + } ); + + it( 'should not modify list item if not needed if the list if the `options.forceValue` is set to `true`', () => { + setData( model, modelList( [ + '# fo[]o' + ] ) ); + + command.execute( { forceValue: true } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# fo[]o' + ] ) ); + } ); + + it( 'should force converting into the paragraph if the `options.forceValue` is set to `false`', () => { + setData( model, modelList( [ + '# fo[]o' + ] ) ); + + command.execute( { forceValue: false } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'fo[]o' + ] ) ); + } ); + + it( 'should not modify list item if not needed if the `options.forceValue` is set to `false`', () => { + setData( model, modelList( [ + 'fo[]o' + ] ) ); + + command.execute( { forceValue: false } ); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'fo[]o' + ] ) ); + } ); + } ); + + describe( 'when turning on', () => { + it( 'should turn the closest block into a list item', () => { + setData( model, 'fo[]o' ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# fo[]o {id:a00}' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ) + ] ); + } ); + + it( 'should change the type of an existing (closest) list item', () => { + setData( model, modelList( [ + '* fo[]o' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# fo[]o' + ] ) ); + + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ) + ] ); + } ); + + it( 'should make a list items from multiple paragraphs', () => { + setData( model, modelList( [ + 'fo[o', + 'ba]r' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# fo[o {id:a00}', + '# ba]r {id:a01}' + ] ) ); + + expect( changedBlocks.length ).to.equal( 2 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ), + root.getChild( 1 ) + ] ); + } ); + + it( 'should make a list items from multiple paragraphs mixed with list items', () => { + setData( model, modelList( [ + 'a', + '[b', + '# c', + 'd]', + 'e' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'a', + '# [b {id:a00}', + '# c', + '# d] {id:a01}', + 'e' + ] ) ); + + expect( changedBlocks.length ).to.equal( 2 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 1 ), + root.getChild( 3 ) + ] ); + } ); + + it( 'should not change type of nested list if parent is selected', () => { + setData( model, modelList( [ + '* [a', + '* b]', + ' * c', + '* d' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# [a', + '# b]', + ' * c', + '* d' + ] ) ); + + expect( changedBlocks.length ).to.equal( 2 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ), + root.getChild( 1 ) + ] ); + } ); + + it( 'should change the type of the whole list if the selection is collapsed (bulleted lists at the boundaries)', () => { + setData( model, modelList( [ + '# a', + '* b[]', + ' * c', + '* d', + '# e' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# a', + '# b[]', + ' * c', + '# d', + '# e' + ] ) ); + + expect( changedBlocks.length ).to.equal( 2 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 1 ), + root.getChild( 3 ) + ] ); + } ); + } ); + + describe( 'when turning off', () => { + it( 'should strip the list attributes from the closest list item (single list item)', () => { + setData( model, modelList( [ + '# fo[]o' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'fo[]o' + ] ) ); + + expect( changedBlocks.length ).to.equal( 1 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ) + ] ); + } ); + + it( 'should strip the list attributes from the closest item (multiple list items, selection in first item)', () => { + setData( model, modelList( [ + '# f[]oo', + '# bar', + '# baz' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'f[]oo', + '# bar', + '# baz' + ] ) ); + + expect( changedBlocks.length ).to.equal( 1 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ) + ] ); + } ); + + it( 'should strip the list attributes from the closest item (multiple list items, selection in the middle item)', () => { + setData( model, modelList( [ + '# foo', + '# b[]ar', + '# baz' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# foo', + 'b[]ar', + '# baz' + ] ) ); + + expect( changedBlocks.length ).to.equal( 1 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 1 ) + ] ); + } ); + + it( 'should strip the list attributes from the closest item (multiple list items, selection in the last item)', () => { + setData( model, modelList( [ + '# foo', + '# bar', + '# b[]az' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# foo', + '# bar', + 'b[]az' + ] ) ); + + expect( changedBlocks.length ).to.equal( 1 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 2 ) + ] ); + } ); + + describe( 'with nested lists inside', () => { + it( 'should strip the list attributes from the closest item and decrease indent of children (first item)', () => { + setData( model, modelList( [ + '# f[]oo', + ' # bar', + ' # baz', + ' # qux' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + 'f[]oo', + '# bar', + '# baz', + ' # qux' + ] ) ); + + expect( changedBlocks.length ).to.equal( 4 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 0 ), + root.getChild( 1 ), + root.getChild( 2 ), + root.getChild( 3 ) + ] ); + } ); + + it( 'should strip the list attributes from the closest item and decrease indent of children (middle item)', () => { + setData( model, modelList( [ + '# foo', + '# b[]ar', + ' # baz', + ' # qux' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# foo', + 'b[]ar', + '# baz', + ' # qux' + ] ) ); + + expect( changedBlocks.length ).to.equal( 3 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 1 ), + root.getChild( 2 ), + root.getChild( 3 ) + ] ); + } ); + + it( 'should strip the list attributes from the selected items and decrease indent of nested list', () => { + setData( model, modelList( [ + '0', + '# 1', + ' # 2', + ' # 3[]', // <- this is turned off. + ' # 4', // <- this has to become indent = 0, because it will be first item on a new list. + ' # 5', // <- this should be still be a child of item above, so indent = 1. + ' # 6', // <- this has to become indent = 0, because it should not be a child of any of items above. + ' # 7', // <- this should be still be a child of item above, so indent = 1. + ' # 8', // <- this has to become indent = 0. + ' # 9', // <- this should still be a child of item above, so indent = 1. + ' # 10', // <- this should still be a child of item above, so indent = 2. + ' # 11', // <- this should still be at the same level as item above, so indent = 2. + '# 12', // <- this and all below are left unchanged. + ' # 13', + ' # 14' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '0', + '# 1', + ' # 2', + '3[]', + '# 4', + ' # 5', + '# 6', + ' # 7', + '# 8', + ' # 9', + ' # 10', + ' # 11', + '# 12', + ' # 13', + ' # 14' + ] ) ); + + expect( changedBlocks.length ).to.equal( 9 ); + expect( changedBlocks ).to.deep.equal( [ + root.getChild( 3 ), + root.getChild( 4 ), + root.getChild( 5 ), + root.getChild( 6 ), + root.getChild( 7 ), + root.getChild( 8 ), + root.getChild( 9 ), + root.getChild( 10 ), + root.getChild( 11 ) + ] ); + } ); + } ); + } ); + } ); + } ); +} ); From ed06680454c9375af6e6932771c9328e832b1cb2 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Sat, 23 Sep 2023 16:18:11 +0200 Subject: [PATCH 17/20] Adding tests. --- .../documentlistindentcommand-single-block.js | 793 +++++ .../integrations/clipboard-single-block.js | 562 ++++ .../integrations/delete-single-block.js | 2711 +++++++++++++++++ 3 files changed, 4066 insertions(+) create mode 100644 packages/ckeditor5-list/tests/documentlist/documentlistindentcommand-single-block.js create mode 100644 packages/ckeditor5-list/tests/documentlist/integrations/clipboard-single-block.js create mode 100644 packages/ckeditor5-list/tests/documentlist/integrations/delete-single-block.js diff --git a/packages/ckeditor5-list/tests/documentlist/documentlistindentcommand-single-block.js b/packages/ckeditor5-list/tests/documentlist/documentlistindentcommand-single-block.js new file mode 100644 index 00000000000..96093e8781a --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/documentlistindentcommand-single-block.js @@ -0,0 +1,793 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import DocumentListEditing from '../../src/documentlist/documentlistediting'; +import DocumentListIndentCommand from '../../src/documentlist/documentlistindentcommand'; +import stubUid from './_utils/uid'; +import { modelList } from './_utils/utils'; + +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; + +describe( 'DocumentListIndentCommand (multiBlock=false)', () => { + let editor, model, root; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + editor = await VirtualTestEditor.create( { + plugins: [ DocumentListEditing, Paragraph ], + list: { + multiBlock: false + } + } ); + + model = editor.model; + root = model.document.getRoot(); + + stubUid(); + modelList.defaultBlock = 'listItem'; + } ); + + afterEach( async () => { + modelList.defaultBlock = 'paragraph'; + await editor.destroy(); + } ); + + describe( 'forward (indent)', () => { + let command; + + beforeEach( () => { + command = new DocumentListIndentCommand( editor, 'forward' ); + } ); + + afterEach( () => { + command.destroy(); + } ); + + describe( 'isEnabled', () => { + it( 'should be true if selection starts in list item', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + ' * []5', + '* 6' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false if selection starts in first list item', () => { + setData( model, modelList( [ + '* []0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + ' * 5', + '* 6' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false if selection starts in first list item at given indent', () => { + setData( model, modelList( [ + '* 0', + ' * 1', + '* 2', + ' * []3', + ' * 4' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false if selection starts in first list item (different list type)', () => { + setData( model, modelList( [ + '* 0', + ' * 1', + '# 2', + ' * 3', + '* []4' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false if selection is in first list item with different type than previous list', () => { + setData( model, modelList( [ + '* 0', + '# []1' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false if selection starts in a list item that has higher indent than it\'s previous sibling', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' * []2', + ' * 3', + ' * 4', + ' * 5', + '* 6' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be false if selection starts before a list item', () => { + setData( model, modelList( [ + '[]0', + '* 1', + '* 2' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + } ); + + describe( 'execute()', () => { + it( 'should use parent batch', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + ' * []5', + '* 6' + ] ) ); + + model.change( writer => { + expect( writer.batch.operations.length ).to.equal( 0 ); + + command.execute(); + + expect( writer.batch.operations.length ).to.be.above( 0 ); + } ); + } ); + + it( 'should increment indent attribute by 1', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + ' * []5', + '* 6' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + ' * []5', + '* 6' + ] ) ); + } ); + + it( 'should increment indent of all sub-items of indented item', () => { + setData( model, modelList( [ + '* 0', + '* []1', + ' * 2', + ' * 3', + ' * 4', + ' * 5', + '* 6' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * []1', + ' * 2', + ' * 3', + ' * 4', + ' * 5', + '* 6' + ] ) ); + } ); + + describe( 'mixed list types', () => { + it( 'should not change list item type if the indented list item is the first one in the nested list (bulleted)', () => { + setData( model, modelList( [ + '* 0', + '* 1[]', + ' # 2', + '* 3' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * 1[]', + ' # 2', + '* 3' + ] ) ); + } ); + + it( 'should not change list item type if the indented list item is the first one in the nested list (numbered)', () => { + setData( model, modelList( [ + '# 0', + '# 1[]', + ' * 2', + '# 3' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# 0', + ' # 1[]', + ' * 2', + '# 3' + ] ) ); + } ); + + it( 'should adjust list type to the previous list item (numbered)', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' # 2', + '* []3' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + ' # 2', + ' # []3' + ] ) ); + } ); + + it( 'should not change list item type if the indented list item is the first one in the nested list', () => { + setData( model, modelList( [ + '* 0', + '* []1', + ' # 2', + ' * 3', + ' # 4' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * []1', + ' # 2', + ' * 3', + ' # 4' + ] ) ); + } ); + + it( 'should not change list item type if the first item in the nested list (has more items)', () => { + setData( model, modelList( [ + '* 0', + '* []1', + ' # 2', + ' * 3', + ' # 4', + '* 5', + ' # 6' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * []1', + ' # 2', + ' * 3', + ' # 4', + '* 5', + ' # 6' + ] ) ); + } ); + } ); + + describe( 'non-collapsed selection', () => { + it( 'should increment indent of all selected item when multiple items are selected', () => { + setData( model, modelList( [ + '* 0', + '* [1', + ' * 2', + ' * 3]', + ' * 4', + ' * 5', + '* 6' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * [1', + ' * 2', + ' * 3]', + ' * 4', + ' * 5', + '* 6' + ] ) ); + } ); + + describe( 'mixed list types', () => { + it( 'should not change list types for the first list items', () => { + setData( model, modelList( [ + '* 0', + '* [1', + ' # 2]', + ' * 3' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * [1', + ' # 2]', + ' * 3' + ] ) ); + } ); + + it( 'should not change list types for the first list items (with nested lists)', () => { + setData( model, modelList( [ + '* 0', + '* [1', + ' # 2]', + '* 3' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * [1', + ' # 2]', + '* 3' + ] ) ); + } ); + + it( 'should align the list type if become a part of other list (bulleted)', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' # 2', + '* [3', + '* 4]' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + ' # 2', + ' # [3', + ' # 4]' + ] ) ); + } ); + + it( 'should align the list type if become a part of other list (numbered)', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' # 2', + ' # [3', + '* 4]' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + ' # 2', + ' # [3', + ' # 4]' + ] ) ); + } ); + + it( 'should align the list type (bigger structure)', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' # 2', + ' * 3', + ' * [4', + ' # 5', + ' * 6', + ' # 7]' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + ' # 2', + ' * 3', + ' * [4', + ' * 5', + ' * 6', + ' * 7]' + ] ) ); + } ); + } ); + } ); + + it( 'should fire "afterExecute" event after finish all operations with all changed items', done => { + setData( model, modelList( [ + '* 0', + '* []1', + ' * 2', + ' * 3', + ' * 4', + ' * 5', + '* 6' + ] ) ); + + command.on( 'afterExecute', ( evt, data ) => { + expect( data ).to.deep.equal( [ + root.getChild( 1 ), + root.getChild( 2 ), + root.getChild( 3 ), + root.getChild( 4 ), + root.getChild( 5 ) + ] ); + + done(); + } ); + + command.execute(); + } ); + } ); + } ); + + describe( 'backward (outdent)', () => { + let command; + + beforeEach( () => { + command = new DocumentListIndentCommand( editor, 'backward' ); + } ); + + afterEach( () => { + command.destroy(); + } ); + + describe( 'isEnabled', () => { + it( 'should be true if selection starts in list item', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + ' * []5', + '* 6' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if selection starts in first list item', () => { + setData( model, modelList( [ + '* []0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + ' * 5', + '* 6' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true if selection starts in a list item that has higher indent than it\'s previous sibling', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' * []2', + ' * 3', + ' * 4', + ' * 5', + '* 6' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be false if selection starts before a list', () => { + setData( model, modelList( [ + '[0', + '* 1]', + ' * 2' + ] ) ); + + expect( command.isEnabled ).to.be.false; + } ); + + it( 'should be true with selection in the middle block of a list item', () => { + setData( model, modelList( [ + '* 0', + ' []1', + ' 2' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + + it( 'should be true with selection in the last block of a list item', () => { + setData( model, modelList( [ + '* 0', + ' 1', + ' []2' + ] ) ); + + expect( command.isEnabled ).to.be.true; + } ); + } ); + + describe( 'execute()', () => { + it( 'should decrement indent attribute by 1 (if it is higher than 0)', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + ' * []5', + '* 6' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + '* []5', + '* 6' + ] ) ); + } ); + + it( 'should remove list attributes (if indent is less than to 0)', () => { + setData( model, modelList( [ + '* []0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + ' * 5', + '* 6' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '[]0', + '* 1', + ' * 2', + ' * 3', + ' * 4', + ' * 5', + '* 6' + ] ) ); + } ); + + it( 'should decrement indent of all sub-items of outdented item', () => { + setData( model, modelList( [ + '* 0', + '* []1', + ' * 2', + ' * 3', + ' * 4', + ' * 5', + '* 6' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '[]1', + '* 2', + ' * 3', + ' * 4', + '* 5', + '* 6' + ] ) ); + } ); + + it( 'should outdent all selected item when multiple items are selected', () => { + setData( model, modelList( [ + '* 0', + '* [1', + ' * 2', + ' * 3]', + ' * 4', + ' * 5', + '* 6' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '[1', + '* 2', + ' * 3]', + ' * 4', + '* 5', + '* 6' + ] ) ); + } ); + + it( 'should not merge item if parent has no more following blocks', () => { + setData( model, modelList( [ + '* 0', + ' * []1', + '* 2' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* []1', + '* 2' + ] ) ); + } ); + + it( 'should handle higher indent drop between items', () => { + setData( model, modelList( [ + '* 0', + ' * 1', + ' * 2', + ' * [3', + ' * 4]', + ' * 5' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + ' * 1', + ' * 2', + ' * [3', + '* 4]', + ' * 5' + ] ) ); + } ); + + it( 'should align a list item type after outdenting item', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' # 2[]', + '* 3' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + '* 2[]', + '* 3' + ] ) ); + } ); + + it( 'should align a list item type after outdenting the last list item', () => { + setData( model, modelList( [ + '# 0', + ' * 1', + ' * 2[]', + '# 3' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '# 0', + ' * 1', + '# 2[]', + '# 3' + ] ) ); + } ); + + it( 'should align the list item type after the more indented item', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' # 2', + ' * 3', + ' # 4[]' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + ' # 2', + ' * 3', + '* 4[]' + ] ) ); + } ); + + it( 'should outdent the whole nested list (and align appropriate list item types)', () => { + setData( model, modelList( [ + '* 0', + ' # []1', + ' # 2', + ' * 3', + ' # 4', + '* 5', + ' # 6' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* []1', + ' # 2', + ' * 3', + ' # 4', + '* 5', + ' # 6' + ] ) ); + } ); + + it( 'should align list item typed after outdenting a bigger structure', () => { + setData( model, modelList( [ + '* 0', + '* 1', + ' # 2', + ' * 3', + ' # [4', + ' * 5', + ' # 6', + ' * 7]' + ] ) ); + + command.execute(); + + expect( getData( model ) ).to.equalMarkup( modelList( [ + '* 0', + '* 1', + ' # 2', + ' * 3', + ' * [4', + ' # 5', + ' # 6', + ' # 7]' + ] ) ); + } ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-list/tests/documentlist/integrations/clipboard-single-block.js b/packages/ckeditor5-list/tests/documentlist/integrations/clipboard-single-block.js new file mode 100644 index 00000000000..e3daf9efa8c --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/integrations/clipboard-single-block.js @@ -0,0 +1,562 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* global document */ + +import DocumentListEditing from '../../../src/documentlist/documentlistediting'; +import { isListItemBlock } from '../../../src/documentlist/utils/model'; +import { modelList } from '../_utils/utils'; + +import BoldEditing from '@ckeditor/ckeditor5-basic-styles/src/bold/boldediting'; +import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting'; +import ClipboardPipeline from '@ckeditor/ckeditor5-clipboard/src/clipboardpipeline'; +import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting'; +import HeadingEditing from '@ckeditor/ckeditor5-heading/src/headingediting'; +import TableEditing from '@ckeditor/ckeditor5-table/src/tableediting'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import ImageBlockEditing from '@ckeditor/ckeditor5-image/src/image/imageblockediting'; +import ImageInlineEditing from '@ckeditor/ckeditor5-image/src/image/imageinlineediting'; +import Widget from '@ckeditor/ckeditor5-widget/src/widget'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; + +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import { + getData as getModelData, + parse as parseModel, + setData as setModelData +} from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { parse as parseView } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; + +import stubUid from '../_utils/uid'; + +describe( 'DocumentListEditing (multiBlock=false) integrations: clipboard copy & paste', () => { + let element, editor, model, modelDoc, modelRoot, view; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + element = document.createElement( 'div' ); + document.body.appendChild( element ); + + editor = await ClassicTestEditor.create( element, { + plugins: [ + Paragraph, ClipboardPipeline, BoldEditing, DocumentListEditing, UndoEditing, + BlockQuoteEditing, TableEditing, HeadingEditing, ImageBlockEditing, ImageInlineEditing, Widget + ], + list: { + multiBlock: false + } + } ); + + model = editor.model; + modelDoc = model.document; + modelRoot = modelDoc.getRoot(); + + view = editor.editing.view; + + model.schema.extend( 'paragraph', { + allowAttributes: 'foo' + } ); + + // Stub `view.scrollToTheSelection` as it will fail on VirtualTestEditor without DOM. + sinon.stub( view, 'scrollToTheSelection' ).callsFake( () => { } ); + stubUid(); + modelList.defaultBlock = 'listItem'; + } ); + + afterEach( async () => { + element.remove(); + modelList.defaultBlock = 'paragraph'; + + await editor.destroy(); + } ); + + describe( 'copy and getSelectedContent()', () => { + it( 'should be able to downcast part of a nested list', () => { + setModelData( model, + 'A' + + '[B1' + + 'B2' + + 'C1]' + + 'C2' + ); + + const modelFragment = model.getSelectedContent( model.document.selection ); + const viewFragment = editor.data.toView( modelFragment ); + const data = editor.data.htmlProcessor.toData( viewFragment ); + + expect( data ).to.equal( + '
        ' + + '
      • B1
      • ' + + '
      • B2' + + '
          ' + + '
        • C1
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'should be able to downcast part of a deep nested list', () => { + setModelData( model, + 'A' + + 'B1' + + 'B2' + + '[C1' + + 'C2]' + ); + + const modelFragment = model.getSelectedContent( model.document.selection ); + const viewFragment = editor.data.toView( modelFragment ); + const data = editor.data.htmlProcessor.toData( viewFragment ); + + expect( data ).to.equal( + '
        ' + + '
      • C1
      • ' + + '
      • C2
      • ' + + '
      ' + ); + } ); + + describe( 'UX enhancements', () => { + describe( 'preserving list structure when a cross-list item selection existed', () => { + it( 'should return a list structure, if more than a single list item was selected', () => { + setModelData( model, modelList( [ + '* Fo[o', + '* Ba]r' + ] ) ); + + const modelFragment = model.getSelectedContent( model.document.selection ); + + expect( modelFragment.childCount ).to.equal( 2 ); + expect( Array.from( modelFragment.getChildren() ).every( isListItemBlock ) ).to.be.true; + } ); + + it( 'should return a list structure, if a nested items were included in the selection', () => { + setModelData( model, modelList( [ + '* Fo[o', + ' Bar', + ' * B]az' + ] ) ); + + const modelFragment = model.getSelectedContent( model.document.selection ); + + expect( modelFragment.childCount ).to.equal( 3 ); + expect( Array.from( modelFragment.getChildren() ).every( isListItemBlock ) ).to.be.true; + } ); + + // Note: This test also verifies support for arbitrary selection passed to getSelectedContent(). + it( 'should return a list structure, if multiple list items were selected from the outside', () => { + setModelData( model, modelList( [ + '* Foo', + '* Bar' + ] ) ); + + // [* Foo + // * Bar] + // + // Note: It is impossible to set a document selection like this because the postfixer will normalize it to + // * [Foo + // * Bar] + const modelFragment = model.getSelectedContent( model.createSelection( model.document.getRoot(), 'in' ) ); + + expect( modelFragment.childCount ).to.equal( 2 ); + expect( Array.from( modelFragment.getChildren() ).every( isListItemBlock ) ).to.be.true; + } ); + } ); + } ); + } ); + + describe( 'paste and insertContent() integration', () => { + it( 'should be triggered on DataController#insertContent()', () => { + setModelData( model, + 'A' + + 'B[]' + + 'C' + ); + + editor.model.insertContent( + parseModel( + 'X' + + 'Y', + model.schema + ) + ); + + expect( getModelData( model ) ).to.equalMarkup( + 'A' + + 'BX' + + 'Y[]' + + 'C' + ); + } ); + + it( 'should be triggered when selectable is passed', () => { + setModelData( model, + 'A' + + 'B[]' + + 'C' + ); + + model.insertContent( + parseModel( + 'X' + + 'Y', + model.schema + ), + model.createRange( + model.createPositionFromPath( modelRoot, [ 1, 1 ] ), + model.createPositionFromPath( modelRoot, [ 1, 1 ] ) + ) + ); + + expect( getModelData( model ) ).to.equalMarkup( + 'A' + + 'B[]X' + + 'Y' + + 'C' + ); + } ); + + // Just checking that it doesn't crash. #69 + it( 'should work if an element is passed to DataController#insertContent()', () => { + setModelData( model, + 'A' + + 'B[]' + + 'C' + ); + + model.change( writer => { + const item = writer.createElement( 'listItem', { listType: 'bulleted', listItemId: 'x', listIndent: '0' } ); + writer.insertText( 'X', item ); + + model.insertContent( item ); + } ); + + expect( getModelData( model ) ).to.equalMarkup( + 'A' + + 'BX[]' + + 'C' + ); + } ); + + // Just checking that it doesn't crash. #69 + it( 'should work if an element is passed to DataController#insertContent() - case #69', () => { + setModelData( model, + 'A' + + 'B[]' + + 'C' + ); + + model.change( writer => { + model.insertContent( writer.createText( 'X' ) ); + } ); + + expect( getModelData( model ) ).to.equalMarkup( + 'A' + + 'BX[]' + + 'C' + ); + } ); + + it( 'should fix indents of pasted list items', () => { + setModelData( model, + 'A' + + 'B[]' + + 'C' + ); + + const clipboard = editor.plugins.get( 'ClipboardPipeline' ); + + clipboard.fire( 'inputTransformation', { + content: parseView( '
      • X
        • Y
      ' ) + } ); + + expect( getModelData( model ) ).to.equalMarkup( + 'A' + + 'BX' + + 'Y[]' + + 'C' + ); + } ); + + it( 'should not fix indents of list items that are separated by non-list element', () => { + setModelData( model, + 'A' + + 'B[]' + + 'C' + ); + + const clipboard = editor.plugins.get( 'ClipboardPipeline' ); + + clipboard.fire( 'inputTransformation', { + content: parseView( '
      • W
        • X

      Y

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

      X

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

          Y

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

          Y

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

        X

        Y

      • Z
      ' ) + } ); + + expect( getModelData( model ) ).to.equalMarkup( + 'AW' + + 'X' + + 'Y' + + 'Z[]' + + 'B' + + 'C' + ); + } ); + + it( 'should properly handle split of list items with non-standard converters', () => { + setModelData( model, + 'A[]' + + 'B' + + 'C' + ); + + editor.model.schema.register( 'splitBlock', { allowWhere: '$block' } ); + + editor.conversion.for( 'downcast' ).elementToElement( { model: 'splitBlock', view: 'splitBlock' } ); + editor.conversion.for( 'upcast' ).add( dispatcher => dispatcher.on( 'element:splitBlock', ( evt, data, conversionApi ) => { + const splitBlock = conversionApi.writer.createElement( 'splitBlock' ); + + conversionApi.consumable.consume( data.viewItem, { name: true } ); + conversionApi.safeInsert( splitBlock, data.modelCursor ); + conversionApi.updateConversionResult( splitBlock, data ); + } ) ); + + const clipboard = editor.plugins.get( 'ClipboardPipeline' ); + + clipboard.fire( 'inputTransformation', { + content: parseView( '
      • ab
      ' ) + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + 'Aa' + + '' + + 'b' + + 'B' + + 'C' + ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-list/tests/documentlist/integrations/delete-single-block.js b/packages/ckeditor5-list/tests/documentlist/integrations/delete-single-block.js new file mode 100644 index 00000000000..f8b2dff6a15 --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/integrations/delete-single-block.js @@ -0,0 +1,2711 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* global document */ + +import DocumentListEditing from '../../../src/documentlist/documentlistediting'; + +import Delete from '@ckeditor/ckeditor5-typing/src/delete'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import Widget from '@ckeditor/ckeditor5-widget/src/widget'; +import { toWidget } from '@ckeditor/ckeditor5-widget/src/utils'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; + +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import { + getData as getModelData, + setData as setModelData +} from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { DomEventData } from '@ckeditor/ckeditor5-engine'; + +import stubUid from '../_utils/uid'; +import { modelList } from '../_utils/utils'; +import BubblingEventInfo from '@ckeditor/ckeditor5-engine/src/view/observer/bubblingeventinfo'; + +describe( 'DocumentListEditing (multiBlock=false) integrations: backspace & delete', () => { + const blocksChangedByCommands = []; + + let element; + let editor, model, view; + let eventInfo, domEventData; + let splitAfterCommand, outdentCommand, + commandSpies, + splitAfterCommandExecuteSpy, outdentCommandExecuteSpy; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + element = document.createElement( 'div' ); + document.body.appendChild( element ); + + editor = await ClassicTestEditor.create( element, { + plugins: [ + DocumentListEditing, Paragraph, Delete, Widget + ], + list: { + multiBlock: false + } + } ); + + model = editor.model; + view = editor.editing.view; + + model.schema.extend( 'paragraph', { + allowAttributes: 'foo' + } ); + + model.schema.register( 'blockWidget', { + isObject: true, + isBlock: true, + allowIn: '$root', + allowAttributesOf: '$container' + } ); + + editor.conversion.for( 'downcast' ).elementToElement( { + model: 'blockWidget', + view: ( modelItem, { writer } ) => { + return toWidget( writer.createContainerElement( 'blockwidget', { class: 'block-widget' } ), writer ); + } + } ); + + editor.model.schema.register( 'inlineWidget', { + isObject: true, + isInline: true, + allowWhere: '$text', + allowAttributesOf: '$text' + } ); + + // The view element has no children. + editor.conversion.for( 'downcast' ).elementToElement( { + model: 'inlineWidget', + view: ( modelItem, { writer } ) => toWidget( + writer.createContainerElement( 'inlinewidget', { class: 'inline-widget' } ), writer, { label: 'inline widget' } + ) + } ); + + stubUid(); + modelList.defaultBlock = 'listItem'; + + eventInfo = new BubblingEventInfo( view.document, 'delete' ); + + splitAfterCommand = editor.commands.get( 'splitListItemAfter' ); + outdentCommand = editor.commands.get( 'outdentList' ); + + splitAfterCommandExecuteSpy = sinon.spy(); + outdentCommandExecuteSpy = sinon.spy(); + + splitAfterCommand.on( 'execute', splitAfterCommandExecuteSpy ); + outdentCommand.on( 'execute', outdentCommandExecuteSpy ); + + commandSpies = { + outdent: outdentCommandExecuteSpy, + splitAfter: splitAfterCommandExecuteSpy + }; + + blocksChangedByCommands.length = 0; + + outdentCommand.on( 'afterExecute', ( evt, data ) => { + blocksChangedByCommands.push( ...data ); + } ); + + splitAfterCommand.on( 'afterExecute', ( evt, data ) => { + blocksChangedByCommands.push( ...data ); + } ); + } ); + + afterEach( async () => { + element.remove(); + modelList.defaultBlock = 'paragraph'; + + await editor.destroy(); + } ); + + describe( 'backspace (backward)', () => { + beforeEach( () => { + domEventData = new DomEventData( view, { + preventDefault: sinon.spy() + }, { + direction: 'backward', + unit: 'codePoint', + sequence: 1 + } ); + } ); + + describe( 'single block list item', () => { + it( 'should not engage when the selection is in the middle of a text', () => { + runTest( { + input: [ + '* a[]b' + ], + expected: [ + '* []b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + describe( 'collapsed selection at the beginning of a list item', () => { + describe( 'item before is empty', () => { + it( 'should remove list when in empty only element of a list', () => { + runTest( { + input: [ + '* []' + ], + expected: [ + '[]' + ], + eventStopped: true, + executedCommands: { + outdent: 1, + splitAfter: 0 + }, + changedBlocks: [ 0 ] + } ); + } ); + + it( 'should merge non empty list item with with previous list item as a block', () => { + runTest( { + input: [ + '* ', + '* []b' + ], + expected: [ + '* []b {id:001}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge empty list item with with previous empty list item', () => { + runTest( { + input: [ + '* ', + '* []' + ], + expected: [ + '* []' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge indented list item with with previous empty list item', () => { + runTest( { + input: [ + '* ', + ' * []a' + ], + expected: [ + '* []a {id:001}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge indented empty list item with with previous empty list item', () => { + runTest( { + input: [ + '* ', + ' * []' + ], + expected: [ + '* []' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge list item with with previous indented empty list item', () => { + runTest( { + input: [ + '* ', + ' * ', + '* []a' + ], + expected: [ + '* ', + '* []a{id:002}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge empty list item with with previous indented empty list item', () => { + runTest( { + input: [ + '* ', + ' * ', + '* []' + ], + expected: [ + '* ', + ' * []' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + + describe( 'item before is not empty', () => { + it( 'should merge non empty list item with with previous list item as a block', () => { + runTest( { + input: [ + '* a', + '* []b' + ], + expected: [ + '* a[]b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge empty list item with with previous list item as a block', () => { + runTest( { + input: [ + '* a', + '* []' + ], + expected: [ + '* a[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge indented list item with with parent list item as a block', () => { + runTest( { + input: [ + '* a', + ' * []b' + ], + expected: [ + '* a[]b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge indented empty list item with with parent list item as a block', () => { + runTest( { + input: [ + '* a', + ' * []' + ], + expected: [ + '* a[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge list item with with previous list item with higher indent as a block', () => { + runTest( { + input: [ + '* a', + ' * b', + '* []c' + ], + expected: [ + '* a', + ' * b[]c' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge empty list item with with previous list item with higher indent as a block', () => { + runTest( { + input: [ + '* a', + ' * b', + '* []' + ], + expected: [ + '* a', + ' * b[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should keep merged list item\'s children', () => { + runTest( { + input: [ + '* a', + ' * []b', + ' * c', + ' * d', + ' * e', + ' * f' + ], + expected: [ + '* a[]b', + ' * c {id:002}', + ' * d {id:003}', + ' * e {id:004}', + ' * f {id:005}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + } ); + + describe( 'collapsed selection at the end of a list item', () => { + describe( 'item after is empty', () => { + it( 'should merge non empty list item with with previous list item as a block', () => { + runTest( { + input: [ + '* ', + '* []b' + ], + expected: [ + '* []b{id:001}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + // Default behaviour of backspace? + it( 'should merge empty list item with with previous empty list item', () => { + runTest( { + input: [ + '* ', + '* []' + ], + expected: [ + '* []' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge indented list item with with previous empty list item', () => { + runTest( { + input: [ + '* ', + ' * []a' + ], + expected: [ + '* []a {id:001}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge indented empty list item with with previous empty list item', () => { + runTest( { + input: [ + '* ', + ' * []' + ], + expected: [ + '* []' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge list item with with previous indented empty list item', () => { + runTest( { + input: [ + '* ', + ' * ', + '* []a' + ], + expected: [ + '* ', + '* []a{id:002}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge empty list item with with previous indented empty list item', () => { + runTest( { + input: [ + '* ', + ' * ', + '* []' + ], + expected: [ + '* ', + ' * []' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + + describe( 'item before is not empty', () => { + it( 'should merge non empty list item with with previous list item as a block', () => { + runTest( { + input: [ + '* a', + '* []b' + ], + expected: [ + '* a[]b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge empty list item with with previous list item as a block', () => { + runTest( { + input: [ + '* a', + '* []' + ], + expected: [ + '* a[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge indented list item with with parent list item as a block', () => { + runTest( { + input: [ + '* a', + ' * []b' + ], + expected: [ + '* a[]b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge indented empty list item with with parent list item as a block', () => { + runTest( { + input: [ + '* a', + ' * []' + ], + expected: [ + '* a[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge list item with with previous list item with higher indent as a block', () => { + runTest( { + input: [ + '* a', + ' * b', + '* []c' + ], + expected: [ + '* a', + ' * b[]c' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge empty list item with with previous list item with higher indent as a block', () => { + runTest( { + input: [ + '* a', + ' * b', + '* []' + ], + expected: [ + '* a', + ' * b[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should keep merged list item\'s children', () => { + runTest( { + input: [ + '* a', + ' * []b', + ' * c', + ' * d', + ' * e', + ' * f' + ], + expected: [ + '* a[]b', + ' * c {id:002}', + ' * d {id:003}', + ' * e {id:004}', + ' * f {id:005}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + } ); + + describe( 'non-collapsed selection starting in first block of a list item', () => { + describe( 'first position in empty block', () => { + it( 'should merge two empty list items', () => { + runTest( { + input: [ + 'a', + '* [', + '* ]' + ], + expected: [ + 'a', + '* []' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge non empty list item', () => { + runTest( { + input: [ + '* [', + '* ]text' + ], + expected: [ + '* []text{id:001}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge non empty list item and delete text', () => { + runTest( { + input: [ + '* [', + '* te]xt' + ], + expected: [ + '* []xt{id:001}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge and adjust indentation of child list item when end selection is at the beginning of item', () => { + runTest( { + input: [ + '* [', + '* a', + ' * ]b' + ], + expected: [ + '* []', + ' * b {id:002}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge and adjust indentation of child list items', () => { + runTest( { + input: [ + '* [', + '* a', + ' * b]c', + ' * d' + ], + expected: [ + '* []c{id:002}', + ' * d{id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge and adjust indentation of child list items when selection at the end of an item', () => { + runTest( { + input: [ + '* [', + '* a', + ' * bc]', + ' * d' + ], + expected: [ + '* []{id:000}', + ' * d{id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should delete all items till the end of selection and merge last list item', () => { + runTest( { + input: [ + '* [', + '* a', + ' * b', + '* ]d' + ], + expected: [ + '* []', + '* d {id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should delete all items and text till the end of selection and merge last list item', () => { + runTest( { + input: [ + '* [', + '* a', + ' * b', + '* d]e' + ], + expected: [ + '* []e{id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + + describe( 'first position in non-empty block', () => { + it( 'should merge two list items', () => { + runTest( { + input: [ + '* [text', + '* ano]ther' + ], + expected: [ + '* []ther{id:001}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge two list items if selection is in the middle', () => { + runTest( { + input: [ + '* te[xt', + '* ano]ther' + ], + expected: [ + '* te[]ther' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge non empty list item', () => { + runTest( { + input: [ + '* text[', + '* ]another' + ], + expected: [ + '* text[]another' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge non empty list item and delete text', () => { + runTest( { + input: [ + '* text[', + '* ano]ther' + ], + expected: [ + '* text[]ther' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge and adjust indentation of child list item when end selection is at the beginning of item', () => { + runTest( { + input: [ + '* text[', + '* a', + ' * ]b', + ' * c' + ], + expected: [ + '* text[]', + ' * b {id:002}', + ' * c {id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge and adjust indentation of child list items', () => { + runTest( { + input: [ + '* text[', + '* a', + ' * b]c', + ' * d' + ], + expected: [ + '* text[]c', + ' * d{id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge and adjust indentation of child list items when selection at the end of an item', () => { + runTest( { + input: [ + '* text[', + '* a', + ' * bc]', + ' * d' + ], + expected: [ + '* text[]{id:000}', + ' * d{id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should delete all items till the end of selection and merge last list item', () => { + runTest( { + input: [ + '* text[', + '* a', + ' * b', + '* ]d' + ], + expected: [ + '* text[]', + '* d {id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should delete all items and text till the end of selection and merge last list item', () => { + runTest( { + input: [ + '* text[', + '* a', + ' * b', + '* d]e' + ], + expected: [ + '* text[]e' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + } ); + } ); + + describe( 'selection outside list', () => { + it( 'should not engage for a
    5. that is not a document list item', () => { + model.schema.register( 'thirdPartyListItem', { inheritAllFrom: '$block' } ); + + editor.conversion.for( 'downcast' ).elementToElement( { + model: 'thirdPartyListItem', + view: ( modelItem, { writer } ) => writer.createContainerElement( 'li' ) + } ); + + runTest( { + input: [ + 'a', + '[]b' + ], + expected: [ + 'a[]b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + describe( 'collapsed selection', () => { + it( 'no list editing commands should be executed outside list (empty paragraph)', () => { + runTest( { + input: [ + '[]' + ], + expected: [ + '[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'no list editing commands should be executed outside list (selection at the beginning of text)', () => { + runTest( { + input: [ + '[]text' + ], + expected: [ + '[]text' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'no list editing commands should be executed outside list (selection at the end of text)', () => { + runTest( { + input: [ + 'text[]' + ], + expected: [ + 'tex[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'no list editing commands should be executed outside list (selection in the middle of text)', () => { + runTest( { + input: [ + 'te[]xt' + ], + expected: [ + 't[]xt' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'no list editing commands should be executed next to a list', () => { + runTest( { + input: [ + '1[]', + '* 2' + ], + expected: [ + '[]', + '* 2' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'no list editing commands should be executed when merging two lists', () => { + runTest( { + input: [ + '* 1', + '[]2', + '* 3' + ], + expected: [ + '* 1[]2', + '* 3 {id:002}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'no list editing commands should be executed when merging two lists - one nested', () => { + runTest( { + input: [ + '* 1', + '[]2', + '* 3', + ' * 4' + ], + expected: [ + '* 1[]2', + '* 3 {id:002}', + ' * 4 {id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'empty list should be deleted', () => { + runTest( { + input: [ + '* ', + '[]2', + '* 3' + ], + expected: [ + '[]2', + '* 3 {id:002}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + + describe( 'non-collapsed selection', () => { + describe( 'outside list', () => { + it( 'no list editing commands should be executed', () => { + runTest( { + input: [ + 't[ex]t' + ], + expected: [ + 't[]t' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'no list editing commands should be executed when outside list when next to a list', () => { + runTest( { + input: [ + 't[ex]t', + '* 1' + ], + expected: [ + 't[]t', + '* 1' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + + describe( 'only start in a list', () => { + it( 'no list editing commands should be executed when doing delete', () => { + runTest( { + input: [ + '* te[xt', + 'aa]' + ], + expected: [ + '* te[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + + describe( 'only end in a list', () => { + it( 'should delete everything till end of selection', () => { + runTest( { + input: [ + '[', + '* te]xt' + ], + expected: [ + '* []xt {id:001}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should delete everything till the end of selection and adjust remaining item list indentation', () => { + runTest( { + input: [ + 'a[', + '* b]b', + ' * c' + ], + expected: [ + 'a[]b', + '* c {id:002}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + + describe( 'spanning multiple lists', () => { + it( 'should merge lists into one with one list item', () => { + runTest( { + input: [ + '* a[a', + 'b', + '* c]c' + ], + expected: [ + '* a[]c' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge two lists into one with two list items', () => { + runTest( { + input: [ + '* a[', + 'c', + '* d]', + '* e' + ], + expected: [ + '* a[]', + '* e {id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge two lists into one with deeper indendation', () => { + runTest( { + input: [ + '* a', + ' * b[', + 'c', + '* d', + ' * e', + ' * f]f', + ' * g' + ], + expected: [ + '* a', + ' * b[]f', + ' * g {id:006}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge two lists into one and keep items after selection', () => { + runTest( { + input: [ + '* a[', + 'c', + '* d', + ' * e]e', + '* f' + ], + expected: [ + '* a[]e', + '* f {id:004}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge lists of different types to a single list and keep item lists types', () => { + runTest( { + input: [ + '* a', + '* b[b', + 'c', + '# d]d', + '# d' + ], + expected: [ + '* a', + '* b[]d', + '# d {id:004}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge lists of mixed types to a single list and keep item lists types', () => { + runTest( { + input: [ + '* a', + '# b[b', + 'c', + '# d]d', + ' * f' + ], + expected: [ + '* a', + '# b[]d', + ' * f {id:004}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + } ); + } ); + } ); + + describe( 'delete (forward)', () => { + beforeEach( () => { + domEventData = new DomEventData( view, { + preventDefault: sinon.spy() + }, { + direction: 'forward', + unit: 'codePoint', + sequence: 1 + } ); + } ); + + describe( 'single block list item', () => { + it( 'should not engage when the selection is in the middle of a text', () => { + runTest( { + input: [ + '* a[]b' + ], + expected: [ + '* a[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + describe( 'collapsed selection at the end of a list item', () => { + describe( 'item after is empty', () => { + it( 'should not remove list when in empty only element of a list', () => { + runTest( { + input: [ + '* []' + ], + expected: [ + '[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should remove next empty list item', () => { + runTest( { + input: [ + '* b[]', + '* ' + ], + expected: [ + '* b[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should remove next empty list item when current is empty', () => { + runTest( { + input: [ + '* []', + '* ' + ], + expected: [ + '* []' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should remove current list item if empty and replace with indented', () => { + runTest( { + input: [ + '* []', + ' * a' + ], + expected: [ + '* []a {id:001}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should remove next empty indented item list', () => { + runTest( { + input: [ + '* []', + ' * ' + ], + expected: [ + '* []' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should replace current empty list item with next list item', () => { + runTest( { + input: [ + '* ', + ' * []', + '* a' + ], + expected: [ + '* ', + '* []a{id:002}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should remove next empty list item when current is also empty', () => { + runTest( { + input: [ + '* ', + ' * []', + '* ' + ], + expected: [ + '* ', + ' * []' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + + describe( 'next list item is not empty', () => { + it( 'should merge text from next list item with current list item text', () => { + runTest( { + input: [ + '* a[]', + '* b' + ], + expected: [ + '* a[]b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should delete next empty item list', () => { + runTest( { + input: [ + '* a[]', + '* ' + ], + expected: [ + '* a[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge text of indented list item with current list item', () => { + runTest( { + input: [ + '* a[]', + ' * b' + ], + expected: [ + '* a[]b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should remove indented empty list item', () => { + runTest( { + input: [ + '* a[]', + ' * ' + ], + expected: [ + '* a[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge text of lower indent list item', () => { + runTest( { + input: [ + '* a', + ' * b[]', + '* c' + ], + expected: [ + '* a', + ' * b[]c' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should delete next empty list item with lower ident', () => { + runTest( { + input: [ + '* a', + ' * b[]', + '* ' + ], + expected: [ + '* a', + ' * b[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge following item list of first block and adjust it\'s children', () => { + runTest( { + input: [ + '* a[]', + ' * b', + ' * c', + ' * d', + ' * e', + ' * f' + ], + expected: [ + '* a[]b', + ' * c {id:002}', + ' * d {id:003}', + ' * e {id:004}', + ' * f {id:005}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + } ); + + describe( 'non-collapsed selection starting in first block of a list item', () => { + describe( 'first position in empty block', () => { + it( 'should merge two empty list items', () => { + runTest( { + input: [ + 'a', + '* [', + '* ]' + ], + expected: [ + 'a', + '* []' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge non empty list item', () => { + runTest( { + input: [ + '* [', + '* ]text' + ], + expected: [ + '* []text{id:001}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge non empty list item and delete text', () => { + runTest( { + input: [ + '* [', + '* te]xt' + ], + expected: [ + '* []xt{id:001}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge and adjust indentation of child list item when end selection is at the beginning of item', () => { + runTest( { + input: [ + '* [', + '* a', + ' * ]b' + ], + expected: [ + '* []', + ' * b {id:002}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge and adjust indentation of child list items', () => { + runTest( { + input: [ + '* [', + '* a', + ' * b]c', + ' * d' + ], + expected: [ + '* []c{id:002}', + ' * d{id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge and adjust indentation of child list items when selection at the end of an item', () => { + runTest( { + input: [ + '* [', + '* a', + ' * bc]', + ' * d' + ], + expected: [ + '* []{id:000}', + ' * d{id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should delete all items till the end of selection and merge last list item', () => { + runTest( { + input: [ + '* [', + '* a', + ' * b', + '* ]d' + ], + expected: [ + '* []', + '* d {id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should delete all items and text till the end of selection and merge last list item', () => { + runTest( { + input: [ + '* [', + '* a', + ' * b', + '* d]e' + ], + expected: [ + '* []e{id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + + describe( 'first position in non-empty block', () => { + it( 'should merge two list items', () => { + runTest( { + input: [ + '* [text', + '* ano]ther' + ], + expected: [ + '* []ther{id:001}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge two list items if selection starts in the middle of text', () => { + runTest( { + input: [ + '* te[xt', + '* ano]ther' + ], + expected: [ + '* te[]ther' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge non empty list item', () => { + runTest( { + input: [ + '* text[', + '* ]another' + ], + expected: [ + '* text[]another' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge non empty list item and delete text', () => { + runTest( { + input: [ + '* text[', + '* ano]ther' + ], + expected: [ + '* text[]ther' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge and adjust indentation of child list item when end selection is at the beginning of item', () => { + runTest( { + input: [ + '* text[', + '* a', + ' * ]b', + ' * c' + ], + expected: [ + '* text[]', + ' * b {id:002}', + ' * c {id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge and adjust indentation of child list items', () => { + runTest( { + input: [ + '* text[', + '* a', + ' * b]c', + ' * d' + ], + expected: [ + '* text[]c', + ' * d {id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge and adjust indentation of child list items when selection at the end of an item', () => { + runTest( { + input: [ + '* text[', + '* a', + ' * bc]', + ' * d' + ], + expected: [ + '* text[] {id:000}', + ' * d {id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should delete all items till the end of selection and merge last list item', () => { + runTest( { + input: [ + '* text[', + '* a', + ' * b', + '* ]d' + ], + expected: [ + '* text[]', + '* d {id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should delete all items and text till the end of selection and merge last list item', () => { + runTest( { + input: [ + '* text[', + '* a', + ' * b', + '* d]e' + ], + expected: [ + '* text[]e' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + } ); + } ); + + describe( 'selection outside list', () => { + it( 'should not engage for a
    6. that is not a document list item', () => { + model.schema.register( 'thirdPartyListItem', { inheritAllFrom: '$block' } ); + + editor.conversion.for( 'downcast' ).elementToElement( { + model: 'thirdPartyListItem', + view: ( modelItem, { writer } ) => writer.createContainerElement( 'li' ) + } ); + + runTest( { + input: [ + 'a[]', + 'b' + ], + expected: [ + 'a[]b' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + describe( 'collapsed selection', () => { + it( 'no list editing commands should be executed outside list (empty paragraph)', () => { + runTest( { + input: [ + '[]' + ], + expected: [ + '[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'no list editing commands should be executed outside list (selection at the beginning of text)', () => { + runTest( { + input: [ + '[]text' + ], + expected: [ + '[]ext' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'no list editing commands should be executed outside list (selection at the end of text)', () => { + runTest( { + input: [ + 'text[]' + ], + expected: [ + 'text[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'no list editing commands should be executed outside list (selection in the middle of text)', () => { + runTest( { + input: [ + 'te[]xt' + ], + expected: [ + 'te[]t' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'no list editing commands should be executed next to a list', () => { + runTest( { + input: [ + '* 1', + '[]2' + ], + expected: [ + '* 1', + '[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'empty list should be deleted', () => { + runTest( { + input: [ + '* 1', + '2[]', + '* ' + ], + expected: [ + '* 1', + '2[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + + describe( 'non-collapsed selection', () => { + describe( 'outside list', () => { + it( 'no list editing commands should be executed', () => { + runTest( { + input: [ + 't[ex]t' + ], + expected: [ + 't[]t' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'no list editing commands should be executed when outside list when next to a list', () => { + runTest( { + input: [ + 't[ex]t', + '* 1' + ], + expected: [ + 't[]t', + '* 1' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + + describe( 'only start in a list', () => { + it( 'no list editing commands should be executed when doing delete', () => { + runTest( { + input: [ + '* te[xt', + 'aa]' + ], + expected: [ + '* te[]' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + + describe( 'only end in a list', () => { + it( 'should delete everything till end of selection', () => { + runTest( { + input: [ + '[', + '* te]xt' + ], + expected: [ + '* []xt {id:001}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should delete everything till the end of selection and adjust remaining block to item list', () => { + runTest( { + input: [ + 'a[', + '* b]b', + ' c' + ], + expected: [ + 'a[]b', + '* c {id:a00}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should delete everything till the end of selection and adjust remaining item list indentation', () => { + runTest( { + input: [ + 'a[', + '* b]b', + ' * c' + ], + expected: [ + 'a[]b', + '* c {id:002}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + + describe( 'spanning multiple lists', () => { + it( 'should merge lists into one with one list item', () => { + runTest( { + input: [ + '* a[a', + 'b', + '* c]c' + ], + expected: [ + '* a[]c' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge two lists into one with two list items', () => { + runTest( { + input: [ + '* a[', + 'c', + '* d]', + '* e' + ], + expected: [ + '* a[]', + '* e {id:003}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge two lists into one with deeper indendation', () => { + runTest( { + input: [ + '* a', + ' * b[', + 'c', + '* d', + ' * e', + ' * f]f', + ' * g' + ], + expected: [ + '* a', + ' * b[]f', + ' * g {id:006}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge lists of different types to a single list and keep item lists types', () => { + runTest( { + input: [ + '* a', + '* b[b', + 'c', + '# d]d', + '# d' + ], + expected: [ + '* a', + '* b[]d', + '# d {id:004}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + + it( 'should merge lists of mixed types to a single list and keep item lists types', () => { + runTest( { + input: [ + '* a', + '# b[b', + 'c', + '# d]d', + ' * f' + ], + expected: [ + '* a', + '# b[]d', + ' * f {id:004}' + ], + eventStopped: { + preventDefault: true, + stop: false + }, + executedCommands: { + outdent: 0, + splitAfter: 0 + } + } ); + } ); + } ); + } ); + } ); + } ); + + // @param {Iterable.} input + // @param {Iterable.} expected + // @param {Boolean|Object.} eventStopped Boolean when preventDefault() and stop() were called/not called together. + // Object, when mixed behavior was expected. + // @param {Object.} executedCommands Numbers of command executions. + // @param {Array.} changedBlocks Indexes of changed blocks. + function runTest( { input, expected, eventStopped, executedCommands = {}, changedBlocks = [] } ) { + setModelData( model, modelList( input ) ); + + view.document.fire( eventInfo, domEventData ); + + expect( getModelData( model ) ).to.equalMarkup( modelList( expected ) ); + + if ( typeof eventStopped === 'object' ) { + expect( domEventData.domEvent.preventDefault.called ).to.equal( eventStopped.preventDefault, 'preventDefault() call' ); + expect( !!eventInfo.stop.called ).to.equal( eventStopped.stop, 'eventInfo.stop() call' ); + } else { + expect( domEventData.domEvent.preventDefault.callCount ).to.equal( eventStopped ? 1 : 0, 'preventDefault() call' ); + expect( eventInfo.stop.called ).to.equal( eventStopped ? true : undefined, 'eventInfo.stop() call' ); + } + + for ( const name in executedCommands ) { + expect( commandSpies[ name ].callCount ).to.equal( executedCommands[ name ], `${ name } command call count` ); + } + + expect( blocksChangedByCommands.map( block => block.index ) ).to.deep.equal( changedBlocks, 'changed blocks\' indexes' ); + } +} ); From fb85770a9f9afac5553d34c060c12cd1e5f11ed7 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Sat, 23 Sep 2023 16:28:16 +0200 Subject: [PATCH 18/20] Adding tests. --- .../tododocumentlistediting-single-block.js | 749 ++++++++++++++++++ 1 file changed, 749 insertions(+) create mode 100644 packages/ckeditor5-list/tests/tododocumentlist/tododocumentlistediting-single-block.js diff --git a/packages/ckeditor5-list/tests/tododocumentlist/tododocumentlistediting-single-block.js b/packages/ckeditor5-list/tests/tododocumentlist/tododocumentlistediting-single-block.js new file mode 100644 index 00000000000..5ac2f9c234a --- /dev/null +++ b/packages/ckeditor5-list/tests/tododocumentlist/tododocumentlistediting-single-block.js @@ -0,0 +1,749 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* global document */ + +import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting'; +import HeadingEditing from '@ckeditor/ckeditor5-heading/src/headingediting'; +import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import TableEditing from '@ckeditor/ckeditor5-table/src/tableediting'; +import GeneralHtmlSupport from '@ckeditor/ckeditor5-html-support/src/generalhtmlsupport'; +import AlignmentEditing from '@ckeditor/ckeditor5-alignment/src/alignmentediting'; + +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; + +import TodoDocumentListEditing from '../../src/tododocumentlist/tododocumentlistediting'; +import DocumentListEditing from '../../src/documentlist/documentlistediting'; +import DocumentListCommand from '../../src/documentlist/documentlistcommand'; +import CheckTodoDocumentListCommand from '../../src/tododocumentlist/checktododocumentlistcommand'; +import TodoCheckboxChangeObserver from '../../src/tododocumentlist/todocheckboxchangeobserver'; +import DocumentListPropertiesEditing from '../../src/documentlistproperties/documentlistpropertiesediting'; + +import stubUid from '../documentlist/_utils/uid'; + +describe( 'TodoDocumentListEditing (multiBlock=false)', () => { + let editor, model, view, editorElement; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + editorElement = document.createElement( 'div' ); + document.body.appendChild( editorElement ); + + editor = await ClassicTestEditor.create( editorElement, { + plugins: [ Paragraph, TodoDocumentListEditing, BlockQuoteEditing, TableEditing, HeadingEditing, AlignmentEditing ], + list: { + multiBlock: false + } + } ); + + model = editor.model; + view = editor.editing.view; + + stubUid(); + } ); + + afterEach( async () => { + editorElement.remove(); + + await editor.destroy(); + } ); + + it( 'should have pluginName', () => { + expect( TodoDocumentListEditing.pluginName ).to.equal( 'TodoDocumentListEditing' ); + } ); + + it( 'should load DocumentListEditing', () => { + expect( TodoDocumentListEditing.requires ).to.have.members( [ DocumentListEditing ] ); + } ); + + describe( 'commands', () => { + it( 'should register todoList command', () => { + const command = editor.commands.get( 'todoList' ); + + expect( command ).to.be.instanceOf( DocumentListCommand ); + expect( command ).to.have.property( 'type', 'todo' ); + } ); + + it( 'should register checkTodoList command', () => { + const command = editor.commands.get( 'checkTodoList' ); + + expect( command ).to.be.instanceOf( CheckTodoDocumentListCommand ); + } ); + } ); + + it( 'should register TodoCheckboxChangeObserver', () => { + expect( view.getObserver( TodoCheckboxChangeObserver ) ).to.be.instanceOf( TodoCheckboxChangeObserver ); + } ); + + it( 'should set proper schema rules', () => { + const paragraph = new ModelElement( 'paragraph', { listItemId: 'foo', listType: 'todo' } ); + const heading = new ModelElement( 'heading1', { listItemId: 'foo', listType: 'todo' } ); + const blockQuote = new ModelElement( 'blockQuote', { listItemId: 'foo', listType: 'todo' } ); + const table = new ModelElement( 'table', { listItemId: 'foo', listType: 'todo' }, [ ] ); + const listItem = new ModelElement( 'listItem', { listItemId: 'foo', listType: 'todo' }, [ ] ); + + expect( model.schema.checkAttribute( [ '$root', paragraph ], 'todoListChecked' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', heading ], 'todoListChecked' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', blockQuote ], 'todoListChecked' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', table ], 'todoListChecked' ) ).to.be.false; + expect( model.schema.checkAttribute( [ '$root', listItem ], 'todoListChecked' ) ).to.be.true; + } ); + + describe( 'upcast', () => { + it( 'should convert li with a checkbox before the first text node as a to-do list item', () => { + testUpcast( + '
      • foo
      ', + 'foo' + ); + } ); + + it( 'should convert the full markup generated by the editor', () => { + testUpcast( + '
      • foo
      ', + 'foo' + ); + + testUpcast( + editor.getData(), + 'foo' + ); + } ); + + it( 'should convert li with checked checkbox as checked to-do list item', () => { + testUpcast( + '
        ' + + '
      • a
      • ' + + '
      • b
      • ' + + '
      • c
      • ' + + '
      ', + 'a' + + 'b' + + 'c' + ); + } ); + + it( 'should not convert li with checkbox in the middle of the text', () => { + testUpcast( + '
      • FooBar
      ', + 'FooBar' + ); + } ); + + it( 'should split items with checkboxes - bulleted list', () => { + testUpcast( + '
        ' + + '
      • foo
      • ' + + '
      • bar
      • ' + + '
      • baz
      • ' + + '
      ', + 'foo' + + 'bar' + + 'baz' + ); + } ); + + it( 'should split items with checkboxes - numbered list', () => { + testUpcast( + '
        ' + + '
      1. foo
      2. ' + + '
      3. bar
      4. ' + + '
      5. baz
      6. ' + + '
      ', + 'foo' + + 'bar' + + 'baz' + ); + } ); + + it( 'should convert li with a checkbox in a nested list', () => { + testUpcast( + '
        ' + + '
      • ' + + '' + + 'foo' + + '
        • foo
        ' + + '
      • ' + + '
      ', + 'foo' + + 'foo' + ); + } ); + + it( 'should convert li with checkboxes in a nested lists (bulleted > todo > todo)', () => { + testUpcast( + '
        ' + + '
      • ' + + '
          ' + + '
        • ' + + 'foo' + + '
          • bar
          ' + + '
        • ' + + '
        ' + + '
      • ' + + '
      ', + '' + + 'foo' + + 'bar' + ); + } ); + + it( 'should convert li with a checkbox and a paragraph', () => { + testUpcast( + '
        ' + + '
      • ' + + '' + + '

        foo

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

        foo

        ' + + '

        bar

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

        foo

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

        foo

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

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

        bar

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

        ' + + 'foo' + + '

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

        bar

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

        foo

        ' + + '
      • ' + + '
      ' + ); + } ); + } ); + + function testUpcast( input, output ) { + editor.setData( input ); + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( output ); + } + + function testEditing( input, output ) { + setModelData( model, input ); + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( output ); + } + + function testData( input, output ) { + setModelData( model, input ); + expect( editor.getData() ).to.equalMarkup( output ); + } +} ); From 233599c05eacecdcefc450e932a931c50f8d70f4 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Sat, 23 Sep 2023 16:31:20 +0200 Subject: [PATCH 19/20] Added manual test. --- .../manual/todo-documentlist-simple.html | 44 ++++++++ .../tests/manual/todo-documentlist-simple.js | 100 ++++++++++++++++++ .../tests/manual/todo-documentlist-simple.md | 0 3 files changed, 144 insertions(+) create mode 100644 packages/ckeditor5-list/tests/manual/todo-documentlist-simple.html create mode 100644 packages/ckeditor5-list/tests/manual/todo-documentlist-simple.js create mode 100644 packages/ckeditor5-list/tests/manual/todo-documentlist-simple.md diff --git a/packages/ckeditor5-list/tests/manual/todo-documentlist-simple.html b/packages/ckeditor5-list/tests/manual/todo-documentlist-simple.html new file mode 100644 index 00000000000..e36503201f3 --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/todo-documentlist-simple.html @@ -0,0 +1,44 @@ +

      Editor

      +
      +

      This is a test for list feature.

      +

      Some more text for testing.

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

      Paragraph.

      +

      Another testing paragraph.

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

      Checkbox in paragraph

      +
      + +

      Editor content preview

      +
      + + diff --git a/packages/ckeditor5-list/tests/manual/todo-documentlist-simple.js b/packages/ckeditor5-list/tests/manual/todo-documentlist-simple.js new file mode 100644 index 00000000000..b473ca6d253 --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/todo-documentlist-simple.js @@ -0,0 +1,100 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals console, window, document */ + +import { ClassicEditor } from '@ckeditor/ckeditor5-editor-classic'; + +import { Essentials } from '@ckeditor/ckeditor5-essentials'; +import { Autoformat } from '@ckeditor/ckeditor5-autoformat'; +import { BlockQuote } from '@ckeditor/ckeditor5-block-quote'; +import { Bold, Italic } from '@ckeditor/ckeditor5-basic-styles'; +import { Heading } from '@ckeditor/ckeditor5-heading'; +import { Link, LinkImage } from '@ckeditor/ckeditor5-link'; +import { MediaEmbed } from '@ckeditor/ckeditor5-media-embed'; +import { Paragraph } from '@ckeditor/ckeditor5-paragraph'; +import { Table, TableToolbar } from '@ckeditor/ckeditor5-table'; +import { FontSize } from '@ckeditor/ckeditor5-font'; +import { Indent } from '@ckeditor/ckeditor5-indent'; +import { SourceEditing } from '@ckeditor/ckeditor5-source-editing'; +import { GeneralHtmlSupport } from '@ckeditor/ckeditor5-html-support'; +import { Alignment } from '@ckeditor/ckeditor5-alignment'; +import { CloudServices } from '@ckeditor/ckeditor5-cloud-services'; +import { EasyImage } from '@ckeditor/ckeditor5-easy-image'; +import { Image, ImageResize, ImageInsert } from '@ckeditor/ckeditor5-image'; + +import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config'; + +import Documentlist from '../../src/documentlist'; +import TodoDocumentlist from '../../src/tododocumentlist'; + +ClassicEditor + .create( document.querySelector( '#editor' ), { + plugins: [ + Essentials, + Autoformat, + BlockQuote, + Bold, + Heading, + Italic, + Link, + MediaEmbed, + Paragraph, + Table, + TableToolbar, + FontSize, + Indent, + Documentlist, + TodoDocumentlist, + SourceEditing, + GeneralHtmlSupport, + Alignment, + Image, + CloudServices, + EasyImage, + ImageResize, + ImageInsert, + LinkImage + ], + toolbar: [ + 'heading', + '|', + 'bulletedList', 'numberedList', 'todoList', 'outdent', 'indent', + '|', + 'bold', 'link', 'fontSize', 'alignment', + '|', + 'insertTable', 'insertImage', + '|', + 'undo', 'redo', '|', 'sourceEditing' + ], + cloudServices: CS_CONFIG, + table: { + contentToolbar: [ + 'tableColumn', + 'tableRow', + 'mergeTableCells' + ] + }, + list: { + multiBlock: false + }, + htmlSupport: { + allow: [ { name: /.*/, attributes: true, classes: true, styles: true } ] + } + } ) + .then( editor => { + window.editor = editor; + + const contentPreviewBox = document.getElementById( 'preview' ); + + contentPreviewBox.innerHTML = editor.getData(); + + editor.model.document.on( 'change:data', () => { + contentPreviewBox.innerHTML = editor.getData(); + } ); + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/packages/ckeditor5-list/tests/manual/todo-documentlist-simple.md b/packages/ckeditor5-list/tests/manual/todo-documentlist-simple.md new file mode 100644 index 00000000000..e69de29bb2d From f3c852a37f74742bdea388d45d2296e3f2d51aa5 Mon Sep 17 00:00:00 2001 From: Kuba Niegowski Date: Mon, 25 Sep 2023 11:38:51 +0200 Subject: [PATCH 20/20] Comments. --- packages/ckeditor5-list/src/listconfig.ts | 4 ++ .../tests/manual/documentlist-simple.md | 44 ------------------- 2 files changed, 4 insertions(+), 44 deletions(-) diff --git a/packages/ckeditor5-list/src/listconfig.ts b/packages/ckeditor5-list/src/listconfig.ts index 852399254ad..10efa045ab3 100644 --- a/packages/ckeditor5-list/src/listconfig.ts +++ b/packages/ckeditor5-list/src/listconfig.ts @@ -37,6 +37,10 @@ export interface ListConfig { /** * Allows multiple blocks in single list item. * + * With this option enabled you can have block widgets, for example images or even tables, within a list item. + * + * **Note:** This is enabled by default. + * * @default true */ multiBlock?: boolean; diff --git a/packages/ckeditor5-list/tests/manual/documentlist-simple.md b/packages/ckeditor5-list/tests/manual/documentlist-simple.md index d58bd9c2c16..ea316d4174d 100644 --- a/packages/ckeditor5-list/tests/manual/documentlist-simple.md +++ b/packages/ckeditor5-list/tests/manual/documentlist-simple.md @@ -7,47 +7,3 @@ * numbered list with one item, * bullet list with one item. 2. Toolbar should have two buttons: for bullet and for numbered list. - -### Testing - -After each step test undo (whole stack) -> redo (whole stack) -> undo (whole stack). - -Creating: - -1. Convert first paragraph to list item -2. Create empty paragraph and convert to list item -3. Enter in the middle of item -4. Enter at the start of item -5. Enter at the end of item - -Removing: - -1. Delete all contents from list item and then the list item -2. Press enter in empty list item -3. Click on highlighted button ("turn off" list feature) -4. Do it for first, second and last list item - -Changing type: - -1. Change type from bulleted to numbered -2. Do it for first, second and last item -3. Do it for multiple items at once - -Merging: - -1. Convert paragraph before list to same type of list -2. Convert paragraph after list to same type of list -3. Convert paragraph before list to different type of list -4. Convert paragraph after list to different type of list -5. Convert first paragraph to bulleted list, then convert second paragraph to bulleted list -6. Convert multiple items and paragraphs at once - -Selection deletion. Make selection between items and press delete button: - -1. two items from the same list -2. all items in a list -3. paragraph before list and second item of list -4. paragraph after list and one-but-last item of list -5. two paragraphs that have list between them -6. two items from different lists of same type -7. two items from different lists of different type