diff --git a/.circleci/template.yml b/.circleci/template.yml index 77141feaec7..a45cea8825b 100644 --- a/.circleci/template.yml +++ b/.circleci/template.yml @@ -145,6 +145,31 @@ jobs: name: Upload the coverage to Coveralls command: npx coveralls -v < .out/combined_lcov.info + cke5_trigger_release_process: + machine: true + steps: + - community_verification_command + - checkout_command + - bootstrap_repository_command + - run: + name: Verify if CKEditor 5 is ready to release + command: | + #!/bin/bash + + # Do not fail if the Node script ends with non-zero exit code. + set +e + + node scripts/ci/is-ckeditor5-ready-to-release.js + EXIT_CODE=$( echo $? ) + + if [ ${EXIT_CODE} -eq 1 ]; + then + circleci-agent step halt + fi + - run: + name: Trigger the Uber CI + command: node scripts/ci/trigger-ckeditor5-continuous-integration.js -r ckeditor/ckeditor5 -c $CIRCLE_SHA1 -b $CIRCLE_BRANCH + cke5_trigger_uber_ci: machine: true steps: @@ -234,6 +259,17 @@ workflows: branches: only: - master + - cke5_trigger_release_process: + requires: + - cke5_tests_framework + - cke5_tests_features + - cke5_validators + - cke5_manual + - release_prepare + filters: + branches: + only: + - release - notify_ci_failure: filters: branches: diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 804269db4bd..12d6c9fe112 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -15,14 +15,16 @@ jobs: repo-token: ${{ secrets.GH_BOT_TOKEN }} stale-issue-label: status:stale close-issue-label: resolution:expired - stale-issue-message: "There has been no activity on this issue for the past two years. We've marked it as stale and will close it in 30 days. We understand it may be relevant, so if you're interested in the solution, leave a comment or reaction under this issue." - stale-pr-message: "There has been no activity on this PR for the past two years. We've marked it as stale and will close it in 30 days. We understand it may be relevant, so if you're interested in the contribution, leave a comment or reaction under this PR." - close-issue-message: "We've closed your issue due to inactivity over the last two years. We understand that the issue may still be relevant. If so, feel free to open a new one (and link this issue to it)." - close-pr-message: "We've closed your PR due to inactivity over the last two years. While time has passed, the core of your contribution might still be relevant. If you're able, consider reopening a similar PR." - days-before-stale: 730 + stale-pr-label: status:stale + close-pr-label: resolution:expired + stale-issue-message: "There has been no activity on this issue for the past year. We've marked it as stale and will close it in 30 days. We understand it may be relevant, so if you're interested in the solution, leave a comment or reaction under this issue." + stale-pr-message: "There has been no activity on this PR for the past year. We've marked it as stale and will close it in 30 days. We understand it may be relevant, so if you're interested in the contribution, leave a comment or reaction under this PR." + close-issue-message: "We've closed your issue due to inactivity over the last year. We understand that the issue may still be relevant. If so, feel free to open a new one (and link this issue to it)." + close-pr-message: "We've closed your PR due to inactivity over the last year. While time has passed, the core of your contribution might still be relevant. If you're able, consider reopening a similar PR." + days-before-stale: 365 days-before-close: 30 exempt-issue-labels: support:1,support:2,support:3,domain:accessibility exempt-pr-labels: support:1,support:2,support:3,domain:accessibility ignore-reactions: false - operations-per-run: 300 + operations-per-run: 1000 ascending: true diff --git a/docs/features/index.md b/docs/features/index.md index bcd78f17f5f..5f07839cc6d 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -59,7 +59,7 @@ Additionally, CKEditor 5 offers the {@link features/restricted-editing rest {@img assets/img/features-collaboration.png 800 CKEditor 5 collaboration features.} -You can also easily track the progress and changes done in the content with the {@link features/revision-history revision history feature} {@icon @ckeditor/ckeditor5-revision-history/theme/icons/revision-history.svg Revision history}. This modern and robust document versioning tool lets you create named versions, compare changes, and restore previous document versions at ease, tracking all progress — also when multiple editors work together. +You can also easily track the progress and changes done in the content with the {@link features/revision-history revision history feature} {@icon @ckeditor/ckeditor5-core/theme/icons/history.svg Revision history}. This modern and robust document versioning tool lets you create named versions, compare changes, and restore previous document versions at ease, tracking all progress — also when multiple editors work together. {@img assets/img/features-revision-history.png 800 CKEditor 5 document versioning feature.} diff --git a/docs/installation/advanced/content-styles.md b/docs/installation/advanced/content-styles.md index 2ecfde48776..df293f6b8d0 100644 --- a/docs/installation/advanced/content-styles.md +++ b/docs/installation/advanced/content-styles.md @@ -10,7 +10,7 @@ order: 30 # Content styles -Some of the {@link features/index core editor features} bring additional CSS to control the look of the content they produce. Take, for example, the {@link features/images-overview image feature} that needs special content styles to render images and their captions in the content. Or the {@link module:block-quote/blockquote~BlockQuote block quote} feature that displays quotes in italic with a subtle border on the side. +Some of the {@link features/index core editor features} bring additional CSS to control the look of the content they produce. Take, for example, the {@link features/images-overview image feature} that needs special content styles to render images and their captions in the content. Or the {@link module:block-quote/blockquote~BlockQuote block quote} feature that displays quotes in italics with a subtle border on the side. {@img assets/img/builds-content-styles.png 823 Editor content styles.} @@ -45,7 +45,7 @@ The content in the front–end of your application should now look the same as w ## The full list of content styles -Below there is a full list of content styles used by the editor features. You can copy it and use straight in your project. **Make sure to add the `ck-content` class to your content container for the styles to work** ([see above](#sharing-content-styles-between-frontend-and-backend)). +Below there is a full list of content styles used by the editor features. You can copy it and use it straight in your project. **Make sure to add the `ck-content` class to your content container for the styles to work** ([see above](#sharing-content-styles-between-frontend-and-backend)). ```css {@exec ../scripts/docs/read-content-styles-file.js} diff --git a/docs/tutorials/crash-course/plugin-configuration.md b/docs/tutorials/crash-course/plugin-configuration.md index 584436705aa..494048441fa 100644 --- a/docs/tutorials/crash-course/plugin-configuration.md +++ b/docs/tutorials/crash-course/plugin-configuration.md @@ -10,19 +10,15 @@ modified_at: 2023-08-16 ## Configuration convention -As we learned at the beginning of this tutorial, the editor accepts a configuration object that allows you to change its default behavior and appearance. +As we learned at the beginning of this tutorial, the editor accepts a configuration object that allows you to change its default behavior and appearance. The convention used in CKEditor 5 is to have a unique object key for each plugin to avoid conflicts and to make it obvious what each part of the configuration does. -For the purpose of this tutorial, we will add a single option to configure the keyboard shortcut for highlighting the selected text. - -The convention used in CKEditor is to have a unique object key for each plugin, to avoid conflicts and make it obvious what each part of the configuration does. - -Following this convention, we will add an optional `highlight` key to the configuration, which can contain a `keystroke` option: +In this tutorial, we will add a single option to the `highlight` plugin to configure its keyboard shortcut. This configuration will be included in an optional `highlight` key: ```js const editor = await ClassicEditor.create( element, { // Other options are omitted for readability - do not remove them. highlight: { - keystroke: '...' + } } ); ``` @@ -39,7 +35,7 @@ editor.config.define( 'highlight', { } ); ``` -The first parameter passed to the method is the name of the configuration object key, and the second is the defaults. +The first parameter passed to the method is the name of the configuration object key, and the second is the default values. ### Loading configuration option diff --git a/docs/updating/update-to-39.md b/docs/updating/update-to-39.md index 493de0799e4..a9dc0db80b3 100644 --- a/docs/updating/update-to-39.md +++ b/docs/updating/update-to-39.md @@ -3,7 +3,6 @@ category: update-guides meta-title: Update to version 39.x | CKEditor 5 Documentation menu-title: Update to v39.x order: 85 -modified_at: 2023-07-31 --- # Update to CKEditor 5 v39.x diff --git a/docs/updating/update-to-40.md b/docs/updating/update-to-40.md new file mode 100644 index 00000000000..5cfbcedda6b --- /dev/null +++ b/docs/updating/update-to-40.md @@ -0,0 +1,56 @@ +--- +category: update-guides +meta-title: Update to version 40.x | CKEditor 5 Documentation +menu-title: Update to v40.x +order: 84 +modified_at: 2023-09-26 +--- + +# Update to CKEditor 5 v40.x + + + When updating your CKEditor 5 installation, make sure **all the packages are the same version** to avoid errors. + + For custom builds, you may try removing the `package-lock.json` or `yarn.lock` files (if applicable) and reinstalling all packages before rebuilding the editor. For best results, make sure you use the most recent package versions. + + +## Update to CKEditor 5 v40.0.0 + +For the entire list of changes introduced in version 40.0.0, see the [release notes for CKEditor 5 v40.0.0](https://github.com/ckeditor/ckeditor5/releases/tag/v40.0.0). + +Listed below are the most important changes that require your attention when upgrading to CKEditor 5 v40.0.0. + +### Changes to the image feature + +This release introduces changes connected with the image `width` and `height` attributes. These are now preserved while loading editor content. Images without their size specified will automatically gain natural image size on any interaction with the image within the editor. Due to this new behavior, the `width` and `height` attributes are now used to preserve the image's natural width and height and the model attribute name of a resized image is now changed to `resizedWidth`. + +The `srcset` model attribute which provides parameters for responsive images, has been simplified. It is no longer an object `{ data: "...", width: "..." }`, but the value that was previously stored in the `data` part. + +Last but not least, content styles have been updated with this release, which means you need to update them in your editor implementation to avoid any discrepancies. Please refer to the {@link installation/advanced/content-styles Content styles} guide to learn how to generate the stylesheet. + +### New Balloon Block editor icon + +We have changed the default {@link features/blocktoolbar Balloon Block editor toolbar} indicator icon from the pilcrow icon (`¶`) to the braille pattern dots icon (`⠿`). The new icon better corresponds to the dual function of the indicator, which may be used to both invoke the balloon toolbar and to drag to content block around. + +While `⠿` is now a default, the icon can still be configured by the integrator, for example: + +```js + blockToolbar: { + items: [ + 'bold', + 'italic', + 'link' + ], + icon: 'pilcrow' // or SVG. + }, +``` + +### A new default lists plugin coming + +We currently maintain two list features: {@link features/lists List} and {@link features/document-lists DocumentList}. The list v1 feature was implemented in the early days of CKEditor 5. It supports “plain lists” – lists where `
  • ` cannot contain block content (paragraphs, headings, tables, block images). It supports to-do lists, but it does not support extending list markup via the {@link features/general-html-support General HTML Support (GHS)} feature. + +The list v2 (document list) feature was implemented in 2022 to add support for block content in list items. It supported extending list markup via GHS. It did not, however, support to-do lists. Since then we concentrated on bringing full list v1 functionality to this plugin. We are nearing the end of a long job of pairing these two plugins in their functions. You can follow the current state of works in the [Document list feature parity](https://github.com/ckeditor/ckeditor5/issues/14632) issue. + +Considering this progress, the old lists feature will be replaced with the new document lists in one of the upcoming releases and it will be sunset at the beginning of 2024. The change will be seamless for the users, but there are significant changes between these plugins. We will update the information about this process as it unfolds. + +See the [#14767](https://github.com/ckeditor/ckeditor5/issues/14767) issue for more details. diff --git a/packages/ckeditor5-ckbox/docs/features/ckbox.md b/packages/ckeditor5-ckbox/docs/features/ckbox.md index 492c881ab53..260357e57ff 100644 --- a/packages/ckeditor5-ckbox/docs/features/ckbox.md +++ b/packages/ckeditor5-ckbox/docs/features/ckbox.md @@ -37,7 +37,7 @@ As a full-fledged file manager, CKBox also replaces the basic CKEditor 5 im With CKBox you no longer need to write server-side code to upload and scale images or manage uploaded files. -To find out more about CKBox, the brand-new file manager, visit the [CKBox website](https://ckeditor.com/ckbox/) and read the dedicated [CKBox documentation page](https://ckeditor.com/docs/ckbox/latest/guides/index.html). +To find out more about CKBox, the brand-new file manager and image editor, visit the [CKBox website](https://ckeditor.com/ckbox/) and read the dedicated [CKBox documentation page](https://ckeditor.com/docs/ckbox/latest/guides/index.html). ## Demo diff --git a/packages/ckeditor5-code-block/package.json b/packages/ckeditor5-code-block/package.json index 4506f42198c..11d1735156e 100644 --- a/packages/ckeditor5-code-block/package.json +++ b/packages/ckeditor5-code-block/package.json @@ -22,9 +22,10 @@ "@ckeditor/ckeditor5-clipboard": "39.0.2", "@ckeditor/ckeditor5-core": "39.0.2", "@ckeditor/ckeditor5-dev-utils": "^39.0.0", + "@ckeditor/ckeditor5-editor-classic": "39.0.2", "@ckeditor/ckeditor5-engine": "39.0.2", "@ckeditor/ckeditor5-enter": "39.0.2", - "@ckeditor/ckeditor5-editor-classic": "39.0.2", + "@ckeditor/ckeditor5-html-support": "39.0.2", "@ckeditor/ckeditor5-image": "39.0.2", "@ckeditor/ckeditor5-indent": "39.0.2", "@ckeditor/ckeditor5-list": "39.0.2", diff --git a/packages/ckeditor5-code-block/src/codeblockediting.ts b/packages/ckeditor5-code-block/src/codeblockediting.ts index 562ee2f3647..6514c63bac3 100644 --- a/packages/ckeditor5-code-block/src/codeblockediting.ts +++ b/packages/ckeditor5-code-block/src/codeblockediting.ts @@ -22,6 +22,8 @@ import { type Element } from 'ckeditor5/src/engine'; +import type { DocumentListEditing } from '@ckeditor/ckeditor5-list'; + import CodeBlockCommand from './codeblockcommand'; import IndentCodeBlockCommand from './indentcodeblockcommand'; import OutdentCodeBlockCommand from './outdentcodeblockcommand'; @@ -97,7 +99,8 @@ export default class CodeBlockEditing extends Plugin { const schema = editor.model.schema; const model = editor.model; const view = editor.editing.view; - const isDocumentListEditingLoaded = editor.plugins.has( 'DocumentListEditing' ); + const documentListEditing: DocumentListEditing | null = editor.plugins.has( 'DocumentListEditing' ) ? + editor.plugins.get( 'DocumentListEditing' ) : null; const normalizedLanguagesDefs = getNormalizedAndLocalizedLanguageDefinitions( editor ); @@ -133,11 +136,10 @@ export default class CodeBlockEditing extends Plugin { // Allow all list* attributes on `codeBlock` (integration with DocumentList). // Disallow all attributes on $text inside `codeBlock`. schema.addAttributeCheck( ( context, attributeName ) => { - const isDocumentListAttributeOnCodeBlock = context.endsWith( 'codeBlock' ) && - attributeName.startsWith( 'list' ) && - attributeName !== 'list'; - - if ( isDocumentListEditingLoaded && isDocumentListAttributeOnCodeBlock ) { + if ( + context.endsWith( 'codeBlock' ) && + documentListEditing && documentListEditing.getListAttributeNames().includes( attributeName ) + ) { return true; } diff --git a/packages/ckeditor5-code-block/tests/codeblock-integration.js b/packages/ckeditor5-code-block/tests/codeblock-integration.js index 365aed21d9c..7cb219ac1f2 100644 --- a/packages/ckeditor5-code-block/tests/codeblock-integration.js +++ b/packages/ckeditor5-code-block/tests/codeblock-integration.js @@ -12,6 +12,8 @@ import GFMDataProcessor from '@ckeditor/ckeditor5-markdown-gfm/src/gfmdataproces import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import ImageInlineEditing from '@ckeditor/ckeditor5-image/src/image/imageinlineediting'; import DocumentListEditing from '@ckeditor/ckeditor5-list/src/documentlist/documentlistediting'; +import DocumentListPropertiesEditing from '@ckeditor/ckeditor5-list/src/documentlistproperties/documentlistpropertiesediting'; +import GeneralHtmlSupport from '@ckeditor/ckeditor5-html-support/src/generalhtmlsupport'; import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import CodeBlockUI from '../src/codeblockui'; @@ -178,7 +180,14 @@ describe( 'CodeBlock - integration', () => { beforeEach( async () => { editor = await ClassicTestEditor .create( '', { - plugins: [ CodeBlockEditing, DocumentListEditing, Enter, Paragraph ] + plugins: [ + CodeBlockEditing, DocumentListEditing, DocumentListPropertiesEditing, Enter, Paragraph, GeneralHtmlSupport + ], + htmlSupport: { + allow: [ + { name: /./, attributes: true, styles: true, classes: true } + ] + } } ); model = editor.model; @@ -188,22 +197,26 @@ describe( 'CodeBlock - integration', () => { await editor.destroy(); } ); - it( 'should allow all attributes starting with list* in the schema', () => { + it( 'should allow all list attributes in the schema', () => { setData( model, '[]foo' ); const codeBlock = model.document.getRoot().getChild( 0 ); expect( model.schema.checkAttribute( codeBlock, 'listItemId' ), 'listItemId' ).to.be.true; expect( model.schema.checkAttribute( codeBlock, 'listType' ), 'listType' ).to.be.true; - expect( model.schema.checkAttribute( codeBlock, 'listStart' ), 'listStart' ).to.be.true; - expect( model.schema.checkAttribute( codeBlock, 'listFoo' ), 'listFoo' ).to.be.true; + expect( model.schema.checkAttribute( codeBlock, 'listStyle' ), 'listStyle' ).to.be.true; + expect( model.schema.checkAttribute( codeBlock, 'htmlLiAttributes' ), 'htmlLiAttributes' ).to.be.true; + expect( model.schema.checkAttribute( codeBlock, 'htmlUlAttributes' ), 'htmlUlAttributes' ).to.be.true; + expect( model.schema.checkAttribute( codeBlock, 'htmlOlAttributes' ), 'htmlOlAttributes' ).to.be.true; } ); - it( 'should disallow attributes that do not start with "list" in the schema but include the sequence', () => { + it( 'should disallow attributes that are not registered as list attributes', () => { setData( model, '[]foo' ); const codeBlock = model.document.getRoot().getChild( 0 ); + expect( model.schema.checkAttribute( codeBlock, 'listReversed' ), 'listReversed' ).to.be.false; + expect( model.schema.checkAttribute( codeBlock, 'listStart' ), 'listStart' ).to.be.false; expect( model.schema.checkAttribute( codeBlock, 'list' ), 'list' ).to.be.false; expect( model.schema.checkAttribute( codeBlock, 'fooList' ), 'fooList' ).to.be.false; expect( model.schema.checkAttribute( codeBlock, 'alist' ), 'alist' ).to.be.false; diff --git a/packages/ckeditor5-core/src/index.ts b/packages/ckeditor5-core/src/index.ts index 9cbe6bd1699..8bd7878460a 100644 --- a/packages/ckeditor5-core/src/index.ts +++ b/packages/ckeditor5-core/src/index.ts @@ -38,7 +38,9 @@ import caption from './../theme/icons/caption.svg'; import check from './../theme/icons/check.svg'; import cog from './../theme/icons/cog.svg'; import eraser from './../theme/icons/eraser.svg'; +import history from './../theme/icons/history.svg'; import lowVision from './../theme/icons/low-vision.svg'; +import loupe from './../theme/icons/loupe.svg'; import image from './../theme/icons/image.svg'; import alignBottom from './../theme/icons/align-bottom.svg'; @@ -81,8 +83,10 @@ export const icons = { check, cog, eraser, + history, image, lowVision, + loupe, importExport, paragraph, plus, diff --git a/packages/ckeditor5-core/theme/icons/history.svg b/packages/ckeditor5-core/theme/icons/history.svg new file mode 100644 index 00000000000..daf9197f3e6 --- /dev/null +++ b/packages/ckeditor5-core/theme/icons/history.svg @@ -0,0 +1 @@ + diff --git a/packages/ckeditor5-core/theme/icons/loupe.svg b/packages/ckeditor5-core/theme/icons/loupe.svg new file mode 100644 index 00000000000..ba9b3fa8962 --- /dev/null +++ b/packages/ckeditor5-core/theme/icons/loupe.svg @@ -0,0 +1 @@ + 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-html-support/src/datafilter.ts b/packages/ckeditor5-html-support/src/datafilter.ts index de681af2e7a..97f79620ad7 100644 --- a/packages/ckeditor5-html-support/src/datafilter.ts +++ b/packages/ckeditor5-html-support/src/datafilter.ts @@ -132,17 +132,11 @@ export default class DataFilter extends Plugin { super( editor ); this._dataSchema = editor.plugins.get( 'DataSchema' ); - this._allowedAttributes = new Matcher(); - this._disallowedAttributes = new Matcher(); - this._allowedElements = new Set(); - this._disallowedElements = new Set(); - this._dataInitialized = false; - this._coupledAttributes = null; this._registerElementsAfterInit(); diff --git a/packages/ckeditor5-html-support/src/integrations/documentlist.ts b/packages/ckeditor5-html-support/src/integrations/documentlist.ts index e7c304286fa..0233abeb73b 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( @@ -107,36 +105,8 @@ export default class DocumentListElementSupport extends Plugin { } ); // Make sure that all items in a single list (items at the same level & listType) have the same properties. - // Note: This is almost an exact copy from DocumentListPropertiesEditing. documentListEditing.on( 'postFixer', ( evt, { listNodes, writer } ) => { - const previousNodesByIndent = []; // Last seen nodes of lower indented lists. - - for ( const { node, previous } of listNodes ) { - // For the first list block there is nothing to compare with. - if ( !previous ) { - continue; - } - - const nodeIndent = node.getAttribute( 'listIndent' ); - const previousNodeIndent = previous.getAttribute( 'listIndent' ); - - let previousNodeInList = null; // It's like `previous` but has the same indent as current node. - - // Let's find previous node for the same indent. - // We're going to need that when we get back to previous indent. - if ( nodeIndent > previousNodeIndent ) { - previousNodesByIndent[ previousNodeIndent ] = previous; - } - // Restore the one for given indent. - else if ( nodeIndent < previousNodeIndent ) { - previousNodeInList = previousNodesByIndent[ nodeIndent ]; - previousNodesByIndent.length = nodeIndent; - } - // Same indent. - else { - previousNodeInList = previous; - } - + for ( const { node, previousNodeInList } of listNodes ) { // This is a first item of a nested list. if ( !previousNodeInList ) { continue; @@ -174,7 +144,7 @@ export default class DocumentListElementSupport extends Plugin { for ( const { node } of listNodes ) { const listType = node.getAttribute( 'listType' ); - if ( listType === 'bulleted' && node.getAttribute( 'htmlOlAttributes' ) ) { + if ( listType !== 'numbered' && node.getAttribute( 'htmlOlAttributes' ) ) { writer.removeAttribute( 'htmlOlAttributes', node ); evt.return = true; } @@ -256,8 +226,8 @@ function viewToModelListAttributeConverter( attributeName: string, dataFilter: D /** * Returns HTML attribute name based on provided list type. */ -function getAttributeFromListType( listType: 'bulleted' | 'numbered' ) { - return listType === 'bulleted' ? - 'htmlUlAttributes' : - 'htmlOlAttributes'; +function getAttributeFromListType( listType: 'bulleted' | 'numbered' | 'todo' ) { + return listType === 'numbered' ? + 'htmlOlAttributes' : + 'htmlUlAttributes'; } diff --git a/packages/ckeditor5-image/docs/features/images-overview.md b/packages/ckeditor5-image/docs/features/images-overview.md index 4ba726250cb..3a23b222001 100644 --- a/packages/ckeditor5-image/docs/features/images-overview.md +++ b/packages/ckeditor5-image/docs/features/images-overview.md @@ -22,7 +22,6 @@ To see all the image features in action, check out the demo below. To learn more This demo only presents a limited set of features. Visit the {@link examples/builds/full-featured-editor feature-rich editor example} to see more in action. - ## Base image feature The base image feature does not support any user interface for inserting or managing images. Its sole purpose is to lay ground for other plugins (listed below) to build the target user experience. This pattern (composition of atomic features) is common for CKEditor 5 and allows the developers to build their own customized experience by implementing specific subfeatures differently. @@ -205,6 +204,16 @@ Refer to the {@link features/images-installation image installation} guide for m See the common API of image-related features such as {@link module:image/imagestyle~ImageStyle}, {@link module:image/imageresize~ImageResize}, and {@link module:link/linkimage~LinkImage} to learn more about available image toolbar buttons. +## Image `width` and `height` attributes + +Starting with v40.0.0, the image's `width` and `height` attributes are retained by the editor when it is loaded. Upon {@link features/image-upload uploading an image file} or {@link features/images-inserting inserting it} into the editor content, the CKEditor 5 image feature fetches these dimensions from the file. It will also happen on any interaction with the image if the content is preloaded. The editor then adds these properties to the markup, just like the {@link features/images-text-alternative text alternative tag}. + +However, if the user uses an upload adapter and the server sends back the uploaded image with the `width` or `height` parameters already set, these existing values are not overwritten. + +Adding the image's `width` and `height` attributes is done to ensure that the image dimensions ratio is properly kept when it is styled or aligned and that the image always looks like it should, rather than forcing the image size within the content. + +These image properties can be further controlled via CSS styles. If you need to crop, resize, or mirror flip your images, you can use the {@link features/ckbox CKBox asset manager} to achieve that. + ## Typing around images To type before or after an image easily, select the image, then press the Arrow key ( or ) once, depending on where you want to add content – before or after respectively. The image becomes no longer selected and whatever text you type will appear in the desired position. diff --git a/packages/ckeditor5-image/src/imageupload/imageuploadediting.ts b/packages/ckeditor5-image/src/imageupload/imageuploadediting.ts index f93e374c484..6e72532156f 100644 --- a/packages/ckeditor5-image/src/imageupload/imageuploadediting.ts +++ b/packages/ckeditor5-image/src/imageupload/imageuploadediting.ts @@ -133,10 +133,7 @@ export default class ImageUploadEditing extends Plugin { writer.setSelection( data.targetRanges.map( viewRange => editor.editing.mapper.toModelRange( viewRange ) ) ); } - // Upload images after the selection has changed in order to ensure the command's state is refreshed. - editor.model.enqueueChange( () => { - editor.execute( 'uploadImage', { file: images } ); - } ); + editor.execute( 'uploadImage', { file: images } ); } ); } ); diff --git a/packages/ckeditor5-image/tests/imageupload/imageuploadediting.js b/packages/ckeditor5-image/tests/imageupload/imageuploadediting.js index ebb1343d6b7..1d2081582e4 100644 --- a/packages/ckeditor5-image/tests/imageupload/imageuploadediting.js +++ b/packages/ckeditor5-image/tests/imageupload/imageuploadediting.js @@ -171,7 +171,7 @@ describe( 'ImageUploadEditing', () => { const id = fileRepository.getLoader( fileMock ).id; expect( getModelData( model ) ).to.equal( - `foo[]` + `foo[]` ); expect( eventInfo.stop.called ).to.be.true; } ); @@ -198,7 +198,10 @@ describe( 'ImageUploadEditing', () => { const dataTransfer = new DataTransfer( { files, types: [ 'Files' ] } ); setModelData( model, '[]foo' ); - const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) ); + const targetRange = model.createRange( + model.createPositionAt( doc.getRoot().getChild( 0 ), 3 ), + model.createPositionAt( doc.getRoot().getChild( 0 ), 3 ) + ); const targetViewRange = editor.editing.mapper.toViewRange( targetRange ); viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } ); @@ -214,7 +217,7 @@ describe( 'ImageUploadEditing', () => { ); } ); - it( 'should insert multiple image files when are pasted (block image type)', () => { + it( 'should insert multiple image files when are pasted', () => { const files = [ createNativeFileMock(), createNativeFileMock() ]; const dataTransfer = new DataTransfer( { files, types: [ 'Files' ] } ); setModelData( model, '[]' ); @@ -228,12 +231,13 @@ describe( 'ImageUploadEditing', () => { const id2 = fileRepository.getLoader( files[ 1 ] ).id; expect( getModelData( model ) ).to.equal( + '' + `` + `[]` ); } ); - it( 'should insert image when is pasted on allowed position when UploadImageCommand is disabled', () => { + it( 'should insert image when is pasted on allowed position when UploadImageCommand is enabled', () => { setModelData( model, 'foo[]' ); const fileMock = createNativeFileMock(); @@ -250,7 +254,7 @@ describe( 'ImageUploadEditing', () => { const id = fileRepository.getLoader( fileMock ).id; expect( getModelData( model ) ).to.equal( - `[]foo` + `[]foo` ); } ); diff --git a/packages/ckeditor5-list/src/augmentation.ts b/packages/ckeditor5-list/src/augmentation.ts index 7e579551e18..357b4cdfb1d 100644 --- a/packages/ckeditor5-list/src/augmentation.ts +++ b/packages/ckeditor5-list/src/augmentation.ts @@ -23,6 +23,8 @@ import type { TodoList, TodoListEditing, TodoListUI, + TodoDocumentList, + TodoDocumentListEditing, ListCommand, DocumentListCommand, @@ -36,7 +38,8 @@ import type { DocumentListStartCommand, ListReversedCommand, DocumentListReversedCommand, - CheckTodoListCommand + CheckTodoListCommand, + CheckTodoDocumentListCommand } from '.'; declare module '@ckeditor/ckeditor5-core' { @@ -69,6 +72,8 @@ declare module '@ckeditor/ckeditor5-core' { [ TodoList.pluginName ]: TodoList; [ TodoListEditing.pluginName ]: TodoListEditing; [ TodoListUI.pluginName ]: TodoListUI; + [ TodoDocumentList.pluginName ]: TodoDocumentList; + [ TodoDocumentListEditing.pluginName ]: TodoDocumentListEditing; } interface CommandsMap { @@ -83,7 +88,7 @@ declare module '@ckeditor/ckeditor5-core' { listStyle: ListStyleCommand | DocumentListStyleCommand; listStart: ListStartCommand | DocumentListStartCommand; listReversed: ListReversedCommand | DocumentListReversedCommand; - todoList: ListCommand; - checkTodoList: CheckTodoListCommand; + todoList: ListCommand | DocumentListCommand; + checkTodoList: CheckTodoListCommand | CheckTodoDocumentListCommand; } } diff --git a/packages/ckeditor5-list/src/documentlist/converters.ts b/packages/ckeditor5-list/src/documentlist/converters.ts index 751e4307b71..1e5f18b29a1 100644 --- a/packages/ckeditor5-list/src/documentlist/converters.ts +++ b/packages/ckeditor5-list/src/documentlist/converters.ts @@ -30,6 +30,7 @@ import { getAllListItemBlocks, getListItemBlocks, isListItemBlock, + isFirstBlockOfListItem, ListItemUid, type ListElement } from './utils/model'; @@ -48,8 +49,9 @@ import { findAndAddListHeadToMap } from './utils/postfixers'; import type { default as DocumentListEditing, DocumentListEditingCheckAttributesEvent, - DowncastStrategy, - ListItemAttributesMap + DocumentListEditingCheckElementEvent, + ListItemAttributesMap, + DowncastStrategy } from './documentlistediting'; /** @@ -72,15 +74,26 @@ export function listItemUpcastConverter(): GetCallback { return; } + const listItemId = ListItemUid.next(); + const listIndent = getIndent( data.viewItem ); + let listType = data.viewItem.parent && data.viewItem.parent.is( 'element', 'ol' ) ? 'numbered' : 'bulleted'; + + // Preserve list type if was already set (for example by to-do list feature). + const firstItemListType = items[ 0 ].getAttribute( 'listType' ) as string; + + if ( firstItemListType ) { + listType = firstItemListType; + } + const attributes = { - listItemId: ListItemUid.next(), - listIndent: getIndent( data.viewItem ), - listType: data.viewItem.parent && data.viewItem.parent.is( 'element', 'ol' ) ? 'numbered' : 'bulleted' + listItemId, + listIndent, + listType }; 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 ); } } @@ -172,7 +185,7 @@ export function reconvertItemsOnDataChange( findAndAddListHeadToMap( entry.range.start.getShiftedBy( 1 ), itemToListHead ); // Check if paragraph should be converted from bogus to plain paragraph. - if ( doesItemParagraphRequiresRefresh( item ) ) { + if ( doesItemBlockRequiresRefresh( item as Element ) ) { itemsToRefresh.push( item ); } } else { @@ -181,7 +194,7 @@ export function reconvertItemsOnDataChange( } else if ( isListItemBlock( item ) ) { // Some other attribute was changed on the list item, // check if paragraph does not need to be converted to bogus or back. - if ( doesItemParagraphRequiresRefresh( item ) ) { + if ( doesItemBlockRequiresRefresh( item ) ) { itemsToRefresh.push( item ); } } @@ -227,7 +240,7 @@ export function reconvertItemsOnDataChange( visited.add( block ); // Check if bogus vs plain paragraph needs refresh. - if ( doesItemParagraphRequiresRefresh( block, blocks ) ) { + if ( doesItemBlockRequiresRefresh( block, blocks ) ) { itemsToRefresh.push( block ); } // Check if wrapping with UL, OL, LIs needs refresh. @@ -240,14 +253,23 @@ export function reconvertItemsOnDataChange( return itemsToRefresh; } - function doesItemParagraphRequiresRefresh( item: Node, blocks?: Array ) { - if ( !item.is( 'element', 'paragraph' ) ) { + function doesItemBlockRequiresRefresh( item: Element, blocks?: Array ) { + const viewElement = editing.mapper.toViewElement( item ); + + if ( !viewElement ) { return false; } - const viewElement = editing.mapper.toViewElement( item ); + const needsRefresh = documentListEditing.fire( 'checkElement', { + modelElement: item, + viewElement + } ); - if ( !viewElement ) { + if ( needsRefresh ) { + return true; + } + + if ( !item.is( 'element', 'paragraph' ) && !item.is( 'element', 'listItem' ) ) { return false; } @@ -323,7 +345,8 @@ export function reconvertItemsOnDataChange( export function listItemDowncastConverter( attributeNames: Array, strategies: Array, - model: Model + model: Model, + { dataPipeline }: { dataPipeline?: boolean } = {} ): GetCallback> { const consumer = createAttributesConsumer( attributeNames ); @@ -345,11 +368,17 @@ export function listItemDowncastConverter( // This is for cases when mapping is using inner view element like in the code blocks (pre > code). const viewElement = findMappedViewElement( listItem, mapper, model )!; + // Remove custom item marker. + removeCustomMarkerElements( viewElement, writer, mapper ); + // Unwrap element from current list wrappers. unwrapListItemBlock( viewElement, writer ); - // Then wrap them with the new list wrappers. - wrapListItemBlock( listItem, writer.createRangeOn( viewElement ), strategies, writer ); + // Insert custom item marker. + const viewRange = insertCustomMarkerElements( listItem, viewElement, strategies, writer, { dataPipeline } ); + + // Then wrap them with the new list wrappers (UL, OL, LI). + wrapListItemBlock( listItem, viewRange, strategies, writer ); }; } @@ -395,10 +424,104 @@ export function findMappedViewElement( element: Element, mapper: Mapper, model: const modelRange = model.createRangeOn( element ); const viewRange = mapper.toViewRange( modelRange ).getTrimmed(); - return viewRange.getContainedElement(); + return viewRange.end.nodeBefore as ViewElement | null; +} + +/** + * Removes a custom marker elements and item wrappers related to that marker. + */ +function removeCustomMarkerElements( viewElement: ViewElement, viewWriter: DowncastWriter, mapper: Mapper ): void { + // Remove item wrapper. + while ( viewElement.parent!.is( 'attributeElement' ) && viewElement.parent!.getCustomProperty( 'listItemWrapper' ) ) { + viewWriter.unwrap( viewWriter.createRangeIn( viewElement.parent ), viewElement.parent ); + } + + // Remove custom item markers. + const viewWalker = viewWriter.createPositionBefore( viewElement ).getWalker( { direction: 'backward' } ); + const markersToRemove = []; + + for ( const { item } of viewWalker ) { + // Walk only over the non-mapped elements between list item blocks. + if ( item.is( 'element' ) && mapper.toModelElement( item ) ) { + break; + } + + if ( item.is( 'element' ) && item.getCustomProperty( 'listItemMarker' ) ) { + markersToRemove.push( item ); + } + } + + for ( const marker of markersToRemove ) { + viewWriter.remove( marker ); + } +} + +/** + * Inserts a custom marker elements and wraps first block of a list item if marker requires it. + */ +function insertCustomMarkerElements( + listItem: Element, + viewElement: ViewElement, + strategies: Array, + writer: DowncastWriter, + { dataPipeline }: { dataPipeline?: boolean } +): ViewRange { + let viewRange = writer.createRangeOn( viewElement ); + + // Marker can be inserted only before the first block of a list item. + if ( !isFirstBlockOfListItem( listItem ) ) { + return viewRange; + } + + for ( const strategy of strategies ) { + if ( strategy.scope != 'itemMarker' ) { + continue; + } + + // Create the custom marker element and inject it before the first block of the list item. + const markerElement = strategy.createElement( writer, listItem, { dataPipeline } ); + + if ( !markerElement ) { + continue; + } + + writer.setCustomProperty( 'listItemMarker', true, markerElement ); + writer.insert( viewRange.start, markerElement ); + + viewRange = writer.createRange( + writer.createPositionBefore( markerElement ), + writer.createPositionAfter( viewElement ) + ); + + // Wrap the marker and optionally the first block with an attribute element (label for to-do lists). + if ( !strategy.createWrapperElement || !strategy.canWrapElement ) { + continue; + } + + const wrapper = strategy.createWrapperElement( writer, listItem, { dataPipeline } ); + + writer.setCustomProperty( 'listItemWrapper', true, wrapper ); + + // The whole block can be wrapped... + if ( strategy.canWrapElement( listItem ) ) { + viewRange = writer.wrap( viewRange, wrapper ); + } else { + // ... or only the marker element (if the block is downcasted to heading or block widget). + viewRange = writer.wrap( writer.createRangeOn( markerElement ), wrapper ); + + viewRange = writer.createRange( + viewRange.start, + writer.createPositionAfter( viewElement ) + ); + } + } + + return viewRange; } -// Unwraps all ol, ul, and li attribute elements that are wrapping the provided view element. +/** + * Unwraps all ol, ul, and li attribute elements that are wrapping the provided view element. + */ function unwrapListItemBlock( viewElement: ViewElement, viewWriter: DowncastWriter ) { let attributeElement: ViewElement | ViewDocumentFragment = viewElement.parent!; @@ -411,7 +534,9 @@ function unwrapListItemBlock( viewElement: ViewElement, viewWriter: DowncastWrit } } -// Wraps the given list item with appropriate attribute elements for ul, ol, and li. +/** + * Wraps the given list item with appropriate attribute elements for ul, ol, and li. + */ function wrapListItemBlock( listItem: ListElement, viewRange: ViewRange, @@ -430,7 +555,10 @@ function wrapListItemBlock( const listViewElement = createListElement( writer, indent, currentListItem.getAttribute( 'listType' ) ); for ( const strategy of strategies ) { - if ( currentListItem.hasAttribute( strategy.attributeName ) ) { + if ( + ( strategy.scope == 'list' || strategy.scope == 'item' ) && + currentListItem.hasAttribute( strategy.attributeName ) + ) { strategy.setAttributeOnDowncast( writer, currentListItem.getAttribute( strategy.attributeName ), diff --git a/packages/ckeditor5-list/src/documentlist/documentlistcommand.ts b/packages/ckeditor5-list/src/documentlist/documentlistcommand.ts index e9b51287f56..0b1f4aa8c9f 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, + canBecomeSimpleListItem } from './utils/model'; /** @@ -29,7 +30,7 @@ export default class DocumentListCommand extends Command { /** * The type of the list created by the command. */ - public readonly type: 'numbered' | 'bulleted'; + public readonly type: 'numbered' | 'bulleted' | 'todo'; /** * A flag indicating whether the command is active, which means that the selection starts in a list of the same type. @@ -45,7 +46,7 @@ export default class DocumentListCommand extends Command { * @param editor The editor instance. * @param type List type that will be handled by this command. */ - constructor( editor: Editor, type: 'numbered' | 'bulleted' ) { + constructor( editor: Editor, type: 'numbered' | 'bulleted' | 'todo' ) { super( editor ); this.type = type; @@ -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' ) || canBecomeSimpleListItem( block, model.schema ) ); // Whether we are turning off some items. const turnOff = options.forceValue !== undefined ? !options.forceValue : this.value; @@ -92,7 +93,7 @@ export default class DocumentListCommand extends Command { changedBlocks.push( ...splitListItemBefore( itemBlocks[ 1 ], writer ) ); } - // Convert list blocks to plain blocks. + // Strip list attributes. changedBlocks.push( ...removeListAttributes( blocks, writer ) ); // Outdent items following the selected list item. @@ -100,7 +101,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 +118,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' ) && canBecomeSimpleListItem( block, model.schema ) ) { + writer.rename( block, 'listItem' ); + } + writer.setAttributes( { listIndent: 0, listItemId: ListItemUid.next(), @@ -178,8 +184,10 @@ export default class DocumentListCommand extends Command { * @returns Whether the command should be enabled. */ private _checkEnabled(): boolean { - const selection = this.editor.model.document.selection; - const schema = this.editor.model.schema; + const model = this.editor.model; + const schema = model.schema; + const selection = model.document.selection; + const blocks = Array.from( selection.getSelectedBlocks() ); if ( !blocks.length ) { @@ -192,7 +200,7 @@ export default class DocumentListCommand extends Command { } for ( const block of blocks ) { - if ( schema.checkAttribute( block, 'listType' ) ) { + 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 3046084f42f..6601ce84f30 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'; @@ -23,6 +24,7 @@ import type { UpcastElementEvent, ViewDocumentTabEvent, ViewElement, + ViewAttributeElement, Writer } from 'ckeditor5/src/engine'; @@ -79,7 +81,7 @@ const LIST_BASE_ATTRIBUTES = [ 'listType', 'listIndent', 'listItemId' ]; * Map of model attributes applicable to list blocks. */ export interface ListItemAttributesMap { - listType?: 'numbered' | 'bulleted'; + listType?: 'numbered' | 'bulleted' | 'todo'; listIndent?: number; listItemId?: string; } @@ -107,12 +109,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 +136,18 @@ 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 } ); + model.schema.register( '$listItem', { allowAttributes: LIST_BASE_ATTRIBUTES } ); + + if ( multiBlock ) { + model.schema.extend( '$container', { allowAttributesOf: '$listItem' } ); + model.schema.extend( '$block', { allowAttributesOf: '$listItem' } ); + model.schema.extend( '$blockObject', { allowAttributesOf: '$listItem' } ); + } else { + model.schema.register( 'listItem', { + inheritAllFrom: '$block', + allowAttributesOf: '$listItem' + } ); + } for ( const attribute of LIST_BASE_ATTRIBUTES ) { model.schema.setAttributeProperties( attribute, { @@ -141,12 +162,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(); @@ -194,7 +217,7 @@ export default class DocumentListEditing extends Plugin { /** * Returns list of model attribute names that should affect downcast conversion. */ - private _getListAttributeNames() { + public getListAttributeNames(): Array { return [ ...LIST_BASE_ATTRIBUTES, ...this._downcastStrategies.map( strategy => strategy.attributeName ) @@ -207,8 +230,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; @@ -247,7 +270,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; } @@ -266,7 +289,7 @@ export default class DocumentListEditing extends Plugin { return; } - if ( !mergeForwardCommand.isEnabled ) { + if ( !mergeForwardCommand || !mergeForwardCommand.isEnabled ) { return; } @@ -388,35 +411,73 @@ export default class DocumentListEditing extends Plugin { private _setupConversion() { const editor = this.editor; const model = editor.model; - const attributeNames = this._getListAttributeNames(); + 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' } ) + // 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 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( { + view: 'li', + model: ( viewElement, { writer } ) => writer.createElement( elementName, { listType: '' } ) + } ) + // Convert paragraph to the list block (without list type defined yet). + // This is important to properly handle bogus paragraph and to-do lists. + // Most of the time the bogus paragraph should not appear in the data of to-do list, + // but if there is any marker or an attribute on the paragraph then the bogus paragraph + // is preserved in the data, and we need to be able to detect this case. + .elementToElement( { + view: 'p', + model: ( viewElement, { writer } ) => { + if ( viewElement.parent && viewElement.parent.is( 'element', 'li' ) ) { + return writer.createElement( elementName, { listType: '' } ); + } + + return null; + }, + converterPriority: 'high' + } ) .add( dispatcher => { dispatcher.on( 'element:li', listItemUpcastConverter() ); dispatcher.on( 'element:ul', listUpcastCleanList(), { priority: 'high' } ); dispatcher.on( 'element:ol', listUpcastCleanList(), { priority: 'high' } ); } ); + if ( !multiBlock ) { + editor.conversion.for( 'downcast' ) + .elementToElement( { + model: 'listItem', + view: 'p' + } ); + } + editor.conversion.for( 'editingDowncast' ) .elementToElement( { - model: 'paragraph', + model: elementName, view: bogusParagraphCreator( attributeNames ), converterPriority: 'high' + } ) + .add( dispatcher => { + dispatcher.on>( + 'attribute', + listItemDowncastConverter( attributeNames, this._downcastStrategies, model ) + ); } ); editor.conversion.for( 'dataDowncast' ) .elementToElement( { - model: 'paragraph', + model: elementName, view: bogusParagraphCreator( attributeNames, { dataPipeline: true } ), converterPriority: 'high' - } ); - - editor.conversion.for( 'downcast' ) + } ) .add( dispatcher => { dispatcher.on>( 'attribute', - listItemDowncastConverter( attributeNames, this._downcastStrategies, model ) + listItemDowncastConverter( attributeNames, this._downcastStrategies, model, { dataPipeline: true } ) ); } ); @@ -452,7 +513,7 @@ export default class DocumentListEditing extends Plugin { */ private _setupModelPostFixing() { const model = this.editor.model; - const attributeNames = this._getListAttributeNames(); + const attributeNames = this.getListAttributeNames(); // Register list fixing. // First the low level handler. @@ -518,9 +579,9 @@ export default class DocumentListEditing extends Plugin { } /** - * The downcast strategy. + * The attribute to attribute downcast strategy for UL, OL, LI elements. */ -export interface DowncastStrategy { +export interface AttributeDowncastStrategy { /** * The scope of the downcast (whether it applies to LI or OL/UL). @@ -538,6 +599,51 @@ export interface DowncastStrategy { setAttributeOnDowncast( writer: DowncastWriter, value: unknown, element: ViewElement ): void; } +/** + * The custom marker downcast strategy. + */ +export interface ItemMarkerDowncastStrategy { + + /** + * The scope of the downcast. + */ + scope: 'itemMarker'; + + /** + * The model attribute name. + */ + attributeName: string; + + /** + * Creates a view element for a custom item marker. + */ + createElement( + writer: DowncastWriter, + modelElement: Element, + { dataPipeline }: { dataPipeline?: boolean } + ): ViewElement | null; + + /** + * Creates an AttributeElement to be used for wrapping a first block of a list item. + */ + createWrapperElement?( + writer: DowncastWriter, + modelElement: Element, + { dataPipeline }: { dataPipeline?: boolean } + ): ViewAttributeElement; + + /** + * Should return true if the given list block can be wrapped with the wrapper created by `createWrapperElement()` + * or only the marker element should be wrapped. + */ + canWrapElement?( modelElement: Element ): boolean; +} + +/** + * The downcast strategy. + */ +export type DowncastStrategy = AttributeDowncastStrategy | ItemMarkerDowncastStrategy; + /** * Post-fixer that reacts to changes on document and fixes incorrect model states (invalid `listItemId` and `listIndent` values). * @@ -578,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; @@ -622,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. @@ -792,3 +912,21 @@ export type DocumentListEditingCheckAttributesEvent = { } ]; return: boolean; }; + +/** + * Event fired on changes detected on the model list element to verify if the view representation of a list block element + * is representing those attributes. + * + * It allows triggering a reconversion of a list item block. + * + * @internal + * @eventName ~DocumentListEditing#checkElement + */ +export type DocumentListEditingCheckElementEvent = { + name: 'checkElement'; + args: [ { + viewElement: ViewElement; + modelElement: Element; + } ]; + return: boolean; +}; 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 ) ); diff --git a/packages/ckeditor5-list/src/documentlist/utils/listwalker.ts b/packages/ckeditor5-list/src/documentlist/utils/listwalker.ts index 745ff8ad6b0..95130c29732 100644 --- a/packages/ckeditor5-list/src/documentlist/utils/listwalker.ts +++ b/packages/ckeditor5-list/src/documentlist/utils/listwalker.ts @@ -10,7 +10,7 @@ import { first, toArray, type ArrayOrItem } from 'ckeditor5/src/utils'; import { isListItemBlock, type ListElement } from './model'; -import type { DocumentFragment, Element, Node } from 'ckeditor5/src/engine'; +import type { Element, Node } from 'ckeditor5/src/engine'; /** * Document list blocks iterator. @@ -216,10 +216,33 @@ export function* iterateSiblingListBlocks( direction: 'forward' | 'backward' = 'forward' ): IterableIterator { const isForward = direction == 'forward'; + const previousNodesByIndent: Array = []; // Last seen nodes of lower indented lists. let previous = null; while ( isListItemBlock( node ) ) { - yield { node, previous }; + let previousNodeInList = null; // It's like `previous` but has the same indent as current node. + + if ( previous ) { + const nodeIndent = node.getAttribute( 'listIndent' ); + const previousNodeIndent = previous.getAttribute( 'listIndent' ); + + // Let's find previous node for the same indent. + // We're going to need that when we get back to previous indent. + if ( nodeIndent > previousNodeIndent ) { + previousNodesByIndent[ previousNodeIndent ] = previous; + } + // Restore the one for given indent. + else if ( nodeIndent < previousNodeIndent ) { + previousNodeInList = previousNodesByIndent[ nodeIndent ]; + previousNodesByIndent.length = nodeIndent; + } + // Same indent. + else { + previousNodeInList = previous; + } + } + + yield { node, previous, previousNodeInList }; previous = node; node = isForward ? node.nextSibling : node.previousSibling; @@ -267,4 +290,9 @@ export interface ListIteratorValue { * The previous list node. */ previous: ListElement | null; + + /** + * The previous list node at the same indent as current node. + */ + previousNodeInList: ListElement | null; } diff --git a/packages/ckeditor5-list/src/documentlist/utils/model.ts b/packages/ckeditor5-list/src/documentlist/utils/model.ts index b9a34845488..b1ccba25539 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'; @@ -45,7 +46,7 @@ export class ListItemUid { export interface ListElement extends Element { getAttribute( key: 'listItemId' ): string; getAttribute( key: 'listIndent' ): number; - getAttribute( key: 'listType' ): 'numbered' | 'bulleted'; + getAttribute( key: 'listType' ): 'numbered' | 'bulleted' | 'todo'; getAttribute( key: string ): unknown; } @@ -392,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' ) ) { @@ -545,7 +554,21 @@ 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. + * + * 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. + */ +export function canBecomeSimpleListItem( block: Element, schema: Schema ): boolean { + return schema.checkChild( block.parent as Element, 'listItem' ) && schema.checkChild( block, '$text' ) && !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, 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 ); diff --git a/packages/ckeditor5-list/src/documentlist/utils/view.ts b/packages/ckeditor5-list/src/documentlist/utils/view.ts index ad25eeb8254..20326aaebee 100644 --- a/packages/ckeditor5-list/src/documentlist/utils/view.ts +++ b/packages/ckeditor5-list/src/documentlist/utils/view.ts @@ -96,7 +96,7 @@ export function getIndent( listItem: ViewElement ): number { export function createListElement( writer: DowncastWriter, indent: number, - type: 'bulleted' | 'numbered', + type: 'bulleted' | 'numbered' | 'todo', id = getViewElementIdForListType( type, indent ) ): ViewAttributeElement { // Negative priorities so that restricted editing attribute won't wrap lists. @@ -128,7 +128,7 @@ export function createListItemElement( * * @internal */ -export function getViewElementNameForListType( type?: 'bulleted' | 'numbered' ): 'ol' | 'ul' { +export function getViewElementNameForListType( type?: 'bulleted' | 'numbered' | 'todo' ): 'ol' | 'ul' { return type == 'numbered' ? 'ol' : 'ul'; } @@ -137,6 +137,6 @@ export function getViewElementNameForListType( type?: 'bulleted' | 'numbered' ): * * @internal */ -export function getViewElementIdForListType( type?: 'bulleted' | 'numbered', indent?: number ): string { +export function getViewElementIdForListType( type?: 'bulleted' | 'numbered' | 'todo', indent?: number ): string { return `list-${ type }-${ indent }`; } diff --git a/packages/ckeditor5-list/src/documentlistproperties/documentlistpropertiesediting.ts b/packages/ckeditor5-list/src/documentlistproperties/documentlistpropertiesediting.ts index 8d6dabe3de7..b63aa811922 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,9 +89,7 @@ export default class DocumentListPropertiesEditing extends Plugin { for ( const strategy of strategies ) { strategy.addCommand( editor ); - model.schema.extend( '$container', { allowAttributes: strategy.attributeName } ); - model.schema.extend( '$block', { allowAttributes: strategy.attributeName } ); - model.schema.extend( '$blockObject', { allowAttributes: strategy.attributeName } ); + model.schema.extend( '$listItem', { allowAttributes: strategy.attributeName } ); // Register downcast strategy. documentListEditing.registerDowncastStrategy( { @@ -171,35 +167,8 @@ export default class DocumentListPropertiesEditing extends Plugin { } ); // Make sure that all items in a single list (items at the same level & listType) have the same properties. - documentListEditing.on( 'postFixer', ( evt, { listNodes, writer } ) => { - const previousNodesByIndent = []; // Last seen nodes of lower indented lists. - - for ( const { node, previous } of listNodes ) { - // For the first list block there is nothing to compare with. - if ( !previous ) { - continue; - } - - const nodeIndent = node.getAttribute( 'listIndent' ); - const previousNodeIndent = previous.getAttribute( 'listIndent' ); - - let previousNodeInList = null; // It's like `previous` but has the same indent as current node. - - // Let's find previous node for the same indent. - // We're going to need that when we get back to previous indent. - if ( nodeIndent > previousNodeIndent ) { - previousNodesByIndent[ previousNodeIndent ] = previous; - } - // Restore the one for given indent. - else if ( nodeIndent < previousNodeIndent ) { - previousNodeInList = previousNodesByIndent[ nodeIndent ]; - previousNodesByIndent.length = nodeIndent; - } - // Same indent. - else { - previousNodeInList = previous; - } - + documentListEditing.on( 'postFixer', ( evt, { listNodes, writer } ) => { + for ( const { node, previousNodeInList } of listNodes ) { // This is a first item of a nested list. if ( !previousNodeInList ) { continue; @@ -302,11 +271,15 @@ function createAttributeStrategies( enabledProperties: ListPropertiesConfig ) { editor.commands.add( 'listStyle', new DocumentListStyleCommand( editor, DEFAULT_LIST_TYPE, supportedTypes ) ); }, - appliesToListItem() { - return true; + appliesToListItem( item ) { + return item.getAttribute( 'listType' ) == 'numbered' || item.getAttribute( 'listType' ) == 'bulleted'; }, hasValidAttribute( item ) { + if ( !this.appliesToListItem( item ) ) { + return !item.hasAttribute( 'listStyle' ); + } + if ( !item.hasAttribute( 'listStyle' ) ) { return false; } diff --git a/packages/ckeditor5-list/src/index.ts b/packages/ckeditor5-list/src/index.ts index 5ff7baa1c15..c6dc8eb719c 100644 --- a/packages/ckeditor5-list/src/index.ts +++ b/packages/ckeditor5-list/src/index.ts @@ -26,6 +26,8 @@ export { default as ListPropertiesUI } from './listproperties/listpropertiesui'; export { default as TodoList } from './todolist'; export { default as TodoListEditing } from './todolist/todolistediting'; export { default as TodoListUI } from './todolist/todolistui'; +export { default as TodoDocumentList } from './tododocumentlist'; +export { default as TodoDocumentListEditing } from './tododocumentlist/tododocumentlistediting'; export type { ListConfig, ListPropertiesConfig } from './listconfig'; export type { default as ListStyle } from './liststyle'; @@ -40,5 +42,6 @@ export type { default as ListReversedCommand } from './listproperties/listrevers export type { default as ListStartCommand } from './listproperties/liststartcommand'; export type { default as ListStyleCommand } from './listproperties/liststylecommand'; export type { default as CheckTodoListCommand } from './todolist/checktodolistcommand'; +export type { default as CheckTodoDocumentListCommand } from './tododocumentlist/checktododocumentlistcommand'; import './augmentation'; diff --git a/packages/ckeditor5-list/src/listconfig.ts b/packages/ckeditor5-list/src/listconfig.ts index 2ff7dea0ca8..10efa045ab3 100644 --- a/packages/ckeditor5-list/src/listconfig.ts +++ b/packages/ckeditor5-list/src/listconfig.ts @@ -33,6 +33,17 @@ export interface ListConfig { * Read more in {@link module:list/listconfig~ListPropertiesConfig}. */ properties?: ListPropertiesConfig; + + /** + * 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/src/tododocumentlist.ts b/packages/ckeditor5-list/src/tododocumentlist.ts new file mode 100644 index 00000000000..129b3d36abd --- /dev/null +++ b/packages/ckeditor5-list/src/tododocumentlist.ts @@ -0,0 +1,36 @@ +/** + * @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/tododocumentlist + */ + +import TodoDocumentListEditing from './tododocumentlist/tododocumentlistediting'; +import TodoListUI from './todolist/todolistui'; +import { Plugin } from 'ckeditor5/src/core'; + +import '../theme/todolist.css'; + +/** + * The to-do list feature. + * + * This is a "glue" plugin that loads the {@link module:list/todolist/todolistediting~TodoListEditing to-do list editing feature} + * and the {@link module:list/todolist/todolistui~TodoListUI to-do list UI feature}. + */ +export default class TodoDocumentList extends Plugin { + /** + * @inheritDoc + */ + public static get requires() { + return [ TodoDocumentListEditing, TodoListUI ] as const; + } + + /** + * @inheritDoc + */ + public static get pluginName() { + return 'TodoDocumentList' as const; + } +} diff --git a/packages/ckeditor5-list/src/tododocumentlist/checktododocumentlistcommand.ts b/packages/ckeditor5-list/src/tododocumentlist/checktododocumentlistcommand.ts new file mode 100644 index 00000000000..18c94ac20f3 --- /dev/null +++ b/packages/ckeditor5-list/src/tododocumentlist/checktododocumentlistcommand.ts @@ -0,0 +1,104 @@ +/** + * @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/tododocumentlist/checktododocumentlistcommand + */ + +import { Command, type Editor } from 'ckeditor5/src/core'; +import type { Element } from 'ckeditor5/src/engine'; +import { getAllListItemBlocks } from '../documentlist/utils/model'; + +/** + * The check to-do command. + * + * The command is registered by the {@link module:list/tododocumentlist/tododocumentlistediting~TodoDocumentListEditing} as + * the `checkTodoList` editor command. + */ +export default class CheckTodoDocumentListCommand extends Command { + /** + * A list of to-do list items selected by the {@link module:engine/model/selection~Selection}. + * + * @observable + * @readonly + */ + declare public value: boolean; + + /** + * @inheritDoc + */ + constructor( editor: Editor ) { + super( editor ); + + // Refresh command before executing to be sure all values are up to date. + // It is needed when selection has changed before command execution, in the same change block. + this.on( 'execute', () => { + this.refresh(); + }, { priority: 'highest' } ); + } + + /** + * Updates the command's {@link #value} and {@link #isEnabled} properties based on the current selection. + */ + public override refresh(): void { + const selectedElements = this._getSelectedItems(); + + this.value = this._getValue( selectedElements ); + this.isEnabled = !!selectedElements.length; + } + + /** + * Executes the command. + * + * @param options.forceValue If set, it will force the command behavior. If `true`, the command will apply + * the attribute. Otherwise, the command will remove the attribute. If not set, the command will look for its current + * value to decide what it should do. + */ + public override execute( options: { forceValue?: boolean } = {} ): void { + this.editor.model.change( writer => { + const selectedElements = this._getSelectedItems(); + const value = ( options.forceValue === undefined ) ? !this._getValue( selectedElements ) : options.forceValue; + + for ( const element of selectedElements ) { + if ( value ) { + writer.setAttribute( 'todoListChecked', true, element ); + } else { + writer.removeAttribute( 'todoListChecked', element ); + } + } + } ); + } + + /** + * Returns a value for the command. + */ + private _getValue( selectedElements: Array ): boolean { + return selectedElements.every( element => element.getAttribute( 'todoListChecked' ) ); + } + + /** + * Gets all to-do list items selected by the {@link module:engine/model/selection~Selection}. + */ + private _getSelectedItems() { + const model = this.editor.model; + const schema = model.schema; + + const selectionRange = model.document.selection.getFirstRange()!; + const startElement = selectionRange.start.parent as Element; + const elements: Array = []; + + if ( schema.checkAttribute( startElement, 'todoListChecked' ) ) { + elements.push( ...getAllListItemBlocks( startElement ) ); + } + + for ( const item of selectionRange.getItems( { shallow: true } ) as Iterable ) { + if ( schema.checkAttribute( item, 'todoListChecked' ) && !elements.includes( item ) ) { + elements.push( ...getAllListItemBlocks( item ) ); + } + } + + return elements; + } +} diff --git a/packages/ckeditor5-list/src/tododocumentlist/todocheckboxchangeobserver.ts b/packages/ckeditor5-list/src/tododocumentlist/todocheckboxchangeobserver.ts new file mode 100644 index 00000000000..cdf721287d5 --- /dev/null +++ b/packages/ckeditor5-list/src/tododocumentlist/todocheckboxchangeobserver.ts @@ -0,0 +1,59 @@ +/** + * @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/tododocumentlist/todocheckboxchangeobserver + */ + +import { DomEventObserver, type DomEventData } from 'ckeditor5/src/engine'; + +/** + * Observes all to-do list checkboxes state changes. + * + * Note that this observer is not available by default. To make it available it needs to be added to + * {@link module:engine/view/view~View} by {@link module:engine/view/view~View#addObserver} method. + */ +export default class TodoCheckboxChangeObserver extends DomEventObserver<'change'> { + /** + * @inheritDoc + */ + public readonly domEventType = [ 'change' ] as const; + + /** + * @inheritDoc + */ + public onDomEvent( domEvent: Event ): void { + if ( domEvent.target ) { + const viewTarget = this.view.domConverter.mapDomToView( domEvent.target as HTMLElement ); + + if ( + viewTarget && + viewTarget.is( 'element', 'input' ) && + viewTarget.getAttribute( 'type' ) == 'checkbox' && + viewTarget.findAncestor( { classes: 'todo-list__label' } ) + ) { + this.fire( 'todoCheckboxChange', domEvent ); + } + } + } +} + +/** + * Fired when the to-do list checkbox is toggled. + * + * Introduced by {@link module:list/tododocumentlist/todocheckboxchangeobserver~TodoCheckboxChangeObserver}. + * + * Note that this event is not available by default. To make it available, + * {@link module:list/tododocumentlist/todocheckboxchangeobserver~TodoCheckboxChangeObserver} + * needs to be added to {@link module:engine/view/view~View} by the {@link module:engine/view/view~View#addObserver} method. + * + * @see module:list/tododocumentlist/todocheckboxchangeobserver~TodoCheckboxChangeObserver + * @eventName module:engine/view/document~Document#todoCheckboxChange + * @param data The event data. + */ +export type ViewDocumentTodoCheckboxChangeEvent = { + name: 'todoCheckboxChange'; + args: [ data: DomEventData ]; +}; diff --git a/packages/ckeditor5-list/src/tododocumentlist/tododocumentlistediting.ts b/packages/ckeditor5-list/src/tododocumentlist/tododocumentlistediting.ts new file mode 100644 index 00000000000..ff0a8d917d5 --- /dev/null +++ b/packages/ckeditor5-list/src/tododocumentlist/tododocumentlistediting.ts @@ -0,0 +1,529 @@ +/** + * @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/tododocumentlist/tododocumentlistediting + */ + +import { + Matcher, + type UpcastElementEvent, + type Model, + type Element, + type MatcherPattern, + type ViewElement, + type ViewDocumentKeyDownEvent, + type ViewDocumentArrowKeyEvent, + type MapperViewToModelPositionEvent, + type ViewDocumentFragment +} from 'ckeditor5/src/engine'; + +import { + getCode, + parseKeystroke, + getLocalizedArrowKeyCodeDirection, + type GetCallback, + type Locale +} from 'ckeditor5/src/utils'; + +import { Plugin } from 'ckeditor5/src/core'; + +import { isFirstBlockOfListItem, isListItemBlock } from '../documentlist/utils/model'; +import DocumentListEditing, { + type DocumentListEditingCheckElementEvent, + type DocumentListEditingPostFixerEvent +} from '../documentlist/documentlistediting'; +import DocumentListCommand from '../documentlist/documentlistcommand'; +import CheckTodoDocumentListCommand from './checktododocumentlistcommand'; +import TodoCheckboxChangeObserver, { type ViewDocumentTodoCheckboxChangeEvent } from './todocheckboxchangeobserver'; + +const ITEM_TOGGLE_KEYSTROKE = parseKeystroke( 'Ctrl+Enter' ); + +/** + * The engine of the to-do list feature. It handles creating, editing and removing to-do lists and their items. + * + * It registers the entire functionality of the {@link module:list/documentlist/documentlistediting~DocumentListEditing list editing plugin} + * and extends it with the commands: + * + * - `'todoList'`, + * - `'checkTodoList'`, + */ +export default class TodoDocumentListEditing extends Plugin { + /** + * @inheritDoc + */ + public static get pluginName() { + return 'TodoDocumentListEditing' as const; + } + + /** + * @inheritDoc + */ + public static get requires() { + return [ DocumentListEditing ] as const; + } + + /** + * @inheritDoc + */ + public init(): void { + const editor = this.editor; + 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 ) ); + + editing.view.addObserver( TodoCheckboxChangeObserver ); + + model.schema.extend( '$listItem', { allowAttributes: 'todoListChecked' } ); + + model.schema.addAttributeCheck( ( context, attributeName ) => { + const item = context.last; + + if ( attributeName != 'todoListChecked' ) { + return; + } + + if ( !item.getAttribute( 'listItemId' ) || item.getAttribute( 'listType' ) != 'todo' ) { + return false; + } + } ); + + editor.conversion.for( 'upcast' ).add( dispatcher => { + // Upcast of to-do list item is based on a checkbox at the beginning of a
  • to keep compatibility with markdown input. + dispatcher.on( 'element:input', todoItemInputConverter() ); + + // Consume other elements that are normally generated in data downcast, so they won't get captured by GHS. + dispatcher.on( 'element:label', elementUpcastConsumingConverter( + { name: 'label', classes: 'todo-list__label' } + ) ); + dispatcher.on( 'element:label', elementUpcastConsumingConverter( + { name: 'label', classes: [ 'todo-list__label', 'todo-list__label_without-description' ] } + ) ); + dispatcher.on( 'element:span', elementUpcastConsumingConverter( + { name: 'span', classes: 'todo-list__label__description' } + ) ); + dispatcher.on( 'element:ul', attributeUpcastConsumingConverter( + { name: 'ul', classes: 'todo-list' } + ) ); + } ); + + editor.conversion.for( 'downcast' ).elementToElement( { + model: elementName, + view: ( element, { writer } ) => { + if ( isDescriptionBlock( element, documentListEditing.getListAttributeNames() ) ) { + return writer.createContainerElement( 'span', { class: 'todo-list__label__description' } ); + } + }, + converterPriority: 'highest' + } ); + + documentListEditing.registerDowncastStrategy( { + scope: 'list', + attributeName: 'listType', + + setAttributeOnDowncast( writer, value, element ) { + if ( value == 'todo' ) { + writer.addClass( 'todo-list', element ); + } else { + writer.removeClass( 'todo-list', element ); + } + } + } ); + + documentListEditing.registerDowncastStrategy( { + scope: 'itemMarker', + attributeName: 'todoListChecked', + + createElement( writer, modelElement, { dataPipeline } ) { + if ( modelElement.getAttribute( 'listType' ) != 'todo' ) { + return null; + } + + const viewElement = writer.createEmptyElement( 'input', { + type: 'checkbox', + ...( modelElement.getAttribute( 'todoListChecked' ) ? + { checked: 'checked' } : + null + ), + ...( dataPipeline ? + { disabled: 'disabled' } : + { tabindex: '-1' } + ) + } ); + + if ( dataPipeline ) { + return viewElement; + } + + return writer.createContainerElement( 'span', { contenteditable: 'false' }, viewElement ); + }, + + canWrapElement( modelElement ) { + return isDescriptionBlock( modelElement, documentListEditing.getListAttributeNames() ); + }, + + createWrapperElement( writer, modelElement, { dataPipeline } ) { + const classes = [ 'todo-list__label' ]; + + if ( !isDescriptionBlock( modelElement, documentListEditing.getListAttributeNames() ) ) { + classes.push( 'todo-list__label_without-description' ); + } + + return writer.createAttributeElement( dataPipeline ? 'label' : 'span', { + class: classes.join( ' ' ) + } ); + } + } ); + + // We need to register the model length callback for the view checkbox input because it has no mapped model element. + // The to-do list item checkbox does not use the UIElement because it would be trimmed by ViewRange#getTrimmed() + // and removing the default remove converter would not include checkbox in the range to remove. + editing.mapper.registerViewToModelLength( 'input', viewElement => { + if ( + viewElement.getAttribute( 'type' ) == 'checkbox' && + viewElement.findAncestor( { classes: 'todo-list__label' } ) + ) { + return 0; + } + + return editing.mapper.toModelElement( viewElement ) ? 1 : 0; + } ); + + // Verifies if a to-do list block requires reconversion of a first item downcasted as an item description. + documentListEditing.on( 'checkElement', ( evt, { modelElement, viewElement } ) => { + const isFirstTodoModelParagraphBlock = isDescriptionBlock( modelElement, documentListEditing.getListAttributeNames() ); + const hasViewClass = viewElement.hasClass( 'todo-list__label__description' ); + + if ( hasViewClass != isFirstTodoModelParagraphBlock ) { + evt.return = true; + evt.stop(); + } + } ); + + // Verifies if a to-do list block requires reconversion of a checkbox element + // (for example there is a new paragraph inserted as a first block of a list item). + documentListEditing.on( 'checkElement', ( evt, { modelElement, viewElement } ) => { + const isFirstTodoModelItemBlock = modelElement.getAttribute( 'listType' ) == 'todo' && isFirstBlockOfListItem( modelElement ); + + let hasViewItemMarker = false; + const viewWalker = editor.editing.view.createPositionBefore( viewElement ).getWalker( { direction: 'backward' } ); + + for ( const { item } of viewWalker ) { + if ( item.is( 'element' ) && editor.editing.mapper.toModelElement( item ) ) { + break; + } + + if ( item.is( 'element', 'input' ) && item.getAttribute( 'type' ) == 'checkbox' ) { + hasViewItemMarker = true; + } + } + + if ( hasViewItemMarker != isFirstTodoModelItemBlock ) { + evt.return = true; + evt.stop(); + } + } ); + + // Make sure that all blocks of the same list item have the same todoListChecked attribute. + documentListEditing.on( 'postFixer', ( evt, { listNodes, writer } ) => { + for ( const { node, previousNodeInList } of listNodes ) { + // This is a first item of a nested list. + if ( !previousNodeInList ) { + continue; + } + + if ( previousNodeInList.getAttribute( 'listItemId' ) != node.getAttribute( 'listItemId' ) ) { + continue; + } + + const previousHasAttribute = previousNodeInList.hasAttribute( 'todoListChecked' ); + const nodeHasAttribute = node.hasAttribute( 'todoListChecked' ); + + if ( nodeHasAttribute && !previousHasAttribute ) { + writer.removeAttribute( 'todoListChecked', node ); + evt.return = true; + } + else if ( !nodeHasAttribute && previousHasAttribute ) { + writer.setAttribute( 'todoListChecked', true, node ); + evt.return = true; + } + } + } ); + + // Make sure that todoListChecked attribute is only present for to-do list items. + model.document.registerPostFixer( writer => { + const changes = model.document.differ.getChanges(); + let wasFixed = false; + + for ( const change of changes ) { + if ( change.type == 'attribute' && change.attributeKey == 'listType' ) { + const element = change.range.start.nodeAfter!; + + if ( change.attributeOldValue == 'todo' && element.hasAttribute( 'todoListChecked' ) ) { + writer.removeAttribute( 'todoListChecked', element ); + wasFixed = true; + } + } else if ( change.type == 'insert' && change.name != '$text' ) { + for ( const { item } of writer.createRangeOn( change.position.nodeAfter! ) ) { + if ( item.is( 'element' ) && item.getAttribute( 'listType' ) != 'todo' && item.hasAttribute( 'todoListChecked' ) ) { + writer.removeAttribute( 'todoListChecked', item ); + wasFixed = true; + } + } + } + } + + return wasFixed; + } ); + + // Toggle check state of selected to-do list items on keystroke. + this.listenTo( editing.view.document, 'keydown', ( evt, data ) => { + if ( getCode( data ) === ITEM_TOGGLE_KEYSTROKE ) { + editor.execute( 'checkTodoList' ); + evt.stop(); + } + }, { priority: 'high' } ); + + // Toggle check state of a to-do list item clicked on the checkbox. + this.listenTo( editing.view.document, 'todoCheckboxChange', ( evt, data ) => { + const viewTarget = data.target; + + if ( !viewTarget || !viewTarget.is( 'element', 'input' ) ) { + return; + } + + const viewPositionAfter = editing.view.createPositionAfter( viewTarget ); + const modelPositionAfter = editing.mapper.toModelPosition( viewPositionAfter ); + const modelElement = modelPositionAfter.parent; + + if ( modelElement && isListItemBlock( modelElement ) && modelElement.getAttribute( 'listType' ) == 'todo' ) { + this._handleCheckmarkChange( modelElement ); + } + } ); + + // Jump at the start/end of the next node on right arrow key press, when selection is before the checkbox. + // + //

    Foo{}

    + //
    • Bar
    + // + // press: `->` + // + //

    Foo

    + //
    • {}Bar
    + // + this.listenTo( + editing.view.document, + 'arrowKey', + jumpOverCheckmarkOnSideArrowKeyPress( model, editor.locale ), + { context: '$text' } + ); + + // Map view positions inside the checkbox and wrappers to the position in the first block of the list item. + this.listenTo( editing.mapper, 'viewToModelPosition', ( evt, data ) => { + const viewParent = data.viewPosition.parent as ViewElement; + + const isStartOfListItem = viewParent.is( 'attributeElement', 'li' ) && data.viewPosition.offset == 0; + const isStartOfListLabel = isLabelElement( viewParent ) && data.viewPosition.offset <= 1; + + const isInInputWrapper = viewParent.is( 'element', 'span' ) && + viewParent.getAttribute( 'contenteditable' ) == 'false' && + isLabelElement( viewParent.parent ); + + if ( !isStartOfListItem && !isStartOfListLabel && !isInInputWrapper ) { + return; + } + + const nodeAfter = data.modelPosition!.nodeAfter; + + if ( nodeAfter && nodeAfter.getAttribute( 'listType' ) == 'todo' ) { + data.modelPosition = model.createPositionAt( nodeAfter, 0 ); + } + }, { priority: 'low' } ); + } + + /** + * Handles the checkbox element change, moves the selection to the corresponding model item to make it possible + * to toggle the `todoListChecked` attribute using the command, and restores the selection position. + * + * Some say it's a hack :) Moving the selection only for executing the command on a certain node and restoring it after, + * is not a clear solution. We need to design an API for using commands beyond the selection range. + * See https://github.com/ckeditor/ckeditor5/issues/1954. + */ + private _handleCheckmarkChange( listItem: Element ): void { + const editor = this.editor; + const model = editor.model; + const previousSelectionRanges = Array.from( model.document.selection.getRanges() ); + + model.change( writer => { + writer.setSelection( listItem, 'end' ); + editor.execute( 'checkTodoList' ); + writer.setSelection( previousSelectionRanges ); + } ); + } +} + +/** + * Returns an upcast converter that detects a to-do list checkbox and marks the list item as a to-do list. + */ +function todoItemInputConverter(): GetCallback { + return ( evt, data, conversionApi ) => { + const modelCursor = data.modelCursor; + const modelItem = modelCursor.parent as Element; + const viewItem = data.viewItem; + + if ( !conversionApi.consumable.test( viewItem, { name: true } ) ) { + return; + } + + if ( viewItem.getAttribute( 'type' ) != 'checkbox' || !modelCursor.isAtStart || !modelItem.hasAttribute( 'listType' ) ) { + return; + } + + conversionApi.consumable.consume( viewItem, { name: true } ); + + const writer = conversionApi.writer; + + writer.setAttribute( 'listType', 'todo', modelItem ); + + if ( data.viewItem.hasAttribute( 'checked' ) ) { + writer.setAttribute( 'todoListChecked', true, modelItem ); + } + + data.modelRange = writer.createRange( modelCursor ); + }; +} + +/** + * Returns an upcast converter that consumes element matching the given matcher pattern. + */ +function elementUpcastConsumingConverter( matcherPattern: MatcherPattern ): GetCallback { + const matcher = new Matcher( matcherPattern ); + + return ( evt, data, conversionApi ) => { + const matcherResult = matcher.match( data.viewItem ); + + if ( !matcherResult ) { + return; + } + + if ( !conversionApi.consumable.consume( data.viewItem, matcherResult.match ) ) { + return; + } + + Object.assign( data, conversionApi.convertChildren( data.viewItem, data.modelCursor ) ); + }; +} + +/** + * Returns an upcast converter that consumes attributes matching the given matcher pattern. + */ +function attributeUpcastConsumingConverter( matcherPattern: MatcherPattern ): GetCallback { + const matcher = new Matcher( matcherPattern ); + + return ( evt, data, conversionApi ) => { + const matcherResult = matcher.match( data.viewItem ); + + if ( !matcherResult ) { + return; + } + + const match = matcherResult.match; + + match.name = false; + conversionApi.consumable.consume( data.viewItem, match ); + }; +} + +/** + * 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' ) || modelElement.is( 'element', 'listItem' ) ) && + modelElement.getAttribute( 'listType' ) == 'todo' && + isFirstBlockOfListItem( modelElement ) && + hasOnlyListAttributes( modelElement, listAttributeNames ); +} + +/** + * Returns true if only attributes from the given list are present on the model element. + */ +function hasOnlyListAttributes( modelElement: Element, attributeNames: Array ): boolean { + for ( const attributeKey of modelElement.getAttributeKeys() ) { + // Ignore selection attributes stored on block elements. + if ( attributeKey.startsWith( 'selection:' ) ) { + continue; + } + + if ( !attributeNames.includes( attributeKey ) ) { + return false; + } + } + + return true; +} + +/** + * Jump at the start and end of a to-do list item. + */ +function jumpOverCheckmarkOnSideArrowKeyPress( model: Model, locale: Locale ): GetCallback { + return ( eventInfo, domEventData ) => { + const direction = getLocalizedArrowKeyCodeDirection( domEventData.keyCode, locale.contentLanguageDirection ); + + const schema = model.schema; + const selection = model.document.selection; + + if ( !selection.isCollapsed ) { + return; + } + + const position = selection.getFirstPosition()!; + const parent = position.parent as Element; + + // Right arrow before a to-do list item. + if ( direction == 'right' && position.isAtEnd ) { + const newRange = schema.getNearestSelectionRange( model.createPositionAfter( parent ), 'forward' ); + + if ( !newRange ) { + return; + } + + const newRangeParent = newRange.start.parent; + + if ( newRangeParent && isListItemBlock( newRangeParent ) && newRangeParent.getAttribute( 'listType' ) == 'todo' ) { + model.change( writer => writer.setSelection( newRange ) ); + + domEventData.preventDefault(); + domEventData.stopPropagation(); + eventInfo.stop(); + } + } + // Left arrow at the beginning of a to-do list item. + else if ( direction == 'left' && position.isAtStart && isListItemBlock( parent ) && parent.getAttribute( 'listType' ) == 'todo' ) { + const newRange = schema.getNearestSelectionRange( model.createPositionBefore( parent ), 'backward' ); + + if ( !newRange ) { + return; + } + + model.change( writer => writer.setSelection( newRange ) ); + + domEventData.preventDefault(); + domEventData.stopPropagation(); + eventInfo.stop(); + } + }; +} + +/** + * Returns true if the given element is a label element of a to-do list item. + */ +function isLabelElement( viewElement: ViewElement | ViewDocumentFragment | null ): boolean { + return !!viewElement && viewElement.is( 'attributeElement' ) && viewElement.hasClass( 'todo-list__label' ); +} 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 e3cbc42c3cf..75e82280dac 100644 --- a/packages/ckeditor5-list/tests/documentlist/_utils/utils.js +++ b/packages/ckeditor5-list/tests/documentlist/_utils/utils.js @@ -116,10 +116,10 @@ export function setupTestHelpers( editor ) { test.test( input, output, actionCallback ); }, - changeType( input, output ) { + changeType( input, output, type ) { const actionCallback = selection => { const element = selection.getFirstPosition().nodeAfter; - const newType = element.getAttribute( 'listType' ) == 'numbered' ? 'bulleted' : 'numbered'; + const newType = type || ( element.getAttribute( 'listType' ) == 'numbered' ? 'bulleted' : 'numbered' ); model.change( writer => { const itemsToChange = Array.from( selection.getSelectedBlocks() ); @@ -159,12 +159,15 @@ export function setupTestHelpers( editor ) { test.test( input, output, actionCallback, testUndo ); }, - setListAttributes( newIndent, input, output ) { + setListAttributes( newIndentOrType, input, output ) { + const newIndent = typeof newIndentOrType == 'number' ? newIndentOrType : 0; + const newType = typeof newIndentOrType == 'string' ? newIndentOrType : 'bulleted'; + const actionCallback = selection => { const element = selection.getFirstPosition().nodeAfter; model.change( writer => { - writer.setAttributes( { listType: 'bulleted', listIndent: newIndent, listItemId: 'x' }, element ); + writer.setAttributes( { listType: newType, listIndent: newIndent, listItemId: 'x' }, element ); } ); }; @@ -309,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. * @@ -346,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 ); } @@ -366,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/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/converters-data.js b/packages/ckeditor5-list/tests/documentlist/converters-data.js index ffa0bed9e79..45b096a030d 100644 --- a/packages/ckeditor5-list/tests/documentlist/converters-data.js +++ b/packages/ckeditor5-list/tests/documentlist/converters-data.js @@ -2506,4 +2506,88 @@ describe( 'DocumentListEditing - converters - data pipeline', () => { } ); } ); } ); + + 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/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 ) + ] ); + } ); + } ); + } ); + } ); + } ); +} ); 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..98a21d7ec00 --- /dev/null +++ b/packages/ckeditor5-list/tests/documentlist/documentlistediting-single-block.js @@ -0,0 +1,401 @@ +/** + * @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 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, 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'; + +import stubUid from './_utils/uid'; +import { prepareTest } from './_utils/utils'; + +describe( 'DocumentListEditing (multiBlock=false)', () => { + let editor, model, view; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + editor = await VirtualTestEditor.create( { + list: { + multiBlock: false + }, + plugins: [ Paragraph, ClipboardPipeline, BoldEditing, DocumentListEditing, UndoEditing, + BlockQuoteEditing, TableEditing, HeadingEditing, AlignmentEditing ] + } ); + + model = editor.model; + 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', () => { + function testList( input, inserted, output ) { + const selection = prepareTest( model, input ); + + model.change( () => { + model.change( writer => { + writer.insert( parseModel( inserted, model.schema ), selection.getFirstPosition() ); + } ); + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equal( output ); + } + + it( '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', () => { + 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' + + 'c' + + 'd' + + 'e' + + 'f' + + 'g' + + 'h' + + 'i'; + + const selection = prepareTest( model, modelBefore ); + const element = selection.getFirstPosition().nodeAfter; + + model.change( writer => { + writer.removeAttribute( 'listItemId', element ); + writer.removeAttribute( 'listIndent', element ); + writer.removeAttribute( 'listType', element ); + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( expectedModel ); + } ); + + it( 'add list attributes', () => { + const modelBefore = + 'a' + + 'b' + + '[c]' + + 'd' + + 'e' + + 'f' + + 'g'; + + const expectedModel = + 'a' + + 'b' + + 'c' + + 'd' + + 'e' + + 'f' + + 'g'; + + const selection = prepareTest( model, modelBefore ); + const element = selection.getFirstPosition().nodeAfter; + + model.change( writer => { + writer.setAttribute( 'listItemId', 'c', element ); + writer.setAttribute( 'listIndent', 2, element ); + writer.setAttribute( 'listType', 'bulleted', element ); + writer.setAttribute( 'listIndent', 2, element.nextSibling ); + } ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( expectedModel ); + } ); + } ); + } ); + + 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
      • ' + + '
      ' + ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-list/tests/documentlist/documentlistediting.js b/packages/ckeditor5-list/tests/documentlist/documentlistediting.js index a03eb25ebec..5c2e2a4f9dc 100644 --- a/packages/ckeditor5-list/tests/documentlist/documentlistediting.js +++ b/packages/ckeditor5-list/tests/documentlist/documentlistediting.js @@ -24,6 +24,7 @@ import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils import ListEditing from '../../src/list/listediting'; import DocumentListIndentCommand from '../../src/documentlist/documentlistindentcommand'; import DocumentListSplitCommand from '../../src/documentlist/documentlistsplitcommand'; +import { isFirstBlockOfListItem } from '../../src/documentlist/utils/model'; import stubUid from './_utils/uid'; import { modelList, prepareTest } from './_utils/utils'; @@ -75,6 +76,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 +97,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; @@ -782,9 +792,219 @@ describe( 'DocumentListEditing - registerDowncastStrategy()', () => { ); } ); + describe( 'should allow registering strategy for list items markers', () => { + describe( 'without first block wrapper', () => { + beforeEach( async () => { + await createEditor( class CustomPlugin extends Plugin { + init() { + this.editor.plugins.get( 'DocumentListEditing' ).registerDowncastStrategy( { + scope: 'itemMarker', + attributeName: 'someFoo', + + createElement( writer, modelElement, { dataPipeline } ) { + return writer.createEmptyElement( 'input', { + type: 'checkbox', + value: modelElement.getAttribute( 'someFoo' ), + ...( dataPipeline ? { disabled: 'disabled' } : null ) + } ); + } + } ); + } + } ); + } ); + + it( 'single block in a list item', () => { + setModelData( model, modelList( ` + * foo + * bar + ` ) ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
        ' + + '
      • foo
      • ' + + '
      • bar
      • ' + + '
      ' + ); + + expect( editor.getData() ).to.equalMarkup( + '
        ' + + '
      • foo
      • ' + + '
      • bar
      • ' + + '
      ' + ); + } ); + + it( 'multiple blocks in a single list item', () => { + setModelData( model, modelList( ` + * foo + bar + ` ) ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
        ' + + '
      • ' + + '' + + '

        foo

        ' + + '

        bar

        ' + + '
      • ' + + '
      ' + ); + + expect( editor.getData() ).to.equalMarkup( + '
        ' + + '
      • ' + + '' + + '

        foo

        ' + + '

        bar

        ' + + '
      • ' + + '
      ' + ); + } ); + } ); + + describe( 'with first block wrapper', () => { + beforeEach( async () => { + await createEditor( class CustomPlugin extends Plugin { + init() { + this.editor.plugins.get( 'DocumentListEditing' ).registerDowncastStrategy( { + scope: 'itemMarker', + attributeName: 'someFoo', + + createElement( writer, modelElement, { dataPipeline } ) { + return writer.createEmptyElement( 'input', { + type: 'checkbox', + value: modelElement.getAttribute( 'someFoo' ), + ...( dataPipeline ? { disabled: 'disabled' } : null ) + } ); + }, + + canWrapElement( modelElement ) { + return isFirstBlockOfListItem( modelElement ) && modelElement.is( 'element', 'paragraph' ); + }, + + createWrapperElement( writer, modelElement, { dataPipeline } ) { + return writer.createAttributeElement( dataPipeline ? 'label' : 'span', { class: 'label' } ); + } + } ); + + this.editor.conversion.for( 'downcast' ).elementToElement( { + model: 'paragraph', + view: ( element, { writer } ) => { + if ( isFirstBlockOfListItem( element ) ) { + return writer.createContainerElement( 'span', { class: 'description' } ); + } + }, + converterPriority: 'highest' + } ); + } + } ); + } ); + + it( 'single block in a list item', () => { + setModelData( model, modelList( ` + * foo + * bar + ` ) ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
        ' + + '
      • ' + + '' + + '' + + 'foo' + + '' + + '
      • ' + + '
      • ' + + '' + + '' + + 'bar' + + '' + + '
      • ' + + '
      ' + ); + + expect( editor.getData() ).to.equalMarkup( + '
        ' + + '
      • ' + + '' + + '
      • ' + + '
      • ' + + '' + + '
      • ' + + '
      ' + ); + } ); + + it( 'multiple blocks in a single list item', () => { + setModelData( model, modelList( ` + * foo + bar + ` ) ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
        ' + + '
      • ' + + '' + + '' + + 'foo' + + '' + + '

        bar

        ' + + '
      • ' + + '
      ' + ); + + expect( editor.getData() ).to.equalMarkup( + '
        ' + + '
      • ' + + '' + + '

        bar

        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'single block (non paragraph) in a list item', () => { + setModelData( model, modelList( ` + * foo + ` ) ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
        ' + + '
      • ' + + '' + + '' + + '' + + '

        foo

        ' + + '
      • ' + + '
      ' + ); + + expect( editor.getData() ).to.equalMarkup( + '
        ' + + '
      • ' + + '' + + '

        foo

        ' + + '
      • ' + + '
      ' + ); + } ); + } ); + } ); + async function createEditor( extraPlugin ) { editor = await VirtualTestEditor.create( { - plugins: [ extraPlugin, Paragraph, DocumentListEditing, UndoEditing ] + plugins: [ extraPlugin, Paragraph, DocumentListEditing, UndoEditing, HeadingEditing ] } ); model = editor.model; 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' ); + } +} ); 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 } ); } ); diff --git a/packages/ckeditor5-list/tests/documentlistproperties/documentlistreversedcommand.js b/packages/ckeditor5-list/tests/documentlistproperties/documentlistreversedcommand.js index 9088387228a..a644a21b86d 100644 --- a/packages/ckeditor5-list/tests/documentlistproperties/documentlistreversedcommand.js +++ b/packages/ckeditor5-list/tests/documentlistproperties/documentlistreversedcommand.js @@ -63,6 +63,12 @@ describe( 'DocumentListReversedCommand', () => { expect( listReversedCommand.isEnabled ).to.be.false; } ); + it( 'should be false if selection is inside a to-do list item', () => { + setData( model, 'foo[]' ); + + expect( listReversedCommand.isEnabled ).to.be.false; + } ); + it( 'should be true if selection is inside a listItem (collapsed selection)', () => { setData( model, modelList( [ '# Foo[] {reversed:true}' ] ) ); diff --git a/packages/ckeditor5-list/tests/documentlistproperties/documentliststartcommand.js b/packages/ckeditor5-list/tests/documentlistproperties/documentliststartcommand.js index 5c588af7095..08ce81aa31a 100644 --- a/packages/ckeditor5-list/tests/documentlistproperties/documentliststartcommand.js +++ b/packages/ckeditor5-list/tests/documentlistproperties/documentliststartcommand.js @@ -63,6 +63,12 @@ describe( 'DocumentListStartCommand', () => { expect( listStartCommand.isEnabled ).to.be.false; } ); + it( 'should be false if selection is inside a listItem (listType: todo)', () => { + setData( model, 'foo[]' ); + + expect( listStartCommand.isEnabled ).to.be.false; + } ); + it( 'should be true if selection is inside a listItem (collapsed selection)', () => { setData( model, modelList( [ '# Foo[] {start:2}' ] ) ); diff --git a/packages/ckeditor5-list/tests/documentlistproperties/documentliststylecommand.js b/packages/ckeditor5-list/tests/documentlistproperties/documentliststylecommand.js index 6fac94e7684..0a8120ec89e 100644 --- a/packages/ckeditor5-list/tests/documentlistproperties/documentliststylecommand.js +++ b/packages/ckeditor5-list/tests/documentlistproperties/documentliststylecommand.js @@ -75,6 +75,14 @@ describe( 'DocumentListStyleCommand', () => { expect( listStyleCommand.isEnabled ).to.equal( false ); } ); + + it( 'should be true if selection is inside a to-do list item', () => { + setData( model, 'foo[]' ); + + listStyleCommand.refresh(); + + expect( listStyleCommand.isEnabled ).to.be.true; + } ); } ); describe( '#value', () => { 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..903a1ba3810 --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/documentlist-simple.html @@ -0,0 +1,32 @@ +
      +

      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
      • +
      +
      + +

      Editor content preview

      +
      + + 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..a5e05d02cf5 --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/documentlist-simple.js @@ -0,0 +1,65 @@ +/** + * @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 { 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'; + +ClassicEditor + .create( document.querySelector( '#editor' ), { + plugins: [ + Enter, Typing, Heading, Paragraph, Undo, DocumentList, TodoDocumentList, Indent, Clipboard, Alignment, SourceEditing, + GeneralHtmlSupport, Autoformat + ], + 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 ); + } ); 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..ea316d4174d --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/documentlist-simple.md @@ -0,0 +1,9 @@ +### 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. diff --git a/packages/ckeditor5-list/tests/manual/todo-documentlist-rtl.html b/packages/ckeditor5-list/tests/manual/todo-documentlist-rtl.html new file mode 100644 index 00000000000..303a2b50a1c --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/todo-documentlist-rtl.html @@ -0,0 +1,35 @@ +

      Editor

      +
      +

      مرحبا

      +
        +
      • مرحبا
      • +
      • مرحبا
      • +
      • مرحبا
      • +
      • مرحبا
      • +
      • مرحبا
      • +
      • مرحبا
      • +
      • مرحبا
      • +
      • مرحبا
      • +
      +

      مرحبا.

      +

      مرحبا

      +
        +
      1. مرحبا
      2. +
      +
        +
      • مرحبا
      • +
      +
        +
      • مرحبا
      • +
      +
      + +

      Editor content preview

      +
      + + diff --git a/packages/ckeditor5-list/tests/manual/todo-documentlist-rtl.js b/packages/ckeditor5-list/tests/manual/todo-documentlist-rtl.js new file mode 100644 index 00000000000..84c0d516386 --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/todo-documentlist-rtl.js @@ -0,0 +1,79 @@ +/** + * @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 } 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 { Alignment } from '@ckeditor/ckeditor5-alignment'; + +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, + Alignment + ], + language: 'ar', + toolbar: [ + 'heading', + '|', + 'bulletedList', 'numberedList', 'todoList', 'outdent', 'indent', + '|', + 'bold', 'link', 'insertTable', 'fontSize', 'alignment', + '|', + 'undo', 'redo', '|', 'sourceEditing' + ], + table: { + contentToolbar: [ + 'tableColumn', + 'tableRow', + 'mergeTableCells' + ] + } + } ) + .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-rtl.md b/packages/ckeditor5-list/tests/manual/todo-documentlist-rtl.md new file mode 100644 index 00000000000..e69de29bb2d 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 diff --git a/packages/ckeditor5-list/tests/manual/todo-documentlist.html b/packages/ckeditor5-list/tests/manual/todo-documentlist.html new file mode 100644 index 00000000000..e36503201f3 --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/todo-documentlist.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.js b/packages/ckeditor5-list/tests/manual/todo-documentlist.js new file mode 100644 index 00000000000..68d9384abe9 --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/todo-documentlist.js @@ -0,0 +1,97 @@ +/** + * @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' + ] + }, + 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.md b/packages/ckeditor5-list/tests/manual/todo-documentlist.md new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/ckeditor5-list/tests/tododocumentlist.js b/packages/ckeditor5-list/tests/tododocumentlist.js new file mode 100644 index 00000000000..c8a4df15897 --- /dev/null +++ b/packages/ckeditor5-list/tests/tododocumentlist.js @@ -0,0 +1,18 @@ +/** + * @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 TodoDocumentList from '../src/tododocumentlist'; +import TodoDocumentListEditing from '../src/tododocumentlist/tododocumentlistediting'; +import TodoListUI from '../src/todolist/todolistui'; + +describe( 'TodoDocumentList', () => { + it( 'should be named', () => { + expect( TodoDocumentList.pluginName ).to.equal( 'TodoDocumentList' ); + } ); + + it( 'should require TodoDocumentListEditing and TodoListUI', () => { + expect( TodoDocumentList.requires ).to.deep.equal( [ TodoDocumentListEditing, TodoListUI ] ); + } ); +} ); diff --git a/packages/ckeditor5-list/tests/tododocumentlist/checktododocumentlistcommand.js b/packages/ckeditor5-list/tests/tododocumentlist/checktododocumentlistcommand.js new file mode 100644 index 00000000000..4b46eb0cf89 --- /dev/null +++ b/packages/ckeditor5-list/tests/tododocumentlist/checktododocumentlistcommand.js @@ -0,0 +1,417 @@ +/** + * @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 HeadingEditing from '@ckeditor/ckeditor5-heading/src/headingediting'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; + +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; + +import TodoDocumentListEditing from '../../src/tododocumentlist/tododocumentlistediting'; +import CheckTodoDocumentListCommand from '../../src/tododocumentlist/checktododocumentlistcommand'; + +describe( 'CheckTodoListCommand', () => { + let editor, model, command; + + beforeEach( () => { + return VirtualTestEditor + .create( { + plugins: [ Paragraph, HeadingEditing, TodoDocumentListEditing ] + } ) + .then( newEditor => { + editor = newEditor; + model = editor.model; + command = new CheckTodoDocumentListCommand( editor ); + } ); + } ); + + afterEach( () => { + command.destroy(); + + return editor.destroy(); + } ); + + describe( 'isEnabled', () => { + it( 'should be enabled when collapsed selection is inside to-do list item', () => { + setModelData( model, 'f[]oo' ); + + expect( command.isEnabled ).to.equal( true ); + } ); + + it( 'should be enabled when item is already checked', () => { + setModelData( model, 'f[]oo' ); + + expect( command.isEnabled ).to.equal( true ); + } ); + + it( 'should be enabled when non-collapsed selection is inside to-do list item', () => { + setModelData( model, 'f[o]o' ); + + expect( command.isEnabled ).to.equal( true ); + } ); + + it( 'should be disabled when selection is not inside to-do list item', () => { + setModelData( model, 'f[]oo' ); + + expect( command.isEnabled ).to.equal( false ); + } ); + + it( 'should be enabled when at least one to-do list item is selected', () => { + setModelData( model, + 'f[oo' + + 'bar' + + 'ba]z' + ); + + expect( command.isEnabled ).to.equal( true ); + } ); + + it( 'should be disabled when no to-do list item is selected', () => { + setModelData( model, + 'foo' + + 'b[ar' + + 'baz' + + 'b]ax' + ); + + expect( command.isEnabled ).to.equal( false ); + } ); + + it( 'should be enabled when a to-do list item is selected together with other list items', () => { + setModelData( model, + 'fo[o' + + 'bar' + + 'b]az' + ); + + expect( command.isEnabled ).to.equal( true ); + } ); + + it( 'should be enabled when a to-do list item is selected together with other list items in nested list', () => { + setModelData( model, + 'fo[o' + + 'bar' + + 'b]az' + ); + + expect( command.isEnabled ).to.equal( true ); + } ); + + it( 'should be enabled when selection is in paragraph in list item', () => { + setModelData( model, + 'foo' + + 'b[]ar' + ); + + expect( command.isEnabled ).to.equal( true ); + } ); + + it( 'should be enabled when selection is in heading as a first child of list item', () => { + setModelData( model, + 'f[]oo' + + 'bar' + ); + + expect( command.isEnabled ).to.equal( true ); + } ); + + it( 'should be enabled when selection is in heading as a second child of list item', () => { + setModelData( model, + 'bar' + + 'f[]oo' + ); + + expect( command.isEnabled ).to.equal( true ); + } ); + } ); + + describe( 'value', () => { + it( 'should be false when collapsed selection is in not checked element', () => { + setModelData( model, 'f[]oo' ); + + expect( command.value ).to.equal( false ); + } ); + + it( 'should be false when non-collapsed selection is in not checked element', () => { + setModelData( model, 'f[o]o' ); + + expect( command.value ).to.equal( false ); + } ); + + it( 'should be true when collapsed selection is in checked element', () => { + setModelData( model, 'f[]oo' ); + + expect( command.value ).to.equal( true ); + } ); + + it( 'should be true when non-collapsed selection is in checked element', () => { + setModelData( model, 'f[o]o' ); + + expect( command.value ).to.equal( true ); + } ); + + it( 'should be false when at least one selected element is not checked', () => { + setModelData( model, + 'f[oo' + + 'bar' + + 'b]az' + ); + + expect( command.value ).to.equal( false ); + } ); + + it( 'should be true when all selected elements are checked', () => { + setModelData( model, + 'f[oo' + + 'bar' + + 'b]az' + ); + + expect( command.value ).to.equal( true ); + } ); + + it( 'should be true when a checked to-do list items are selected together with other list items', () => { + setModelData( model, + 'fo[o' + + 'bar' + + 'b]az' + ); + + expect( command.value ).to.equal( true ); + } ); + + it( 'should be true when a to-do list item is selected together with other list items in nested list', () => { + setModelData( model, + 'fo[o' + + 'bar' + + 'b]az' + ); + + expect( command.value ).to.equal( true ); + } ); + + it( 'should be true when selection is in paragraph', () => { + setModelData( model, + 'foo' + + 'b[]ar' + ); + + expect( command.value ).to.equal( true ); + } ); + + it( 'should be true when selection is in heading as a first child of checkked list item', () => { + setModelData( model, + 'f[]oo' + + 'bar' + ); + + expect( command.value ).to.equal( true ); + } ); + + it( 'should be true when selection is in heading as a second child of checkked list item', () => { + setModelData( model, + 'bar' + + 'f[]oo' + ); + + expect( command.value ).to.equal( true ); + } ); + } ); + + describe( 'execute()', () => { + it( 'should toggle checked state on to-do list item when collapsed selection is inside this item', () => { + testCommandToggle( + 'f[]oo', + 'f[]oo' + ); + } ); + + it( 'should toggle checked state on to-do list item when non-collapsed selection is inside this item', () => { + testCommandToggle( + 'f[o]o', + 'f[o]o' + ); + } ); + + it( 'should toggle state on multiple items', () => { + testCommandToggle( + 'foo[' + + 'bar' + + ']baz', + + 'foo[' + + 'bar' + + ']baz' + ); + } ); + + it( 'should toggle state on multiple items in nested list', () => { + testCommandToggle( + 'foo[' + + 'bar' + + ']baz', + + 'foo[' + + 'bar' + + ']baz' + ); + } ); + + it( 'should toggle state on multiple items mixed with none to-do list items', () => { + testCommandToggle( + 'a[bc' + + 'foo' + + 'bar' + + 'baz' + + 'xy]z', + + 'a[bc' + + 'foo' + + 'bar' + + 'baz' + + 'xy]z' + ); + } ); + + it( 'should toggle state on multiple items mixed with none to-do list items in nested list', () => { + testCommandToggle( + 'a[bc' + + 'foo' + + 'bar' + + 'baz' + + 'xy]z', + + 'a[bc' + + 'foo' + + 'bar' + + 'baz' + + 'xy]z' + ); + } ); + + it( 'should toggle state on items if selection is in paragraph', () => { + testCommandToggle( + 'foo' + + 'b[]ar', + + 'foo' + + 'b[]ar' + ); + } ); + + it( 'should toggle state items at the same level if selection is in paragraph', () => { + testCommandToggle( + 'foo' + + 'bar' + + 'b[]az', + + 'foo' + + 'bar' + + 'b[]az' + ); + } ); + + it( 'should toggle state items when selection is in heading as a first child of list item', () => { + testCommandToggle( + 'f[]oo' + + 'bar', + 'f[]oo' + + 'bar' + ); + } ); + + it( 'should toggle state items when selection is in heading as a second child of list item', () => { + testCommandToggle( + 'bar' + + 'f[]oo', + 'bar' + + 'f[]oo' + ); + } ); + + it( 'should toggle state items when selection is in heading as a second child of nested list item', () => { + testCommandToggle( + 'foo' + + 'bar' + + 'b[]az', + 'foo' + + 'bar' + + 'b[]az' + ); + } ); + + it( 'should mark all selected items as checked when at least one selected item is not checked', () => { + setModelData( model, + 'foo[' + + 'bar' + + ']baz' + ); + + command.execute(); + + expect( getModelData( model ) ).to.equal( + 'foo[' + + 'bar' + + ']baz' + ); + } ); + + it( 'should mark all selected items as checked when at least one selected item is not checked in nested list', () => { + setModelData( model, + 'foo[' + + 'bar' + + ']baz' + ); + + command.execute(); + + expect( getModelData( model ) ).to.equal( + 'foo[' + + 'bar' + + ']baz' + ); + } ); + + it( 'should do nothing when there are no elements to toggle attribute', () => { + setModelData( model, 'b[]ar' ); + + command.execute(); + + expect( getModelData( model ) ).to.equal( 'b[]ar' ); + } ); + + it( 'should set attribute if `forceValue` parameter is set to `true`', () => { + setModelData( model, 'f[]oo' ); + + command.execute( { forceValue: true } ); + + expect( getModelData( model ) ).to.equal( + 'f[]oo' + ); + } ); + + it( 'should remove attribute if `forceValue` parameter is set to `false`', () => { + setModelData( model, 'f[]oo' ); + + command.execute( { forceValue: false } ); + + expect( getModelData( model ) ).to.equal( + 'f[]oo' + ); + } ); + + function testCommandToggle( initialData, changedData ) { + setModelData( model, initialData ); + + command.execute(); + + expect( getModelData( model ) ).to.equal( changedData ); + + command.execute(); + + expect( getModelData( model ) ).to.equal( initialData ); + } + } ); +} ); diff --git a/packages/ckeditor5-list/tests/tododocumentlist/todocheckboxchangeobserver.js b/packages/ckeditor5-list/tests/tododocumentlist/todocheckboxchangeobserver.js new file mode 100644 index 00000000000..f0e22889983 --- /dev/null +++ b/packages/ckeditor5-list/tests/tododocumentlist/todocheckboxchangeobserver.js @@ -0,0 +1,112 @@ +/** + * @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 View from '@ckeditor/ckeditor5-engine/src/view/view'; +import DomEventObserver from '@ckeditor/ckeditor5-engine/src/view/observer/domeventobserver'; +import createViewRoot from '@ckeditor/ckeditor5-engine/tests/view/_utils/createroot'; +import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; + +import TodoCheckboxChangeObserver from '../../src/tododocumentlist/todocheckboxchangeobserver'; + +describe( 'TodoCheckboxChangeObserver', () => { + let view, viewDocument, observer, domRoot; + + beforeEach( () => { + domRoot = document.createElement( 'div' ); + view = new View(); + viewDocument = view.document; + createViewRoot( viewDocument ); + view.attachDomRoot( domRoot ); + observer = view.addObserver( TodoCheckboxChangeObserver ); + } ); + + afterEach( () => { + view.destroy(); + } ); + + it( 'should extend DomEventObserver', () => { + expect( observer ).instanceof( DomEventObserver ); + } ); + + it( 'should define domEventType', () => { + expect( observer.domEventType ).to.deep.equal( [ 'change' ] ); + } ); + + it( 'should fire `todoCheckboxChange` for a checkbox in a span with "todo-list__label" class', () => { + const spy = sinon.spy(); + + viewDocument.on( 'todoCheckboxChange', spy ); + + setData( view, + '' + + '' + + '' + + '' + + '' + ); + + sinon.assert.notCalled( spy ); + + observer.onDomEvent( { type: 'change', target: domRoot.querySelector( 'input' ) } ); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'should not fire `todoCheckboxChange` for an input without type checkbox', () => { + const spy = sinon.spy(); + + viewDocument.on( 'todoCheckboxChange', spy ); + + setData( view, + '' + + '' + + '' + + '' + + '' + ); + + observer.onDomEvent( { type: 'change', target: domRoot.querySelector( 'input' ) } ); + + sinon.assert.notCalled( spy ); + } ); + + it( 'should not fire `todoCheckboxChange` for a checkbox in a span without a class', () => { + const spy = sinon.spy(); + + viewDocument.on( 'todoCheckboxChange', spy ); + + setData( view, + '' + + '' + + '' + + '' + + '' + ); + + observer.onDomEvent( { type: 'change', target: domRoot.querySelector( 'input' ) } ); + + sinon.assert.notCalled( spy ); + } ); + + it( 'should not fire `todoCheckboxChange` for a span in a span with "todo-list__label" class', () => { + const spy = sinon.spy(); + + viewDocument.on( 'todoCheckboxChange', spy ); + + setData( view, + '' + + '' + + '' + + '' + + '' + ); + + observer.onDomEvent( { type: 'change', target: domRoot.querySelector( 'span[contenteditable=false]' ) } ); + + sinon.assert.notCalled( spy ); + } ); +} ); diff --git a/packages/ckeditor5-list/tests/tododocumentlist/tododocumentlistediting-conversion-changes.js b/packages/ckeditor5-list/tests/tododocumentlist/tododocumentlistediting-conversion-changes.js new file mode 100644 index 00000000000..93f0c546838 --- /dev/null +++ b/packages/ckeditor5-list/tests/tododocumentlist/tododocumentlistediting-conversion-changes.js @@ -0,0 +1,3420 @@ +/** + * @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 BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting'; +import HeadingEditing from '@ckeditor/ckeditor5-heading/src/headingediting'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import TableEditing from '@ckeditor/ckeditor5-table/src/tableediting'; +import { UndoEditing } from '@ckeditor/ckeditor5-undo'; +import { CodeBlockEditing } from '@ckeditor/ckeditor5-code-block'; + +import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; +import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; + +import TodoDocumentListEditing from '../../src/tododocumentlist/tododocumentlistediting'; +import { setupTestHelpers } from '../documentlist/_utils/utils'; + +import stubUid from '../documentlist/_utils/uid'; + +describe( 'TodoDocumentListEditing - conversion - changes', () => { + let editor, model, test, modelRoot; + + testUtils.createSinonSandbox(); + + beforeEach( async () => { + editor = await VirtualTestEditor.create( { + plugins: [ Paragraph, TodoDocumentListEditing, BlockQuoteEditing, TableEditing, CodeBlockEditing, HeadingEditing, UndoEditing ] + } ); + + model = editor.model; + modelRoot = model.document.getRoot(); + + stubUid(); + + test = setupTestHelpers( editor ); + } ); + + afterEach( () => { + return editor.destroy(); + } ); + + describe( 'flat lists', () => { + describe( 'insert', () => { + it( 'list item at the beginning of same list type', () => { + test.insert( + 'p' + + '[x]' + + 'a', + + '

      p

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

      p

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

      p

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

      p

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

      p

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

      p

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

      x

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

      p

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

        x

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

      p

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

        b

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

      p

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

        b

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

      p

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

        x

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

      p

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

        x

        ' + + '

        x2

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

      p

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

      p

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

      p

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

      p

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

      p

      ' + + '

      p

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

      p

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

      p

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

      p

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

      p

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

      p

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

      p

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

      p

      ' + + '
        ' + + '
      • ' + + '' + + '' + + 'a1' + + '' + + '

        a3

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

      p

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

      p

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

      p

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

      p

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

      p

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

        a

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

        b

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

        b

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

        b1

        ' + + '

        b2

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

        b2

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

        b2

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

        b2

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

      a

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

      b

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

      b

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

      p

      ' + + '

      x

      ' + + '

      p

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

      a

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

      a1

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

      a2

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

      a2

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

      x

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

      x

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

        a

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

        x

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

      p

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

      p

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

      p

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

      p

      ' + + '

      p

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

      p

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

      p

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

      p

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

      p

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

      p

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

        1b

        ' + + '
          ' + + '
        • ' + + '' + + '' + + 'xa' + + '' + + '

          xb

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

      p

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

      p

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

        1b

        ' + + '
          ' + + '
        • ' + + '' + + '' + + 'xa' + + '' + + '

          xb

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

          1.1b

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

      p

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

      p

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

        1b

        ' + + '
          ' + + '
        • ' + + '' + + '' + + 'xa' + + '' + + '

          xb

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

        2b

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

      p

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

      p

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

        1b

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

          1.1b

          ' + + '
        • ' + + '
        • ' + + '' + + '' + + 'xa' + + '' + + '

          xb

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

      p

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

      p

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

        1b

        ' + + '
      • ' + + '
      • ' + + '' + + '' + + 'xa' + + '' + + '

        xb

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

          1.1b

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

      p

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

      p

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

          1.1

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

        x

        ' + + '
          ' + + '
        • ' + + '' + + '' + + '1.2' + + '' + + '

          1.2

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

      p

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

        x

        ' + + '
          ' + + '
        • ' + + '' + + '' + + '2' + + '' + + '
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + + expect( test.reconvertSpy.callCount ).to.equal( 1 ); + expect( test.reconvertSpy.firstCall.firstArg ).to.equal( modelRoot.getChild( 1 ) ); + } ); + } ); + + describe( 'remove', () => { + it( 'the first nested item', () => { + test.remove( + 'a' + + '[b]' + + 'c', + + '
        ' + + '
      • ' + + '' + + '' + + 'a' + + '' + + '
          ' + + '
        • ' + + '' + + '' + + 'c' + + '' + + '
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'the last nested item', () => { + test.remove( + 'a' + + 'b' + + '[c]', + + '
        ' + + '
      • ' + + '' + + '' + + 'a' + + '' + + '
          ' + + '
        • ' + + '' + + '' + + 'b' + + '' + + '
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'the only nested item', () => { + test.remove( + 'a' + + '[c]', + + '
        ' + + '
      • ' + + '' + + '' + + 'a' + + '' + + '
      • ' + + '
      ' + ); + } ); + + it( 'first list item that has nested list', () => { + test.remove( + '[a]' + + 'b' + + 'c', + + '
        ' + + '
      • ' + + '' + + '' + + 'b' + + '' + + '
          ' + + '
        • ' + + '' + + '' + + 'c' + + '' + + '
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + } ); + + describe( 'change indent', () => { + it( 'indent last item of flat list', () => { + test.changeIndent( + 1, + + 'a' + + '[b]', + + '
        ' + + '
      • ' + + '' + + '' + + 'a' + + '' + + '
          ' + + '
        • ' + + '' + + '' + + 'b' + + '' + + '
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'indent last item in nested list', () => { + test.changeIndent( + 2, + + 'a' + + 'b' + + '[c]', + + '
        ' + + '
      • ' + + '' + + '' + + 'a' + + '' + + '
          ' + + '
        • ' + + '' + + '' + + 'b' + + '' + + '
            ' + + '
          • ' + + '' + + '' + + 'c' + + '' + + '
          • ' + + '
          ' + + '
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'indent item that has nested list', () => { + test.changeIndent( + 1, + + 'a' + + '[b]' + + 'c', + + '
        ' + + '
      • ' + + '' + + '' + + 'a' + + '' + + '
          ' + + '
        • ' + + '' + + '' + + 'b' + + '' + + '
        • ' + + '
        • ' + + '' + + '' + + 'c' + + '' + + '
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'indent item that in view is a next sibling of item that has nested list', () => { + test.changeIndent( + 1, + + 'a' + + 'b' + + '[c]' + + 'd', + + '
        ' + + '
      • ' + + '' + + '' + + 'a' + + '' + + '
          ' + + '
        • ' + + '' + + '' + + 'b' + + '' + + '
        • ' + + '
        • ' + + '' + + '' + + 'c' + + '' + + '
        • ' + + '
        • ' + + '' + + '' + + 'd' + + '' + + '
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'outdent the first item of nested list', () => { + test.changeIndent( + 0, + + 'a' + + '[b]' + + 'c' + + 'd', + + '
        ' + + '
      • ' + + '' + + '' + + 'a' + + '' + + '
      • ' + + '
      • ' + + '' + + '' + + 'b' + + '' + + '
          ' + + '
        • ' + + '' + + '' + + 'c' + + '' + + '
        • ' + + '
        • ' + + '' + + '' + + 'd' + + '' + + '
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'outdent the last item of nested list', () => { + test.changeIndent( + 0, + + 'a' + + 'b' + + '[c]', + + '
        ' + + '
      • ' + + '' + + '' + + 'a' + + '' + + '
          ' + + '
        • ' + + '' + + '' + + 'b' + + '' + + '
        • ' + + '
        ' + + '
      • ' + + '
      • ' + + '' + + '' + + 'c' + + '' + + '
      • ' + + '
      ' + ); + } ); + + it( 'outdent the only item of nested list', () => { + test.changeIndent( + 1, + + 'a' + + 'b' + + '[c]' + + 'd', + + '
        ' + + '
      • ' + + '' + + '' + + 'a' + + '' + + '
          ' + + '
        • ' + + '' + + '' + + 'b' + + '' + + '
        • ' + + '
        • ' + + '' + + '' + + 'c' + + '' + + '
        • ' + + '
        • ' + + '' + + '' + + 'd' + + '' + + '
        • ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + } ); + + describe( 'remove list item attributes', () => { + it( 'rename nested item from the middle #1', () => { + test.removeListAttributes( + 'a' + + 'b' + + '[c]' + + 'd', + + '
        ' + + '
      • ' + + '' + + '' + + 'a' + + '' + + '
          ' + + '
        • ' + + '' + + '' + + 'b' + + '' + + '
        • ' + + '
        ' + + '
      • ' + + '
      ' + + '

      c

      ' + + '
        ' + + '
      • ' + + '' + + '' + + 'd' + + '' + + '
      • ' + + '
      ' + ); + } ); + + it( 'rename the only nested item', () => { + test.removeListAttributes( + 'a' + + '[b]', + + '
        ' + + '
      • ' + + '' + + '' + + 'a' + + '' + + '
      • ' + + '
      ' + + '

      b

      ' + ); + } ); + } ); + } ); + + describe( 'position mapping', () => { + let mapper, view, viewRoot; + + beforeEach( () => { + mapper = editor.editing.mapper; + view = editor.editing.view; + viewRoot = view.document.getRoot(); + + setModelData( model, + '0' + + '1' + + '2' + ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '

      0

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

        2

        ' + + '
      • ' + + '
      ' + ); + } ); + + describe( 'view to model', () => { + function testList( viewPath, modelPath ) { + const viewPos = getViewPosition( viewRoot, viewPath, view ); + const modelPos = mapper.toModelPosition( viewPos ); + + expect( modelPos.root ).to.equal( modelRoot ); + expect( modelPos.path ).to.deep.equal( modelPath ); + } + + it( 'before ul --> before first list item', () => { + testList( [ 1 ], [ 1 ] ); + } ); + + it( 'before first li --> before first list item', () => { + testList( [ 1, 0 ], [ 1 ] ); + } ); + + it( 'before label --> inside list item block', () => { + testList( [ 1, 0, 0 ], [ 1, 0 ] ); + } ); + + it( 'before checkbox wrapper --> inside list item block', () => { + testList( [ 1, 0, 0, 0 ], [ 1, 0 ] ); + } ); + + it( 'before checkbox --> inside list item block', () => { + testList( [ 1, 0, 0, 0, 0 ], [ 1, 0 ] ); + } ); + + it( 'after checkbox --> inside list item block', () => { + testList( [ 1, 0, 0, 0, 1 ], [ 1, 0 ] ); + } ); + + it( 'before description --> inside list item block', () => { + testList( [ 1, 0, 0, 1 ], [ 1, 0 ] ); + } ); + + it( 'start of description --> inside list item block', () => { + testList( [ 1, 0, 0, 1, 0 ], [ 1, 0 ] ); + } ); + + it( 'end of description --> inside list item block', () => { + testList( [ 1, 0, 0, 1, 1 ], [ 1, 1 ] ); + } ); + + it( 'after description --> after first block', () => { + testList( [ 1, 0, 0, 2 ], [ 2 ] ); + } ); + + it( 'after label --> after first block', () => { + testList( [ 1, 0, 1 ], [ 2 ] ); + } ); + } ); + + describe( 'model to view', () => { + function testList( modelPath, viewPath ) { + const modelPos = model.createPositionFromPath( modelRoot, modelPath ); + const viewPos = mapper.toViewPosition( modelPos ); + + expect( viewPos.root ).to.equal( viewRoot ); + expect( getViewPath( viewPos ) ).to.deep.equal( viewPath ); + } + + it( 'before list item --> before ul', () => { + testList( [ 1 ], [ 1 ] ); + } ); + + it( 'start of list item --> start of description', () => { + testList( [ 1, 0 ], [ 1, 0, 0, 1, 0, 0 ] ); + } ); + + it( 'end of list item --> start of description', () => { + testList( [ 1, 1 ], [ 1, 0, 0, 1, 0, 1 ] ); + } ); + + it( 'after list item --> after a description', () => { + testList( [ 2 ], [ 1, 0, 1 ] ); + } ); + + it( 'start of second list item block --> start of paragraph', () => { + testList( [ 2, 0 ], [ 1, 0, 1, 0, 0 ] ); + } ); + + it( 'should not affect other input elements', () => { + model.schema.register( 'input', { inheritAllFrom: '$inlineObject' } ); + editor.conversion.for( 'downcast' ).elementToElement( { + model: 'input', + view: 'input', + converterPriority: 'low' + } ); + + setModelData( model, 'foobar' ); + + testList( [ 0, 7 ], [ 0, 0, 0, 1, 2, 3 ] ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
        ' + + '
      • ' + + '' + + '' + + 'foobar' + + '' + + '
      • ' + + '
      ' + ); + } ); + + it( 'should not affect other input UI elements', () => { + editor.conversion.for( 'downcast' ).markerToElement( { + model: 'input', + view: ( data, { writer } ) => writer.createUIElement( 'input' ) + } ); + + setModelData( model, 'foo[]bar' ); + + model.change( writer => { + writer.addMarker( 'input', { + range: model.document.selection.getFirstRange(), + usingOperation: false, + affectsData: false + } ); + } ); + + testList( [ 0, 6 ], [ 0, 0, 0, 1, 2, 3 ] ); + + expect( getViewData( view, { withoutSelection: true } ) ).to.equalMarkup( + '
        ' + + '
      • ' + + '' + + '' + + 'foobar' + + '' + + '
      • ' + + '
      ' + ); + } ); + } ); + + function getViewPosition( root, path, view ) { + let parent = root; + + while ( path.length > 1 ) { + parent = parent.getChild( path.shift() ); + } + + if ( !parent ) { + throw new Error( 'Invalid view path' ); + } + + return view.createPositionAt( parent, path[ 0 ] ); + } + + function getViewPath( position ) { + const path = [ position.offset ]; + let parent = position.parent; + + while ( parent.parent ) { + path.unshift( parent.index ); + parent = parent.parent; + } + + return path; + } + } ); +} ); diff --git a/packages/ckeditor5-list/tests/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 ); + } +} ); diff --git a/packages/ckeditor5-list/tests/tododocumentlist/tododocumentlistediting.js b/packages/ckeditor5-list/tests/tododocumentlist/tododocumentlistediting.js new file mode 100644 index 00000000000..c6035d2c03a --- /dev/null +++ b/packages/ckeditor5-list/tests/tododocumentlist/tododocumentlistediting.js @@ -0,0 +1,1234 @@ +/** + * @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, Event */ + +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 { getCode } from '@ckeditor/ckeditor5-utils/src/keyboard'; +import { env } from '@ckeditor/ckeditor5-utils'; +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', () => { + 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 ] + } ); + + 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' }, [ ] ); + + expect( model.schema.checkAttribute( [ '$root', paragraph ], 'todoListChecked' ) ).to.be.true; + expect( model.schema.checkAttribute( [ '$root', heading ], 'todoListChecked' ) ).to.be.true; + expect( model.schema.checkAttribute( [ '$root', blockQuote ], 'todoListChecked' ) ).to.be.true; + expect( model.schema.checkAttribute( [ '$root', table ], '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 wrap only a checkbox in a span if first element is a blockquote', () => { + testEditing( + '
      ' + + 'foo' + + '
      ', + '
        ' + + '
      • ' + + '' + + '' + + '' + + '' + + '' + + '
        ' + + '

        foo

        ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'should wrap only a checkbox in a span if first element is a heading', () => { + testEditing( + 'foo', + '
        ' + + '
      • ' + + '' + + '' + + '' + + '' + + '' + + '

        foo

        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'should wrap only a checkbox in a span if first element is a table', () => { + testEditing( + '' + + '' + + '' + + 'foo' + + '' + + '' + + '
      ', + '
        ' + + '
      • ' + + '' + + '' + + '' + + '' + + '' + + '
        ' + + '
        ' + + '' + + '' + + '' + + '' + + '' + + '' + + '
        ' + + 'foo' + + '
        ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + 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 wrap only a checkbox in a label element if first element is a blockquote', () => { + testData( + '
      ' + + 'foo' + + '
      ', + '
        ' + + '
      • ' + + '' + + '

        foo

        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'should wrap only a checkbox in a label element if first element is a heading', () => { + testData( + 'foo', + '
        ' + + '
      • ' + + '' + + '

        foo

        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'should wrap only a checkbox in a label element if first element is a table', () => { + testData( + '' + + '' + + '' + + 'foo' + + '' + + '' + + '
      ', + '
        ' + + '
      • ' + + '' + + '
        ' + + '' + + '' + + '' + + '' + + '' + + '' + + '
        foo
        ' + + '
        ' + + '
      • ' + + '
      ' + ); + } ); + + it( 'should convert a todo list item with alignment set', () => { + testData( + 'foo', + + '
        ' + + '
      • ' + + '' + + '

        foo

        ' + + '
      • ' + + '
      ' + ); + } ); + } ); + + describe( 'postfixers', () => { + describe( '`todoListChecked` attribute should be the same in all blocks of a single list item', () => { + it( 'should add missing `todoListChecked` attribute to other blocks', () => { + testPostfixer( + 'foo' + + 'bar' + + 'baz', + + 'foo' + + 'bar' + + 'baz' + ); + } ); + + it( 'should add missing `todoListChecked` attribute to other blocks excluding nested list items', () => { + testPostfixer( + 'foo' + + 'bar' + + 'nested 1' + + 'nested 2' + + 'baz', + + 'foo' + + 'bar' + + 'nested 1' + + 'nested 2' + + 'baz' + ); + } ); + + it( 'should remove `todoListChecked` from other blocks', () => { + testPostfixer( + 'foo' + + 'bar' + + 'baz', + + 'foo' + + 'bar' + + 'baz' + ); + } ); + + it( 'should remove `todoListChecked` from other blocks excluding nested list items', () => { + testPostfixer( + 'foo' + + 'bar' + + 'nested 1' + + 'nested 2' + + 'baz', + + 'foo' + + 'bar' + + 'nested 1' + + 'nested 2' + + 'baz' + ); + } ); + } ); + + describe( '`todoListChecked` attribute should be applied only to todo list items', () => { + it( 'should remove `todoListChecked` from elements other than todo list items', () => { + testPostfixer( + 'foo' + + 'foo' + + 'baz', + + 'foo' + + 'foo' + + 'baz' + ); + } ); + + it( 'should remove `todoListChecked` attribute from list items that are converted from todo to bulleted type', () => { + setModelData( model, + '[foo' + + 'foo' + + 'baz]' + ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + 'foo' + + 'foo' + + 'baz' + ); + + editor.execute( 'bulletedList' ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + 'foo' + + 'foo' + + 'baz' + ); + } ); + } ); + } ); + + describe( 'user interaction events', () => { + it( 'should toggle check state of selected to-do list item on keystroke', () => { + const command = editor.commands.get( 'checkTodoList' ); + + sinon.spy( command, 'execute' ); + + const domEvtDataStub = { + keyCode: getCode( 'enter' ), + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + if ( env.isMac ) { + domEvtDataStub.metaKey = true; + } else { + domEvtDataStub.ctrlKey = true; + } + + view.document.fire( 'keydown', domEvtDataStub ); + + sinon.assert.calledOnce( command.execute ); + + view.document.fire( 'keydown', domEvtDataStub ); + + sinon.assert.calledTwice( command.execute ); + } ); + + it( 'should toggle check state of a to-do list item on clicking the checkbox', () => { + setModelData( model, + 'foo' + ); + + const command = editor.commands.get( 'checkTodoList' ); + + sinon.spy( command, 'execute' ); + + view.getDomRoot().querySelector( 'input' ).dispatchEvent( new Event( 'change', { 'bubbles': true } ) ); + + sinon.assert.calledOnce( command.execute ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + 'foo' + ); + } ); + + it( 'should toggle check state of a to-do list item on todoCheckboxChange event with input target element', () => { + setModelData( model, + 'foo' + ); + + const command = editor.commands.get( 'checkTodoList' ); + + sinon.spy( command, 'execute' ); + + view.document.fire( 'todoCheckboxChange', { + target: view.document.getRoot().getChild( 0 ).getChild( 0 ).getChild( 0 ).getChild( 0 ).getChild( 0 ) + } ); + + sinon.assert.calledOnce( command.execute ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + 'foo' + ); + } ); + + it( 'should not toggle check state of a to-do list item on todoCheckboxChange event without target element', () => { + setModelData( model, + 'foo' + ); + + const command = editor.commands.get( 'checkTodoList' ); + + sinon.spy( command, 'execute' ); + + view.document.fire( 'todoCheckboxChange', {} ); + + sinon.assert.notCalled( command.execute ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + 'foo' + ); + } ); + + it( 'should not toggle check state of a to-do list item on todoCheckboxChange event with target element', () => { + setModelData( model, + 'foo' + ); + + const command = editor.commands.get( 'checkTodoList' ); + + sinon.spy( command, 'execute' ); + + view.document.fire( 'todoCheckboxChange', { + target: view.document.getRoot() + } ); + + sinon.assert.notCalled( command.execute ); + + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( + 'foo' + ); + } ); + + describe( 'arrow keys', () => { + it( 'should move collapsed selection at start of following todo list item on right arrow in todo list item', () => { + setModelData( model, + 'foo[]' + + 'bar' + ); + + const eventData = { + keyCode: getCode( 'arrowRight' ), + preventDefault: () => {}, + stopPropagation: () => {} + }; + + view.document.fire( 'keydown', eventData ); + + expect( getModelData( model ) ).to.equalMarkup( + 'foo' + + '[]bar' + ); + } ); + + it( 'should move collapsed selection at start of following todo list item on right arrow in paragraph', () => { + setModelData( model, + 'foo[]' + + 'bar' + ); + + const eventData = { + keyCode: getCode( 'arrowRight' ), + preventDefault: () => {}, + stopPropagation: () => {} + }; + + view.document.fire( 'keydown', eventData ); + + expect( getModelData( model ) ).to.equalMarkup( + 'foo' + + '[]bar' + ); + } ); + + it( 'should do nothing if selection is at end of the last todo list item and right arrow is pressed', () => { + setModelData( model, + 'foo[]' + ); + + const eventData = { + keyCode: getCode( 'arrowRight' ), + preventDefault: sinon.spy(), + stopPropagation: sinon.spy(), + domTarget: view.getDomRoot() + }; + + view.document.fire( 'keydown', eventData ); + + sinon.assert.notCalled( eventData.preventDefault ); + sinon.assert.notCalled( eventData.stopPropagation ); + + expect( getModelData( model ) ).to.equalMarkup( + 'foo[]' + ); + } ); + + it( 'should not move non-collapsed selection at start of following todo list item on right arrow key in todo list item', () => { + setModelData( model, + 'fo[o]' + + 'bar' + ); + + const eventData = { + keyCode: getCode( 'arrowRight' ), + preventDefault: sinon.spy(), + stopPropagation: sinon.spy(), + domTarget: view.getDomRoot() + }; + + view.document.fire( 'keydown', eventData ); + + sinon.assert.notCalled( eventData.preventDefault ); + sinon.assert.notCalled( eventData.stopPropagation ); + + expect( getModelData( model ) ).to.equalMarkup( + 'fo[o]' + + 'bar' + ); + } ); + + it( 'should not move non-collapsed selection at start of following todo list item on right arrow key in paragraph', () => { + setModelData( model, + 'fo[o]' + + 'bar' + ); + + const eventData = { + keyCode: getCode( 'arrowRight' ), + preventDefault: sinon.spy(), + stopPropagation: sinon.spy(), + domTarget: view.getDomRoot() + }; + + view.document.fire( 'keydown', eventData ); + + sinon.assert.notCalled( eventData.preventDefault ); + sinon.assert.notCalled( eventData.stopPropagation ); + + expect( getModelData( model ) ).to.equalMarkup( + 'fo[o]' + + 'bar' + ); + } ); + + it( 'should move a collapsed selection to the end of the preceding todo list item on left arrow', () => { + setModelData( model, + 'foo' + + '[]bar' + ); + + const eventData = { + keyCode: getCode( 'arrowLeft' ), + preventDefault: () => {}, + stopPropagation: () => {}, + domTarget: view.getDomRoot() + }; + + view.document.fire( 'keydown', eventData ); + + expect( getModelData( model ) ).to.equalMarkup( + 'foo[]' + + 'bar' + ); + } ); + + it( 'should do nothing if selection is at start of first element which is a todo list item and left arrow is pressed', () => { + setModelData( model, + '[]foo' + ); + + const eventData = { + keyCode: getCode( 'arrowLeft' ), + preventDefault: sinon.spy(), + stopPropagation: sinon.spy(), + domTarget: view.getDomRoot() + }; + + view.document.fire( 'keydown', eventData ); + + sinon.assert.notCalled( eventData.preventDefault ); + sinon.assert.notCalled( eventData.stopPropagation ); + + expect( getModelData( model ) ).to.equalMarkup( + '[]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 ); + } + + function testPostfixer( input, output ) { + setModelData( model, input ); + expect( getModelData( model, { withoutSelection: true } ) ).to.equalMarkup( output ); + } +} ); diff --git a/packages/ckeditor5-list/theme/todolist.css b/packages/ckeditor5-list/theme/todolist.css index 6723fc1aae2..915c28085f6 100644 --- a/packages/ckeditor5-list/theme/todolist.css +++ b/packages/ckeditor5-list/theme/todolist.css @@ -7,10 +7,81 @@ --ck-todo-list-checkmark-size: 16px; } +@define-mixin todo-list-checkbox { + -webkit-appearance: none; + display: inline-block; + position: relative; + width: var(--ck-todo-list-checkmark-size); + height: var(--ck-todo-list-checkmark-size); + vertical-align: middle; + + /* Needed on iOS */ + border: 0; + + /* LTR styles */ + left: -25px; + margin-right: -15px; + right: 0; + margin-left: 0; + + /* RTL styles */ + @nest [dir=rtl]& { + left: 0; + margin-right: 0; + right: -25px; + margin-left: -15px; + } + + &::before { + display: block; + position: absolute; + box-sizing: border-box; + content: ''; + width: 100%; + height: 100%; + border: 1px solid hsl(0, 0%, 20%); + border-radius: 2px; + transition: 250ms ease-in-out box-shadow; + } + + &::after { + display: block; + position: absolute; + box-sizing: content-box; + pointer-events: none; + content: ''; + + /* Calculate tick position, size and border-width proportional to the checkmark size. */ + left: calc( var(--ck-todo-list-checkmark-size) / 3 ); + top: calc( var(--ck-todo-list-checkmark-size) / 5.3 ); + width: calc( var(--ck-todo-list-checkmark-size) / 5.3 ); + height: calc( var(--ck-todo-list-checkmark-size) / 2.6 ); + border-style: solid; + border-color: transparent; + border-width: 0 calc( var(--ck-todo-list-checkmark-size) / 8 ) calc( var(--ck-todo-list-checkmark-size) / 8 ) 0; + transform: rotate(45deg); + } + + &[checked] { + &::before { + background: hsl(126, 64%, 41%); + border-color: hsl(126, 64%, 41%); + } + + &::after { + border-color: hsl(0, 0%, 100%); + } + } +} + +/* + * To-do list content styles. + */ .ck-content .todo-list { list-style: none; & li { + position: relative; margin-bottom: 5px; & .todo-list { @@ -20,86 +91,46 @@ & .todo-list__label { & > input { - -webkit-appearance: none; - display: inline-block; - position: relative; - width: var(--ck-todo-list-checkmark-size); - height: var(--ck-todo-list-checkmark-size); - vertical-align: middle; - - /* Needed on iOS */ - border: 0; - - /* LTR styles */ - left: -25px; - margin-right: -15px; - right: 0; - margin-left: 0; - - &::before { - display: block; - position: absolute; - box-sizing: border-box; - content: ''; - width: 100%; - height: 100%; - border: 1px solid hsl(0, 0%, 20%); - border-radius: 2px; - transition: 250ms ease-in-out box-shadow, 250ms ease-in-out background, 250ms ease-in-out border; - } - - &::after { - display: block; - position: absolute; - box-sizing: content-box; - pointer-events: none; - content: ''; - - /* Calculate tick position, size and border-width proportional to the checkmark size. */ - left: calc( var(--ck-todo-list-checkmark-size) / 3 ); - top: calc( var(--ck-todo-list-checkmark-size) / 5.3 ); - width: calc( var(--ck-todo-list-checkmark-size) / 5.3 ); - height: calc( var(--ck-todo-list-checkmark-size) / 2.6 ); - border-style: solid; - border-color: transparent; - border-width: 0 calc( var(--ck-todo-list-checkmark-size) / 8 ) calc( var(--ck-todo-list-checkmark-size) / 8 ) 0; - transform: rotate(45deg); - } - - &[checked] { - &::before { - background: hsl(126, 64%, 41%); - border-color: hsl(126, 64%, 41%); - } - - &::after { - border-color: hsl(0, 0%, 100%); - } - } + @mixin todo-list-checkbox; } & .todo-list__label__description { vertical-align: middle; } - } -} -/* RTL styles */ -[dir="rtl"] .todo-list .todo-list__label > input { - left: 0; - margin-right: 0; - right: -25px; - margin-left: -15px; + &.todo-list__label_without-description input[type=checkbox] { + position: absolute; + } + } } /* - * To-do list should be interactive only during the editing - * (https://github.com/ckeditor/ckeditor5/issues/2090). + * To-do list editing view styles. */ -.ck-editor__editable .todo-list .todo-list__label > input { - cursor: pointer; +.ck-editor__editable.ck-content .todo-list .todo-list__label { + /* + * To-do list should be interactive only during the editing + * (https://github.com/ckeditor/ckeditor5/issues/2090). + */ + & > input, + & > span[contenteditable=false] > input { + cursor: pointer; + + &:hover::before { + box-shadow: 0 0 0 5px hsla(0, 0%, 0%, 0.1); + } + } - &:hover::before { - box-shadow: 0 0 0 5px hsla(0, 0%, 0%, 0.1); + /* + * Document Lists - editing view has an additional span around checkbox. + */ + & > span[contenteditable=false] > input { + @mixin todo-list-checkbox; + } + + &.todo-list__label_without-description { + & input[type=checkbox] { + position: absolute; + } } } diff --git a/packages/ckeditor5-mention/src/ui/domwrapperview.ts b/packages/ckeditor5-mention/src/ui/domwrapperview.ts index f6bcd392792..3828944b414 100644 --- a/packages/ckeditor5-mention/src/ui/domwrapperview.ts +++ b/packages/ckeditor5-mention/src/ui/domwrapperview.ts @@ -72,4 +72,11 @@ export default class DomWrapperView extends View { this.element = this.domElement; } + + /** + * Focuses the DOM element. + */ + public focus(): void { + this.domElement.focus(); + } } diff --git a/packages/ckeditor5-mention/tests/ui/domwrapperview.js b/packages/ckeditor5-mention/tests/ui/domwrapperview.js new file mode 100644 index 00000000000..0496d5d0314 --- /dev/null +++ b/packages/ckeditor5-mention/tests/ui/domwrapperview.js @@ -0,0 +1,70 @@ +/** + * @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, Event */ + +import { Locale } from '@ckeditor/ckeditor5-utils'; +import DomWrapperView from '../../src/ui/domwrapperview'; + +describe( 'DomWrapperView', () => { + let domElement, view; + + beforeEach( () => { + domElement = document.createElement( 'div' ); + view = new DomWrapperView( new Locale(), domElement ); + } ); + + afterEach( () => { + view.destroy(); + } ); + + describe( 'constructor()', () => { + it( 'should add CSS class to the element', () => { + expect( domElement.classList.contains( 'ck-button' ) ).to.be.true; + } ); + + it( 'should set #isOn observable property with a CSS class binding', () => { + expect( view.isOn ).to.be.false; + + // TODO: This is actually a bug because the initial state is not set correctly. + expect( domElement.classList.contains( 'ck-on' ) ).to.be.false; + expect( domElement.classList.contains( 'ck-off' ) ).to.be.false; + + view.isOn = true; + expect( domElement.classList.contains( 'ck-on' ) ).to.be.true; + expect( domElement.classList.contains( 'ck-off' ) ).to.be.false; + + view.isOn = false; + expect( domElement.classList.contains( 'ck-on' ) ).to.be.false; + expect( domElement.classList.contains( 'ck-off' ) ).to.be.true; + } ); + + it( 'should fire #execute on DOM element click', () => { + const spy = sinon.spy(); + view.on( 'execute', spy ); + + domElement.dispatchEvent( new Event( 'click' ) ); + + sinon.assert.calledOnce( spy ); + } ); + } ); + + describe( 'render()', () => { + it( 'should assign passed element to #element', () => { + view.render(); + expect( view.element ).to.equal( domElement ); + } ); + } ); + + describe( 'focus()', () => { + it( 'focuses the #domElement', () => { + const spy = sinon.spy( domElement, 'focus' ); + + view.focus(); + + sinon.assert.calledOnce( spy ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-source-editing/src/utils/formathtml.ts b/packages/ckeditor5-source-editing/src/utils/formathtml.ts index d1e440c8774..6c7a21e8578 100644 --- a/packages/ckeditor5-source-editing/src/utils/formathtml.ts +++ b/packages/ckeditor5-source-editing/src/utils/formathtml.ts @@ -51,7 +51,6 @@ export function formatHtml( input: string ): string { { name: 'header', isVoid: false }, { name: 'hgroup', isVoid: false }, { name: 'hr', isVoid: true }, - { name: 'input', isVoid: true }, { name: 'li', isVoid: false }, { name: 'main', isVoid: false }, { name: 'nav', isVoid: false }, @@ -61,7 +60,6 @@ export function formatHtml( input: string ): string { { name: 'table', isVoid: false }, { name: 'tbody', isVoid: false }, { name: 'td', isVoid: false }, - { name: 'textarea', isVoid: false }, { name: 'th', isVoid: false }, { name: 'thead', isVoid: false }, { name: 'tr', isVoid: false }, diff --git a/packages/ckeditor5-source-editing/tests/utils/formathtml.js b/packages/ckeditor5-source-editing/tests/utils/formathtml.js index 57b551f59e0..316472dcbb7 100644 --- a/packages/ckeditor5-source-editing/tests/utils/formathtml.js +++ b/packages/ckeditor5-source-editing/tests/utils/formathtml.js @@ -179,14 +179,13 @@ describe( 'SourceEditing utils', () => { '
      \n' + '
      \n' + '
      \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + + ' ' + + '' + + '' + + '' + + '' + + '' + + '\n' + '
      \n' + '
      \n' + '
      '; diff --git a/packages/ckeditor5-table/src/tablecellproperties/ui/tablecellpropertiesview.ts b/packages/ckeditor5-table/src/tablecellproperties/ui/tablecellpropertiesview.ts index a79229a7787..19bebde3410 100644 --- a/packages/ckeditor5-table/src/tablecellproperties/ui/tablecellpropertiesview.ts +++ b/packages/ckeditor5-table/src/tablecellproperties/ui/tablecellpropertiesview.ts @@ -22,7 +22,9 @@ import { ViewCollection, type FocusableView, type NormalizedColorOption, - type ColorPickerConfig + type ColorPickerConfig, + type FocusCyclerBackwardCycleEvent, + type FocusCyclerForwardCycleEvent } from 'ckeditor5/src/ui'; import { KeystrokeHandler, @@ -390,13 +392,24 @@ export default class TableCellPropertiesView extends View { view: this } ); + // Maintain continuous focus cycling over views that have focusable children and focus cyclers themselves. + [ this.borderColorInput, this.backgroundInput ].forEach( view => { + view.fieldView.focusCycler.on( 'forwardCycle', evt => { + this._focusCycler.focusNext(); + evt.stop(); + } ); + + view.fieldView.focusCycler.on( 'backwardCycle', evt => { + this._focusCycler.focusPrevious(); + evt.stop(); + } ); + } ); + [ this.borderStyleDropdown, this.borderColorInput, - this.borderColorInput.fieldView.dropdownView.buttonView, this.borderWidthInput, this.backgroundInput, - this.backgroundInput.fieldView.dropdownView.buttonView, this.widthInput, this.heightInput, this.paddingInput, diff --git a/packages/ckeditor5-table/src/tableproperties/ui/tablepropertiesview.ts b/packages/ckeditor5-table/src/tableproperties/ui/tablepropertiesview.ts index af42ddff5bb..f0ef3c04092 100644 --- a/packages/ckeditor5-table/src/tableproperties/ui/tablepropertiesview.ts +++ b/packages/ckeditor5-table/src/tableproperties/ui/tablepropertiesview.ts @@ -23,7 +23,9 @@ import { type DropdownView, type InputTextView, type NormalizedColorOption, - type ColorPickerConfig + type ColorPickerConfig, + type FocusCyclerForwardCycleEvent, + type FocusCyclerBackwardCycleEvent } from 'ckeditor5/src/ui'; import { FocusTracker, KeystrokeHandler, type ObservableChangeEvent, type Locale } from 'ckeditor5/src/utils'; import { icons } from 'ckeditor5/src/core'; @@ -358,13 +360,24 @@ export default class TablePropertiesView extends View { view: this } ); + // Maintain continuous focus cycling over views that have focusable children and focus cyclers themselves. + [ this.borderColorInput, this.backgroundInput ].forEach( view => { + view.fieldView.focusCycler.on( 'forwardCycle', evt => { + this._focusCycler.focusNext(); + evt.stop(); + } ); + + view.fieldView.focusCycler.on( 'backwardCycle', evt => { + this._focusCycler.focusPrevious(); + evt.stop(); + } ); + } ); + [ this.borderStyleDropdown, this.borderColorInput, - this.borderColorInput!.fieldView.dropdownView.buttonView, this.borderWidthInput, this.backgroundInput, - this.backgroundInput!.fieldView.dropdownView.buttonView, this.widthInput, this.heightInput, this.alignmentToolbar, diff --git a/packages/ckeditor5-table/src/ui/colorinputview.ts b/packages/ckeditor5-table/src/ui/colorinputview.ts index f7dc0890836..5623065fa56 100644 --- a/packages/ckeditor5-table/src/ui/colorinputview.ts +++ b/packages/ckeditor5-table/src/ui/colorinputview.ts @@ -18,7 +18,8 @@ import { type DropdownView, type ColorPickerConfig, type ColorSelectorExecuteEvent, - type ColorSelectorColorPickerCancelEvent + type ColorSelectorColorPickerCancelEvent, + type FocusableView } from 'ckeditor5/src/ui'; import { FocusTracker, KeystrokeHandler, type Locale } from 'ckeditor5/src/utils'; @@ -38,7 +39,7 @@ export type ColorInputViewOptions = { * * @internal */ -export default class ColorInputView extends View { +export default class ColorInputView extends View implements FocusableView { /** * The value of the input. * @@ -87,6 +88,11 @@ export default class ColorInputView extends View { */ public readonly focusTracker: FocusTracker; + /** + * Helps cycling over focusable children in the input view. + */ + public readonly focusCycler: FocusCycler; + /** * A collection of views that can be focused in the view. */ @@ -114,11 +120,6 @@ export default class ColorInputView extends View { */ protected _stillTyping: boolean; - /** - * Helps cycling over focusable items in the view. - */ - protected readonly _focusCycler: FocusCycler; - /** * Creates an instance of the color input view. * @@ -145,7 +146,7 @@ export default class ColorInputView extends View { this.keystrokes = new KeystrokeHandler(); this._stillTyping = false; - this._focusCycler = new FocusCycler( { + this.focusCycler = new FocusCycler( { focusables: this._focusables, focusTracker: this.focusTracker, keystrokeHandler: this.keystrokes, @@ -181,15 +182,23 @@ export default class ColorInputView extends View { public override render(): void { super.render(); - // Start listening for the keystrokes coming from the dropdown panel view. - this.keystrokes.listenTo( this.dropdownView.panelView.element! ); + [ this.inputView, this.dropdownView.buttonView ].forEach( view => { + this.focusTracker.add( view.element! ); + this._focusables.add( view ); + } ); + + this.keystrokes.listenTo( this.element! ); } /** - * Focuses the input. + * Focuses the view. */ - public focus(): void { - this.inputView.focus(); + public focus( direction: 1 | -1 ): void { + if ( direction === -1 ) { + this.focusCycler.focusLast(); + } else { + this.focusCycler.focusFirst(); + } } /** @@ -250,10 +259,6 @@ export default class ColorInputView extends View { dropdown.panelView.children.add( colorSelector ); dropdown.bind( 'isEnabled' ).to( this, 'isReadOnly', value => !value ); - this._focusables.add( colorSelector ); - - this.focusTracker.add( colorSelector.element! ); - dropdown.on( 'change:isOpen', ( evt, name, isVisible ) => { if ( isVisible ) { colorSelector.updateSelectedColors(); diff --git a/packages/ckeditor5-table/tests/tablecellproperties/ui/tablecellpropertiesview.js b/packages/ckeditor5-table/tests/tablecellproperties/ui/tablecellpropertiesview.js index 44d1d6e1b11..b9199ce90e8 100644 --- a/packages/ckeditor5-table/tests/tablecellproperties/ui/tablecellpropertiesview.js +++ b/packages/ckeditor5-table/tests/tablecellproperties/ui/tablecellpropertiesview.js @@ -695,10 +695,8 @@ describe( 'table cell properties', () => { expect( view._focusables.map( f => f ) ).to.have.members( [ view.borderStyleDropdown, view.borderColorInput, - view.borderColorInput.fieldView.dropdownView.buttonView, view.borderWidthInput, view.backgroundInput, - view.backgroundInput.fieldView.dropdownView.buttonView, view.widthInput, view.heightInput, view.paddingInput, @@ -775,6 +773,49 @@ describe( 'table cell properties', () => { sinon.assert.calledOnce( keyEvtData.stopPropagation ); sinon.assert.calledOnce( spy ); } ); + + it( 'providing seamless forward navigation over child views with their own focusable children and focus cyclers', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Mock the border color dropdown button button is focused. + view.focusTracker.isFocused = view.borderColorInput.fieldView.focusTracker.isFocused = true; + view.focusTracker.focusedElement = view.borderColorInput.element; + view.borderColorInput.fieldView.focusTracker.focusedElement = + view.borderColorInput.fieldView.dropdownView.buttonView.element; + + const spy = sinon.spy( view.borderWidthInput, 'focus' ); + + view.borderColorInput.fieldView.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); + + it( 'providing seamless backward navigation over child views with their own focusable children and focus cyclers', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + shiftKey: true, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Mock the border color dropdown input is focused. + view.focusTracker.isFocused = view.borderColorInput.fieldView.focusTracker.isFocused = true; + view.focusTracker.focusedElement = view.borderColorInput.element; + view.borderColorInput.fieldView.focusTracker.focusedElement = + view.borderColorInput.fieldView.inputView.element; + + const spy = sinon.spy( view.borderStyleDropdown, 'focus' ); + + view.borderColorInput.fieldView.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); } ); } ); diff --git a/packages/ckeditor5-table/tests/tableproperties/ui/tablepropertiesview.js b/packages/ckeditor5-table/tests/tableproperties/ui/tablepropertiesview.js index 9a9b1b6fe97..d8e12701206 100644 --- a/packages/ckeditor5-table/tests/tableproperties/ui/tablepropertiesview.js +++ b/packages/ckeditor5-table/tests/tableproperties/ui/tablepropertiesview.js @@ -630,10 +630,8 @@ describe( 'table properties', () => { expect( view._focusables.map( f => f ) ).to.have.members( [ view.borderStyleDropdown, view.borderColorInput, - view.borderColorInput.fieldView.dropdownView.buttonView, view.borderWidthInput, view.backgroundInput, - view.backgroundInput.fieldView.dropdownView.buttonView, view.widthInput, view.heightInput, view.alignmentToolbar, @@ -708,6 +706,49 @@ describe( 'table properties', () => { sinon.assert.calledOnce( keyEvtData.stopPropagation ); sinon.assert.calledOnce( spy ); } ); + + it( 'providing seamless forward navigation over child views with their own focusable children and focus cyclers', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Mock the border color dropdown button button is focused. + view.focusTracker.isFocused = view.borderColorInput.fieldView.focusTracker.isFocused = true; + view.focusTracker.focusedElement = view.borderColorInput.element; + view.borderColorInput.fieldView.focusTracker.focusedElement = + view.borderColorInput.fieldView.dropdownView.buttonView.element; + + const spy = sinon.spy( view.borderWidthInput, 'focus' ); + + view.borderColorInput.fieldView.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); + + it( 'providing seamless backward navigation over child views with their own focusable children and focus cyclers', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + shiftKey: true, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Mock the border color dropdown input is focused. + view.focusTracker.isFocused = view.borderColorInput.fieldView.focusTracker.isFocused = true; + view.focusTracker.focusedElement = view.borderColorInput.element; + view.borderColorInput.fieldView.focusTracker.focusedElement = + view.borderColorInput.fieldView.inputView.element; + + const spy = sinon.spy( view.borderStyleDropdown, 'focus' ); + + view.borderColorInput.fieldView.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); } ); } ); diff --git a/packages/ckeditor5-table/tests/ui/colorinputview.js b/packages/ckeditor5-table/tests/ui/colorinputview.js index 725161e726c..5d7419f2f20 100644 --- a/packages/ckeditor5-table/tests/ui/colorinputview.js +++ b/packages/ckeditor5-table/tests/ui/colorinputview.js @@ -3,7 +3,7 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -/* global Event */ +/* global document, Event */ import ColorInputView from '../../src/ui/colorinputview'; import InputTextView from '@ckeditor/ckeditor5-ui/src/inputtext/inputtextview'; @@ -48,10 +48,12 @@ describe( 'ColorInputView', () => { inputView = view.inputView; removeColorButton = colorSelectorView.colorGridsFragmentView.removeColorButtonView; colorGridView = colorSelectorView.colorGridsFragmentView.staticColorsGrid; + document.body.appendChild( view.element ); } ); afterEach( () => { view.destroy(); + view.element.remove(); } ); describe( 'constructor()', () => { @@ -106,8 +108,8 @@ describe( 'ColorInputView', () => { expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler ); } ); - it( 'should have #_focusCycler', () => { - expect( view._focusCycler ).to.be.instanceOf( FocusCycler ); + it( 'should have #focusCycler', () => { + expect( view.focusCycler ).to.be.instanceOf( FocusCycler ); } ); describe( 'dropdown', () => { @@ -230,6 +232,12 @@ describe( 'ColorInputView', () => { } ); describe( 'position', () => { + let view; + + afterEach( () => { + view.destroy(); + } ); + it( 'should be SouthWest in LTR', () => { locale.uiLanguageDirection = 'ltr'; view = new ColorInputView( locale, { @@ -252,17 +260,6 @@ describe( 'ColorInputView', () => { expect( view.dropdownView.panelPosition ).to.equal( 'se' ); } ); } ); - - it( 'should register panelView children in #_focusables', () => { - expect( view._focusables.map( f => f ) ).to.have.members( [ - view.dropdownView.panelView.children.first - ] ); - } ); - - it( 'should register panelView children elements in #focusTracker', () => { - expect( view.focusTracker._elements ).to.include( view.dropdownView.panelView.children.first.element ); - expect( view.focusTracker._elements ).to.include( view.dropdownView.panelView.children.last.element ); - } ); } ); describe( 'color grid', () => { @@ -590,10 +587,10 @@ describe( 'ColorInputView', () => { // Mock the remove color button view is focused. view.focusTracker.isFocused = true; - view.focusTracker.focusedElement = view._focusables.first.element; + view.focusTracker.focusedElement = view.inputView.element; // Spy the next view which in this case is the color grid view. - const spy = sinon.spy( view._focusables.last, 'focus' ); + const spy = sinon.spy( view.dropdownView.buttonView, 'focus' ); view.keystrokes.press( keyEvtData ); sinon.assert.calledOnce( keyEvtData.preventDefault ); @@ -613,10 +610,10 @@ describe( 'ColorInputView', () => { // Mock the remove color button view is focused. view.focusTracker.isFocused = true; - view.focusTracker.focusedElement = view._focusables.first.element; + view.focusTracker.focusedElement = view.inputView.element; // Spy the previous view which in this case is the color grid view. - const spy = sinon.spy( view._focusables.last, 'focus' ); + const spy = sinon.spy( view.dropdownView.buttonView, 'focus' ); view.keystrokes.press( keyEvtData ); sinon.assert.calledOnce( keyEvtData.preventDefault ); @@ -679,6 +676,14 @@ describe( 'ColorInputView', () => { sinon.assert.calledOnce( spy ); } ); + + it( 'should focus the dropdown button if the backwards direction was specified', () => { + const spy = sinon.spy( view.dropdownView.buttonView, 'focus' ); + + view.focus( -1 ); + + sinon.assert.calledOnce( spy ); + } ); } ); describe( 'render()', () => { @@ -692,7 +697,7 @@ describe( 'ColorInputView', () => { view.render(); sinon.assert.calledOnce( spy ); - sinon.assert.calledWithExactly( spy, view.dropdownView.panelView.element ); + sinon.assert.calledWithExactly( spy, view.element ); view.destroy(); } ); diff --git a/packages/ckeditor5-theme-lark/tests/manual/theme.html b/packages/ckeditor5-theme-lark/tests/manual/theme.html index d721d1dedcc..59fa17b8561 100644 --- a/packages/ckeditor5-theme-lark/tests/manual/theme.html +++ b/packages/ckeditor5-theme-lark/tests/manual/theme.html @@ -92,6 +92,9 @@

      Button: Responsiveness

      Button: Tooltip

      +

      Button: Spinner

      +
      +

      Dropdown

      ListDropdown

      diff --git a/packages/ckeditor5-theme-lark/tests/manual/theme.js b/packages/ckeditor5-theme-lark/tests/manual/theme.js index f255f1f2beb..5ecd7cd4dea 100644 --- a/packages/ckeditor5-theme-lark/tests/manual/theme.js +++ b/packages/ckeditor5-theme-lark/tests/manual/theme.js @@ -30,6 +30,7 @@ import checkIcon from '@ckeditor/ckeditor5-core/theme/icons/check.svg'; import cancelIcon from '@ckeditor/ckeditor5-core/theme/icons/cancel.svg'; import SplitButtonView from '@ckeditor/ckeditor5-ui/src/dropdown/button/splitbuttonview'; +import { SpinnerView } from '@ckeditor/ckeditor5-ui'; const locale = new Locale(); @@ -71,6 +72,7 @@ const ui = testUtils.createTestUIView( { 'buttonResponsive2': '#button-responsive-2', 'buttonResponsive3': '#button-responsive-3', 'buttonTooltip': '#button-tooltip', + 'buttonSpinner': '#button-spinner', listDropdown: '#list-dropdown', buttonDropdown: '#button-dropdown', @@ -273,6 +275,22 @@ function renderButton() { tooltipPosition: 'sw' } ) ] ) ); + + // --- With spinner ------------------------------------------------------------ + + const buttonWithSpinner = button( { + label: 'Button with spinner', + withText: false + } ); + + const spinnerView = new SpinnerView(); + spinnerView.isVisible = true; + + buttonWithSpinner.children.add( spinnerView ); + + ui.buttonSpinner.add( toolbar( [ + buttonWithSpinner + ] ) ); } function renderDropdown() { diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/autocomplete/autocomplete.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/autocomplete/autocomplete.css new file mode 100644 index 00000000000..63456470aee --- /dev/null +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/autocomplete/autocomplete.css @@ -0,0 +1,36 @@ +/* + * 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 "@ckeditor/ckeditor5-theme-lark/theme/mixins/_rounded.css"; +@import "@ckeditor/ckeditor5-theme-lark/theme/mixins/_shadow.css"; + +.ck.ck-autocomplete { + & > .ck-search__results { + @mixin ck-rounded-corners; + @mixin ck-drop-shadow; + + max-height: 200px; + overflow-y: auto; + background: var(--ck-color-base-background); + border: 1px solid var(--ck-color-dropdown-panel-border); + min-width: auto; + + &.ck-search__results_n { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + + /* Prevent duplicated borders between the input and the results pane. */ + margin-bottom: -1px; + } + + &.ck-search__results_s { + border-top-left-radius: 0; + border-top-right-radius: 0; + + /* Prevent duplicated borders between the input and the results pane. */ + margin-top: -1px; + } + } +} diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/list/list.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/list/list.css index 9199dd73572..16060d48c07 100644 --- a/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/list/list.css +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/list/list.css @@ -77,6 +77,21 @@ } } +.ck-list .ck-list__group { + padding-top: var(--ck-spacing-medium); + + /* The group should have a border when it's not the first item. */ + *:not(.ck-hidden) ~ & { + border-top: 1px solid var(--ck-color-base-border); + } + + & > span { + font-size: 11px; + font-weight: bold; + padding: var(--ck-spacing-medium); + } +} + .ck.ck-list__separator { height: 1px; width: 100%; diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/search/search.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/search/search.css new file mode 100644 index 00000000000..9a0e4f52486 --- /dev/null +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/search/search.css @@ -0,0 +1,106 @@ +/* + * 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 "@ckeditor/ckeditor5-ui/theme/mixins/_dir.css"; + +:root { + --ck-search-field-view-horizontal-spacing: calc(var(--ck-icon-size) + var(--ck-spacing-medium)); +} + +.ck.ck-search { + & > .ck-labeled-field-view { + & .ck-input { + width: 100%; + } + + &.ck-search__query_with-icon { + --ck-labeled-field-label-default-position-x: var(--ck-search-field-view-horizontal-spacing); + + & > .ck-labeled-field-view__input-wrapper > .ck-icon { + opacity: .5; + pointer-events: none; + } + + & .ck-input { + width: 100%; + + @mixin ck-dir ltr { + padding-left: var(--ck-search-field-view-horizontal-spacing); + } + + @mixin ck-dir rtl { + &:not(.ck-input-text_empty) { + padding-left: var(--ck-search-field-view-horizontal-spacing); + } + } + } + } + + &.ck-search__query_with-reset { + --ck-labeled-field-empty-unfocused-max-width: 100% - 2 * var(--ck-search-field-view-horizontal-spacing); + + &.ck-labeled-field-view_empty { + --ck-labeled-field-empty-unfocused-max-width: 100% - var(--ck-search-field-view-horizontal-spacing) - var(--ck-spacing-medium); + } + + & .ck-search__reset { + min-width: auto; + min-height: auto; + + background: none; + opacity: .5; + padding: 0; + + @mixin ck-dir ltr { + right: var(--ck-spacing-medium); + } + + @mixin ck-dir rtl { + left: var(--ck-spacing-medium); + } + + &:hover { + opacity: 1; + } + } + + & .ck-input { + width: 100%; + + @mixin ck-dir ltr { + &:not(.ck-input-text_empty) { + padding-right: var(--ck-search-field-view-horizontal-spacing); + } + } + + @mixin ck-dir rtl { + padding-right: var(--ck-search-field-view-horizontal-spacing); + } + } + } + } + + & > .ck-search__results { + min-width: 100%; + + & > .ck-search__info { + width: 100%; + padding: var(--ck-spacing-medium) var(--ck-spacing-large); + + & * { + white-space: normal; + } + + & > span:first-child { + font-weight: bold; + } + + & > span:last-child { + margin-top: var(--ck-spacing-medium); + } + } + } +} + diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/spinner/spinner.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/spinner/spinner.css new file mode 100644 index 00000000000..16223a79ead --- /dev/null +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/spinner/spinner.css @@ -0,0 +1,29 @@ +/* + * 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 + */ + +:root { + --ck-toolbar-spinner-size: 18px; +} + +.ck.ck-spinner-container { + width: var(--ck-toolbar-spinner-size); + height: var(--ck-toolbar-spinner-size); + animation: 1.5s infinite rotate linear; +} + +.ck.ck-spinner { + width: var(--ck-toolbar-spinner-size); + height: var(--ck-toolbar-spinner-size); + border-radius: 50%; + border: 2px solid var(--ck-color-text); + border-top-color: transparent; +} + +@keyframes rotate { + to { + transform: rotate(360deg) + } +} + diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/textarea/textarea.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/textarea/textarea.css new file mode 100644 index 00000000000..6af487b9f9f --- /dev/null +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/textarea/textarea.css @@ -0,0 +1,12 @@ +/* + * 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 + */ + +/* + * This fixes a problem in Firefox when the initial height of the complement does not match the number of rows. + * This bug is especially visible when rows=1. + */ +.ck-textarea { + overflow-x: hidden +} diff --git a/packages/ckeditor5-ui/src/autocomplete/autocompleteview.ts b/packages/ckeditor5-ui/src/autocomplete/autocompleteview.ts new file mode 100644 index 00000000000..fde873ce699 --- /dev/null +++ b/packages/ckeditor5-ui/src/autocomplete/autocompleteview.ts @@ -0,0 +1,230 @@ +/** + * @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 ui/autocomplete/autocompleteview +*/ + +import { getOptimalPosition, type PositioningFunction, type Locale, global, toUnit, Rect } from '@ckeditor/ckeditor5-utils'; +import SearchTextView, { type SearchTextViewConfig } from '../search/text/searchtextview'; +import type SearchResultsView from '../search/searchresultsview'; +import type InputBase from '../input/inputbase'; +import type { FilteredViewExecuteEvent } from '../search/filteredview'; + +import '../../theme/components/autocomplete/autocomplete.css'; + +/** + * The autocomplete component's view class. It extends the {@link module:ui/search/text/searchtextview~SearchTextView} class + * with a floating {@link #resultsView} that shows up when the user starts typing and hides when they blur + * the component. + */ +export default class AutocompleteView< + TQueryFieldView extends InputBase +> extends SearchTextView { + /** + * The configuration of the autocomplete view. + */ + protected override _config: AutocompleteViewConfig; + + declare public resultsView: AutocompleteResultsView; + + /** + * @inheritDoc + */ + constructor( locale: Locale, config: AutocompleteViewConfig ) { + super( locale, config ); + + this._config = config; + + const toPx = toUnit( 'px' ); + + this.extendTemplate( { + attributes: { + class: [ 'ck-autocomplete' ] + } + } ); + + const bindResultsView = this.resultsView.bindTemplate; + + this.resultsView.set( 'isVisible', false ); + this.resultsView.set( '_position', 's' ); + this.resultsView.set( '_width', 0 ); + + this.resultsView.extendTemplate( { + attributes: { + class: [ + bindResultsView.if( 'isVisible', 'ck-hidden', value => !value ), + bindResultsView.to( '_position', value => `ck-search__results_${ value }` ) + ], + style: { + width: bindResultsView.to( '_width', toPx ) + } + } + } ); + + // Update the visibility of the results view when the user focuses or blurs the component. + // This is also integration for the `resetOnBlur` configuration. + this.focusTracker.on( 'change:isFocused', ( evt, name, isFocused ) => { + this._updateResultsVisibility(); + + if ( isFocused ) { + // Reset the scroll position of the results view whenever the autocomplete reopens. + this.resultsView.element!.scrollTop = 0; + } else if ( config.resetOnBlur ) { + this.queryView.reset(); + } + } ); + + // Update the visibility of the results view when the user types in the query field. + // This is an integration for `queryMinChars` configuration. + // This is an integration for search results changing length and the #resultsView requiring to be repositioned. + this.on( 'search', () => { + this._updateResultsVisibility(); + this._updateResultsViewWidthAndPosition(); + } ); + + // Hide the results view when the user presses the ESC key. + this.keystrokes.set( 'esc', ( evt, cancel ) => { + this.resultsView.isVisible = false; + cancel(); + } ); + + // Update the position of the results view when the user scrolls the page. + // TODO: This needs to be debounced down the road. + this.listenTo( global.document, 'scroll', () => { + this._updateResultsViewWidthAndPosition(); + } ); + + // Hide the results when the component becomes disabled. + this.on( 'change:isEnabled', () => { + this._updateResultsVisibility(); + } ); + + // Update the value of the query field when the user selects a result. + this.filteredView.on( 'execute', ( evt, { value } ) => { + // Focus the query view first to avoid losing the focus. + this.focus(); + + // Resetting the view will ensure that the #queryView will update its empty state correctly. + // This prevents bugs related to dynamic labels or auto-grow when re-setting the same value + // to #queryView.fieldView.value (which does not trigger empty state change) to an + // #queryView.fieldView.element that has been changed by the user. + this.reset(); + + // Update the value of the query field. + this.queryView.fieldView.value = this.queryView.fieldView.element!.value = value; + + // Finally, hide the results view. The focus has been moved earlier so this is safe. + this.resultsView.isVisible = false; + } ); + + // Update the position and width of the results view when it becomes visible. + this.resultsView.on( 'change:isVisible', () => { + this._updateResultsViewWidthAndPosition(); + } ); + } + + /** + * Updates the position of the results view on demand. + */ + private _updateResultsViewWidthAndPosition() { + if ( !this.resultsView.isVisible ) { + return; + } + + this.resultsView._width = new Rect( this.queryView.fieldView.element! ).width; + + const optimalResultsPosition = AutocompleteView._getOptimalPosition( { + element: this.resultsView.element!, + target: this.queryView.element!, + fitInViewport: true, + positions: AutocompleteView.defaultResultsPositions + } ); + + // _getOptimalPosition will return null if there is no optimal position found (e.g. target is off the viewport). + this.resultsView._position = optimalResultsPosition ? optimalResultsPosition.name : 's'; + } + + /** + * Updates the visibility of the results view on demand. + */ + private _updateResultsVisibility() { + const queryMinChars = typeof this._config.queryMinChars === 'undefined' ? 0 : this._config.queryMinChars; + const queryLength = this.queryView.fieldView.element!.value.length; + + this.resultsView.isVisible = this.focusTracker.isFocused && this.isEnabled && queryLength >= queryMinChars; + } + + /** + * Positions for the autocomplete results view. Two positions are defined by default: + * * `s` - below the search field, + * * `n` - above the search field. + */ + public static defaultResultsPositions: Array = [ + ( fieldRect => { + return { + top: fieldRect.bottom, + left: fieldRect.left, + name: 's' + }; + } ) as PositioningFunction, + ( ( fieldRect, resultsRect ) => { + return { + top: fieldRect.top - resultsRect.height, + left: fieldRect.left, + name: 'n' + }; + } ) as PositioningFunction + ]; + + /** + * A function used to calculate the optimal position for the dropdown panel. + */ + private static _getOptimalPosition = getOptimalPosition; +} + +/** + * An interface describing additional properties of the floating search results view used by the autocomplete plugin. + */ +export interface AutocompleteResultsView extends SearchResultsView { + + /** + * Controls the visibility of the results view. + * + * @observable + */ + isVisible: boolean; + + /** + * Controls the position (CSS class suffix) of the results view. + * + * @internal + */ + _position?: string; + + /** + * The observable property determining the CSS width of the results view. + * + * @internal + */ + _width: number; +} + +export interface AutocompleteViewConfig< + TConfigInputCreator extends InputBase +> extends SearchTextViewConfig { + + /** + * When set `true`, the query view will be reset when the autocomplete view loses focus. + */ + resetOnBlur?: boolean; + + /** + * Minimum number of characters that need to be typed before the search is performed. + * + * @default 0 + */ + queryMinChars?: number; +} diff --git a/packages/ckeditor5-ui/src/button/buttonlabel.ts b/packages/ckeditor5-ui/src/button/buttonlabel.ts new file mode 100644 index 00000000000..17d340e56cb --- /dev/null +++ b/packages/ckeditor5-ui/src/button/buttonlabel.ts @@ -0,0 +1,41 @@ +/** + * @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 ui/button/buttonlabel +*/ + +import type View from '../view'; + +/** + * The button label interface. Implemented by the {@link module:ui/button/buttonlabelview~ButtonLabelView} + * and any label view that can be used with the {@link module:ui/button/buttonview~ButtonView}. + */ +export default interface ButtonLabel extends View { + + /** + * The `id` attribute of the button label. It is used for accessibility purposes + * to describe the button. + * + * @observable + */ + id: string | undefined; + + /** + * The `style` attribute of the button label. It allows customizing the presentation + * of the label. + * + * @observable + */ + style: string | undefined; + + /** + * The human-readable text of the label. + * + * @observable + */ + text: string | undefined; + +} diff --git a/packages/ckeditor5-ui/src/button/buttonlabelview.ts b/packages/ckeditor5-ui/src/button/buttonlabelview.ts new file mode 100644 index 00000000000..7a54ef959dd --- /dev/null +++ b/packages/ckeditor5-ui/src/button/buttonlabelview.ts @@ -0,0 +1,66 @@ +/** + * @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 ui/button/buttonlabelview + */ + +import View from '../view'; +import type ButtonLabel from './buttonlabel'; + +/** + * A default implementation of the button view's label. It comes with a dynamic text support + * via {@link module:ui/button/buttonlabelview~ButtonLabelView#text} property. + */ +export default class ButtonLabelView extends View implements ButtonLabel { + /** + * @inheritDoc + */ + declare public id: string | undefined; + + /** + * @inheritDoc + */ + declare public style: string | undefined; + + /** + * @inheritDoc + */ + declare public text: string | undefined; + + /** + * @inheritDoc + */ + constructor() { + super(); + + this.set( { + style: undefined, + text: undefined, + id: undefined + } ); + + const bind = this.bindTemplate; + + this.setTemplate( { + tag: 'span', + + attributes: { + class: [ + 'ck', + 'ck-button__label' + ], + style: bind.to( 'style' ), + id: bind.to( 'id' ) + }, + + children: [ + { + text: bind.to( 'text' ) + } + ] + } ); + } +} diff --git a/packages/ckeditor5-ui/src/button/buttonview.ts b/packages/ckeditor5-ui/src/button/buttonview.ts index d2de2ed9eb2..10a26a735db 100644 --- a/packages/ckeditor5-ui/src/button/buttonview.ts +++ b/packages/ckeditor5-ui/src/button/buttonview.ts @@ -13,6 +13,8 @@ import IconView from '../icon/iconview'; import type { TemplateDefinition } from '../template'; import type ViewCollection from '../viewcollection'; import type { default as Button, ButtonExecuteEvent } from './button'; +import type ButtonLabel from './buttonlabel'; +import ButtonLabelView from './buttonlabelview'; import { env, @@ -50,9 +52,12 @@ export default class ButtonView extends View implements Butto public readonly children: ViewCollection; /** - * Label of the button view. It is configurable using the {@link #label label attribute}. + * Label of the button view. Its text is configurable using the {@link #label label attribute}. + * + * If not configured otherwise in the `constructor()`, by default the label is an instance + * of {@link module:ui/button/buttonlabelview~ButtonLabelView}. */ - public readonly labelView: View; + public readonly labelView: ButtonLabel; /** * The icon view of the button. Will be added to {@link #children} when the @@ -178,9 +183,13 @@ export default class ButtonView extends View implements Butto private _focusDelayed: DelayedFunc<() => void> | null = null; /** - * @inheritDoc + * Creates an instance of the button view class. + * + * @param locale The {@link module:core/editor/editor~Editor#locale} instance. + * @param labelView The instance of the button's label. If not provided, an instance of + * {@link module:ui/button/buttonlabelview~ButtonLabelView} is used. */ - constructor( locale?: Locale ) { + constructor( locale?: Locale, labelView: ButtonLabel = new ButtonLabelView() ) { super( locale ); const bind = this.bindTemplate; @@ -208,7 +217,7 @@ export default class ButtonView extends View implements Butto this.set( 'withKeystroke', false ); this.children = this.createCollection(); - this.labelView = this._createLabelView(); + this.labelView = this._setupLabelView( labelView ); this.iconView = new IconView(); this.iconView.extendTemplate( { @@ -325,30 +334,10 @@ export default class ButtonView extends View implements Butto } /** - * Creates a label view instance and binds it with button attributes. + * Binds the label view instance it with button attributes. */ - private _createLabelView() { - const labelView = new View(); - const bind = this.bindTemplate; - - labelView.setTemplate( { - tag: 'span', - - attributes: { - class: [ - 'ck', - 'ck-button__label' - ], - style: bind.to( 'labelStyle' ), - id: this.ariaLabelledBy - }, - - children: [ - { - text: bind.to( 'label' ) - } - ] - } ); + private _setupLabelView( labelView: ButtonLabelView ) { + labelView.bind( 'text', 'style', 'id' ).to( this, 'label', 'labelStyle', 'ariaLabelledBy' ); return labelView; } diff --git a/packages/ckeditor5-ui/src/dropdown/utils.ts b/packages/ckeditor5-ui/src/dropdown/utils.ts index 8a4130c88fa..06b9dac09f2 100644 --- a/packages/ckeditor5-ui/src/dropdown/utils.ts +++ b/packages/ckeditor5-ui/src/dropdown/utils.ts @@ -39,6 +39,7 @@ import { import '../../theme/components/dropdown/toolbardropdown.css'; import '../../theme/components/dropdown/listdropdown.css'; +import ListItemGroupView from '../list/listitemgroupview'; /** * A helper for creating dropdowns. It creates an instance of a {@link module:ui/dropdown/dropdownview~DropdownView dropdown}, @@ -345,38 +346,14 @@ function addListToOpenDropdown( role?: string; } ): void { - const locale = dropdownView.locale; - + const locale = dropdownView.locale!; const listView = dropdownView.listView = new ListView( locale ); const items = typeof itemsOrCallback == 'function' ? itemsOrCallback() : itemsOrCallback; listView.ariaLabel = options.ariaLabel; listView.role = options.role; - listView.items.bindTo( items ).using( def => { - if ( def.type === 'separator' ) { - return new ListSeparatorView( locale ); - } else if ( def.type === 'button' || def.type === 'switchbutton' ) { - const listItemView = new ListItemView( locale ); - let buttonView; - - if ( def.type === 'button' ) { - buttonView = new ButtonView( locale ); - } else { - buttonView = new SwitchButtonView( locale ); - } - - // Bind all model properties to the button view. - buttonView.bind( ...Object.keys( def.model ) as Array ).to( def.model ); - buttonView.delegate( 'execute' ).to( listItemView ); - - listItemView.children.add( buttonView ); - - return listItemView; - } - - return null; - } ); + bindViewCollectionItemsToDefinitions( dropdownView, listView.items, items, locale ); dropdownView.panelView.children.add( listView ); @@ -547,11 +524,63 @@ function focusDropdownPanelOnOpen( dropdownView: DropdownView ) { }, { priority: 'low' } ); } +/** + * This helper populates a dropdown list with items and groups according to the + * collection of item definitions. A permanent binding is created in this process allowing + * dynamic management of the dropdown list content. + * + * @param dropdownView + * @param listItems + * @param definitions + * @param locale + */ +function bindViewCollectionItemsToDefinitions( + dropdownView: DropdownView, + listItems: ViewCollection, + definitions: Collection, + locale: Locale +) { + listItems.bindTo( definitions ).using( def => { + if ( def.type === 'separator' ) { + return new ListSeparatorView( locale ); + } else if ( def.type === 'group' ) { + const groupView = new ListItemGroupView( locale ); + + groupView.set( { label: def.label } ); + + bindViewCollectionItemsToDefinitions( dropdownView, groupView.items, def.items, locale ); + + groupView.items.delegate( 'execute' ).to( dropdownView ); + + return groupView; + } else if ( def.type === 'button' || def.type === 'switchbutton' ) { + const listItemView = new ListItemView( locale ); + let buttonView; + + if ( def.type === 'button' ) { + buttonView = new ButtonView( locale ); + } else { + buttonView = new SwitchButtonView( locale ); + } + + // Bind all model properties to the button view. + buttonView.bind( ...Object.keys( def.model ) as Array ).to( def.model ); + buttonView.delegate( 'execute' ).to( listItemView ); + + listItemView.children.add( buttonView ); + + return listItemView; + } + + return null; + } ); +} + /** * A definition of the list item used by the {@link module:ui/dropdown/utils~addListToDropdown} * utility. */ -export type ListDropdownItemDefinition = ListDropdownSeparatorDefinition | ListDropdownButtonDefinition; +export type ListDropdownItemDefinition = ListDropdownSeparatorDefinition | ListDropdownButtonDefinition | ListDropdownGroupDefinition; /** * A definition of the 'separator' list item. @@ -571,3 +600,20 @@ export type ListDropdownButtonDefinition = { */ model: Model; }; + +/** + * A definition of the group inside the list. A group can contain one or more list items (buttons). + */ +export type ListDropdownGroupDefinition = { + type: 'group'; + + /** + * The visible label of the group. + */ + label: string; + + /** + * The collection of the child list items inside this group. + */ + items: Collection; +}; diff --git a/packages/ckeditor5-ui/src/focuscycler.ts b/packages/ckeditor5-ui/src/focuscycler.ts index 052e4f6ba10..9283a7f1ff6 100644 --- a/packages/ckeditor5-ui/src/focuscycler.ts +++ b/packages/ckeditor5-ui/src/focuscycler.ts @@ -11,7 +11,8 @@ import { isVisible, type ArrayOrItem, type FocusTracker, - type KeystrokeHandler + type KeystrokeHandler, + EmitterMixin } from '@ckeditor/ckeditor5-utils'; import type View from './view'; @@ -69,7 +70,7 @@ import type ViewCollection from './viewcollection'; * * Check out the {@glink framework/deep-dive/ui/focus-tracking "Deep dive into focus tracking"} guide to learn more. */ -export default class FocusCycler { +export default class FocusCycler extends EmitterMixin() { /** * A {@link module:ui/view~View view} collection that the cycler operates on. */ @@ -116,6 +117,8 @@ export default class FocusCycler { keystrokeHandler?: KeystrokeHandler; actions?: FocusCyclerActions; } ) { + super(); + this.focusables = options.focusables; this.focusTracker = options.focusTracker; this.keystrokeHandler = options.keystrokeHandler; @@ -137,6 +140,9 @@ export default class FocusCycler { } } } + + this.on( 'forwardCycle', () => this.focusFirst(), { priority: 'low' } ); + this.on( 'backwardCycle', () => this.focusLast(), { priority: 'low' } ); } /** @@ -210,7 +216,7 @@ export default class FocusCycler { * **Note**: Hidden views (e.g. with `display: none`) are ignored. */ public focusFirst(): void { - this._focus( this.first ); + this._focus( this.first, 1 ); } /** @@ -219,7 +225,7 @@ export default class FocusCycler { * **Note**: Hidden views (e.g. with `display: none`) are ignored. */ public focusLast(): void { - this._focus( this.last ); + this._focus( this.last, -1 ); } /** @@ -228,7 +234,17 @@ export default class FocusCycler { * **Note**: Hidden views (e.g. with `display: none`) are ignored. */ public focusNext(): void { - this._focus( this.next ); + const next = this.next; + + if ( next && this.focusables.getIndex( next ) === this.current ) { + return; + } + + if ( next === this.first ) { + this.fire( 'forwardCycle' ); + } else { + this._focus( next, 1 ); + } } /** @@ -237,15 +253,29 @@ export default class FocusCycler { * **Note**: Hidden views (e.g. with `display: none`) are ignored. */ public focusPrevious(): void { - this._focus( this.previous ); + const previous = this.previous; + + if ( previous && this.focusables.getIndex( previous ) === this.current ) { + return; + } + + if ( previous === this.last ) { + this.fire( 'backwardCycle' ); + } else { + this._focus( previous, -1 ); + } } /** * Focuses the given view if it exists. + * + * @param view The view to be focused + * @param direction The direction of the focus if the view has focusable children. + * @returns */ - private _focus( view: FocusableView | null ) { + private _focus( view: FocusableView | null, direction: 1 | -1 ) { if ( view ) { - view.focus(); + view.focus( direction ); } } @@ -277,7 +307,7 @@ export default class FocusCycler { const view = this.focusables.get( index )!; if ( isFocusable( view ) ) { - return view as FocusableView; + return view; } // Cycle in both directions. @@ -288,7 +318,24 @@ export default class FocusCycler { } } -export type FocusableView = View & { focus(): void }; +/** + * A view that can be focused. + */ +export type FocusableView = View & { + + /** + * Focuses the view. + * + * @param direction This optional parameter helps improve the UX by providing additional information about the direction the focus moved + * (e.g. in a complex view or a form). It is useful for views that host multiple focusable children (e.g. lists, toolbars): + * * `1` indicates that the focus moved forward and, in most cases, the first child of the focused view should get focused, + * * `-1` indicates that the focus moved backwards, and the last focusable child should get focused + * + * See {@link module:ui/focuscycler~FocusCycler#event:forwardCycle} and {@link module:ui/focuscycler~FocusCycler#event:backwardCycle} + * to learn more. + */ + focus( direction?: 1 | -1 ): void; +}; export interface FocusCyclerActions { focusFirst?: ArrayOrItem; @@ -297,11 +344,33 @@ export interface FocusCyclerActions { focusPrevious?: ArrayOrItem; } +/** + * Fired when the focus cycler is about to move the focus from the last focusable item + * to the first one. + * + * @eventName ~FocusCycler#forwardCycle + */ +export type FocusCyclerForwardCycleEvent = { + name: 'forwardCycle'; + args: []; +}; + +/** + * Fired when the focus cycler is about to move the focus from the first focusable item + * to the last one. + * + * @eventName ~FocusCycler#backwardCycle + */ +export type FocusCyclerBackwardCycleEvent = { + name: 'backwardCycle'; + args: []; +}; + /** * Checks whether a view is focusable. * * @param view A view to be checked. */ -function isFocusable( view: View & { focus?: unknown } ) { - return !!( view.focus && isVisible( view.element ) ); +function isFocusable( view: View ): view is FocusableView { + return !!( 'focus' in view && isVisible( view.element ) ); } diff --git a/packages/ckeditor5-ui/src/formheader/formheaderview.ts b/packages/ckeditor5-ui/src/formheader/formheaderview.ts index 540842db7a2..50d39abc6f1 100644 --- a/packages/ckeditor5-ui/src/formheader/formheaderview.ts +++ b/packages/ckeditor5-ui/src/formheader/formheaderview.ts @@ -9,6 +9,7 @@ import View from '../view'; import type ViewCollection from '../viewcollection'; +import IconView from '../icon/iconview'; import type { Locale } from '@ckeditor/ckeditor5-utils'; @@ -47,6 +48,11 @@ export default class FormHeaderView extends View { */ public declare class: string | null; + /** + * The icon view instance. Defined only if icon was passed in the constructor's options. + */ + public readonly iconView?: IconView; + /** * Creates an instance of the form header class. * @@ -56,7 +62,11 @@ export default class FormHeaderView extends View { */ constructor( locale: Locale | undefined, - options: { label?: string | null; class?: string | null } = {} + options: { + label?: string | null; + class?: string | null; + icon?: string | null; + } = {} ) { super( locale ); @@ -79,6 +89,13 @@ export default class FormHeaderView extends View { children: this.children } ); + if ( options.icon ) { + this.iconView = new IconView(); + this.iconView.content = options.icon; + + this.children.add( this.iconView ); + } + const label = new View( locale ); label.setTemplate( { diff --git a/packages/ckeditor5-ui/src/highlightedtext/highlightedtextview.ts b/packages/ckeditor5-ui/src/highlightedtext/highlightedtextview.ts new file mode 100644 index 00000000000..f93d007ebbe --- /dev/null +++ b/packages/ckeditor5-ui/src/highlightedtext/highlightedtextview.ts @@ -0,0 +1,129 @@ +/** + * @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 ui/highlightedtext/highlightedtextview + */ + +import View from '../view'; +import { escape } from 'lodash-es'; + +import '../../theme/components/highlightedtext/highlightedtext.css'; + +/** + * A class representing a view that displays a text which subset can be highlighted using the + * {@link #highlightText} method. + */ +export default class HighlightedTextView extends View { + /** + * The text that can be highlighted using the {@link #highlightText} method. + * + * **Note:** When this property changes, the previous highlighting is removed. + * + * @observable + */ + declare public text: string | undefined; + + /** + * @inheritDoc + */ + constructor() { + super(); + + this.set( 'text', undefined ); + + this.setTemplate( { + tag: 'span', + attributes: { + class: [ 'ck', 'ck-highlighted-text' ] + } + } ); + + this.on( 'render', () => { + // Classic setTemplate binding for #text will not work because highlightText() replaces the + // pre-rendered DOM text node new a new one (and elements). + this.on( 'change:text', () => { + this._updateInnerHTML( this.text ); + } ); + + this._updateInnerHTML( this.text ); + } ); + } + + /** + * Highlights view's {@link #text} according to the specified `RegExp`. If the passed RegExp is `null`, the + * highlighting is removed + * + * @param regExp + */ + public highlightText( regExp: RegExp | null ): void { + this._updateInnerHTML( markText( this.text || '', regExp ) ); + } + + /** + * Updates element's `innerHTML` with the passed content. + */ + private _updateInnerHTML( newInnerHTML: string | undefined ) { + this.element!.innerHTML = newInnerHTML || ''; + } +} + +/** + * Replaces `regExp` occurrences with `` tags in a text. + * + * @param text A text to get marked. + * @param regExp An optional `RegExp`. If not passed, this is a pass-through function. + * @returns A text with `RegExp` occurrences marked by ``. + */ +function markText( text: string, regExp?: RegExp | null ) { + if ( !regExp ) { + return escape( text ); + } + + const textParts: Array<{ text: string; isMatch: boolean }> = []; + let lastMatchEnd = 0; + let matchInfo = regExp.exec( text ); + + // Iterate over all matches and create an array of text parts. The idea is to mark which parts are query matches + // so that later on they can be highlighted. + while ( matchInfo !== null ) { + const curMatchStart = matchInfo.index; + // Detect if there was something between last match and this one. + if ( curMatchStart !== lastMatchEnd ) { + textParts.push( { + text: text.substring( lastMatchEnd, curMatchStart ), + isMatch: false + } ); + } + + textParts.push( { + text: matchInfo[ 0 ], + isMatch: true + } ); + + lastMatchEnd = regExp.lastIndex; + matchInfo = regExp.exec( text ); + } + + // Your match might not be the last part of a string. Be sure to add any plain text following the last match. + if ( lastMatchEnd !== text.length ) { + textParts.push( { + text: text.substring( lastMatchEnd ), + isMatch: false + } ); + } + + const outputHtml = textParts + // The entire text should be escaped. + .map( part => { + part.text = escape( part.text ); + return part; + } ) + // Only matched text should be wrapped with HTML mark element. + .map( part => part.isMatch ? `${ part.text }` : part.text ) + .join( '' ); + + return outputHtml; +} diff --git a/packages/ckeditor5-ui/src/icon/iconview.ts b/packages/ckeditor5-ui/src/icon/iconview.ts index 505b462abde..8e65e30e370 100644 --- a/packages/ckeditor5-ui/src/icon/iconview.ts +++ b/packages/ckeditor5-ui/src/icon/iconview.ts @@ -64,6 +64,14 @@ export default class IconView extends View { */ declare public isColorInherited: boolean; + /** + * Controls whether the icon is visible. + * + * @observable + * @default true + */ + declare public isVisible: boolean; + /** * A list of presentational attributes that can be set on the `` element and should be preserved * when the icon {@link module:ui/icon/iconview~IconView#content content} is loaded. @@ -93,6 +101,7 @@ export default class IconView extends View { this.set( 'viewBox', '0 0 20 20' ); this.set( 'fillColor', '' ); this.set( 'isColorInherited', true ); + this.set( 'isVisible', true ); this.setTemplate( { tag: 'svg', @@ -101,6 +110,7 @@ export default class IconView extends View { class: [ 'ck', 'ck-icon', + bind.if( 'isVisible', 'ck-hidden', value => !value ), // Exclude icon internals from the CSS reset to allow rich (non-monochromatic) icons // (https://github.com/ckeditor/ckeditor5/issues/12599). diff --git a/packages/ckeditor5-ui/src/index.ts b/packages/ckeditor5-ui/src/index.ts index 36185b2cace..a269d0a8bf1 100644 --- a/packages/ckeditor5-ui/src/index.ts +++ b/packages/ckeditor5-ui/src/index.ts @@ -16,7 +16,9 @@ export { default as addKeyboardHandlingForGrid } from './bindings/addkeyboardhan export { default as BodyCollection } from './editorui/bodycollection'; export { type ButtonExecuteEvent } from './button/button'; +export { type default as ButtonLabel } from './button/buttonlabel'; export { default as ButtonView } from './button/buttonview'; +export { default as ButtonLabelView } from './button/buttonlabelview'; export { default as SwitchButtonView } from './button/switchbuttonview'; export * from './colorgrid/utils'; @@ -47,19 +49,27 @@ export { default as BoxedEditorUIView } from './editorui/boxed/boxededitoruiview export { default as InlineEditableUIView } from './editableui/inline/inlineeditableuiview'; export { default as FormHeaderView } from './formheader/formheaderview'; -export { default as FocusCycler, type FocusableView } from './focuscycler'; +export { + default as FocusCycler, + type FocusableView, + type FocusCyclerForwardCycleEvent, + type FocusCyclerBackwardCycleEvent +} from './focuscycler'; export { default as IconView } from './icon/iconview'; export { default as InputView } from './input/inputview'; export { default as InputTextView } from './inputtext/inputtextview'; export { default as InputNumberView } from './inputnumber/inputnumberview'; +export { default as TextareaView, type TextareaViewUpdateEvent } from './textarea/textareaview'; + export { default as IframeView } from './iframe/iframeview'; export { default as LabelView } from './label/labelview'; -export { default as LabeledFieldView } from './labeledfield/labeledfieldview'; +export { type LabeledFieldViewCreator, default as LabeledFieldView } from './labeledfield/labeledfieldview'; export * from './labeledfield/utils'; +export { default as ListItemGroupView } from './list/listitemgroupview'; export { default as ListItemView } from './list/listitemview'; export { default as ListView } from './list/listview'; @@ -70,9 +80,17 @@ export { default as BalloonPanelView } from './panel/balloon/balloonpanelview'; export { default as ContextualBalloon } from './panel/balloon/contextualballoon'; export { default as StickyPanelView } from './panel/sticky/stickypanelview'; +export { default as AutocompleteView, type AutocompleteViewConfig, type AutocompleteResultsView } from './autocomplete/autocompleteview'; +export { default as SearchTextView, type SearchTextViewSearchEvent, type SearchTextViewConfig } from './search/text/searchtextview'; +export { default as SearchInfoView } from './search/searchinfoview'; +export { default as FilteredView, type FilteredViewExecuteEvent } from './search/filteredview'; +export { default as HighlightedTextView } from './highlightedtext/highlightedtextview'; + export { default as TooltipManager } from './tooltipmanager'; export { default as Template, type TemplateDefinition } from './template'; +export { default as SpinnerView } from './spinner/spinnerview'; + export { default as ToolbarView } from './toolbar/toolbarview'; export { default as ToolbarLineBreakView } from './toolbar/toolbarlinebreakview'; export { default as ToolbarSeparatorView } from './toolbar/toolbarseparatorview'; diff --git a/packages/ckeditor5-ui/src/input/inputbase.ts b/packages/ckeditor5-ui/src/input/inputbase.ts new file mode 100644 index 00000000000..a24f5e93f15 --- /dev/null +++ b/packages/ckeditor5-ui/src/input/inputbase.ts @@ -0,0 +1,205 @@ +/** + * @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 ui/input/inputbase + */ + +import View from '../view'; + +import { + FocusTracker, + type Locale, + type ObservableChangeEvent +} from '@ckeditor/ckeditor5-utils'; + +/** + * The base input view class. + */ +export default abstract class InputBase extends View { + /** + * Stores information about the editor UI focus and propagates it so various plugins and components + * are unified as a focus group. + */ + public readonly focusTracker: FocusTracker; + + /** + * The value of the input. + * + * @observable + */ + declare public value: string | undefined; + + /** + * The `id` attribute of the input (i.e. to pair with a `
      + + diff --git a/packages/ckeditor5-ui/tests/manual/autocomplete/autocomplete.md b/packages/ckeditor5-ui/tests/manual/autocomplete/autocomplete.md new file mode 100644 index 00000000000..1f260145fcd --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/autocomplete/autocomplete.md @@ -0,0 +1,6 @@ +# AutoCompleteView component + +1. **Note**: The component is rendered some distance from the top of the screen. +2. Make sure the results show up as soon as you start typing. +3. Make sure you can move focus between the field and results using Tab/Shift+Tab. +4. Scroll the viewport: The results should show up below or above the input depending on the available space on the screen. diff --git a/packages/ckeditor5-ui/tests/manual/autocomplete/autocomplete.ts b/packages/ckeditor5-ui/tests/manual/autocomplete/autocomplete.ts new file mode 100644 index 00000000000..a2567f1f32e --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/autocomplete/autocomplete.ts @@ -0,0 +1,77 @@ +/** + * @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 { Locale } from '@ckeditor/ckeditor5-utils'; +import { + ButtonView, + ListItemView, + ListView, + AutocompleteView, + type FilteredView, + type FilteredViewExecuteEvent +} from '../../../src'; + +const locale = new Locale(); + +class FilteredTestListView extends ListView implements FilteredView { + public filter( query ) { + let visibleItems = 0; + + for ( const item of this.items ) { + const listItemView = ( item as ListItemView ); + const buttonView = listItemView.children.first! as ButtonView; + + listItemView.isVisible = query ? !!buttonView.label!.match( query ) : true; + + if ( listItemView.isVisible ) { + visibleItems++; + } + } + + return { + resultsCount: visibleItems, + totalItemsCount: this.items.length + }; + } +} + +const listView = new FilteredTestListView(); + +[ + 'getAttribute()', 'getAttributeNames()', 'getAttributeNode()', 'getAttributeNodeNS()', 'getAttributeNS()', + 'getBoundingClientRect()', 'getClientRects()', 'getElementsByClassName()', 'getElementsByTagName()', 'getElementsByTagNameNS()', + 'hasAttribute()', 'hasAttributeNS()', 'hasAttributes()', 'hasPointerCapture()', 'insertAdjacentElement()', 'insertAdjacentHTML()', + 'insertAdjacentText()', 'matches()', 'prepend()', 'querySelector()', 'querySelectorAll()', 'releasePointerCapture()', 'remove()', + 'removeAttribute()', 'removeAttributeNode()', 'removeAttributeNS()' +].forEach( item => { + const listItemView = new ListItemView(); + const buttonView = new ButtonView(); + + buttonView.on( 'execute', () => { + listView.fire( 'execute', { + value: buttonView.label! + } ); + } ); + + buttonView.withText = true; + buttonView.label = item; + listItemView.children.add( buttonView ); + listView.items.add( listItemView ); +} ); + +const view = new AutocompleteView( locale, { + queryView: { + label: 'Search field label', + showIcon: false, + showResetButton: false + }, + filteredView: listView +} ); + +view.render(); + +document.querySelector( '.playground' )!.appendChild( view.element! ); diff --git a/packages/ckeditor5-ui/tests/manual/dropdown/dropdown.html b/packages/ckeditor5-ui/tests/manual/dropdown/dropdown.html index 70104b6ac55..049b8aa85e8 100644 --- a/packages/ckeditor5-ui/tests/manual/dropdown/dropdown.html +++ b/packages/ckeditor5-ui/tests/manual/dropdown/dropdown.html @@ -6,6 +6,11 @@

      Dropdown with ListView

      +

      Dropdown with ListView (and groups)

      + +
      + +

      Long label (truncated)

      diff --git a/packages/ckeditor5-ui/tests/manual/dropdown/dropdown.js b/packages/ckeditor5-ui/tests/manual/dropdown/dropdown.js index 707a46d3e48..b152c0d5a8d 100644 --- a/packages/ckeditor5-ui/tests/manual/dropdown/dropdown.js +++ b/packages/ckeditor5-ui/tests/manual/dropdown/dropdown.js @@ -21,6 +21,7 @@ import { createDropdown, addToolbarToDropdown, addListToDropdown } from '../../. const ui = testUtils.createTestUIView( { dropdown: '#dropdown', listDropdown: '#list-dropdown', + listDropdownWithGroups: '#list-dropdown-with-groups', dropdownLabel: '#dropdown-label', toolbarDropdown: '#dropdown-toolbar', splitButton: '#dropdown-splitbutton' @@ -76,6 +77,80 @@ function testList() { window.Model = Model; } +function testListWithGroups() { + const collection = new Collection( { idProperty: 'label' } ); + + collection.addMany( [ + { + type: 'button', + model: new Model( { + label: 'Item 1', + withText: true + } ) + }, + { + type: 'group', + label: 'Group 1', + items: new Collection( [ + { + type: 'button', + model: new Model( { + label: 'Group 1, Item 1', + withText: true + } ) + }, + { + type: 'button', + model: new Model( { + label: 'Group 1, Item 1', + withText: true + } ) + } + ] ) + }, + { + type: 'group', + label: 'Group 2', + items: new Collection( [ + { + type: 'button', + model: new Model( { + label: 'Group 2, Item 1', + withText: true + } ) + }, + { + type: 'button', + model: new Model( { + label: 'Group 2, Item 1', + withText: true + } ) + } + ] ) + } + ] ); + + const dropdownView = createDropdown( {} ); + + dropdownView.buttonView.set( { + label: 'ListDropdown (with groups)', + isEnabled: true, + isOn: false, + withText: true + } ); + + addListToDropdown( dropdownView, collection ); + + dropdownView.on( 'execute', evt => { + console.log( 'List#execute:', evt.source.label ); + } ); + + ui.listDropdownWithGroups.add( dropdownView ); + + window.listDropdownWithGroupsCollection = collection; + window.Model = Model; +} + function testLongLabel() { const dropdownView = createDropdown( {} ); @@ -160,6 +235,7 @@ function testSplitButton() { testEmpty(); testList(); +testListWithGroups(); testLongLabel(); testToolbar(); testSplitButton(); diff --git a/packages/ckeditor5-ui/tests/manual/list/list.html b/packages/ckeditor5-ui/tests/manual/list/list.html new file mode 100644 index 00000000000..24a9b97abbb --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/list/list.html @@ -0,0 +1,13 @@ +
      + + diff --git a/packages/ckeditor5-ui/tests/manual/list/list.js b/packages/ckeditor5-ui/tests/manual/list/list.js new file mode 100644 index 00000000000..f95c894b91f --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/list/list.js @@ -0,0 +1,60 @@ +/** + * @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 { ButtonView, ListItemGroupView, ListItemView, ListView } from '../../../src'; + +const playground = document.querySelector( '#playground' ); + +const defaultView = new ListView(); +defaultView.render(); +playground.appendChild( defaultView.element ); + +const grouppedView = new ListView(); +grouppedView.render(); +playground.appendChild( grouppedView.element ); + +defaultView.items.addMany( [ + createItem( 'Item 1' ), + createItem( 'Item 2' ), + createItem( 'Item 3' ), + createItem( 'Item 4' ), + createItem( 'Item 5' ) +] ); + +grouppedView.items.addMany( [ + createItem( 'Item 1' ), + createItem( 'Item 2' ), + createGroup( 'Items group 1', [ + createItem( 'Item 1.1' ), + createItem( 'Item 1.2' ) + ] ), + createGroup( 'Items group 2', [ + createItem( 'Item 2.1' ), + createItem( 'Item 2.2' ) + ] ) +] ); + +function createItem( label ) { + const item = new ListItemView(); + const button = new ButtonView(); + + item.children.add( button ); + + button.set( { label, withText: true } ); + + return item; +} + +function createGroup( label, items ) { + const groupView = new ListItemGroupView(); + + groupView.label = label; + groupView.items.addMany( items ); + + return groupView; +} + diff --git a/packages/ckeditor5-ui/tests/manual/list/list.md b/packages/ckeditor5-ui/tests/manual/list/list.md new file mode 100644 index 00000000000..3f0a817bd35 --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/list/list.md @@ -0,0 +1,5 @@ +# ListView component + +1. There are two examples of a list in this test. +2. Make sure both render correctly, especially the one with the item groups. +3. Verify accessibility: click the first item and then navigate across the list. The navigation should be smooth, all items should get focus in the right order. diff --git a/packages/ckeditor5-ui/tests/manual/search/search.html b/packages/ckeditor5-ui/tests/manual/search/search.html new file mode 100644 index 00000000000..295ff3a57de --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/search/search.html @@ -0,0 +1,31 @@ +
      + + diff --git a/packages/ckeditor5-ui/tests/manual/search/search.md b/packages/ckeditor5-ui/tests/manual/search/search.md new file mode 100644 index 00000000000..d8ef709ac6e --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/search/search.md @@ -0,0 +1,5 @@ +# SearchTextView component + +1. There are 3 examples of the component in this test. +2. Make sure searching works and the list/toolbar gets filtered. +3. Check accessibility, make sure Tab/Shift+Tab navigation works. diff --git a/packages/ckeditor5-ui/tests/manual/search/search.ts b/packages/ckeditor5-ui/tests/manual/search/search.ts new file mode 100644 index 00000000000..9aed3b490a9 --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/search/search.ts @@ -0,0 +1,210 @@ +/** + * @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 { Locale } from '@ckeditor/ckeditor5-utils'; +import { + ButtonView, + ListItemGroupView, + ListItemView, + ListView, + SearchTextView, + ToolbarView, + type FilteredView, + createLabeledTextarea +} from '../../../src'; + +const locale = new Locale(); + +function createSearchableList() { + class FilteredTestListView extends ListView implements FilteredView { + public filter( query ) { + let totalItemsCount = 0; + let visibleItemsCount = 0; + + function updateListItemVisibility( listItemView: ListItemView ) { + const buttonView = listItemView.children.first! as ButtonView; + + listItemView.isVisible = query ? !!buttonView.label!.match( query ) : true; + + if ( listItemView.isVisible ) { + visibleItemsCount++; + } + + totalItemsCount++; + } + + for ( const listItemOrGroupView of this.items ) { + if ( listItemOrGroupView instanceof ListItemView ) { + updateListItemVisibility( listItemOrGroupView ); + } else { + const groupView = listItemOrGroupView as ListItemGroupView; + + for ( const item of groupView.items ) { + updateListItemVisibility( item as ListItemView ); + } + + groupView.isVisible = !!groupView.items.filter( listItemView => listItemView.isVisible ).length; + } + } + + return { + resultsCount: visibleItemsCount, + totalItemsCount + }; + } + } + + const listView = new FilteredTestListView(); + const hasGroupView = new ListItemGroupView(); + const getGroupView = new ListItemGroupView(); + + hasGroupView.label = 'Starting with "has"...'; + getGroupView.label = 'Starting with "get"...'; + + [ + 'getAttribute()', 'getAttributeNames()', 'getAttributeNode()', 'getAttributeNodeNS()', 'getAttributeNS()', + 'getBoundingClientRect()', 'getClientRects()', 'getElementsByClassName()', 'getElementsByTagName()', 'getElementsByTagNameNS()', + 'hasAttribute()', 'hasAttributeNS()', 'hasAttributes()', 'hasPointerCapture()', 'releasePointerCapture()', 'remove()', + 'removeAttribute()', 'removeAttributeNode()', 'removeAttributeNS()' + ].forEach( item => { + const listItemView = new ListItemView(); + const buttonView = new ButtonView(); + + buttonView.withText = true; + buttonView.label = item; + listItemView.children.add( buttonView ); + + if ( item.startsWith( 'has' ) ) { + hasGroupView.items.add( listItemView ); + } else if ( item.startsWith( 'get' ) ) { + getGroupView.items.add( listItemView ); + } else { + listView.items.add( listItemView ); + } + } ); + + listView.items.add( getGroupView ); + listView.items.add( hasGroupView ); + + const searchView = new SearchTextView( locale, { + queryView: { + label: 'Search list items' + }, + filteredView: listView + } ); + + addToPlayground( 'Filtering a list with grouped items', searchView ); +} + +function createSearchableToolbar() { + class FilteredTestToolbarView extends ToolbarView implements FilteredView { + public filter( query ) { + let visibleItemsCount = 0; + + for ( const item of this.items ) { + const buttonView = ( item as ButtonView ); + + buttonView.isVisible = query ? !!buttonView.label!.match( query ) : true; + + if ( buttonView.isVisible ) { + visibleItemsCount++; + } + } + + return { + resultsCount: visibleItemsCount, + totalItemsCount: this.items.length + }; + } + } + + const toolbarView = new FilteredTestToolbarView( locale ); + + [ + 'AddEventListenerOptions', 'AesCbcParams', 'AesCtrParams', 'AesDerivedKeyParams', 'AesGcmParams', 'AesKeyAlgorithm', + 'AesKeyGenParams', 'Algorithm', 'AnalyserOptions', 'AnimationEventInit', 'AnimationPlaybackEventInit', 'AssignedNodesOptions', + 'AudioBufferOptions', 'AudioBufferSourceOptions', 'AudioConfiguration', 'AudioContextOptions', 'AudioNodeOptions', + 'AudioProcessingEventInit', 'AudioTimestamp', 'MediaTrackConstraints', + 'MediaTrackSettings', 'MediaTrackSupportedConstraints', 'MessageEventInit', 'MouseEventInit', 'MultiCacheQueryOptions', + 'MutationObserverInit', 'NavigationPreloadState' + ].forEach( item => { + const buttonView = new ButtonView(); + + buttonView.withText = true; + buttonView.label = item; + toolbarView.items.add( buttonView ); + } ); + + const searchView = new SearchTextView( locale, { + queryView: { + label: 'Search toolbar buttons' + }, + filteredView: toolbarView + } ); + + addToPlayground( 'Filtering a toolbar', searchView ); +} + +function createSearchWithCustomInput() { + class FilteredTestToolbarView extends ToolbarView implements FilteredView { + public filter( query ) { + let visibleItemsCount = 0; + + for ( const item of this.items ) { + const buttonView = ( item as ButtonView ); + + buttonView.isVisible = query ? !!buttonView.label!.match( query ) : true; + + if ( buttonView.isVisible ) { + visibleItemsCount++; + } + } + + return { + resultsCount: visibleItemsCount, + totalItemsCount: this.items.length + }; + } + } + + const toolbarView = new FilteredTestToolbarView( locale ); + + Array.from( Array( 30 ).keys() ) + .forEach( item => { + const buttonView = new ButtonView( locale ); + + buttonView.withText = true; + buttonView.label = String( item ); + toolbarView.items.add( buttonView ); + } ); + + const searchView = new SearchTextView( locale, { + queryView: { + label: 'Search toolbar buttons', + creator: createLabeledTextarea + }, + filteredView: toolbarView + } ); + + addToPlayground( 'Custom input (textarea)', searchView ); +} + +function addToPlayground( name, view ) { + view.render(); + + const container = document.createElement( 'div' ); + const heading = document.createElement( 'h2' ); + heading.textContent = name; + + container.appendChild( heading ); + container.appendChild( view.element! ); + document.querySelector( '.playground' )!.appendChild( container ); +} + +createSearchableList(); +createSearchableToolbar(); +createSearchWithCustomInput(); diff --git a/packages/ckeditor5-ui/tests/manual/textarea/textareaview.html b/packages/ckeditor5-ui/tests/manual/textarea/textareaview.html new file mode 100644 index 00000000000..93a920d9c5d --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/textarea/textareaview.html @@ -0,0 +1,42 @@ +
      + + diff --git a/packages/ckeditor5-ui/tests/manual/textarea/textareaview.md b/packages/ckeditor5-ui/tests/manual/textarea/textareaview.md new file mode 100644 index 00000000000..4ed9c41deb8 --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/textarea/textareaview.md @@ -0,0 +1,8 @@ +# `TextareaView` + +There's a number of different view configurations showcased in this test. + +1. Make sure the view resizes according to the configured min and max rows. +2. Try removing all content. Or pasting a lot of content into each textarea. +3. Try resizing textarea manually, it should act according to the description. + * **Known bug**: Typing in a once manually resized textarea will reset it's size. diff --git a/packages/ckeditor5-ui/tests/manual/textarea/textareaview.ts b/packages/ckeditor5-ui/tests/manual/textarea/textareaview.ts new file mode 100644 index 00000000000..31bcc4a602d --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/textarea/textareaview.ts @@ -0,0 +1,105 @@ +/** + * @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 { ButtonView, TextareaView } from '../../../src'; + +function createPlainTextarea() { + const textareaView = new TextareaView(); + + addToPlayground( 'Default textarea', textareaView ); +} + +function createPlainTextareaWithMoreRows() { + const textareaView = new TextareaView(); + + textareaView.minRows = 5; + textareaView.maxRows = 10; + + addToPlayground( 'min 5 rows, max 10 rows', textareaView ); +} + +function createPlainTextareaWithFixedRows() { + const textareaView = new TextareaView(); + + textareaView.minRows = 3; + textareaView.maxRows = 3; + + addToPlayground( '3 rows, fixed', textareaView ); +} + +function createPlainTextareaWithSingleRow() { + const textareaView = new TextareaView(); + + textareaView.minRows = 1; + textareaView.maxRows = 1; + + addToPlayground( '1 row, fixed', textareaView ); +} + +function createPlainTextareaWithVerticalResizeOnly() { + const textareaView = new TextareaView(); + + textareaView.resize = 'vertical'; + + addToPlayground( 'Default rows, manual v-resize only', textareaView ); +} + +function createPlainTextareaWithFixedSizeAndResizeBoth() { + const textareaView = new TextareaView(); + + textareaView.minRows = textareaView.maxRows = 3; + textareaView.resize = 'both'; + + addToPlayground( '3 fixed rows, resize: both', textareaView ); +} + +function addToPlayground( name, view ) { + view.render(); + + const setLargeTextButton = new ButtonView(); + const clearButton = new ButtonView(); + + view.value = 'Hello world!'; + setLargeTextButton.label = 'Set large text'; + setLargeTextButton.withText = true; + setLargeTextButton.render(); + setLargeTextButton.class = 'ck-button-save'; + + clearButton.label = 'Clear'; + clearButton.withText = true; + clearButton.render(); + clearButton.class = 'ck-button-cancel'; + + setLargeTextButton.on( 'execute', () => { + view.value = ''; + view.value = 'Life doesn\'t allow us to execute every single plan perfectly. This especially seems to be the case when you ' + + 'travel. You plan it down to every minute with a big checklist. But when it comes to executing it, something always comes' + + ' up and you’re left with your improvising skills. You learn to adapt as you go.' + + 'Life doesn\'t allow us to execute every single plan perfectly. This especially seems to be the case when you ' + + 'travel. You plan it down to every minute with a big checklist. But when it comes to executing it, something always comes' + + ' up and you’re left with your improvising skills. You learn to adapt as you go.'; + } ); + + clearButton.on( 'execute', () => { + view.reset(); + } ); + + const container = document.createElement( 'div' ); + const heading = document.createElement( 'h2' ); + heading.textContent = name; + + container.appendChild( heading ); + container.appendChild( view.element! ); + container.appendChild( setLargeTextButton.element! ); + container.appendChild( clearButton.element! ); + document.querySelector( '.playground' )!.appendChild( container ); +} + +createPlainTextarea(); +createPlainTextareaWithMoreRows(); +createPlainTextareaWithFixedRows(); +createPlainTextareaWithSingleRow(); +createPlainTextareaWithVerticalResizeOnly(); +createPlainTextareaWithFixedSizeAndResizeBoth(); diff --git a/packages/ckeditor5-ui/tests/search/searchinfoview.js b/packages/ckeditor5-ui/tests/search/searchinfoview.js new file mode 100644 index 00000000000..6918fb53db2 --- /dev/null +++ b/packages/ckeditor5-ui/tests/search/searchinfoview.js @@ -0,0 +1,64 @@ +/** + * @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 SearchInfoView from '../../src/search/searchinfoview'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; + +describe( 'SearchInfoView', () => { + let view; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + view = new SearchInfoView(); + view.render(); + } ); + + afterEach( () => { + view.destroy(); + } ); + + describe( 'constructor()', () => { + it( 'creates and element from template with CSS classes', () => { + expect( view.element.classList.contains( 'ck' ) ).to.be.true; + expect( view.element.classList.contains( 'ck-search__info' ) ).to.be.true; + expect( view.element.classList.contains( 'ck-hidden' ) ).to.be.true; + } ); + + it( 'sets #isVisible and creates a DOM binding', () => { + expect( view.isVisible ).to.be.false; + + view.isVisible = true; + + expect( view.element.classList.contains( 'ck-hidden' ) ).to.be.false; + } ); + + it( 'sets #primaryText and creates a DOM binding', () => { + expect( view.primaryText ).to.equal( '' ); + + view.primaryText = 'foo'; + + expect( view.element.innerHTML ).to.equal( 'foo' ); + } ); + + it( 'sets #secondaryText', () => { + expect( view.secondaryText ).to.equal( '' ); + + view.secondaryText = 'bar'; + + expect( view.element.innerHTML ).to.equal( 'bar' ); + } ); + } ); + + describe( 'focus()', () => { + it( 'should focus #element', () => { + const spy = sinon.spy( view.element, 'focus' ); + + view.focus(); + + sinon.assert.calledOnce( spy ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-ui/tests/search/searchresultsview.js b/packages/ckeditor5-ui/tests/search/searchresultsview.js new file mode 100644 index 00000000000..c9ffd3f261a --- /dev/null +++ b/packages/ckeditor5-ui/tests/search/searchresultsview.js @@ -0,0 +1,97 @@ +/** + * @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 { Locale } from '@ckeditor/ckeditor5-utils'; +import SearchResultsView from '../../src/search/searchresultsview'; + +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import { ButtonView, View, ViewCollection } from '../../src'; + +describe( 'SearchResultsView', () => { + let locale, view; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + locale = new Locale(); + + view = new SearchResultsView( locale ); + view.children.addMany( [ createNonFocusableView(), createFocusableView(), createFocusableView() ] ); + view.render(); + + document.body.appendChild( view.element ); + } ); + + afterEach( () => { + view.destroy(); + view.element.remove(); + } ); + + describe( 'constructor()', () => { + it( 'creates and element from template with CSS classes', () => { + expect( view.element.classList.contains( 'ck' ) ).to.be.true; + expect( view.element.classList.contains( 'ck-search__results' ) ).to.be.true; + expect( view.element.getAttribute( 'tabIndex' ) ).to.equal( '-1' ); + } ); + + it( 'has a collection of #children', () => { + expect( view.children ).to.be.instanceOf( ViewCollection ); + + view.children.add( new ButtonView() ); + + expect( view.element.firstChild ).to.equal( view.children.first.element ); + } ); + } ); + + describe( 'focus()', () => { + it( 'does nothing for empty panel', () => { + expect( () => view.focus() ).to.not.throw(); + } ); + + it( 'focuses first focusable view in #children', () => { + view.focus(); + + sinon.assert.calledOnce( view.children.get( 1 ).focus ); + } ); + } ); + + describe( 'focusFirst()', () => { + it( 'focuses first focusable view in #children', () => { + view.focusFirst(); + + sinon.assert.calledOnce( view.children.get( 1 ).focus ); + } ); + } ); + + describe( 'focusLast()', () => { + it( 'focuses first focusable view in #children', () => { + view.focusLast(); + + sinon.assert.calledOnce( view.children.get( 2 ).focus ); + } ); + } ); + + function createFocusableView( name ) { + const view = createNonFocusableView(); + + view.name = name; + view.focus = () => view.element.focus(); + sinon.spy( view, 'focus' ); + + return view; + } + + function createNonFocusableView() { + const view = new View(); + + view.element = document.createElement( 'div' ); + view.element.textContent = 'foo'; + document.body.appendChild( view.element ); + + return view; + } +} ); diff --git a/packages/ckeditor5-ui/tests/search/text/searchtextqueryview.js b/packages/ckeditor5-ui/tests/search/text/searchtextqueryview.js new file mode 100644 index 00000000000..f1f1f2bc34d --- /dev/null +++ b/packages/ckeditor5-ui/tests/search/text/searchtextqueryview.js @@ -0,0 +1,169 @@ +/** + * @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 { Locale } from '@ckeditor/ckeditor5-utils'; +import { ButtonView, createLabeledInputText, IconView } from '@ckeditor/ckeditor5-ui'; +import SearchTextQueryView from '../../../src/search/text/searchtextqueryview'; +import { icons } from '@ckeditor/ckeditor5-core'; + +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; + +describe( 'SearchTextQueryView', () => { + let locale, view; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + locale = new Locale(); + + view = new SearchTextQueryView( locale, { + creator: createLabeledInputText, + label: 'Test' + } ); + + view.render(); + } ); + + afterEach( () => { + view.destroy(); + } ); + + describe( 'constructor()', () => { + it( 'sets view#locale', () => { + expect( view.locale ).to.equal( locale ); + } ); + + it( 'should have a label', () => { + expect( view.label ).to.equal( 'Test' ); + } ); + + describe( 'reset value button', () => { + it( 'should be created by default', () => { + const resetButtonView = view.fieldWrapperChildren.last; + + expect( resetButtonView ).to.equal( view.resetButtonView ); + expect( resetButtonView ).to.be.instanceOf( ButtonView ); + expect( resetButtonView.isVisible ).to.be.false; + expect( resetButtonView.tooltip ).to.be.true; + expect( resetButtonView.class ).to.equal( 'ck-search__reset' ); + expect( resetButtonView.label ).to.equal( 'Clear' ); + expect( resetButtonView.icon ).to.equal( icons.cancel ); + } ); + + it( 'should reset the search field value upon #execute', () => { + const resetSpy = testUtils.sinon.spy( view, 'reset' ); + + view.resetButtonView.fire( 'execute' ); + + sinon.assert.calledOnce( resetSpy ); + } ); + + it( 'should focus the field view upon #execute', () => { + const focusSpy = testUtils.sinon.spy( view, 'focus' ); + + view.resetButtonView.fire( 'execute' ); + + sinon.assert.calledOnce( focusSpy ); + } ); + + it( 'should get hidden upon #execute', () => { + view.resetButtonView.isVisible = true; + + view.resetButtonView.fire( 'execute' ); + + expect( view.resetButtonView.isVisible ).to.be.false; + } ); + + it( 'should fire the #reset event upon #execute', () => { + const spy = sinon.spy(); + + view.on( 'reset', spy ); + + view.resetButtonView.fire( 'execute' ); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'should be possible to hide using view\'s configuration', () => { + const view = new SearchTextQueryView( locale, { + creator: createLabeledInputText, + label: 'Test', + showResetButton: false + } ); + + expect( view.resetButtonView ).to.be.undefined; + expect( view.fieldWrapperChildren.last ).to.equal( view.labelView ); + + view.destroy(); + } ); + } ); + + describe( 'icon', () => { + it( 'should be added to the view by default', () => { + const iconView = view.fieldWrapperChildren.first; + + expect( view.iconView ).to.equal( iconView ); + expect( iconView ).to.equal( view.iconView ); + expect( iconView ).to.be.instanceOf( IconView ); + expect( iconView.content ).to.equal( icons.loupe ); + } ); + + it( 'should be possible to hide using view\'s configuration', () => { + const view = new SearchTextQueryView( locale, { + creator: createLabeledInputText, + label: 'Test', + showIcon: false + } ); + + expect( view.iconView ).to.be.undefined; + expect( view.fieldWrapperChildren.first ).to.equal( view.fieldView ); + + view.destroy(); + } ); + } ); + + describe( '#input event', () => { + it( 'should toggle visibility of the clear value button', () => { + view.fieldView.value = 'foo'; + view.fieldView.fire( 'input' ); + + expect( view.resetButtonView.isVisible ).to.be.true; + + view.fieldView.value = ''; + view.fieldView.fire( 'input' ); + + expect( view.resetButtonView.isVisible ).to.be.false; + } ); + } ); + } ); + + describe( 'reset()', () => { + it( 'should not fire the #reset event', () => { + const spy = sinon.spy(); + + view.on( 'reset', spy ); + + view.reset(); + + sinon.assert.notCalled( spy ); + } ); + + it( 'should clear the field view value in DOM', () => { + view.fieldView.element.value = 'foo'; + + view.reset(); + + expect( view.fieldView.element.value ).to.equal( '' ); + } ); + + it( 'should clear the field view value in InputView', () => { + view.fieldView.value = 'foo'; + + view.reset(); + + expect( view.fieldView.value ).to.equal( '' ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-ui/tests/search/text/searchtextview.js b/packages/ckeditor5-ui/tests/search/text/searchtextview.js new file mode 100644 index 00000000000..0cb0388bfbe --- /dev/null +++ b/packages/ckeditor5-ui/tests/search/text/searchtextview.js @@ -0,0 +1,577 @@ +/** + * @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 { FocusTracker, KeystrokeHandler } from '@ckeditor/ckeditor5-utils'; +import { + FocusCycler, + InputNumberView, + InputTextView, + LabeledFieldView, + ListView, + SearchInfoView, + SearchTextView, + View, + ViewCollection, + createLabeledInputNumber +} from '../../../src'; +import Locale from '@ckeditor/ckeditor5-utils/src/locale'; +import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; + +describe( 'SearchTextView', () => { + let view, filteredView; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + filteredView = new ListView(); + filteredView.filter = () => { + return { + resultsCount: 1, + totalItemsCount: 5 + }; + }; + + view = new SearchTextView( new Locale(), { + filteredView, + queryView: { + label: 'test label' + } + } ); + + view.render(); + + document.body.appendChild( view.element ); + } ); + + afterEach( () => { + filteredView.destroy(); + view.destroy(); + view.element.remove(); + } ); + + describe( 'constructor()', () => { + it( 'creates and element from template with CSS classes and attributes', () => { + expect( view.element.classList.contains( 'ck' ) ).to.true; + expect( view.element.classList.contains( 'ck-search' ) ).to.true; + expect( view.element.getAttribute( 'tabindex' ) ).to.equal( '-1' ); + } ); + + it( 'supports extra CSS class in the config', () => { + const view = new SearchTextView( new Locale(), { + filteredView, + queryView: { + label: 'foo' + }, + class: 'bar' + } ); + + view.render(); + + expect( view.element.classList.contains( 'bar' ) ).to.true; + + view.destroy(); + } ); + + it( 'creates an instance of FocusTracker', () => { + expect( view.focusTracker ).to.be.instanceOf( FocusTracker ); + } ); + + it( 'creates an instance of KeystrokeHandler', () => { + expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler ); + } ); + + it( 'creates and instance of FocusCycler', () => { + expect( view.focusCycler ).to.be.instanceOf( FocusCycler ); + } ); + + it( 'assigns an instance of a view to #filteredView', () => { + expect( view.filteredView ).to.equal( filteredView ); + } ); + + it( 'creates a #resultsView as a container for the #filteredView', () => { + expect( view.resultsView ).to.be.instanceOf( View ); + + expect( view.resultsView.element.classList.contains( 'ck' ) ).to.true; + expect( view.resultsView.element.classList.contains( 'ck-search__results' ) ).to.true; + + expect( view.resultsView.children.first ).to.equal( view.infoView ); + expect( view.resultsView.children.last ).to.equal( filteredView ); + } ); + + it( 'sets #resultsCount', () => { + expect( view.resultsCount ).to.equal( 1 ); + } ); + + it( 'sets #totalItemsCount', () => { + expect( view.totalItemsCount ).to.equal( 5 ); + } ); + + it( 'should update #resultsCount and #totalItemsCount upon #search event', () => { + expect( view.resultsCount ).to.equal( 1 ); + expect( view.totalItemsCount ).to.equal( 5 ); + + view.fire( 'search', { resultsCount: 5, totalItemsCount: 10 } ); + + expect( view.resultsCount ).to.equal( 5 ); + expect( view.totalItemsCount ).to.equal( 10 ); + } ); + + it( 'should have #children view collection', () => { + expect( view.children ).to.be.instanceOf( ViewCollection ); + } ); + + it( 'should have #focusableChildren view collection', () => { + expect( view.focusableChildren ).to.be.instanceOf( ViewCollection ); + } ); + + describe( '#queryView', () => { + it( 'gets created as labeled text view if not configured otherwise', () => { + expect( view.queryView ).to.be.instanceOf( LabeledFieldView ); + expect( view.queryView.fieldView ).to.be.instanceOf( InputTextView ); + expect( view.queryView.label ).to.equal( 'test label' ); + } ); + + it( 'gets created by a custom view creator configured by the user', () => { + const view = new SearchTextView( new Locale(), { + filteredView, + queryView: { + label: 'foo', + creator: createLabeledInputNumber + }, + class: 'bar' + } ); + + view.render(); + + expect( view.queryView ).to.be.instanceOf( LabeledFieldView ); + expect( view.queryView.fieldView ).to.be.instanceOf( InputNumberView ); + + view.destroy(); + } ); + + it( 'shoud trigger #search() upon #input', () => { + const spy = sinon.spy( view, 'search' ); + + view.queryView.fieldView.fire( 'input' ); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'should reset the entire view if fired #reset', () => { + const spy = sinon.spy( view, 'reset' ); + + view.queryView.fire( 'reset' ); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'should be bound to #isEnabled', () => { + expect( view.queryView.isEnabled ).to.be.true; + + view.isEnabled = false; + + expect( view.queryView.isEnabled ).to.be.false; + } ); + } ); + + describe( '#infoView', () => { + let view; + + beforeEach( () => { + filteredView.filter = () => { + return { + resultsCount: 5, + totalItemsCount: 5 + }; + }; + + view = new SearchTextView( new Locale(), { + filteredView, + queryView: { + label: 'test label' + } + } ); + + view.render(); + document.body.appendChild( view.element ); + } ); + + afterEach( () => { + view.destroy(); + view.element.remove(); + } ); + + describe( 'if not specified', () => { + it( 'is an instance of SearchInfoView if not specified in the config', () => { + expect( view.infoView ).to.be.instanceOf( SearchInfoView ); + expect( view.infoView.isVisible ).to.be.false; + } ); + + it( 'comes with a default behavior for no search results', () => { + filteredView.filter = () => { + return { + resultsCount: 0, + totalItemsCount: 5 + }; + }; + + const view = new SearchTextView( new Locale(), { + filteredView, + queryView: { + label: 'test label' + } + } ); + + view.render(); + view.search( 'will not be found' ); + + expect( view.infoView.isVisible ).to.be.true; + expect( view.infoView.primaryText ).to.equal( 'No results found' ); + expect( view.infoView.secondaryText ).to.equal( '' ); + + view.destroy(); + } ); + + it( 'comes with a default behavior for no searchable items', () => { + filteredView.filter = () => { + return { + resultsCount: 0, + totalItemsCount: 0 + }; + }; + + const view = new SearchTextView( new Locale(), { + filteredView, + queryView: { + label: 'test label' + } + } ); + + view.render(); + + expect( view.infoView.isVisible ).to.be.true; + expect( view.infoView.primaryText ).to.equal( 'No searchable items' ); + expect( view.infoView.secondaryText ).to.equal( '' ); + + view.destroy(); + } ); + + it( 'allows customization of info texts', () => { + filteredView.filter = () => { + return { + resultsCount: 0, + totalItemsCount: 0 + }; + }; + + const view = new SearchTextView( new Locale(), { + filteredView, + queryView: { + label: 'test label' + }, + infoView: { + text: { + notFound: { + primary: 'foo', + secondary: 'bar' + }, + noSearchableItems: { + primary: 'baz', + secondary: 'qux' + } + } + } + } ); + + view.render(); + + expect( view.infoView.isVisible ).to.be.true; + expect( view.infoView.primaryText ).to.equal( 'baz' ); + expect( view.infoView.secondaryText ).to.equal( 'qux' ); + + filteredView.filter = () => { + return { + resultsCount: 0, + totalItemsCount: 5 + }; + }; + + view.search( 'test' ); + + expect( view.infoView.isVisible ).to.be.true; + expect( view.infoView.primaryText ).to.equal( 'foo' ); + expect( view.infoView.secondaryText ).to.equal( 'bar' ); + + view.destroy(); + } ); + + it( 'allows info texts specified as functions', () => { + filteredView.filter = () => { + return { + resultsCount: 0, + totalItemsCount: 0 + }; + }; + + const dynamicLabelText = ( query, resultsCount, totalItemsCount ) => + `"${ query }" ${ resultsCount } of ${ totalItemsCount }`; + + const view = new SearchTextView( new Locale(), { + filteredView, + queryView: { + label: 'test label' + }, + infoView: { + text: { + notFound: { + primary: dynamicLabelText, + secondary: dynamicLabelText + }, + noSearchableItems: { + primary: dynamicLabelText, + secondary: dynamicLabelText + } + } + } + } ); + + view.render(); + + expect( view.infoView.isVisible ).to.be.true; + expect( view.infoView.primaryText ).to.equal( '"" 0 of 0' ); + expect( view.infoView.secondaryText ).to.equal( '"" 0 of 0' ); + + filteredView.filter = () => { + return { + resultsCount: 0, + totalItemsCount: 5 + }; + }; + + view.search( 'test' ); + + expect( view.infoView.isVisible ).to.be.true; + expect( view.infoView.primaryText ).to.equal( '"test" 0 of 5' ); + expect( view.infoView.secondaryText ).to.equal( '"test" 0 of 5' ); + + view.destroy(); + } ); + } ); + + it( 'accpets a view from the configuration', () => { + const customInfoView = new View(); + customInfoView.setTemplate( { + tag: 'div', + attributes: { + class: 'custom' + } + } ); + + const view = new SearchTextView( new Locale(), { + filteredView, + queryView: { + label: 'test label' + }, + infoView: { + instance: customInfoView + } + } ); + + view.render(); + + expect( view.infoView ).to.equal( customInfoView ); + expect( view.resultsView.children.first ).to.equal( customInfoView ); + expect( view.resultsView.children.last ).to.equal( filteredView ); + + view.destroy(); + } ); + } ); + } ); + + describe( 'render()', () => { + describe( 'focus tracking and cycling', () => { + it( 'should add #queryView and #resultsView to the #focusableChildren collection', () => { + expect( view.focusableChildren.map( view => view ) ).to.have.ordered.members( [ + view.queryView, view.resultsView + ] ); + } ); + + describe( 'activates keyboard navigation', () => { + it( 'makes "tab" focus the next focusable item', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Mock the query input is focused. + view.focusTracker.isFocused = true; + view.focusTracker.focusedElement = view.queryView.element; + + const spy = sinon.spy( view.resultsView, 'focus' ); + + view.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); + + it( 'makes "shift + tab" focus the previous focusable item', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + shiftKey: true, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Mock the results are focused. + view.focusTracker.isFocused = true; + view.focusTracker.focusedElement = filteredView.element; + + const spy = sinon.spy( view.resultsView, 'focus' ); + + view.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); + + it( 'should allow adding extra views to the focus cycling logic', () => { + const anotherFocusableView = new View(); + + anotherFocusableView.setTemplate( { + tag: 'div', + attributes: { + tabindex: -1 + } + } ); + + anotherFocusableView.focus = sinon.spy(); + + anotherFocusableView.render(); + + view.focusTracker.add( anotherFocusableView ); + view.focusableChildren.add( anotherFocusableView ); + view.element.appendChild( anotherFocusableView.element ); + + // Mock the query input is focused. + view.focusTracker.isFocused = true; + view.focusTracker.focusedElement = view.queryView.element; + + const keyEvtData = { + keyCode: keyCodes.tab, + shiftKey: true, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + view.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( anotherFocusableView.focus ); + } ); + } ); + + it( 'intercepts the arrow* events and overrides the default toolbar behavior', () => { + const keyEvtData = { + stopPropagation: sinon.spy() + }; + + keyEvtData.keyCode = keyCodes.arrowdown; + view.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + + keyEvtData.keyCode = keyCodes.arrowup; + view.keystrokes.press( keyEvtData ); + sinon.assert.calledTwice( keyEvtData.stopPropagation ); + + keyEvtData.keyCode = keyCodes.arrowleft; + view.keystrokes.press( keyEvtData ); + sinon.assert.calledThrice( keyEvtData.stopPropagation ); + + keyEvtData.keyCode = keyCodes.arrowright; + view.keystrokes.press( keyEvtData ); + sinon.assert.callCount( keyEvtData.stopPropagation, 4 ); + } ); + } ); + + it( 'should add #queryView and #resultsView to the #children view collection', () => { + expect( view.children.map( child => child ) ).to.deep.equal( [ view.queryView, view.resultsView ] ); + + expect( view.element.firstChild ).to.equal( view.queryView.element ); + expect( view.element.lastChild ).to.equal( view.resultsView.element ); + } ); + } ); + + describe( 'focus()', () => { + it( 'focuses the #queryView', () => { + const spy = sinon.spy( view.queryView, 'focus' ); + + view.focus(); + + sinon.assert.calledOnce( spy ); + } ); + } ); + + describe( 'reset()', () => { + it( 'resets the #queryView', () => { + const spy = sinon.spy( view.queryView, 'reset' ); + + view.reset(); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'resets the search results', () => { + const spy = sinon.spy( view, 'search' ); + + view.reset(); + + sinon.assert.calledOnceWithExactly( spy, '' ); + } ); + } ); + + describe( 'search()', () => { + it( 'should escape the query when creating a RegExp to avoid mismatches', () => { + const spy = sinon.spy( filteredView, 'filter' ); + + view.search( 'foo[ar]' ); + sinon.assert.calledOnceWithExactly( spy, /foo\[ar\]/gi ); + + view.search( 'foo/bar' ); + sinon.assert.calledWithExactly( spy.secondCall, /foo\/bar/gi ); + } ); + + it( 'should filter the #filteredView', () => { + const spy = sinon.spy( filteredView, 'filter' ); + + view.search( 'foo' ); + + sinon.assert.calledOnceWithExactly( spy, /foo/gi ); + } ); + + it( 'should fire the #search event with the query and search stats', done => { + filteredView.filter = () => { + return { + resultsCount: 1, + totalItemsCount: 10 + }; + }; + + view.on( 'search', ( evt, data ) => { + expect( data ).to.deep.equal( { + query: 'foo', + resultsCount: 1, + totalItemsCount: 10 + } ); + + done(); + } ); + + view.search( 'foo' ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-ui/tests/spinner/spinner.js b/packages/ckeditor5-ui/tests/spinner/spinner.js new file mode 100644 index 00000000000..df991060ff7 --- /dev/null +++ b/packages/ckeditor5-ui/tests/spinner/spinner.js @@ -0,0 +1,44 @@ +/** + * @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 SpinnerView from '../../src/spinner/spinnerview'; + +describe( 'SpinnerView', () => { + let view; + + beforeEach( () => { + view = new SpinnerView(); + view.render(); + } ); + + describe( 'constructor()', () => { + it( 'sets #isVisible', () => { + expect( view.isVisible ).to.equal( false ); + } ); + + it( 'creates element from template', () => { + expect( view.element.tagName ).to.equal( 'SPAN' ); + expect( view.element.classList.contains( 'ck' ) ).to.be.true; + expect( view.element.classList.contains( 'ck-spinner-container' ) ).to.be.true; + + expect( view.element.children[ 0 ].tagName ).to.equal( 'SPAN' ); + expect( view.element.children[ 0 ].classList.contains( 'ck' ) ).to.be.true; + expect( view.element.children[ 0 ].classList.contains( 'ck-spinner' ) ).to.be.true; + } ); + } ); + + describe( 'bindings', () => { + it( 'should react to changes in view#isVisible', () => { + view.isVisible = true; + + expect( view.element.classList.contains( 'ck-hidden' ) ).to.be.false; + + view.isVisible = false; + + expect( view.element.classList.contains( 'ck-hidden' ) ).to.be.true; + } ); + } ); +} ); + diff --git a/packages/ckeditor5-ui/tests/textarea/textareaview.js b/packages/ckeditor5-ui/tests/textarea/textareaview.js new file mode 100644 index 00000000000..8340f90a6ee --- /dev/null +++ b/packages/ckeditor5-ui/tests/textarea/textareaview.js @@ -0,0 +1,258 @@ +/** + * @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 { global } from '@ckeditor/ckeditor5-utils'; +import TextareaView from '../../src/textarea/textareaview'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import { expectToThrowCKEditorError } from '@ckeditor/ckeditor5-utils/tests/_utils/utils'; + +describe( 'TextareaView', () => { + let wrapper, view; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + // The reset wrapper is needed for proper line height calculation. + wrapper = document.createElement( 'div' ); + wrapper.classList.add( 'ck', 'ck-reset_all' ); + + view = new TextareaView(); + view.render(); + wrapper.appendChild( view.element ); + document.body.appendChild( wrapper ); + } ); + + afterEach( () => { + view.destroy(); + wrapper.remove(); + } ); + + describe( 'constructor()', () => { + it( 'should create element from template', () => { + expect( view.element.tagName ).to.equal( 'TEXTAREA' ); + expect( view.element.getAttribute( 'type' ) ).to.be.null; + expect( view.element.classList.contains( 'ck' ) ).to.be.true; + expect( view.element.classList.contains( 'ck-input' ) ).to.be.true; + expect( view.element.classList.contains( 'ck-textarea' ) ).to.be.true; + } ); + + it( 'should have default resize attribute value', () => { + expect( view.element.style.resize ).to.equal( 'none' ); + } ); + + it( 'should throw if #minHeight is greater than #maxHeight', () => { + view.minRows = 2; + view.maxRows = 3; + view.minRows = view.maxRows; + + expectToThrowCKEditorError( + () => { view.minRows = 4; }, + 'ui-textarea-view-min-rows-greater-than-max-rows', + { + view, + minRows: 4, + maxRows: 3 + } + ); + + expectToThrowCKEditorError( + () => { view.minRows = 5; }, + 'ui-textarea-view-min-rows-greater-than-max-rows', + { + view, + minRows: 5, + maxRows: 3 + } + ); + } ); + } ); + + describe( 'reset()', () => { + it( 'should reset the #value of the view', () => { + view.value = 'foo'; + + view.reset(); + + expect( view.value ).to.equal( '' ); + } ); + + it( 'should reset the value of the DOM #element', () => { + view.element.value = 'foo'; + + view.reset(); + + expect( view.element.value ).to.equal( '' ); + } ); + + it( 'should update the size of the view', () => { + view.element.value = '1'; + view.fire( 'input' ); + + const initialHeight = view.element.style.height; + const initialScrollTop = view.element.scrollTop; + + view.element.value = '1\n2\n3\n4\n5\n6'; + view.fire( 'input' ); + expect( view.element.style.height ).to.not.equal( initialHeight ); + expect( view.element.scrollTop ).to.not.equal( initialScrollTop ); + + view.reset(); + expect( view.element.style.height ).to.equal( initialHeight ); + expect( view.element.scrollTop ).to.equal( initialScrollTop ); + } ); + } ); + + describe( 'render()', () => { + it( 'should resize the view on the #input event and scroll to the end', async () => { + const initialHeight = view.element.style.height; + const initialScrollTop = view.element.scrollTop; + + view.element.value = '1\n2\n3\n4\n5\n6'; + + expect( view.element.style.height ).to.equal( initialHeight ); + expect( view.element.scrollTop ).to.equal( initialScrollTop ); + + view.fire( 'input' ); + + expect( view.element.style.height ).to.not.equal( initialHeight ); + expect( view.element.scrollTop ).to.not.equal( initialScrollTop ); + } ); + + it( 'should resize the view on the #value change using requestAnimationFrame to let the browser update the UI', async () => { + const initialHeight = view.element.style.height; + + view.value = 'foo\nbar\nbaz\nqux'; + + expect( view.element.style.height ).to.equal( initialHeight ); + + await requestAnimationFrame(); + expect( view.element.style.height ).to.not.equal( initialHeight ); + } ); + + describe( 'dynamic resizing', () => { + it( 'should respect #minRows and #maxRows', async () => { + // One row, it's less than default #minRows. + view.value = '1'; + await requestAnimationFrame(); + const oneRowHeight = parseFloat( view.element.style.height ); + + // Two rows (default). + view.value = '1\n2'; + await requestAnimationFrame(); + const twoRowsHeight = parseFloat( view.element.style.height ); + expect( twoRowsHeight ).to.equal( oneRowHeight ); + + // Three rows (more then default #minRows), resize again. + view.value = '1\n2\n3'; + await requestAnimationFrame(); + const threeRowsHeight = parseFloat( view.element.style.height ); + expect( threeRowsHeight ).to.be.greaterThan( twoRowsHeight ); + + // Four rows. + view.value = '1\n2\n3\n4'; + await requestAnimationFrame(); + const fourRowsHeight = parseFloat( view.element.style.height ); + expect( fourRowsHeight ).to.be.greaterThan( threeRowsHeight ); + + // Five rows (default #maxRows), this will be the max height. + view.value = '1\n2\n3\n4\n5'; + await requestAnimationFrame(); + const maxHeight = parseFloat( view.element.style.height ); + expect( maxHeight ).to.be.greaterThan( fourRowsHeight ); + + // Six rows (more than #maxRows), the view is no longer growing. + view.value = '1\n2\n3\n4\n5\n6'; + await requestAnimationFrame(); + expect( parseFloat( view.element.style.height ) ).to.equal( maxHeight ); + + // Going back to #minRows + view.value = '1'; + await requestAnimationFrame(); + expect( parseFloat( view.element.style.height ) ).to.equal( twoRowsHeight ); + } ); + } ); + + describe( '#update event', () => { + it( 'should get fired on the user #input', () => { + const spy = sinon.spy(); + + view.on( 'update', spy ); + + view.element.value = '1\n2\n3\n4\n5\n6'; + + view.fire( 'input' ); + sinon.assert.calledOnce( spy ); + + view.fire( 'input' ); + + // The event gets fired whether the view is changing dimensions or not. + sinon.assert.calledTwice( spy ); + } ); + + it( 'should get fired on #value change', async () => { + const spy = sinon.spy(); + + view.on( 'update', spy ); + + view.value = '1\n2\n3\n4\n5\n6'; + + await requestAnimationFrame(); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'should be fired upon reset()', async () => { + const spy = sinon.spy(); + + view.on( 'update', spy ); + + view.value = '1\n2\n3\n4\n5\n6'; + + await requestAnimationFrame(); + + sinon.assert.calledOnce( spy ); + + view.reset(); + + sinon.assert.calledTwice( spy ); + } ); + } ); + } ); + + describe( 'DOM bindings', () => { + beforeEach( () => { + view.value = 'foo'; + view.id = 'bar'; + } ); + + describe( 'rows attribute', () => { + it( 'should react on view#minRows', () => { + expect( view.element.getAttribute( 'rows' ) ).to.equal( '2' ); + + view.minRows = 5; + + expect( view.element.getAttribute( 'rows' ) ).to.equal( '5' ); + } ); + } ); + + describe( 'resize attribute', () => { + it( 'should react on view#reisze', () => { + expect( view.element.style.resize ).to.equal( 'none' ); + + view.resize = 'vertical'; + + expect( view.element.style.resize ).to.equal( 'vertical' ); + } ); + } ); + } ); + + function requestAnimationFrame() { + return new Promise( res => { + global.window.requestAnimationFrame( res ); + } ); + } +} ); diff --git a/packages/ckeditor5-ui/theme/components/autocomplete/autocomplete.css b/packages/ckeditor5-ui/theme/components/autocomplete/autocomplete.css new file mode 100644 index 00000000000..561f5892265 --- /dev/null +++ b/packages/ckeditor5-ui/theme/components/autocomplete/autocomplete.css @@ -0,0 +1,22 @@ +/* + * 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 + */ + +.ck.ck-autocomplete { + position: relative; + + & > .ck-search__results { + position: absolute; + z-index: var(--ck-z-modal); + + &.ck-search__results_n { + bottom: 100%; + } + + &.ck-search__results_s { + top: 100%; + bottom: auto; + } + } +} diff --git a/packages/ckeditor5-ui/theme/components/formheader/formheader.css b/packages/ckeditor5-ui/theme/components/formheader/formheader.css index 930f3d11681..bb85138701e 100644 --- a/packages/ckeditor5-ui/theme/components/formheader/formheader.css +++ b/packages/ckeditor5-ui/theme/components/formheader/formheader.css @@ -9,4 +9,12 @@ flex-wrap: nowrap; align-items: center; justify-content: space-between; + + & .ck-icon { + margin-right: var(--ck-spacing-medium); + } + + & h2.ck-form__header__label { + flex-grow: 1; + } } diff --git a/packages/ckeditor5-ui/theme/components/highlightedtext/highlightedtext.css b/packages/ckeditor5-ui/theme/components/highlightedtext/highlightedtext.css new file mode 100644 index 00000000000..5462bcd81fb --- /dev/null +++ b/packages/ckeditor5-ui/theme/components/highlightedtext/highlightedtext.css @@ -0,0 +1,12 @@ +/* + * 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 + */ + +.ck.ck-highlighted-text mark { + background: var(--ck-color-highlight-background); + vertical-align: initial; + font-weight: inherit; + line-height: inherit; + font-size: inherit; +} diff --git a/packages/ckeditor5-ui/theme/components/search/search.css b/packages/ckeditor5-ui/theme/components/search/search.css new file mode 100644 index 00000000000..28c4ec1c90e --- /dev/null +++ b/packages/ckeditor5-ui/theme/components/search/search.css @@ -0,0 +1,43 @@ +/* + * 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 "@ckeditor/ckeditor5-ui/theme/mixins/_dir.css"; + +.ck.ck-search { + & > .ck-labeled-field-view { + & > .ck-labeled-field-view__input-wrapper > .ck-icon { + position: absolute; + top: 50%; + transform: translateY(-50%); + + @mixin ck-dir ltr { + left: var(--ck-spacing-medium); + } + + @mixin ck-dir rtl { + right: var(--ck-spacing-medium); + } + } + + & .ck-search__reset { + position: absolute; + top: 50%; + transform: translateY(-50%); + } + } + + & > .ck-search__results { + & > .ck-search__info { + & > span:first-child { + display: block; + } + + /* Hide the filtered view when nothing was found */ + &:not(.ck-hidden) ~ * { + display: none; + } + } + } +} diff --git a/packages/ckeditor5-ui/theme/components/spinner/spinner.css b/packages/ckeditor5-ui/theme/components/spinner/spinner.css new file mode 100644 index 00000000000..16969cc9742 --- /dev/null +++ b/packages/ckeditor5-ui/theme/components/spinner/spinner.css @@ -0,0 +1,23 @@ +/* + * 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 + */ + +:root { + --ck-toolbar-spinner-size: 18px; +} + +.ck.ck-spinner-container { + display: block; + position: relative; +} + +.ck.ck-spinner { + position: absolute; + top: 50%; + left: 0; + right: 0; + margin: 0 auto; + transform: translateY(-50%); + z-index: 1; +} diff --git a/packages/ckeditor5-ui/theme/components/textarea/textarea.css b/packages/ckeditor5-ui/theme/components/textarea/textarea.css new file mode 100644 index 00000000000..563b03d7a66 --- /dev/null +++ b/packages/ckeditor5-ui/theme/components/textarea/textarea.css @@ -0,0 +1,10 @@ +/* + * 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 + */ + +/* + * Note: This file should contain the wireframe styles only. But since there are no such styles, + * it acts as a message to the builder telling that it should look for the corresponding styles + * **in the theme** when compiling the editor. + */ diff --git a/packages/ckeditor5-utils/src/index.ts b/packages/ckeditor5-utils/src/index.ts index 9f2c77cfcb6..e9d021373fb 100644 --- a/packages/ckeditor5-utils/src/index.ts +++ b/packages/ckeditor5-utils/src/index.ts @@ -52,6 +52,7 @@ export { default as findClosestScrollableAncestor } from './dom/findclosestscrol export { default as global } from './dom/global'; export { default as getAncestors } from './dom/getancestors'; export { default as getDataFromElement } from './dom/getdatafromelement'; +export { default as getBorderWidths } from './dom/getborderwidths'; export { default as isText } from './dom/istext'; export { default as Rect, type RectSource } from './dom/rect'; export { default as ResizeObserver } from './dom/resizeobserver'; diff --git a/scripts/ci/check-dependencies-versions-match.js b/scripts/ci/check-dependencies-versions-match.js index 55b9713d9f6..f43aed6ca3d 100644 --- a/scripts/ci/check-dependencies-versions-match.js +++ b/scripts/ci/check-dependencies-versions-match.js @@ -19,6 +19,7 @@ const { globSync } = require( 'glob' ); const fs = require( 'fs-extra' ); const upath = require( 'upath' ); const { execSync } = require( 'child_process' ); +const isCKEditor5PackageFactory = require( '../release/utils/isckeditor5packagefactory' ); const versionsCache = {}; const shouldFix = process.argv[ 2 ] === '--fix'; @@ -32,29 +33,48 @@ const [ packageJsons, pathMappings ] = getPackageJsons( [ 'external/ckeditor5-commercial/package.json' ] ); -const expectedDependencies = getExpectedDepsVersions( packageJsons ); +main().catch( err => { + console.error( err ); -if ( shouldFix ) { - fixDependenciesVersions( expectedDependencies, packageJsons, pathMappings ); -} else { - checkDependenciesMatch( expectedDependencies, packageJsons ); + process.exit( 1 ); +} ); + +async function main() { + const isCkeditor5Package = await isCKEditor5PackageFactory(); + const expectedDependencies = getExpectedDepsVersions( packageJsons, isCkeditor5Package ); + + if ( shouldFix ) { + fixDependenciesVersions( expectedDependencies, packageJsons, pathMappings, isCkeditor5Package ); + } else { + checkDependenciesMatch( expectedDependencies, packageJsons, isCkeditor5Package ); + } } /** * @param {Object.} expectedDependencies * @param {Array.} packageJsons * @param {Object.} pathMappings + * @param {Function} isCkeditor5Package */ -function fixDependenciesVersions( expectedDependencies, packageJsons, pathMappings ) { +function fixDependenciesVersions( expectedDependencies, packageJsons, pathMappings, isCkeditor5Package ) { packageJsons .filter( packageJson => packageJson.dependencies ) .forEach( packageJson => { - Object.entries( packageJson.dependencies ) - .forEach( ( [ dependency, version ] ) => { - if ( version !== expectedDependencies[ dependency ] ) { - packageJson.dependencies[ dependency ] = expectedDependencies[ dependency ]; - } - } ); + for ( const [ dependency, version ] of Object.entries( packageJson.dependencies ) ) { + if ( !isCkeditor5Package( dependency ) || version === expectedDependencies[ dependency ] ) { + continue; + } + + packageJson.dependencies[ dependency ] = expectedDependencies[ dependency ]; + } + + for ( const [ dependency, version ] of Object.entries( packageJson.devDependencies ) ) { + if ( !isCkeditor5Package( dependency ) || version === expectedDependencies[ dependency ] ) { + continue; + } + + packageJson.devDependencies[ dependency ] = expectedDependencies[ dependency ]; + } fs.writeJsonSync( pathMappings[ packageJson.name ], packageJson, { spaces: 2 } ); } ); @@ -64,16 +84,19 @@ function fixDependenciesVersions( expectedDependencies, packageJsons, pathMappin /** * @param {Object.} expectedDependencies + * @param {Function} isCkeditor5Package * @param {Array.} packageJsons */ -function checkDependenciesMatch( expectedDependencies, packageJsons ) { +function checkDependenciesMatch( expectedDependencies, packageJsons, isCkeditor5Package ) { const errors = packageJsons .filter( packageJson => packageJson.dependencies ) - .flatMap( packageJson => Object.entries( packageJson.dependencies ) + .flatMap( packageJson => Object.entries( getDepsAndDevDeps( packageJson ) ) .map( ( [ dependency, version ] ) => { - if ( version !== expectedDependencies[ dependency ] ) { - return getWrongVersionErrorMsg( dependency, packageJson.name, version, expectedDependencies ); + if ( !isCkeditor5Package( dependency ) || version === expectedDependencies[ dependency ] ) { + return ''; } + + return getWrongVersionErrorMsg( dependency, packageJson.name, version, expectedDependencies ); } ) .filter( Boolean ) ); @@ -100,16 +123,21 @@ function getWrongVersionErrorMsg( dependency, name, version, expectedDependencie /** * @param {Array.} packageJsons + * @param {Function} isCkeditor5Package * @return {Object.} expectedDependencies */ -function getExpectedDepsVersions( packageJsons ) { +function getExpectedDepsVersions( packageJsons, isCkeditor5Package ) { return packageJsons - .map( packageJson => packageJson.dependencies ) + .map( packageJson => getDepsAndDevDeps( packageJson ) ) .filter( Boolean ) .reduce( ( expectedDependencies, dependencies ) => { - Object.entries( dependencies ).forEach( ( [ dependency, version ] ) => { + for ( const [ dependency, version ] of Object.entries( dependencies ) ) { + if ( !isCkeditor5Package( dependency ) ) { + continue; + } + expectedDependencies[ dependency ] = getNewestVersion( dependency, version, expectedDependencies[ dependency ] ); - } ); + } return expectedDependencies; }, {} ); @@ -158,3 +186,11 @@ function getPackageJsons( directories ) { return [ packageJsons, nameToPathMappings ]; } + +/** + * @param {Object.} packageJson + * @returns {Object.} + */ +function getDepsAndDevDeps( packageJson ) { + return { ...packageJson.dependencies, ...( packageJson.devDependencies || {} ) }; +} diff --git a/scripts/ci/is-ckeditor5-ready-to-release.js b/scripts/ci/is-ckeditor5-ready-to-release.js new file mode 100755 index 00000000000..aae0faa04a8 --- /dev/null +++ b/scripts/ci/is-ckeditor5-ready-to-release.js @@ -0,0 +1,46 @@ +#!/usr/bin/env node + +/** + * @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 + */ + +/* eslint-env node */ + +'use strict'; + +const { execSync } = require( 'child_process' ); +const releaseTools = require( '@ckeditor/ckeditor5-dev-release-tools' ); +const semver = require( 'semver' ); + +const latestPublishedVersion = execSync( 'npm view ckeditor5@latest version', { encoding: 'utf-8' } ).trim(); +const changelogVersion = releaseTools.getLastFromChangelog(); + +if ( getVersionTag( changelogVersion ) !== 'latest' ) { + console.log( `Aborting due non-latest changelog version (${ changelogVersion }).` ); + process.exit( 1 ); +} + +if ( changelogVersion === latestPublishedVersion ) { + console.log( 'Nothing to release.' ); + process.exit( 1 ); +} + +if ( semver.lt( changelogVersion, latestPublishedVersion ) ) { + console.log( `The proposed changelog (${ changelogVersion }) version is lower than the published one (${ latestPublishedVersion }).` ); + process.exit( 1 ); +} + +console.log( 'CKEditor 5 is ready to release.' ); + +/** + * Returns an npm tag based on the specified release version. + * + * @param {String} version + * @returns {String} + */ +function getVersionTag( version ) { + const [ versionTag ] = semver.prerelease( version ) || [ 'latest' ]; + + return versionTag; +} diff --git a/scripts/ci/trigger-ckeditor5-continuous-integration.js b/scripts/ci/trigger-ckeditor5-continuous-integration.js index 0cc68ce6806..d3006f96109 100755 --- a/scripts/ci/trigger-ckeditor5-continuous-integration.js +++ b/scripts/ci/trigger-ckeditor5-continuous-integration.js @@ -35,7 +35,7 @@ main() * @returns {Object} CircleCI API response as JSON. */ function main() { - const { repository, commit } = getOptions( process.argv.slice( 2 ) ); + const { repository, commit, branch } = getOptions( process.argv.slice( 2 ) ); const requestUrl = `https://circleci.com/api/v2/project/github/${ INTEGRATION_CI_ORGANIZATION }/${ INTEGRATION_CI_REPOSITORY }/pipeline`; @@ -47,11 +47,11 @@ function main() { 'Circle-Token': CKE5_CIRCLE_TOKEN }, body: JSON.stringify( { - branch: 'master', + branch, parameters: { triggerRepositorySlug: repository, triggerCommitHash: commit, - isExternal: true + isRelease: branch === 'release' } } ) }; @@ -72,16 +72,22 @@ function main() { * @returns {Object} options * @returns {String} options.commit * @returns {String} options.repository + * @returns {String} [options.branch='master'] */ function getOptions( argv ) { return minimist( argv, { string: [ 'commit', - 'repository' + 'repository', + 'branch' ], alias: { + b: 'branch', c: 'commit', r: 'repository' + }, + default: { + branch: 'master' } } ); } diff --git a/scripts/clean-up-svg-icons.js b/scripts/clean-up-svg-icons.js index 669fe79f341..672b70c148b 100644 --- a/scripts/clean-up-svg-icons.js +++ b/scripts/clean-up-svg-icons.js @@ -12,10 +12,13 @@ // Usage: // yarn run clean-up-svg-icons // +// yarn run clean-up-svg-icons +// // The can be either a direct path to a SVG file, or a path to a directory. Glob patterns in path are supported. +// Multiple arguments (paths) in one call are supported. // // To optimize the entire project run: -// yarn clean-up-svg-icons packages/**/theme/icons +// yarn run clean-up-svg-icons 'use strict'; @@ -32,7 +35,10 @@ const EXCLUDED_ICONS = [ 'project-logo.svg' ]; -const globPattern = minimist( process.argv.slice( 2 ) )._ +// A pattern to match all the icons. +const ALL_ICONS_PATTERN = 'packages/**/theme/icons'; + +const globPattern = parseArguments( process.argv.slice( 2 ) ) .map( pathToIcon => pathToIcon.endsWith( '.svg' ) ? pathToIcon : pathToIcon + '/*.svg' ); globSync( globPattern ) @@ -52,3 +58,13 @@ globSync( globPattern ) execSync( `svgo --config=./scripts/svgo.config.js -i ${ pathToIcon }` ); } ); + +function parseArguments( args ) { + const paths = minimist( args )._; + + if ( paths.length > 0 ) { + return paths; + } + + return [ ALL_ICONS_PATTERN ]; +} diff --git a/scripts/release/publishpackages.js b/scripts/release/publishpackages.js index 917270fe750..fc8016cd3cd 100644 --- a/scripts/release/publishpackages.js +++ b/scripts/release/publishpackages.js @@ -44,7 +44,7 @@ const tasks = new Listr( [ npmTag: cliArguments.npmTag, listrTask: task, confirmationCallback: () => { - if ( cliArguments.nightly ) { + if ( cliArguments.ci ) { return true; } @@ -85,6 +85,7 @@ const tasks = new Listr( [ version: latestVersion } ); }, + // Nightly releases are not stored in the repository. skip: cliArguments.nightly }, { @@ -101,13 +102,16 @@ const tasks = new Listr( [ options: { persistentOutput: true }, + // Nightly releases are not described in the changelog. skip: cliArguments.nightly } ], getListrOptions( cliArguments ) ); ( async () => { try { - if ( !cliArguments.nightly ) { + if ( process.env.CKE5_RELEASE_TOKEN ) { + githubToken = process.env.CKE5_RELEASE_TOKEN; + } else if ( !cliArguments.nightly ) { githubToken = await provideToken(); } diff --git a/scripts/release/utils/parsearguments.js b/scripts/release/utils/parsearguments.js index 388b1851d58..26cb7c9fd8f 100644 --- a/scripts/release/utils/parsearguments.js +++ b/scripts/release/utils/parsearguments.js @@ -18,7 +18,8 @@ module.exports = function parseArguments( cliArguments ) { boolean: [ 'nightly', 'verbose', - 'compile-only' + 'compile-only', + 'ci' ], number: [ @@ -39,7 +40,8 @@ module.exports = function parseArguments( cliArguments ) { packages: null, branch: 'release', 'npm-tag': 'staging', - verbose: false + verbose: false, + ci: false } }; @@ -59,6 +61,10 @@ module.exports = function parseArguments( cliArguments ) { options.npmTag = 'nightly'; } + if ( process.env.CI ) { + options.ci = true; + } + return options; }; @@ -80,4 +86,6 @@ module.exports = function parseArguments( cliArguments ) { * @property {Array.|null} packages * * @property {Boolean} [verbose=false] + * + * @property {Boolean} [ci=false] */