diff --git a/packages/ckeditor5-list/docs/features/document-lists.md b/packages/ckeditor5-list/docs/features/document-lists.md index 31b86dd5d6b..6581385a8f7 100644 --- a/packages/ckeditor5-list/docs/features/document-lists.md +++ b/packages/ckeditor5-list/docs/features/document-lists.md @@ -96,6 +96,28 @@ ClassicEditor The {@link module:list/documentlistproperties~DocumentListProperties} feature overrides UI button implementations from the {@link module:list/list/listui~ListUI}. +## List merging + +By default, two lists of the same type (ordered and unordered) that are next to each other are merged together. This is done so that lists that visually appear to be one continuous list actually are, even if the user has accidentally created several of them. + +Unfortunately, in some cases this can be undesirable behavior. For example, two adjacent numbered lists, each with two items, will merge into a single list with the numbers 1 through 4. + +To prevent this behavior, enable the `AdjacentListsSupport` plugin. + +```js +import AdjacentListsSupport from '@ckeditor/ckeditor5-list/src/documentlist/adjacentlistssupport.js'; + +ClassicEditor + .create( document.querySelector( '#editor' ), { + plugins: [ + AdjacentListsSupport, + /* Other plugins */ + ], + } ) + .then( /* ... */ ) + .catch( /* ... */ ); +``` + ## Related features These features also provide similar functionality: diff --git a/packages/ckeditor5-list/src/augmentation.ts b/packages/ckeditor5-list/src/augmentation.ts index 3ce3bb8782e..7e579551e18 100644 --- a/packages/ckeditor5-list/src/augmentation.ts +++ b/packages/ckeditor5-list/src/augmentation.ts @@ -9,6 +9,7 @@ import type { DocumentListProperties, DocumentListPropertiesEditing, DocumentListPropertiesUtils, + AdjacentListsSupport, DocumentListUtils, ListConfig, List, @@ -56,6 +57,7 @@ declare module '@ckeditor/ckeditor5-core' { [ DocumentListPropertiesEditing.pluginName ]: DocumentListPropertiesEditing; [ DocumentListPropertiesUtils.pluginName ]: DocumentListPropertiesUtils; [ DocumentListUtils.pluginName ]: DocumentListUtils; + [ AdjacentListsSupport.pluginName ]: AdjacentListsSupport; [ List.pluginName ]: List; [ ListEditing.pluginName ]: ListEditing; [ ListProperties.pluginName ]: ListProperties; diff --git a/packages/ckeditor5-list/src/documentlist/adjacentlistssupport.ts b/packages/ckeditor5-list/src/documentlist/adjacentlistssupport.ts new file mode 100644 index 00000000000..16566b3e09d --- /dev/null +++ b/packages/ckeditor5-list/src/documentlist/adjacentlistssupport.ts @@ -0,0 +1,110 @@ +/** + * @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 + */ + +/** + * @module list/documentlist/adjacentlistssupport + */ + +import type { GetCallback } from 'ckeditor5/src/utils'; +import { Plugin } from 'ckeditor5/src/core'; + +import type { UpcastElementEvent, ViewElement } from 'ckeditor5/src/engine'; + +export default class AdjacentListsSupport extends Plugin { + /** + * @inheritDoc + */ + public static get pluginName(): 'AdjacentListsSupport' { + return 'AdjacentListsSupport'; + } + + /** + * @inheritDoc + */ + public init(): void { + const editor = this.editor; + const model = editor.model; + + model.schema.register( 'listSeparator', { + allowWhere: '$block', + isBlock: true + } ); + + editor.conversion.for( 'upcast' ) + // Add `listSeparator` element between similar list elements on upcast. + .add( dispatcher => { + dispatcher.on( 'element:ol', listSeparatorUpcastConverter() ); + dispatcher.on( 'element:ul', listSeparatorUpcastConverter() ); + } ) + // View to model transformation. + .elementToElement( { + model: 'listSeparator', + view: 'ck-list-separator' + } ); + + // The `listSeparator` element should exist in the view, but should be invisible (hidden). + editor.conversion.for( 'editingDowncast' ).elementToElement( { + model: 'listSeparator', + view: { + name: 'div', + classes: [ 'ck-list-separator', 'ck-hidden' ] + } + } ); + + // The `listSeparator` element should not exist in output data. + editor.conversion.for( 'dataDowncast' ).elementToElement( { + model: 'listSeparator', + view: ( modelElement, conversionApi ) => { + const viewElement = conversionApi.writer.createContainerElement( 'ck-list-separator' ); + + conversionApi.writer.setCustomProperty( 'dataPipeline:transparentRendering', true, viewElement ); + + viewElement.getFillerOffset = () => null; + + return viewElement; + } + } ); + } +} + +/** + * Inserts a `listSeparator` element between two lists of the same type (`ol` + `ol` or `ul` + `ul`). + */ +function listSeparatorUpcastConverter(): GetCallback { + return ( evt, data, conversionApi ) => { + const element: ViewElement = data.viewItem; + const nextSibling = element.nextSibling as ViewElement | null; + + if ( !nextSibling ) { + return; + } + + if ( element.name !== nextSibling.name ) { + return; + } + + if ( !data.modelRange ) { + Object.assign( data, conversionApi.convertChildren( data.viewItem, data.modelCursor ) ); + } + + const writer = conversionApi.writer; + const modelElement = writer.createElement( 'listSeparator' ); + + // Try to insert `listSeparator` element on the current model cursor position. + if ( !conversionApi.safeInsert( modelElement, data.modelCursor ) ) { + return; + } + + const parts = conversionApi.getSplitParts( modelElement ); + + // Extend model range with the range of the created listSeparator element. + data.modelRange = writer.createRange( + data.modelRange!.start, + writer.createPositionAfter( parts[ parts.length - 1 ] ) + ); + + conversionApi.updateConversionResult( modelElement, data ); + }; +} diff --git a/packages/ckeditor5-list/src/index.ts b/packages/ckeditor5-list/src/index.ts index 7cc16d5dfc7..fa0f89f0011 100644 --- a/packages/ckeditor5-list/src/index.ts +++ b/packages/ckeditor5-list/src/index.ts @@ -10,6 +10,7 @@ export { default as DocumentList } from './documentlist'; export { default as DocumentListEditing, type DocumentListEditingPostFixerEvent } from './documentlist/documentlistediting'; export { default as DocumentListIndentCommand } from './documentlist/documentlistindentcommand'; +export { default as AdjacentListsSupport } from './documentlist/adjacentlistssupport'; export { default as DocumentListProperties } from './documentlistproperties'; export { default as DocumentListPropertiesEditing } from './documentlistproperties/documentlistpropertiesediting'; export { default as DocumentListUtils } from './documentlist/documentlistutils'; diff --git a/packages/ckeditor5-list/tests/documentlist/adjacentlistssupport.js b/packages/ckeditor5-list/tests/documentlist/adjacentlistssupport.js new file mode 100644 index 00000000000..9fd07785342 --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/adjacentlistssupport.js @@ -0,0 +1,304 @@ +/** + * @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 document */ + +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import { getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; +import { Paragraph } from '@ckeditor/ckeditor5-paragraph'; + +import { DocumentList, DocumentListProperties, AdjacentListsSupport } from '../../src'; + +import stubUid from './_utils/uid'; + +describe( 'AdjacentListsSupport', () => { + let editorElement, editor, model, view; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + editorElement = document.createElement( 'div' ); + document.body.appendChild( editorElement ); + + editor = await ClassicTestEditor.create( editorElement, { + plugins: [ + Paragraph, + DocumentList, + AdjacentListsSupport + ] + } ); + + model = editor.model; + view = editor.editing.view; + + stubUid(); + } ); + + afterEach( async () => { + if ( editorElement ) { + editorElement.remove(); + } + + if ( editor ) { + await editor.destroy(); + } + } ); + + it( 'should have pluginName', () => { + expect( AdjacentListsSupport.pluginName ).to.equal( 'AdjacentListsSupport' ); + } ); + + it( 'should be loaded', () => { + expect( editor.plugins.get( AdjacentListsSupport ) ).to.be.instanceOf( AdjacentListsSupport ); + } ); + + it( 'shoud set proper schema rules', () => { + expect( model.schema.isRegistered( 'listSeparator' ) ).to.equal( true ); + } ); + + describe( 'upcast', () => { + it( 'inserts "listSeparator" element between two "ul" lists in model', () => { + editor.setData( + '
    ' + + '
  • One
  • ' + + '
' + + '
    ' + + '
  • Two
  • ' + + '
' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + 'One' + + '' + + 'Two' + ); + } ); + + it( 'inserts "listSeparator" element between two "ol" lists in model', () => { + editor.setData( + '
    ' + + '
  1. One
  2. ' + + '
' + + '
    ' + + '
  1. Two
  2. ' + + '
' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + 'One' + + '' + + 'Two' + ); + } ); + + it( 'doesn\'t insert "listSeparator" element between two different lists in model', () => { + editor.setData( + '
    ' + + '
  1. One
  2. ' + + '
' + + '
    ' + + '
  • Two
  • ' + + '
' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + 'One' + + 'Two' + ); + } ); + + it( 'should not fail if "listSeparator" is not allowed to be inserted', () => { + model.schema.register( 'customContainer', { + allowChildren: 'paragraph', + allowWhere: '$block', + isLimit: true + } ); + + editor.conversion.elementToElement( { + model: 'customContainer', + view: 'custom-block' + } ); + + editor.setData( + '' + + '
    ' + + '
  • One
  • ' + + '
' + + '
    ' + + '
  • Two
  • ' + + '
' + + '
' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + '' + + 'One' + + 'Two' + + '' + ); + } ); + } ); + + describe( 'editingDowncast', () => { + it( 'inserts a "div.ck-list-separator" between two "ul" lists in view', () => { + editor.setData( + '
    ' + + '
  • One
  • ' + + '
' + + '
    ' + + '
  • Two
  • ' + + '
' + ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
    ' + + '
  • One
  • ' + + '
' + + '
' + + '
    ' + + '
  • Two
  • ' + + '
' + ); + } ); + + it( 'inserts a "div.ck-list-separator" between two "ol" lists in view', () => { + editor.setData( + '
    ' + + '
  1. One
  2. ' + + '
' + + '
    ' + + '
  1. Two
  2. ' + + '
' + ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
    ' + + '
  1. One
  2. ' + + '
' + + '
' + + '
    ' + + '
  1. Two
  2. ' + + '
' + ); + } ); + + it( 'doesn\'t insert a "div.ck-list-separator" between two different lists in view', () => { + editor.setData( + '
    ' + + '
  1. One
  2. ' + + '
' + + '
    ' + + '
  • Two
  • ' + + '
' + ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
    ' + + '
  1. One
  2. ' + + '
' + + '
    ' + + '
  • Two
  • ' + + '
' + ); + } ); + } ); + + describe( 'dataDowncast', () => { + it( 'doesn\'t insert anything between two "ul" lists in output data', () => { + const data = + '
    ' + + '
  • One
  • ' + + '
' + + '
    ' + + '
  • Two
  • ' + + '
'; + + editor.setData( data ); + + expect( editor.getData() ).to.equalMarkup( data ); + } ); + + it( 'doesn\'t insert anything between two "ol" lists in output data', () => { + const data = + '
    ' + + '
  1. One
  2. ' + + '
' + + '
    ' + + '
  1. Two
  2. ' + + '
'; + + editor.setData( data ); + + expect( editor.getData() ).to.equalMarkup( data ); + } ); + + it( 'doesn\'t insert anything between two different lists in output data', () => { + const data = + '
    ' + + '
  1. One
  2. ' + + '
' + + '
    ' + + '
  • Two
  • ' + + '
'; + + editor.setData( data ); + + expect( editor.getData() ).to.equalMarkup( data ); + } ); + } ); +} ); + +describe( 'AdjacentListsSupport - integrations', () => { + let editorElement, editor, model; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + editorElement = document.createElement( 'div' ); + document.body.appendChild( editorElement ); + + editor = await ClassicTestEditor.create( editorElement, { + plugins: [ + Paragraph, + DocumentList, + DocumentListProperties, + AdjacentListsSupport + ] + } ); + + model = editor.model; + + stubUid(); + } ); + + afterEach( async () => { + if ( editorElement ) { + editorElement.remove(); + } + + if ( editor ) { + await editor.destroy(); + } + } ); + + it( 'works with DocumentListProperties', () => { + editor.setData( + '
    ' + + '
  • One
  • ' + + '
' + + '
    ' + + '
  • Two
  • ' + + '
' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + 'One' + + '' + + 'Two' + ); + } ); +} ); diff --git a/packages/ckeditor5-list/tests/manual/documentlist-separator.html b/packages/ckeditor5-list/tests/manual/documentlist-separator.html new file mode 100644 index 00000000000..76ceeb94a36 --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/documentlist-separator.html @@ -0,0 +1,83 @@ +
+ + +
+ +
+

foo

+ +

 

+
    +
  1. + +

    foobar

    +
  2. +
+ +

 

+
    +
  1. + +

    foobar

    +
  2. +
  3. + +

    foobar

    +
  4. +
+
    +
  1. + +

    Decimal list item again

    +
  2. +
  3. + +

    Decimal list item again +

    +
  4. +
+ +

 

+
    +
  1. + +

    foobar

    +
  2. +
  3. + +

    foobar

    +
  4. +
+
    +
  • + +

    foobar

    +
  • +
+
    +
  1. + +

    foobar +

    +
  2. +
+
+ + diff --git a/packages/ckeditor5-list/tests/manual/documentlist-separator.js b/packages/ckeditor5-list/tests/manual/documentlist-separator.js new file mode 100644 index 00000000000..ef8dad79eb0 --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/documentlist-separator.js @@ -0,0 +1,93 @@ +/** + * @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/src/classiceditor'; +import Alignment from '@ckeditor/ckeditor5-alignment/src/alignment'; +import AutoImage from '@ckeditor/ckeditor5-image/src/autoimage'; +import CodeBlock from '@ckeditor/ckeditor5-code-block/src/codeblock'; +import EasyImage from '@ckeditor/ckeditor5-easy-image/src/easyimage'; +import HorizontalLine from '@ckeditor/ckeditor5-horizontal-line/src/horizontalline'; +import HtmlEmbed from '@ckeditor/ckeditor5-html-embed/src/htmlembed'; +import HtmlComment from '@ckeditor/ckeditor5-html-support/src/htmlcomment'; +import ImageResize from '@ckeditor/ckeditor5-image/src/imageresize'; +import LinkImage from '@ckeditor/ckeditor5-link/src/linkimage'; +import PageBreak from '@ckeditor/ckeditor5-page-break/src/pagebreak'; +import SourceEditing from '@ckeditor/ckeditor5-source-editing/src/sourceediting'; +import TableCaption from '@ckeditor/ckeditor5-table/src/tablecaption'; +import CloudServices from '@ckeditor/ckeditor5-cloud-services/src/cloudservices'; +import ImageUpload from '@ckeditor/ckeditor5-image/src/imageupload'; +import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials'; +import BlockQuote from '@ckeditor/ckeditor5-block-quote/src/blockquote'; +import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold'; +import Heading from '@ckeditor/ckeditor5-heading/src/heading'; +import Image from '@ckeditor/ckeditor5-image/src/image'; +import ImageCaption from '@ckeditor/ckeditor5-image/src/imagecaption'; +import ImageStyle from '@ckeditor/ckeditor5-image/src/imagestyle'; +import ImageToolbar from '@ckeditor/ckeditor5-image/src/imagetoolbar'; +import Indent from '@ckeditor/ckeditor5-indent/src/indent'; +import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic'; +import Link from '@ckeditor/ckeditor5-link/src/link'; +import MediaEmbed from '@ckeditor/ckeditor5-media-embed/src/mediaembed'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import Table from '@ckeditor/ckeditor5-table/src/table'; +import TableToolbar from '@ckeditor/ckeditor5-table/src/tabletoolbar'; + +import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config'; + +import DocumentList from '../../src/documentlist'; +import DocumentListProperties from '../../src/documentlistproperties'; +import AdjacentListsSupport from '../../src/documentlist/adjacentlistssupport'; + +ClassicEditor + .create( document.querySelector( '#editor' ), { + ...( { + plugins: [ + Essentials, BlockQuote, Bold, Heading, Image, ImageCaption, ImageStyle, ImageToolbar, Indent, Italic, Link, + MediaEmbed, Paragraph, Table, TableToolbar, CodeBlock, TableCaption, EasyImage, ImageResize, LinkImage, + AutoImage, HtmlEmbed, HtmlComment, Alignment, PageBreak, HorizontalLine, ImageUpload, + CloudServices, SourceEditing, + DocumentList, + AdjacentListsSupport, + DocumentListProperties + ], + toolbar: [ + 'sourceEditing', '|', + 'numberedList', 'bulletedList', '|', + 'outdent', 'indent', '|', + 'heading', '|', + 'bold', 'italic', 'link', '|', + 'blockQuote', 'uploadImage', 'insertTable', 'mediaEmbed', 'codeBlock', '|', + 'htmlEmbed', '|', + 'alignment', '|', + 'pageBreak', 'horizontalLine', '|', + 'undo', 'redo' + ], + cloudServices: CS_CONFIG, + placeholder: 'Type the content here!', + htmlEmbed: { + showPreviews: true, + sanitizeHtml: html => ( { html, hasChange: false } ) + } + } ), + list: { + properties: { + styles: true, + startIndex: true, + reversed: true + } + } + } ) + .then( editor => { + window.editor = editor; + } ) + .catch( err => { + console.error( err.stack ); + } ); + +document.getElementById( 'chbx-show-borders' ).addEventListener( 'change', () => { + document.body.classList.toggle( 'show-borders' ); +} ); diff --git a/packages/ckeditor5-list/tests/manual/documentlist-separator.md b/packages/ckeditor5-list/tests/manual/documentlist-separator.md new file mode 100644 index 00000000000..93db6bf079a --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/documentlist-separator.md @@ -0,0 +1,3 @@ +# List separator feature + +This is an editor instance with the `DocumentListSeparator` plugin enabled. It separates two lists of the same type (`ol` + `ol` or `ul` + `ul`) if they are next to eachother.