diff --git a/.eslintrc.js b/.eslintrc.js index 880651d7f44..90499b7e2a0 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -7,16 +7,8 @@ 'use strict'; -const fs = require( 'fs' ); -const path = require( 'path' ); - -const dllPackages = fs.readdirSync( path.join( __dirname, 'src' ) ).map( directory => directory.replace( /\.js$/, '' ) ); - module.exports = { extends: 'ckeditor5', - settings: { - dllPackages - }, rules: { 'ckeditor5-rules/ckeditor-imports': 'error' }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e557f391a3..40c2fe548ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,9 @@ We are happy to announce the release of CKEditor 5 v29.2.0. This release introduces several new features: -* [Redesigned](https://github.com/ckeditor/ckeditor5/issues/10229) the find and replace panel and introduced [a few improvements to the feature itself]((https://github.com/ckeditor/ckeditor5/issues?q=is%3Aissue+milestone%3A%22iteration+46%22+-label%3Atype%3Adocs+-label%3Atype%3Atask+sort%3Aupdated-desc+label%3Apackage%3Afind-and-replace)). -* Added the possibility to create a localized editor when using [DLL builds](https://ckeditor.com/docs/ckeditor5/latest/builds/guides/development/dll-builds.html). -* Improved the performance when [pasting large images](https://github.com/ckeditor/ckeditor5/issues/10287). +* [Redesigned find and replace panel](https://github.com/ckeditor/ckeditor5/issues/10229) and [a few improvements to the feature itself]((https://github.com/ckeditor/ckeditor5/issues?q=is%3Aissue+milestone%3A%22iteration+46%22+-label%3Atype%3Adocs+-label%3Atype%3Atask+sort%3Aupdated-desc+label%3Apackage%3Afind-and-replace)). +* The possibility to create a localized editor when using [DLL builds](https://ckeditor.com/docs/ckeditor5/latest/builds/guides/development/dll-builds.html). +* Improved performance when [pasting large images](https://github.com/ckeditor/ckeditor5/issues/10287). There were also a few bug fixes: @@ -19,13 +19,13 @@ There were also a few bug fixes: * The highlight feature [can now be used while typing](https://github.com/ckeditor/ckeditor5/issues/2616). * Pasted HTML comments [are filtered out](https://github.com/ckeditor/ckeditor5/issues/10213). - +Read more in the blog post: https://ckeditor.com/blog/ckeditor-5-v29.2.0-with-redesigned-find-and-replace-and-localized-dll-builds/ ### MINOR BREAKING CHANGES [โ„น๏ธ](https://ckeditor.com/docs/ckeditor5/latest/framework/guides/support/versioning-policy.html#major-and-minor-breaking-changes) * **[find-and-replace](https://www.npmjs.com/package/@ckeditor/ckeditor5-find-and-replace)**: The layout, styling, and view structure of the find and replace form have changed radically, which may affect integrations that either customized or extended this form (see [#10229](https://github.com/ckeditor/ckeditor5/issues/10229)). -* **[revision-history](https://www.npmjs.com/package/@ckeditor/ckeditor5-revision-history)**: The revision data now includes a new property: `authorsIds`. This property needs to be handled (saved and loaded) similarly to other revision properties. For revisions that are already saved in your database, set this value to an array with one string, equal to the `creatorId` value (e.g. `["user1"]`). Check the updated [revision history integration](https://ckeditor.com/docs/ckeditor5/latest/features/revision-history/revision-history-integration.html) guide to see an example. -* **[revision-history](https://www.npmjs.com/package/@ckeditor/ckeditor5-revision-history)**: The documentation for revision history adapter has been updated. Please check the `RevisionHistoryAdapter#addRevision()` and `updateRevision()` documentation to make sure that you correctly handle all the data passed to those methods. +* **[revision-history](https://www.npmjs.com/package/@ckeditor/ckeditor5-revision-history)**: The revision data now includes a new property: `authorsIds`. This property needs to be handled (saved and loaded) similarly to other revision properties. For revisions that are already saved in your database, set this value to an array with one string, equal to the `creatorId` value (e.g. `["user1"]`). Check the updated [revision history integration guide](https://ckeditor.com/docs/ckeditor5/latest/features/revision-history/revision-history-integration.html) to see an example. +* **[revision-history](https://www.npmjs.com/package/@ckeditor/ckeditor5-revision-history)**: The documentation for revision history adapter has been updated. Please check the `RevisionHistoryAdapter#addRevision()` and `updateRevision()` documentation to make sure that you correctly handle all the data passed to these methods. ### Features @@ -36,18 +36,18 @@ There were also a few bug fixes: ### Bug fixes -* **[comments](https://www.npmjs.com/package/@ckeditor/ckeditor5-comments)**: `Comment#setAttribute()` and `Comment#removeAttribute()` will now correctly set the attribute value and fire the adapter call also for comments created by other users. +* **[comments](https://www.npmjs.com/package/@ckeditor/ckeditor5-comments)**: The `Comment#setAttribute()` and `Comment#removeAttribute()` methods will now correctly set the attribute value and fire the adapter call also for comments created by other users. * **[find-and-replace](https://www.npmjs.com/package/@ckeditor/ckeditor5-find-and-replace)**: Changing the search text should reset the results. Closes [#10304](https://github.com/ckeditor/ckeditor5/issues/10304). ([commit](https://github.com/ckeditor/ckeditor5/commit/dc3160944d6d0c95469e982a47936d47aa1bbd64)) * **[find-and-replace](https://www.npmjs.com/package/@ckeditor/ckeditor5-find-and-replace)**: Toggling search options should reset the results. Closes [#10021](https://github.com/ckeditor/ckeditor5/issues/10021). ([commit](https://github.com/ckeditor/ckeditor5/commit/dc3160944d6d0c95469e982a47936d47aa1bbd64)) * **[find-and-replace](https://www.npmjs.com/package/@ckeditor/ckeditor5-find-and-replace)**: The find and replace form should be responsive. Closes [#10019](https://github.com/ckeditor/ckeditor5/issues/10019). ([commit](https://github.com/ckeditor/ckeditor5/commit/dc3160944d6d0c95469e982a47936d47aa1bbd64)) -* **[find-and-replace](https://www.npmjs.com/package/@ckeditor/ckeditor5-find-and-replace)**: Allows search term to contain a trailing/leading space when searching "whole words only". Closes [#10131](https://github.com/ckeditor/ckeditor5/issues/10131). ([commit](https://github.com/ckeditor/ckeditor5/commit/fd9dfa7658e7ee3e968612274294baf224828326)) -* **[html-support](https://www.npmjs.com/package/@ckeditor/ckeditor5-html-support)**: Filters out all pasted HTML comments. Closes [#10213](https://github.com/ckeditor/ckeditor5/issues/10213). ([commit](https://github.com/ckeditor/ckeditor5/commit/59e23271a9ed32fb7bdc4eb51360b5e0f5d7b0ba)) +* **[find-and-replace](https://www.npmjs.com/package/@ckeditor/ckeditor5-find-and-replace)**: The search term should be allowed to contain a trailing or leading space when searching "whole words only". Closes [#10131](https://github.com/ckeditor/ckeditor5/issues/10131). ([commit](https://github.com/ckeditor/ckeditor5/commit/fd9dfa7658e7ee3e968612274294baf224828326)) +* **[html-support](https://www.npmjs.com/package/@ckeditor/ckeditor5-html-support)**: All pasted HTML comments will now be filtered. Closes [#10213](https://github.com/ckeditor/ckeditor5/issues/10213). ([commit](https://github.com/ckeditor/ckeditor5/commit/59e23271a9ed32fb7bdc4eb51360b5e0f5d7b0ba)) * **[html-support](https://www.npmjs.com/package/@ckeditor/ckeditor5-html-support)**: Extended the schema definition for `$root` to allow storing a comment's content as the `$root` attribute. Closes [#10274](https://github.com/ckeditor/ckeditor5/issues/10274). ([commit](https://github.com/ckeditor/ckeditor5/commit/35b08a96f41423b1e1173af60c186fb0193c92cb)) * **[revision-history](https://www.npmjs.com/package/@ckeditor/ckeditor5-revision-history)**: Enabled multiple authors in one revision. Introduced the `authorsIds` property in the revision data. -* **[revision-history](https://www.npmjs.com/package/@ckeditor/ckeditor5-revision-history)**: Visual improvements on how nested changes are displayed. -* **[source-editing](https://www.npmjs.com/package/@ckeditor/ckeditor5-source-editing)**: The selection is now set at the beginning of the source editing view. Closes [#10180](https://github.com/ckeditor/ckeditor5/issues/10180). ([commit](https://github.com/ckeditor/ckeditor5/commit/1e5b03d8a91038dfcf4a7afc7a47f11cb7a27041)) +* **[revision-history](https://www.npmjs.com/package/@ckeditor/ckeditor5-revision-history)**: Visual improvements to how nested changes are displayed. +* **[source-editing](https://www.npmjs.com/package/@ckeditor/ckeditor5-source-editing)**: The selection is now set to the beginning of the source editing view. Closes [#10180](https://github.com/ckeditor/ckeditor5/issues/10180). ([commit](https://github.com/ckeditor/ckeditor5/commit/1e5b03d8a91038dfcf4a7afc7a47f11cb7a27041)) * **[source-editing](https://www.npmjs.com/package/@ckeditor/ckeditor5-source-editing)**: The source editing feature will send a warning to the console when the restricted editing feature is loaded. Closes [#10228](https://github.com/ckeditor/ckeditor5/issues/10228). ([commit](https://github.com/ckeditor/ckeditor5/commit/42a31c423c1b770e7a25d3dbb546fc1327100779)) -* **[theme-lark](https://www.npmjs.com/package/@ckeditor/ckeditor5-theme-lark)**: The label of the labeled field should stay on the top when the field is disabled and not empty to not cover the field's text (see [#10229](https://github.com/ckeditor/ckeditor5/issues/10229)). ([commit](https://github.com/ckeditor/ckeditor5/commit/dc3160944d6d0c95469e982a47936d47aa1bbd64)) +* **[theme-lark](https://www.npmjs.com/package/@ckeditor/ckeditor5-theme-lark)**: The label of the labeled field should stay at the top when the field is disabled and not empty to not cover the field's text (see [#10229](https://github.com/ckeditor/ckeditor5/issues/10229)). ([commit](https://github.com/ckeditor/ckeditor5/commit/dc3160944d6d0c95469e982a47936d47aa1bbd64)) ### Other changes @@ -55,14 +55,14 @@ There were also a few bug fixes: * **[clipboard](https://www.npmjs.com/package/@ckeditor/ckeditor5-clipboard)**: The `DataTransfer.files` property is not evaluated more than once. Closes [#10287](https://github.com/ckeditor/ckeditor5/issues/10287). ([commit](https://github.com/ckeditor/ckeditor5/commit/2bde3d069ead725d8fc2f6e7379da05523c29fde)) * **[comments](https://www.npmjs.com/package/@ckeditor/ckeditor5-comments)**: Raised comments character limit to 65000. * **[core](https://www.npmjs.com/package/@ckeditor/ckeditor5-core)**: Merged a duplicated translation context from `ckeditor5-ui` and `ckeditor5-find-and-replace` packages. Closes [#10400](https://github.com/ckeditor/ckeditor5/issues/10400). ([commit](https://github.com/ckeditor/ckeditor5/commit/f931085112d9f8cdeb731543a01729058c7e3ce6)) -* **[find-and-replace](https://www.npmjs.com/package/@ckeditor/ckeditor5-find-and-replace)**: Moved the search result translation context to ckeditor5-core. Closes [#10400](https://github.com/ckeditor/ckeditor5/issues/10400). ([commit](https://github.com/ckeditor/ckeditor5/commit/f931085112d9f8cdeb731543a01729058c7e3ce6)) +* **[find-and-replace](https://www.npmjs.com/package/@ckeditor/ckeditor5-find-and-replace)**: Moved the search result translation context to `ckeditor5-core`. Closes [#10400](https://github.com/ckeditor/ckeditor5/issues/10400). ([commit](https://github.com/ckeditor/ckeditor5/commit/f931085112d9f8cdeb731543a01729058c7e3ce6)) * **[find-and-replace](https://www.npmjs.com/package/@ckeditor/ckeditor5-find-and-replace)**: Visually revamped the find and replace form. Closes [#10229](https://github.com/ckeditor/ckeditor5/issues/10229). ([commit](https://github.com/ckeditor/ckeditor5/commit/dc3160944d6d0c95469e982a47936d47aa1bbd64)) * **[find-and-replace](https://www.npmjs.com/package/@ckeditor/ckeditor5-find-and-replace)**: Increased the contrast between selected and unselected find and replace results. Closes [#10242](https://github.com/ckeditor/ckeditor5/issues/10242). ([commit](https://github.com/ckeditor/ckeditor5/commit/f5a2c57aa7b2aeea5a01ad1e7c7bf0a9b9118078)) * **[highlight](https://www.npmjs.com/package/@ckeditor/ckeditor5-highlight)**: Toggling highlight does not remove it when the caret is at the end of the highlighted range. Closes [#2616](https://github.com/ckeditor/ckeditor5/issues/2616). ([commit](https://github.com/ckeditor/ckeditor5/commit/d1a271d127777bb0f33f0e4e52222f0fbf21f6c2)) * **[language](https://www.npmjs.com/package/@ckeditor/ckeditor5-language)**: The "Remove language" option of text part language dropdown is now the first one in the list. Closes [#10338](https://github.com/ckeditor/ckeditor5/issues/10338). ([commit](https://github.com/ckeditor/ckeditor5/commit/e6cd6e487d6ca528d19734ac27b447003063ca5c)) * **[revision-history](https://www.npmjs.com/package/@ckeditor/ckeditor5-revision-history)**: Some CSS styling improvements for suggestions and changes highlights. * **[theme-lark](https://www.npmjs.com/package/@ckeditor/ckeditor5-theme-lark)**: Moved the presentational find and replace form styles to the theme (see [#10229](https://github.com/ckeditor/ckeditor5/issues/10229)). ([commit](https://github.com/ckeditor/ckeditor5/commit/dc3160944d6d0c95469e982a47936d47aa1bbd64)) -* **[ui](https://www.npmjs.com/package/@ckeditor/ckeditor5-ui)**: Moved the page label translation context to ckeditor5-core. Closes [#10400](https://github.com/ckeditor/ckeditor5/issues/10400). ([commit](https://github.com/ckeditor/ckeditor5/commit/f931085112d9f8cdeb731543a01729058c7e3ce6)) +* **[ui](https://www.npmjs.com/package/@ckeditor/ckeditor5-ui)**: Moved the page label translation context to `ckeditor5-core`. Closes [#10400](https://github.com/ckeditor/ckeditor5/issues/10400). ([commit](https://github.com/ckeditor/ckeditor5/commit/f931085112d9f8cdeb731543a01729058c7e3ce6)) * Updated translations. ([commit](https://github.com/ckeditor/ckeditor5/commit/d88cc7e93d4fbb0ad4f68943cff55d37532ec2cb), [commit](https://github.com/ckeditor/ckeditor5/commit/70bd66b567f19450717ed69ce999521e5c4aa26e)) * The content styles stylesheet for the guide will now be generated on-demand using the `{@exec...}` feature. Closes [#10299](https://github.com/ckeditor/ckeditor5/issues/10299). ([commit](https://github.com/ckeditor/ckeditor5/commit/18123a72726a2f9f052189b89ec58218603d26da)) diff --git a/docs/assets/img/cog.svg b/docs/assets/img/cog.svg new file mode 100644 index 00000000000..588fb8478ba --- /dev/null +++ b/docs/assets/img/cog.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/assets/img/document.svg b/docs/assets/img/document.svg new file mode 100644 index 00000000000..0546ab556e7 --- /dev/null +++ b/docs/assets/img/document.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/assets/styles.css b/docs/assets/styles.css index f0561fb9eea..0adeeae4c7c 100644 --- a/docs/assets/styles.css +++ b/docs/assets/styles.css @@ -51,3 +51,17 @@ https://github.com/ckeditor/ckeditor5-build-decoupled-document/issues/12 */ display: inline; } } + +/* https://github.com/ckeditor/ckeditor5/issues/9700 */ +.main .main__content-inner .features-html-output .output-overview-table-icon { + display: inline; + margin: 0; + height: 1rem; + vertical-align: middle; + position: relative; + top: -1px; +} + +.main .main__content-inner .features-html-output th { + background-color: hsl(0, 0%, 96%); +} diff --git a/docs/builds/guides/development/dll-builds.md b/docs/builds/guides/development/dll-builds.md index e9ae54cad9a..0205f6dd5f2 100644 --- a/docs/builds/guides/development/dll-builds.md +++ b/docs/builds/guides/development/dll-builds.md @@ -22,7 +22,7 @@ DLL builds are based on the [DLL webpack](https://webpack.js.org/plugins/dll-plu Currently, CKEditor 5 does not come with a ready-to-use DLL build. Using this integration method requires creating it on your own, based on the tools available in the {@link framework/guides/contributing/development-environment CKEditor 5 development environment}. -Follow the [Ship CKEditor 5 DLLs](https://github.com/ckeditor/ckeditor5/issues/9145) issue for updates (and add ๐Ÿ‘ if you are interested in this functionality). +Follow the [Ship CKEditor 5 DLLs](https://github.com/ckeditor/ckeditor5/issues/9145) issue for updates (and add ๐Ÿ‘  if you are interested in this functionality). ## Anatomy of a DLL build @@ -151,6 +151,12 @@ For example: ``` +## Live implementation sample + +Presented below is a working sample editor using the DLL mechanism. Observe the source and then click **"Result"** to switch to the live view of the working CKEditor 5 instance. + + + ## Localization All DLL builds use the default (English) translation files. However, a localized version of the the editor can be easily configured. diff --git a/docs/builds/guides/migration/migration-to-25.md b/docs/builds/guides/migration/migration-to-25.md index b8ebd038c9c..55079264176 100644 --- a/docs/builds/guides/migration/migration-to-25.md +++ b/docs/builds/guides/migration/migration-to-25.md @@ -4,6 +4,12 @@ menu-title: Migration to v25.x order: 99 --- + + 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. + + # Migration to CKEditor 5 v25.0.0 For the entire list of changes introduced in version 25.0.0, see the [changelog for CKEditor 5 v25.0.0](https://github.com/ckeditor/ckeditor5/blob/master/CHANGELOG.md#2500-2021-01-25). diff --git a/docs/builds/guides/migration/migration-to-26.md b/docs/builds/guides/migration/migration-to-26.md index b4dd375de03..917b35c82d3 100644 --- a/docs/builds/guides/migration/migration-to-26.md +++ b/docs/builds/guides/migration/migration-to-26.md @@ -4,6 +4,12 @@ menu-title: Migration to v26.x order: 98 --- + + 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. + + # Migration to CKEditor 5 v26.0.0 For the entire list of changes introduced in version 26.0.0, see the [changelog for CKEditor 5 v26.0.0](https://github.com/ckeditor/ckeditor5/blob/master/CHANGELOG.md#2600-2021-03-01). diff --git a/docs/builds/guides/migration/migration-to-27.md b/docs/builds/guides/migration/migration-to-27.md index df3f8556604..beef00e2ae5 100644 --- a/docs/builds/guides/migration/migration-to-27.md +++ b/docs/builds/guides/migration/migration-to-27.md @@ -4,6 +4,12 @@ menu-title: Migration to v27.x order: 97 --- + + 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. + + # Migration to CKEditor 5 v27.x ## Migration to CKEditor 5 v27.1.0 diff --git a/docs/builds/guides/migration/migration-to-28.md b/docs/builds/guides/migration/migration-to-28.md index 2df68921abb..097b9e3a41b 100644 --- a/docs/builds/guides/migration/migration-to-28.md +++ b/docs/builds/guides/migration/migration-to-28.md @@ -5,6 +5,12 @@ order: 96 modified_at: 2021-06-01 --- + + 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. + + # Migration to CKEditor 5 v28.0.0 For the entire list of changes introduced in version 28.0.0, see the [changelog for CKEditor 5 v28.0.0](https://github.com/ckeditor/ckeditor5/blob/master/CHANGELOG.md#2800-2021-05-31). diff --git a/docs/builds/guides/migration/migration-to-29.md b/docs/builds/guides/migration/migration-to-29.md index a97bdb11fe1..56c5dc1a4a0 100644 --- a/docs/builds/guides/migration/migration-to-29.md +++ b/docs/builds/guides/migration/migration-to-29.md @@ -6,9 +6,13 @@ order: 95 modified_at: 2021-07-25 --- -# Migration to CKEditor 5 v29.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. + + +# Migration to CKEditor 5 v29.x ## Migration to CKEditor 5 v29.1.0 diff --git a/docs/builds/guides/migration/migration-to-30.md b/docs/builds/guides/migration/migration-to-30.md new file mode 100644 index 00000000000..b9536796c2d --- /dev/null +++ b/docs/builds/guides/migration/migration-to-30.md @@ -0,0 +1,63 @@ + +--- +category: builds-migration +menu-title: Migration to v30.x +order: 94 +modified_at: 2021-09-16 +--- + +# Migration to CKEditor 5 v30.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. + + +## Migration to CKEditor 5 v30.0.0 + + + +Listed below are the most important changes that require your attention when upgrading to CKEditor 5 v30.0.0. + +### Viewport (toolbar) offset config change + +Starting from v30.0.0, the {@link module:core/editor/editorconfig~EditorConfig#toolbar `EditorConfig#toolbar.viewportTopOffset`} config is deprecated. + +The new {@link module:core/editor/editorconfig~EditorConfig#ui `EditorConfig#ui.viewportOffset`} editor config allows to set `viewportOffset` from every direction. + +```js +const config = { + ui: { + viewportOffset: { top: 10, right: 10, bottom: 10, left: 10 } + } +} +``` + +Here is the exact change you would need to introduce for proper integration with the new {@link module:core/editor/editorconfig~EditorConfig#ui `EditorConfig#ui.viewportOffset`} config change: + +```js +// Before v30.0.0 +ClassicEditor + .create( ..., { + // ... + toolbar: { + items: [ ... ], + viewportTopOffset: 100 + } + } ) + +// Since v30.0.0 +ClassicEditor + .create( ..., { + // ... + toolbar: { + items: [ ... ] + }, + ui: { + viewportOffset: { + top: 100 + } + } + } ) +``` diff --git a/docs/framework/guides/contributing/git-commit-message-convention.md b/docs/framework/guides/contributing/git-commit-message-convention.md index eb200b67bed..05aaf2e15f0 100644 --- a/docs/framework/guides/contributing/git-commit-message-convention.md +++ b/docs/framework/guides/contributing/git-commit-message-convention.md @@ -79,6 +79,14 @@ When creating PRs that address specific issues, use the following messages to in * `See #123` – when the PR only references an issue, but does not close it yet. * _No reference_ – when the PR does not reference any issue. +### Methods name syntax + +All methods mentioned in the git commit message should use the **#** sign inbetween the class name and the method name. And example of a properly named method: + +``` +MarkerCollection#has() +``` + ### Order of entries The proper order of sections for a commit message is as follows: @@ -90,7 +98,7 @@ All entries must be separated with a blank line, otherwise the lines will not be ### Examples of correct and incorrect message formatting -Example of a proper commit message: +An example of a proper commit message: ``` Feature (package-name-1): Message 1. Closes: #123 @@ -149,11 +157,11 @@ Tests (widget): Introduced missing tests. Closes #5. An improvement that is not backward compatible and sent by a non-core contributor. Public API was changed: ``` -Other (utils): Extracted the `utils.foo()` to a separate package. Closes #9. +Other (utils): Extracted the `utils#foo()` to a separate package. Closes #9. -Feature (engine): Introduced the `engine.foo()` method. Closes #9. +Feature (engine): Introduced the `engine#foo()` method. Closes #9. -MAJOR BREAKING CHANGE (utils): The `utils.foo()` method was moved to the `engine` package. See #9. +MAJOR BREAKING CHANGE (utils): The `utils#foo()` method was moved to the `engine` package. See #9. ``` For the commits shown above the changelog will look like this: @@ -166,11 +174,11 @@ Changelog ### MAJOR BREAKING CHANGES [โ„น๏ธ](https://ckeditor.com/docs/ckeditor5/latest/framework/guides/support/versioning-policy.html#major-and-minor-breaking-changes) -* **[utils](http://npmjs.com/package/@ckeditor/ckeditor5-utils)**: The `utils.foo()` method was moved to the `engine` package. See [#9](https://github.com/ckeditor/ckeditor5/issue/9). +* **[utils](http://npmjs.com/package/@ckeditor/ckeditor5-utils)**: The `utils#foo()` method was moved to the `engine` package. See [#9](https://github.com/ckeditor/ckeditor5/issue/9). ### Features -* **[engine](http://npmjs.com/package/@ckeditor/ckeditor5-engine)**: Introduced the `engine.foo()` method. Thanks to [@CKEditor](https://github.com/CKEditor). Closes [#9](https://github.com/ckeditor/ckeditor5/issue/9). ([e8cc04f](https://github.com/ckeditor/ckeditor5/commit/e8cc04f)) +* **[engine](http://npmjs.com/package/@ckeditor/ckeditor5-engine)**: Introduced the `engine#foo()` method. Thanks to [@CKEditor](https://github.com/CKEditor). Closes [#9](https://github.com/ckeditor/ckeditor5/issue/9). ([e8cc04f](https://github.com/ckeditor/ckeditor5/commit/e8cc04f)) * **[ui](http://npmjs.com/package/@ckeditor/ckeditor5-ui)**: Added support for RTL languages. Closes [#1](https://github.com/ckeditor/ckeditor5/issue/1). ([adc59ed](https://github.com/ckeditor/ckeditor5/commit/adc59ed)) RTL content will now be rendered correctly. @@ -181,7 +189,7 @@ Changelog ### Other changes -* **[utils](http://npmjs.com/package/@ckeditor/ckeditor5-utils)**: Extracted the `utils.foo()` to a separate package. Thanks to [@CKEditor](https://github.com/CKEditor). ([e8cc04f](https://github.com/ckeditor/ckeditor5/commit/e8cc04f)) +* **[utils](http://npmjs.com/package/@ckeditor/ckeditor5-utils)**: Extracted the `utils#foo()` to a separate package. Thanks to [@CKEditor](https://github.com/CKEditor). ([e8cc04f](https://github.com/ckeditor/ckeditor5/commit/e8cc04f)) ``` ## Handling pull requests diff --git a/docs/index.md b/docs/index.md index 9c07b82adcf..872b10f19f4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,12 +3,12 @@ toc: false sitenav: false feedback-widget: false meta-title: CKEditor 5 documentation -meta-description: Learn how to install, integrate, configure and develop CKEditor 5. Browse through API documentation and online samples. +meta-description: Learn how to install, integrate, update, configure and develop CKEditor 5. Browse through API documentation and online samples. --- # CKEditor 5 documentation -
{@link builds/guides/overview CKEditor 5 Builds documentation}
Learn how to install, integrate and configure CKEditor 5 Builds. More complex aspects, like creating custom builds, are explained here, too.
+
{@link builds/guides/overview CKEditor 5 Builds documentation}
Learn how to install, integrate and configure CKEditor 5 Builds. More complex aspects, like creating custom builds or updating and migrating from an older version are explained here, too.
{@link framework/guides/overview CKEditor 5 Framework documentation}
Learn how to work with CKEditor 5 Framework, customize it, create your own plugins and custom editors, change the UI or even bring your own UI to the editor.
{@link examples/index CKEditor 5 Examples}
Try out all CKEditor 5 Builds. See some of the possible customizations of CKEditor.
{@link features/index CKEditor 5 Features}
Learn about the features available for CKEditor 5 — both the ones included in Builds and a plethora of others.
diff --git a/packages/ckeditor5-autoformat/src/autoformat.js b/packages/ckeditor5-autoformat/src/autoformat.js index 6f63160ab23..f0e6ec9c876 100644 --- a/packages/ckeditor5-autoformat/src/autoformat.js +++ b/packages/ckeditor5-autoformat/src/autoformat.js @@ -8,6 +8,7 @@ */ import { Plugin } from 'ckeditor5/src/core'; +import { Delete } from 'ckeditor5/src/typing'; import blockAutoformatEditing from './blockautoformatediting'; import inlineAutoformatEditing from './inlineautoformatediting'; @@ -21,6 +22,13 @@ import inlineAutoformatEditing from './inlineautoformatediting'; * @extends module:core/plugin~Plugin */ export default class Autoformat extends Plugin { + /** + * @inheritdoc + */ + static get requires() { + return [ Delete ]; + } + /** * @inheritDoc */ diff --git a/packages/ckeditor5-autoformat/src/blockautoformatediting.js b/packages/ckeditor5-autoformat/src/blockautoformatediting.js index 65809f48c43..4f4da61dd9a 100644 --- a/packages/ckeditor5-autoformat/src/blockautoformatediting.js +++ b/packages/ckeditor5-autoformat/src/blockautoformatediting.js @@ -148,6 +148,10 @@ export default function blockAutoformatEditing( editor, plugin, pattern, callbac } } range.detach(); + + editor.model.enqueueChange( () => { + editor.plugins.get( 'Delete' ).requestUndoOnBackspace(); + } ); } ); } ); } diff --git a/packages/ckeditor5-autoformat/src/inlineautoformatediting.js b/packages/ckeditor5-autoformat/src/inlineautoformatediting.js index 8b7fd727835..c01e1772d2e 100644 --- a/packages/ckeditor5-autoformat/src/inlineautoformatediting.js +++ b/packages/ckeditor5-autoformat/src/inlineautoformatediting.js @@ -170,6 +170,10 @@ export default function inlineAutoformatEditing( editor, plugin, testRegexpOrCal for ( const range of rangesToRemove.reverse() ) { writer.remove( range ); } + + model.enqueueChange( () => { + editor.plugins.get( 'Delete' ).requestUndoOnBackspace(); + } ); } ); } ); } diff --git a/packages/ckeditor5-autoformat/tests/undointegration.js b/packages/ckeditor5-autoformat/tests/undointegration.js index 25d6e5ba3fd..dd294268633 100644 --- a/packages/ckeditor5-autoformat/tests/undointegration.js +++ b/packages/ckeditor5-autoformat/tests/undointegration.js @@ -14,11 +14,14 @@ import StrikethroughEditing from '@ckeditor/ckeditor5-basic-styles/src/strikethr import ItalicEditing from '@ckeditor/ckeditor5-basic-styles/src/italic/italicediting'; import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting'; import Enter from '@ckeditor/ckeditor5-enter/src/enter'; +import Delete from '@ckeditor/ckeditor5-typing/src/delete'; import Undo from '@ckeditor/ckeditor5-undo/src/undoediting'; +import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor'; import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor'; import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { DomEventData } from '@ckeditor/ckeditor5-engine'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; describe( 'Autoformat undo integration', () => { @@ -26,35 +29,13 @@ describe( 'Autoformat undo integration', () => { testUtils.createSinonSandbox(); - beforeEach( () => { - return VirtualTestEditor - .create( { - plugins: [ - Enter, - Undo, - Paragraph, - Autoformat, - ListEditing, - HeadingEditing, - BoldEditing, - ItalicEditing, - CodeEditing, - StrikethroughEditing, - BlockQuoteEditing - ] - } ) - .then( newEditor => { - editor = newEditor; - model = editor.model; - doc = model.document; - } ); - } ); - afterEach( () => { return editor.destroy(); } ); describe( 'inline', () => { + beforeEach( createVirtualEditorInstance ); + it( 'should undo replacing "**" with bold', () => { setData( model, '**foobar*[]' ); model.change( writer => { @@ -123,6 +104,8 @@ describe( 'Autoformat undo integration', () => { } ); describe( 'block', () => { + beforeEach( createVirtualEditorInstance ); + it( 'should work when replacing asterisk', () => { setData( model, '*[]' ); model.change( writer => { @@ -203,4 +186,100 @@ describe( 'Autoformat undo integration', () => { expect( getData( model ) ).to.equal( '> []' ); } ); } ); + + describe( 'by pressing backspace', () => { + let viewDocument, deleteEvent; + + beforeEach( async () => { + const newEditor = await ModelTestEditor + .create( { + plugins: [ + Autoformat, + Paragraph, + BoldEditing, + ListEditing, + Delete, + Undo + ] + } ); + + editor = newEditor; + model = editor.model; + doc = model.document; + viewDocument = editor.editing.view.document; + deleteEvent = new DomEventData( + viewDocument, + { preventDefault: sinon.spy() }, + { direction: 'backward', unit: 'codePoint', sequence: 1 } + ); + } ); + + it( 'should undo after inline autoformat', () => { + setData( model, '**foobar*[]' ); + model.change( writer => { + writer.insertText( '*', doc.selection.getFirstPosition() ); + } ); + + expect( getData( model ) ).to.equal( '<$text bold="true">foobar[]' ); + + viewDocument.fire( 'delete', deleteEvent ); + + expect( getData( model ) ).to.equal( '**foobar**[]' ); + } ); + + it( 'should undo after block autoformat', () => { + setData( model, '-[]' ); + model.change( writer => { + writer.insertText( ' ', doc.selection.getFirstPosition() ); + } ); + + expect( getData( model ) ).to.equal( '[]' ); + + viewDocument.fire( 'delete', deleteEvent ); + + expect( getData( model ) ).to.equal( '- []' ); + } ); + + it( 'should not undo after selection has changed', () => { + setData( model, '**foobar*[]' ); + model.change( writer => { + writer.insertText( '*', doc.selection.getFirstPosition() ); + } ); + + expect( getData( model ) ).to.equal( '<$text bold="true">foobar[]' ); + + model.change( writer => { + const selection = model.createSelection(); + writer.setSelection( selection ); + } ); + + viewDocument.fire( 'delete', deleteEvent ); + + expect( getData( model, { withoutSelection: true } ) ) + .to.equal( '<$text bold="true">foobar' ); + } ); + } ); + + async function createVirtualEditorInstance() { + const newEditor = await VirtualTestEditor + .create( { + plugins: [ + Enter, + Undo, + Paragraph, + Autoformat, + ListEditing, + HeadingEditing, + BoldEditing, + ItalicEditing, + CodeEditing, + StrikethroughEditing, + BlockQuoteEditing + ] + } ); + + editor = newEditor; + model = editor.model; + doc = model.document; + } } ); diff --git a/packages/ckeditor5-core/src/editor/editorconfig.jsdoc b/packages/ckeditor5-core/src/editor/editorconfig.jsdoc index 8050ab1cb2e..daaf89b8f62 100644 --- a/packages/ckeditor5-core/src/editor/editorconfig.jsdoc +++ b/packages/ckeditor5-core/src/editor/editorconfig.jsdoc @@ -319,8 +319,6 @@ * viewportOffset: { top: 10, right: 10, bottom: 10, left: 10 } * } * - * **Note:** At the moment, only `top` property is supported. Support for other directions will be added in the future. - * * **Note:** If you want to modify the viewport offset in runtime (after editor was created), you can do that by overriding {@link module:core/editor/editorui~EditorUI#viewportOffset `editor.ui.viewportOffset`}. * * @member {Object} module:core/editor/editorconfig~EditorConfig#ui diff --git a/packages/ckeditor5-editor-inline/src/inlineeditoruiview.js b/packages/ckeditor5-editor-inline/src/inlineeditoruiview.js index a213e930957..4b98ea591c5 100644 --- a/packages/ckeditor5-editor-inline/src/inlineeditoruiview.js +++ b/packages/ckeditor5-editor-inline/src/inlineeditoruiview.js @@ -71,8 +71,6 @@ export default class InlineEditorUIView extends EditorUIView { */ this.panel = new BalloonPanelView( locale ); - this.panel.withArrow = false; - /** * A set of positioning functions used by the {@link #panel} to float around * {@link #element editableElement}. @@ -118,7 +116,7 @@ export default class InlineEditorUIView extends EditorUIView { * See: {@link module:utils/dom/position~Options#positions}. * * @readonly - * @type {Array.} + * @type {Array.} */ this.panelPositions = this._getPanelPositions(); @@ -209,7 +207,7 @@ export default class InlineEditorUIView extends EditorUIView { * See: {@link module:utils/dom/position~Options#positions}. * * @private - * @returns {Array.} + * @returns {Array.} */ _getPanelPositions() { const positions = [ @@ -217,14 +215,20 @@ export default class InlineEditorUIView extends EditorUIView { return { top: this._getPanelPositionTop( editableRect, panelRect ), left: editableRect.left, - name: 'toolbar_west' + name: 'toolbar_west', + config: { + withArrow: false + } }; }, ( editableRect, panelRect ) => { return { top: this._getPanelPositionTop( editableRect, panelRect ), left: editableRect.left + editableRect.width - panelRect.width, - name: 'toolbar_east' + name: 'toolbar_east', + config: { + withArrow: false + } }; } ]; diff --git a/packages/ckeditor5-editor-inline/tests/inlineeditoruiview.js b/packages/ckeditor5-editor-inline/tests/inlineeditoruiview.js index b18edccf347..9a16f002b9d 100644 --- a/packages/ckeditor5-editor-inline/tests/inlineeditoruiview.js +++ b/packages/ckeditor5-editor-inline/tests/inlineeditoruiview.js @@ -89,10 +89,6 @@ describe( 'InlineEditorUIView', () => { expect( view.panel.locale ).to.equal( locale ); } ); - it( 'gets view.panel#withArrow set', () => { - expect( view.panel.withArrow ).to.be.false; - } ); - it( 'is not rendered', () => { expect( view.panel.isRendered ).to.be.false; } ); @@ -249,6 +245,27 @@ describe( 'InlineEditorUIView', () => { expect( positions[ 1 ]( editableRect, panelRect ).name ).to.equal( 'toolbar_west' ); } ); + it( 'returned positions ahould have no arrow', () => { + const uiView = new InlineEditorUIView( locale, editingView ); + const positions = uiView.panelPositions; + const editableRect = { + top: 100, + bottom: 200, + left: 100, + right: 100, + width: 100, + height: 100 + }; + const panelRect = { + width: 50, + height: 50 + }; + + expect( positions ).to.have.length( 2 ); + expect( positions[ 0 ]( editableRect, panelRect ).config.withArrow ).to.be.false; + expect( positions[ 1 ]( editableRect, panelRect ).config.withArrow ).to.be.false; + } ); + describe( 'west', () => { testTopPositions( 0, 100 ); } ); diff --git a/packages/ckeditor5-engine/docs/framework/guides/deep-dive/schema.md b/packages/ckeditor5-engine/docs/framework/guides/deep-dive/schema.md index a9bd696510f..03693861892 100644 --- a/packages/ckeditor5-engine/docs/framework/guides/deep-dive/schema.md +++ b/packages/ckeditor5-engine/docs/framework/guides/deep-dive/schema.md @@ -110,6 +110,15 @@ Here is a table listing various model elements and their properties registered i false false + + $documentFragment + false + true + false + false + false + false + $marker false @@ -201,7 +210,7 @@ Here is a table listing various model elements and their properties registered i true[3] - image + imageBlock true true[1] true @@ -209,6 +218,15 @@ Here is a table listing various model elements and their properties registered i true[2] true[3] + + imageInline + false + true[1] + true + true + true[2] + true[3] + listItem true diff --git a/packages/ckeditor5-engine/src/view/styles/utils.js b/packages/ckeditor5-engine/src/view/styles/utils.js index af8a463778f..12e64628b83 100644 --- a/packages/ckeditor5-engine/src/view/styles/utils.js +++ b/packages/ckeditor5-engine/src/view/styles/utils.js @@ -36,6 +36,11 @@ const COLOR_NAMES = new Set( [ 'papayawhip', 'peachpuff', 'peru', 'pink', 'plum', 'powderblue', 'rosybrown', 'royalblue', 'saddlebrown', 'salmon', 'sandybrown', 'seagreen', 'seashell', 'sienna', 'skyblue', 'slateblue', 'slategray', 'slategrey', 'snow', 'springgreen', 'steelblue', 'tan', 'thistle', 'tomato', 'turquoise', 'violet', 'wheat', 'whitesmoke', 'yellowgreen', + // CSS Color Module Level 3 (System Colors) + 'activeborder', 'activecaption', 'appworkspace', 'background', 'buttonface', 'buttonhighlight', 'buttonshadow', + 'buttontext', 'captiontext', 'graytext', 'highlight', 'highlighttext', 'inactiveborder', 'inactivecaption', + 'inactivecaptiontext', 'infobackground', 'infotext', 'menu', 'menutext', 'scrollbar', 'threeddarkshadow', + 'threedface', 'threedhighlight', 'threedlightshadow', 'threedshadow', 'window', 'windowframe', 'windowtext', // CSS Color Module Level 4 'rebeccapurple', // Keywords diff --git a/packages/ckeditor5-engine/tests/controller/datacontroller.js b/packages/ckeditor5-engine/tests/controller/datacontroller.js index 31a75cfa1e7..790a9a838cd 100644 --- a/packages/ckeditor5-engine/tests/controller/datacontroller.js +++ b/packages/ckeditor5-engine/tests/controller/datacontroller.js @@ -904,4 +904,137 @@ describe( 'DataController', () => { sinon.assert.calledWithExactly( spyHtmlProcessor, 'div' ); } ); } ); + + describe( 'nested conversion', () => { + beforeEach( () => { + model.schema.register( 'container', { + inheritAllFrom: '$block' + } ); + model.schema.register( 'caption', { + allowIn: 'container', + inheritAllFrom: '$block' + } ); + model.schema.extend( '$text', { + allowAttributes: [ 'bold' ] + } ); + } ); + + it( 'should allow nesting upcast conversion', () => { + const dataProcessor = data.processor; + + upcastHelpers.elementToAttribute( { view: 'strong', model: 'bold' } ); + + data.upcastDispatcher.on( 'element:div', ( evt, data, conversionApi ) => { + const viewItem = data.viewItem; + + // Check if the view element has still unconsumed `data-caption` attribute. + if ( !conversionApi.consumable.test( viewItem, { name: true, attributes: 'data-caption' } ) ) { + return; + } + + const container = conversionApi.writer.createElement( 'container' ); + + // Create `caption` model element. Thanks to that element the rest of the `ckeditor5-plugin` converters can + // recognize this image as a block image with a caption. + const caption = conversionApi.writer.createElement( 'caption' ); + + // Parse HTML from data-caption attribute and upcast it to model fragment. + const viewFragment = dataProcessor.toView( viewItem.getAttribute( 'data-caption' ) ); + const modelFragment = conversionApi.writer.createDocumentFragment(); + + // Consumable must know about those newly parsed view elements. + conversionApi.consumable.constructor.createFrom( viewFragment, conversionApi.consumable ); + conversionApi.convertChildren( viewFragment, modelFragment ); + + // Insert caption model nodes into the caption. + for ( const child of Array.from( modelFragment.getChildren() ) ) { + conversionApi.writer.append( child, caption ); + } + + // Insert the caption element into image, as a last child. + conversionApi.writer.append( caption, container ); + + // Try to place the image in the allowed position. + if ( !conversionApi.safeInsert( container, data.modelCursor ) ) { + return; + } + + // Mark given element as consumed. Now other converters will not process it anymore. + conversionApi.consumable.consume( viewItem, { name: true, attributes: [ 'data-caption' ] } ); + + // Make sure `modelRange` and `modelCursor` is up to date after inserting new nodes into the model. + conversionApi.updateConversionResult( container, data ); + } ); + + data.set( '
 
' ); + + expect( getData( model, { withoutSelection: true } ) ).to.equal( + 'foo<$text bold="true">baz' + ); + } ); + + it( 'should allow nesting downcast conversion', () => { + const downcastDispatcher = data.downcastDispatcher; + const dataProcessor = data.processor; + + downcastHelpers.elementToElement( { model: 'container', view: 'div' } ); + downcastHelpers.attributeToElement( { model: 'bold', view: 'strong' } ); + + data.downcastDispatcher.on( 'insert:caption', ( evt, data, conversionApi ) => { + if ( !conversionApi.consumable.consume( data.item, 'insert' ) ) { + return; + } + + const range = model.createRangeIn( data.item ); + const viewDocumentFragment = conversionApi.writer.createDocumentFragment(); + + // Bind caption model element to the detached view document fragment so all content of the caption + // will be downcasted into that document fragment. + conversionApi.mapper.bindElements( data.item, viewDocumentFragment ); + + for ( const { item } of range ) { + const data = { + item, + range: model.createRangeOn( item ) + }; + + // The following lines are extracted from DowncastDispatcher#_convertInsertWithAttributes(). + + const eventName = `insert:${ item.is( '$textProxy' ) ? '$text' : item.name }`; + + downcastDispatcher.fire( eventName, data, conversionApi ); + + for ( const key of item.getAttributeKeys() ) { + Object.assign( data, { + attributeKey: key, + attributeOldValue: null, + attributeNewValue: data.item.getAttribute( key ) + } ); + + downcastDispatcher.fire( `attribute:${ key }`, data, conversionApi ); + } + } + + // Unbind all the view elements that were downcasted to the document fragment. + for ( const child of conversionApi.writer.createRangeIn( viewDocumentFragment ).getItems() ) { + conversionApi.mapper.unbindViewElement( child ); + } + + conversionApi.mapper.unbindViewElement( viewDocumentFragment ); + + // Stringify view document fragment to HTML string. + const captionText = dataProcessor.toData( viewDocumentFragment ); + + if ( captionText ) { + const imageViewElement = conversionApi.mapper.toViewElement( data.item.parent ); + + conversionApi.writer.setAttribute( 'data-caption', captionText, imageViewElement ); + } + } ); + + setData( model, 'foo<$text bold="true">baz' ); + + expect( data.get() ).to.equal( '
 
' ); + } ); + } ); } ); diff --git a/packages/ckeditor5-engine/tests/view/styles/utils.js b/packages/ckeditor5-engine/tests/view/styles/utils.js index 66c229507a7..f38de73abbc 100644 --- a/packages/ckeditor5-engine/tests/view/styles/utils.js +++ b/packages/ckeditor5-engine/tests/view/styles/utils.js @@ -126,6 +126,8 @@ describe( 'Styles utils', () => { 'orange', // CSS Level 3 'cyan', 'azure', 'wheat', + // CSS Level 3 System Colors + 'windowtext', // CSS Level 4 'rebeccapurple' ], isColor ); diff --git a/packages/ckeditor5-find-and-replace/src/findandreplaceediting.js b/packages/ckeditor5-find-and-replace/src/findandreplaceediting.js index 24a0bcbd961..443dde58694 100644 --- a/packages/ckeditor5-find-and-replace/src/findandreplaceediting.js +++ b/packages/ckeditor5-find-and-replace/src/findandreplaceediting.js @@ -94,7 +94,13 @@ export default class FindAndReplaceEditing extends Plugin { * @inheritDoc */ init() { - this.activeResults = null; + /** + * The collection of currently highlighted search results. + * + * @private + * @member {module:utils/collection~Collection} #_activeResults + */ + this._activeResults = null; /** * An object storing the find and replace state within a given editor instance. @@ -164,19 +170,19 @@ export default class FindAndReplaceEditing extends Plugin { const { findCallback, results } = editor.execute( 'find', callbackOrText ); - this.activeResults = results; + this._activeResults = results; // @todo: handle this listener, another copy is in findcommand.js file. - this.listenTo( model.document, 'change:data', () => onDocumentChange( this.activeResults, model, findCallback ) ); + this.listenTo( model.document, 'change:data', () => onDocumentChange( this._activeResults, model, findCallback ) ); - return this.activeResults; + return this._activeResults; } /** * Stops active results from updating, and clears out the results. */ stop() { - if ( !this.activeResults ) { + if ( !this._activeResults ) { return; } @@ -184,10 +190,12 @@ export default class FindAndReplaceEditing extends Plugin { this.state.clear( this.editor.model ); - this.activeResults = null; + this._activeResults = null; } /** + * Sets up the commands. + * * @private */ _defineCommands() { @@ -199,6 +207,8 @@ export default class FindAndReplaceEditing extends Plugin { } /** + * Sets up the marker downcast converters for search results highlighting. + * * @private */ _defineConverters() { diff --git a/packages/ckeditor5-find-and-replace/src/findandreplacestate.js b/packages/ckeditor5-find-and-replace/src/findandreplacestate.js index a6d2a95d8a6..8216824b68c 100644 --- a/packages/ckeditor5-find-and-replace/src/findandreplacestate.js +++ b/packages/ckeditor5-find-and-replace/src/findandreplacestate.js @@ -12,13 +12,20 @@ import { ObservableMixin, mix, Collection } from 'ckeditor5/src/utils'; /** * The object storing find and replace plugin state for a given editor instance. * + * @mixes module:utils/observablemixin~ObservableMixin */ export default class FindAndReplaceState { + /** + * Creates an instance of the state. + * + * @param {module:engine/model/model~Model} model + */ constructor( model ) { /** * A collection of find matches. * - * @private + * @protected + * @observable * @member {module:utils/collection~Collection} #results */ this.set( 'results', new Collection() ); @@ -94,6 +101,11 @@ export default class FindAndReplaceState { } ); } + /** + * Cleans the state up and removes markers from the model. + * + * @param {module:engine/model/model~Model} model + */ clear( model ) { this.searchText = ''; diff --git a/packages/ckeditor5-find-and-replace/src/findandreplaceui.js b/packages/ckeditor5-find-and-replace/src/findandreplaceui.js index bdd43639ce2..4a6dcc4cec0 100644 --- a/packages/ckeditor5-find-and-replace/src/findandreplaceui.js +++ b/packages/ckeditor5-find-and-replace/src/findandreplaceui.js @@ -29,6 +29,9 @@ export default class FindAndReplaceUI extends Plugin { return 'FindAndReplaceUI'; } + /** + * @inheritDoc + */ constructor( editor ) { super( editor ); @@ -60,7 +63,7 @@ export default class FindAndReplaceUI extends Plugin { // the default action of the drop-down is executed (i.e. the panel showed up). Otherwise, // the invisible form/input cannot be focused/selected. // - // Each time a dropdown is closed, move the focus back to the editing root (to preserve it) + // Each time a dropdown is closed, move the focus back to the find and replace toolbar button // and let the find and replace editing feature know that all search results can be invalidated // and no longer should be marked in the content. dropdown.on( 'change:isOpen', ( event, name, isOpen ) => { @@ -73,7 +76,7 @@ export default class FindAndReplaceUI extends Plugin { formView.enableCssTransitions(); } else { - editor.editing.view.focus(); + formView.focus(); this.fire( 'searchReseted' ); } @@ -87,8 +90,10 @@ export default class FindAndReplaceUI extends Plugin { } /** + * Sets up the find and replace button. + * * @private - * @param {module:ui/dropdown/dropdownview~DropdownView} buttonView + * @param {module:ui/dropdown/dropdownview~DropdownView} dropdown */ _setupDropdownButton( dropdown ) { const editor = this.editor; @@ -108,6 +113,8 @@ export default class FindAndReplaceUI extends Plugin { } /** + * Sets up the form view for the find and replace. + * * @private * @param {module:find-and-replace/ui/findandreplaceformview~FindAndReplaceFormView} formView A related form view. */ diff --git a/packages/ckeditor5-find-and-replace/src/findcommand.js b/packages/ckeditor5-find-and-replace/src/findcommand.js index 4e8cacf5409..0e068d3fc5b 100644 --- a/packages/ckeditor5-find-and-replace/src/findcommand.js +++ b/packages/ckeditor5-find-and-replace/src/findcommand.js @@ -20,6 +20,7 @@ export default class FindCommand extends Command { * Creates a new `FindCommand` instance. * * @param {module:core/editor/editor~Editor} editor The editor on which this command will be used. + * @param {module:find-and-replace/findandreplacestate~FindAndReplaceState} state An object to hold plugin state. */ constructor( editor, state ) { super( editor ); @@ -27,7 +28,13 @@ export default class FindCommand extends Command { // The find command is always enabled. this.isEnabled = true; - this.state = state; + /** + * The find and replace state object used for command operations. + * + * @private + * @member {module:find-and-replace/findandreplacestate~FindAndReplaceState} #_state + */ + this._state = state; // Do not block the command if the editor goes into the read-only mode as it does not impact the data. See #9975. this.listenTo( editor, 'change:isReadOnly', () => { @@ -42,6 +49,7 @@ export default class FindCommand extends Command { * @param {Object} [options] * @param {Boolean} [options.matchCase=false] If set to `true`, the letter case will be matched. * @param {Boolean} [options.wholeWords=false] If set to `true`, only whole words that match `callbackOrText` will be matched. + * * @fires execute */ execute( callbackOrText, { matchCase, wholeWords } = {} ) { @@ -54,7 +62,7 @@ export default class FindCommand extends Command { if ( typeof callbackOrText === 'string' ) { findCallback = findByTextCallback( callbackOrText, { matchCase, wholeWords } ); - this.state.searchText = callbackOrText; + this._state.searchText = callbackOrText; } else { findCallback = callbackOrText; } @@ -68,16 +76,16 @@ export default class FindCommand extends Command { currentResults ) ), null ); - this.state.clear( model ); - this.state.results.addMany( Array.from( results ) ); - this.state.highlightedResult = results.get( 0 ); + this._state.clear( model ); + this._state.results.addMany( Array.from( results ) ); + this._state.highlightedResult = results.get( 0 ); if ( typeof callbackOrText === 'string' ) { - this.state.searchText = callbackOrText; + this._state.searchText = callbackOrText; } - this.state.matchCase = !!matchCase; - this.state.matchWholeWords = !!wholeWords; + this._state.matchCase = !!matchCase; + this._state.matchWholeWords = !!wholeWords; return { results, diff --git a/packages/ckeditor5-find-and-replace/src/findnextcommand.js b/packages/ckeditor5-find-and-replace/src/findnextcommand.js index da419ee44af..c04d4e4982d 100644 --- a/packages/ckeditor5-find-and-replace/src/findnextcommand.js +++ b/packages/ckeditor5-find-and-replace/src/findnextcommand.js @@ -21,6 +21,7 @@ export default class FindNextCommand extends Command { * Creates a new `FindNextCommand` instance. * * @param {module:core/editor/editor~Editor} editor The editor on which this command will be used. + * @param {module:find-and-replace/findandreplacestate~FindAndReplaceState} state An object to hold plugin state. */ constructor( editor, state ) { super( editor ); @@ -28,7 +29,7 @@ export default class FindNextCommand extends Command { /** * The find and replace state object used for command operations. * - * @private + * @protected * @member {module:find-and-replace/findandreplacestate~FindAndReplaceState} #_state */ this._state = state; @@ -45,10 +46,16 @@ export default class FindNextCommand extends Command { } ); } + /** + * @inheritDoc + */ refresh() { this.isEnabled = this._state.results.length > 1; } + /** + * @inheritDoc + */ execute() { const results = this._state.results; const currentIndex = results.getIndex( this._state.highlightedResult ); diff --git a/packages/ckeditor5-find-and-replace/src/findpreviouscommand.js b/packages/ckeditor5-find-and-replace/src/findpreviouscommand.js index 3dbf5f474ce..7a1f910a1e7 100644 --- a/packages/ckeditor5-find-and-replace/src/findpreviouscommand.js +++ b/packages/ckeditor5-find-and-replace/src/findpreviouscommand.js @@ -10,13 +10,16 @@ import FindNextCommand from './findnextcommand'; /** - * The find next command. Moves the highlight to the next search result. + * The find previous command. Moves the highlight to the previous search result. * * It is used by the {@link module:find-and-replace/findandreplace~FindAndReplace find and replace feature}. * * @extends module:find-and-replace/findnextcommand~FindNextCommand */ export default class FindPreviousCommand extends FindNextCommand { + /** + * @inheritDoc + */ execute() { const results = this._state.results; const currentIndex = results.getIndex( this._state.highlightedResult ); diff --git a/packages/ckeditor5-find-and-replace/src/replaceallcommand.js b/packages/ckeditor5-find-and-replace/src/replaceallcommand.js index eb8f7712a07..fb7cdc87aee 100644 --- a/packages/ckeditor5-find-and-replace/src/replaceallcommand.js +++ b/packages/ckeditor5-find-and-replace/src/replaceallcommand.js @@ -14,7 +14,7 @@ import ReplaceCommand from './replacecommand'; /** * The replace all command. It is used by the {@link module:find-and-replace/findandreplace~FindAndReplace find and replace feature}. * - * @extends module:core/command~Command + * @extends module:find-and-replace/replacecommand~ReplaceCommand */ export default class ReplaceAllCommand extends ReplaceCommand { /** @@ -33,6 +33,8 @@ export default class ReplaceAllCommand extends ReplaceCommand { * @param {String} newText Text that will be inserted to the editor for each match. * @param {String|module:utils/collection~Collection} textToReplace Text to be replaced or a collection of matches * as returned by the find command. + * + * @fires module:core/command~Command#event:execute */ execute( newText, textToReplace ) { const { editor } = this; diff --git a/packages/ckeditor5-find-and-replace/src/replacecommand.js b/packages/ckeditor5-find-and-replace/src/replacecommand.js index fa35e005512..a0ad8421702 100644 --- a/packages/ckeditor5-find-and-replace/src/replacecommand.js +++ b/packages/ckeditor5-find-and-replace/src/replacecommand.js @@ -19,6 +19,7 @@ export default class ReplaceCommand extends Command { * Creates a new `ReplaceCommand` instance. * * @param {module:core/editor/editor~Editor} editor Editor on which this command will be used. + * @param {module:find-and-replace/findandreplacestate~FindAndReplaceState} state An object to hold plugin state. */ constructor( editor, state ) { super( editor ); @@ -29,7 +30,7 @@ export default class ReplaceCommand extends Command { /** * The find and replace state object used for command operations. * - * @private + * @protected * @member {module:find-and-replace/findandreplacestate~FindAndReplaceState} #_state */ this._state = state; @@ -40,6 +41,8 @@ export default class ReplaceCommand extends Command { * * @param {String} replacementText * @param {Object} result A single result from the find command. + * + * @fires module:core/command~Command#event:execute */ execute( replacementText, result ) { const { model } = this.editor; diff --git a/packages/ckeditor5-find-and-replace/src/utils.js b/packages/ckeditor5-find-and-replace/src/utils.js index 7ba137e04f6..631b4e3fc3e 100644 --- a/packages/ckeditor5-find-and-replace/src/utils.js +++ b/packages/ckeditor5-find-and-replace/src/utils.js @@ -4,7 +4,7 @@ */ /** - * @module find-and-replace + * @module find-and-replace/utils */ import { uid, Collection } from 'ckeditor5/src/utils'; @@ -13,11 +13,11 @@ import { escapeRegExp } from 'lodash-es'; /** * Executes findCallback and updates search results list. * - * @param {module:engine/model/range~Range} range - * @param {module:engine/model/model~Model} model - * @param {Function} findCallback + * @param {module:engine/model/range~Range} range The model range to scan for matches. + * @param {module:engine/model/model~Model} model The model. + * @param {Function} findCallback The callback that should return `true` if provided text matches the search term. * @param {module:utils/collection~Collection} [startResults] An optional collection of find matches that the function should - * starts with. This would be a collection returned by a previous `updateFindResultFromRange()` call. + * start with. This would be a collection returned by a previous `updateFindResultFromRange()` call. * @returns {module:utils/collection~Collection} A collection of objects describing find match. * * An example structure: @@ -79,6 +79,9 @@ export function updateFindResultFromRange( range, model, findCallback, startResu /** * Returns text representation of a range. The returned text length should be the same as range length. * In order to achieve this this function will replace inline elements (text-line) as new line character ("\n"). + * + * @param {module:engine/model/range~Range} range The model range. + * @returns {String} The text content of the provided range. */ export function rangeToText( range ) { return Array.from( range.getItems() ).reduce( ( rangeText, node ) => { @@ -93,6 +96,7 @@ export function rangeToText( range ) { }, '' ); } +// Finds the appropriate index in the resultsList Collection. function findInsertIndex( resultsList, markerToInsert ) { const result = resultsList.find( ( { marker } ) => { return markerToInsert.getStart().isBefore( marker.getStart() ); @@ -101,6 +105,7 @@ function findInsertIndex( resultsList, markerToInsert ) { return result ? resultsList.getIndex( result ) : resultsList.length; } +// Maps RegExp match result to find result. function regexpMatchToFindResult( matchResult ) { const lastGroupIndex = matchResult.length - 1; @@ -120,9 +125,10 @@ function regexpMatchToFindResult( matchResult ) { } /** + * Creates a text matching callback for a specified search term and matching options. * - * @param {String} searchTerm - * @param {Object} [options] + * @param {String} searchTerm The search term. + * @param {Object} [options] Matching options. * @param {Boolean} [options.matchCase=false] If set to `true` letter casing will be ignored. * @param {Boolean} [options.wholeWords=false] If set to `true` only whole words that match `callbackOrText` will be matched. * @returns {Function} diff --git a/packages/ckeditor5-find-and-replace/tests/findandreplaceui.js b/packages/ckeditor5-find-and-replace/tests/findandreplaceui.js index 8e09ea8917f..8966ca86b2f 100644 --- a/packages/ckeditor5-find-and-replace/tests/findandreplaceui.js +++ b/packages/ckeditor5-find-and-replace/tests/findandreplaceui.js @@ -122,10 +122,10 @@ describe( 'FindAndReplaceUI', () => { } ); describe( 'upon dropdown close', () => { - it( 'the editing view should be focused', () => { + it( 'the form should be focused', () => { dropdown.isOpen = true; - const spy = sinon.spy( editor.editing.view, 'focus' ); + const spy = sinon.spy( form, 'focus' ); dropdown.isOpen = false; diff --git a/packages/ckeditor5-find-and-replace/tests/findcommand.js b/packages/ckeditor5-find-and-replace/tests/findcommand.js index 7d426588db8..750373bcdf2 100644 --- a/packages/ckeditor5-find-and-replace/tests/findcommand.js +++ b/packages/ckeditor5-find-and-replace/tests/findcommand.js @@ -58,7 +58,7 @@ describe( 'FindCommand', () => { describe( 'state', () => { it( 'is set to plugin\'s state', () => { - expect( command.state ).to.equal( editor.plugins.get( 'FindAndReplaceEditing' ).state ); + expect( command._state ).to.equal( editor.plugins.get( 'FindAndReplaceEditing' ).state ); } ); } ); diff --git a/packages/ckeditor5-image/src/autoimage.js b/packages/ckeditor5-image/src/autoimage.js index cd9f7324fcf..42a9796956b 100644 --- a/packages/ckeditor5-image/src/autoimage.js +++ b/packages/ckeditor5-image/src/autoimage.js @@ -11,6 +11,7 @@ import { Plugin } from 'ckeditor5/src/core'; import { Clipboard } from 'ckeditor5/src/clipboard'; import { LivePosition, LiveRange } from 'ckeditor5/src/engine'; import { Undo } from 'ckeditor5/src/undo'; +import { Delete } from 'ckeditor5/src/typing'; import { global } from 'ckeditor5/src/utils'; import ImageUtils from './imageutils'; @@ -32,7 +33,7 @@ export default class AutoImage extends Plugin { * @inheritDoc */ static get requires() { - return [ Clipboard, ImageUtils, Undo ]; + return [ Clipboard, ImageUtils, Undo, Delete ]; } /** @@ -173,6 +174,8 @@ export default class AutoImage extends Plugin { this._positionToInsert.detach(); this._positionToInsert = null; } ); + + editor.plugins.get( 'Delete' ).requestUndoOnBackspace(); }, 100 ); } } diff --git a/packages/ckeditor5-image/src/image/ui/utils.js b/packages/ckeditor5-image/src/image/ui/utils.js index 30d80dc7fa1..8f5e8f9cc2c 100644 --- a/packages/ckeditor5-image/src/image/ui/utils.js +++ b/packages/ckeditor5-image/src/image/ui/utils.js @@ -47,7 +47,8 @@ export function getBalloonPositionData( editor ) { defaultPositions.northArrowSouthEast, defaultPositions.southArrowNorth, defaultPositions.southArrowNorthWest, - defaultPositions.southArrowNorthEast + defaultPositions.southArrowNorthEast, + defaultPositions.viewportStickyNorth ] }; } diff --git a/packages/ckeditor5-image/tests/autoimage.js b/packages/ckeditor5-image/tests/autoimage.js index a863deeca26..f81964e3648 100644 --- a/packages/ckeditor5-image/tests/autoimage.js +++ b/packages/ckeditor5-image/tests/autoimage.js @@ -14,6 +14,7 @@ import Table from '@ckeditor/ckeditor5-table/src/table'; import Typing from '@ckeditor/ckeditor5-typing/src/typing'; import Undo from '@ckeditor/ckeditor5-undo/src/undo'; import global from '@ckeditor/ckeditor5-utils/src/dom/global'; +import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import Image from '../src/image'; @@ -102,6 +103,30 @@ describe( 'AutoImage - integration', () => { ); } ); + it( 'can undo auto-embeding by pressing backspace', () => { + const viewDocument = editor.editing.view.document; + const deleteEvent = new DomEventData( + viewDocument, + { preventDefault: sinon.spy() }, + { direction: 'backward', unit: 'codePoint', sequence: 1 } + ); + + setData( editor.model, '[]' ); + pasteHtml( editor, 'http://example.com/image.png' ); + + expect( getData( editor.model ) ).to.equal( + 'http://example.com/image.png[]' + ); + + clock.tick( 100 ); + + viewDocument.fire( 'delete', deleteEvent ); + + expect( getData( editor.model ) ).to.equal( + 'http://example.com/image.png[]' + ); + } ); + describe( 'supported URL', () => { const supportedURLs = [ 'example.com/image.png', diff --git a/packages/ckeditor5-image/tests/image/ui/utils.js b/packages/ckeditor5-image/tests/image/ui/utils.js index f0acd66228d..338103dc753 100644 --- a/packages/ckeditor5-image/tests/image/ui/utils.js +++ b/packages/ckeditor5-image/tests/image/ui/utils.js @@ -22,7 +22,8 @@ describe( 'Utils', () => { defaultPositions.northArrowSouthEast, defaultPositions.southArrowNorth, defaultPositions.southArrowNorthWest, - defaultPositions.southArrowNorthEast + defaultPositions.southArrowNorthEast, + defaultPositions.viewportStickyNorth ]; let editor, converter, selection, balloon, editorElement; diff --git a/packages/ckeditor5-link/src/autolink.js b/packages/ckeditor5-link/src/autolink.js index 15d2edf43a2..f5b49c0a7bc 100644 --- a/packages/ckeditor5-link/src/autolink.js +++ b/packages/ckeditor5-link/src/autolink.js @@ -8,7 +8,7 @@ */ import { Plugin } from 'ckeditor5/src/core'; -import { TextWatcher, getLastTextLine } from 'ckeditor5/src/typing'; +import { Delete, TextWatcher, getLastTextLine } from 'ckeditor5/src/typing'; import { addLinkProtocolIfApplicable } from './utils'; @@ -69,6 +69,13 @@ const URL_GROUP_IN_MATCH = 2; * @extends module:core/plugin~Plugin */ export default class AutoLink extends Plugin { + /** + * @inheritDoc + */ + static get requires() { + return [ Delete ]; + } + /** * @inheritDoc */ @@ -226,6 +233,7 @@ export default class AutoLink extends Plugin { */ _applyAutoLink( link, range ) { const model = this.editor.model; + const deletePlugin = this.editor.plugins.get( 'Delete' ); if ( !this.isEnabled || !isLinkAllowedOnRange( range, model ) ) { return; @@ -236,6 +244,10 @@ export default class AutoLink extends Plugin { const defaultProtocol = this.editor.config.get( 'link.defaultProtocol' ); const parsedUrl = addLinkProtocolIfApplicable( link, defaultProtocol ); writer.setAttribute( 'linkHref', parsedUrl, range ); + + model.enqueueChange( () => { + deletePlugin.requestUndoOnBackspace(); + } ); } ); } } diff --git a/packages/ckeditor5-link/tests/autolink.js b/packages/ckeditor5-link/tests/autolink.js index 6ebe9a1726c..ec2fd9910c1 100644 --- a/packages/ckeditor5-link/tests/autolink.js +++ b/packages/ckeditor5-link/tests/autolink.js @@ -10,6 +10,7 @@ import Input from '@ckeditor/ckeditor5-typing/src/input'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import ShiftEnter from '@ckeditor/ckeditor5-enter/src/shiftenter'; import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting'; +import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import LinkEditing from '../src/linkediting'; @@ -388,6 +389,23 @@ describe( 'AutoLink', () => { '[]' ); } ); + + it( 'should undo auto-linking by pressing backspace', () => { + const viewDocument = editor.editing.view.document; + const deleteEvent = new DomEventData( + viewDocument, + { preventDefault: sinon.spy() }, + { direction: 'backward', unit: 'codePoint', sequence: 1 } + ); + + simulateTyping( ' ' ); + + viewDocument.fire( 'delete', deleteEvent ); + + expect( getData( model ) ).to.equal( + 'https://www.cksource.com []' + ); + } ); } ); describe( 'Code blocks integration', () => { diff --git a/packages/ckeditor5-media-embed/src/automediaembed.js b/packages/ckeditor5-media-embed/src/automediaembed.js index d9ceca358c3..51b0d1e7948 100644 --- a/packages/ckeditor5-media-embed/src/automediaembed.js +++ b/packages/ckeditor5-media-embed/src/automediaembed.js @@ -10,6 +10,7 @@ import { Plugin } from 'ckeditor5/src/core'; import { LiveRange, LivePosition } from 'ckeditor5/src/engine'; import { Clipboard } from 'ckeditor5/src/clipboard'; +import { Delete } from 'ckeditor5/src/typing'; import { Undo } from 'ckeditor5/src/undo'; import { global } from 'ckeditor5/src/utils'; @@ -29,7 +30,7 @@ export default class AutoMediaEmbed extends Plugin { * @inheritDoc */ static get requires() { - return [ Clipboard, Undo ]; + return [ Clipboard, Delete, Undo ]; } /** @@ -174,6 +175,8 @@ export default class AutoMediaEmbed extends Plugin { this._positionToInsert.detach(); this._positionToInsert = null; } ); + + editor.plugins.get( 'Delete' ).requestUndoOnBackspace(); }, 100 ); } } diff --git a/packages/ckeditor5-media-embed/tests/automediaembed.js b/packages/ckeditor5-media-embed/tests/automediaembed.js index fcdf0562d25..5aa36c7d420 100644 --- a/packages/ckeditor5-media-embed/tests/automediaembed.js +++ b/packages/ckeditor5-media-embed/tests/automediaembed.js @@ -17,6 +17,7 @@ import Image from '@ckeditor/ckeditor5-image/src/image'; import ImageCaption from '@ckeditor/ckeditor5-image/src/imagecaption'; import Table from '@ckeditor/ckeditor5-table/src/table'; import global from '@ckeditor/ckeditor5-utils/src/dom/global'; +import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; describe( 'AutoMediaEmbed - integration', () => { @@ -96,6 +97,30 @@ describe( 'AutoMediaEmbed - integration', () => { ); } ); + it( 'can undo auto-embeding by pressing backspace', () => { + const viewDocument = editor.editing.view.document; + const deleteEvent = new DomEventData( + viewDocument, + { preventDefault: sinon.spy() }, + { direction: 'backward', unit: 'codePoint', sequence: 1 } + ); + + setData( editor.model, '[]' ); + pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' ); + + expect( getData( editor.model ) ).to.equal( + 'https://www.youtube.com/watch?v=H08tGjXNHO4[]' + ); + + clock.tick( 100 ); + + viewDocument.fire( 'delete', deleteEvent ); + + expect( getData( editor.model ) ).to.equal( + 'https://www.youtube.com/watch?v=H08tGjXNHO4[]' + ); + } ); + it( 'works for a full URL (https + "www" sub-domain)', () => { setData( editor.model, '[]' ); pasteHtml( editor, 'https://www.youtube.com/watch?v=H08tGjXNHO4' ); diff --git a/packages/ckeditor5-mention/docs/features/mentions.md b/packages/ckeditor5-mention/docs/features/mentions.md index 51e9557e15f..1b224d0b7ec 100644 --- a/packages/ckeditor5-mention/docs/features/mentions.md +++ b/packages/ckeditor5-mention/docs/features/mentions.md @@ -9,6 +9,8 @@ menu-title: Mentions The {@link module:mention/mention~Mention} feature brings support for smart autocompletion based on user input. When a user types a pre-configured marker, such as `@` or `#`, they get autocomplete suggestions in a panel displayed next to the caret. The selected suggestion is then inserted into the content. +You can read more about possible implementations of the mention feature in a [dedicated blog post](https://ckeditor.com/blog/mentions-in-ckeditor-5-feature-of-the-month/). + ## Demo You can type the "@" character to invoke the mention autocomplete UI. The demo below is configured to suggest a static list of names ("Barney", "Lily", "Marshall", "Robin", and "Ted"). diff --git a/packages/ckeditor5-mention/src/mentionui.js b/packages/ckeditor5-mention/src/mentionui.js index 7f26848b51d..fe9614aad5e 100644 --- a/packages/ckeditor5-mention/src/mentionui.js +++ b/packages/ckeditor5-mention/src/mentionui.js @@ -463,7 +463,6 @@ export default class MentionUI extends Plugin { this._balloon.add( { view: this._mentionsView, position: this._getBalloonPanelPositionData( markerMarker, this._mentionsView.position ), - withArrow: false, singleViewMode: true } ); } @@ -586,7 +585,10 @@ function getBalloonPanelPositions( preferredPosition ) { return { top: targetRect.bottom + VERTICAL_SPACING, left: targetRect.right, - name: 'caret_se' + name: 'caret_se', + config: { + withArrow: false + } }; }, @@ -595,7 +597,10 @@ function getBalloonPanelPositions( preferredPosition ) { return { top: targetRect.top - balloonRect.height - VERTICAL_SPACING, left: targetRect.right, - name: 'caret_ne' + name: 'caret_ne', + config: { + withArrow: false + } }; }, @@ -604,7 +609,10 @@ function getBalloonPanelPositions( preferredPosition ) { return { top: targetRect.bottom + VERTICAL_SPACING, left: targetRect.right - balloonRect.width, - name: 'caret_sw' + name: 'caret_sw', + config: { + withArrow: false + } }; }, @@ -613,7 +621,10 @@ function getBalloonPanelPositions( preferredPosition ) { return { top: targetRect.top - balloonRect.height - VERTICAL_SPACING, left: targetRect.right - balloonRect.width, - name: 'caret_nw' + name: 'caret_nw', + config: { + withArrow: false + } }; } }; diff --git a/packages/ckeditor5-mention/tests/mentionui.js b/packages/ckeditor5-mention/tests/mentionui.js index e6dec3e5ff7..f11640a06f6 100644 --- a/packages/ckeditor5-mention/tests/mentionui.js +++ b/packages/ckeditor5-mention/tests/mentionui.js @@ -23,7 +23,7 @@ import MentionsView from '../src/ui/mentionsview'; import { assertCKEditorError } from '@ckeditor/ckeditor5-utils/tests/_utils/utils'; describe( 'MentionUI', () => { - let editor, model, doc, editingView, mentionUI, editorElement, mentionsView, panelView; + let editor, model, doc, editingView, mentionUI, editorElement, mentionsView, panelView, clock; const staticConfig = { feeds: [ @@ -37,12 +37,14 @@ describe( 'MentionUI', () => { testUtils.createSinonSandbox(); beforeEach( () => { + clock = sinon.useFakeTimers( { now: Date.now() } ); editorElement = document.createElement( 'div' ); document.body.appendChild( editorElement ); } ); afterEach( () => { sinon.restore(); + clock.restore(); editorElement.remove(); if ( editor ) { @@ -196,25 +198,37 @@ describe( 'MentionUI', () => { expect( caretSouthEast( caretRect, balloonRect ) ).to.deep.equal( { left: 501, name: 'caret_se', - top: 121 + top: 121, + config: { + withArrow: false + } } ); expect( caretSouthWest( caretRect, balloonRect ) ).to.deep.equal( { left: 301, name: 'caret_sw', - top: 121 + top: 121, + config: { + withArrow: false + } } ); expect( caretNorthEast( caretRect, balloonRect ) ).to.deep.equal( { left: 501, name: 'caret_ne', - top: -53 + top: -53, + config: { + withArrow: false + } } ); expect( caretNorthWest( caretRect, balloonRect ) ).to.deep.equal( { left: 301, name: 'caret_nw', - top: -53 + top: -53, + config: { + withArrow: false + } } ); } ); } ); @@ -2253,14 +2267,13 @@ describe( 'MentionUI', () => { function wait( timeout ) { return () => new Promise( resolve => { - setTimeout( () => { - resolve(); - }, timeout ); + clock.tick( timeout ); + resolve(); } ); } - function waitForDebounce() { - return wait( 180 )(); + async function waitForDebounce() { + return await wait( 180 )(); } function fireKeyDownEvent( options ) { diff --git a/packages/ckeditor5-table/src/converters/table-heading-rows-refresh-post-fixer.js b/packages/ckeditor5-table/src/converters/table-heading-rows-refresh-post-fixer.js index b3810fd6cfb..f3a62c29f8e 100644 --- a/packages/ckeditor5-table/src/converters/table-heading-rows-refresh-post-fixer.js +++ b/packages/ckeditor5-table/src/converters/table-heading-rows-refresh-post-fixer.js @@ -29,14 +29,31 @@ function tableHeadingRowsRefreshPostFixer( model ) { const tablesToRefresh = new Set(); for ( const change of differ.getChanges() ) { - if ( change.type != 'attribute' ) { - continue; - } + if ( change.type === 'attribute' ) { + const element = change.range.start.nodeAfter; + + if ( element && element.is( 'element', 'table' ) && change.attributeKey === 'headingRows' ) { + tablesToRefresh.add( element ); + } + } else { + /* istanbul ignore else */ + if ( change.type === 'insert' || change.type === 'remove' ) { + if ( change.name === 'tableRow' ) { + const table = change.position.findAncestor( 'table' ); + const headingRows = table.getAttribute( 'headingRows' ) || 0; - const element = change.range.start.nodeAfter; + if ( change.position.offset < headingRows ) { + tablesToRefresh.add( table ); + } + } else if ( change.name === 'tableCell' ) { + const table = change.position.findAncestor( 'table' ); + const headingColumns = table.getAttribute( 'headingColumns' ) || 0; - if ( element && element.is( 'element', 'table' ) && change.attributeKey == 'headingRows' ) { - tablesToRefresh.add( element ); + if ( change.position.offset < headingColumns ) { + tablesToRefresh.add( table ); + } + } + } } } diff --git a/packages/ckeditor5-table/src/tablecellproperties/ui/tablecellpropertiesview.js b/packages/ckeditor5-table/src/tablecellproperties/ui/tablecellpropertiesview.js index 4c743cd2c66..8b33d3dfdef 100644 --- a/packages/ckeditor5-table/src/tablecellproperties/ui/tablecellpropertiesview.js +++ b/packages/ckeditor5-table/src/tablecellproperties/ui/tablecellpropertiesview.js @@ -799,7 +799,6 @@ export default class TableCellPropertiesView extends View { label: t( 'Cancel' ), icon: icons.cancel, class: 'ck-button-cancel', - type: 'cancel', withText: true } ); diff --git a/packages/ckeditor5-table/src/tableproperties/ui/tablepropertiesview.js b/packages/ckeditor5-table/src/tableproperties/ui/tablepropertiesview.js index 647fb9f7a71..bfd28e493d2 100644 --- a/packages/ckeditor5-table/src/tableproperties/ui/tablepropertiesview.js +++ b/packages/ckeditor5-table/src/tableproperties/ui/tablepropertiesview.js @@ -690,7 +690,6 @@ export default class TablePropertiesView extends View { label: t( 'Cancel' ), icon: icons.cancel, class: 'ck-button-cancel', - type: 'cancel', withText: true } ); diff --git a/packages/ckeditor5-table/src/utils/ui/contextualballoon.js b/packages/ckeditor5-table/src/utils/ui/contextualballoon.js index 7b81d367624..e624b766849 100644 --- a/packages/ckeditor5-table/src/utils/ui/contextualballoon.js +++ b/packages/ckeditor5-table/src/utils/ui/contextualballoon.js @@ -7,7 +7,6 @@ * @module table/utils/ui/contextualballoon */ -import { centeredBalloonPositionForLongWidgets } from 'ckeditor5/src/widget'; import { Rect } from 'ckeditor5/src/utils'; import { BalloonPanelView } from 'ckeditor5/src/ui'; @@ -21,12 +20,8 @@ const BALLOON_POSITIONS = [ DEFAULT_BALLOON_POSITIONS.northArrowSouthEast, DEFAULT_BALLOON_POSITIONS.southArrowNorth, DEFAULT_BALLOON_POSITIONS.southArrowNorthWest, - DEFAULT_BALLOON_POSITIONS.southArrowNorthEast -]; - -const TABLE_PROPERTIES_BALLOON_POSITIONS = [ - ...BALLOON_POSITIONS, - centeredBalloonPositionForLongWidgets + DEFAULT_BALLOON_POSITIONS.southArrowNorthEast, + DEFAULT_BALLOON_POSITIONS.viewportStickyNorth ]; /** @@ -69,7 +64,7 @@ export function getBalloonTablePositionData( editor ) { return { target: editor.editing.view.domConverter.viewToDom( viewTable ), - positions: TABLE_PROPERTIES_BALLOON_POSITIONS + positions: BALLOON_POSITIONS }; } diff --git a/packages/ckeditor5-table/tests/_utils/utils.js b/packages/ckeditor5-table/tests/_utils/utils.js index d646e6c0f91..527abaf74bd 100644 --- a/packages/ckeditor5-table/tests/_utils/utils.js +++ b/packages/ckeditor5-table/tests/_utils/utils.js @@ -141,6 +141,10 @@ export function setTableWithObjectAttributes( model, attributes, cellContent ) { * @returns {String} */ export function viewTable( tableData, attributes = {} ) { + if ( attributes.headingColumns ) { + throw new Error( 'The headingColumns attribute is not supported in viewTable util' ); + } + const headingRows = attributes.headingRows || 0; const asWidget = !!attributes.asWidget; diff --git a/packages/ckeditor5-table/tests/converters/downcast.js b/packages/ckeditor5-table/tests/converters/downcast.js index db215889e78..4741897502b 100644 --- a/packages/ckeditor5-table/tests/converters/downcast.js +++ b/packages/ckeditor5-table/tests/converters/downcast.js @@ -8,7 +8,7 @@ import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view'; -import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils'; import { modelTable, viewTable } from '../_utils/utils'; @@ -479,6 +479,8 @@ describe( 'downcast converters', () => { writer.insertElement( 'tableCell', row, 'end' ); writer.insertElement( 'tableCell', row, 'end' ); + + writer.setAttribute( 'headingRows', 3, table ); } ); assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [ @@ -727,7 +729,7 @@ describe( 'downcast converters', () => { assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [ [ { isHeading: true, contents: '00' }, '01' ], [ { isHeading: true, contents: '10' }, '11' ] - ], { headingColumns: 1, asWidget: true } ) ); + ], { asWidget: true } ) ); } ); it( 'should work for changing heading columns to a bigger number', () => { @@ -763,7 +765,7 @@ describe( 'downcast converters', () => { assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [ [ { isHeading: true, contents: '00' }, '01', '02', '03' ], [ { isHeading: true, contents: '10' }, '11', '12', '13' ] - ], { headingColumns: 3, asWidget: true } ) ); + ], { asWidget: true } ) ); } ); it( 'should work for removing heading columns', () => { @@ -1032,6 +1034,114 @@ describe( 'downcast converters', () => { ], { headingRows: 2, asWidget: true } ) ); } ); + it( 'should reorder rows with header correctly - up direction', () => { + setModelData( model, modelTable( [ + [ '00', '01', '02' ], + [ '10', '11', '12' ] + ], { headingRows: 1 } ) ); + + const table = root.getChild( 0 ); + + editor.model.change( writer => { + writer.move( + writer.createRangeOn( table.getChild( 1 ) ), + writer.createPositionAt( table, 0 ) + ); + } ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ '10', '11', '12' ], + [ '00', '01', '02' ] + ], { headingRows: 1 } ) ); + + assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [ + [ '10', '11', '12' ], + [ '00', '01', '02' ] + ], { headingRows: 1, asWidget: true } ) ); + } ); + + it( 'should reorder rows with header correctly - down direction', () => { + setModelData( model, modelTable( [ + [ '00', '01', '02' ], + [ '10', '11', '12' ] + ], { headingRows: 1 } ) ); + + const table = root.getChild( 0 ); + + editor.model.change( writer => { + writer.move( + writer.createRangeOn( table.getChild( 0 ) ), + writer.createPositionAt( table, 2 ) + ); + } ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ '10', '11', '12' ], + [ '00', '01', '02' ] + ], { headingRows: 1 } ) ); + + assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [ + [ '10', '11', '12' ], + [ '00', '01', '02' ] + ], { headingRows: 1, asWidget: true } ) ); + } ); + + it( 'should reorder columns with header correctly - left direction', () => { + setModelData( model, modelTable( [ + [ '00', '01', '02' ], + [ '10', '11', '12' ] + ], { headingColumns: 1 } ) ); + + const table = root.getChild( 0 ); + + editor.model.change( writer => { + for ( const tableRow of table.getChildren() ) { + writer.move( + writer.createRangeOn( tableRow.getChild( 1 ) ), + writer.createPositionAt( tableRow, 0 ) + ); + } + } ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ '01', '00', '02' ], + [ '11', '10', '12' ] + ], { headingColumns: 1 } ) ); + + assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [ + [ { isHeading: true, contents: '01' }, '00', '02' ], + [ { isHeading: true, contents: '11' }, '10', '12' ] + ], { asWidget: true } ) ); + } ); + + it( 'should reorder columns with header correctly - right direction', () => { + setModelData( model, modelTable( [ + [ '00', '01', '02' ], + [ '10', '11', '12' ] + ], { headingColumns: 1 } ) ); + + const table = root.getChild( 0 ); + + editor.model.change( writer => { + for ( const tableRow of table.getChildren() ) { + writer.move( + writer.createRangeOn( tableRow.getChild( 0 ) ), + writer.createPositionAt( tableRow, 2 ) + ); + } + } ); + + assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [ + [ '01', '00', '02' ], + [ '11', '10', '12' ] + ], { headingColumns: 1 } ) ); + + assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [ + [ { isHeading: true, contents: '01' }, '00', '02' ], + [ { isHeading: true, contents: '11' }, '10', '12' ] + ], { asWidget: true } ) ); + } ); + it( 'should create renamed cell as a widget', () => { setModelData( model, modelTable( [ [ '00' ] ] ) ); diff --git a/packages/ckeditor5-table/tests/tablecellproperties/ui/tablecellpropertiesview.js b/packages/ckeditor5-table/tests/tablecellproperties/ui/tablecellpropertiesview.js index 8deb652117c..1fa114ca2ce 100644 --- a/packages/ckeditor5-table/tests/tablecellproperties/ui/tablecellpropertiesview.js +++ b/packages/ckeditor5-table/tests/tablecellproperties/ui/tablecellpropertiesview.js @@ -605,6 +605,7 @@ describe( 'table cell properties', () => { expect( view.cancelButtonView.label ).to.equal( 'Cancel' ); expect( view.cancelButtonView.withText ).to.be.true; expect( view.cancelButtonView.class ).to.equal( 'ck-button-cancel' ); + expect( view.cancelButtonView.type ).to.equal( 'button' ); } ); it( 'should make the cancel button fire the #cancel event when executed', () => { diff --git a/packages/ckeditor5-table/tests/tableclipboard-copy-cut.js b/packages/ckeditor5-table/tests/tableclipboard-copy-cut.js index 36b8b0e844c..9fc2d02f3ef 100644 --- a/packages/ckeditor5-table/tests/tableclipboard-copy-cut.js +++ b/packages/ckeditor5-table/tests/tableclipboard-copy-cut.js @@ -287,8 +287,8 @@ describe( 'table clipboard', () => { assertClipboardContentOnMethod( 'copy', viewTable( [ [ '11', '12', '13' ], [ '21', '22', '23' ], - [ { contents: '31', isHeading: true }, '32', '33' ] // TODO: bug in viewTable - ], { headingRows: 2, headingColumns: 1 } ) ); + [ { contents: '31', isHeading: true }, '32', '33' ] + ], { headingRows: 2 } ) ); } ); it( 'should update table heading attributes (selection without headings)', () => { diff --git a/packages/ckeditor5-table/tests/tableproperties/ui/tablepropertiesview.js b/packages/ckeditor5-table/tests/tableproperties/ui/tablepropertiesview.js index 00638e6c0c9..01be9ee018f 100644 --- a/packages/ckeditor5-table/tests/tableproperties/ui/tablepropertiesview.js +++ b/packages/ckeditor5-table/tests/tableproperties/ui/tablepropertiesview.js @@ -527,6 +527,7 @@ describe( 'table properties', () => { expect( view.cancelButtonView.label ).to.equal( 'Cancel' ); expect( view.cancelButtonView.withText ).to.be.true; expect( view.cancelButtonView.class ).to.equal( 'ck-button-cancel' ); + expect( view.cancelButtonView.type ).to.equal( 'button' ); } ); it( 'should make the cancel button fire the #cancel event when executed', () => { diff --git a/packages/ckeditor5-table/tests/utils/ui/contextualballoon.js b/packages/ckeditor5-table/tests/utils/ui/contextualballoon.js index 78cfd744a89..41d8d1e2df4 100644 --- a/packages/ckeditor5-table/tests/utils/ui/contextualballoon.js +++ b/packages/ckeditor5-table/tests/utils/ui/contextualballoon.js @@ -13,7 +13,6 @@ import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpa import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; -import { centeredBalloonPositionForLongWidgets } from '@ckeditor/ckeditor5-widget/src/utils'; import { modelTable } from '../../_utils/utils'; import { getTableCellsContainingSelection } from '../../../src/utils/selection'; import { getBalloonCellPositionData, repositionContextualBalloon } from '../../../src/utils/ui/contextualballoon'; @@ -79,7 +78,8 @@ describe( 'table utils', () => { defaultPositions.northArrowSouthEast, defaultPositions.southArrowNorth, defaultPositions.southArrowNorthWest, - defaultPositions.southArrowNorthEast + defaultPositions.southArrowNorthEast, + defaultPositions.viewportStickyNorth ] } ); } ); @@ -128,7 +128,7 @@ describe( 'table utils', () => { defaultPositions.southArrowNorth, defaultPositions.southArrowNorthWest, defaultPositions.southArrowNorthEast, - centeredBalloonPositionForLongWidgets + defaultPositions.viewportStickyNorth ] } ); } ); @@ -186,7 +186,8 @@ describe( 'table utils', () => { defaultPositions.northArrowSouthEast, defaultPositions.southArrowNorth, defaultPositions.southArrowNorthWest, - defaultPositions.southArrowNorthEast + defaultPositions.southArrowNorthEast, + defaultPositions.viewportStickyNorth ] } ); } ); diff --git a/packages/ckeditor5-typing/src/delete.js b/packages/ckeditor5-typing/src/delete.js index 0d921748261..2e66d65fd3c 100644 --- a/packages/ckeditor5-typing/src/delete.js +++ b/packages/ckeditor5-typing/src/delete.js @@ -18,6 +18,13 @@ import env from '@ckeditor/ckeditor5-utils/src/env'; * @extends module:core/plugin~Plugin */ export default class Delete extends Plugin { + /** + * Whether pressing backspace should trigger undo action + * + * @private + * @member {Boolean} #_undoOnBackspace + */ + /** * @inheritDoc */ @@ -29,9 +36,12 @@ export default class Delete extends Plugin { const editor = this.editor; const view = editor.editing.view; const viewDocument = view.document; + const modelDocument = editor.model.document; view.addObserver( DeleteObserver ); + this._undoOnBackspace = false; + const deleteForwardCommand = new DeleteCommand( editor, 'forward' ); // Register `deleteForward` command and add `forwardDelete` command as an alias for backward compatibility. @@ -97,5 +107,33 @@ export default class Delete extends Plugin { } } ); } + + if ( this.editor.plugins.has( 'UndoEditing' ) ) { + this.listenTo( viewDocument, 'delete', ( evt, data ) => { + if ( this._undoOnBackspace && data.direction == 'backward' && data.sequence == 1 && data.unit == 'codePoint' ) { + this._undoOnBackspace = false; + + editor.execute( 'undo' ); + + data.preventDefault(); + evt.stop(); + } + }, { context: '$capture' } ); + + this.listenTo( modelDocument, 'change', () => { + this._undoOnBackspace = false; + } ); + } + } + + /** + * If the next user action after calling this method is pressing backspace, it would undo the last change. + * + * Requires {@link module:undo/undoediting~UndoEditing} plugin. If not loaded, does nothing. + */ + requestUndoOnBackspace() { + if ( this.editor.plugins.has( 'UndoEditing' ) ) { + this._undoOnBackspace = true; + } } } diff --git a/packages/ckeditor5-typing/src/deleteobserver.js b/packages/ckeditor5-typing/src/deleteobserver.js index b8258a89d36..a7c59d0b3a6 100644 --- a/packages/ckeditor5-typing/src/deleteobserver.js +++ b/packages/ckeditor5-typing/src/deleteobserver.js @@ -111,7 +111,7 @@ export default class DeleteObserver extends Observer { * @event module:engine/view/document~Document#event:delete * @param {module:engine/view/observer/domeventdata~DomEventData} data * @param {'forward'|'delete'} data.direction The direction in which the deletion should happen. - * @param {'character'|'word'} data.unit The "amount" of content that should be deleted. + * @param {'character'|'codePoint'|'word'} data.unit The "amount" of content that should be deleted. * @param {Number} data.sequence A number describing which subsequent delete event it is without the key being released. * If it's 2 or more it means that the key was pressed and hold. * @param {module:engine/view/selection~Selection} [data.selectionToRemove] View selection which content should be removed. If not set, diff --git a/packages/ckeditor5-typing/src/texttransformation.js b/packages/ckeditor5-typing/src/texttransformation.js index 3c89ce7237a..24dfb93b29d 100644 --- a/packages/ckeditor5-typing/src/texttransformation.js +++ b/packages/ckeditor5-typing/src/texttransformation.js @@ -19,11 +19,11 @@ const TRANSFORMATIONS = { trademark: { from: '(tm)', to: 'โ„ข' }, // Mathematical: - oneHalf: { from: '1/2', to: 'ยฝ' }, - oneThird: { from: '1/3', to: 'โ…“' }, - twoThirds: { from: '2/3', to: 'โ…”' }, - oneForth: { from: '1/4', to: 'ยผ' }, - threeQuarters: { from: '3/4', to: 'ยพ' }, + oneHalf: { from: /(^|[^/a-z0-9])(1\/2)([^/a-z0-9])$/i, to: [ null, 'ยฝ', null ] }, + oneThird: { from: /(^|[^/a-z0-9])(1\/3)([^/a-z0-9])$/i, to: [ null, 'โ…“', null ] }, + twoThirds: { from: /(^|[^/a-z0-9])(2\/3)([^/a-z0-9])$/i, to: [ null, 'โ…”', null ] }, + oneForth: { from: /(^|[^/a-z0-9])(1\/4)([^/a-z0-9])$/i, to: [ null, 'ยผ', null ] }, + threeQuarters: { from: /(^|[^/a-z0-9])(3\/4)([^/a-z0-9])$/i, to: [ null, 'ยพ', null ] }, lessThanOrEqual: { from: '<=', to: 'โ‰ค' }, greaterThanOrEqual: { from: '>=', to: 'โ‰ฅ' }, notEqual: { from: '!=', to: 'โ‰ ' }, @@ -74,6 +74,13 @@ const DEFAULT_TRANSFORMATIONS = [ * @extends module:core/plugin~Plugin */ export default class TextTransformation extends Plugin { + /** + * @inheritDoc + */ + static get requires() { + return [ 'Delete', 'Input' ]; + } + /** * @inheritDoc */ @@ -117,7 +124,8 @@ export default class TextTransformation extends Plugin { _enableTransformationWatchers() { const editor = this.editor; const model = editor.model; - const input = editor.plugins.get( 'Input' ); + const inputPlugin = editor.plugins.get( 'Input' ); + const deletePlugin = editor.plugins.get( 'Delete' ); const normalizedTransformations = normalizeTransformations( editor.config.get( 'typing.transformations' ) ); const testCallback = text => { @@ -132,7 +140,7 @@ export default class TextTransformation extends Plugin { }; const watcherCallback = ( evt, data ) => { - if ( !input.isInput( data.batch ) ) { + if ( !inputPlugin.isInput( data.batch ) ) { return; } @@ -164,6 +172,10 @@ export default class TextTransformation extends Plugin { changeIndex += replaceWith.length; } + + model.enqueueChange( () => { + deletePlugin.requestUndoOnBackspace(); + } ); } ); }; diff --git a/packages/ckeditor5-typing/tests/delete.js b/packages/ckeditor5-typing/tests/delete.js index ed4ef7c56d8..7af7903eb84 100644 --- a/packages/ckeditor5-typing/tests/delete.js +++ b/packages/ckeditor5-typing/tests/delete.js @@ -5,8 +5,11 @@ import Delete from '../src/delete'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting'; import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo'; +import Batch from '@ckeditor/ckeditor5-engine/src/model/batch'; import env from '@ckeditor/ckeditor5-utils/src/env'; import { getCode } from '@ckeditor/ckeditor5-utils/src/keyboard'; @@ -106,12 +109,6 @@ describe( 'Delete feature', () => { sinon.assert.calledOnce( scrollSpy ); sinon.assert.callOrder( executeSpy, scrollSpy ); } ); - - function getDomEvent() { - return { - preventDefault: sinon.spy() - }; - } } ); describe( 'Delete feature - Android', () => { @@ -222,3 +219,135 @@ describe( 'Delete feature - Android', () => { } ).not.to.throw(); } ); } ); + +describe( 'Delete feature - undo by pressing backspace', () => { + let element, editor, viewDocument, plugin; + + const deleteEventEventData = { + direction: 'backward', + unit: 'codePoint', + sequence: 1 + }; + + beforeEach( () => { + element = document.createElement( 'div' ); + document.body.appendChild( element ); + + return ClassicTestEditor + .create( element, { plugins: [ Delete, UndoEditing ] } ) + .then( newEditor => { + editor = newEditor; + viewDocument = editor.editing.view.document; + plugin = newEditor.plugins.get( 'Delete' ); + } ); + } ); + + afterEach( () => { + element.remove(); + return editor.destroy(); + } ); + + it( 'executes `undo` once on pressing backspace after requestUndoOnBackspace()', () => { + const spy = editor.execute = sinon.spy(); + const domEvt = getDomEvent(); + const event = new EventInfo( viewDocument, 'delete' ); + + plugin.requestUndoOnBackspace(); + + viewDocument.fire( event, new DomEventData( viewDocument, domEvt, deleteEventEventData ) ); + + expect( spy.calledOnce ).to.be.true; + expect( spy.calledWithMatch( 'undo' ) ).to.be.true; + + expect( event.stop.called ).to.be.true; + expect( domEvt.preventDefault.calledOnce ).to.be.true; + + viewDocument.fire( 'delete', new DomEventData( viewDocument, getDomEvent(), deleteEventEventData ) ); + + expect( spy.calledTwice ).to.be.true; + expect( spy.calledWithMatch( 'delete', {} ) ).to.be.true; + } ); + + describe( 'does not execute `undo` instead of deleting', () => { + const testCases = [ + { + condition: 'it\'s forward deletion', + eventData: { direction: 'forward', unit: 'codePoint', sequence: 1 } + }, + { + condition: 'the sequence doesn\'t equal 1', + eventData: { direction: 'backward', unit: 'codePoint', sequence: 2 } + }, + { + condition: 'the unit is not `codePoint`', + eventData: { direction: 'backward', unit: 'word', sequence: 1 } + } + ]; + + testCases.forEach( ( { condition, eventData } ) => { + it( 'if ' + condition, () => { + const spy = editor.execute = sinon.spy(); + + plugin.requestUndoOnBackspace(); + + viewDocument.fire( 'delete', new DomEventData( viewDocument, getDomEvent(), eventData ) ); + + expect( spy.calledOnce ).to.be.true; + expect( spy.calledWithMatch( 'undo' ) ).to.be.false; + expect( spy.calledWithMatch( 'delete', {} ) ).to.be.true; + } ); + } ); + + it( 'if requestUndoOnBackspace() hasn\'t been called', () => { + const spy = editor.execute = sinon.spy(); + + viewDocument.fire( 'delete', new DomEventData( viewDocument, getDomEvent(), deleteEventEventData ) ); + + expect( spy.calledOnce ).to.be.true; + expect( spy.calledWithMatch( 'undo' ) ).to.be.false; + expect( spy.calledWithMatch( 'delete', {} ) ).to.be.true; + } ); + + it( 'if `UndoEditing` plugin is not loaded', async () => { + await editor.destroy(); + + editor = await ClassicTestEditor.create( element, { plugins: [ Delete ] } ); + viewDocument = editor.editing.view.document; + plugin = editor.plugins.get( 'Delete' ); + + const spy = editor.execute = sinon.spy(); + + plugin.requestUndoOnBackspace(); + + viewDocument.fire( 'delete', new DomEventData( viewDocument, getDomEvent(), { + direction: 'backward', + unit: 'word', + sequence: 1 + } ) ); + + expect( spy.calledOnce ).to.be.true; + expect( spy.calledWithMatch( 'undo' ) ).to.be.false; + expect( spy.calledWithMatch( 'delete', {} ) ).to.be.true; + } ); + + it( 'after model has changed', () => { + const modelDocument = editor.model.document; + const spy = editor.execute = sinon.spy(); + + plugin.requestUndoOnBackspace(); + + modelDocument.fire( 'change', new Batch() ); + viewDocument.fire( 'delete', new DomEventData( viewDocument, getDomEvent(), deleteEventEventData ) ); + + expect( spy.calledOnce ).to.be.true; + expect( spy.calledWithMatch( 'undo' ) ).to.be.false; + expect( spy.calledWithMatch( 'delete', {} ) ).to.be.true; + } ); + } ); +} ); + +function getDomEvent() { + return { + preventDefault: sinon.spy() + }; +} diff --git a/packages/ckeditor5-typing/tests/texttransformation.js b/packages/ckeditor5-typing/tests/texttransformation.js index 016d6e3bfa4..62f955d37cc 100644 --- a/packages/ckeditor5-typing/tests/texttransformation.js +++ b/packages/ckeditor5-typing/tests/texttransformation.js @@ -12,6 +12,8 @@ import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold'; import CodeBlock from '@ckeditor/ckeditor5-code-block/src/codeblock'; +import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting'; +import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata'; describe( 'Text transformation feature', () => { let editorElement, editor, model, doc; @@ -103,7 +105,13 @@ describe( 'Text transformation feature', () => { } ); describe( 'mathematical', () => { - testTransformation( '1/2', 'ยฝ' ); + testTransformation( '1/2 ', 'ยฝ ', '' ); + testTransformation( '1/2.', 'ยฝ.', 'A foo ' ); + testTransformation( '1/2+', 'ยฝ+', '+' ); + testShouldNotTransform( 'x1/2 ', 'ยฝ ' ); + testShouldNotTransform( '1/22', 'ยฝ2' ); + testShouldNotTransform( '11/2', '1ยฝ' ); + testShouldNotTransform( '1/2A', 'ยฝA' ); testTransformation( '<=', 'โ‰ค' ); } ); @@ -186,6 +194,35 @@ describe( 'Text transformation feature', () => { .to.equal( 'some 1/2 code' ); } ); + it( 'can undo transformation', () => { + setData( model, 'Foo[]' ); + + simulateTyping( '(c)' ); + + editor.commands.execute( 'undo' ); + + expect( getData( model, { withoutSelection: true } ) ) + .to.equal( 'Foo(c)' ); + } ); + + it( 'can undo transformation by pressing backspace', () => { + const viewDocument = editor.editing.view.document; + const deleteEvent = new DomEventData( + viewDocument, + { preventDefault: sinon.spy() }, + { direction: 'backward', unit: 'codePoint', sequence: 1 } + ); + + setData( model, 'Foo[]' ); + + simulateTyping( '(c)' ); + + viewDocument.fire( 'delete', deleteEvent ); + + expect( getData( model, { withoutSelection: true } ) ) + .to.equal( 'Foo(c)' ); + } ); + function testTransformation( transformFrom, transformTo, textInParagraph = 'A foo' ) { it( `should transform "${ transformFrom }" to "${ transformTo }"`, () => { setData( model, `${ textInParagraph }[]` ); @@ -212,6 +249,31 @@ describe( 'Text transformation feature', () => { expect( getData( model, { withoutSelection: true } ) ) .to.equal( `${ textInParagraph }${ transformFrom } bar ` ); } ); + + it( `should not transform "${ transformFrom }" to "${ transformTo } if not right before selection"`, () => { + setData( model, '[]' ); + + // Insert text - should not be transformed. + model.enqueueChange( model.createBatch(), writer => { + writer.insertText( `${ textInParagraph }${ transformFrom }`, doc.selection.focus ); + } ); + + simulateTyping( ' ' ); + + expect( getData( model, { withoutSelection: true } ) ) + .to.equal( `${ textInParagraph }${ transformFrom } ` ); + } ); + } + + function testShouldNotTransform( transformFrom, transformTo ) { + it( `should not transform "${ transformFrom }" to "${ transformTo }"`, () => { + setData( model, '[]' ); + + simulateTyping( transformFrom ); + + expect( getData( model, { withoutSelection: true } ) ) + .to.equal( `${ transformFrom }` ); + } ); } } ); @@ -386,7 +448,7 @@ describe( 'Text transformation feature', () => { function createEditorInstance( additionalConfig = {} ) { return ClassicTestEditor .create( editorElement, Object.assign( { - plugins: [ Typing, Paragraph, Bold, TextTransformation, CodeBlock ] + plugins: [ Typing, Paragraph, Bold, TextTransformation, CodeBlock, UndoEditing ] }, additionalConfig ) ) .then( newEditor => { editor = newEditor; diff --git a/packages/ckeditor5-ui/src/button/buttonview.js b/packages/ckeditor5-ui/src/button/buttonview.js index 66244bc1a95..473bfe4042a 100644 --- a/packages/ckeditor5-ui/src/button/buttonview.js +++ b/packages/ckeditor5-ui/src/button/buttonview.js @@ -184,7 +184,7 @@ export default class ButtonView extends View { this.children.add( this.tooltipView ); this.children.add( this.labelView ); - if ( this.withKeystroke ) { + if ( this.withKeystroke && this.keystroke ) { this.children.add( this.keystrokeView ); } } diff --git a/packages/ckeditor5-ui/src/panel/balloon/balloonpanelview.js b/packages/ckeditor5-ui/src/panel/balloon/balloonpanelview.js index b9cf9d3cd1a..405585902c5 100644 --- a/packages/ckeditor5-ui/src/panel/balloon/balloonpanelview.js +++ b/packages/ckeditor5-ui/src/panel/balloon/balloonpanelview.js @@ -232,7 +232,8 @@ export default class BalloonPanelView extends View { defaultPositions.northArrowSouthMiddleWest, defaultPositions.northArrowSouthMiddleEast, defaultPositions.northArrowSouthWest, - defaultPositions.northArrowSouthEast + defaultPositions.northArrowSouthEast, + defaultPositions.viewportStickyNorth ], limiter: defaultLimiterElement, fitInViewport: true @@ -244,9 +245,11 @@ export default class BalloonPanelView extends View { // so it is better to use int values. const left = parseInt( optimalPosition.left ); const top = parseInt( optimalPosition.top ); - const position = optimalPosition.name; - Object.assign( this, { top, left, position } ); + const { name: position, config = {} } = optimalPosition; + const { withArrow = true } = config; + + Object.assign( this, { top, left, position, withArrow } ); } /** @@ -401,7 +404,7 @@ function getDomElement( object ) { * \|/ * >|-----|<---------------- horizontal offset * - * @default 30 + * @default 25 * @member {Number} module:ui/panel/balloon/balloonpanelview~BalloonPanelView.arrowHorizontalOffset */ BalloonPanelView.arrowHorizontalOffset = 25; @@ -420,11 +423,35 @@ BalloonPanelView.arrowHorizontalOffset = 25; * ------------------------------- * ^ * - * @default 15 + * @default 10 * @member {Number} module:ui/panel/balloon/balloonpanelview~BalloonPanelView.arrowVerticalOffset */ BalloonPanelView.arrowVerticalOffset = 10; +/** + * A vertical offset of the balloon panel from the edge of the viewport if sticky. + * It helps in accessing toolbar buttons underneath the balloon panel. + * + * +---------------------------------------------------+ + * | Target | + * | | + * | /-- vertical offset | + * +-----------------------------V-------------------------+ + * | Toolbar +-------------+ | + * +--------------------| Balloon |--------------------+ + * | | +-------------+ | | + * | | | | + * | | | | + * | | | | + * | +---------------------------------------------------+ | + * | Viewport | + * +-------------------------------------------------------+ + * + * @default 20 + * @member {Number} module:ui/panel/balloon/balloonpanelview~BalloonPanelView.stickyVerticalOffset + */ +BalloonPanelView.stickyVerticalOffset = 20; + /** * Function used to calculate the optimal position for the balloon. * @@ -702,6 +729,22 @@ BalloonPanelView._getOptimalPosition = getOptimalPosition; * | Balloon | * +-----------------+ * + * * `viewportStickyNorth` + * + * +---------------------------+ + * | [ Target ] | + * | | + * +-----------------------------------+ + * | | +-----------------+ | | + * | | | Balloon | | | + * | | +-----------------+ | | + * | | | | + * | | | | + * | | | | + * | | | | + * | +---------------------------+ | + * | Viewport | + * +-----------------------------------+ * * See {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView#attachTo}. * @@ -710,7 +753,8 @@ BalloonPanelView._getOptimalPosition = getOptimalPosition; * The name that the position function returns will be reflected in the balloon panel's class that * controls the placement of the "arrow". See {@link #position} to learn more. * - * @member {Object} module:ui/panel/balloon/balloonpanelview~BalloonPanelView.defaultPositions + * @member {Object.} + * module:ui/panel/balloon/balloonpanelview~BalloonPanelView.defaultPositions */ BalloonPanelView.defaultPositions = { @@ -791,6 +835,7 @@ BalloonPanelView.defaultPositions = { left: targetRect.right - ( balloonRect.width * .25 ) - BalloonPanelView.arrowHorizontalOffset, name: 'arrow_smw' } ), + northEastArrowSouth: ( targetRect, balloonRect ) => ( { top: getNorthTop( targetRect, balloonRect ), left: targetRect.right - balloonRect.width / 2, @@ -808,6 +853,7 @@ BalloonPanelView.defaultPositions = { left: targetRect.right - balloonRect.width + BalloonPanelView.arrowHorizontalOffset, name: 'arrow_se' } ), + // ------- South west southWestArrowNorthWest: ( targetRect, balloonRect ) => ( { @@ -901,8 +947,24 @@ BalloonPanelView.defaultPositions = { top: getSouthTop( targetRect, balloonRect ), left: targetRect.right - balloonRect.width + BalloonPanelView.arrowHorizontalOffset, name: 'arrow_ne' - } ) + } ), + + // ------- Sticky + + viewportStickyNorth: ( targetRect, balloonRect, viewportRect ) => { + if ( !targetRect.getIntersection( viewportRect ) ) { + return null; + } + return { + top: viewportRect.top + BalloonPanelView.stickyVerticalOffset, + left: targetRect.left + targetRect.width / 2 - balloonRect.width / 2, + name: 'arrowless', + config: { + withArrow: false + } + }; + } }; // Returns the top coordinate for positions starting with `north*`. diff --git a/packages/ckeditor5-ui/src/panel/balloon/contextualballoon.js b/packages/ckeditor5-ui/src/panel/balloon/contextualballoon.js index b3b509d6d9b..05de3305e51 100644 --- a/packages/ckeditor5-ui/src/panel/balloon/contextualballoon.js +++ b/packages/ckeditor5-ui/src/panel/balloon/contextualballoon.js @@ -498,11 +498,18 @@ export default class ContextualBalloon extends Plugin { _getBalloonPosition() { let position = Array.from( this._visibleStack.values() ).pop().position; - // Use the default limiter if none has been specified. - if ( position && !position.limiter ) { + if ( position ) { + // Use the default limiter if none has been specified. + if ( !position.limiter ) { + // Don't modify the original options object. + position = Object.assign( {}, position, { + limiter: this.positionLimiter + } ); + } + // Don't modify the original options object. position = Object.assign( {}, position, { - limiter: this.positionLimiter + viewportOffsetConfig: this.editor.config.get( 'ui.viewportOffset' ) } ); } diff --git a/packages/ckeditor5-ui/tests/button/buttonview.js b/packages/ckeditor5-ui/tests/button/buttonview.js index db1922da7bb..2cc6b2287c3 100644 --- a/packages/ckeditor5-ui/tests/button/buttonview.js +++ b/packages/ckeditor5-ui/tests/button/buttonview.js @@ -343,7 +343,7 @@ describe( 'ButtonView', () => { } ); describe( '#keystrokeView', () => { - it( 'is omited in #children when view#icon is not defined', () => { + it( 'is omitted in #children when view#withKeystroke is not set', () => { view = new ButtonView( locale ); view.render(); @@ -369,7 +369,17 @@ describe( 'ButtonView', () => { expect( view.keystrokeView.element.textContent ).to.equal( 'Ctrl+A' ); } ); - it( 'usese fancy kesytroke preview on Mac', () => { + it( 'is omitted in #children when view#keystroke is not defined', () => { + // (#9412) + view = new ButtonView( locale ); + view.withKeystroke = true; + view.render(); + + expect( view.element.childNodes ).to.have.length( 2 ); + expect( view.keystrokeView.element ).to.be.null; + } ); + + it( 'usese fancy keystroke preview on Mac', () => { testUtils.sinon.stub( env, 'isMac' ).value( true ); view = new ButtonView( locale ); diff --git a/packages/ckeditor5-ui/tests/manual/panel/balloon/balloonpanelview.js b/packages/ckeditor5-ui/tests/manual/panel/balloon/balloonpanelview.js index c9a90320bbe..85b30fbce53 100644 --- a/packages/ckeditor5-ui/tests/manual/panel/balloon/balloonpanelview.js +++ b/packages/ckeditor5-ui/tests/manual/panel/balloon/balloonpanelview.js @@ -10,6 +10,10 @@ import BalloonPanelView from '../../../../src/panel/balloon/balloonpanelview'; const defaultPositions = BalloonPanelView.defaultPositions; const container = document.querySelector( '#container' ); +// It makes no sense to test the sticky position in this context, +// thus exclude it from this manual test. +delete defaultPositions.viewportStickyNorth; + let currentHeading = ''; for ( const i in defaultPositions ) { diff --git a/packages/ckeditor5-ui/tests/manual/tickets/9412/1.html b/packages/ckeditor5-ui/tests/manual/tickets/9412/1.html new file mode 100644 index 00000000000..c845ad8299f --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/tickets/9412/1.html @@ -0,0 +1,4 @@ +
+
+
+
diff --git a/packages/ckeditor5-ui/tests/manual/tickets/9412/1.js b/packages/ckeditor5-ui/tests/manual/tickets/9412/1.js new file mode 100644 index 00000000000..aa8ab51a133 --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/tickets/9412/1.js @@ -0,0 +1,44 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals window, document, console:false */ + +import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'; +import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import Heading from '@ckeditor/ckeditor5-heading/src/heading'; +import boldIcon from '@ckeditor/ckeditor5-basic-styles/theme/icons/bold.svg'; +import ButtonView from '../../../../src/button/buttonview'; + +function customButtonView( editor ) { + editor.ui.componentFactory.add( 'customButtonView', locale => { + const view = new ButtonView( locale ); + view.set( { + label: 'Custom Button', + icon: boldIcon, + tooltip: true, + withKeystroke: true + } ); + + return view; + } ); +} + +ClassicEditor + .create( document.querySelector( '#editor' ), { + plugins: [ + Essentials, + Paragraph, + Heading, + customButtonView + ], + toolbar: [ 'customButtonView' ] + } ) + .then( editor => { + window.editor = editor; + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/packages/ckeditor5-ui/tests/manual/tickets/9412/1.md b/packages/ckeditor5-ui/tests/manual/tickets/9412/1.md new file mode 100644 index 00000000000..95d2c8fd8ff --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/tickets/9412/1.md @@ -0,0 +1,3 @@ +## Editor crash with keystroke undefined [#9412](https://github.com/ckeditor/ckeditor5/issues/9412) + +**Expected**: The editor should create ButtonView without keystrokeView and there shouldn't be any error in console. \ No newline at end of file diff --git a/packages/ckeditor5-ui/tests/manual/tickets/9892/1.html b/packages/ckeditor5-ui/tests/manual/tickets/9892/1.html new file mode 100644 index 00000000000..078019f8193 --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/tickets/9892/1.html @@ -0,0 +1,127 @@ + + + +

Other webpage content

+

Lorem ipsum dolor sit amet consectetur, adipisicing elit. Officia itaque eum necessitatibus possimus adipisci mollitia dolor quia molestias voluptate dignissimos? Optio architecto dicta dolorum laborum nam nulla minus aspernatur iste.

+
+

Short table

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


























+

Very long table

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


























+

Short image

+
+ Autumn fields +
+


























+

Very tall image

+
+ Very tall image +
+


























+


























+


























+
+ + diff --git a/packages/ckeditor5-ui/tests/manual/tickets/9892/1.js b/packages/ckeditor5-ui/tests/manual/tickets/9892/1.js new file mode 100644 index 00000000000..fb20b6be929 --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/tickets/9892/1.js @@ -0,0 +1,37 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals window, document, console:false */ + +import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor'; +import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset'; +import TableProperties from '@ckeditor/ckeditor5-table/src/tableproperties'; +import TableCellProperties from '@ckeditor/ckeditor5-table/src/tablecellproperties'; + +ClassicEditor + .create( document.querySelector( '#editor' ), { + image: { toolbar: [ 'toggleImageCaption', 'imageTextAlternative' ] }, + plugins: [ ArticlePluginSet, TableProperties, TableCellProperties ], + toolbar: { + items: [ 'heading', '|', 'insertTable', '|', 'bold', 'italic', 'bulletedList', 'numberedList', 'blockQuote', 'undo', 'redo' ], + viewportTopOffset: 100 + }, + table: { + contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells', '|', 'tableProperties', 'tableCellProperties' ], + tableToolbar: [ 'bold', 'italic' ] + }, + ui: { + viewportOffset: { + top: 100, + bottom: 100 + } + } + } ) + .then( editor => { + window.editor = editor; + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/packages/ckeditor5-ui/tests/manual/tickets/9892/1.md b/packages/ckeditor5-ui/tests/manual/tickets/9892/1.md new file mode 100644 index 00000000000..060a650c344 --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/tickets/9892/1.md @@ -0,0 +1,16 @@ +## Table toolbar does not respect viewportTopOffset configuration [#9892](https://github.com/ckeditor/ckeditor5/issues/9892) + +### Short table / image + +- select a short table or image +- toolbar balloon shouldn't go under the sticky header if balloon is pinned to the top +- toolbar balloon shouldn't go under the sticky footer if balloon is pinned to the bottom +- it's ok for balloon to go under the sticky header if balloon is pinned to the bottom after scrolling down + +### Long table / image + +- select a long table or image +- toolbar balloon shouldn't go under the sticky header if balloon is pinned to the top +- instead it should stick to the bottom of the sticky header, shifted by 20px down, until table / image is scrolled down and there is enough space for the balloon to be pinned to the bottom +- toolbar balloon shouldn't go under the sticky footer if balloon is pinned to the bottom +- it's ok for balloon to go under the sticky header if balloon is pinned to the bottom after scrolling down diff --git a/packages/ckeditor5-ui/tests/manual/tickets/9892/sample-very-tall.jpg b/packages/ckeditor5-ui/tests/manual/tickets/9892/sample-very-tall.jpg new file mode 100644 index 00000000000..e9d82c3dbc3 Binary files /dev/null and b/packages/ckeditor5-ui/tests/manual/tickets/9892/sample-very-tall.jpg differ diff --git a/packages/ckeditor5-ui/tests/manual/tickets/9892/sample.jpg b/packages/ckeditor5-ui/tests/manual/tickets/9892/sample.jpg new file mode 100644 index 00000000000..b77d07e7bff Binary files /dev/null and b/packages/ckeditor5-ui/tests/manual/tickets/9892/sample.jpg differ diff --git a/packages/ckeditor5-ui/tests/panel/balloon/balloonpanelview.js b/packages/ckeditor5-ui/tests/panel/balloon/balloonpanelview.js index 8ad02bdf38f..b48e5083b3d 100644 --- a/packages/ckeditor5-ui/tests/panel/balloon/balloonpanelview.js +++ b/packages/ckeditor5-ui/tests/panel/balloon/balloonpanelview.js @@ -9,6 +9,7 @@ import ViewCollection from '../../../src/viewcollection'; import BalloonPanelView from '../../../src/panel/balloon/balloonpanelview'; import ButtonView from '../../../src/button/buttonview'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import { Rect } from '@ckeditor/ckeditor5-utils'; describe( 'BalloonPanelView', () => { let view; @@ -189,7 +190,8 @@ describe( 'BalloonPanelView', () => { BalloonPanelView.defaultPositions.northArrowSouthMiddleWest, BalloonPanelView.defaultPositions.northArrowSouthMiddleEast, BalloonPanelView.defaultPositions.northArrowSouthWest, - BalloonPanelView.defaultPositions.northArrowSouthEast + BalloonPanelView.defaultPositions.northArrowSouthEast, + BalloonPanelView.defaultPositions.viewportStickyNorth ], limiter: document.body, fitInViewport: true @@ -209,6 +211,38 @@ describe( 'BalloonPanelView', () => { expect( view.left ).to.equal( 10 ); } ); + it( 'should set and override withArrow property', () => { + testUtils.sinon.stub( BalloonPanelView, '_getOptimalPosition' ).returns( { + top: 10.345, + left: 10.345, + name: 'position' + } ); + + view.withArrow = false; + view.attachTo( { target, limiter } ); + + expect( view.withArrow ).to.be.true; + + view.set( 'withArrow', false ); + view.attachTo( { target, limiter } ); + + expect( view.withArrow ).to.be.true; + + BalloonPanelView._getOptimalPosition.restore(); + + testUtils.sinon.stub( BalloonPanelView, '_getOptimalPosition' ).returns( { + top: 10.345, + left: 10.345, + name: 'position', + config: { + withArrow: false + } + } ); + + view.attachTo( { target, limiter } ); + expect( view.withArrow ).to.be.false; + } ); + describe( 'limited by limiter element', () => { beforeEach( () => { // Mock limiter element dimensions. @@ -688,34 +722,43 @@ describe( 'BalloonPanelView', () => { } ); describe( 'defaultPositions', () => { - let positions, balloonRect, targetRect, arrowHOffset, arrowVOffset; + let positions, balloonRect, targetRect, viewportRect, arrowHOffset, arrowVOffset; beforeEach( () => { positions = BalloonPanelView.defaultPositions; arrowHOffset = BalloonPanelView.arrowHorizontalOffset; arrowVOffset = BalloonPanelView.arrowVerticalOffset; - targetRect = { + viewportRect = new Rect( { + top: 0, + bottom: 0, + left: 0, + right: 0, + width: 200, + height: 200 + } ); + + targetRect = new Rect( { top: 100, bottom: 200, left: 100, right: 200, width: 100, height: 100 - }; + } ); - balloonRect = { + balloonRect = new Rect( { top: 0, bottom: 0, left: 0, right: 0, width: 50, height: 50 - }; + } ); } ); it( 'should have a proper length', () => { - expect( Object.keys( positions ) ).to.have.length( 30 ); + expect( Object.keys( positions ) ).to.have.length( 31 ); } ); // ------- North @@ -969,6 +1012,82 @@ describe( 'BalloonPanelView', () => { name: 'arrow_nmw' } ); } ); + + // ------- Sticky + + it( 'should define the "viewportStickyNorth" position and return null if not sticky', () => { + expect( positions.viewportStickyNorth( targetRect, balloonRect, viewportRect ) ).to.equal( null ); + } ); + } ); + + describe( 'stickyPositions', () => { + let positions, balloonRect, targetRect, viewportRect, stickyOffset; + + beforeEach( () => { + positions = BalloonPanelView.defaultPositions; + stickyOffset = BalloonPanelView.stickyVerticalOffset; + + balloonRect = new Rect( { + top: 0, + bottom: 0, + left: 0, + right: 0, + width: 50, + height: 50 + } ); + } ); + + it( 'should stick position to the top when top position of the element is above the viewport and the element' + + 'area intersects with the viewport area', () => { + viewportRect = new Rect( { + top: 300, + bottom: 800, + left: 0, + right: 200, + width: 0, + height: 0 + } ); + + targetRect = new Rect( { + top: 200, + bottom: 400, + left: 50, + right: 100, + width: 0, + height: 0 + } ); + + expect( positions.viewportStickyNorth( targetRect, balloonRect, viewportRect ) ).to.deep.equal( { + top: 300 + stickyOffset, + left: 25, + name: 'arrowless', + config: { + withArrow: false + } + } ); + } ); + + it( 'should return null if not sticky because element is fully outside of the viewport', () => { + viewportRect = new Rect( { + top: 200, + bottom: 0, + left: 0, + right: 0, + width: 200, + height: 200 + } ); + + targetRect = new Rect( { + top: 0, + bottom: 0, + left: 0, + right: 0, + width: 100, + height: 100 + } ); + + expect( positions.viewportStickyNorth( targetRect, balloonRect, viewportRect ) ).to.equal( null ); + } ); } ); } ); diff --git a/packages/ckeditor5-utils/src/dom/position.js b/packages/ckeditor5-utils/src/dom/position.js index 969000cc40a..8cc5497597f 100644 --- a/packages/ckeditor5-utils/src/dom/position.js +++ b/packages/ckeditor5-utils/src/dom/position.js @@ -13,6 +13,8 @@ import getPositionedAncestor from './getpositionedancestor'; import getBorderWidths from './getborderwidths'; import { isFunction } from 'lodash-es'; +// @if CK_DEBUG_POSITION // import { RectDrawer } from '@ckeditor/ckeditor5-minimap/src/utils'; + /** * Calculates the `position: absolute` coordinates of a given element so it can be positioned with respect to the * target in the visually most efficient way, taking various restrictions like viewport or limiter geometry @@ -74,10 +76,10 @@ import { isFunction } from 'lodash-es'; * element.style.top = top; * element.style.left = left; * - * @param {module:utils/dom/position~Options} options Positioning options object. + * @param {module:utils/dom/position~Options} options The input data and configuration of the helper. * @returns {module:utils/dom/position~Position} */ -export function getOptimalPosition( { element, target, positions, limiter, fitInViewport } ) { +export function getOptimalPosition( { element, target, positions, limiter, fitInViewport, viewportOffsetConfig } ) { // If the {@link module:utils/dom/position~Options#target} is a function, use what it returns. // https://github.com/ckeditor/ckeditor5-utils/issues/157 if ( isFunction( target ) ) { @@ -94,52 +96,54 @@ export function getOptimalPosition( { element, target, positions, limiter, fitIn const elementRect = new Rect( element ); const targetRect = new Rect( target ); - let bestPositionRect; - let bestPositionName; + let bestPosition; + + // @if CK_DEBUG_POSITION // RectDrawer.clear(); + // @if CK_DEBUG_POSITION // RectDrawer.draw( targetRect, { outlineWidth: '5px' }, 'Target' ); + + const positionOptions = { targetRect, elementRect, positionedElementAncestor }; // If there are no limits, just grab the very first position and be done with that drama. if ( !limiter && !fitInViewport ) { - [ bestPositionName, bestPositionRect ] = getPositionNameAndRect( positions[ 0 ], targetRect, elementRect ); + bestPosition = new Position( positions[ 0 ], positionOptions ); } else { const limiterRect = limiter && new Rect( limiter ).getVisible(); - const viewportRect = fitInViewport && new Rect( global.window ); - const bestPosition = getBestPositionNameAndRect( positions, { targetRect, elementRect, limiterRect, viewportRect } ); + const viewportRect = fitInViewport && getConstrainedViewportRect( viewportOffsetConfig ); - // If there's no best position found, i.e. when all intersections have no area because - // rects have no width or height, then just use the first available position. - [ bestPositionName, bestPositionRect ] = bestPosition || getPositionNameAndRect( positions[ 0 ], targetRect, elementRect ); - } + // @if CK_DEBUG_POSITION // if ( viewportRect ) { + // @if CK_DEBUG_POSITION // RectDrawer.draw( viewportRect, { outlineWidth: '5px' }, 'Viewport' ); + // @if CK_DEBUG_POSITION // } - let absoluteRectCoordinates = getAbsoluteRectCoordinates( bestPositionRect ); + // @if CK_DEBUG_POSITION // if ( limiter ) { + // @if CK_DEBUG_POSITION // RectDrawer.draw( limiterRect, { outlineWidth: '5px', outlineColor: 'green' }, 'Visible limiter' ); + // @if CK_DEBUG_POSITION // } - if ( positionedElementAncestor ) { - absoluteRectCoordinates = shiftRectCoordinatesDueToPositionedAncestor( absoluteRectCoordinates, positionedElementAncestor ); + Object.assign( positionOptions, { limiterRect, viewportRect } ); + + // If there's no best position found, i.e. when all intersections have no area because + // rects have no width or height, then just use the first available position. + bestPosition = getBestPosition( positions, positionOptions ) || new Position( positions[ 0 ], positionOptions ); } - return { - left: absoluteRectCoordinates.left, - top: absoluteRectCoordinates.top, - name: bestPositionName - }; + return bestPosition; } -// For given position function, returns a corresponding `Rect` instance. +// Returns a viewport `Rect` shrunk by the viewport offset config from all sides. // // @private -// @param {Function} position A function returning {@link module:utils/dom/position~Position}. -// @param {utils/dom/rect~Rect} targetRect A rect of the target. -// @param {utils/dom/rect~Rect} elementRect A rect of positioned element. -// @returns {Array|null} An array containing position name and its Rect (or null if position should be ignored). -function getPositionNameAndRect( position, targetRect, elementRect ) { - const positionData = position( targetRect, elementRect ); - - if ( !positionData ) { - return null; - } +// @param {Object} An object containing viewportOffset config. +// @returns {utils/dom/rect~Rect} A shrunken rect of the viewport. +function getConstrainedViewportRect( viewportOffsetConfig ) { + viewportOffsetConfig = Object.assign( { top: 0, bottom: 0, left: 0, right: 0 }, viewportOffsetConfig ); - const { left, top, name } = positionData; + const viewportRect = new Rect( global.window ); - return [ name, elementRect.clone().moveTo( left, top ) ]; + viewportRect.top += viewportOffsetConfig.top; + viewportRect.height -= viewportOffsetConfig.top; + viewportRect.bottom -= viewportOffsetConfig.bottom; + viewportRect.height -= viewportOffsetConfig.bottom; + + return viewportRect; } // For a given array of positioning functions, returns such that provides the best @@ -148,157 +152,50 @@ function getPositionNameAndRect( position, targetRect, elementRect ) { // @private // // @param {Object} options -// @param {module:utils/dom/position~Options#positions} positions Functions returning {@link module:utils/dom/position~Position} -// to be checked, in the order of preference. +// @param {module:utils/dom/position~Options#positions} positions Functions returning +// {@link module:utils/dom/position~Position}to be checked, in the order of preference. // @param {Object} options // @param {utils/dom/rect~Rect} options.targetRect A rect of the {@link module:utils/dom/position~Options#target}. -// @param {utils/dom/rect~Rect} options.elementRect A rect of positioned {@link module:utils/dom/position~Options#element}. +// @param {utils/dom/rect~Rect} options.elementRect A rect of positioned +// {@link module:utils/dom/position~Options#element}. // @param {utils/dom/rect~Rect} options.limiterRect A rect of the {@link module:utils/dom/position~Options#limiter}. -// @param {utils/dom/rect~Rect} options.viewportRect A rect of the viewport. +// @param {utils/dom/rect~Rect} options.viewportRect A rect of the {@link module:utils/dom/position~Options#viewport}. // // @returns {Array} An array containing the name of the position and it's rect. -function getBestPositionNameAndRect( positions, options ) { - const { elementRect, viewportRect } = options; - - // This is when element is fully visible. - const elementRectArea = elementRect.getArea(); - - // Let's calculate intersection areas for positions. It will end early if best match is found. - const processedPositions = processPositionsToAreas( positions, options ); - - // First let's check all positions that fully fit in the viewport. - if ( viewportRect ) { - const processedPositionsInViewport = processedPositions.filter( ( { viewportIntersectArea } ) => { - return viewportIntersectArea === elementRectArea; - } ); - - // Try to find best position from those which fit completely in viewport. - const bestPositionData = getBestOfProcessedPositions( processedPositionsInViewport, elementRectArea ); - - if ( bestPositionData ) { - return bestPositionData; - } - } - - // Either there is no viewportRect or there is no position that fits completely in the viewport. - return getBestOfProcessedPositions( processedPositions, elementRectArea ); -} - -// For a given array of positioning functions, calculates intersection areas for them. -// -// Note: If some position fully fits into the `limiterRect`, it will be returned early, without further consideration -// of other positions. -// -// @private -// -// @param {module:utils/dom/position~Options#positions} positions Functions returning {@link module:utils/dom/position~Position} -// to be checked, in the order of preference. -// @param {Object} options -// @param {utils/dom/rect~Rect} options.targetRect A rect of the {@link module:utils/dom/position~Options#target}. -// @param {utils/dom/rect~Rect} options.elementRect A rect of positioned {@link module:utils/dom/position~Options#element}. -// @param {utils/dom/rect~Rect} options.limiterRect A rect of the {@link module:utils/dom/position~Options#limiter}. -// @param {utils/dom/rect~Rect} options.viewportRect A rect of the viewport. -// -// @returns {Array.} Array of positions with calculated intersection areas. Each item is an object containing: -// * {String} positionName Name of position. -// * {utils/dom/rect~Rect} positionRect Rect of position. -// * {Number} limiterIntersectArea Area of intersection of the position with limiter part that is in the viewport. -// * {Number} viewportIntersectArea Area of intersection of the position with viewport. -function processPositionsToAreas( positions, { targetRect, elementRect, limiterRect, viewportRect } ) { - const processedPositions = []; +function getBestPosition( positions, options ) { + const { elementRect } = options; // This is when element is fully visible. const elementRectArea = elementRect.getArea(); - for ( const position of positions ) { - const positionData = getPositionNameAndRect( position, targetRect, elementRect ); - - if ( !positionData ) { - continue; - } - - const [ positionName, positionRect ] = positionData; - let limiterIntersectArea = 0; - let viewportIntersectArea = 0; - - if ( limiterRect ) { - if ( viewportRect ) { - // Consider only the part of the limiter which is visible in the viewport. So the limiter is getting limited. - const limiterViewportIntersectRect = limiterRect.getIntersection( viewportRect ); - - if ( limiterViewportIntersectRect ) { - // If the limiter is within the viewport, then check the intersection between that part of the - // limiter and actual position. - limiterIntersectArea = limiterViewportIntersectRect.getIntersectionArea( positionRect ); - } - } else { - limiterIntersectArea = limiterRect.getIntersectionArea( positionRect ); - } - } + const positionInstances = positions + .map( positioningFunction => new Position( positioningFunction, options ) ) + // Some positioning functions may return `null` if they don't want to participate. + .filter( position => !!position.name ); - if ( viewportRect ) { - viewportIntersectArea = viewportRect.getIntersectionArea( positionRect ); - } + let maxFitFactor = 0; + let bestPosition = null; - const processedPosition = { - positionName, - positionRect, - limiterIntersectArea, - viewportIntersectArea - }; + for ( const position of positionInstances ) { + const { _limiterIntersectionArea, _viewportIntersectionArea } = position; // If a such position is found that element is fully contained by the limiter then, obviously, // there will be no better one, so finishing. - if ( limiterIntersectArea === elementRectArea ) { - return [ processedPosition ]; - } - - processedPositions.push( processedPosition ); - } - - return processedPositions; -} - -// For a given array of processed position data (with calculated Rects for positions and intersection areas) -// returns such that provides the best fit of the `elementRect` into the `limiterRect` and `viewportRect` at the same time. -// -// **Note**: It will return early if some position fully fits into the `limiterRect`. -// -// @private -// @param {Array.} Array of positions with calculated intersection areas (in order of preference). -// Each item is an object containing: -// -// * {String} positionName Name of position. -// * {utils/dom/rect~Rect} positionRect Rect of position. -// * {Number} limiterIntersectArea Area of intersection of the position with limiter part that is in the viewport. -// * {Number} viewportIntersectArea Area of intersection of the position with viewport. -// -// @param {Number} elementRectArea Area of positioned {@link module:utils/dom/position~Options#element}. -// @returns {Array|null} An array containing the name of the position and it's rect, or null if not found. -function getBestOfProcessedPositions( processedPositions, elementRectArea ) { - let maxFitFactor = 0; - let bestPositionRect; - let bestPositionName; - - for ( const { positionName, positionRect, limiterIntersectArea, viewportIntersectArea } of processedPositions ) { - // If a such position is found that element is fully container by the limiter then, obviously, - // there will be no better one, so finishing. - if ( limiterIntersectArea === elementRectArea ) { - return [ positionName, positionRect ]; + if ( _limiterIntersectionArea === elementRectArea ) { + return position; } - // To maximize both viewport and limiter intersection areas we use distance on viewportIntersectArea - // and limiterIntersectArea plane (without sqrt because we are looking for max value). - const fitFactor = viewportIntersectArea ** 2 + limiterIntersectArea ** 2; + // To maximize both viewport and limiter intersection areas we use distance on _viewportIntersectionArea + // and _limiterIntersectionArea plane (without sqrt because we are looking for max value). + const fitFactor = _viewportIntersectionArea ** 2 + _limiterIntersectionArea ** 2; if ( fitFactor > maxFitFactor ) { maxFitFactor = fitFactor; - bestPositionRect = positionRect; - bestPositionName = positionName; + bestPosition = position; } } - return bestPositionRect ? [ bestPositionName, bestPositionRect ] : null; + return bestPosition; } // For a given absolute Rect coordinates object and a positioned element ancestor, it returns an object with @@ -310,41 +207,44 @@ function getBestOfProcessedPositions( processedPositions, elementRectArea ) { // // @private // -// @param {Object} absoluteRectCoordinates An object with absolute rect coordinates. -// @param {Object} absoluteRectCoordinates.top -// @param {Object} absoluteRectCoordinates.left +// @param {utils/dom/rect~Rect} rect A rect with absolute rect coordinates. +// @param {Number} rect.top +// @param {Number} rect.left // @param {HTMLElement} positionedElementAncestor An ancestor element that should be considered. // -// @returns {Object} An object corresponding to `absoluteRectCoordinates` input but with values shifted +// @returns {utils/dom/rect~Rect} A rect corresponding to `absoluteRect` input but with values shifted // to make up for the positioned element ancestor. -function shiftRectCoordinatesDueToPositionedAncestor( { left, top }, positionedElementAncestor ) { - const ancestorPosition = getAbsoluteRectCoordinates( new Rect( positionedElementAncestor ) ); +function shiftRectToCompensatePositionedAncestor( rect, positionedElementAncestor ) { + const ancestorPosition = getRectForAbsolutePositioning( new Rect( positionedElementAncestor ) ); const ancestorBorderWidths = getBorderWidths( positionedElementAncestor ); + let moveX = 0; + let moveY = 0; + // (https://github.com/ckeditor/ckeditor5-ui-default/issues/126) // If there's some positioned ancestor of the panel, then its `Rect` must be taken into // consideration. `Rect` is always relative to the viewport while `position: absolute` works // with respect to that positioned ancestor. - left -= ancestorPosition.left; - top -= ancestorPosition.top; + moveX -= ancestorPosition.left; + moveY -= ancestorPosition.top; // (https://github.com/ckeditor/ckeditor5-utils/issues/139) // If there's some positioned ancestor of the panel, not only its position must be taken into // consideration (see above) but also its internal scrolls. Scroll have an impact here because `Rect` // is relative to the viewport (it doesn't care about scrolling), while `position: absolute` // must compensate that scrolling. - left += positionedElementAncestor.scrollLeft; - top += positionedElementAncestor.scrollTop; + moveX += positionedElementAncestor.scrollLeft; + moveY += positionedElementAncestor.scrollTop; // (https://github.com/ckeditor/ckeditor5-utils/issues/139) // If there's some positioned ancestor of the panel, then its `Rect` includes its CSS `borderWidth` // while `position: absolute` positioning does not consider it. // E.g. `{ position: absolute, top: 0, left: 0 }` means upper left corner of the element, // not upper-left corner of its border. - left -= ancestorBorderWidths.left; - top -= ancestorBorderWidths.top; + moveX -= ancestorBorderWidths.left; + moveY -= ancestorBorderWidths.top; - return { left, top }; + rect.moveBy( moveX, moveY ); } // DOMRect (also Rect) works in a scrollโ€“independent geometry but `position: absolute` doesn't. @@ -353,13 +253,172 @@ function shiftRectCoordinatesDueToPositionedAncestor( { left, top }, positionedE // @private // @param {utils/dom/rect~Rect} rect A rect to be converted. // @returns {Object} Object containing `left` and `top` properties, in absolute coordinates. -function getAbsoluteRectCoordinates( { left, top } ) { +function getRectForAbsolutePositioning( rect ) { const { scrollX, scrollY } = global.window; - return { - left: left + scrollX, - top: top + scrollY - }; + return rect.clone().moveBy( scrollX, scrollY ); +} + +/** + * A position class which instances are created and used by the {@link module:utils/dom/position~getOptimalPosition} helper. + * + * {@link module:utils/dom/position~Position#top} and {@link module:utils/dom/position~Position#left} properties of the position instance + * translate directly to the `top` and `left` properties in CSS "`position: absolute` coordinate system". If set on the positioned element + * in DOM, they will make it display it in the right place in the viewport. + */ +export class Position { + /** + * Creates an instance of the {@link module:utils/dom/position~Position} class. + * + * @param {module:utils/dom/position~positioningFunction} [positioningFunction] function The function that defines the expected + * coordinates the positioned element should move to. + * @param {Object} [options] options object. + * @param {module:utils/dom/rect~Rect} options.elementRect The positioned element rect. + * @param {module:utils/dom/rect~Rect} options.targetRect The target element rect. + * @param {module:utils/dom/rect~Rect} options.viewportRect The viewport rect. + * @param {HTMLElement|null} [options.positionedElementAncestor] Nearest element ancestor element which CSS position is not "static". + */ + constructor( positioningFunction, options ) { + const positioningFunctionOutput = positioningFunction( options.targetRect, options.elementRect, options.viewportRect ); + + // Nameless position for a function that didn't participate. + if ( !positioningFunctionOutput ) { + return; + } + + const { left, top, name, config } = positioningFunctionOutput; + + Object.assign( this, { name, config } ); + + this._positioningFunctionCorrdinates = { left, top }; + this._options = options; + + /** + * Position name. + * + * @readonly + * @member {String} #name + */ + + /** + * Additional position configuration, as passed from the {@link module:utils/dom/position~positioningFunction positioning function}. + * + * This object can be use, for instance, to pass through presentation options used by the consumer of the + * {@link module:utils/dom/position~getOptimalPosition} helper. + * + * @readonly + * @member {Object} #config + */ + } + + /** + * The left value in pixels in the CSS `position: absolute` coordinate system. + * Set it on the positioned element in DOM to move it to the position. + * + * @readonly + * @type {Number} + */ + get left() { + return this._absoluteRect.left; + } + + /** + * The top value in pixels in the CSS `position: absolute` coordinate system. + * Set it on the positioned element in DOM to move it to the position. + * + * @readonly + * @type {Number} + */ + get top() { + return this._absoluteRect.top; + } + + /** + * An intersection area between positioned element and limiter within viewport constraints. + * + * @readonly + * @private + * @type {Number} + */ + get _limiterIntersectionArea() { + const limiterRect = this._options.limiterRect; + + if ( limiterRect ) { + const viewportRect = this._options.viewportRect; + + if ( viewportRect ) { + // Consider only the part of the limiter which is visible in the viewport. So the limiter is getting limited. + const limiterViewportIntersectRect = limiterRect.getIntersection( viewportRect ); + + if ( limiterViewportIntersectRect ) { + // If the limiter is within the viewport, then check the intersection between that part of the + // limiter and actual position. + return limiterViewportIntersectRect.getIntersectionArea( this._rect ); + } + } else { + return limiterRect.getIntersectionArea( this._rect ); + } + } + + return 0; + } + + /** + * An intersection area between positioned element and viewport. + * + * @readonly + * @private + * @type {Number} + */ + get _viewportIntersectionArea() { + const viewportRect = this._options.viewportRect; + + if ( viewportRect ) { + return viewportRect.getIntersectionArea( this._rect ); + } + + return 0; + } + + /** + * An already positioned element rect. A clone of the element rect passed to the constructor + * but placed in the viewport according to the positioning function. + * + * @private + * @type {module:utils/dom/rect~Rect} + */ + get _rect() { + if ( this._cachedRect ) { + return this._cachedRect; + } + + this._cachedRect = this._options.elementRect.clone().moveTo( + this._positioningFunctionCorrdinates.left, + this._positioningFunctionCorrdinates.top + ); + + return this._cachedRect; + } + + /** + * An already absolutely positioned element rect. See ({@link #_rect}). + * + * @private + * @type {module:utils/dom/rect~Rect} + */ + get _absoluteRect() { + if ( this._cachedAbsoluteRect ) { + return this._cachedAbsoluteRect; + } + + this._cachedAbsoluteRect = getRectForAbsolutePositioning( this._rect ); + + if ( this._options.positionedElementAncestor ) { + shiftRectToCompensatePositionedAncestor( this._cachedAbsoluteRect, this._options.positionedElementAncestor ); + } + + return this._cachedAbsoluteRect; + } } /** @@ -381,12 +440,15 @@ function getAbsoluteRectCoordinates( { left, top } ) { */ /** - * An array of functions which return {@link module:utils/dom/position~Position} relative - * to the `target`, in the order of preference. + * An array of positioning functions. + * + * **Note**: Positioning functions are processed in the order of preference. The first function that works + * in the current environment (e.g. offers the complete fit in the viewport geometry) will be picked by + * `getOptimalPosition()`. * - * **Note**: If a function returns `null`, it is ignored by the `getOptimalPosition()`. + * **Note**: Any positioning function returning `null` is ignored. * - * @member {Array.} #positions + * @member {Array.} #positions */ /** @@ -404,12 +466,53 @@ function getAbsoluteRectCoordinates( { left, top } ) { */ /** - * An object describing a position in `position: absolute` coordinate - * system, along with position name. + * Viewport offset config object. It restricts the visible viewport available to the `getOptimalPosition()` from each side. * - * @typedef {Object} module:utils/dom/position~Position + * { + * top: 50, + * right: 50, + * bottom: 50, + * left: 50 + * } + * + * @member {Object} #viewportOffsetConfig + */ + +/** + * A positioning function which, based on positioned element and target {@link module:utils/dom/rect~Rect Rects}, returns rect coordinates + * representing the geometrical relation between them. Used by the {@link module:utils/dom/position~getOptimalPosition} helper. + * + * // This simple position will place the element directly under the target, in the middle: + * // + * // [ Target ] + * // +-----------------+ + * // | Element | + * // +-----------------+ + * // + * const position = ( targetRect, elementRect, [ viewportRect ] ) => ( { + * top: targetRect.bottom, + * left: targetRect.left + targetRect.width / 2 - elementRect.width / 2, + * name: 'bottomMiddle', + * + * // Note: The config is optional. + * config: { + * zIndex: '999' + * } + * } ); * - * @property {Number} top Top position offset. - * @property {Number} left Left position offset. - * @property {String} name Name of the position. + * @callback module:utils/dom/position~positioningFunction + * @param {module:utils/dom/rect~Rect} elementRect The rect of the element to be positioned. + * @param {module:utils/dom/rect~Rect} targetRect The rect of the target the element (its rect) is relatively positioned to. + * @param {module:utils/dom/rect~Rect} viewportRect The rect of the visual browser viewport. + * @returns {Object|null} return When the function returns `null`, it will not be considered by + * {@link module:utils/dom/position~getOptimalPosition}. + * @returns {Number} return.top The `top` value of the element rect that would represent the position. + * @returns {Number} return.left The `left` value of the element rect that would represent the position. + * @returns {Number} return.name The name of the position. It helps the user of the {@link module:utils/dom/position~getOptimalPosition} + * helper to recognize different positioning function results. It will pass through to the {@link module:utils/dom/position~Position} + * returned by the helper. + * @returns {Number} [return.config] An optional configuration that will pass-through the + * {@link module:utils/dom/position~getOptimalPosition} helper to the {@link module:utils/dom/position~Position} returned by this helper. + * This configuration may, for instance, let the user of {@link module:utils/dom/position~getOptimalPosition} know that this particular + * position comes with a certain presentation. */ diff --git a/packages/ckeditor5-utils/src/keyboard.js b/packages/ckeditor5-utils/src/keyboard.js index a29d85cebe8..83aba00b06b 100644 --- a/packages/ckeditor5-utils/src/keyboard.js +++ b/packages/ckeditor5-utils/src/keyboard.js @@ -33,6 +33,7 @@ const modifiersToGlyphsNonMac = { * * `a-z`, * * `0-9`, * * `f1-f12`, + * * `` ` ``, `-`, `=`, `[`, `]`, `;`, `'`, `,`, `.`, `/`, `\`, * * `arrow(left|up|right|bottom)`, * * `backspace`, `delete`, `enter`, `esc`, `tab`, * * `ctrl`, `cmd`, `shift`, `alt`. @@ -251,6 +252,11 @@ function generateKnownKeyCodes() { keyCodes[ 'f' + ( code - 111 ) ] = code; } + // other characters + for ( const char of '`-=[];\',./\\' ) { + keyCodes[ char ] = char.charCodeAt( 0 ); + } + return keyCodes; } diff --git a/packages/ckeditor5-utils/tests/dom/position.js b/packages/ckeditor5-utils/tests/dom/position.js index 36e3cfdd553..a5066557435 100644 --- a/packages/ckeditor5-utils/tests/dom/position.js +++ b/packages/ckeditor5-utils/tests/dom/position.js @@ -5,7 +5,7 @@ /* global document, window */ -import { getOptimalPosition } from '../../src/dom/position'; +import { getOptimalPosition, Position } from '../../src/dom/position'; import Rect from '../../src/dom/rect'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; @@ -97,6 +97,15 @@ const attachTopRight = ( targetRect, elementRect ) => ( { const attachNone = () => null; +const attachWithoutArrow = () => ( { + top: 0, + left: 0, + name: 'arowless', + config: { + withArrow: false + } +} ); + const allPositions = [ attachLeftBottom, attachLeftTop, @@ -133,6 +142,31 @@ describe( 'getOptimalPosition()', () => { } } ); + it( 'output should be an instance of Position', () => { + setElementTargetPlayground(); + + const position = getOptimalPosition( { + element, + target: () => target, + positions: [ attachLeftBottom ] + } ); + + expect( position ).to.be.instanceOf( Position ); + } ); + + it( 'should pass position config to the Position object', () => { + setElementTargetPlayground(); + + const position = getOptimalPosition( { + element, + target: () => target, + positions: [ attachWithoutArrow ] + } ); + + expect( position.config ).to.be.an( 'object' ); + expect( position.config.withArrow ).to.be.false; + } ); + it( 'should work when the target is a Function', () => { setElementTargetPlayground(); @@ -688,8 +722,9 @@ describe( 'getOptimalPosition()', () => { function assertPosition( options, expected ) { const position = getOptimalPosition( options ); + const { top, left, name } = position; - expect( position ).to.deep.equal( expected ); + expect( { top, left, name } ).to.deep.equal( expected ); } function assertPositionName( options, expected ) { diff --git a/packages/ckeditor5-utils/tests/keyboard.js b/packages/ckeditor5-utils/tests/keyboard.js index e7114906938..4818b30a942 100644 --- a/packages/ckeditor5-utils/tests/keyboard.js +++ b/packages/ckeditor5-utils/tests/keyboard.js @@ -38,7 +38,8 @@ describe( 'Keyboard', () => { 'ctrl', 'cmd', 'shift', 'alt', 'arrowleft', 'arrowup', 'arrowright', 'arrowdown', 'backspace', 'delete', 'enter', 'space', 'esc', 'tab', - 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', 'f12' + 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', 'f12', + '`', '-', '=', '[', ']', ';', '\'', ',', '.', '/', '\\' ); } ); } ); @@ -56,6 +57,10 @@ describe( 'Keyboard', () => { expect( getCode( 'f6' ) ).to.equal( 117 ); } ); + it( 'gets code of a punctuation character', () => { + expect( getCode( ']' ) ).to.equal( 93 ); + } ); + it( 'is case insensitive', () => { expect( getCode( 'A' ) ).to.equal( 65 ); expect( getCode( 'Ctrl' ) ).to.equal( 0x110000 ); @@ -94,6 +99,10 @@ describe( 'Keyboard', () => { expect( parseKeystroke( 'ctrl+a' ) ).to.equal( 0x880000 + 65 ); } ); + it( 'parses string without modifier', () => { + expect( parseKeystroke( '[' ) ).to.equal( 91 ); + } ); + it( 'allows spacing', () => { expect( parseKeystroke( 'ctrl + a' ) ).to.equal( 0x880000 + 65 ); } ); @@ -134,6 +143,10 @@ describe( 'Keyboard', () => { expect( parseKeystroke( 'ctrl+a' ) ).to.equal( 0x110000 + 65 ); } ); + it( 'parses string without modifier', () => { + expect( parseKeystroke( '[' ) ).to.equal( 91 ); + } ); + it( 'allows spacing', () => { expect( parseKeystroke( 'ctrl + a' ) ).to.equal( 0x110000 + 65 ); } ); @@ -214,6 +227,8 @@ describe( 'Keyboard', () => { expect( getEnvKeystrokeText( 'a' ) ).to.equal( 'A' ); expect( getEnvKeystrokeText( 'CTRL+a' ) ).to.equal( 'โŒ˜A' ); expect( getEnvKeystrokeText( 'ctrl+b' ) ).to.equal( 'โŒ˜B' ); + expect( getEnvKeystrokeText( 'CTRL+[' ) ).to.equal( 'โŒ˜[' ); + expect( getEnvKeystrokeText( 'CTRL+]' ) ).to.equal( 'โŒ˜]' ); } ); } ); @@ -234,6 +249,8 @@ describe( 'Keyboard', () => { expect( getEnvKeystrokeText( 'SHIFT+A' ) ).to.equal( 'Shift+A' ); expect( getEnvKeystrokeText( 'alt+A' ) ).to.equal( 'Alt+A' ); expect( getEnvKeystrokeText( 'CTRL+SHIFT+A' ) ).to.equal( 'Ctrl+Shift+A' ); + expect( getEnvKeystrokeText( 'CTRL+[' ) ).to.equal( 'Ctrl+[' ); + expect( getEnvKeystrokeText( 'CTRL+]' ) ).to.equal( 'Ctrl+]' ); } ); } ); } ); diff --git a/packages/ckeditor5-widget/src/utils.js b/packages/ckeditor5-widget/src/utils.js index 5c4d0637b9e..8f1014d8594 100644 --- a/packages/ckeditor5-widget/src/utils.js +++ b/packages/ckeditor5-widget/src/utils.js @@ -7,10 +7,6 @@ * @module widget/utils */ -import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview'; - -import global from '@ckeditor/ckeditor5-utils/src/dom/global'; -import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect'; import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; import toArray from '@ckeditor/ckeditor5-utils/src/toarray'; @@ -399,65 +395,6 @@ export function viewToModelPositionOutsideModelElement( model, viewElementMatche }; } -/** - * A positioning function passed to the {@link module:utils/dom/position~getOptimalPosition} helper as a last resort - * when attaching {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView balloon UI} to widgets. - * It comes in handy when a widget is longer than the visual viewport of the web browser and/or upper/lower boundaries - * of a widget are off screen because of the web page scroll. - * - * โ”Œโ”€โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„Widgetโ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ” - * โ”Š โ”Š - * โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€Viewportโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ•โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€Viewportโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•โ”€โ”€โ” - * โ”‚ โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”Widgetโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”“ โ”‚ โ”‚ โ”ƒ ^ โ”ƒ โ”‚ - * โ”‚ โ”ƒ ^ โ”ƒ โ”‚ โ”‚ โ”ƒ โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€/ \โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ โ”ƒ โ”‚ - * โ”‚ โ”ƒ โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€/ \โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ โ”ƒ โ”‚ โ”‚ โ”ƒ โ”‚ Balloon โ”‚ โ”ƒ โ”‚ - * โ”‚ โ”ƒ โ”‚ Balloon โ”‚ โ”ƒ โ”‚ โ”‚ โ”ƒ โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ โ”ƒ โ”‚ - * โ”‚ โ”ƒ โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ โ”ƒ โ”‚ โ”‚ โ”ƒ โ”ƒ โ”‚ - * โ”‚ โ”ƒ โ”ƒ โ”‚ โ”‚ โ”ƒ โ”ƒ โ”‚ - * โ”‚ โ”ƒ โ”ƒ โ”‚ โ”‚ โ”ƒ โ”ƒ โ”‚ - * โ”‚ โ”ƒ โ”ƒ โ”‚ โ”‚ โ”ƒ โ”ƒ โ”‚ - * โ”‚ โ”ƒ โ”ƒ โ”‚ โ”‚ โ”ƒ โ”ƒ โ”‚ - * โ”‚ โ”ƒ โ”ƒ โ”‚ โ”‚ โ”ƒ โ”ƒ โ”‚ - * โ”‚ โ”ƒ โ”ƒ โ”‚ โ”‚ โ”ƒ โ”ƒ โ”‚ - * โ””โ”€โ”€โ•€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•€โ”€โ”€โ”˜ โ””โ”€โ”€โ•€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•€โ”€โ”€โ”˜ - * โ”Š โ”Š โ”Š โ”Š - * โ”Š โ”Š โ””โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”˜ - * โ”Š โ”Š - * โ””โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”„โ”˜ - * - * **Note**: Works best if used together with - * {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView.defaultPositions default `BalloonPanelView` positions} - * like `northArrowSouth` and `southArrowNorth`; the transition between these two and this position is smooth. - * - * @param {module:utils/dom/rect~Rect} widgetRect A rect of the widget. - * @param {module:utils/dom/rect~Rect} balloonRect A rect of the balloon. - * @returns {module:utils/dom/position~Position|null} - */ -export function centeredBalloonPositionForLongWidgets( widgetRect, balloonRect ) { - const viewportRect = new Rect( global.window ); - const viewportWidgetInsersectionRect = viewportRect.getIntersection( widgetRect ); - - const balloonTotalHeight = balloonRect.height + BalloonPanelView.arrowVerticalOffset; - - // If there is enough space above or below the widget then this position should not be used. - if ( widgetRect.top - balloonTotalHeight > viewportRect.top || widgetRect.bottom + balloonTotalHeight < viewportRect.bottom ) { - return null; - } - - // Because this is a last resort positioning, to keep things simple we're not playing with positions of the arrow - // like, for instance, "south west" or whatever. Just try to keep the balloon in the middle of the visible area of - // the widget for as long as it is possible. If the widgets becomes invisible (because cropped by the viewport), - // just... place the balloon in the middle of it (because why not?). - const targetRect = viewportWidgetInsersectionRect || widgetRect; - const left = targetRect.left + targetRect.width / 2 - balloonRect.width / 2; - - return { - top: Math.max( widgetRect.top, 0 ) + BalloonPanelView.arrowVerticalOffset, - left, - name: 'arrow_n' - }; -} - // Default filler offset function applied to all widget elements. // // @returns {null} diff --git a/packages/ckeditor5-widget/src/widgettoolbarrepository.js b/packages/ckeditor5-widget/src/widgettoolbarrepository.js index bfe74c09c3c..117dcfabc9b 100644 --- a/packages/ckeditor5-widget/src/widgettoolbarrepository.js +++ b/packages/ckeditor5-widget/src/widgettoolbarrepository.js @@ -11,10 +11,7 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon'; import ToolbarView from '@ckeditor/ckeditor5-ui/src/toolbar/toolbarview'; import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview'; -import { - isWidget, - centeredBalloonPositionForLongWidgets -} from './utils'; +import { isWidget } from './utils'; import CKEditorError, { logWarning } from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; /** @@ -289,7 +286,7 @@ function getBalloonPositionData( editor, relatedElement ) { defaultPositions.southArrowNorth, defaultPositions.southArrowNorthWest, defaultPositions.southArrowNorthEast, - centeredBalloonPositionForLongWidgets + defaultPositions.viewportStickyNorth ] }; } diff --git a/packages/ckeditor5-widget/tests/utils.js b/packages/ckeditor5-widget/tests/utils.js index 8a53febe77a..218c4133659 100644 --- a/packages/ckeditor5-widget/tests/utils.js +++ b/packages/ckeditor5-widget/tests/utils.js @@ -20,8 +20,7 @@ import { setHighlightHandling, findOptimalInsertionRange, viewToModelPositionOutsideModelElement, - WIDGET_CLASS_NAME, - centeredBalloonPositionForLongWidgets + WIDGET_CLASS_NAME } from '../src/utils'; import UIElement from '@ckeditor/ckeditor5-engine/src/view/uielement'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; @@ -30,9 +29,6 @@ import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; import Mapper from '@ckeditor/ckeditor5-engine/src/conversion/mapper'; import ModelElement from '@ckeditor/ckeditor5-engine/src/model/element'; import ModelText from '@ckeditor/ckeditor5-engine/src/model/text'; -import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview'; -import global from '@ckeditor/ckeditor5-utils/src/dom/global'; -import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect'; describe( 'widget utils', () => { let element, writer, viewDocument; @@ -708,135 +704,4 @@ describe( 'widget utils', () => { expect( modelPosition.path ).to.deep.equal( [ 3, 1 ] ); } ); } ); - - describe( 'centeredBalloonPositionForLongWidgets()', () => { - const arrowVerticalOffset = BalloonPanelView.arrowVerticalOffset; - - // Balloon is a 10x10 rect. - const balloonRect = new Rect( { - top: 0, - left: 0, - right: 10, - bottom: 10, - width: 10, - height: 10 - } ); - - beforeEach( () => { - testUtils.sinon.stub( global.window, 'innerWidth' ).value( 100 ); - testUtils.sinon.stub( global.window, 'innerHeight' ).value( 100 ); - } ); - - it( 'should return null if there is enough space above the widget', () => { - // Widget is a 50x150 rect, translated (25,25) from viewport's beginning (0,0). - const widgetRect = new Rect( { - top: 25, - left: 25, - right: 75, - bottom: 175, - width: 50, - height: 150 - } ); - - const position = centeredBalloonPositionForLongWidgets( widgetRect, balloonRect ); - - expect( position ).to.equal( null ); - } ); - - it( 'should return null if there is enough space below the widget', () => { - // Widget is a 50x150 rect, translated (25,-125) from viewport's beginning (0,0). - const widgetRect = new Rect( { - top: -125, - left: 25, - right: 75, - bottom: 25, - width: 50, - height: 150 - } ); - - const position = centeredBalloonPositionForLongWidgets( widgetRect, balloonRect ); - - expect( position ).to.equal( null ); - } ); - - it( 'should position the balloon inside a widget โ€“ at the top + in the middle', () => { - // Widget is a 50x150 rect, translated (25,5) from viewport's beginning (0,0). - const widgetRect = new Rect( { - top: 5, - left: 25, - right: 75, - bottom: 155, - width: 50, - height: 150 - } ); - - const position = centeredBalloonPositionForLongWidgets( widgetRect, balloonRect ); - - expect( position ).to.deep.equal( { - top: 5 + arrowVerticalOffset, - left: 45, - name: 'arrow_n' - } ); - } ); - - it( 'should stick the balloon to the top of the viewport when the top of a widget is off-screen', () => { - // Widget is a 50x150 rect, translated (25,-25) from viewport's beginning (0,0). - const widgetRect = new Rect( { - top: -25, - left: 25, - right: 75, - bottom: 150, - width: 50, - height: 150 - } ); - - const position = centeredBalloonPositionForLongWidgets( widgetRect, balloonRect ); - - expect( position ).to.deep.equal( { - top: arrowVerticalOffset, - left: 45, - name: 'arrow_n' - } ); - } ); - - it( 'should horizontally center the balloon in the visible area when the widget is cropped by the viewport', () => { - // Widget is a 50x150 rect, translated (-25,5) from viewport's beginning (0,0). - const widgetRect = new Rect( { - top: 5, - left: -25, - right: 25, - bottom: 155, - width: 50, - height: 150 - } ); - - const position = centeredBalloonPositionForLongWidgets( widgetRect, balloonRect ); - - expect( position ).to.deep.equal( { - top: 5 + arrowVerticalOffset, - left: 7.5, - name: 'arrow_n' - } ); - } ); - - it( 'should horizontally center the balloon in the widget when the widget is completely off the viewport', () => { - // Widget is a 50x150 rect, translated (0,-100) from viewport's beginning (0,0). - const widgetRect = new Rect( { - top: 0, - left: -100, - right: -50, - bottom: 150, - width: 50, - height: 150 - } ); - - const position = centeredBalloonPositionForLongWidgets( widgetRect, balloonRect ); - - expect( position ).to.deep.equal( { - top: 0 + arrowVerticalOffset, - left: -80, - name: 'arrow_n' - } ); - } ); - } ); } ); diff --git a/packages/ckeditor5-widget/tests/widgettoolbarrepository.js b/packages/ckeditor5-widget/tests/widgettoolbarrepository.js index 3984c48dd1f..47be62d872c 100644 --- a/packages/ckeditor5-widget/tests/widgettoolbarrepository.js +++ b/packages/ckeditor5-widget/tests/widgettoolbarrepository.js @@ -14,11 +14,7 @@ import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold'; import BlockQuote from '@ckeditor/ckeditor5-block-quote/src/blockquote'; import Widget from '../src/widget'; import WidgetToolbarRepository from '../src/widgettoolbarrepository'; -import { - isWidget, - toWidget, - centeredBalloonPositionForLongWidgets -} from '../src/utils'; +import { isWidget, toWidget } from '../src/utils'; import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; import View from '@ckeditor/ckeditor5-ui/src/view'; @@ -517,7 +513,7 @@ describe( 'WidgetToolbarRepository', () => { defaultPositions.southArrowNorth, defaultPositions.southArrowNorthWest, defaultPositions.southArrowNorthEast, - centeredBalloonPositionForLongWidgets + defaultPositions.viewportStickyNorth ] }, balloonClassName: 'ck-toolbar-container' diff --git a/packages/ckeditor5-word-count/docs/features/word-count.md b/packages/ckeditor5-word-count/docs/features/word-count.md index 9bc94c01016..1073f906de2 100644 --- a/packages/ckeditor5-word-count/docs/features/word-count.md +++ b/packages/ckeditor5-word-count/docs/features/word-count.md @@ -13,6 +13,8 @@ It is a feature crucial for writing professionals but also for students and mark ## Demo +Type some more or edit the content and observe the counter below the main editor window react in real-time. + {@snippet features/word-count} The example above was created by using the following HTML page structure: diff --git a/scripts/docs/features-html-output/build-features-html-output.js b/scripts/docs/features-html-output/build-features-html-output.js index d6c1e05ad4a..a049fc97357 100644 --- a/scripts/docs/features-html-output/build-features-html-output.js +++ b/scripts/docs/features-html-output/build-features-html-output.js @@ -25,29 +25,29 @@ const THIRD_PARTY_PACKAGES_LOCAL_DIR = 'scripts/docs/features-html-output/third- * - use the parsed data to create tables for each package, that contains all plugins and their possible HTML output. * * Each generated table contains 2 columns: "Plugin" and "HTML output". Each table cell in the "Plugin" column has a human-readable name of - * the plugin (which is a link to the feature documentation) and the name of the class used to create the plugin (which is a link to the API - * documentation). For each row in the "Plugin" column there is at least one row in the "HTML output" column. If given plugin does not - * generate any output, the one and only row in the "HTML output" column contains the word "None". Each item from the `htmlOutput` property - * from the package metadata file corresponds to a separate row in the "HTML output" column. It contains one or more preformatted paragraphs - * describing the possible HTML output: HTML elements, their CSS classes, inline styles, other attributes and comments. + * the plugin, a link to the feature documentation, and a link to the API documentation. For each row in the "Plugin" column there is at + * least one row in the "HTML output" column. If given plugin does not generate any output, the one and only row in the "HTML output" + * column contains the word "None". Each item from the `htmlOutput` property from the package metadata file corresponds to a separate row + * in the "HTML output" column. It contains one or more preformatted paragraphs describing the possible HTML output: HTML elements, their + * CSS classes, inline styles, other attributes and comments. * - * โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”ณโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”“ - * โ”ƒ Plugin โ”ƒ HTML output โ”ƒ - * โ”ฃโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ•‡โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”ซ - * โ”ƒ first plugin โ”‚ output #1 for the first plugin โ”ƒ - * โ”ƒ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”จ - * โ”ƒ โ”„ โ”„ - * โ”ƒ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”จ - * โ”ƒ โ”‚ output #N for the first plugin โ”ƒ - * โ”ƒโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”จ - * โ”„ โ”„ โ”„ - * โ”ƒโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”จ - * โ”ƒ last plugin โ”‚ output #1 for the last plugin โ”ƒ - * โ”ƒ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”จ - * โ”ƒ โ”„ โ”„ - * โ”ƒ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”จ - * โ”ƒ โ”‚ output #N for the last plugin โ”ƒ - * โ”—โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”ทโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”› + * โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”ณโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”“ + * โ”ƒ Plugin โ”ƒ HTML output โ”ƒ + * โ”ฃโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ•‡โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”ซ + * โ”ƒ #1 plugin name โ”‚ output #1 for the #1 plugin โ”ƒ + * โ”ƒ Feature guide link โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”จ + * โ”ƒ API documentation link โ”„ โ”„ + * โ”ƒ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”จ + * โ”ƒ โ”‚ output #N for the #1 plugin โ”ƒ + * โ”ƒโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”จ + * โ”„ โ”„ โ”„ + * โ”ƒโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”จ + * โ”ƒ #N plugin name โ”‚ output #1 for the #N plugin โ”ƒ + * โ”ƒ Feature guide link โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”จ + * โ”ƒ API documentation link โ”„ โ”„ + * โ”ƒ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”จ + * โ”ƒ โ”‚ output #N for the #N plugin โ”ƒ + * โ”—โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”ทโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”› * * Generated table is preceded by the package name as a heading and the link to a source package metadata file on GitHub. * @@ -235,23 +235,32 @@ function createSourceFileMarkupForThirdPartyPackage( filePath ) { function createHtmlOutputMarkupForPackage( packageData, plugins = [] ) { return plugins .map( plugin => { - const pluginNameLink = createFeatureLink( packageData, plugin ); + const links = [ + createFeatureLink( packageData, plugin ), + createApiLink( packageData, plugin ) + ]; - const pluginClassNameLink = createApiLink( packageData, plugin ); + let pluginNameMarkup = `

${ plugin.name }

`; + + for ( const link of links ) { + if ( link ) { + pluginNameMarkup += `

${ link }

`; + } + } const htmlOutputMarkup = plugin.htmlOutput ? createHtmlOutputMarkupForPlugin( plugin.htmlOutput ) : [ '

None.

' ]; return { - pluginNameMarkup: `

${ pluginNameLink }

${ pluginClassNameLink }

`, + pluginNameMarkup, htmlOutputMarkup }; } ); } /** - * Creates link to the plugin's feature documentation. If the feature documentation is missing, just the plugin name is returned. + * Creates link to the plugin's feature documentation. If the feature documentation is missing, it returns undefined. * * @param {Package} packageData Package properties. * @param {Plugin} plugin Plugin definition. @@ -259,7 +268,7 @@ function createHtmlOutputMarkupForPackage( packageData, plugins = [] ) { */ function createFeatureLink( packageData, plugin ) { if ( !plugin.docs ) { - return plugin.name; + return; } const link = /http(s)?:/.test( plugin.docs ) ? @@ -268,21 +277,21 @@ function createFeatureLink( packageData, plugin ) { const skipLinkValidation = packageData.isExternalPackage ? 'data-skip-validation' : ''; - return `${ plugin.name }`; + const docImg = 'Book'; + + return `${ docImg } Feature guide`; } /** - * Creates link to the plugin's API documentation. If given package is a third-party one, just the plugin class name is returned. + * Creates link to the plugin's API documentation. If given package is a third-party one, it returns undefined. * * @param {Package} packageData Package properties. * @param {Plugin} plugin Plugin definition. * @returns {String} */ function createApiLink( packageData, plugin ) { - const pluginClassName = `${ plugin.className }`; - if ( packageData.isThirdPartyPackage ) { - return pluginClassName; + return; } const shortPackageName = packageData.packageName.replace( /^ckeditor5-/g, '' ); @@ -295,7 +304,9 @@ function createApiLink( packageData, plugin ) { const skipLinkValidation = packageData.isExternalPackage ? 'data-skip-validation' : ''; - return `${ pluginClassName }`; + const cogImg = 'Cog'; + + return `${ cogImg } API documentation`; } /**