Przeglądaj źródła

Merge branch 'master' into i/6587

Piotrek Koszuliński 5 lat temu
rodzic
commit
025e119a8d
90 zmienionych plików z 8100 dodań i 1061 usunięć
  1. 31 0
      .github/workflows/merge-stable.yml
  2. 1 0
      .gitignore
  3. 2 0
      .travis.yml
  4. 1 1
      docs/builds/guides/support/reporting-issues.md
  5. 7 5
      docs/features/ui-language.md
  6. 4 0
      docs/framework/guides/architecture/ui-library.md
  7. 2 2
      docs/framework/guides/contributing/contributing.md
  8. 22 21
      docs/framework/guides/contributing/development-environment.md
  9. 16 14
      docs/framework/guides/contributing/git-commit-message-convention.md
  10. 4 3
      docs/framework/guides/contributing/testing-environment.md
  11. 5 1
      docs/framework/guides/creating-simple-plugin.md
  12. 242 0
      docs/framework/guides/deep-dive/localization.md
  13. 1 0
      docs/umberto.json
  14. 6 4
      package.json
  15. 11 0
      packages/ckeditor5-cloud-services/src/cloudservices.js
  16. 31 0
      packages/ckeditor5-cloud-services/tests/cloudservices.js
  17. 1 1
      packages/ckeditor5-code-block/src/codeblockediting.js
  18. 8 15
      packages/ckeditor5-code-block/src/converters.js
  19. 17 7
      packages/ckeditor5-code-block/tests/codeblockediting.js
  20. 66 1
      packages/ckeditor5-engine/docs/framework/guides/deep-dive/schema.md
  21. 18 3
      packages/ckeditor5-engine/src/model/model.js
  22. 105 31
      packages/ckeditor5-engine/src/model/schema.js
  23. 33 27
      packages/ckeditor5-engine/tests/view/view/view.js
  24. 1 1
      packages/ckeditor5-font/tests/ui/colorui.js
  25. 0 1
      packages/ckeditor5-highlight/src/highlightcommand.js
  26. 1 1
      packages/ckeditor5-horizontal-line/theme/horizontalline.css
  27. 41 11
      packages/ckeditor5-image/tests/image.js
  28. 1 1
      packages/ckeditor5-image/tests/imageresize.js
  29. 2 1
      packages/ckeditor5-media-embed/package.json
  30. 7 0
      packages/ckeditor5-media-embed/src/automediaembed.js
  31. 4 2
      packages/ckeditor5-media-embed/src/converters.js
  32. 7 2
      packages/ckeditor5-media-embed/src/mediaregistry.js
  33. 34 0
      packages/ckeditor5-media-embed/tests/automediaembed.js
  34. 42 0
      packages/ckeditor5-media-embed/tests/mediaembedediting.js
  35. 49 0
      packages/ckeditor5-paragraph/src/insertparagraphcommand.js
  36. 9 0
      packages/ckeditor5-paragraph/src/paragraph.js
  37. 101 0
      packages/ckeditor5-paragraph/tests/insertparagraphcommand.js
  38. 7 2
      packages/ckeditor5-paragraph/tests/paragraph.js
  39. 1 1
      packages/ckeditor5-table/src/commands/insertrowcommand.js
  40. 4 129
      packages/ckeditor5-table/src/commands/mergecellscommand.js
  41. 12 3
      packages/ckeditor5-table/src/commands/setheadercolumncommand.js
  42. 5 79
      packages/ckeditor5-table/src/commands/setheaderrowcommand.js
  43. 495 2
      packages/ckeditor5-table/src/tableclipboard.js
  44. 16 6
      packages/ckeditor5-table/src/tableselection.js
  45. 100 99
      packages/ckeditor5-table/src/tableselection/croptable.js
  46. 47 20
      packages/ckeditor5-table/src/tableutils.js
  47. 19 1
      packages/ckeditor5-table/src/ui/colorinputview.js
  48. 6 1
      packages/ckeditor5-table/src/ui/utils.js
  49. 292 1
      packages/ckeditor5-table/src/utils.js
  50. 54 0
      packages/ckeditor5-table/tests/commands/insertrowcommand.js
  51. 104 2
      packages/ckeditor5-table/tests/commands/setheadercolumncommand.js
  52. 3 3
      packages/ckeditor5-table/tests/commands/setheaderrowcommand.js
  53. 1 0
      packages/ckeditor5-table/tests/manual/tablemocking.html
  54. 68 12
      packages/ckeditor5-table/tests/manual/tablemocking.js
  55. 422 0
      packages/ckeditor5-table/tests/tableclipboard-copy-cut.js
  56. 3400 0
      packages/ckeditor5-table/tests/tableclipboard-paste.js
  57. 13 394
      packages/ckeditor5-table/tests/tableclipboard.js
  58. 82 0
      packages/ckeditor5-table/tests/tableutils.js
  59. 20 0
      packages/ckeditor5-table/tests/ui/colorinputview.js
  60. 3 1
      packages/ckeditor5-table/tests/ui/utils.js
  61. 89 1
      packages/ckeditor5-table/tests/utils.js
  62. 2 1
      packages/ckeditor5-theme-lark/theme/ckeditor5-ui/globals/_colors.css
  63. 6 2
      packages/ckeditor5-theme-lark/theme/ckeditor5-widget/widget.css
  64. 176 0
      packages/ckeditor5-theme-lark/theme/ckeditor5-widget/widgettypearound.css
  65. 5 2
      packages/ckeditor5-ui/src/colorgrid/utils.js
  66. 18 4
      packages/ckeditor5-utils/src/dom/position.js
  67. 26 1
      packages/ckeditor5-utils/tests/dom/position.js
  68. 3 1
      packages/ckeditor5-widget/lang/contexts.json
  69. 2 0
      packages/ckeditor5-widget/package.json
  70. 62 0
      packages/ckeditor5-widget/src/utils.js
  71. 8 0
      packages/ckeditor5-widget/src/widget.js
  72. 6 2
      packages/ckeditor5-widget/src/widgettoolbarrepository.js
  73. 95 0
      packages/ckeditor5-widget/src/widgettypearound/utils.js
  74. 299 0
      packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js
  75. BIN
      packages/ckeditor5-widget/tests/manual/sample.jpg
  76. 99 0
      packages/ckeditor5-widget/tests/manual/type-around.html
  77. 136 0
      packages/ckeditor5-widget/tests/manual/type-around.js
  78. 22 0
      packages/ckeditor5-widget/tests/manual/type-around.md
  79. 136 1
      packages/ckeditor5-widget/tests/utils.js
  80. 21 11
      packages/ckeditor5-widget/tests/widget-integration.js
  81. 179 73
      packages/ckeditor5-widget/tests/widget.js
  82. 40 1
      packages/ckeditor5-widget/tests/widgettoolbarrepository.js
  83. 382 0
      packages/ckeditor5-widget/tests/widgettypearound/widgettypearound.js
  84. 1 0
      packages/ckeditor5-widget/theme/icons/return-arrow.svg
  85. 5 0
      packages/ckeditor5-widget/theme/widget.css
  86. 90 0
      packages/ckeditor5-widget/theme/widgettypearound.css
  87. 10 1
      scripts/clean-up-svg-icons.sh
  88. 4 1
      scripts/docs/buildapi.js
  89. 22 19
      scripts/release/changelog.js
  90. 49 28
      yarn.lock

+ 31 - 0
.github/workflows/merge-stable.yml

@@ -0,0 +1,31 @@
+name: Stable branch auto merge
+
+on:
+  push:
+    branches: [ stable ]
+
+jobs:
+  merge:
+    runs-on: ubuntu-latest
+    steps:
+      # First: merge
+      - uses: octokit/request-action@v2.x
+        id: merge_action
+        with:
+          route: POST /repos/:repository/merges
+          repository: ${{ github.repository }}
+          base: master
+          head: stable
+        env:
+          GITHUB_TOKEN: ${{ secrets.STABLE_MERGE_GITHUB_TOKEN }}
+      # Report errors if any
+      - uses: rtCamp/action-slack-notify@v2.0.2
+        id: error_message_slack
+        name: Slack notification
+        if: (steps.merge_action.outputs.status != 201) && (steps.merge_action.outputs.status != 204)
+        env:
+          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
+          SLACK_CHANNEL: "cke5-ci"
+          SLACK_ICON: https://github.com/ckeditor.png?size=48
+          SLACK_USERNAME: "stable auto merge action"
+          SLACK_MESSAGE: ${{ format('💥 Error, merge call returned code {0}, see {1} for a list of codes with explanation. 💥', steps.merge_action.outputs.status, 'https://developer.github.com/v3/repos/merging/#perform-a-merge' ) }}

+ 1 - 0
.gitignore

@@ -3,6 +3,7 @@ build/
 coverage/
 packages/*/node_modules/
 docs/api/output.json
+external/
 lerna-debug.log
 .idea
 .vscode

+ 2 - 0
.travis.yml

@@ -13,6 +13,8 @@ cache:
 - node_modules
 before_install:
 - export START_TIME=$( date +%s )
+- export COVERALLS_SERVICE_JOB_ID=$( TRAVIS_JOB_ID )
+- export COVERALLS_SERVICE_NAME="CKEditor5 code coverage"
 - npm i -g yarn
 install:
 - yarn install

+ 1 - 1
docs/builds/guides/support/reporting-issues.md

@@ -22,7 +22,7 @@ Most of the issues are reproducible on the demo. If you have a different case th
 
 ## Reporting issues
 
-All issues should be reported in the [CKEditor 5 repository](https://github.com/ckeditor/ckeditor5/issues) on GitHub, except ones that are related to one of the following projects (which have their own issue pages):
+All issues should be reported in the [CKEditor 5 repository](https://github.com/ckeditor/ckeditor5/issues) on GitHub, except ones that are related to one of the following projects (that have their own issue pages):
 
 * [Angular integration](https://github.com/ckeditor/ckeditor5-angular),
 * [React integration](https://github.com/ckeditor/ckeditor5-react),

+ 7 - 5
docs/features/ui-language.md

@@ -7,7 +7,7 @@ menu-title: UI language
 
 # Setting the UI language
 
-The UI of the editor can be localized. CKEditor 5 currently supports around 50 languages and the number is growing.
+The UI of the editor can be localized. CKEditor 5 currently supports around 90 languages and the number is growing.
 
 <info-box>
 	If you want to help translate CKEditor 5 into your native language, join the [CKEditor 5 project on Transifex](https://www.transifex.com/ckeditor/ckeditor5/). Your help will be much appreciated!
@@ -17,6 +17,10 @@ See the demo of the editor in Spanish:
 
 {@snippet features/ui-language}
 
+<info-box>
+	If you are interested in creating features that can be localized, check out the {@link framework/guides/deep-dive/localization localization guide}.
+</info-box>
+
 ## Right–to–left (RTL) languages support
 
 CKEditor 5 supports right–to–left languages out–of–the–box. When one of <abbr title="right–to–left">RTL</abbr> languages is used, the WYSIWYG editor adapts its UI for the best editing experience, for instance, mirroring various elements like toolbars, dropdowns, buttons, etc.
@@ -102,7 +106,7 @@ If you build CKEditor 5 from scratch or integrate it directly into your applicat
 
 1. Install the [`@ckeditor/ckeditor5-dev-webpack-plugin`](https://www.npmjs.com/package/@ckeditor/ckeditor5-dev-webpack-plugin) package:
 
-	```bash
+	```
 	npm install --save @ckeditor/ckeditor5-dev-webpack-plugin
 	```
 
@@ -142,7 +146,7 @@ If you build CKEditor 5 from scratch or integrate it directly into your applicat
 	// ...
 	```
 
-3. Run webpack. If the `additionalLanguages` option is not set, the CKEditor 5 plugin for webpack will replace the {@link module:utils/locale~Locale#t `t()`} function call parameters used in the source code with localized language strings. Otherwise the CKEditor 5 plugin for webpack will replace the {@link module:utils/locale~Locale#t `t()`} function call parameters with short IDs and emit the translation files that should land in the `'translations'` directory (or different, if the `outputDirectory` option is specified).
+3. Run webpack. The CKEditor 5 plugin for webpack will emit additional files for each language specified in the `additionalLanguages` option. They will contain translations for messages from the {@link module:utils/locale~Locale#t `t()` function} calls. The files will be created in the `translations` directory (or another one if the `outputDirectory` option is specified). Translations from the language specified in the `language` option will be automatically included in the build.
 
 4. If you want to change the language after the build ends, you will need to edit the `index.html` file, add the translation file, and set the UI language to the target one.
 
@@ -165,8 +169,6 @@ If you build CKEditor 5 from scratch or integrate it directly into your applicat
 
 <info-box>
 	We are aware that the current localization method is not sufficient for some needs. It does not support different bundlers (e.g. Rollup or Browserify). We will be extending the localization possibilities in the future.
-
-	You can read more about the used techniques in the [Implement translation services](https://github.com/ckeditor/ckeditor5/issues/387) and [Implement translation services v2](https://github.com/ckeditor/ckeditor5/issues/624) issues.
 </info-box>
 
 ## Setting the language of the content

+ 4 - 0
docs/framework/guides/architecture/ui-library.md

@@ -11,6 +11,10 @@ The standard UI library of CKEditor 5 is [`@ckeditor/ckeditor5-ui`](https://www.
 
 Views use [templates](#templates) to build the UI. They also provide observable interfaces that other features (e.g. {@link framework/guides/architecture/core-editor-architecture#plugins plugins}, {@link framework/guides/architecture/core-editor-architecture#commands commands}, etc.) can use to change the DOM without any actual interaction with the native API.
 
+<info-box>
+	All views can be localized using the `locale` instance with which they were created. Check the {@link framework/guides/deep-dive/localization localization guide} to see how to use the `t()` function available in the `locale` instance.
+</info-box>
+
 ### Definition
 
 A simple input view class can be defined as follows:

+ 2 - 2
docs/framework/guides/contributing/contributing.md

@@ -42,12 +42,12 @@ Read more about our {@link framework/guides/contributing/testing-environment tes
 </info-box>
 
 <info-box>
-	[Angular](https://github.com/ckeditor/ckeditor5-angular), [React](https://github.com/ckeditor/ckeditor5-react) and [Vue](https://github.com/ckeditor/ckeditor5-vue) integrations as well as [CKEditor 5 inspector](https://github.com/ckeditor/ckeditor5-inspector) and [dev packages](https://github.com/ckeditor/ckeditor5-dev) are kept in separate repositories. The below steps assume that you want to propose a change in the [main CKEditor 5 repository](https://github.com/ckeditor/ckeditor5).
+	The [Angular](https://github.com/ckeditor/ckeditor5-angular), [React](https://github.com/ckeditor/ckeditor5-react) and [Vue](https://github.com/ckeditor/ckeditor5-vue) integrations as well as [CKEditor 5 inspector](https://github.com/ckeditor/ckeditor5-inspector) and [development tools packages](https://github.com/ckeditor/ckeditor5-dev) are kept in separate repositories. The steps below assume that you want to propose a change in the [main CKEditor 5 repository](https://github.com/ckeditor/ckeditor5).
 </info-box>
 
 Assuming that you would like to propose some changes, these are the steps you should take to create a pull request:
 
-1. Make sure to open a ticket in https://github.com/ckeditor/ckeditor5 describing the issue/feature/problem that you want to solve in your pull request. This can be skipped in case of obvious and trivial changes (typos, documentation, etc.).
+1. Make sure to open a ticket in https://github.com/ckeditor/ckeditor5 describing the issue, feature or problem that you want to solve in your pull request. This can be skipped in case of obvious and trivial changes (typos, documentation, etc.).
 1. Make sure your {@link framework/guides/contributing/development-environment development environment} is ready.
 1. Go to GitHub and [fork the repository](https://help.github.com/articles/fork-a-repo). The forked repository will appear in your GitHub account as `https://github.com/YOUR-USERNAME/ckeditor5`.
 1. Open your terminal, then go to the package ("repository") folder in your development environment:

+ 22 - 21
docs/framework/guides/contributing/development-environment.md

@@ -15,10 +15,10 @@ The CKEditor 5 codebase is divided into multiple [npm](http://npmjs.com/) packag
 
 The main package's GitHub repository also [hosts all other CKEditor5 sub-packages](https://github.com/ckeditor/ckeditor5/tree/master/packages).
 
-You can find all the official packages listed in [CKEditor 5 development repository's README](https://github.com/ckeditor/ckeditor5#packages).
+You can find all the official packages listed in the [CKEditor 5 repository's README](https://github.com/ckeditor/ckeditor5#packages).
 
 <info-box info>
-Prior to version **19.0.0** CKEditor 5 was developed in a multi repository architecture. If you'd like to work with the older multi-repository release, please refer to [older Developer Environment guide](/docs/ckeditor5/19.0.0/framework/guides/contributing/development-environment.html) for multi-repository oriented instructions.
+	Prior to version **19.0.0** CKEditor 5 was developed in a multi-repository architecture. If you would like to work with an older multi-repository release, please refer to the [older Development environment guide](/docs/ckeditor5/19.0.0/framework/guides/contributing/development-environment.html) for multi-repository oriented instructions.
 </info-box>
 
 ## Requirements
@@ -34,7 +34,7 @@ First, you need to install  [Yarn](https://yarnpkg.com/) that will be used for d
 
 It is best to install it globally in your system for an easier use later on:
 
-```bash
+```
 npm install -g yarn
 ```
 
@@ -42,28 +42,28 @@ npm install -g yarn
 
 Then clone the [CKEditor 5 repository](https://github.com/ckeditor/ckeditor5):
 
-```bash
+```
 git clone https://github.com/ckeditor/ckeditor5.git
 cd ckeditor5
 ```
 
 And install all CKEditor 5 packages from the [npm registry](http://npmjs.com/).
 
-```bash
+```
 yarn install
 ```
 
 ## Running tests
 
-In order to run tests you need to use the `test` and `manual` tasks.
+In order to run tests, you need to use the `test` and `manual` tasks.
 
-```bash
+```
 yarn run test --watch --coverage --source-map --files=engine
 ```
 
 or, shorter:
 
-```bash
+```
 yarn run test -- -wcs --files=engine
 ```
 
@@ -73,19 +73,19 @@ This command will run the [`ckeditor5-engine`](https://github.com/ckeditor/ckedi
 
 To create a server for manual tests use the `manual` task:
 
-```bash
+```
 yarn run manual
 ```
 
-To help test localized editors, the task accepts two optional configurations: `--language="en"` and `--additionalLanguages="ar,pl,..."`. The former sets the main language used by test editors. By default it is `"en"` and it in most scenarios you do not need to change it. The later brings more languages to manual tests, e.g. which is helpful when working with {@link features/ui-language#righttoleft-rtl-languages-support right–to–left languages in the user interface}.
+To help test localized editors, the task accepts two optional configurations: `--language="en"` and `--additionalLanguages="ar,pl,..."`. The former sets the main language used by test editors. By default it is `"en"` and in most scenarios you do not need to change it. The latter brings more languages to manual tests, which is helpful e.g. when working with {@link features/ui-language#righttoleft-rtl-languages-support right–to–left languages in the user interface}.
 
 You can read more about the {@link framework/guides/contributing/testing-environment Testing environment}.
 
 ## Generating documentation
 
-To build the documentation you need to run the `docs` task:
+To build the documentation, you need to run the `docs` task:
 
-```bash
+```
 yarn run docs
 ```
 
@@ -93,13 +93,13 @@ The documentation will be available in `build/docs/`.
 
 This task accepts the following arguments:
 
-* `--skip-api` &mdash; Skips building the API documentation (which takes the majority of the total time).
-* `--skip-snippets` &mdash; Skips building live snippets.
-* `--snippets=snippet-name` &mdash; Whitelist snippets to build (accepts glob patterns).
-* `--skip-validation` &mdash; Skips the final link validation.
-* `--watch` &mdash; Runs the documentation generator in a watch mode. It covers guides (it does not cover API docs).
-* `--production` &mdash; Minifies the assets and performs other actions which are unnecessary during CKEditor 5 development.
-* `--verbose` &mdash; Prints out more information.
+* `--skip-api` &ndash; Skips building the API documentation (which takes the majority of the total time).
+* `--skip-snippets` &ndash; Skips building live snippets.
+* `--snippets=snippet-name` &ndash; Whitelists snippets to build (accepts glob patterns).
+* `--skip-validation` &ndash; Skips the final link validation.
+* `--watch` &ndash; Runs the documentation generator in a watch mode. It covers guides but it does not cover API docs.
+* `--production` &ndash; Minifies the assets and performs other actions which are unnecessary during CKEditor 5 development.
+* `--verbose` &ndash; Prints out more information.
 
 Note: These arguments must be passed after additional `--`:
 
@@ -117,9 +117,10 @@ yarn run docs:serve
 
 It is possible to generate a stylesheet containing content styles brought by all CKEditor 5 features. In order to do that, execute:
 
-```bash
+```
 yarn docs:content-styles
 ```
+
 The stylesheet will be saved in the `build/content-styles` folder.
 
 To learn more, refer to the {@link builds/guides/integration/content-styles Content styles} guide.
@@ -132,7 +133,7 @@ By default, CKEditor 5 supports SVG icons found in the `ckeditor5-*/theme/icons`
 
 To remove the excess data and prevent [certain issues](https://github.com/ckeditor/ckeditor5-ui/issues/245), **all new icons should be optimized before joining the code base**. To do that, you can use the `clean-up-svg-icons` script in the [root of the project](#setting-up-the-ckeditor-development-environment), a wrapper for the [SVGO](https://github.com/svg/svgo) tool:
 
-```bash
+```
 cd path/to/ckeditor5
 
 # Optimize all SVG files in the folder.

+ 16 - 14
docs/framework/guides/contributing/git-commit-message-convention.md

@@ -5,7 +5,7 @@ order: 40
 
 # Git commit message convention
 
-Every commit made *directly* to the `master` branch must follow the below convention. Based on commits in the `master` branch CKEditor 5 release tools will generate changelog entries for the current release.
+Every commit made *directly* to the `master` branch must follow the convention below. Based on commits in the `master` branch CKEditor 5 release tools will generate changelog entries for the current release.
 
 <info-box>
 	Commits in the ticket branches are not analyzed for the changelog and do not have to follow any specific convention (other than finishing sentences with periods). In case of ticket branches, **only merge commits are analyzed**.
@@ -20,12 +20,12 @@ Commit message template:
 ```
 Type (package-name): A short sentence about the commit. Closes #XXX.
 
-Type (other-package-name): If the change affects more than one package, it's possible to put multiple entries at once. Closes #YYY.
+Type (another-package-name): If the change affects more than one package, it's possible to put multiple entries at once. Closes #YYY.
 
 Optional description.
 
-BREAKING CHANGE: If any breaking changes were done, they need to be listed here.
-BREAKING CHANGE: Another breaking change if needed. Closes #ZZZ.
+BREAKING CHANGE (scope): If any breaking changes were done, they need to be listed here.
+BREAKING CHANGE (scope): Another breaking change if needed. Closes #ZZZ.
 ```
 
 ### Commit types
@@ -41,7 +41,7 @@ BREAKING CHANGE: Another breaking change if needed. Closes #ZZZ.
 | Revert | `patch` | Revert of some commit. | Hidden |
 | Release | `patch` | A special type of commit used by the release tools. | Hidden |
 
-Each commit can contain additional notes which will be inserted into the changelog:
+Each commit can contain additional notes that will be inserted into the changelog:
 
 * `MAJOR BREAKING CHANGE` (alias: `BREAKING CHANGE`),
 * `MINOR BREAKING CHANGE`.
@@ -50,13 +50,15 @@ If any change contains the `MAJOR BREAKING CHANGE` note, the next release will b
 
 For reference on how to identify minor or major breaking change see the {@link framework/guides/support/versioning-policy versioning policy guide}.
 
+Each `BREAKING CHANGE` note must be followed by the scope of changes.
+
 ### Package name
 
 Most commits are related to one or more packages. Each affected package should be listed in parenthesis following the commit type. A package that was the most impacted by the change should be listed first.
 
-It is, however, possible to skip this part if many packages are affected. This is a typically indication that this is a generic change and having all the packages listed would reduce changelog readability.
+It is, however, possible to skip this part if many packages are affected. This usually indicates a generic change. In this case having all the packages listed would reduce the changelog readability.
 
-The package name is based on npm package name, however it has the `@ckeditor/ckeditor(5)-` prefix stripped.
+The package name is based on the npm package name, however, it has the `@ckeditor/ckeditor(5)-` prefix stripped.
 
 If your change is related to the main package only, use `ckeditor5` as the package name.
 
@@ -76,13 +78,13 @@ A generic bug fix for an existing feature that affects many packages (closes two
 Fix: The editor will be great again. Closes #3. Closes #4.
 ```
 
-Commit with updated documentation:
+A commit with updated documentation:
 
 ```
 Docs (link): Updated the README.
 ```
 
-Commit that provides or changes the tests:
+A commit that provides or changes the tests:
 
 ```
 Tests (widget): Introduced missing tests. Closes #5.
@@ -95,7 +97,7 @@ Other (utils): Extracted the `utils.foo()` to a separate package. Closes #9.
 
 Feature (engine): Introduced the `engine.foo()` method. Closes #9.
 
-MAJOR BREAKING CHANGE: 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:
@@ -108,7 +110,7 @@ Changelog
 
 ### MAJOR BREAKING CHANGES [ℹ️](https://ckeditor.com/docs/ckeditor5/latest/framework/guides/support/versioning-policy.html#major-and-minor-breaking-changes)
 
-* The `utils.foo()` method was moved to the `engine` package. See #9. 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
 
@@ -128,9 +130,9 @@ Changelog
 
 ## Handling pull requests
 
-When making a pull request its author may (it is recommended in the pull request template) propose a merge commit message.
+When creating a pull request, its author may (it is recommended in the pull request template) propose a merge commit message.
 
-The reviewer's duty is to validate the proposed message and apply necessary changes (the PR description can be edited).
+The reviewer's duty is to validate the proposed message and apply necessary changes. The PR description can be edited.
 
 Things like:
 
@@ -144,7 +146,7 @@ should be checked and added if missing.
 
 As a reviewer, remember that the message will end up in the changelog and must be understandable in a broad context of the entire editor. It is not for you &mdash; it is for other developers.
 
-When closing a PR remember to copy the source of the message to the textarea with the merge commit message:
+When closing a PR, remember to copy the source of the message to the textarea with the merge commit message:
 
 {@img assets/img/closing-a-pr.gif Screencast how to copy a source version of the suggested commit message when closing a PR.}
 

+ 4 - 3
docs/framework/guides/contributing/testing-environment.md

@@ -26,24 +26,25 @@ It accepts the following arguments that must be passed after the `--` option:
 * `--files` &ndash; Specifies test files to run. Accepts a package name or a glob. For example `--files=engine` will run tests from `ckeditor5-engine` package. Read more about the [rules for converting the `--files` option to a glob pattern](https://github.com/ckeditor/ckeditor5-dev/tree/master/packages/ckeditor5-dev-tests#rules-for-converting---files-option-to-glob-pattern).
 * `--browsers` &ndash; Browsers that will be used to run the tests. Defaults to `Chrome`.
 * `--debug` (alias `-d`) &ndash; Allows specifying custom debug flags. For example, the `--debug engine` option uncomments the `// @if CK_DEBUG_ENGINE //` lines in the code. Note that by default `--debug` is set to `true` even if you did not specify it. This enables the base set of debug logs (`// @if CK_DEBUG //`) which should always be enabled in the testing environment. You can completely turn off the debug mode by setting the `--debug false` option.
+* `--port` &ndash; Specifies the port for the server to use. Defaults to `9876`.
 
 ### Examples
 
 Run all tests with the code coverage check of the [`ckeditor5-core`](https://github.com/ckeditor/ckeditor5/tree/master/packages/ckeditor5-core/tests) package:
 
-```bash
+```
 yarn run test -c --files=core
 ```
 
 Run and watch the [engine's `view` namespace tests](https://github.com/ckeditor/ckeditor5/tree/master/packages/ckeditor5-engine/tests/view) and all the tests in [`ckeditor5-typing`](https://github.com/ckeditor/ckeditor5/tree/master/packages/ckeditor5-typing/tests):
 
-```bash
+```
 yarn run test -cw --files=engine/view,typing
 ```
 
 Run the `bold*.js` tests in the [`ckeditor5-basic-styles`](https://github.com/ckeditor/ckeditor5/tree/master/packages/ckeditor5-basic-styles/tests) package:
 
-```bash
+```
 yarn run test -cw --files=basic-styles/bold*.js
 ```
 

+ 5 - 1
docs/framework/guides/creating-simple-plugin.md

@@ -19,6 +19,10 @@ The plugin that you will write will use a part of the {@link features/image imag
 	We recommend using the official {@link framework/guides/development-tools#ckeditor-5-inspector CKEditor 5 inspector} for development and debugging. It will give you tons of useful information about the state of the editor such as internal data structures, selection, commands, and many more.
 </info-box>
 
+<info-box hint>
+	For simplicity reasons this guide does not describe how to localize the created plugin. To see how to localize plugins, refer to the {@link framework/guides/deep-dive/localization localization guide}.
+</info-box>
+
 ## Step 1. Installing dependencies
 
 Start from installing the necessary dependencies:
@@ -27,7 +31,7 @@ Start from installing the necessary dependencies:
 * The [`@ckeditor/ckeditor5-core`](https://www.npmjs.com/package/@ckeditor/ckeditor5-core) package which contains the {@link module:core/plugin~Plugin} and {@link module:core/command~Command} classes.
 * The [`@ckeditor/ckeditor5-ui`](https://www.npmjs.com/package/@ckeditor/ckeditor5-ui) package which contains the UI library and framework.
 
-```bash
+```
 npm install --save @ckeditor/ckeditor5-image \
 	@ckeditor/ckeditor5-core \
 	@ckeditor/ckeditor5-ui

+ 242 - 0
docs/framework/guides/deep-dive/localization.md

@@ -0,0 +1,242 @@
+---
+category: framework-deep-dive-ui
+order: 70
+---
+
+# Localization
+
+## Introduction
+
+All CKEditor 5 WYSIWYG editor features support message localization. It means that the user interface of any feature can be translated into various languages and regions depending on the user's preferences.
+
+CKEditor 5 translation system is open to third-party plugins. Any custom features that you introduce can be localized. The system also provides a way to add missing or overwrite existing translations and supports translating plural forms.
+
+<info-box warning>
+	Make sure to use up-to-date CKEditor 5 development tool packages. Versions of the tools older than v19.0.1 do not provide support for features described in this guide.
+</info-box>
+
+### Open translation API
+
+The CKEditor 5 localization system focuses on the following points:
+
+- Supporting the localization of third-party plugins.
+- Making it possible to pass your own translations to fix missing or invalid localizations.
+- Generating deterministic builds.
+- Exposing easy-to-use APIs for providing translations and writing localizable content.
+- Supporting plural forms in each step of the localization system for better translations.
+
+### Glossary of terms
+
+Before we start, let us explain the meaning of terms that are crucial for the translation process:
+
+- *A message* &ndash; A string or an object that should be translated.
+	The string version works as a shortcut for the `{ id: message, string: message }` object form.
+- *A message ID* &ndash; A property used to distinguish messages.
+	It is useful for short messages where a collision might occur, like `%0 images`.
+- *A message string* &ndash; The default (English) form of the message.
+	When the message supports plural versions, this is the default singular version.
+- *A message plural* &ndash; An optional plural (English) version of the message.
+	The presence of this property indicates that the message should support both singular and plural forms.
+- *A PO file (`.po`)* &ndash; A file containing translations for the language in the [PO format](https://www.gnu.org/software/gettext/manual/html_node/PO-Files.html).
+	All localizable packages of CKEditor 5 contain such files in the `lang/translations/` directory.
+- *A POT file (`.pot`)* &ndash; A file containing source messages (English sentences) that will be translated.
+- *A translation asset* &ndash; A JavaScript file or a part of the file with generated translations for one language.
+
+## Writing a localizable UI
+
+All *messages* that need localization should be passed to the special CKEditor 5's {@link module:utils/locale~Locale#t `t()` function}. This function can be retrieved either from the editor's {@link module:utils/locale~Locale} instance: `const { t } = editor.locale;` or from any view method: `const t = this.t;`.
+
+As the first argument, the `t()` function accepts either a string literal or an object literal containing the `id`, `string` and `plural` (optional) properties. The string literal will serve as both the *message ID* and the *message string*.
+
+As the second argument, the translation function accepts a value or an array of values. These values will be used to fill the placeholders in more advanced translation scenarios. If the `plural` property is specified, the first value will be used as the quantity determining the plural form.
+
+<info-box warning>
+    Due to the fact that a static code analyzer is used in the translation process, you must use a function named exactly `t()`. It should not be called on a `Locale` instance and it cannot have a different name. The code analyzer looks for *localizable messages* only in `t()` function calls.
+
+    For the same reason, the first argument can only be a string literal or an object literal. Variables cannot be passed.
+</info-box>
+
+When using the `t()` function, you can create your own *localizable messages* or reuse *messages* created in CKEditor 5 packages that your project depends on. In case of reusing *messages*, you will not need to worry about translating them as all work will be done by the CKEditor 5 team and [Transifex translators](https://www.transifex.com/ckeditor/ckeditor5/). Obviously, {@link framework/guides/contributing/contributing#translating your help in translating} will still be appreciated!
+
+For simple *localizable messages*, use the string form for simplicity:
+
+```js
+const emojiName = 'cat';
+
+// Assuming that the English language was picked:
+t( 'insert emoji' ); // "insert emoji"
+t( 'insert %0 emoji', emojiName ); // "insert cat emoji"
+t( 'insert %0 emoji', [ emojiName ] ); // "insert cat emoji"
+```
+
+For more advanced scenarios, use plain object forms:
+
+```js
+const quantity = 3;
+
+// Assuming that the English language was picked:
+t( { string: '%0 emoji', id: 'ACTION_EMOJI' }, 'insert' ); // "insert emoji"
+t( { string: '%0 emoji', plural: '%0 emojis', id: 'N_EMOJIS' }, quantity ); // "3 emojis"
+t( { string: '%1 %0 emoji', plural: '%1 %0 emojis', id: 'ACTION_N_EMOJIS' }, [ quantity, 'Insert' ] ); // "Insert 3 emojis"
+```
+
+### Example: Localizing the plugin UI
+
+This example shows how to create a localizable user interface of a plugin. Here is how you can create a button that will insert a smiling face emoji. The button will have a localizable tooltip.
+
+```js
+// ...
+editor.ui.componentFactory.add( 'smilingFaceEmoji', locale => {
+	const buttonView = new ButtonView( locale );
+
+	// The translation function.
+	const { t } = editor.locale;
+
+	// The localized label.
+	const label = t( 'Insert smiling face emoji' );
+
+	buttonView.set( {
+		label,
+		icon: emojiIcon,
+		tooltip: true
+	} );
+
+	buttonView.on( 'execute', () => {
+		editor.execute( 'insertSmilingFaceEmoji' );
+		editor.editing.view.focus();
+	} );
+} );
+// ...
+```
+
+<info-box warning>
+	See {@link framework/guides/creating-simple-plugin how to create a complete plugin} to have a better understanding of creating CKEditor 5 plugins.
+</info-box>
+
+### Example: Localizing pending actions
+
+{@link module:core/pendingactions~PendingActions Pending actions} are used to inform the user that an action is in progress and they will lose data if they exit the editor at the given moment. Here is how you can localize them:
+
+```js
+class FileRepository {
+	// ...
+	updatePendingAction() {
+		// ...
+		const pendingActions = this.editor.plugins.get( PendingActions );
+
+		// ...
+		const t = this.editor.t;
+		const getMessage = value => t( 'Upload in progress (%0%).', value ); // Upload in progress (12%).
+
+		this._pendingAction = pendingActions.add( getMessage( this.uploadedPercent ) );
+		this._pendingAction.bind( 'message' ).to( this, 'uploadedPercent', getMessage );
+	}
+}
+```
+
+## Adding translations and localizing the editor UI
+
+First of all, if you found a missing or incorrect translation in any of CKEditor 5 features, {@link framework/guides/contributing/contributing#translating see how you can contribute to the project}. CKEditor 5 is an Open Source project used by people from all around the world, so your help will be appreciated by others.
+
+Adding translations to the editor can be done in three ways to satisfy various needs.
+
+- By [adding translations via the translation-service's `add()` function](#using-the-add-function).
+	This can be done before initiating the CKEditor 5 editor instance but it requires importing the CKEditor 5 utility function.
+- By [extending the global `window.CKEDITOR_TRANSLATIONS` object](#using-the-windowckeditor_translations-object).
+	This can be done before initiating the CKEditor 5 editor instance.
+- By [creating `.po` files with translations](#creating-po-files) in the `lang/translations/` directory of the published package like other CKEditor 5 packages do.
+	This option will be useful for third-party plugin creators as it allows bundling translations only for needed languages during the webpack compilation step.
+
+### Using the `add()` function
+
+The first option for adding translations is via {@link module:utils/translation-service.add the translation-service's `add()` helper}. This utility adds translations to the global `window.CKEDITOR_TRANSLATIONS` object by extending it. Since it needs to be imported, it works only before building the editor.
+
+Starting with the CKEditor 5 v19.0.0 release, the `add()` method now accepts an optional `getPluralForm()` function as the third argument. This function is only needed for defining the plural form if no language file was loaded for a particular language. It also accepts an array of translations for a *message* if the *message* should support singular and plural forms.
+
+```js
+add( 'pl', {
+	'Add space': [ 'Dodaj spację', 'Dodaj %0 spacje', 'Dodaj %0 spacji' ]
+} );
+
+// Assuming that the Polish language was picked:
+t( { string: 'Add space', plural: 'Add %0 spaces' }, 1 ) // "Dodaj spację"
+t( { string: 'Add space', plural: 'Add %0 spaces' }, 2 ) // "Dodaj 2 spacje"
+t( { string: 'Add space', plural: 'Add %0 spaces' }, 5 ) // "Dodaj 5 spacji"
+```
+
+### Using the `window.CKEDITOR_TRANSLATIONS` object
+
+The second option is adding translations via the global `window.CKEDITOR_TRANSLATIONS` object.
+
+For each language that should be supported, the `dictionary` property of this object should be extended and the `getPluralForm()` function should be provided if missing.
+
+The `dictionary` property is a `message ID ⇒ translations` map, where the `translations` can be either a string or an array of translations with plural forms for the given language if the message should support plural forms as well.
+
+The `getPluralForm()` property should be a function that returns the plural form index for a given quantity. Note that when using CKEditor 5 translations, this property will be defined by *CKEditor 5 translation assets*.
+
+Check an example below that demonstrates a part of the `window.CKEDITOR_TRANSLATIONS` object with Polish translations for the `Cancel` and `Add space` *message IDs*:
+
+```js
+{
+	// Each key should be a valid language code.
+	pl: {
+		// A map of translations for the 'pl' language.
+		dictionary: {
+			'Cancel': 'Anuluj',
+			'Add space': [ 'Dodaj spację', 'Dodaj %0 spacje', 'Dodaj %0 spacji' ]
+		},
+
+		// A function that returns the plural form index for the given language.
+		// Note that you only need to pass this function when you add translations for a new language.
+		getPluralForm: n => n == 1 ? 0 : n % 10 >= 2 && n % 10 <= 4 && ( n % 100 < 10 || n % 100 >= 20 ) ? 1 : 2
+	}
+	// Other languages...
+}
+```
+
+It is important to extend the existing properties in the `window.CKEDITOR_TRANSLATIONS` object to not lose other translations. This can be achieved easily using `Object.assign()` and the `||` operator.
+
+```js
+// Make sure that the global object is defined. If not, define it.
+window.CKEDITOR_TRANSLATIONS = window.CKEDITOR_TRANSLATIONS || {};
+
+// Make sure that the dictionary for Polish translations exist.
+window.CKEDITOR_TRANSLATIONS[ 'pl' ] = window.CKEDITOR_TRANSLATIONS[ 'pl' ] || {};
+window.CKEDITOR_TRANSLATIONS[ 'pl' ].dictionary =  window.CKEDITOR_TRANSLATIONS[ 'pl' ].dictionary || {};
+
+// Extend the dictionary for Polish translations with your translations:
+Object.assign( window.CKEDITOR_TRANSLATIONS[ 'pl' ].dictionary, {
+	'Save': 'Zapisz'
+} );
+```
+
+If you add a new language, remember to set the `getPluralForm()` function which should return a number (or a Boolean for languages with simple plural rules like English) that indicates which form should be used for the given value.
+
+### Creating `.po` files
+
+The third option of adding plugins should fit mostly owners of plugins that contain many localizable messages. Using this option you need to create a `.po` file per each language code in the `lang/translations/` directory containing translations that follow the [PO file format](https://www.gnu.org/software/gettext/manual/html_node/PO-Files.html).
+
+```
+# lang/translations/es.po
+
+msgid ""
+msgstr ""
+"Language: es\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+msgid "Align left"
+msgstr "Alinear a la izquierda"
+```
+
+<info-box warning>
+	Note that by default, the [CKEditor 5 webpack plugin](https://github.com/ckeditor/ckeditor5-dev/tree/master/packages/ckeditor5-dev-webpack-plugin) is configured to parse only the CKEditor 5 source code when looking for *localizable messages* and generating *translation assets*.
+
+	If you develop your own plugin outside the CKEditor 5 ecosystem and localize it by creating *PO files*, you should override both the `sourceFilesPattern` and the `packageNamePattern` options to allow the CKEditor 5 webpack plugin to analyze the code and find *messages* with corresponding translations. You should also mention these webpack plugin changes in your package README to make other users build the localized CKEditor 5 editor with your plugin correctly. This obstacle may be simplified in the future when the localization feature gets more popular.
+</info-box>
+
+To build and configure a localized editor, follow the steps from the {@link features/ui-language Setting the UI language guide}.
+
+## Known limitations
+
+- Currently it is impossible to change the chosen editor's language at runtime without destroying the editor.
+- Currently it is impossible to add more than one language to the bundle using the the CKEditor 5 webpack plugin. In case where multiple *translation assets* should be added to the application, they should be added using the `<script>` tags or imports to the generated *translation assets*.

+ 1 - 0
docs/umberto.json

@@ -3,6 +3,7 @@
 	"slug": "ckeditor5",
 	"hexo-config": "docs/hexo-custom-config.json",
 	"extraStyles": "assets/styles.css",
+	"packagesDir": ["packages", "external/*/packages"],
 	"path": "docs",
 	"observablesdocs": "module:utils/observablemixin~ObservableMixin",
 	"redirects": {

+ 6 - 4
package.json

@@ -79,7 +79,7 @@
     "@ckeditor/ckeditor5-comments": "^19.0.1",
     "@ckeditor/ckeditor5-dev-docs": "^11.1.0",
     "@ckeditor/ckeditor5-dev-env": "^18.0.0",
-    "@ckeditor/ckeditor5-dev-tests": "^19.1.0",
+    "@ckeditor/ckeditor5-dev-tests": "^19.2.0",
     "@ckeditor/ckeditor5-dev-utils": "^13.0.0",
     "@ckeditor/ckeditor5-dev-webpack-plugin": "^9.0.0",
     "@ckeditor/ckeditor5-inspector": "^2.0.0",
@@ -109,7 +109,7 @@
     "stylelint-config-ckeditor5": "^1.0.0",
     "svgo": "^1.1.0",
     "terser-webpack-plugin": "^2.2.1",
-    "umberto": "^1.2.0",
+    "umberto": "^1.3.0",
     "webpack": "^4.39.1"
   },
   "engines": {
@@ -126,11 +126,11 @@
   },
   "scripts": {
     "lint": "eslint --quiet '**/*.js'",
-    "stylelint": "stylelint --quiet --allow-empty-input 'packages/**/*.css' 'docs/**/*.css'",
+    "stylelint": "stylelint --quiet --allow-empty-input 'packages/**/*.css' 'external/*/packages/**/*.css' 'docs/**/*.css'",
     "test": "node --max_old_space_size=4096 node_modules/@ckeditor/ckeditor5-dev-tests/bin/test.js",
     "manual": "node --max_old_space_size=4096 node_modules/@ckeditor/ckeditor5-dev-tests/bin/test-manual.js",
     "bootstrap": "yarn install",
-    "clean": "rm -rf node_modules",
+    "clean": "rm -rf ./node_modules && rm -rf ./external/*/node_modules",
     "reset": "rm -rf ./node_modules && yarn run bootstrap",
     "reinstall": "yarn run clean && yarn run bootstrap",
     "docs": "node --max-old-space-size=4096 ./scripts/docs/build-docs.js",
@@ -167,6 +167,8 @@
   "workspaces": {
     "packages": [
       "packages/*",
+      "external/*",
+      "external/*/packages/*",
       "."
     ],
     "nohoist": [

+ 11 - 0
packages/ckeditor5-cloud-services/src/cloudservices.js

@@ -71,6 +71,17 @@ export default class CloudServices extends ContextPlugin {
 
 		return this.token.init();
 	}
+
+	/**
+	 * @inheritDoc
+	 */
+	destroy() {
+		super.destroy();
+
+		if ( this.token ) {
+			this.token.destroy();
+		}
+	}
 }
 
 CloudServices.Token = Token;

+ 31 - 0
packages/ckeditor5-cloud-services/tests/cloudservices.js

@@ -136,4 +136,35 @@ describe( 'CloudServices', () => {
 			} );
 		} );
 	} );
+
+	describe( 'destroy()', () => {
+		it( 'should destroy created token when tokenUrl was provided', async () => {
+			CloudServices.Token.initialToken = 'initial-token';
+
+			const context = await Context.create( {
+				plugins: [ CloudServices ],
+				cloudServices: {
+					tokenUrl: 'http://token-endpoint'
+				}
+			} );
+
+			const cloudServicesPlugin = context.plugins.get( CloudServices );
+
+			const destroySpy = sinon.spy( cloudServicesPlugin.token, 'destroy' );
+
+			await context.destroy();
+
+			sinon.assert.calledOnce( destroySpy );
+		} );
+
+		it( 'should not crash when tokenUrl was not provided', async () => {
+			const context = await Context.create( { plugins: [ CloudServices ] } );
+
+			try {
+				await context.destroy();
+			} catch ( error ) {
+				expect.fail( 'Error should not be thrown.' );
+			}
+		} );
+	} );
 } );

+ 1 - 1
packages/ckeditor5-code-block/src/codeblockediting.js

@@ -128,7 +128,7 @@ export default class CodeBlockEditing extends Plugin {
 		editor.editing.downcastDispatcher.on( 'insert:codeBlock', modelToViewCodeBlockInsertion( model, normalizedLanguagesDefs, true ) );
 		editor.data.downcastDispatcher.on( 'insert:codeBlock', modelToViewCodeBlockInsertion( model, normalizedLanguagesDefs ) );
 		editor.data.downcastDispatcher.on( 'insert:softBreak', modelToDataViewSoftBreakInsertion( model ), { priority: 'high' } );
-		editor.data.upcastDispatcher.on( 'element:pre', dataViewToModelCodeBlockInsertion( editor.data, normalizedLanguagesDefs ) );
+		editor.data.upcastDispatcher.on( 'element:pre', dataViewToModelCodeBlockInsertion( editor.editing.view, normalizedLanguagesDefs ) );
 
 		// Intercept the clipboard input (paste) when the selection is anchored in the code block and force the clipboard
 		// data to be pasted as a single plain text. Otherwise, the code lines will split the code block and

+ 8 - 15
packages/ckeditor5-code-block/src/converters.js

@@ -126,12 +126,12 @@ export function modelToDataViewSoftBreakInsertion( model ) {
  *
  *		<codeBlock language="javascript">foo();<softBreak></softBreak>bar();</codeBlock>
  *
- * @param {module:engine/controller/datacontroller~DataController} dataController
+ * @param {module:engine/view/view~View} editingView
  * @param {Array.<module:code-block/codeblock~CodeBlockLanguageDefinition>} languageDefs The normalized language
  * configuration passed to the feature.
  * @returns {Function} Returns a conversion callback.
  */
-export function dataViewToModelCodeBlockInsertion( dataController, languageDefs ) {
+export function dataViewToModelCodeBlockInsertion( editingView, languageDefs ) {
 	// Language names associated with CSS classes:
 	//
 	//		{
@@ -183,8 +183,12 @@ export function dataViewToModelCodeBlockInsertion( dataController, languageDefs
 			writer.setAttribute( 'language', defaultLanguageName, codeBlock );
 		}
 
-		const stringifiedElement = dataController.processor.toData( viewChild );
-		const textData = extractDataFromCodeElement( stringifiedElement );
+		// HTML elements are invalid content for `<code>`.
+		// Read only text nodes.
+		const textData = [ ...editingView.createRangeIn( viewChild ) ]
+			.filter( current => current.type === 'text' )
+			.map( ( { item } ) => item.data )
+			.join( '' );
 		const fragment = rawSnippetTextToModelDocumentFragment( writer, textData );
 
 		writer.append( fragment, codeBlock );
@@ -234,14 +238,3 @@ export function dataViewToModelCodeBlockInsertion( dataController, languageDefs
 		}
 	};
 }
-
-// Returns content of `<pre></pre>` with unescaped html inside.
-//
-// @param {String} stringifiedElement
-function extractDataFromCodeElement( stringifiedElement ) {
-	const data = new RegExp( /^<code[^>]*>([\S\s]*)<\/code>$/ ).exec( stringifiedElement )[ 1 ];
-
-	return data
-		.replace( /&lt;/g, '<' )
-		.replace( /&gt;/g, '>' );
-}

+ 17 - 7
packages/ckeditor5-code-block/tests/codeblockediting.js

@@ -889,29 +889,39 @@ describe( 'CodeBlockEditing', () => {
 			);
 		} );
 
+		// Undesired by expected. There is an issue with identifying the correct filler type.
+		// <code> is inline, so dom-to-view converter expects an inline filler.
+		it( 'should convert pre > code with only &nbsp; inside to a codeBlock with &nbsp;', () => {
+			editor.setData( '<pre><code>&nbsp;</code></pre>' );
+
+			expect( getModelData( model ) ).to.equal(
+				'<codeBlock language="plaintext">[]\u00a0</codeBlock>'
+			);
+		} );
+
 		it( 'should convert pre > code with HTML inside', () => {
 			editor.setData( '<pre><code><p>Foo</p>\n<p>Bar</p></code></pre>' );
 
 			expect( getModelData( model ) ).to.equal(
 				'<codeBlock language="plaintext">[]' +
-					'<p>Foo</p>' +
+					'Foo' +
 					'<softBreak></softBreak>' +
-					'<p>Bar</p>' +
+					'Bar' +
 				'</codeBlock>'
 			);
 		} );
 
-		it( 'should convert pre > code tag with HTML and nested pre > code tag', () => {
-			editor.setData( '<pre><code><p>Foo</p><pre>Bar</pre><p>Biz</p></code></pre>' );
+		it( 'should convert pre > code tag with HTML and nested pre > code tag and use only the text content of invalid HTML tags', () => {
+			editor.setData( '<pre><code><p>Foo</p><pre><code>Bar</code></pre><p>Biz</p></code></pre>' );
 
 			expect( getModelData( model ) ).to.equal(
-				'<codeBlock language="plaintext">[]<p>Foo</p><pre>Bar</pre><p>Biz</p></codeBlock>' );
+				'<codeBlock language="plaintext">[]FooBarBiz</codeBlock>' );
 		} );
 
 		it( 'should convert pre > code tag with escaped html content', () => {
-			editor.setData( '<pre><code>&lt;div&gt;&lt;p&gt;Foo&lt;/p&gt;&lt;/div&gt;</code></pre>' );
+			editor.setData( '<pre><code>&lt;div&gt;&lt;p&gt;Foo&apos;s&amp;&quot;bar&quot;&lt;/p&gt;&lt;/div&gt;</code></pre>' );
 
-			expect( getModelData( model ) ).to.equal( '<codeBlock language="plaintext">[]<div><p>Foo</p></div></codeBlock>' );
+			expect( getModelData( model ) ).to.equal( '<codeBlock language="plaintext">[]<div><p>Foo\'s&"bar"</p></div></codeBlock>' );
 		} );
 
 		it( 'should be overridable', () => {

+ 66 - 1
packages/ckeditor5-engine/docs/framework/guides/deep-dive/schema.md

@@ -8,7 +8,7 @@ This article assumes that you have already read the {@link framework/guides/arch
 
 ## Quick recap
 
-The editor's schema is available in the {@link module:engine/model/model~Model#schema `editor.model.schema`} property. It defines allowed model structures (how model elements can be nested) and allowed attributes (of both elements and text nodes). This information is later used by editing features and the editing engine to decide how to process the model, where to enable features, etc.
+The editor's schema is available in the {@link module:engine/model/model~Model#schema `editor.model.schema`} property. It defines allowed model structures (how model elements can be nested), allowed attributes (of both elements and text nodes), and other characteristics (inline vs. block, atomicity in regards of external actions). This information is later used by editing features and the editing engine to decide how to process the model, where to enable features, etc.
 
 Schema rules can be defined by using the {@link module:engine/model/schema~Schema#register `Schema#register()`} or {@link module:engine/model/schema~Schema#extend `Schema#extend()`} methods. The former can be used only once for a given item name which ensures that only a single editing feature can introduce this item. Similarly, `extend()` can only be used for defined items.
 
@@ -44,6 +44,71 @@ While this would be incorrect:
 </$root>
 ```
 
+## Defining additional semantics
+
+In addition to setting allowed structures, the schema can also define additional traits of model elements. By using the `is*` properties a feature author may declare how a certain element should be treated by other features and the engine.
+
+### Limit elements
+
+Consider a feature like an image caption. The caption text area should construct a boundary to some internal actions:
+
+* A selection that starts inside should not end outside.
+* Pressing <kbd>Backspace</kbd> or <kbd>Delete</kbd> should not delete the area. Pressing <kbd>Enter</kbd> should not split the area.
+
+It should also act as a boundary for external actions. This is mostly enforced by a selection post-fixer that ensures that a selection that starts outside, should not end inside. That means that most actions will either apply to the "outside" of such an element or to a content inside it.
+
+Taken these characteristics, the image caption should be defined as limit element by using the {@link module:engine/model/schema~SchemaItemDefinition#isLimit `isLimit`} property.
+
+```js
+schema.register( 'myCaption', {
+	isLimit: true
+} );
+```
+
+The engine and various features then check it via {@link module:engine/model/schema~Schema#isLimit `Schema#isLimit()`} and can act accordingly.
+
+<info-box>
+	"Limit element" does not mean "editable element". The concept of "editability" is reserved for the view and expressed by the {@link module:engine/view/editableelement~EditableElement `EditableElement` class}.
+</info-box>
+
+### Object elements
+
+For the image caption as in the example above it does not make much sense to select the caption box, then copy or drag it somewhere else.
+
+A caption without the image that it describes does not make much sense. However, the image is more self-sufficient. Most likely users should be able to select the entire image (with all its internals), then copy or move it around. {@link module:engine/model/schema~SchemaItemDefinition#isObject `isObject`} should be used to mark such behavior.
+
+```js
+schema.register( 'myImage', {
+	isObject: true
+} );
+```
+
+The {@link module:engine/model/schema~Schema#isObject `Schema#isObject()`} can later be used to check this property.
+
+<info-box>
+	Every "object" is also a "limit" element.
+
+	It means that for every element with `isObject` set to `true`, {@link module:engine/model/schema~Schema#isLimit `Schema#isLimit( element )`} will always return `true`.
+</info-box>
+
+### Block elements
+
+Generally speaking, content is usually made out of blocks like paragraphs, list items, images, headings, etc. All these elements should be marked as blocks by using {@link module:engine/model/schema~SchemaItemDefinition#isBlock `isBlock`}.
+
+It is important to remember that a block should not allow another block inside. Container elements like `<blockQuote>` which can contain other block elements should not be marked as blocks.
+
+<info-box>
+	There is also the `$block` generic item which has `isBlock` set to `true`. Most block type items will inherit from `$block` (through `inheritAllFrom`).
+</info-box>
+
+### Inline elements
+
+In the editor, all HTML formatting elements such as `<strong>` or `<code>` are represented by text attributes. Therefore, inline model elements are not to be used for this scenarios.
+
+Currently, the {@link module:engine/model/schema~SchemaItemDefinition#isInline `isInline`} property is used for the `$text` token (so, text nodes) and elements such as `<softBreak>` or placeholder elements such as in the {@link framework/guides/tutorials/implementing-an-inline-widget Implementing an inline widget} tutorial.
+
+The support for inline elements in CKEditor 5 is so far limited to self-contained elements. This is &mdash; all elements marked with `isInline` should also be marked with `isObject`.
+
 ## Generic items
 
 There are three basic generic items: `$root`, `$block` and `$text`. They are defined as follows:

+ 18 - 3
packages/ckeditor5-engine/src/model/model.js

@@ -370,13 +370,28 @@ export default class Model {
 	 *			editor.model.insertContent( writer.createText( 'x' ), writer.createPositionAt( doc.getRoot(), 2 ) );
 	 *		} );
 	 *
-	 * If an instance of {@link module:engine/model/selection~Selection} is passed as `selectable`
-	 * it will be moved to the target position (where the document selection should be moved after the insertion).
+	 * If you want the document selection to be moved to the inserted content, use the
+	 * {@link module:engine/model/writer~Writer#setSelection `setSelection()`} method of the writer after inserting
+	 * the content:
 	 *
 	 *		editor.model.change( writer => {
-	 *			// Insert text replacing the given selection instance.
+	 *			const paragraph = writer.createElement( 'paragraph' );
+	 *
+	 *			// Insert an empty paragraph at the beginning of the root.
+	 *			editor.model.insertContent( paragraph, writer.createPositionAt( editor.model.document.getRoot(), 0 ) );
+	 *
+	 *			// Move the document selection to the inserted paragraph.
+	 *			writer.setSelection( paragraph, 'in' );
+	 *		} );
+	 *
+	 * If an instance of the {@link module:engine/model/selection~Selection model selection} is passed as `selectable`,
+	 * the new content will be inserted at the passed selection (instead of document selection):
+	 *
+	 *		editor.model.change( writer => {
+	 *			// Create a selection in a paragraph that will be used as a place of insertion.
 	 *			const selection = writer.createSelection( paragraph, 'in' );
 	 *
+	 *			// Insert the new text at the created selection.
 	 *			editor.model.insertContent( writer.createText( 'x' ), selection );
 	 *
 	 *			// insertContent() modifies the passed selection instance so it can be used to set the document selection.

+ 105 - 31
packages/ckeditor5-engine/src/model/schema.js

@@ -153,7 +153,12 @@ export default class Schema {
 	}
 
 	/**
-	 * Returns all registered items.
+	 * Returns data of all registered items.
+	 *
+	 * This method should normally be used for reflection purposes (e.g. defining a clone of a certain element,
+	 * checking a list of all block elements, etc).
+	 * Use specific methods (such as {@link #checkChild `checkChild()`} or {@link #isLimit `isLimit()`})
+	 * in other cases.
 	 *
 	 * @returns {Object.<String,module:engine/model/schema~SchemaCompiledItemDefinition>}
 	 */
@@ -168,6 +173,11 @@ export default class Schema {
 	/**
 	 * Returns a definition of the given item or `undefined` if item is not registered.
 	 *
+	 * This method should normally be used for reflection purposes (e.g. defining a clone of a certain element,
+	 * checking a list of all block elements, etc).
+	 * Use specific methods (such as {@link #checkChild `checkChild()`} or {@link #isLimit `isLimit()`})
+	 * in other cases.
+	 *
 	 * @param {module:engine/model/item~Item|module:engine/model/schema~SchemaContextItem|String} item
 	 * @returns {module:engine/model/schema~SchemaCompiledItemDefinition}
 	 */
@@ -210,6 +220,9 @@ export default class Schema {
 	 *		const paragraphElement = writer.createElement( 'paragraph' );
 	 *		schema.isBlock( paragraphElement ); // -> true
 	 *
+	 * See the {@glink framework/guides/deep-dive/schema#block-elements Block elements} section of the "Schema" deep dive}
+	 * guide for more details.
+	 *
 	 * @param {module:engine/model/item~Item|module:engine/model/schema~SchemaContextItem|String} item
 	 */
 	isBlock( item ) {
@@ -219,15 +232,22 @@ export default class Schema {
 	}
 
 	/**
-	 * Returns `true` if the given item is defined to be
-	 * a limit element by {@link module:engine/model/schema~SchemaItemDefinition}'s `isLimit` or `isObject` property
-	 * (all objects are also limits).
+	 * Returns `true` if the given item should be treated as a limit element.
+	 *
+	 * It considers the item to be a limit element if its
+	 * {@link module:engine/model/schema~SchemaItemDefinition}'s
+	 * {@link module:engine/model/schema~SchemaItemDefinition#isLimit `isLimit`} or
+	 * {@link module:engine/model/schema~SchemaItemDefinition#isObject `isObject`} property
+	 * were set to `true`.
 	 *
 	 *		schema.isLimit( 'paragraph' ); // -> false
 	 *		schema.isLimit( '$root' ); // -> true
 	 *		schema.isLimit( editor.model.document.getRoot() ); // -> true
 	 *		schema.isLimit( 'image' ); // -> true
 	 *
+	 * See the {@glink framework/guides/deep-dive/schema#limit-elements Limit elements} section of the "Schema" deep dive}
+	 * guide for more details.
+	 *
 	 * @param {module:engine/model/item~Item|module:engine/model/schema~SchemaContextItem|String} item
 	 */
 	isLimit( item ) {
@@ -241,8 +261,12 @@ export default class Schema {
 	}
 
 	/**
-	 * Returns `true` if the given item is defined to be
-	 * an object element by {@link module:engine/model/schema~SchemaItemDefinition}'s `isObject` property.
+	 * Returns `true` if the given item is should be treated as an object element.
+	 *
+	 * It considers the item to be an object element if its
+	 * {@link module:engine/model/schema~SchemaItemDefinition}'s
+	 * {@link module:engine/model/schema~SchemaItemDefinition#isObject `isObject`} property
+	 * were set to `true`.
 	 *
 	 *		schema.isObject( 'paragraph' ); // -> false
 	 *		schema.isObject( 'image' ); // -> true
@@ -250,6 +274,9 @@ export default class Schema {
 	 *		const imageElement = writer.createElement( 'image' );
 	 *		schema.isObject( imageElement ); // -> true
 	 *
+	 * See the {@glink framework/guides/deep-dive/schema#object-elements Object elements} section of the "Schema" deep dive}
+	 * guide for more details.
+	 *
 	 * @param {module:engine/model/item~Item|module:engine/model/schema~SchemaContextItem|String} item
 	 */
 	isObject( item ) {
@@ -268,6 +295,9 @@ export default class Schema {
 	 *		const text = writer.createText('foo' );
 	 *		schema.isInline( text ); // -> true
 	 *
+	 * See the {@glink framework/guides/deep-dive/schema#inline-elements Inline elements} section of the "Schema" deep dive}
+	 * guide for more details.
+	 *
 	 * @param {module:engine/model/item~Item|module:engine/model/schema~SchemaContextItem|String} item
 	 */
 	isInline( item ) {
@@ -712,8 +742,8 @@ export default class Schema {
 	 * as long as {@link module:engine/model/schema~Schema#isLimit limit element},
 	 * {@link module:engine/model/schema~Schema#isObject object element} or top-most ancestor won't be reached.
 	 *
-	 * @params {module:engine/model/position~Position} position Position from searching will start.
-	 * @params {module:engine/model/node~Node|String} node Node for which allowed parent should be found or its name.
+	 * @param {module:engine/model/position~Position} position Position from searching will start.
+	 * @param {module:engine/model/node~Node|String} node Node for which allowed parent should be found or its name.
 	 * @returns {module:engine/model/element~Element|null} element Allowed parent or null if nothing was found.
 	 */
 	findAllowedParent( position, node ) {
@@ -999,32 +1029,34 @@ mix( Schema, ObservableMixin );
  *
  * You can define the following rules:
  *
- * * `allowIn` &ndash; A string or an array of strings. Defines in which other items this item will be allowed.
- * * `allowAttributes` &ndash; A string or an array of strings. Defines allowed attributes of the given item.
- * * `allowContentOf` &ndash; A string or an array of strings. Inherits "allowed children" from other items.
- * * `allowWhere` &ndash; A string or an array of strings. Inherits "allowed in" from other items.
- * * `allowAttributesOf` &ndash; A string or an array of strings. Inherits attributes from other items.
- * * `inheritTypesFrom` &ndash; A string or an array of strings. Inherits `is*` properties of other items.
- * * `inheritAllFrom` &ndash; A string. A shorthand for `allowContentOf`, `allowWhere`, `allowAttributesOf`, `inheritTypesFrom`.
- * * Additionally, you can define the following `is*` properties: `isBlock`, `isLimit`, `isObject`, `isInline`. Read about them below.
+ * * {@link ~SchemaItemDefinition#allowIn `allowIn`} &ndash; Defines in which other items this item will be allowed.
+ * * {@link ~SchemaItemDefinition#allowAttributes `allowAttributes`} &ndash; Defines allowed attributes of the given item.
+ * * {@link ~SchemaItemDefinition#allowContentOf `allowContentOf`} &ndash; Inherits "allowed children" from other items.
+ * * {@link ~SchemaItemDefinition#allowWhere `allowWhere`} &ndash; Inherits "allowed in" from other items.
+ * * {@link ~SchemaItemDefinition#allowAttributesOf `allowAttributesOf`} &ndash; Inherits attributes from other items.
+ * * {@link ~SchemaItemDefinition#inheritTypesFrom `inheritTypesFrom`} &ndash; Inherits `is*` properties of other items.
+ * * {@link ~SchemaItemDefinition#inheritAllFrom `inheritAllFrom`} &ndash;
+ * A shorthand for `allowContentOf`, `allowWhere`, `allowAttributesOf`, `inheritTypesFrom`.
  *
- * # The is* properties
+ * # The `is*` properties
  *
- * There are 3 commonly used `is*` properties. Their role is to assign additional semantics to schema items.
+ * There are a couple commonly used `is*` properties. Their role is to assign additional semantics to schema items.
  * You can define more properties but you will also need to implement support for them in the existing editor features.
  *
- * * `isBlock` &ndash; Whether this item is paragraph-like. Generally speaking, content is usually made out of blocks
- * like paragraphs, list items, images, headings, etc. All these elements are marked as blocks. A block
- * should not allow another block inside. Note: There is also the `$block` generic item which has `isBlock` set to `true`.
- * Most block type items will inherit from `$block` (through `inheritAllFrom`).
- * * `isLimit` &ndash; It can be understood as whether this element should not be split by <kbd>Enter</kbd>.
- * Examples of limit elements: `$root`, table cell, image caption, etc. In other words, all actions that happen inside
- * a limit element are limited to its content. **Note:** All objects (`isObject`) are treated as limit elements, too.
- * * `isObject` &ndash; Whether an item is "self-contained" and should be treated as a whole. Examples of object elements:
- * `image`, `table`, `video`, etc. **Note:** An object is also a limit, so
+ * * {@link ~SchemaItemDefinition#isBlock `isBlock`} &ndash; Whether this item is paragraph-like.
+ * Generally speaking, content is usually made out of blocks like paragraphs, list items, images, headings, etc.
+ * * {@link ~SchemaItemDefinition#isInline `isInline`} &ndash; Whether an item is "text-like" and should be treated as an inline node.
+ * Examples of inline elements: `$text`, `softBreak` (`<br>`), etc.
+ * * {@link ~SchemaItemDefinition#isLimit `isLimit`} &ndash; It can be understood as whether this element
+ * should not be split by <kbd>Enter</kbd>. Examples of limit elements: `$root`, table cell, image caption, etc.
+ * In other words, all actions that happen inside a limit element are limited to its content.
+ * All objects are treated as limit elements, too.
+ * * {@link ~SchemaItemDefinition#isObject `isObject`} &ndash; Whether an item is "self-contained" and should be treated as a whole.
+ * Examples of object elements: `image`, `table`, `video`, etc. An object is also a limit, so
  * {@link module:engine/model/schema~Schema#isLimit `isLimit()`} returns `true` for object elements automatically.
- * * `isInline` &ndash; Whether an item is "text-like" and should be treated as an inline node. Examples of inline elements:
- * `$text`, `softBreak` (`<br>`), etc.
+ *
+ * Read more about the meaning of these types in the
+ * {@glink framework/guides/deep-dive/schema#defining-additional-semantics Dedicated section of the "Schema" deep dive} guide.
  *
  * # Generic items
  *
@@ -1047,7 +1079,7 @@ mix( Schema, ObservableMixin );
  * (paragraphs, lists items, headings, images) which, in turn, may contain text inside.
  *
  * By inheriting from the generic items you can define new items which will get extended by other editor features.
- * Read more about generic types in the {@glink framework/guides/deep-dive/schema Defining schema} guide.
+ * Read more about generic types in the {@glink framework/guides/deep-dive/schema Schema deep dive} guide.
  *
  * # Example definitions
  *
@@ -1107,10 +1139,52 @@ mix( Schema, ObservableMixin );
  * * If you want to publish your feature so other developers can use it, try to use
  * generic items as much as possible.
  * * Keep your model clean. Limit it to the actual data and store information in a normalized way.
- * * Remember about definining the `is*` properties. They do not affect the allowed structures, but they can
+ * * Remember about defining the `is*` properties. They do not affect the allowed structures, but they can
  * affect how the editor features treat your elements.
  *
  * @typedef {Object} module:engine/model/schema~SchemaItemDefinition
+ *
+ * @property {String|Array.<String>} allowIn Defines in which other items this item will be allowed.
+ * @property {String|Array.<String>} allowAttributes Defines allowed attributes of the given item.
+ * @property {String|Array.<String>} allowContentOf Inherits "allowed children" from other items.
+ * @property {String|Array.<String>} allowWhere Inherits "allowed in" from other items.
+ * @property {String|Array.<String>} allowAttributesOf Inherits attributes from other items.
+ * @property {String|Array.<String>} inheritTypesFrom Inherits `is*` properties of other items.
+ * @property {String} inheritAllFrom A shorthand for `allowContentOf`, `allowWhere`, `allowAttributesOf`, `inheritTypesFrom`.
+ *
+ * @property {Boolean} isBlock
+ * Whether this item is paragraph-like. Generally speaking, content is usually made out of blocks
+ * like paragraphs, list items, images, headings, etc. All these elements are marked as blocks. A block
+ * should not allow another block inside. Note: There is also the `$block` generic item which has `isBlock` set to `true`.
+ * Most block type items will inherit from `$block` (through `inheritAllFrom`).
+ *
+ * Read more about the block elements in the
+ * {@glink framework/guides/deep-dive/schema#block-elements Block elements} section of the "Schema" deep dive} guide.
+ *
+ * @property {Boolean} isInline
+ * Whether an item is "text-like" and should be treated as an inline node. Examples of inline elements:
+ * `$text`, `softBreak` (`<br>`), etc.
+ *
+ * Read more about the inline elements in the
+ * {@glink framework/guides/deep-dive/schema#inline-elements Inline elements} section of the "Schema" deep dive} guide.
+ *
+ * @property {Boolean} isLimit
+ * It can be understood as whether this element should not be split by <kbd>Enter</kbd>.
+ * Examples of limit elements: `$root`, table cell, image caption, etc. In other words, all actions that happen inside
+ * a limit element are limited to its content.
+ *
+ * Read more about the limit elements in the
+ * {@glink framework/guides/deep-dive/schema#limit-elements Limit elements} section of the "Schema" deep dive} guide.
+ *
+ * @property {Boolean} isObject
+ * Whether an item is "self-contained" and should be treated as a whole. Examples of object elements:
+ * `image`, `table`, `video`, etc.
+ *
+ * **Note:** An object is also a limit, so
+ * {@link module:engine/model/schema~Schema#isLimit `isLimit()`} returns `true` for object elements automatically.
+ *
+ * Read more about the object elements in the
+ * {@glink framework/guides/deep-dive/schema#object-elements Object elements} section of the "Schema" deep dive} guide.
  */
 
 /**

+ 33 - 27
packages/ckeditor5-engine/tests/view/view/view.js

@@ -17,6 +17,7 @@ import CompositionObserver from '../../../src/view/observer/compositionobserver'
 import ViewRange from '../../../src/view/range';
 import ViewElement from '../../../src/view/element';
 import ViewContainerElement from '../../../src/view/containerelement';
+import ViewText from '../../../src/view/text';
 import ViewPosition from '../../../src/view/position';
 import ViewSelection from '../../../src/view/selection';
 import { StylesProcessor } from '../../../src/view/stylesmap';
@@ -516,7 +517,7 @@ describe( 'view', () => {
 	} );
 
 	describe( 'hasDomSelection', () => {
-		let domElement, domP, domSelection;
+		let domElement, domP, domText, viewP, viewText, domSelection;
 
 		// Focus tests are too unstable on Firefox to run them.
 		if ( env.isGecko ) {
@@ -528,10 +529,12 @@ describe( 'view', () => {
 
 			view.attachDomRoot( domRoot );
 
-			// It must be a container element to be rendered with the bogus <br> inside which ensures
-			// that the browser sees a selection position inside (empty <p> may not be selectable).
-			// May help resolving https://github.com/ckeditor/ckeditor5/issues/6655.
-			viewRoot._appendChild( new ViewContainerElement( viewDocument, 'p' ) );
+			viewP = new ViewContainerElement( viewDocument, 'p' );
+			viewText = new ViewText( viewDocument, 'ab' );
+
+			viewRoot._appendChild( viewP );
+			viewP._appendChild( viewText );
+
 			view.forceRender();
 
 			domElement = createElement( document, 'div', { contenteditable: 'true' } );
@@ -539,6 +542,7 @@ describe( 'view', () => {
 
 			domSelection = document.getSelection();
 			domP = domRoot.childNodes[ 0 ];
+			domText = domP.childNodes[ 0 ];
 		} );
 
 		afterEach( () => {
@@ -548,38 +552,40 @@ describe( 'view', () => {
 		it( 'should be true if selection is inside a DOM root element', done => {
 			domRoot.focus();
 
-			setTimeout( () => {
-				domSelection.collapse( domP, 0 );
+			// Both selection need to stay in sync to avoid inf selection loops
+			// as there's no editing pipeline that would ensure that the view selection
+			// gets changed based on the selectionChange event. See https://github.com/ckeditor/ckeditor5/issues/6655.
+			viewDocument.selection._setTo( viewText, 1 );
+			domSelection.collapse( domText, 1 );
 
-				// Wait for async selectionchange event on DOM document.
-				setTimeout( () => {
-					expect( view.hasDomSelection ).to.be.true;
+			// Wait for async selectionchange event on DOM document.
+			setTimeout( () => {
+				expect( view.hasDomSelection ).to.be.true;
 
-					done();
-				}, 10 );
-			}, 10 );
+				done();
+			}, 1000 );
 		} );
 
 		it( 'should be true if selection is inside a DOM root element - no focus', done => {
 			domRoot.focus();
 
+			// See the previous test.
+			viewDocument.selection._setTo( viewText, 1 );
+			domSelection.collapse( domText, 1 );
+
 			setTimeout( () => {
-				domSelection.collapse( domP, 0 );
+				// We could also do domRoot.blur() here but it's always better to know where the focus went.
+				// E.g. if it went to some <input>, the selection would disappear from the editor and the test would fail.
+				domRoot.blur();
 
+				// Wait for async selectionchange event on DOM document.
 				setTimeout( () => {
-					// We could also do domRoot.blur() here but it's always better to know where the focus went.
-					// E.g. if it went to some <input>, the selection would disappear from the editor and the test would fail.
-					domRoot.blur();
-
-					// Wait for async selectionchange event on DOM document.
-					setTimeout( () => {
-						expect( view.hasDomSelection ).to.be.true;
-						expect( view.document.isFocused ).to.be.false;
-
-						done();
-					}, 10 );
-				}, 10 );
-			}, 10 );
+					expect( view.hasDomSelection ).to.be.true;
+					expect( view.document.isFocused ).to.be.false;
+
+					done();
+				}, 100 );
+			}, 100 );
 		} );
 
 		it( 'should be false if selection is outside DOM root element', done => {

+ 1 - 1
packages/ckeditor5-font/tests/ui/colorui.js

@@ -304,7 +304,7 @@ describe( 'ColorUI', () => {
 						label: '#000'
 					},
 					{
-						color: 'rgb(255,255,255)',
+						color: 'rgb(255, 255, 255)',
 						label: 'Biały'
 					},
 					{

+ 0 - 1
packages/ckeditor5-highlight/src/highlightcommand.js

@@ -44,7 +44,6 @@ export default class HighlightCommand extends Command {
 	/**
 	 * Executes the command.
 	 *
-	 * @protected
 	 * @param {Object} [options] Options for the executed command.
 	 * @param {String} [options.value] The value to apply.
 	 *

+ 1 - 1
packages/ckeditor5-horizontal-line/theme/horizontalline.css

@@ -6,7 +6,7 @@
 
 .ck-editor__editable .ck-horizontal-line {
 	/* Necessary to render properly next to floated objects, e.g. side image case. */
-	overflow: hidden;
+	display: flow-root;
 }
 
 .ck-content hr {

+ 41 - 11
packages/ckeditor5-image/tests/image.js

@@ -62,8 +62,13 @@ describe( 'Image', () => {
 			setModelData( model, '[<image alt="alt text" src="/assets/sample.png"></image>]' );
 
 			expect( getViewData( view ) ).to.equal(
-				'[<figure class="ck-widget ck-widget_selected image" contenteditable="false">' +
+				'[<figure class="' +
+					'ck-widget ' +
+					'ck-widget_can-type-around_after ck-widget_can-type-around_before ' +
+					'ck-widget_selected image" contenteditable="false"' +
+				'>' +
 					'<img alt="alt text" src="/assets/sample.png"></img>' +
+					'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 				'</figure>]'
 			);
 
@@ -75,8 +80,13 @@ describe( 'Image', () => {
 			setModelData( model, '[<image src="/assets/sample.png" alt=""></image>]' );
 
 			expect( getViewData( view ) ).to.equal(
-				'[<figure class="ck-widget ck-widget_selected image" contenteditable="false">' +
-				'<img alt="" src="/assets/sample.png"></img>' +
+				'[<figure class="' +
+					'ck-widget ' +
+					'ck-widget_can-type-around_after ck-widget_can-type-around_before ' +
+					'ck-widget_selected image" contenteditable="false"' +
+				'>' +
+					'<img alt="" src="/assets/sample.png"></img>' +
+					'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 				'</figure>]'
 			);
 
@@ -91,11 +101,21 @@ describe( 'Image', () => {
 			);
 
 			expect( getViewData( view ) ).to.equal(
-				'[<figure class="ck-widget ck-widget_selected image" contenteditable="false">' +
-				'<img alt="alt text" src="/assets/sample.png"></img>' +
+				'[<figure class="' +
+					'ck-widget ' +
+					'ck-widget_can-type-around_after ck-widget_can-type-around_before ' +
+					'ck-widget_selected image" contenteditable="false"' +
+				'>' +
+					'<img alt="alt text" src="/assets/sample.png"></img>' +
+					'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 				'</figure>]' +
-				'<figure class="ck-widget image" contenteditable="false">' +
-				'<img alt="alt text" src="/assets/sample.png"></img>' +
+				'<figure class="' +
+					'ck-widget ' +
+					'ck-widget_can-type-around_after ck-widget_can-type-around_before ' +
+					'image" contenteditable="false"' +
+				'>' +
+					'<img alt="alt text" src="/assets/sample.png"></img>' +
+					'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 				'</figure>'
 			);
 
@@ -105,11 +125,21 @@ describe( 'Image', () => {
 			} );
 
 			expect( getViewData( view ) ).to.equal(
-				'<figure class="ck-widget image" contenteditable="false">' +
-				'<img alt="alt text" src="/assets/sample.png"></img>' +
+				'<figure class="' +
+					'ck-widget ' +
+					'ck-widget_can-type-around_after ck-widget_can-type-around_before ' +
+					'image" contenteditable="false"' +
+				'>' +
+					'<img alt="alt text" src="/assets/sample.png"></img>' +
+					'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 				'</figure>' +
-				'[<figure class="ck-widget ck-widget_selected image" contenteditable="false">' +
-				'<img alt="alt text" src="/assets/sample.png"></img>' +
+				'[<figure class="' +
+					'ck-widget ' +
+					'ck-widget_can-type-around_after ck-widget_can-type-around_before ' +
+					'ck-widget_selected image" contenteditable="false"' +
+				'>' +
+					'<img alt="alt text" src="/assets/sample.png"></img>' +
+					'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 				'</figure>]'
 			);
 		} );

+ 1 - 1
packages/ckeditor5-image/tests/imageresize.js

@@ -221,7 +221,7 @@ describe( 'ImageResize', () => {
 			const resizerPosition = 'bottom-left';
 			const domParts = getWidgetDomParts( editor, widget, resizerPosition );
 			const initialPointerPosition = getHandleCenterPoint( domParts.widget, resizerPosition );
-			const resizeWrapperView = widget.getChild( 1 );
+			const resizeWrapperView = widget.getChild( 2 );
 
 			resizerMouseSimulator.down( editor, domParts.resizeHandle );
 

+ 2 - 1
packages/ckeditor5-media-embed/package.json

@@ -26,7 +26,8 @@
     "@ckeditor/ckeditor5-link": "^19.0.0",
     "@ckeditor/ckeditor5-list": "^19.0.0",
     "@ckeditor/ckeditor5-paragraph": "^19.0.0",
-    "@ckeditor/ckeditor5-typing": "^19.0.0"
+    "@ckeditor/ckeditor5-typing": "^19.0.0",
+    "@ckeditor/ckeditor5-table": "^19.0.0"
   },
   "engines": {
     "node": ">=8.0.0",

+ 7 - 0
packages/ckeditor5-media-embed/src/automediaembed.js

@@ -129,11 +129,15 @@ export default class AutoMediaEmbed extends Plugin {
 
 		// If the URL does not match to universal URL regexp, let's skip that.
 		if ( !url.match( URL_REGEXP ) ) {
+			urlRange.detach();
+
 			return;
 		}
 
 		// If the URL represents a media, let's use it.
 		if ( !mediaRegistry.hasMedia( url ) ) {
+			urlRange.detach();
+
 			return;
 		}
 
@@ -141,6 +145,8 @@ export default class AutoMediaEmbed extends Plugin {
 
 		// Do not anything if media element cannot be inserted at the current position (#47).
 		if ( !mediaEmbedCommand.isEnabled ) {
+			urlRange.detach();
+
 			return;
 		}
 
@@ -153,6 +159,7 @@ export default class AutoMediaEmbed extends Plugin {
 				this._timeoutId = null;
 
 				writer.remove( urlRange );
+				urlRange.detach();
 
 				let insertionPosition;
 

+ 4 - 2
packages/ckeditor5-media-embed/src/converters.js

@@ -46,9 +46,11 @@ export function modelToViewUrlAttributeConverter( registry, options ) {
 		const url = data.attributeNewValue;
 		const viewWriter = conversionApi.writer;
 		const figure = conversionApi.mapper.toViewElement( data.item );
+		const mediaContentElement = [ ...figure.getChildren() ]
+			.find( child => child.getCustomProperty( 'media-content' ) );
 
-		// TODO: removing it and creating it from scratch is a hack. We can do better than that.
-		viewWriter.remove( viewWriter.createRangeIn( figure ) );
+		// TODO: removing the wrapper and creating it from scratch is a hack. We can do better than that.
+		viewWriter.remove( mediaContentElement );
 
 		const mediaViewElement = registry.getMediaViewElement( viewWriter, url, options );
 

+ 7 - 2
packages/ckeditor5-media-embed/src/mediaregistry.js

@@ -221,6 +221,7 @@ class Media {
 	 */
 	getViewElement( writer, options ) {
 		const attributes = {};
+		let viewElement;
 
 		if ( options.renderForEditingView || ( options.renderMediaPreview && this.url && this._previewRenderer ) ) {
 			if ( this.url ) {
@@ -233,7 +234,7 @@ class Media {
 
 			const mediaHtml = this._getPreviewHtml( options );
 
-			return writer.createUIElement( 'div', attributes, function( domDocument ) {
+			viewElement = writer.createUIElement( 'div', attributes, function( domDocument ) {
 				const domElement = this.toDomElement( domDocument );
 
 				domElement.innerHTML = mediaHtml;
@@ -245,8 +246,12 @@ class Media {
 				attributes.url = this.url;
 			}
 
-			return writer.createEmptyElement( 'oembed', attributes );
+			viewElement = writer.createEmptyElement( 'oembed', attributes );
 		}
+
+		writer.setCustomProperty( 'media-content', true, viewElement );
+
+		return viewElement;
 	}
 
 	/**

+ 34 - 0
packages/ckeditor5-media-embed/tests/automediaembed.js

@@ -17,6 +17,7 @@ import Undo from '@ckeditor/ckeditor5-undo/src/undo';
 import Typing from '@ckeditor/ckeditor5-typing/src/typing';
 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 { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
@@ -573,6 +574,39 @@ describe( 'AutoMediaEmbed - integration', () => {
 		} );
 	} );
 
+	it( 'should detach LiveRange', async () => {
+		const editor = await ClassicTestEditor.create( editorElement, {
+			plugins: [ MediaEmbed, AutoMediaEmbed, Link, List, Bold, Typing, Image, ImageCaption, Table ]
+		} );
+
+		setData(
+			editor.model,
+			'<table>' +
+				'<tableRow>' +
+					'[<tableCell><paragraph>foo</paragraph></tableCell>]' +
+					'[<tableCell><paragraph>bar</paragraph></tableCell>]' +
+				'</tableRow>' +
+			'</table>'
+		);
+
+		pasteHtml( editor, '<table><tr><td>one</td><td>two</td></tr></table>' );
+
+		expect( getData( editor.model, { withoutSelection: true } ) ).to.equal(
+			'<table>' +
+				'<tableRow>' +
+					'<tableCell><paragraph>one</paragraph></tableCell>' +
+					'<tableCell><paragraph>two</paragraph></tableCell>' +
+				'</tableRow>' +
+			'</table>'
+		);
+
+		expect( () => {
+			editor.setData( '' );
+		} ).not.to.throw();
+
+		await editor.destroy();
+	} );
+
 	function simulateTyping( text ) {
 		// While typing, every character is an atomic change.
 		text.split( '' ).forEach( character => {

+ 42 - 0
packages/ckeditor5-media-embed/tests/mediaembedediting.js

@@ -896,6 +896,48 @@ describe( 'MediaEmbedEditing', () => {
 							'</figure>'
 						);
 					} );
+
+					// Related to https://github.com/ckeditor/ckeditor5/issues/407.
+					it( 'should not discard internals (e.g. UI) injected by other features when converting the url attribute', () => {
+						setModelData( model, '<media url="https://ckeditor.com"></media>' );
+						const media = doc.getRoot().getChild( 0 );
+
+						editor.editing.view.change( writer => {
+							const widgetViewElement = editor.editing.mapper.toViewElement( media );
+
+							const externalUIElement = writer.createUIElement( 'div', null, function( domDocument ) {
+								const domElement = this.toDomElement( domDocument );
+
+								domElement.innerHTML = 'external UI';
+
+								return domElement;
+							} );
+
+							writer.insert( writer.createPositionAt( widgetViewElement, 'end' ), externalUIElement );
+						} );
+
+						expect( getViewData( view, { withoutSelection: true, renderUIElements: true } ) ).to.equal(
+							'<figure class="ck-widget media" contenteditable="false">' +
+								'<div class="ck-media__wrapper" data-oembed-url="https://ckeditor.com">' +
+									'allow-everything, id=https://ckeditor.com' +
+								'</div>' +
+								'<div>external UI</div>' +
+							'</figure>'
+						);
+
+						model.change( writer => {
+							writer.setAttribute( 'url', 'https://cksource.com', media );
+						} );
+
+						expect( getViewData( view, { withoutSelection: true, renderUIElements: true } ) ).to.equal(
+							'<figure class="ck-widget media" contenteditable="false">' +
+								'<div class="ck-media__wrapper" data-oembed-url="https://cksource.com">' +
+									'allow-everything, id=https://cksource.com' +
+								'</div>' +
+								'<div>external UI</div>' +
+							'</figure>'
+						);
+					} );
 				} );
 			}
 		} );

+ 49 - 0
packages/ckeditor5-paragraph/src/insertparagraphcommand.js

@@ -0,0 +1,49 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module paragraph/insertparagraphcommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+
+/**
+ * The insert paragraph command. It inserts a new paragraph at a specific
+ * {@link module:engine/model/position~Position document position}.
+ *
+ *		// Insert a new paragraph before an element in the document.
+ *		editor.execute( 'insertParagraph', {
+ *			position: editor.model.createPositionBefore( element )
+ *		} );
+ *
+ * **Note**: This command moves the selection to the inserted paragraph.
+ *
+ * @extends module:core/command~Command
+ */
+export default class InsertParagraphCommand extends Command {
+	/**
+	 * Executes the command.
+	 *
+	 * @param {Object} options Options for the executed command.
+	 * @param {module:engine/model/position~Position} options.position The model position at which
+	 * the new paragraph will be inserted.
+	 * @fires execute
+	 */
+	execute( options ) {
+		const model = this.editor.model;
+
+		if ( !model.schema.checkChild( options.position, 'paragraph' ) ) {
+			return;
+		}
+
+		model.change( writer => {
+			const paragraph = writer.createElement( 'paragraph' );
+
+			model.insertContent( paragraph, options.position );
+
+			writer.setSelection( paragraph, 'in' );
+		} );
+	}
+}

+ 9 - 0
packages/ckeditor5-paragraph/src/paragraph.js

@@ -8,6 +8,7 @@
  */
 
 import ParagraphCommand from './paragraphcommand';
+import InsertParagraphCommand from './insertparagraphcommand';
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 
@@ -16,6 +17,13 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
  *
  * It introduces the `<paragraph>` element in the model which renders as a `<p>` element in the DOM and data.
  *
+ * It also brings two editors commands:
+ *
+ * * The {@link module:paragraph/paragraphcommand~ParagraphCommand `'paragraph'`} command that converts all
+ * blocks in the model selection into paragraphs.
+ * * The {@link module:paragraph/insertparagraphcommand~InsertParagraphCommand `'insertParagraph'`} command
+ * that inserts a new paragraph at a specified location in the model.
+ *
  * @extends module:core/plugin~Plugin
  */
 export default class Paragraph extends Plugin {
@@ -35,6 +43,7 @@ export default class Paragraph extends Plugin {
 		const data = editor.data;
 
 		editor.commands.add( 'paragraph', new ParagraphCommand( editor ) );
+		editor.commands.add( 'insertParagraph', new InsertParagraphCommand( editor ) );
 
 		// Schema.
 		model.schema.register( 'paragraph', { inheritAllFrom: '$block' } );

+ 101 - 0
packages/ckeditor5-paragraph/tests/insertparagraphcommand.js

@@ -0,0 +1,101 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import InsertParagraphCommand from '../src/insertparagraphcommand';
+
+import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+describe( 'InsertParagraphCommand', () => {
+	let editor, model, document, command, root, schema;
+
+	beforeEach( () => {
+		return ModelTestEditor.create().then( newEditor => {
+			editor = newEditor;
+			model = editor.model;
+			document = model.document;
+			schema = model.schema;
+			command = new InsertParagraphCommand( editor );
+			root = document.getRoot();
+
+			editor.commands.add( 'insertParagraph', command );
+			schema.register( 'paragraph', { inheritAllFrom: '$block' } );
+			schema.register( 'heading1', { inheritAllFrom: '$block', allowIn: 'headersOnly' } );
+			schema.register( 'headersOnly', { inheritAllFrom: '$block' } );
+		} );
+	} );
+
+	afterEach( () => {
+		command.destroy();
+	} );
+
+	describe( 'execute()', () => {
+		it( 'should insert a paragraph at a specific document position and anchor the selection inside of it', () => {
+			setData( model, '<heading1>foo[]</heading1>' );
+
+			command.execute( {
+				position: model.createPositionBefore( root.getChild( 0 ) )
+			} );
+
+			expect( getData( model ) ).to.equal( '<paragraph>[]</paragraph><heading1>foo</heading1>' );
+		} );
+
+		it( 'should do nothing if the paragraph is not allowed at the provided position', () => {
+			setData( model, '<headersOnly><heading1>foo[]</heading1></headersOnly>' );
+
+			command.execute( {
+				position: model.createPositionBefore( root.getChild( 0 ).getChild( 0 ) )
+			} );
+
+			command.execute( {
+				position: model.createPositionAfter( root.getChild( 0 ).getChild( 0 ) )
+			} );
+
+			expect( getData( model ) ).to.equal( '<headersOnly><heading1>foo[]</heading1></headersOnly>' );
+		} );
+
+		describe( 'interation with existing paragraphs in the content', () => {
+			it( 'should insert a paragraph before another paragraph', () => {
+				setData( model, '<paragraph>foo[]</paragraph>' );
+
+				command.execute( {
+					position: model.createPositionBefore( root.getChild( 0 ) )
+				} );
+
+				expect( getData( model ) ).to.equal( '<paragraph>[]</paragraph><paragraph>foo</paragraph>' );
+			} );
+
+			it( 'should insert a paragraph after another paragraph', () => {
+				setData( model, '<paragraph>foo[]</paragraph>' );
+
+				command.execute( {
+					position: model.createPositionAfter( root.getChild( 0 ) )
+				} );
+
+				expect( getData( model ) ).to.equal( '<paragraph>foo</paragraph><paragraph>[]</paragraph>' );
+			} );
+
+			it( 'should not merge with a paragraph that precedes the position at which a new paragraph is inserted', () => {
+				setData( model, '<paragraph>bar</paragraph><heading1>foo[]</heading1>' );
+
+				command.execute( {
+					position: model.createPositionBefore( root.getChild( 1 ) )
+				} );
+
+				expect( getData( model ) ).to.equal( '<paragraph>bar</paragraph><paragraph>[]</paragraph><heading1>foo</heading1>' );
+			} );
+
+			it( 'should not merge with a paragraph that follows the position at which a new paragraph is inserted', () => {
+				setData( model, '<heading1>foo[]</heading1><paragraph>bar</paragraph>' );
+
+				command.execute( {
+					position: model.createPositionAfter( root.getChild( 0 ) )
+				} );
+
+				expect( getData( model ) ).to.equal( '<heading1>foo</heading1><paragraph>[]</paragraph><paragraph>bar</paragraph>' );
+			} );
+		} );
+	} );
+} );

+ 7 - 2
packages/ckeditor5-paragraph/tests/paragraph.js

@@ -5,6 +5,7 @@
 
 import Paragraph from '../src/paragraph';
 import ParagraphCommand from '../src/paragraphcommand';
+import InsertParagraphCommand from '../src/insertparagraphcommand';
 import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
 import {
 	getData as getModelData,
@@ -437,9 +438,13 @@ describe( 'Paragraph feature', () => {
 		} );
 	} );
 
-	describe( 'command', () => {
-		it( 'should be set in the editor', () => {
+	describe( 'commands', () => {
+		it( '"paragraph" command should be registered in the editor', () => {
 			expect( editor.commands.get( 'paragraph' ) ).to.be.instanceof( ParagraphCommand );
 		} );
+
+		it( '"insertParagraph" command should be registered in the editor', () => {
+			expect( editor.commands.get( 'insertParagraph' ) ).to.be.instanceof( InsertParagraphCommand );
+		} );
 	} );
 } );

+ 1 - 1
packages/ckeditor5-table/src/commands/insertrowcommand.js

@@ -78,6 +78,6 @@ export default class InsertRowCommand extends Command {
 		const row = insertAbove ? rowIndexes.first : rowIndexes.last;
 		const table = findAncestor( 'table', affectedTableCells[ 0 ] );
 
-		tableUtils.insertRows( table, { rows: 1, at: insertAbove ? row : row + 1 } );
+		tableUtils.insertRows( table, { at: insertAbove ? row : row + 1, copyStructureFromAbove: !insertAbove } );
 	}
 }

+ 4 - 129
packages/ckeditor5-table/src/commands/mergecellscommand.js

@@ -8,9 +8,9 @@
  */
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
-import { findAncestor, updateNumericAttribute } from './utils';
 import TableUtils from '../tableutils';
-import { getColumnIndexes, getRowIndexes, getSelectedTableCells } from '../utils';
+import { findAncestor, updateNumericAttribute } from './utils';
+import { isSelectionRectangular, getSelectedTableCells } from '../utils';
 
 /**
  * The merge cells command.
@@ -28,7 +28,8 @@ export default class MergeCellsCommand extends Command {
 	 * @inheritDoc
 	 */
 	refresh() {
-		this.isEnabled = canMergeCells( this.editor.model.document.selection, this.editor.plugins.get( TableUtils ) );
+		const selectedTableCells = getSelectedTableCells( this.editor.model.document.selection );
+		this.isEnabled = isSelectionRectangular( selectedTableCells, this.editor.plugins.get( TableUtils ) );
 	}
 
 	/**
@@ -107,132 +108,6 @@ function isEmpty( tableCell ) {
 	return tableCell.childCount == 1 && tableCell.getChild( 0 ).is( 'paragraph' ) && tableCell.getChild( 0 ).isEmpty;
 }
 
-// Checks if the selection contains cells that can be merged.
-//
-// In a table below:
-//
-//   ┌───┬───┬───┬───┐
-//   │ a │ b │ c │ d │
-//   ├───┴───┼───┤   │
-//   │ e     │ f │   │
-//   ├       ├───┼───┤
-//   │       │ g │ h │
-//   └───────┴───┴───┘
-//
-// Valid selections are these which create a solid rectangle (without gaps), such as:
-//   - a, b (two horizontal cells)
-//   - c, f (two vertical cells)
-//   - a, b, e (cell "e" spans over four cells)
-//   - c, d, f (cell d spans over a cell in the row below)
-//
-// While an invalid selection would be:
-//   - a, c (the unselected cell "b" creates a gap)
-//   - f, g, h (cell "d" spans over a cell from the row of "f" cell - thus creates a gap)
-//
-// @param {module:engine/model/selection~Selection} selection
-// @param {module:table/tableUtils~TableUtils} tableUtils
-// @returns {boolean}
-function canMergeCells( selection, tableUtils ) {
-	const selectedTableCells = getSelectedTableCells( selection );
-
-	if ( selectedTableCells.length < 2 || !areCellInTheSameTableSection( selectedTableCells ) ) {
-		return false;
-	}
-
-	// A valid selection is a fully occupied rectangle composed of table cells.
-	// Below we will calculate the area of a selected table cells and the area of valid selection.
-	// The area of a valid selection is defined by top-left and bottom-right cells.
-	const rows = new Set();
-	const columns = new Set();
-
-	let areaOfSelectedCells = 0;
-
-	for ( const tableCell of selectedTableCells ) {
-		const { row, column } = tableUtils.getCellLocation( tableCell );
-		const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
-		const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
-
-		// Record row & column indexes of current cell.
-		rows.add( row );
-		columns.add( column );
-
-		// For cells that spans over multiple rows add also the last row that this cell spans over.
-		if ( rowspan > 1 ) {
-			rows.add( row + rowspan - 1 );
-		}
-
-		// For cells that spans over multiple columns add also the last column that this cell spans over.
-		if ( colspan > 1 ) {
-			columns.add( column + colspan - 1 );
-		}
-
-		areaOfSelectedCells += ( rowspan * colspan );
-	}
-
-	// We can only merge table cells that are in adjacent rows...
-	const areaOfValidSelection = getBiggestRectangleArea( rows, columns );
-
-	return areaOfValidSelection == areaOfSelectedCells;
-}
-
-// Calculates the area of a maximum rectangle that can span over the provided row & column indexes.
-//
-// @param {Array.<Number>} rows
-// @param {Array.<Number>} columns
-// @returns {Number}
-function getBiggestRectangleArea( rows, columns ) {
-	const rowsIndexes = Array.from( rows.values() );
-	const columnIndexes = Array.from( columns.values() );
-
-	const lastRow = Math.max( ...rowsIndexes );
-	const firstRow = Math.min( ...rowsIndexes );
-	const lastColumn = Math.max( ...columnIndexes );
-	const firstColumn = Math.min( ...columnIndexes );
-
-	return ( lastRow - firstRow + 1 ) * ( lastColumn - firstColumn + 1 );
-}
-
-// Checks if the selection does not mix a header (column or row) with other cells.
-//
-// For instance, in the table below valid selections consist of cells with the same letter only.
-// So, a-a (same heading row and column) or d-d (body cells) are valid while c-d or a-b are not.
-//
-//    header columns
-//     ↓   ↓
-//   ┌───┬───┬───┬───┐
-//   │ a │ a │ b │ b │  ← header row
-//   ├───┼───┼───┼───┤
-//   │ c │ c │ d │ d │
-//   ├───┼───┼───┼───┤
-//   │ c │ c │ d │ d │
-//   └───┴───┴───┴───┘
-//
-function areCellInTheSameTableSection( tableCells ) {
-	const table = findAncestor( 'table', tableCells[ 0 ] );
-
-	const rowIndexes = getRowIndexes( tableCells );
-	const headingRows = parseInt( table.getAttribute( 'headingRows' ) || 0 );
-
-	// Calculating row indexes is a bit cheaper so if this check fails we can't merge.
-	if ( !areIndexesInSameSection( rowIndexes, headingRows ) ) {
-		return false;
-	}
-
-	const headingColumns = parseInt( table.getAttribute( 'headingColumns' ) || 0 );
-	const columnIndexes = getColumnIndexes( tableCells );
-
-	// Similarly cells must be in same column section.
-	return areIndexesInSameSection( columnIndexes, headingColumns );
-}
-
-// Unified check if table rows/columns indexes are in the same heading/body section.
-function areIndexesInSameSection( { first, last }, headingSectionSize ) {
-	const firstCellIsInHeading = first < headingSectionSize;
-	const lastCellIsInHeading = last < headingSectionSize;
-
-	return firstCellIsInHeading === lastCellIsInHeading;
-}
-
 function getMergeDimensions( firstTableCell, selectedTableCells, tableUtils ) {
 	let maxWidthOffset = 0;
 	let maxHeightOffset = 0;

+ 12 - 3
packages/ckeditor5-table/src/commands/setheadercolumncommand.js

@@ -10,7 +10,7 @@
 import Command from '@ckeditor/ckeditor5-core/src/command';
 
 import { findAncestor, isHeadingColumnCell, updateNumericAttribute } from './utils';
-import { getColumnIndexes, getSelectionAffectedTableCells } from '../utils';
+import { getColumnIndexes, getSelectionAffectedTableCells, getHorizontallyOverlappingCells, splitVertically } from '../utils';
 
 /**
  * The header column command.
@@ -69,12 +69,21 @@ export default class SetHeaderColumnCommand extends Command {
 
 		const model = this.editor.model;
 		const selectedCells = getSelectionAffectedTableCells( model.document.selection );
-		const { first, last } = getColumnIndexes( selectedCells );
+		const table = findAncestor( 'table', selectedCells[ 0 ] );
 
+		const { first, last } = getColumnIndexes( selectedCells );
 		const headingColumnsToSet = this.value ? first : last + 1;
 
 		model.change( writer => {
-			const table = findAncestor( 'table', selectedCells[ 0 ] );
+			if ( headingColumnsToSet ) {
+				// Changing heading columns requires to check if any of a heading cell is overlapping horizontally the table head.
+				// Any table cell that has a colspan attribute > 1 will not exceed the table head so we need to fix it in columns before.
+				const overlappingCells = getHorizontallyOverlappingCells( table, headingColumnsToSet );
+
+				for ( const { cell, column } of overlappingCells ) {
+					splitVertically( cell, column, headingColumnsToSet, writer );
+				}
+			}
 
 			updateNumericAttribute( 'headingColumns', headingColumnsToSet, table, writer, 0 );
 		} );

+ 5 - 79
packages/ckeditor5-table/src/commands/setheaderrowcommand.js

@@ -9,9 +9,8 @@
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
 
-import { createEmptyTableCell, findAncestor, updateNumericAttribute } from './utils';
-import { getRowIndexes, getSelectionAffectedTableCells } from '../utils';
-import TableWalker from '../tablewalker';
+import { findAncestor, updateNumericAttribute } from './utils';
+import { getVerticallyOverlappingCells, getRowIndexes, getSelectionAffectedTableCells, splitHorizontally } from '../utils';
 
 /**
  * The header row command.
@@ -77,9 +76,10 @@ export default class SetHeaderRowCommand extends Command {
 			if ( headingRowsToSet ) {
 				// Changing heading rows requires to check if any of a heading cell is overlapping vertically the table head.
 				// Any table cell that has a rowspan attribute > 1 will not exceed the table head so we need to fix it in rows below.
-				const cellsToSplit = getOverlappingCells( table, headingRowsToSet, currentHeadingRows );
+				const startRow = headingRowsToSet > currentHeadingRows ? currentHeadingRows : 0;
+				const overlappingCells = getVerticallyOverlappingCells( table, headingRowsToSet, startRow );
 
-				for ( const cell of cellsToSplit ) {
+				for ( const { cell } of overlappingCells ) {
 					splitHorizontally( cell, headingRowsToSet, writer );
 				}
 			}
@@ -102,77 +102,3 @@ export default class SetHeaderRowCommand extends Command {
 		return !!headingRows && tableCell.parent.index < headingRows;
 	}
 }
-
-// Returns cells that span beyond the new heading section.
-//
-// @param {module:engine/model/element~Element} table The table to check.
-// @param {Number} headingRowsToSet New heading rows attribute.
-// @param {Number} currentHeadingRows Current heading rows attribute.
-// @returns {Array.<module:engine/model/element~Element>}
-function getOverlappingCells( table, headingRowsToSet, currentHeadingRows ) {
-	const cellsToSplit = [];
-
-	const startAnalysisRow = headingRowsToSet > currentHeadingRows ? currentHeadingRows : 0;
-	// We're analyzing only when headingRowsToSet > 0.
-	const endAnalysisRow = headingRowsToSet - 1;
-
-	const tableWalker = new TableWalker( table, { startRow: startAnalysisRow, endRow: endAnalysisRow } );
-
-	for ( const { row, rowspan, cell } of tableWalker ) {
-		if ( rowspan > 1 && row + rowspan > headingRowsToSet ) {
-			cellsToSplit.push( cell );
-		}
-	}
-
-	return cellsToSplit;
-}
-
-// Splits the table cell horizontally.
-//
-// @param {module:engine/model/element~Element} tableCell
-// @param {Number} headingRows
-// @param {module:engine/model/writer~Writer} writer
-function splitHorizontally( tableCell, headingRows, writer ) {
-	const tableRow = tableCell.parent;
-	const table = tableRow.parent;
-	const rowIndex = tableRow.index;
-
-	const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) );
-	const newRowspan = headingRows - rowIndex;
-
-	const attributes = {};
-
-	const spanToSet = rowspan - newRowspan;
-
-	if ( spanToSet > 1 ) {
-		attributes.rowspan = spanToSet;
-	}
-
-	const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
-
-	if ( colspan > 1 ) {
-		attributes.colspan = colspan;
-	}
-
-	const startRow = table.getChildIndex( tableRow );
-	const endRow = startRow + newRowspan;
-	const tableMap = [ ...new TableWalker( table, { startRow, endRow, includeSpanned: true } ) ];
-
-	let columnIndex;
-
-	for ( const { row, column, cell, cellIndex } of tableMap ) {
-		if ( cell === tableCell && columnIndex === undefined ) {
-			columnIndex = column;
-		}
-
-		if ( columnIndex !== undefined && columnIndex === column && row === endRow ) {
-			const tableRow = table.getChild( row );
-			const tableCellPosition = writer.createPositionAt( tableRow, cellIndex );
-
-			createEmptyTableCell( writer, tableCellPosition, attributes );
-		}
-	}
-
-	// Update the rowspan attribute after updating table.
-	updateNumericAttribute( 'rowspan', newRowspan, tableCell, writer );
-}

+ 495 - 2
packages/ckeditor5-table/src/tableclipboard.js

@@ -8,7 +8,22 @@
  */
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+
 import TableSelection from './tableselection';
+import TableWalker from './tablewalker';
+import {
+	getColumnIndexes,
+	getVerticallyOverlappingCells,
+	getRowIndexes,
+	getSelectionAffectedTableCells,
+	getHorizontallyOverlappingCells,
+	isSelectionRectangular,
+	splitHorizontally,
+	splitVertically
+} from './utils';
+import { findAncestor } from './commands/utils';
+import { cropTableToDimensions, trimTableCellIfNeeded } from './tableselection/croptable';
+import TableUtils from './tableutils';
 
 /**
  * This plugin adds support for copying/cutting/pasting fragments of tables.
@@ -28,7 +43,7 @@ export default class TableClipboard extends Plugin {
 	 * @inheritDoc
 	 */
 	static get requires() {
-		return [ TableSelection ];
+		return [ TableSelection, TableUtils ];
 	}
 
 	/**
@@ -40,6 +55,7 @@ export default class TableClipboard extends Plugin {
 
 		this.listenTo( viewDocument, 'copy', ( evt, data ) => this._onCopyCut( evt, data ) );
 		this.listenTo( viewDocument, 'cut', ( evt, data ) => this._onCopyCut( evt, data ) );
+		this.listenTo( editor.model, 'insertContent', ( evt, args ) => this._onInsertContent( evt, ...args ), { priority: 'high' } );
 	}
 
 	/**
@@ -50,7 +66,7 @@ export default class TableClipboard extends Plugin {
 	 * @param {Object} data Clipboard event data.
 	 */
 	_onCopyCut( evt, data ) {
-		const tableSelection = this.editor.plugins.get( 'TableSelection' );
+		const tableSelection = this.editor.plugins.get( TableSelection );
 
 		if ( !tableSelection.getSelectedTableCells() ) {
 			return;
@@ -74,4 +90,481 @@ export default class TableClipboard extends Plugin {
 			method: evt.name
 		} );
 	}
+
+	/**
+	 * Overrides default {@link module:engine/model/model~Model#insertContent `model.insertContent()`} method to handle pasting table inside
+	 * selected table fragment.
+	 *
+	 * Depending on selected table fragment:
+	 * - If a selected table fragment is smaller than paste table it will crop pasted table to match dimensions.
+	 * - If dimensions are equal it will replace selected table fragment with a pasted table contents.
+	 *
+	 * @private
+	 * @param evt
+	 * @param {module:engine/model/documentfragment~DocumentFragment|module:engine/model/item~Item} content The content to insert.
+	 * @param {module:engine/model/selection~Selectable} [selectable=model.document.selection]
+	 * The selection into which the content should be inserted. If not provided the current model document selection will be used.
+	 */
+	_onInsertContent( evt, content, selectable ) {
+		if ( selectable && !selectable.is( 'documentSelection' ) ) {
+			return;
+		}
+
+		const model = this.editor.model;
+		const tableUtils = this.editor.plugins.get( TableUtils );
+
+		const selectedTableCells = getSelectionAffectedTableCells( model.document.selection );
+
+		if ( !selectedTableCells.length ) {
+			return;
+		}
+
+		// We might need to crop table before inserting so reference might change.
+		let pastedTable = getTableIfOnlyTableInContent( content );
+
+		if ( !pastedTable ) {
+			return;
+		}
+
+		// Override default model.insertContent() handling at this point.
+		evt.stop();
+
+		model.change( writer => {
+			const columnIndexes = getColumnIndexes( selectedTableCells );
+			const rowIndexes = getRowIndexes( selectedTableCells );
+
+			let { first: firstColumnOfSelection, last: lastColumnOfSelection } = columnIndexes;
+			let { first: firstRowOfSelection, last: lastRowOfSelection } = rowIndexes;
+
+			const pasteHeight = tableUtils.getRows( pastedTable );
+			const pasteWidth = tableUtils.getColumns( pastedTable );
+
+			// Content table to which we insert a pasted table.
+			const selectedTable = findAncestor( 'table', selectedTableCells[ 0 ] );
+
+			// Single cell selected - expand selection to pasted table dimensions.
+			const shouldExpandSelection = selectedTableCells.length === 1;
+
+			if ( shouldExpandSelection ) {
+				lastRowOfSelection += pasteHeight - 1;
+				lastColumnOfSelection += pasteWidth - 1;
+
+				expandTableSize( selectedTable, lastRowOfSelection + 1, lastColumnOfSelection + 1, writer, tableUtils );
+			}
+
+			// In case of expanding selection we do not reset the selection so in this case we will always try to fix selection
+			// like in the case of a non-rectangular area. This might be fixed by re-setting selected cells array but this shortcut is safe.
+			if ( shouldExpandSelection || !isSelectionRectangular( selectedTableCells, tableUtils ) ) {
+				const splitDimensions = {
+					firstRow: firstRowOfSelection,
+					lastRow: lastRowOfSelection,
+					firstColumn: firstColumnOfSelection,
+					lastColumn: lastColumnOfSelection
+				};
+
+				// For a non-rectangular selection (ie in which some cells sticks out from a virtual selection rectangle) we need to create
+				// a table layout that has a rectangular selection. This will split cells so the selection become rectangular.
+				// Beyond this point we will operate on fixed content table.
+				splitCellsToRectangularSelection( selectedTable, splitDimensions, writer );
+			}
+			// However a selected table fragment might be invalid if examined alone. Ie such table fragment:
+			//
+			//    +---+---+---+---+
+			//  0 | a | b | c | d |
+			//    +   +   +---+---+
+			//  1 |   | e | f | g |
+			//    +   +---+   +---+
+			//  2 |   | h |   | i | <- last row, each cell has rowspan = 2,
+			//    +   +   +   +   +    so we need to return 3, not 2
+			//  3 |   |   |   |   |
+			//    +---+---+---+---+
+			//
+			// is invalid as the cells "h" and "i" have rowspans.
+			// This case needs only adjusting the selection dimension as the rest of the algorithm operates on empty slots also.
+			else {
+				lastRowOfSelection = adjustLastRowIndex( selectedTable, rowIndexes, columnIndexes );
+				lastColumnOfSelection = adjustLastColumnIndex( selectedTable, rowIndexes, columnIndexes );
+			}
+
+			// Beyond this point we operate on a fixed content table with rectangular selection and proper last row/column values.
+
+			const selectionHeight = lastRowOfSelection - firstRowOfSelection + 1;
+			const selectionWidth = lastColumnOfSelection - firstColumnOfSelection + 1;
+
+			// Crop pasted table if:
+			// - Pasted table dimensions exceeds selection area.
+			// - Pasted table has broken layout (ie some cells sticks out by the table dimensions established by the first and last row).
+			//
+			// Note: The table dimensions are established by the width of the first row and the total number of rows.
+			// It is possible to programmatically create a table that has rows which would have cells anchored beyond first row width but
+			// such table will not be created by other editing solutions.
+			const cropDimensions = {
+				startRow: 0,
+				startColumn: 0,
+				endRow: Math.min( selectionHeight - 1, pasteHeight - 1 ),
+				endColumn: Math.min( selectionWidth - 1, pasteWidth - 1 )
+			};
+
+			pastedTable = cropTableToDimensions( pastedTable, cropDimensions, writer, tableUtils );
+
+			const pastedDimensions = {
+				width: pasteWidth,
+				height: pasteHeight
+			};
+			const selectionDimensions = {
+				firstColumnOfSelection,
+				firstRowOfSelection,
+				lastColumnOfSelection,
+				lastRowOfSelection
+			};
+
+			replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selectedTable, selectionDimensions, writer );
+		} );
+	}
+}
+
+// Replaces the part of selectedTable with pastedTable.
+//
+// @param {module:engine/model/element~Element} pastedTable
+// @param {Object} pastedDimensions
+// @param {Number} pastedDimensions.height
+// @param {Number} pastedDimensions.width
+// @param {module:engine/model/element~Element} selectedTable
+// @param {Object} selectionDimensions
+// @param {Number} selectionDimensions.firstColumnOfSelection
+// @param {Number} selectionDimensions.firstRowOfSelection
+// @param {Number} selectionDimensions.lastColumnOfSelection
+// @param {Number} selectionDimensions.lastRowOfSelection
+// @param {module:engine/model/writer~Writer} writer
+function replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selectedTable, selectionDimensions, writer ) {
+	const {
+		firstColumnOfSelection, lastColumnOfSelection,
+		firstRowOfSelection, lastRowOfSelection
+	} = selectionDimensions;
+
+	const { width: pastedWidth, height: pastedHeight } = pastedDimensions;
+
+	// Holds two-dimensional array that is addressed by [ row ][ column ] that stores cells anchored at given location.
+	const pastedTableLocationMap = createLocationMap( pastedTable, pastedWidth, pastedHeight );
+
+	const selectedTableMap = [ ...new TableWalker( selectedTable, {
+		startRow: firstRowOfSelection,
+		endRow: lastRowOfSelection,
+		includeSpanned: true
+	} ) ];
+
+	// Selection must be set to pasted cells (some might be removed or new created).
+	const cellsToSelect = [];
+
+	// Store previous cell in order to insert a new table cells after it (if required).
+	let previousCellInRow;
+
+	// Content table replace cells algorithm iterates over a selected table fragment and:
+	//
+	// - Removes existing table cells at current slot (location).
+	// - Inserts cell from a pasted table for a matched slots.
+	//
+	// This ensures proper table geometry after the paste
+	for ( const { row, column, cell, isSpanned } of selectedTableMap ) {
+		if ( column === 0 ) {
+			previousCellInRow = null;
+		}
+
+		// Could use startColumn, endColumn. See: https://github.com/ckeditor/ckeditor5/issues/6785.
+		if ( column < firstColumnOfSelection || column > lastColumnOfSelection ) {
+			// Only update the previousCellInRow for non-spanned slots.
+			if ( !isSpanned ) {
+				previousCellInRow = cell;
+			}
+
+			continue;
+		}
+
+		// If the slot is occupied by a cell in a selected table - remove it.
+		// The slot of this cell will be either:
+		// - Replaced by a pasted table cell.
+		// - Spanned by a previously pasted table cell.
+		if ( !isSpanned ) {
+			writer.remove( cell );
+		}
+
+		// Map current table slot location to an pasted table slot location.
+		const pastedRow = row - firstRowOfSelection;
+		const pastedColumn = column - firstColumnOfSelection;
+		const pastedCell = pastedTableLocationMap[ pastedRow % pastedHeight ][ pastedColumn % pastedWidth ];
+
+		// There is no cell to insert (might be spanned by other cell in a pasted table) - advance to the next content table slot.
+		if ( !pastedCell ) {
+			continue;
+		}
+
+		// Clone cell to insert (to duplicate its attributes and children).
+		// Cloning is required to support repeating pasted table content when inserting to a bigger selection.
+		const cellToInsert = pastedCell._clone( true );
+
+		// Trim the cell if it's row/col-spans would exceed selection area.
+		trimTableCellIfNeeded( cellToInsert, row, column, lastRowOfSelection, lastColumnOfSelection, writer );
+
+		let insertPosition;
+
+		if ( !previousCellInRow ) {
+			insertPosition = writer.createPositionAt( selectedTable.getChild( row ), 0 );
+		} else {
+			insertPosition = writer.createPositionAfter( previousCellInRow );
+		}
+
+		writer.insert( cellToInsert, insertPosition );
+		cellsToSelect.push( cellToInsert );
+		previousCellInRow = cellToInsert;
+	}
+
+	writer.setSelection( cellsToSelect.map( cell => writer.createRangeOn( cell ) ) );
+}
+
+// Expand table (in place) to expected size.
+function expandTableSize( table, expectedHeight, expectedWidth, writer, tableUtils ) {
+	const tableWidth = tableUtils.getColumns( table );
+	const tableHeight = tableUtils.getRows( table );
+
+	if ( expectedWidth > tableWidth ) {
+		tableUtils.insertColumns( table, {
+			batch: writer.batch,
+			at: tableWidth,
+			columns: expectedWidth - tableWidth
+		} );
+	}
+
+	if ( expectedHeight > tableHeight ) {
+		tableUtils.insertRows( table, {
+			batch: writer.batch,
+			at: tableHeight,
+			rows: expectedHeight - tableHeight
+		} );
+	}
+}
+
+function getTableIfOnlyTableInContent( content ) {
+	// Table passed directly.
+	if ( content.is( 'table' ) ) {
+		return content;
+	}
+
+	// We do not support mixed content when pasting table into table.
+	// See: https://github.com/ckeditor/ckeditor5/issues/6817.
+	if ( content.childCount != 1 || !content.getChild( 0 ).is( 'table' ) ) {
+		return null;
+	}
+
+	return content.getChild( 0 );
+}
+
+// Returns two-dimensional array that is addressed by [ row ][ column ] that stores cells anchored at given location.
+//
+// At given row & column location it might be one of:
+//
+// * cell - cell from pasted table anchored at this location.
+// * null - if no cell is anchored at this location.
+//
+// For instance, from a table below:
+//
+//		+----+----+----+----+
+//		| 00 | 01 | 02 | 03 |
+//		+    +----+----+----+
+//		|    | 11      | 13 |
+//		+----+         +----+
+//		| 20 |         | 23 |
+//		+----+----+----+----+
+//
+// The method will return an array (numbers represents cell element):
+//
+//	const map = [
+//		[ '00', '01', '02', '03' ],
+//		[ null, '11', null, '13' ],
+//		[ '20', null, null, '23' ]
+//	]
+//
+// This allows for a quick access to table at give row & column. For instance to access table cell "13" from pasted table call:
+//
+//		const cell = map[ 1 ][ 3 ]
+//
+function createLocationMap( table, width, height ) {
+	// Create height x width (row x column) two-dimensional table to store cells.
+	const map = new Array( height ).fill( null )
+		.map( () => new Array( width ).fill( null ) );
+
+	for ( const { column, row, cell } of new TableWalker( table ) ) {
+		map[ row ][ column ] = cell;
+	}
+
+	return map;
+}
+
+// Make selected cells rectangular by splitting the cells that stand out from a rectangular selection.
+//
+// In the table below a selection is shown with "::" and slots with anchor cells are named.
+//
+// +----+----+----+----+----+                    +----+----+----+----+----+
+// | 00 | 01 | 02 | 03      |                    | 00 | 01 | 02 | 03      |
+// +    +----+    +----+----+                    |    ::::::::::::::::----+
+// |    | 11 |    | 13 | 14 |                    |    ::11 |    | 13:: 14 |    <- first row
+// +----+----+    +    +----+                    +----::---|    |   ::----+
+// | 20 | 21 |    |    | 24 |   select cells:    | 20 ::21 |    |   :: 24 |
+// +----+----+    +----+----+     11 -> 33       +----::---|    |---::----+
+// | 30      |    | 33 | 34 |                    | 30 ::   |    | 33:: 34 |    <- last row
+// +         +    +----+    +                    |    ::::::::::::::::    +
+// |         |    | 43 |    |                    |         |    | 43 |    |
+// +----+----+----+----+----+                    +----+----+----+----+----+
+//                                                      ^          ^
+//                                                     first & last columns
+//
+// Will update table to:
+//
+//                       +----+----+----+----+----+
+//                       | 00 | 01 | 02 | 03      |
+//                       +    +----+----+----+----+
+//                       |    | 11 |    | 13 | 14 |
+//                       +----+----+    +    +----+
+//                       | 20 | 21 |    |    | 24 |
+//                       +----+----+    +----+----+
+//                       | 30 |    |    | 33 | 34 |
+//                       +    +----+----+----+    +
+//                       |    |    |    | 43 |    |
+//                       +----+----+----+----+----+
+//
+// In th example above:
+// - Cell "02" which have `rowspan = 4` must be trimmed at first and at after last row.
+// - Cell "03" which have `rowspan = 2` and `colspan = 2` must be trimmed at first column and after last row.
+// - Cells "00", "03" & "30" which cannot be cut by this algorithm as they are outside the trimmed area.
+// - Cell "13" cannot be cut as it is inside the trimmed area.
+function splitCellsToRectangularSelection( table, dimensions, writer ) {
+	const { firstRow, lastRow, firstColumn, lastColumn } = dimensions;
+
+	const rowIndexes = { first: firstRow, last: lastRow };
+	const columnIndexes = { first: firstColumn, last: lastColumn };
+
+	// 1. Split cells vertically in two steps as first step might create cells that needs to split again.
+	doVerticalSplit( table, firstColumn, rowIndexes, writer );
+	doVerticalSplit( table, lastColumn + 1, rowIndexes, writer );
+
+	// 2. Split cells horizontally in two steps as first step might create cells that needs to split again.
+	doHorizontalSplit( table, firstRow, columnIndexes, writer );
+	doHorizontalSplit( table, lastRow + 1, columnIndexes, writer, firstRow );
+}
+
+function doHorizontalSplit( table, splitRow, limitColumns, writer, startRow = 0 ) {
+	// If selection starts at first row then no split is needed.
+	if ( splitRow < 1 ) {
+		return;
+	}
+
+	const overlappingCells = getVerticallyOverlappingCells( table, splitRow, startRow );
+
+	// Filter out cells that are not touching insides of the rectangular selection.
+	const cellsToSplit = overlappingCells.filter( ( { column, colspan } ) => isAffectedBySelection( column, colspan, limitColumns ) );
+
+	for ( const { cell } of cellsToSplit ) {
+		splitHorizontally( cell, splitRow, writer );
+	}
+}
+
+function doVerticalSplit( table, splitColumn, limitRows, writer ) {
+	// If selection starts at first column then no split is needed.
+	if ( splitColumn < 1 ) {
+		return;
+	}
+
+	const overlappingCells = getHorizontallyOverlappingCells( table, splitColumn );
+
+	// Filter out cells that are not touching insides of the rectangular selection.
+	const cellsToSplit = overlappingCells.filter( ( { row, rowspan } ) => isAffectedBySelection( row, rowspan, limitRows ) );
+
+	for ( const { cell, column } of cellsToSplit ) {
+		splitVertically( cell, column, splitColumn, writer );
+	}
+}
+
+// Checks if cell at given row (column) is affected by a rectangular selection defined by first/last column (row).
+//
+// The same check is used for row as for column.
+function isAffectedBySelection( index, span, limit ) {
+	const endIndex = index + span - 1;
+	const { first, last } = limit;
+
+	const isInsideSelection = index >= first && index <= last;
+	const overlapsSelectionFromOutside = index < first && endIndex >= first;
+
+	return isInsideSelection || overlapsSelectionFromOutside;
+}
+
+// Returns adjusted last row index if selection covers part of a row with empty slots (spanned by other cells).
+// The rowIndexes.last is equal to last row index but selection might be bigger.
+//
+// This happens *only* on rectangular selection so we analyze a case like this:
+//
+//    +---+---+---+---+
+//  0 | a | b | c | d |
+//    +   +   +---+---+
+//  1 |   | e | f | g |
+//    +   +---+   +---+
+//  2 |   | h |   | i | <- last row, each cell has rowspan = 2,
+//    +   +   +   +   +    so we need to return 3, not 2
+//  3 |   |   |   |   |
+//    +---+---+---+---+
+function adjustLastRowIndex( table, rowIndexes, columnIndexes ) {
+	const tableIterator = new TableWalker( table, {
+		startRow: rowIndexes.last,
+		endRow: rowIndexes.last
+	} );
+
+	const lastRowMap = Array.from( tableIterator ).filter( ( { column } ) => {
+		// Could use startColumn, endColumn. See: https://github.com/ckeditor/ckeditor5/issues/6785.
+		return columnIndexes.first <= column && column <= columnIndexes.last;
+	} );
+
+	const everyCellHasSingleRowspan = lastRowMap.every( ( { rowspan } ) => rowspan === 1 );
+
+	// It is a "flat" row, so the last row index is OK.
+	if ( everyCellHasSingleRowspan ) {
+		return rowIndexes.last;
+	}
+
+	// Otherwise get any cell's rowspan and adjust the last row index.
+	const rowspanAdjustment = lastRowMap[ 0 ].rowspan - 1;
+	return rowIndexes.last + rowspanAdjustment;
+}
+
+// Returns adjusted last column index if selection covers part of a column with empty slots (spanned by other cells).
+// The columnIndexes.last is equal to last column index but selection might be bigger.
+//
+// This happens *only* on rectangular selection so we analyze a case like this:
+//
+//   0   1   2   3
+// +---+---+---+---+
+// | a             |
+// +---+---+---+---+
+// | b | c | d     |
+// +---+---+---+---+
+// | e     | f     |
+// +---+---+---+---+
+// | g | h         |
+// +---+---+---+---+
+//           ^
+//          last column, each cell has colspan = 2, so we need to return 3, not 2
+function adjustLastColumnIndex( table, rowIndexes, columnIndexes ) {
+	const lastColumnMap = Array.from( new TableWalker( table, {
+		startRow: rowIndexes.first,
+		endRow: rowIndexes.last,
+		column: columnIndexes.last
+	} ) );
+
+	const everyCellHasSingleColspan = lastColumnMap.every( ( { colspan } ) => colspan === 1 );
+
+	// It is a "flat" column, so the last column index is OK.
+	if ( everyCellHasSingleColspan ) {
+		return columnIndexes.last;
+	}
+
+	// Otherwise get any cell's colspan and adjust the last column index.
+	const colspanAdjustment = lastColumnMap[ 0 ].colspan - 1;
+	return columnIndexes.last + colspanAdjustment;
 }

+ 16 - 6
packages/ckeditor5-table/src/tableselection.js

@@ -13,12 +13,9 @@ import first from '@ckeditor/ckeditor5-utils/src/first';
 import TableWalker from './tablewalker';
 import TableUtils from './tableutils';
 import MouseEventsObserver from './tableselection/mouseeventsobserver';
-import {
-	getSelectedTableCells,
-	getTableCellsContainingSelection
-} from './utils';
+import { getColumnIndexes, getRowIndexes, getSelectedTableCells, getTableCellsContainingSelection } from './utils';
 import { findAncestor } from './commands/utils';
-import cropTable from './tableselection/croptable';
+import { cropTableToDimensions } from './tableselection/croptable';
 
 import '../theme/tableselection.css';
 
@@ -99,7 +96,20 @@ export default class TableSelection extends Plugin {
 
 		return this.editor.model.change( writer => {
 			const documentFragment = writer.createDocumentFragment();
-			const table = cropTable( selectedCells, this.editor.plugins.get( 'TableUtils' ), writer );
+
+			const { first: startColumn, last: endColumn } = getColumnIndexes( selectedCells );
+			const { first: startRow, last: endRow } = getRowIndexes( selectedCells );
+
+			const sourceTable = findAncestor( 'table', selectedCells[ 0 ] );
+
+			const cropDimensions = {
+				startRow,
+				startColumn,
+				endRow,
+				endColumn
+			};
+
+			const table = cropTableToDimensions( sourceTable, cropDimensions, writer, this.editor.plugins.get( 'TableUtils' ) );
 
 			writer.insert( table, documentFragment, 0 );
 

+ 100 - 99
packages/ckeditor5-table/src/tableselection/croptable.js

@@ -7,146 +7,147 @@
  * @module table/tableselection/croptable
  */
 
-import { findAncestor } from '../commands/utils';
+import { createEmptyTableCell, updateNumericAttribute } from '../commands/utils';
+import TableWalker from '../tablewalker';
 
 /**
- * Returns a cropped table from the selected table cells.
- *
- * This function is to be used with the table selection.
+ * Returns a cropped table according to given dimensions.
+
+ * To return a cropped table that starts at first row and first column and end in third row and column:
  *
- *		tableSelection.startSelectingFrom( startCell )
- *		tableSelection.setSelectingFrom( endCell )
+ *		const croppedTable = cropTableToDimensions( table, {
+ *			startRow: 1,
+ *			endRow: 1,
+ *			startColumn: 3,
+ *			endColumn: 3
+ *		}, tableUtils, writer );
  *
- *		const croppedTable = cropTable( tableSelection.getSelectedTableCells() );
+ * Calling the code above for the table below:
  *
- * **Note**: This function is also used by {@link module:table/tableselection~TableSelection#getSelectionAsFragment}.
+ *		      0   1   2   3   4                      0   1   2
+ *		    ┌───┬───┬───┬───┬───┐
+ *		 0  │ a │ b │ c │ d │ e │
+ *		    ├───┴───┤   ├───┴───┤                  ┌───┬───┬───┐
+ *		 1  │ f     │   │ g     │                  │   │   │ g │  0
+ *		    ├───┬───┴───┼───┬───┤   will return:   ├───┴───┼───┤
+ *		 2  │ h │ i     │ j │ k │                  │ i     │ j │  1
+ *		    ├───┤       ├───┤   │                  │       ├───┤
+ *		 3  │ l │       │ m │   │                  │       │ m │  2
+ *		    ├───┼───┬───┤   ├───┤                  └───────┴───┘
+ *		 4  │ n │ o │ p │   │ q │
+ *		    └───┴───┴───┴───┴───┘
  *
- * @param {Iterable.<module:engine/model/element~Element>} selectedTableCellsIterator
- * @param {module:table/tableutils~TableUtils} tableUtils
+ * @param {module:engine/model/element~Element} sourceTable
+ * @param {Object} cropDimensions
+ * @param {Number} cropDimensions.startRow
+ * @param {Number} cropDimensions.startColumn
+ * @param {Number} cropDimensions.endRow
+ * @param {Number} cropDimensions.endColumn
  * @param {module:engine/model/writer~Writer} writer
+ * @param {module:table/tableutils~TableUtils} tableUtils
  * @returns {module:engine/model/element~Element}
  */
-export default function cropTable( selectedTableCellsIterator, tableUtils, writer ) {
-	const selectedTableCells = Array.from( selectedTableCellsIterator );
-	const startElement = selectedTableCells[ 0 ];
-	const endElement = selectedTableCells[ selectedTableCells.length - 1 ];
-
-	const { row: startRow, column: startColumn } = tableUtils.getCellLocation( startElement );
-
-	const tableCopy = makeTableCopy( selectedTableCells, startColumn, writer, tableUtils );
-
-	const { row: endRow, column: endColumn } = tableUtils.getCellLocation( endElement );
-	const selectionWidth = endColumn - startColumn + 1;
-	const selectionHeight = endRow - startRow + 1;
+export function cropTableToDimensions( sourceTable, cropDimensions, writer, tableUtils ) {
+	const { startRow, startColumn, endRow, endColumn } = cropDimensions;
 
-	trimTable( tableCopy, selectionWidth, selectionHeight, writer, tableUtils );
+	// Create empty table with empty rows equal to crop height.
+	const croppedTable = writer.createElement( 'table' );
+	const cropHeight = endRow - startRow + 1;
 
-	const sourceTable = findAncestor( 'table', startElement );
-	addHeadingsToTableCopy( tableCopy, sourceTable, startRow, startColumn, writer );
+	for ( let i = 0; i < cropHeight; i++ ) {
+		writer.insertElement( 'tableRow', croppedTable, 'end' );
+	}
 
-	return tableCopy;
-}
+	const tableMap = [ ...new TableWalker( sourceTable, { startRow, endRow, includeSpanned: true } ) ];
 
-// Creates a table copy from a selected table cells.
-//
-// It fills "gaps" in copied table - ie when cell outside copied range was spanning over selection.
-function makeTableCopy( selectedTableCells, startColumn, writer, tableUtils ) {
-	const tableCopy = writer.createElement( 'table' );
+	// Iterate over source table slots (including empty - spanned - ones).
+	for ( const { row: sourceRow, column: sourceColumn, cell: tableCell, isSpanned } of tableMap ) {
+		// Skip slots outside the cropped area.
+		// Could use startColumn, endColumn. See: https://github.com/ckeditor/ckeditor5/issues/6785.
+		if ( sourceColumn < startColumn || sourceColumn > endColumn ) {
+			continue;
+		}
 
-	const rowToCopyMap = new Map();
-	const copyToOriginalColumnMap = new Map();
+		// Row index in cropped table.
+		const rowInCroppedTable = sourceRow - startRow;
+		const row = croppedTable.getChild( rowInCroppedTable );
 
-	for ( const tableCell of selectedTableCells ) {
-		const row = findAncestor( 'tableRow', tableCell );
+		// For empty slots: fill the gap with empty table cell.
+		if ( isSpanned ) {
+			// TODO: Remove table utils usage. See: https://github.com/ckeditor/ckeditor5/issues/6785.
+			const { row: anchorRow, column: anchorColumn } = tableUtils.getCellLocation( tableCell );
 
-		if ( !rowToCopyMap.has( row ) ) {
-			const rowCopy = row._clone();
-			writer.append( rowCopy, tableCopy );
-			rowToCopyMap.set( row, rowCopy );
+			// But fill the gap only if the spanning cell is anchored outside cropped area.
+			// In the table from method jsdoc those cells are: "c" & "f".
+			if ( anchorRow < startRow || anchorColumn < startColumn ) {
+				createEmptyTableCell( writer, writer.createPositionAt( row, 'end' ) );
+			}
 		}
+		// Otherwise clone the cell with all children and trim if it exceeds cropped area.
+		else {
+			const tableCellCopy = tableCell._clone( true );
 
-		const tableCellCopy = tableCell._clone( true );
-		const { column } = tableUtils.getCellLocation( tableCell );
+			writer.append( tableCellCopy, row );
 
-		copyToOriginalColumnMap.set( tableCellCopy, column );
-
-		writer.append( tableCellCopy, rowToCopyMap.get( row ) );
+			// Trim table if it exceeds cropped area.
+			// In the table from method jsdoc those cells are: "g" & "m".
+			trimTableCellIfNeeded( tableCellCopy, sourceRow, sourceColumn, endRow, endColumn, writer );
+		}
 	}
 
-	addMissingTableCells( tableCopy, startColumn, copyToOriginalColumnMap, writer, tableUtils );
+	// Adjust heading rows & columns in cropped table if crop selection includes headings parts.
+	addHeadingsToCroppedTable( croppedTable, sourceTable, startRow, startColumn, writer );
 
-	return tableCopy;
+	return croppedTable;
 }
 
-// Fills gaps for spanned cell from outside the selection range.
-function addMissingTableCells( tableCopy, startColumn, copyToOriginalColumnMap, writer, tableUtils ) {
-	for ( const row of tableCopy.getChildren() ) {
-		for ( const tableCell of Array.from( row.getChildren() ) ) {
-			const { column } = tableUtils.getCellLocation( tableCell );
-
-			const originalColumn = copyToOriginalColumnMap.get( tableCell );
-			const shiftedColumn = originalColumn - startColumn;
+/**
+ * Adjusts table cell dimensions to not exceed limit row and column.
+ *
+ * If table cell width (or height) covers a column (or row) that is after a limit column (or row)
+ * this method will trim "colspan" (or "rowspan") attribute so the table cell will fit in a defined limits.
+ *
+ * @param {module:engine/model/element~Element} tableCell
+ * @param {Number} cellRow
+ * @param {Number} cellColumn
+ * @param {Number} limitRow
+ * @param {Number} limitColumn
+ * @param {module:engine/model/writer~Writer} writer
+ */
+export function trimTableCellIfNeeded( tableCell, cellRow, cellColumn, limitRow, limitColumn, writer ) {
+	const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
+	const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
 
-			if ( column !== shiftedColumn ) {
-				for ( let i = 0; i < shiftedColumn - column; i++ ) {
-					const prepCell = writer.createElement( 'tableCell' );
-					writer.insert( prepCell, writer.createPositionBefore( tableCell ) );
+	const endColumn = cellColumn + colspan - 1;
 
-					const paragraph = writer.createElement( 'paragraph' );
+	if ( endColumn > limitColumn ) {
+		const trimmedSpan = limitColumn - cellColumn + 1;
 
-					writer.insert( paragraph, prepCell, 0 );
-					writer.insertText( '', paragraph, 0 );
-				}
-			}
-		}
+		updateNumericAttribute( 'colspan', trimmedSpan, tableCell, writer, 1 );
 	}
-}
-
-// Trims table to a given dimensions.
-function trimTable( table, width, height, writer, tableUtils ) {
-	for ( const row of table.getChildren() ) {
-		for ( const tableCell of row.getChildren() ) {
-			const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
-			const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
 
-			const { row, column } = tableUtils.getCellLocation( tableCell );
+	const endRow = cellRow + rowspan - 1;
 
-			if ( column + colspan > width ) {
-				const newSpan = width - column;
-
-				if ( newSpan > 1 ) {
-					writer.setAttribute( 'colspan', newSpan, tableCell );
-				} else {
-					writer.removeAttribute( 'colspan', tableCell );
-				}
-			}
+	if ( endRow > limitRow ) {
+		const trimmedSpan = limitRow - cellRow + 1;
 
-			if ( row + rowspan > height ) {
-				const newSpan = height - row;
-
-				if ( newSpan > 1 ) {
-					writer.setAttribute( 'rowspan', newSpan, tableCell );
-				} else {
-					writer.removeAttribute( 'rowspan', tableCell );
-				}
-			}
-		}
+		updateNumericAttribute( 'rowspan', trimmedSpan, tableCell, writer, 1 );
 	}
 }
 
-// Sets proper heading attributes to copied table.
-function addHeadingsToTableCopy( tableCopy, sourceTable, startRow, startColumn, writer ) {
+// Sets proper heading attributes to a cropped table.
+function addHeadingsToCroppedTable( croppedTable, sourceTable, startRow, startColumn, writer ) {
 	const headingRows = parseInt( sourceTable.getAttribute( 'headingRows' ) || 0 );
 
 	if ( headingRows > 0 ) {
-		const copiedRows = headingRows - startRow;
-		writer.setAttribute( 'headingRows', copiedRows, tableCopy );
+		const headingRowsInCrop = headingRows - startRow;
+		updateNumericAttribute( 'headingRows', headingRowsInCrop, croppedTable, writer, 0 );
 	}
 
 	const headingColumns = parseInt( sourceTable.getAttribute( 'headingColumns' ) || 0 );
 
 	if ( headingColumns > 0 ) {
-		const copiedColumns = headingColumns - startColumn;
-		writer.setAttribute( 'headingColumns', copiedColumns, tableCopy );
+		const headingColumnsInCrop = headingColumns - startColumn;
+		updateNumericAttribute( 'headingColumns', headingColumnsInCrop, croppedTable, writer, 0 );
 	}
 }

+ 47 - 20
packages/ckeditor5-table/src/tableutils.js

@@ -116,12 +116,19 @@ export default class TableUtils extends Plugin {
 	 * @param {Object} options
 	 * @param {Number} [options.at=0] The row index at which the rows will be inserted.
 	 * @param {Number} [options.rows=1] The number of rows to insert.
+	 * @param {Boolean|undefined} [options.copyStructureFromAbove] The flag for copying row structure. Note that
+	 * the row structure will not be copied if this option is not provided.
 	 */
 	insertRows( table, options = {} ) {
 		const model = this.editor.model;
 
 		const insertAt = options.at || 0;
 		const rowsToInsert = options.rows || 1;
+		const isCopyStructure = options.copyStructureFromAbove !== undefined;
+		const copyStructureFrom = options.copyStructureFromAbove ? insertAt - 1 : insertAt;
+
+		const rows = this.getRows( table );
+		const columns = this.getColumns( table );
 
 		model.change( writer => {
 			const headingRows = table.getAttribute( 'headingRows' ) || 0;
@@ -131,38 +138,58 @@ export default class TableUtils extends Plugin {
 				writer.setAttribute( 'headingRows', headingRows + rowsToInsert, table );
 			}
 
-			// Inserting at the end and at the beginning of a table doesn't require to calculate anything special.
-			if ( insertAt === 0 || insertAt === table.childCount ) {
-				createEmptyRows( writer, table, insertAt, rowsToInsert, this.getColumns( table ) );
+			// Inserting at the end or at the beginning of a table doesn't require to calculate anything special.
+			if ( !isCopyStructure && ( insertAt === 0 || insertAt === rows ) ) {
+				createEmptyRows( writer, table, insertAt, rowsToInsert, columns );
 
 				return;
 			}
 
-			// Iterate over all rows above inserted rows in order to check for rowspanned cells.
-			const tableIterator = new TableWalker( table, { endRow: insertAt } );
+			// Iterate over all the rows above the inserted rows in order to check for the row-spanned cells.
+			const walkerEndRow = isCopyStructure ? Math.max( insertAt, copyStructureFrom ) : insertAt;
+			const tableIterator = new TableWalker( table, { endRow: walkerEndRow } );
+
+			// Store spans of the reference row to reproduce it's structure. This array is column number indexed.
+			const rowColSpansMap = new Array( columns ).fill( 1 );
 
-			// Will hold number of cells needed to insert in created rows.
-			// The number might be different then table cell width when there are rowspanned cells.
-			let cellsToInsert = 0;
+			for ( const { row, column, rowspan, colspan, cell } of tableIterator ) {
+				const lastCellRow = row + rowspan - 1;
 
-			for ( const { row, rowspan, colspan, cell } of tableIterator ) {
-				const isBeforeInsertedRow = row < insertAt;
-				const overlapsInsertedRow = row + rowspan > insertAt;
+				const isOverlappingInsertedRow = row < insertAt && insertAt <= lastCellRow;
+				const isReferenceRow = row <= copyStructureFrom && copyStructureFrom <= lastCellRow;
 
-				if ( isBeforeInsertedRow && overlapsInsertedRow ) {
-					// This cell overlaps inserted rows so we need to expand it further.
+				// If the cell is row-spanned and overlaps the inserted row, then reserve space for it in the row map.
+				if ( isOverlappingInsertedRow ) {
+					// This cell overlaps the inserted rows so we need to expand it further.
 					writer.setAttribute( 'rowspan', rowspan + rowsToInsert, cell );
-				}
 
-				// Calculate how many cells to insert based on the width of cells in a row at insert position.
-				// It might be lower then table width as some cells might overlaps inserted row.
-				// In the table above the cell 'a' overlaps inserted row so only two empty cells are need to be created.
-				if ( row === insertAt ) {
-					cellsToInsert += colspan;
+					// Mark this cell with negative number to indicate how many cells should be skipped when adding the new cells.
+					rowColSpansMap[ column ] = -colspan;
+				}
+				// Store the colspan from reference row.
+				else if ( isCopyStructure && isReferenceRow ) {
+					rowColSpansMap[ column ] = colspan;
 				}
 			}
 
-			createEmptyRows( writer, table, insertAt, rowsToInsert, cellsToInsert );
+			for ( let rowIndex = 0; rowIndex < rowsToInsert; rowIndex++ ) {
+				const tableRow = writer.createElement( 'tableRow' );
+
+				writer.insert( tableRow, table, insertAt );
+
+				for ( let cellIndex = 0; cellIndex < rowColSpansMap.length; cellIndex++ ) {
+					const colspan = rowColSpansMap[ cellIndex ];
+					const insertPosition = writer.createPositionAt( tableRow, 'end' );
+
+					// Insert the empty cell only if this slot is not row-spanned from any other cell.
+					if ( colspan > 0 ) {
+						createEmptyTableCell( writer, insertPosition, colspan > 1 ? { colspan } : null );
+					}
+
+					// Skip the col-spanned slots, there won't be any cells.
+					cellIndex += Math.abs( colspan ) - 1;
+				}
+			}
 		} );
 	}
 

+ 19 - 1
packages/ckeditor5-table/src/ui/colorinputview.js

@@ -294,8 +294,9 @@ export default class ColorInputView extends View {
 	 */
 	_setInputValue( inputValue ) {
 		if ( !this._stillTyping ) {
+			const normalizedInputValue = normalizeColor( inputValue );
 			// Check if the value matches one of our defined colors.
-			const mappedColor = this.options.colorDefinitions.find( def => inputValue === def.color );
+			const mappedColor = this.options.colorDefinitions.find( def => normalizedInputValue === normalizeColor( def.color ) );
 
 			if ( mappedColor ) {
 				this._inputView.value = mappedColor.label;
@@ -305,3 +306,20 @@ export default class ColorInputView extends View {
 		}
 	}
 }
+
+// Normalizes color value, by stripping extensive whitespace.
+// For example., transforms:
+// * `   rgb(  25 50    0 )` to `rgb(25 50 0)`,
+// * "\t  rgb(  25 ,  50,0 )		" to `rgb(25 50 0)`.
+//
+// @param {String} colorString The value to be normalized.
+// @returns {String}
+function normalizeColor( colorString ) {
+	return colorString
+		// Remove any whitespace right after `(` or `,`.
+		.replace( /([(,])\s+/g, '$1' )
+		// Remove any whitespace at the beginning or right before the end, `)`, `,`, or another whitespace.
+		.replace( /^\s+|\s+(?=[),\s]|$)/g, '' )
+		// Then, replace `,` or whitespace with a single space.
+		.replace( /,|\s/g, ' ' );
+}

+ 6 - 1
packages/ckeditor5-table/src/ui/utils.js

@@ -16,6 +16,7 @@ import { isColor, isLength, isPercentage } from '@ckeditor/ckeditor5-engine/src/
 import { getTableWidgetAncestor } from '../utils';
 import { findAncestor } from '../commands/utils';
 import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
+import { centeredBalloonPositionForLongWidgets } from '@ckeditor/ckeditor5-widget/src/utils';
 
 const DEFAULT_BALLOON_POSITIONS = BalloonPanelView.defaultPositions;
 const BALLOON_POSITIONS = [
@@ -26,6 +27,10 @@ const BALLOON_POSITIONS = [
 	DEFAULT_BALLOON_POSITIONS.southArrowNorthWest,
 	DEFAULT_BALLOON_POSITIONS.southArrowNorthEast
 ];
+const TABLE_PROPERTRIES_BALLOON_POSITIONS = [
+	...BALLOON_POSITIONS,
+	centeredBalloonPositionForLongWidgets
+];
 
 const isEmpty = val => val === '';
 
@@ -69,7 +74,7 @@ export function getBalloonTablePositionData( editor ) {
 
 	return {
 		target: editor.editing.view.domConverter.viewToDom( viewTable ),
-		positions: BALLOON_POSITIONS
+		positions: TABLE_PROPERTRIES_BALLOON_POSITIONS
 	};
 }
 

+ 292 - 1
packages/ckeditor5-table/src/utils.js

@@ -8,7 +8,7 @@
  */
 
 import { isWidget, toWidget } from '@ckeditor/ckeditor5-widget/src/utils';
-import { findAncestor } from './commands/utils';
+import { createEmptyTableCell, findAncestor, updateNumericAttribute } from './commands/utils';
 import TableWalker from './tablewalker';
 
 /**
@@ -179,6 +179,239 @@ export function getColumnIndexes( tableCells ) {
 	return getFirstLastIndexesObject( indexes );
 }
 
+/**
+ * Checks if the selection contains cells that do not exceed rectangular selection.
+ *
+ * In a table below:
+ *
+ *   ┌───┬───┬───┬───┐
+ *   │ a │ b │ c │ d │
+ *   ├───┴───┼───┤   │
+ *   │ e     │ f │   │
+ *   │       ├───┼───┤
+ *   │       │ g │ h │
+ *   └───────┴───┴───┘
+ *
+ * Valid selections are these which create a solid rectangle (without gaps), such as:
+ *   - a, b (two horizontal cells)
+ *   - c, f (two vertical cells)
+ *   - a, b, e (cell "e" spans over four cells)
+ *   - c, d, f (cell d spans over a cell in the row below)
+ *
+ * While an invalid selection would be:
+ *   - a, c (the unselected cell "b" creates a gap)
+ *   - f, g, h (cell "d" spans over a cell from the row of "f" cell - thus creates a gap)
+ *
+ * @param {Array.<module:engine/model/element~Element>} selectedTableCells
+ * @param {module:table/tableutils~TableUtils} tableUtils
+ * @returns {Boolean}
+ */
+export function isSelectionRectangular( selectedTableCells, tableUtils ) {
+	if ( selectedTableCells.length < 2 || !areCellInTheSameTableSection( selectedTableCells ) ) {
+		return false;
+	}
+
+	// A valid selection is a fully occupied rectangle composed of table cells.
+	// Below we will calculate the area of a selected table cells and the area of valid selection.
+	// The area of a valid selection is defined by top-left and bottom-right cells.
+	const rows = new Set();
+	const columns = new Set();
+
+	let areaOfSelectedCells = 0;
+
+	for ( const tableCell of selectedTableCells ) {
+		const { row, column } = tableUtils.getCellLocation( tableCell );
+		const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
+		const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
+
+		// Record row & column indexes of current cell.
+		rows.add( row );
+		columns.add( column );
+
+		// For cells that spans over multiple rows add also the last row that this cell spans over.
+		if ( rowspan > 1 ) {
+			rows.add( row + rowspan - 1 );
+		}
+
+		// For cells that spans over multiple columns add also the last column that this cell spans over.
+		if ( colspan > 1 ) {
+			columns.add( column + colspan - 1 );
+		}
+
+		areaOfSelectedCells += ( rowspan * colspan );
+	}
+
+	// We can only merge table cells that are in adjacent rows...
+	const areaOfValidSelection = getBiggestRectangleArea( rows, columns );
+
+	return areaOfValidSelection == areaOfSelectedCells;
+}
+
+/**
+ * Returns slot info of cells that starts above and overlaps a given row.
+ *
+ * In a table below, passing `overlapRow = 3`
+ *
+ *       ┌───┬───┬───┬───┬───┐
+ *    0  │ a │ b │ c │ d │ e │
+ *       │   ├───┼───┼───┼───┤
+ *    1  │   │ f │ g │ h │ i │
+ *       ├───┤   ├───┼───┤   │
+ *    2  │ j │   │ k │ l │   │
+ *       │   │   │   ├───┼───┤
+ *    3  │   │   │   │ m │ n │  <- overlap row to check
+ *       ├───┼───┤   │   ├───│
+ *    4  │ o │ p │   │   │ q │
+ *       └───┴───┴───┴───┴───┘
+ *
+ * will return slot info for cells: "j", "f", "k".
+ *
+ * @param {module:engine/model/element~Element} table The table to check.
+ * @param {Number} overlapRow The index of the row to check.
+ * @param {Number} [startRow=0] A row to start analysis. Use it when it is known that the cells above that row will not overlap.
+ * @returns {Array.<module:table/tablewalker~TableWalkerValue>}
+ */
+export function getVerticallyOverlappingCells( table, overlapRow, startRow = 0 ) {
+	const cells = [];
+
+	const tableWalker = new TableWalker( table, { startRow, endRow: overlapRow - 1 } );
+
+	for ( const slotInfo of tableWalker ) {
+		const { row, rowspan } = slotInfo;
+		const cellEndRow = row + rowspan - 1;
+
+		if ( row < overlapRow && overlapRow <= cellEndRow ) {
+			cells.push( slotInfo );
+		}
+	}
+
+	return cells;
+}
+
+/**
+ * Splits the table cell horizontally.
+ *
+ * @param {module:engine/model/element~Element} tableCell
+ * @param {Number} splitRow
+ * @param {module:engine/model/writer~Writer} writer
+ */
+export function splitHorizontally( tableCell, splitRow, writer ) {
+	const tableRow = tableCell.parent;
+	const table = tableRow.parent;
+	const rowIndex = tableRow.index;
+
+	const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) );
+	const newRowspan = splitRow - rowIndex;
+
+	const newCellAttributes = {};
+	const newCellRowSpan = rowspan - newRowspan;
+
+	if ( newCellRowSpan > 1 ) {
+		newCellAttributes.rowspan = newCellRowSpan;
+	}
+
+	const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
+
+	if ( colspan > 1 ) {
+		newCellAttributes.colspan = colspan;
+	}
+
+	const startRow = rowIndex;
+	const endRow = startRow + newRowspan;
+	const tableMap = [ ...new TableWalker( table, { startRow, endRow, includeSpanned: true } ) ];
+
+	let columnIndex;
+
+	for ( const { row, column, cell, cellIndex } of tableMap ) {
+		if ( cell === tableCell && columnIndex === undefined ) {
+			columnIndex = column;
+		}
+
+		if ( columnIndex !== undefined && columnIndex === column && row === endRow ) {
+			const tableRow = table.getChild( row );
+			const tableCellPosition = writer.createPositionAt( tableRow, cellIndex );
+
+			createEmptyTableCell( writer, tableCellPosition, newCellAttributes );
+		}
+	}
+
+	// Update the rowspan attribute after updating table.
+	updateNumericAttribute( 'rowspan', newRowspan, tableCell, writer );
+}
+
+/**
+ * Returns slot info of cells that starts before and overlaps a given column.
+ *
+ * In a table below, passing `overlapColumn = 3`
+ *
+ *      0   1   2   3   4
+ *    ┌───────┬───────┬───┐
+ *    │ a     │ b     │ c │
+ *    │───┬───┴───────┼───┤
+ *    │ d │ e         │ f │
+ *    ├───┼───┬───────┴───┤
+ *    │ g │ h │ i         │
+ *    ├───┼───┼───┬───────┤
+ *    │ j │ k │ l │ m     │
+ *    ├───┼───┴───┼───┬───┤
+ *    │ n │ o     │ p │ q │
+ *    └───┴───────┴───┴───┘
+ *                  ^
+ *                  Overlap column to check
+ *
+ * will return slot info for cells: "b", "e", "i".
+ *
+ * @param {module:engine/model/element~Element} table The table to check.
+ * @param {Number} overlapColumn The index of the column to check.
+ * @returns {Array.<module:table/tablewalker~TableWalkerValue>}
+ */
+export function getHorizontallyOverlappingCells( table, overlapColumn ) {
+	const cellsToSplit = [];
+
+	const tableWalker = new TableWalker( table );
+
+	for ( const slotInfo of tableWalker ) {
+		const { column, colspan } = slotInfo;
+		const cellEndColumn = column + colspan - 1;
+
+		if ( column < overlapColumn && overlapColumn <= cellEndColumn ) {
+			cellsToSplit.push( slotInfo );
+		}
+	}
+
+	return cellsToSplit;
+}
+
+/**
+ * Splits the table cell vertically.
+ *
+ * @param {module:engine/model/element~Element} tableCell
+ * @param {Number} columnIndex The table cell column index.
+ * @param {Number} splitColumn The index of column to split cell on.
+ * @param {module:engine/model/writer~Writer} writer
+ */
+export function splitVertically( tableCell, columnIndex, splitColumn, writer ) {
+	const colspan = parseInt( tableCell.getAttribute( 'colspan' ) );
+	const newColspan = splitColumn - columnIndex;
+
+	const newCellAttributes = {};
+	const newCellColSpan = colspan - newColspan;
+
+	if ( newCellColSpan > 1 ) {
+		newCellAttributes.colspan = newCellColSpan;
+	}
+
+	const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
+
+	if ( rowspan > 1 ) {
+		newCellAttributes.rowspan = rowspan;
+	}
+
+	createEmptyTableCell( writer, writer.createPositionAfter( tableCell ), newCellAttributes );
+	// Update the colspan attribute after updating table.
+	updateNumericAttribute( 'colspan', newColspan, tableCell, writer );
+}
+
 // Helper method to get an object with `first` and `last` indexes from an unsorted array of indexes.
 function getFirstLastIndexesObject( indexes ) {
 	const allIndexesSorted = indexes.sort( ( indexA, indexB ) => indexA - indexB );
@@ -203,3 +436,61 @@ function compareRangeOrder( rangeA, rangeB ) {
 	// b. Collapsed range on the same position (allowed by model but should not happen).
 	return posA.isBefore( posB ) ? -1 : 1;
 }
+
+// Calculates the area of a maximum rectangle that can span over the provided row & column indexes.
+//
+// @param {Array.<Number>} rows
+// @param {Array.<Number>} columns
+// @returns {Number}
+function getBiggestRectangleArea( rows, columns ) {
+	const rowsIndexes = Array.from( rows.values() );
+	const columnIndexes = Array.from( columns.values() );
+
+	const lastRow = Math.max( ...rowsIndexes );
+	const firstRow = Math.min( ...rowsIndexes );
+	const lastColumn = Math.max( ...columnIndexes );
+	const firstColumn = Math.min( ...columnIndexes );
+
+	return ( lastRow - firstRow + 1 ) * ( lastColumn - firstColumn + 1 );
+}
+
+// Checks if the selection does not mix a header (column or row) with other cells.
+//
+// For instance, in the table below valid selections consist of cells with the same letter only.
+// So, a-a (same heading row and column) or d-d (body cells) are valid while c-d or a-b are not.
+//
+//    header columns
+//     ↓   ↓
+//   ┌───┬───┬───┬───┐
+//   │ a │ a │ b │ b │  ← header row
+//   ├───┼───┼───┼───┤
+//   │ c │ c │ d │ d │
+//   ├───┼───┼───┼───┤
+//   │ c │ c │ d │ d │
+//   └───┴───┴───┴───┘
+//
+function areCellInTheSameTableSection( tableCells ) {
+	const table = findAncestor( 'table', tableCells[ 0 ] );
+
+	const rowIndexes = getRowIndexes( tableCells );
+	const headingRows = parseInt( table.getAttribute( 'headingRows' ) || 0 );
+
+	// Calculating row indexes is a bit cheaper so if this check fails we can't merge.
+	if ( !areIndexesInSameSection( rowIndexes, headingRows ) ) {
+		return false;
+	}
+
+	const headingColumns = parseInt( table.getAttribute( 'headingColumns' ) || 0 );
+	const columnIndexes = getColumnIndexes( tableCells );
+
+	// Similarly cells must be in same column section.
+	return areIndexesInSameSection( columnIndexes, headingColumns );
+}
+
+// Unified check if table rows/columns indexes are in the same heading/body section.
+function areIndexesInSameSection( { first, last }, headingSectionSize ) {
+	const firstCellIsInHeading = first < headingSectionSize;
+	const lastCellIsInHeading = last < headingSectionSize;
+
+	return firstCellIsInHeading === lastCellIsInHeading;
+}

+ 54 - 0
packages/ckeditor5-table/tests/commands/insertrowcommand.js

@@ -232,6 +232,33 @@ describe( 'InsertRowCommand', () => {
 					[ '', '' ]
 				] ) );
 			} );
+
+			it( 'should copy the row structure from the selected row', () => {
+				// +----+----+----+
+				// | 00 | 01      |
+				// +----+----+----+
+				// | 10 | 11 | 12 |
+				// +----+----+----+
+				setData( model, modelTable( [
+					[ '[]00', { contents: '01', colspan: 2 } ],
+					[ '10', '11', '12' ]
+				] ) );
+
+				command.execute();
+
+				// +----+----+----+
+				// | 00 | 01      |
+				// +----+----+----+
+				// |    |         |
+				// +----+----+----+
+				// | 10 | 11 | 12 |
+				// +----+----+----+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '00', { contents: '01', colspan: 2 } ],
+					[ '', { contents: '', colspan: 2 } ],
+					[ '10', '11', '12' ]
+				] ) );
+			} );
 		} );
 	} );
 
@@ -367,6 +394,33 @@ describe( 'InsertRowCommand', () => {
 					[ 0, 0 ]
 				] );
 			} );
+
+			it( 'should copy the row structure from the selected row', () => {
+				// +----+----+----+
+				// | 00 | 01      |
+				// +----+----+----+
+				// | 10 | 11 | 12 |
+				// +----+----+----+
+				setData( model, modelTable( [
+					[ '[]00', { contents: '01', colspan: 2 } ],
+					[ '10', '11', '12' ]
+				] ) );
+
+				command.execute();
+
+				// +----+----+----+
+				// |    |         |
+				// +----+----+----+
+				// | 00 | 01      |
+				// +----+----+----+
+				// | 10 | 11 | 12 |
+				// +----+----+----+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '', { contents: '', colspan: 2 } ],
+					[ '00', { contents: '01', colspan: 2 } ],
+					[ '10', '11', '12' ]
+				] ) );
+			} );
 		} );
 	} );
 } );

+ 104 - 2
packages/ckeditor5-table/tests/commands/setheadercolumncommand.js

@@ -198,6 +198,18 @@ describe( 'SetHeaderColumnCommand', () => {
 			], { headingColumns: 1 } ) );
 		} );
 
+		it( 'should remove "headingColumns" attribute from table if no value was given', () => {
+			setData( model, modelTable( [
+				[ '[]00', '01', '02', '03' ]
+			], { headingColumns: 3 } ) );
+
+			command.execute();
+
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ '[]00', '01', '02', '03' ]
+			] ) );
+		} );
+
 		describe( 'multi-cell selection', () => {
 			describe( 'setting header', () => {
 				it( 'should set it correctly in a middle of single-row, multiple cell selection', () => {
@@ -404,7 +416,7 @@ describe( 'SetHeaderColumnCommand', () => {
 			], { headingColumns: 2 } ) );
 		} );
 
-		it( 'should respect forceValue parameter #1', () => {
+		it( 'should respect forceValue parameter (forceValue=true)', () => {
 			setData( model, modelTable( [
 				[ '00', '01[]', '02', '03' ]
 			], { headingColumns: 3 } ) );
@@ -416,7 +428,7 @@ describe( 'SetHeaderColumnCommand', () => {
 			], { headingColumns: 3 } ) );
 		} );
 
-		it( 'should respect forceValue parameter #2', () => {
+		it( 'should respect forceValue parameter (forceValue=false)', () => {
 			setData( model, modelTable( [
 				[ '00', '01[]', '02', '03' ]
 			], { headingColumns: 1 } ) );
@@ -427,5 +439,95 @@ describe( 'SetHeaderColumnCommand', () => {
 				[ '00', '01[]', '02', '03' ]
 			], { headingColumns: 1 } ) );
 		} );
+
+		it( 'should fix col-spanned cells on the edge of an table heading columns section', () => {
+			// +----+----+----+
+			// | 00 | 01      |
+			// +----+         +
+			// | 10 |         |
+			// +----+----+----+
+			// | 20 | 21 | 22 |
+			// +----+----+----+
+			//      ^-- heading columns
+			setData( model, modelTable( [
+				[ '00', { contents: '[]01', colspan: 2, rowspan: 2 } ],
+				[ '10' ],
+				[ '20', '21', '22' ]
+			], { headingColumns: 1 } ) );
+
+			command.execute();
+
+			// +----+----+----+
+			// | 00 | 01 |    |
+			// +----+    +    +
+			// | 10 |    |    |
+			// +----+----+----+
+			// | 20 | 21 | 22 |
+			// +----+----+----+
+			//           ^-- heading columns
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ '00', { contents: '[]01', rowspan: 2 }, { contents: '', rowspan: 2 } ],
+				[ '10' ],
+				[ '20', '21', '22' ]
+			], { headingColumns: 2 } ) );
+		} );
+
+		it( 'should split to at most 2 table cells when fixing col-spanned cells on the edge of an table heading columns section', () => {
+			// +----+----+----+----+----+----+
+			// | 00 | 01                     |
+			// +----+                        +
+			// | 10 |                        |
+			// +----+----+----+----+----+----+
+			// | 20 | 21 | 22 | 23 | 24 | 25 |
+			// +----+----+----+----+----+----+
+			//      ^-- heading columns
+			setData( model, modelTable( [
+				[ '00', { contents: '01', colspan: 5, rowspan: 2 } ],
+				[ '10' ],
+				[ '20', '21', '22[]', '23', '24', '25' ]
+			], { headingColumns: 1 } ) );
+
+			command.execute();
+
+			// +----+----+----+----+----+----+
+			// | 00 | 01      |              |
+			// +----+         +              +
+			// | 10 |         |              |
+			// +----+----+----+----+----+----+
+			// | 20 | 21 | 22 | 23 | 24 | 25 |
+			// +----+----+----+----+----+----+
+			//                ^-- heading columns
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ '00', { contents: '01', colspan: 2, rowspan: 2 }, { contents: '', colspan: 3, rowspan: 2 } ],
+				[ '10' ],
+				[ '20', '21', '22[]', '23', '24', '25' ]
+			], { headingColumns: 3 } ) );
+		} );
+
+		it( 'should fix col-spanned cells on the edge of an table heading columns section when creating section', () => {
+			// +----+----+
+			// | 00      |
+			// +----+----+
+			// | 10 | 11 |
+			// +----+----+
+			//           ^-- heading columns
+			setData( model, modelTable( [
+				[ { contents: '00', colspan: 2 } ],
+				[ '10', '[]11' ]
+			], { headingColumns: 2 } ) );
+
+			command.execute();
+
+			// +----+----+
+			// | 00 |    |
+			// +----+----+
+			// | 10 | 11 |
+			// +----+----+
+			//      ^-- heading columns
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ '00', '' ],
+				[ '10', '[]11' ]
+			], { headingColumns: 1 } ) );
+		} );
 	} );
 } );

+ 3 - 3
packages/ckeditor5-table/tests/commands/setheaderrowcommand.js

@@ -225,7 +225,7 @@ describe( 'SetHeaderRowCommand', () => {
 			], { headingRows: 1 } ) );
 		} );
 
-		it( 'should unsetset heading rows attribute', () => {
+		it( 'should remove "headingRows" attribute from table if no value was given', () => {
 			setData( model, modelTable( [
 				[ '[]00' ],
 				[ '10' ],
@@ -551,7 +551,7 @@ describe( 'SetHeaderRowCommand', () => {
 			} );
 		} );
 
-		it( 'should respect forceValue parameter #1', () => {
+		it( 'should respect forceValue parameter (forceValue=true)', () => {
 			setData( model, modelTable( [
 				[ '00' ],
 				[ '[]10' ],
@@ -569,7 +569,7 @@ describe( 'SetHeaderRowCommand', () => {
 			], { headingRows: 3 } ) );
 		} );
 
-		it( 'should respect forceValue parameter #2', () => {
+		it( 'should respect forceValue parameter (forceValue=false)', () => {
 			setData( model, modelTable( [
 				[ '00' ],
 				[ '[]10' ],

+ 1 - 0
packages/ckeditor5-table/tests/manual/tablemocking.html

@@ -43,6 +43,7 @@
 	<button type="button" id="set-model-data">↓ Set model data ↓</button>
 	<button type="button" id="get-model-data">↑ Get model data ↑</button>
 	<button type="button" id="renumber-cells">Renumber cells</button>
+	<label><input type="checkbox" id="use-letters" checked="false">Use letters</label>
 
 	<span id="input-status"></span>
 </div>

+ 68 - 12
packages/ckeditor5-table/tests/manual/tablemocking.js

@@ -14,6 +14,8 @@ import { diffString } from 'json-diff';
 import { debounce } from 'lodash-es';
 import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
 import TableWalker from '../../src/tablewalker';
+import { getSelectionAffectedTableCells } from '../../src/utils';
+import { findAncestor } from '../../src/commands/utils';
 
 ClassicEditor
 	.create( document.querySelector( '#editor' ), {
@@ -38,30 +40,44 @@ ClassicEditor
 		document.getElementById( 'set-model-data' ).addEventListener( 'click', () => {
 			updateInputStatus();
 
+			const table = findTable( editor );
 			const inputModelData = parseModelData( modelData.value );
-			setModelData( editor.model, inputModelData ? modelTable( inputModelData ) : '' );
+
+			if ( inputModelData ) {
+				const element = setModelData._parse( modelTable( inputModelData ), editor.model.schema );
+
+				editor.model.change( writer => {
+					editor.model.insertContent( element, table ? editor.model.createRangeOn( table ) : null );
+					writer.setSelection( element, 'on' );
+				} );
+
+				editor.editing.view.focus();
+			}
 		} );
 
 		document.getElementById( 'get-model-data' ).addEventListener( 'click', () => {
 			updateInputStatus();
 
-			const table = findTable( editor );
+			const table = findTable( editor, true );
 			modelData.value = table ? prettyFormatModelTableInput( prepareModelTableInput( editor.model, table ) ) : '';
 
 			updateAsciiAndDiff();
 		} );
 
 		document.getElementById( 'renumber-cells' ).addEventListener( 'click', () => {
-			const table = findTable( editor );
+			const table = findTable( editor, true );
 
 			if ( !table ) {
 				return;
 			}
 
+			const useLetters = document.getElementById( 'use-letters' ).checked;
+
 			editor.model.change( writer => {
 				for ( const { row, column, cell } of new TableWalker( table ) ) {
 					const selection = editor.model.createSelection( cell, 'in' );
-					editor.model.insertContent( writer.createText( `${ row }${ column }` ), selection );
+
+					editor.model.insertContent( writer.createText( createCellText( row, column, useLetters ) ), selection );
 				}
 			} );
 
@@ -72,15 +88,15 @@ ClassicEditor
 		updateAsciiAndDiff();
 
 		function updateAsciiAndDiff() {
-			const table = findTable( editor );
+			const tables = getAllTables( editor );
 
-			if ( !table ) {
+			if ( !tables.length ) {
 				asciiOut.innerText = '-- table not found --';
 				return;
 			}
 
 			const inputModelData = parseModelData( modelData.value );
-			const currentModelData = prepareModelTableInput( editor.model, table );
+			const currentModelData = prepareModelTableInput( editor.model, tables[ 0 ] );
 
 			const diffOutput = inputModelData ? diffString( inputModelData, currentModelData, {
 				theme: {
@@ -90,20 +106,53 @@ ClassicEditor
 				}
 			} ) : '-- no input --';
 
-			asciiOut.innerHTML = createTableAsciiArt( editor.model, table ) + '\n\n' +
-				'Diff: input vs post-fixed model:\n' + ( diffOutput ? diffOutput : '-- no differences --' );
+			const asciiArt = tables
+				.map( table => createTableAsciiArt( editor.model, table ) )
+				.join( '\n\n' );
+
+			asciiOut.innerHTML = asciiArt + '\n\n' +
+				'Diff: input vs post-fixed model (only first table):\n' + ( diffOutput ? diffOutput : '-- no differences --' );
+		}
+
+		function findTable( editor, useAnyTable = false ) {
+			const selection = editor.model.document.selection;
+
+			const tableCells = getSelectionAffectedTableCells( selection );
+
+			if ( tableCells.length ) {
+				return findAncestor( 'table', tableCells[ 0 ] );
+			}
+
+			const element = selection.getSelectedElement();
+
+			if ( element && element.is( 'table' ) ) {
+				return element;
+			}
+
+			if ( useAnyTable ) {
+				const range = editor.model.createRangeIn( editor.model.document.getRoot() );
+
+				for ( const element of range.getItems() ) {
+					if ( element.is( 'table' ) ) {
+						return element;
+					}
+				}
+			}
+
+			return null;
 		}
 
-		function findTable( editor ) {
+		function getAllTables( editor ) {
 			const range = editor.model.createRangeIn( editor.model.document.getRoot() );
+			const tables = [];
 
 			for ( const element of range.getItems() ) {
 				if ( element.is( 'table' ) ) {
-					return element;
+					tables.push( element );
 				}
 			}
 
-			return null;
+			return tables;
 		}
 
 		function parseModelData( string ) {
@@ -127,6 +176,13 @@ ClassicEditor
 		function updateInputStatus( message = '' ) {
 			document.getElementById( 'input-status' ).innerText = message;
 		}
+
+		function createCellText( row, column, useLetters ) {
+			const rowLabel = useLetters ? String.fromCharCode( row + 'a'.charCodeAt( 0 ) ) : row;
+			const columnLabel = useLetters ? String.fromCharCode( column + 'a'.charCodeAt( 0 ) ) : column;
+
+			return `${ rowLabel }${ columnLabel }`;
+		}
 	} )
 	.catch( err => {
 		console.error( err.stack );

+ 422 - 0
packages/ckeditor5-table/tests/tableclipboard-copy-cut.js

@@ -0,0 +1,422 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals document */
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+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';
+
+import TableEditing from '../src/tableediting';
+import TableClipboard from '../src/tableclipboard';
+
+describe( 'table clipboard', () => {
+	let editor, model, modelRoot, tableSelection, viewDocument, element;
+
+	beforeEach( async () => {
+		element = document.createElement( 'div' );
+		document.body.appendChild( element );
+
+		editor = await ClassicTestEditor.create( element, {
+			plugins: [ TableEditing, TableClipboard, Paragraph, Clipboard ]
+		} );
+
+		model = editor.model;
+		modelRoot = model.document.getRoot();
+		viewDocument = editor.editing.view.document;
+		tableSelection = editor.plugins.get( 'TableSelection' );
+
+		setModelData( model, modelTable( [
+			[ '00[]', '01', '02' ],
+			[ '10', '11', '12' ],
+			[ '20', '21', '22' ]
+		] ) );
+	} );
+
+	afterEach( async () => {
+		await editor.destroy();
+
+		element.remove();
+	} );
+
+	describe( 'Clipboard integration - copy', () => {
+		it( 'should do nothing for normal selection in table', () => {
+			const dataTransferMock = createDataTransfer();
+			const spy = sinon.spy();
+
+			viewDocument.on( 'clipboardOutput', spy );
+
+			viewDocument.fire( 'copy', {
+				dataTransfer: dataTransferMock,
+				preventDefault: sinon.spy()
+			} );
+
+			sinon.assert.calledOnce( spy );
+		} );
+
+		it( 'should copy selected table cells as a standalone table', () => {
+			const preventDefaultSpy = sinon.spy();
+
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 2 ] )
+			);
+
+			const data = {
+				dataTransfer: createDataTransfer(),
+				preventDefault: preventDefaultSpy
+			};
+			viewDocument.fire( 'copy', data );
+
+			sinon.assert.calledOnce( preventDefaultSpy );
+			expect( data.dataTransfer.getData( 'text/html' ) ).to.equal( viewTable( [
+				[ '01', '02' ],
+				[ '11', '12' ]
+			] ) );
+		} );
+
+		it( 'should trim selected table to a selection rectangle (inner cell with colspan, no colspan after trim)', () => {
+			setModelData( model, modelTable( [
+				[ '00[]', '01', '02' ],
+				[ '10', { contents: '11', colspan: 2 } ],
+				[ '20', '21', '22' ]
+			] ) );
+
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 2, 1 ] )
+			);
+
+			assertClipboardContentOnMethod( 'copy', viewTable( [
+				[ '00', '01' ],
+				[ '10', '11' ],
+				[ '20', '21' ]
+			] ) );
+		} );
+
+		it( 'should trim selected table to a selection rectangle (inner cell with colspan, has colspan after trim)', () => {
+			setModelData( model, modelTable( [
+				[ '00[]', '01', '02' ],
+				[ { contents: '10', colspan: 3 } ],
+				[ '20', '21', '22' ]
+			] ) );
+
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 2, 1 ] )
+			);
+
+			assertClipboardContentOnMethod( 'copy', viewTable( [
+				[ '00', '01' ],
+				[ { contents: '10', colspan: 2 } ],
+				[ '20', '21' ]
+			] ) );
+		} );
+
+		it( 'should trim selected table to a selection rectangle (inner cell with rowspan, no colspan after trim)', () => {
+			setModelData( model, modelTable( [
+				[ '00[]', '01', '02' ],
+				[ '10', { contents: '11', rowspan: 2 }, '12' ],
+				[ '20', '21', '22' ]
+			] ) );
+
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 2 ] )
+			);
+
+			assertClipboardContentOnMethod( 'copy', viewTable( [
+				[ '00', '01', '02' ],
+				[ '10', '11', '12' ]
+			] ) );
+		} );
+
+		it( 'should trim selected table to a selection rectangle (inner cell with rowspan, has rowspan after trim)', () => {
+			setModelData( model, modelTable( [
+				[ '00[]', { contents: '01', rowspan: 3 }, '02' ],
+				[ '10', '12' ],
+				[ '20', '22' ]
+			] ) );
+
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+			);
+
+			assertClipboardContentOnMethod( 'copy', viewTable( [
+				[ '00', { contents: '01', rowspan: 2 }, '02' ],
+				[ '10', '12' ]
+			] ) );
+		} );
+
+		it( 'should prepend spanned columns with empty cells (outside cell with colspan)', () => {
+			setModelData( model, modelTable( [
+				[ '00[]', '01', '02' ],
+				[ { contents: '10', colspan: 2 }, '12' ],
+				[ '20', '21', '22' ]
+			] ) );
+
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
+				modelRoot.getNodeByPath( [ 0, 2, 2 ] )
+			);
+
+			assertClipboardContentOnMethod( 'copy', viewTable( [
+				[ '01', '02' ],
+				[ '&nbsp;', '12' ],
+				[ '21', '22' ]
+			] ) );
+		} );
+
+		it( 'should prepend spanned columns with empty cells (outside cell with rowspan)', () => {
+			setModelData( model, modelTable( [
+				[ '00', { contents: '01', rowspan: 2 }, '02' ],
+				[ '10', '12' ],
+				[ '20', '21', '22' ]
+			] ) );
+
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 2, 2 ] )
+			);
+
+			assertClipboardContentOnMethod( 'copy', viewTable( [
+				[ '10', '&nbsp;', '12' ],
+				[ '20', '21', '22' ]
+			] ) );
+		} );
+
+		it( 'should fix selected table to a selection rectangle (hardcore case)', () => {
+			// This test check how previous simple rules run together (mixed prepending and trimming).
+			// In the example below a selection is set from cell "21" to "77"
+			//
+			//                    Input table:                                         Copied table:
+			//
+			//   +----+----+----+----+----+----+----+----+----+
+			//   | 00 | 01 | 02 | 03 | 04      | 06 | 07 | 08 |
+			//   +----+----+    +----+         +----+----+----+
+			//   | 10 | 11 |    | 13 |         | 16 | 17 | 18 |
+			//   +----+----+    +----+         +----+----+----+             +----+----+----+----+----+----+----+
+			//   | 20 | 21 |    | 23 |         | 26           |             | 21 |    | 23 |    |    | 26      |
+			//   +----+----+    +----+         +----+----+----+             +----+----+----+----+----+----+----+
+			//   | 30 | 31 |    | 33 |         | 36 | 37      |             | 31 |    | 33 |    |    | 36 | 37 |
+			//   +----+----+----+----+         +----+----+----+             +----+----+----+----+----+----+----+
+			//   | 40                |         | 46 | 47 | 48 |             |    |    |    |    |    | 46 | 47 |
+			//   +----+----+----+----+         +----+----+----+     ==>     +----+----+----+----+----+----+----+
+			//   | 50 | 51 | 52 | 53 |         | 56 | 57 | 58 |             | 51 | 52 | 53 |    |    | 56 | 57 |
+			//   +----+----+----+----+         +    +----+----+             +----+----+----+----+----+    +----+
+			//   | 60 | 61           |         |    | 67 | 68 |             | 61           |    |    |    | 67 |
+			//   +----+----+----+----+----+----+    +----+----+             +----+----+----+----+----+    +----+
+			//   | 70 | 71 | 72 | 73 | 74 | 75 |    | 77 | 78 |             | 71 | 72 | 73 | 74 | 75 |    | 77 |
+			//   +----+    +----+----+----+----+    +----+----+             +----+----+----+----+----+----+----+
+			//   | 80 |    | 82 | 83 | 84 | 85 |    | 87 | 88 |
+			//   +----+----+----+----+----+----+----+----+----+
+			//
+			setModelData( model, modelTable( [
+				[ '00', '01', { contents: '02', rowspan: 4 }, '03', { contents: '04', colspan: 2, rowspan: 7 }, '06', '07', '08' ],
+				[ '10', '11', '13', '16', '17', '18' ],
+				[ '20', '21', '23', { contents: '26', colspan: 3 } ],
+				[ '30', '31', '33', '36', { contents: '37', colspan: 2 } ],
+				[ { contents: '40', colspan: 4 }, '46', '47', '48' ],
+				[ '50', '51', '52', '53', { contents: '56', rowspan: 4 }, '57', '58' ],
+				[ '60', { contents: '61', colspan: 3 }, '67', '68' ],
+				[ '70', { contents: '71', rowspan: 2 }, '72', '73', '74', '75', '77', '78' ],
+				[ '80', '82', '83', '84', '85', '87', '88' ]
+			] ) );
+
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 2, 1 ] ),
+				modelRoot.getNodeByPath( [ 0, 7, 6 ] )
+			);
+
+			assertClipboardContentOnMethod( 'copy', viewTable( [
+				[ '21', '&nbsp;', '23', '&nbsp;', '&nbsp;', { contents: '26', colspan: 2 } ],
+				[ '31', '&nbsp;', '33', '&nbsp;', '&nbsp;', '36', '37' ],
+				[ '&nbsp;', '&nbsp;', '&nbsp;', '&nbsp;', '&nbsp;', '46', '47' ],
+				[ '51', '52', '53', '&nbsp;', '&nbsp;', { contents: '56', rowspan: 3 }, '57' ],
+				[ { contents: '61', colspan: 3 }, '&nbsp;', '&nbsp;', '67' ],
+				[ '71', '72', '73', '74', '75', '77' ]
+			] ) );
+		} );
+
+		it( 'should update table heading attributes (selection with headings)', () => {
+			setModelData( model, modelTable( [
+				[ '00', '01', '02', '03', '04' ],
+				[ '10', '11', '12', '13', '14' ],
+				[ '20', '21', '22', '23', '24' ],
+				[ '30', '31', '32', '33', '34' ],
+				[ '40', '41', '42', '43', '44' ]
+			], { headingRows: 3, headingColumns: 2 } ) );
+
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 1, 1 ] ),
+				modelRoot.getNodeByPath( [ 0, 3, 3 ] )
+			);
+
+			assertClipboardContentOnMethod( 'copy', viewTable( [
+				[ '11', '12', '13' ],
+				[ '21', '22', '23' ],
+				[ { contents: '31', isHeading: true }, '32', '33' ] // TODO: bug in viewTable
+			], { headingRows: 2, headingColumns: 1 } ) );
+		} );
+
+		it( 'should update table heading attributes (selection without headings)', () => {
+			setModelData( model, modelTable( [
+				[ '00', '01', '02', '03', '04' ],
+				[ '10', '11', '12', '13', '14' ],
+				[ '20', '21', '22', '23', '24' ],
+				[ '30', '31', '32', '33', '34' ],
+				[ '40', '41', '42', '43', '44' ]
+			], { headingRows: 3, headingColumns: 2 } ) );
+
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 3, 2 ] ),
+				modelRoot.getNodeByPath( [ 0, 4, 4 ] )
+			);
+
+			assertClipboardContentOnMethod( 'copy', viewTable( [
+				[ '32', '33', '34' ],
+				[ '42', '43', '44' ]
+			] ) );
+		} );
+	} );
+
+	describe( 'Clipboard integration - cut', () => {
+		it( 'should not block clipboardOutput if no multi-cell selection', () => {
+			setModelData( model, modelTable( [
+				[ '[00]', '01', '02' ],
+				[ '10', '11', '12' ],
+				[ '20', '21', '22' ]
+			] ) );
+
+			const dataTransferMock = createDataTransfer();
+
+			viewDocument.fire( 'cut', {
+				dataTransfer: dataTransferMock,
+				preventDefault: sinon.spy()
+			} );
+
+			expect( dataTransferMock.getData( 'text/html' ) ).to.equal( '00' );
+		} );
+
+		it( 'should be preventable', () => {
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+			);
+
+			viewDocument.on( 'clipboardOutput', evt => evt.stop(), { priority: 'high' } );
+
+			viewDocument.fire( 'cut', {
+				dataTransfer: createDataTransfer(),
+				preventDefault: sinon.spy()
+			} );
+
+			assertEqualMarkup( getModelData( model ), modelTable( [
+				[ { contents: '00', isSelected: true }, { contents: '01', isSelected: true }, '02' ],
+				[ { contents: '10', isSelected: true }, { contents: '11', isSelected: true }, '12' ],
+				[ '20', '21', '22' ]
+			] ) );
+		} );
+
+		it( 'is clears selected table cells', () => {
+			const spy = sinon.spy();
+
+			viewDocument.on( 'clipboardOutput', spy );
+
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+			);
+
+			viewDocument.fire( 'cut', {
+				dataTransfer: createDataTransfer(),
+				preventDefault: sinon.spy()
+			} );
+
+			assertEqualMarkup( getModelData( model ), modelTable( [
+				[ '', '', '02' ],
+				[ '', '[]', '12' ],
+				[ '20', '21', '22' ]
+			] ) );
+		} );
+
+		it( 'should copy selected table cells as a standalone table', () => {
+			const preventDefaultSpy = sinon.spy();
+
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 2 ] )
+			);
+
+			const data = {
+				dataTransfer: createDataTransfer(),
+				preventDefault: preventDefaultSpy
+			};
+			viewDocument.fire( 'cut', data );
+
+			sinon.assert.calledOnce( preventDefaultSpy );
+			expect( data.dataTransfer.getData( 'text/html' ) ).to.equal( viewTable( [
+				[ '01', '02' ],
+				[ '11', '12' ]
+			] ) );
+		} );
+
+		it( 'should be disabled in a readonly mode', () => {
+			const preventDefaultStub = sinon.stub();
+
+			editor.isReadOnly = true;
+
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 2 ] )
+			);
+
+			const data = {
+				dataTransfer: createDataTransfer(),
+				preventDefault: preventDefaultStub
+			};
+			viewDocument.fire( 'cut', data );
+
+			editor.isReadOnly = false;
+
+			expect( data.dataTransfer.getData( 'text/html' ) ).to.be.undefined;
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+				[ '00', '01', '02' ],
+				[ '10', '11', '12' ],
+				[ '20', '21', '22' ]
+			] ) );
+
+			sinon.assert.calledOnce( preventDefaultStub );
+		} );
+	} );
+
+	function assertClipboardContentOnMethod( method, expectedViewTable ) {
+		const data = {
+			dataTransfer: createDataTransfer(),
+			preventDefault: sinon.spy()
+		};
+		viewDocument.fire( method, data );
+
+		expect( data.dataTransfer.getData( 'text/html' ) ).to.equal( expectedViewTable );
+	}
+
+	function createDataTransfer() {
+		const store = new Map();
+
+		return {
+			setData( type, data ) {
+				store.set( type, data );
+			},
+
+			getData( type ) {
+				return store.get( type );
+			}
+		};
+	}
+} );

+ 3400 - 0
packages/ckeditor5-table/tests/tableclipboard-paste.js

@@ -0,0 +1,3400 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals document */
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import BlockQuoteEditing from '@ckeditor/ckeditor5-block-quote/src/blockquoteediting';
+import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
+import HorizontalLineEditing from '@ckeditor/ckeditor5-horizontal-line/src/horizontallineediting';
+import ImageCaptionEditing from '@ckeditor/ckeditor5-image/src/imagecaption/imagecaptionediting';
+import ImageEditing from '@ckeditor/ckeditor5-image/src/image/imageediting';
+import ListEditing from '@ckeditor/ckeditor5-list/src/listediting';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+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 { assertSelectedCells, modelTable, viewTable } from './_utils/utils';
+
+import TableEditing from '../src/tableediting';
+import TableCellPropertiesEditing from '../src/tablecellproperties/tablecellpropertiesediting';
+import TableWalker from '../src/tablewalker';
+
+import TableClipboard from '../src/tableclipboard';
+
+describe( 'table clipboard', () => {
+	let editor, model, modelRoot, tableSelection, viewDocument, element;
+
+	testUtils.createSinonSandbox();
+
+	beforeEach( () => {
+		element = document.createElement( 'div' );
+		document.body.appendChild( element );
+	} );
+
+	afterEach( async () => {
+		await editor.destroy();
+
+		element.remove();
+	} );
+
+	describe( 'Clipboard integration - paste (selection scenarios)', () => {
+		beforeEach( async () => {
+			await createEditor();
+
+			setModelData( model, modelTable( [
+				[ '00[]', '01', '02', '03' ],
+				[ '10', '11', '12', '13' ],
+				[ '20', '21', '22', '23' ],
+				[ '30', '31', '32', '33' ]
+			] ) );
+		} );
+
+		it( 'should be disabled in a readonly mode', () => {
+			editor.isReadOnly = true;
+
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+			);
+
+			const data = pasteTable( [
+				[ 'aa', 'ab' ],
+				[ 'ba', 'bb' ]
+			] );
+
+			editor.isReadOnly = false;
+
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+				[ '00', '01', '02', '03' ],
+				[ '10', '11', '12', '13' ],
+				[ '20', '21', '22', '23' ],
+				[ '30', '31', '32', '33' ]
+			] ) );
+
+			sinon.assert.calledOnce( data.preventDefault );
+		} );
+
+		it( 'should allow normal paste if no table cells are selected', () => {
+			const data = {
+				dataTransfer: createDataTransfer(),
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			};
+			data.dataTransfer.setData( 'text/html', '<p>foo</p>' );
+			viewDocument.fire( 'paste', data );
+
+			editor.isReadOnly = false;
+
+			assertEqualMarkup( getModelData( model ), modelTable( [
+				[ '00foo[]', '01', '02', '03' ],
+				[ '10', '11', '12', '13' ],
+				[ '20', '21', '22', '23' ],
+				[ '30', '31', '32', '33' ]
+			] ) );
+		} );
+
+		it( 'should not alter model.insertContent if selectable is different from document selection', () => {
+			model.change( writer => {
+				writer.setSelection( modelRoot.getNodeByPath( [ 0, 0, 0 ] ), 0 );
+
+				const selectedTableCells = model.createSelection( [
+					model.createRangeOn( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) ),
+					model.createRangeOn( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) ),
+					model.createRangeOn( modelRoot.getNodeByPath( [ 0, 1, 0 ] ) ),
+					model.createRangeOn( modelRoot.getNodeByPath( [ 0, 1, 1 ] ) )
+				] );
+
+				const tableToInsert = editor.plugins.get( 'TableUtils' ).createTable( writer, 2, 2 );
+
+				for ( const { cell } of new TableWalker( tableToInsert ) ) {
+					writer.insertText( 'foo', cell.getChild( 0 ), 0 );
+				}
+
+				model.insertContent( tableToInsert, selectedTableCells );
+			} );
+
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+				[ '', '', '02', '03' ],
+				[ '', '', '12', '13' ],
+				[ '20', '21', '22', '23' ],
+				[ '30', '31', '32', '33' ]
+			] ) );
+		} );
+
+		it( 'should not alter model.insertContent if selection is outside table', () => {
+			model.change( writer => {
+				writer.insertElement( 'paragraph', modelRoot.getChild( 0 ), 'before' );
+				writer.setSelection( modelRoot.getChild( 0 ), 'before' );
+			} );
+
+			const data = {
+				dataTransfer: createDataTransfer(),
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			};
+			data.dataTransfer.setData( 'text/html', '<p>foo</p>' );
+			viewDocument.fire( 'paste', data );
+
+			editor.isReadOnly = false;
+
+			assertEqualMarkup( getModelData( model ), '<paragraph>foo[]</paragraph>' + modelTable( [
+				[ '00', '01', '02', '03' ],
+				[ '10', '11', '12', '13' ],
+				[ '20', '21', '22', '23' ],
+				[ '30', '31', '32', '33' ]
+			] ) );
+		} );
+
+		it( 'should not alter model.insertContent if no table pasted', () => {
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+			);
+
+			const data = {
+				dataTransfer: createDataTransfer(),
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			};
+			data.dataTransfer.setData( 'text/html', '<p>foo</p>' );
+			viewDocument.fire( 'paste', data );
+
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+				[ 'foo', '', '02', '03' ],
+				[ '', '', '12', '13' ],
+				[ '20', '21', '22', '23' ],
+				[ '30', '31', '32', '33' ]
+			] ) );
+		} );
+
+		it( 'should not alter model.insertContent if mixed content is pasted (table + paragraph)', () => {
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+			);
+
+			const table = viewTable( [
+				[ 'aa', 'ab' ],
+				[ 'ba', 'bb' ] ] );
+
+			const data = {
+				dataTransfer: createDataTransfer(),
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			};
+			data.dataTransfer.setData( 'text/html', `${ table }<p>foo</p>` );
+			viewDocument.fire( 'paste', data );
+
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+				[ 'foo', '', '02', '03' ],
+				[ '', '', '12', '13' ],
+				[ '20', '21', '22', '23' ],
+				[ '30', '31', '32', '33' ]
+			] ) );
+		} );
+
+		it( 'should not alter model.insertContent if mixed content is pasted (paragraph + table)', () => {
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+			);
+
+			const table = viewTable( [
+				[ 'aa', 'ab' ],
+				[ 'ba', 'bb' ] ] );
+
+			const data = {
+				dataTransfer: createDataTransfer(),
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			};
+			data.dataTransfer.setData( 'text/html', `<p>foo</p>${ table }` );
+			viewDocument.fire( 'paste', data );
+
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+				[ 'foo', '', '02', '03' ],
+				[ '', '', '12', '13' ],
+				[ '20', '21', '22', '23' ],
+				[ '30', '31', '32', '33' ]
+			] ) );
+		} );
+
+		it( 'should not alter model.insertContent if mixed content is pasted (table + table)', () => {
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+			);
+
+			const table = viewTable( [
+				[ 'aa', 'ab' ],
+				[ 'ba', 'bb' ] ] );
+
+			const data = {
+				dataTransfer: createDataTransfer(),
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			};
+			data.dataTransfer.setData( 'text/html', `${ table }${ table }` );
+			viewDocument.fire( 'paste', data );
+
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+				[ '', '', '02', '03' ],
+				[ '', '', '12', '13' ],
+				[ '20', '21', '22', '23' ],
+				[ '30', '31', '32', '33' ]
+			] ) );
+		} );
+
+		it( 'should alter model.insertContent if selectable is a document selection', () => {
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+			);
+
+			model.change( writer => {
+				const tableToInsert = editor.plugins.get( 'TableUtils' ).createTable( writer, 2, 2 );
+
+				for ( const { cell } of new TableWalker( tableToInsert ) ) {
+					writer.insertText( 'foo', cell.getChild( 0 ), 0 );
+				}
+
+				model.insertContent( tableToInsert, editor.model.document.selection );
+			} );
+
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+				[ 'foo', 'foo', '02', '03' ],
+				[ 'foo', 'foo', '12', '13' ],
+				[ '20', '21', '22', '23' ],
+				[ '30', '31', '32', '33' ]
+			] ) );
+		} );
+
+		describe( 'single cell selected', () => {
+			beforeEach( () => {
+				setModelData( model, modelTable( [
+					[ '00[]', '01', '02' ],
+					[ '10', '11', '12' ],
+					[ '20', '21', '22' ]
+				] ) );
+			} );
+
+			describe( 'with the selection on the middle cell', () => {
+				beforeEach( () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 1, 1 ] ),
+						modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+					);
+				} );
+
+				it( 'should replace the table cells (with single undo step)', () => {
+					const batches = setupBatchWatch();
+
+					pasteTable( [
+						[ 'aa', 'ab' ],
+						[ 'ba', 'bb' ]
+					] );
+
+					expect( batches.size ).to.equal( 1 );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02' ],
+						[ '10', 'aa', 'ab' ],
+						[ '20', 'ba', 'bb' ]
+					] ) );
+				} );
+
+				it( 'should expand the table width and replace the table cells (with single undo step)', () => {
+					const batches = setupBatchWatch();
+
+					pasteTable( [
+						[ 'aa', 'ab', 'ac', 'ad', 'ae' ],
+						[ 'ba', 'bb', 'bc', 'bd', 'be' ]
+					] );
+
+					expect( batches.size ).to.equal( 1 );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02', '', '', '' ],
+						[ '10', 'aa', 'ab', 'ac', 'ad', 'ae' ],
+						[ '20', 'ba', 'bb', 'bc', 'bd', 'be' ]
+					] ) );
+				} );
+
+				it( 'should expand the table height and replace the table cells (with single undo step)', () => {
+					const batches = setupBatchWatch();
+
+					pasteTable( [
+						[ 'aa', 'ab' ],
+						[ 'ba', 'bb' ],
+						[ 'ca', 'cb' ],
+						[ 'da', 'db' ],
+						[ 'ea', 'eb' ]
+					] );
+
+					expect( batches.size ).to.equal( 1 );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02' ],
+						[ '10', 'aa', 'ab' ],
+						[ '20', 'ba', 'bb' ],
+						[ '', 'ca', 'cb' ],
+						[ '', 'da', 'db' ],
+						[ '', 'ea', 'eb' ]
+					] ) );
+				} );
+
+				it( 'should expand the table width, height and replace the table cells (with single undo step)', () => {
+					const batches = setupBatchWatch();
+
+					pasteTable( [
+						[ 'aa', 'ab', 'ac', 'ad', 'ae' ],
+						[ 'ba', 'bb', 'bc', 'bd', 'be' ],
+						[ 'ca', 'cb', 'cc', 'cd', 'ce' ],
+						[ 'da', 'db', 'dc', 'dd', 'de' ],
+						[ 'ea', 'eb', 'ec', 'ed', 'ee' ]
+					] );
+
+					expect( batches.size ).to.equal( 1 );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02', '', '', '' ],
+						[ '10', 'aa', 'ab', 'ac', 'ad', 'ae' ],
+						[ '20', 'ba', 'bb', 'bc', 'bd', 'be' ],
+						[ '', 'ca', 'cb', 'cc', 'cd', 'ce' ],
+						[ '', 'da', 'db', 'dc', 'dd', 'de' ],
+						[ '', 'ea', 'eb', 'ec', 'ed', 'ee' ]
+					] ) );
+				} );
+			} );
+
+			describe( 'with the selection on the first cell', () => {
+				beforeEach( () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+						modelRoot.getNodeByPath( [ 0, 0, 0 ] )
+					);
+				} );
+
+				it( 'should replace the table cells', () => {
+					pasteTable( [
+						[ 'aa', 'ab' ],
+						[ 'ba', 'bb' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ 'aa', 'ab', '02' ],
+						[ 'ba', 'bb', '12' ],
+						[ '20', '21', '22' ]
+					] ) );
+				} );
+
+				it( 'should expand the table and replace the table cells', () => {
+					pasteTable( [
+						[ 'aa', 'ab', 'ac', 'ad', 'ae' ],
+						[ 'ba', 'bb', 'bc', 'bd', 'be' ],
+						[ 'ca', 'cb', 'cc', 'cd', 'ce' ],
+						[ 'da', 'db', 'dc', 'dd', 'de' ],
+						[ 'ea', 'eb', 'ec', 'ed', 'ee' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ 'aa', 'ab', 'ac', 'ad', 'ae' ],
+						[ 'ba', 'bb', 'bc', 'bd', 'be' ],
+						[ 'ca', 'cb', 'cc', 'cd', 'ce' ],
+						[ 'da', 'db', 'dc', 'dd', 'de' ],
+						[ 'ea', 'eb', 'ec', 'ed', 'ee' ]
+					] ) );
+				} );
+			} );
+
+			describe( 'with the selection on the middle cell of the first row', () => {
+				beforeEach( () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
+						modelRoot.getNodeByPath( [ 0, 0, 1 ] )
+					);
+				} );
+
+				it( 'should replace the table cells', () => {
+					pasteTable( [
+						[ 'aa', 'ab' ],
+						[ 'ba', 'bb' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', 'aa', 'ab' ],
+						[ '10', 'ba', 'bb' ],
+						[ '20', '21', '22' ]
+					] ) );
+				} );
+
+				it( 'should expand the table and replace the table cells', () => {
+					pasteTable( [
+						[ 'aa', 'ab', 'ac', 'ad', 'ae' ],
+						[ 'ba', 'bb', 'bc', 'bd', 'be' ],
+						[ 'ca', 'cb', 'cc', 'cd', 'ce' ],
+						[ 'da', 'db', 'dc', 'dd', 'de' ],
+						[ 'ea', 'eb', 'ec', 'ed', 'ee' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', 'aa', 'ab', 'ac', 'ad', 'ae' ],
+						[ '10', 'ba', 'bb', 'bc', 'bd', 'be' ],
+						[ '20', 'ca', 'cb', 'cc', 'cd', 'ce' ],
+						[ '', 'da', 'db', 'dc', 'dd', 'de' ],
+						[ '', 'ea', 'eb', 'ec', 'ed', 'ee' ]
+					] ) );
+				} );
+			} );
+
+			describe( 'with the selection on the last cell of the first row', () => {
+				beforeEach( () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 2 ] ),
+						modelRoot.getNodeByPath( [ 0, 0, 2 ] )
+					);
+				} );
+
+				it( 'should replace the table cells', () => {
+					pasteTable( [
+						[ 'aa', 'ab' ],
+						[ 'ba', 'bb' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', 'aa', 'ab' ],
+						[ '10', '11', 'ba', 'bb' ],
+						[ '20', '21', '22', '' ]
+					] ) );
+				} );
+
+				it( 'should expand the table and replace the table cells', () => {
+					pasteTable( [
+						[ 'aa', 'ab', 'ac', 'ad', 'ae' ],
+						[ 'ba', 'bb', 'bc', 'bd', 'be' ],
+						[ 'ca', 'cb', 'cc', 'cd', 'ce' ],
+						[ 'da', 'db', 'dc', 'dd', 'de' ],
+						[ 'ea', 'eb', 'ec', 'ed', 'ee' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', 'aa', 'ab', 'ac', 'ad', 'ae' ],
+						[ '10', '11', 'ba', 'bb', 'bc', 'bd', 'be' ],
+						[ '20', '21', 'ca', 'cb', 'cc', 'cd', 'ce' ],
+						[ '', '', 'da', 'db', 'dc', 'dd', 'de' ],
+						[ '', '', 'ea', 'eb', 'ec', 'ed', 'ee' ]
+					] ) );
+				} );
+			} );
+
+			describe( 'with the selection is on the middle cell of the first column', () => {
+				beforeEach( () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
+						modelRoot.getNodeByPath( [ 0, 1, 0 ] )
+					);
+				} );
+
+				it( 'should replace the table cells', () => {
+					pasteTable( [
+						[ 'aa', 'ab' ],
+						[ 'ba', 'bb' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02' ],
+						[ 'aa', 'ab', '12' ],
+						[ 'ba', 'bb', '22' ]
+					] ) );
+				} );
+
+				it( 'should expand the table and replace the table cells', () => {
+					pasteTable( [
+						[ 'aa', 'ab', 'ac', 'ad', 'ae' ],
+						[ 'ba', 'bb', 'bc', 'bd', 'be' ],
+						[ 'ca', 'cb', 'cc', 'cd', 'ce' ],
+						[ 'da', 'db', 'dc', 'dd', 'de' ],
+						[ 'ea', 'eb', 'ec', 'ed', 'ee' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02', '', '' ],
+						[ 'aa', 'ab', 'ac', 'ad', 'ae' ],
+						[ 'ba', 'bb', 'bc', 'bd', 'be' ],
+						[ 'ca', 'cb', 'cc', 'cd', 'ce' ],
+						[ 'da', 'db', 'dc', 'dd', 'de' ],
+						[ 'ea', 'eb', 'ec', 'ed', 'ee' ]
+					] ) );
+				} );
+			} );
+
+			describe( 'with the selection is on the last cell of the first column', () => {
+				beforeEach( () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 2, 0 ] ),
+						modelRoot.getNodeByPath( [ 0, 2, 0 ] )
+					);
+				} );
+
+				it( 'should replace the table cells', () => {
+					pasteTable( [
+						[ 'aa', 'ab' ],
+						[ 'ba', 'bb' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02' ],
+						[ '10', '11', '12' ],
+						[ 'aa', 'ab', '22' ],
+						[ 'ba', 'bb', '' ]
+					] ) );
+				} );
+
+				it( 'should expand the table and replace the table cells', () => {
+					pasteTable( [
+						[ 'aa', 'ab', 'ac', 'ad', 'ae' ],
+						[ 'ba', 'bb', 'bc', 'bd', 'be' ],
+						[ 'ca', 'cb', 'cc', 'cd', 'ce' ],
+						[ 'da', 'db', 'dc', 'dd', 'de' ],
+						[ 'ea', 'eb', 'ec', 'ed', 'ee' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02', '', '' ],
+						[ '10', '11', '12', '', '' ],
+						[ 'aa', 'ab', 'ac', 'ad', 'ae' ],
+						[ 'ba', 'bb', 'bc', 'bd', 'be' ],
+						[ 'ca', 'cb', 'cc', 'cd', 'ce' ],
+						[ 'da', 'db', 'dc', 'dd', 'de' ],
+						[ 'ea', 'eb', 'ec', 'ed', 'ee' ]
+					] ) );
+				} );
+			} );
+
+			describe( 'with the selection is on the last cell of the last column', () => {
+				beforeEach( () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 2, 2 ] ),
+						modelRoot.getNodeByPath( [ 0, 2, 2 ] )
+					);
+				} );
+
+				it( 'should replace the table cells', () => {
+					pasteTable( [
+						[ 'aa', 'ab' ],
+						[ 'ba', 'bb' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02', '' ],
+						[ '10', '11', '12', '' ],
+						[ '20', '21', 'aa', 'ab' ],
+						[ '', '', 'ba', 'bb' ]
+					] ) );
+				} );
+
+				it( 'should expand the table and replace the table cells', () => {
+					pasteTable( [
+						[ 'aa', 'ab', 'ac', 'ad', 'ae' ],
+						[ 'ba', 'bb', 'bc', 'bd', 'be' ],
+						[ 'ca', 'cb', 'cc', 'cd', 'ce' ],
+						[ 'da', 'db', 'dc', 'dd', 'de' ],
+						[ 'ea', 'eb', 'ec', 'ed', 'ee' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02', '', '', '', '' ],
+						[ '10', '11', '12', '', '', '', '' ],
+						[ '20', '21', 'aa', 'ab', 'ac', 'ad', 'ae' ],
+						[ '', '', 'ba', 'bb', 'bc', 'bd', 'be' ],
+						[ '', '', 'ca', 'cb', 'cc', 'cd', 'ce' ],
+						[ '', '', 'da', 'db', 'dc', 'dd', 'de' ],
+						[ '', '', 'ea', 'eb', 'ec', 'ed', 'ee' ]
+					] ) );
+				} );
+			} );
+
+			describe( 'the selection inside the table cell', () => {
+				it( 'should replace the table cells when the collapsed selection is in the first cell', () => {
+					pasteTable( [
+						[ 'aa', 'ab' ],
+						[ 'ba', 'bb' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ 'aa', 'ab', '02' ],
+						[ 'ba', 'bb', '12' ],
+						[ '20', '21', '22' ]
+					] ) );
+				} );
+
+				it( 'should replace the table cells when the expanded selection is in the first cell', () => {
+					model.change( writer => {
+						writer.setSelection( modelRoot.getNodeByPath( [ 0, 0, 0 ] ), 'in' );
+					} );
+
+					pasteTable( [
+						[ 'aa', 'ab' ],
+						[ 'ba', 'bb' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ 'aa', 'ab', '02' ],
+						[ 'ba', 'bb', '12' ],
+						[ '20', '21', '22' ]
+					] ) );
+				} );
+
+				it( 'should replace the table cells when selection is on the image inside the table cell', async () => {
+					await editor.destroy();
+					await createEditor( [ ImageEditing, ImageCaptionEditing ] );
+
+					setModelData( model, modelTable( [
+						[ '00', '01', '02' ],
+						[ '10', '11', '12' ],
+						[ '20', '21', '[<image src="/assets/sample.jpg"><caption></caption></image>]' ]
+					] ) );
+
+					pasteTable( [
+						[ 'aa', 'ab' ],
+						[ 'ba', 'bb' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02', '' ],
+						[ '10', '11', '12', '' ],
+						[ '20', '21', 'aa', 'ab' ],
+						[ '', '', 'ba', 'bb' ]
+					] ) );
+				} );
+
+				it( 'should replace the table cells when selection is in the image caption inside the table cell', async () => {
+					await editor.destroy();
+					await createEditor( [ ImageEditing, ImageCaptionEditing ] );
+
+					setModelData( model, modelTable( [
+						[ '00', '01', '02' ],
+						[ '10', '11', '12' ],
+						[ '20', '21', '<image src="/assets/sample.jpg"><caption>fo[]o</caption></image>' ]
+					] ) );
+
+					pasteTable( [
+						[ 'aa', 'ab' ],
+						[ 'ba', 'bb' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02', '' ],
+						[ '10', '11', '12', '' ],
+						[ '20', '21', 'aa', 'ab' ],
+						[ '', '', 'ba', 'bb' ]
+					] ) );
+				} );
+			} );
+
+			describe( 'with spanned cells', () => {
+				it( 'should replace the table structure', () => {
+					// +----+----+----+----+----+----+
+					// | 00 | 01 | 02           | 05 |
+					// +----+----+----+----+----+----+
+					// | 10 | 11      | 13 | 14 | 15 |
+					// +----+         +----+----+----+
+					// | 20 |         | 23      | 25 |
+					// +----+         +----+----+    +
+					// | 30 |         | 33      |    |
+					// +----+----+----+----+----+----+
+					// | 40      | 42 | 43 | 44      |
+					// +----+----+----+----+----+----+
+					setModelData( model, modelTable( [
+						[ '00', '01', { contents: '02', colspan: 3 }, '05' ],
+						[ '10', { contents: '11', colspan: 2, rowspan: 3 }, '13', '14', '15' ],
+						[ '20', { contents: '23', colspan: 2 }, { contents: '25', rowspan: 2 } ],
+						[ '30', { contents: '33', colspan: 2 } ],
+						[ { contents: '40', colspan: 2 }, '42', '43', { contents: '44', colspan: 2 } ]
+					] ) );
+
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 2, 1 ] ), // Cell 23.
+						modelRoot.getNodeByPath( [ 0, 2, 1 ] )
+					);
+
+					// +----+----+----+----+----+
+					// | aa      | ac | ad | ae |
+					// +         +    +----+----+
+					// |         |    | bd | be |
+					// +----+----+----+----+----+
+					// | ca      | cc | cd | ce |
+					// +----+----+----+----+----+
+					// | da           | dd | de |
+					// +----+----+----+----+    +
+					// | ea | eb | ec | ed |    |
+					// +----+----+----+----+----+
+					pasteTable( [
+						[ { contents: 'aa', colspan: 2, rowspan: 2 }, { contents: 'ac', rowspan: 2 }, 'ad', 'ae' ],
+						[ 'bd', 'be' ],
+						[ { contents: 'ca', colspan: 2 }, 'cc', 'cd', 'ce' ],
+						[ { contents: 'da', colspan: 3 }, 'dd', { contents: 'de', rowspan: 2 } ],
+						[ 'ea', 'eb', 'ec', 'ed' ]
+					] );
+
+					// +----+----+----+----+----+----+----+----+
+					// | 00 | 01 | 02           | 05 |    |    |
+					// +----+----+----+----+----+----+----+----+
+					// | 10 | 11      | 13 | 14 | 15 |    |    |
+					// +----+         +----+----+----+----+----+
+					// | 20 |         | aa      | ac | ad | ae |
+					// +----+         +         +    +----+----+
+					// | 30 |         |         |    | bd | be |
+					// +----+----+----+----+----+----+----+----+
+					// | 40      | 42 | ca      | cc | cd | ce |
+					// +----+----+----+----+----+----+----+----+
+					// |    |    |    | da           | dd | de |
+					// +----+----+----+----+----+----+----+    +
+					// |    |    |    | ea | eb | ec | ed |    |
+					// +----+----+----+----+----+----+----+----+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', { contents: '02', colspan: 3 }, '05', '', '' ],
+						[ '10', { contents: '11', colspan: 2, rowspan: 3 }, '13', '14', '15', '', '' ],
+						[ '20', { contents: 'aa', colspan: 2, rowspan: 2 }, { contents: 'ac', rowspan: 2 }, 'ad', 'ae' ],
+						[ '30', 'bd', 'be' ],
+						[ { contents: '40', colspan: 2 }, '42', { contents: 'ca', colspan: 2 }, 'cc', 'cd', 'ce' ],
+						[ '', '', '', { contents: 'da', colspan: 3 }, 'dd', { contents: 'de', rowspan: 2 } ],
+						[ '', '', '', 'ea', 'eb', 'ec', 'ed' ]
+					] ) );
+				} );
+
+				it( 'should fix non-rectangular are on matched table fragment', () => {
+					// +----+----+----+
+					// | 00 | 01 | 02 |
+					// +----+----+    +
+					// | 10 | 11 |    |
+					// +----+----+----+
+					// | 20      | 22 |
+					// +----+----+----+
+					setModelData( model, modelTable( [
+						[ '00', '01', { contents: '02', rowspan: 2 } ],
+						[ '10', '11[]' ],
+						[ { contents: '20', colspan: 2 }, '22' ]
+					] ) );
+
+					pasteTable( [
+						[ 'aa', 'ab' ],
+						[ 'ba', 'bb' ]
+					] );
+
+					// +----+----+----+
+					// | 00 | 01 | 02 |
+					// +----+----+----+
+					// | 10 | aa | ab |
+					// +----+----+----+
+					// | 20 | ba | bb |
+					// +----+----+----+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02' ],
+						[ '10', 'aa', 'ab' ],
+						[ '20', 'ba', 'bb' ]
+					] ) );
+				} );
+			} );
+		} );
+
+		describe( 'pasted table is equal to the selected area', () => {
+			describe( 'no spans', () => {
+				it( 'handles simple table paste to a simple table fragment - at the beginning of a table', () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+						modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab' ],
+						[ 'ba', 'bb' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ 'aa', 'ab', '02', '03' ],
+						[ 'ba', 'bb', '12', '13' ],
+						[ '20', '21', '22', '23' ],
+						[ '30', '31', '32', '33' ]
+					] ) );
+
+					assertSelectedCells( model, [
+						[ 1, 1, 0, 0 ],
+						[ 1, 1, 0, 0 ],
+						[ 0, 0, 0, 0 ],
+						[ 0, 0, 0, 0 ]
+					] );
+				} );
+
+				it( 'handles simple table paste to a simple table fragment - at the end of a table', () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 2, 2 ] ),
+						modelRoot.getNodeByPath( [ 0, 3, 3 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab' ],
+						[ 'ba', 'bb' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02', '03' ],
+						[ '10', '11', '12', '13' ],
+						[ '20', '21', 'aa', 'ab' ],
+						[ '30', '31', 'ba', 'bb' ]
+					] ) );
+
+					assertSelectedCells( model, [
+						[ 0, 0, 0, 0 ],
+						[ 0, 0, 0, 0 ],
+						[ 0, 0, 1, 1 ],
+						[ 0, 0, 1, 1 ]
+					] );
+				} );
+
+				it( 'handles simple table paste to a simple table fragment - in the middle of a table', () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 1, 1 ] ),
+						modelRoot.getNodeByPath( [ 0, 2, 2 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab' ],
+						[ 'ba', 'bb' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02', '03' ],
+						[ '10', 'aa', 'ab', '13' ],
+						[ '20', 'ba', 'bb', '23' ],
+						[ '30', '31', '32', '33' ]
+					] ) );
+
+					assertSelectedCells( model, [
+						[ 0, 0, 0, 0 ],
+						[ 0, 1, 1, 0 ],
+						[ 0, 1, 1, 0 ],
+						[ 0, 0, 0, 0 ]
+					] );
+				} );
+
+				it( 'handles simple row paste to a simple row fragment - in the middle of a table', () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 1, 1 ] ),
+						modelRoot.getNodeByPath( [ 0, 1, 2 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02', '03' ],
+						[ '10', 'aa', 'ab', '13' ],
+						[ '20', '21', '22', '23' ],
+						[ '30', '31', '32', '33' ]
+					] ) );
+
+					assertSelectedCells( model, [
+						[ 0, 0, 0, 0 ],
+						[ 0, 1, 1, 0 ],
+						[ 0, 0, 0, 0 ],
+						[ 0, 0, 0, 0 ]
+					] );
+				} );
+
+				it( 'handles simple column paste to a simple column fragment - in the middle of a table', () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 1, 1 ] ),
+						modelRoot.getNodeByPath( [ 0, 2, 1 ] )
+					);
+
+					pasteTable( [
+						[ 'aa' ],
+						[ 'ba' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02', '03' ],
+						[ '10', 'aa', '12', '13' ],
+						[ '20', 'ba', '22', '23' ],
+						[ '30', '31', '32', '33' ]
+					] ) );
+
+					assertSelectedCells( model, [
+						[ 0, 0, 0, 0 ],
+						[ 0, 1, 0, 0 ],
+						[ 0, 1, 0, 0 ],
+						[ 0, 0, 0, 0 ]
+					] );
+				} );
+
+				it( 'handles simple table paste to a simple table fragment - whole table selected', () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+						modelRoot.getNodeByPath( [ 0, 3, 3 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab', 'ac', 'ad' ],
+						[ 'ba', 'bb', 'bc', 'bd' ],
+						[ 'ca', 'cb', 'cc', 'cd' ],
+						[ 'da', 'db', 'dc', 'dd' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ 'aa', 'ab', 'ac', 'ad' ],
+						[ 'ba', 'bb', 'bc', 'bd' ],
+						[ 'ca', 'cb', 'cc', 'cd' ],
+						[ 'da', 'db', 'dc', 'dd' ]
+					] ) );
+
+					assertSelectedCells( model, [
+						[ 1, 1, 1, 1 ],
+						[ 1, 1, 1, 1 ],
+						[ 1, 1, 1, 1 ],
+						[ 1, 1, 1, 1 ]
+					] );
+				} );
+			} );
+
+			describe( 'pasted table has spans', () => {
+				it( 'handles pasting table that has cell with colspan', () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 1, 1 ] ),
+						modelRoot.getNodeByPath( [ 0, 2, 2 ] )
+					);
+
+					pasteTable( [
+						[ { colspan: 2, contents: 'aa' } ],
+						[ 'ba', 'bb' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02', '03' ],
+						[ '10', { colspan: 2, contents: 'aa' }, '13' ],
+						[ '20', 'ba', 'bb', '23' ],
+						[ '30', '31', '32', '33' ]
+					] ) );
+
+					/* eslint-disable no-multi-spaces */
+					assertSelectedCells( model, [
+						[ 0, 0, 0, 0 ],
+						[ 0, 1,    0 ],
+						[ 0, 1, 1, 0 ],
+						[ 0, 0, 0, 0 ]
+					] );
+					/* eslint-enable no-multi-spaces */
+				} );
+
+				it( 'handles pasting table that has many cells with various colspan', () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+						modelRoot.getNodeByPath( [ 0, 3, 2 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', { colspan: 2, contents: 'ab' } ],
+						[ { colspan: 3, contents: 'ba' } ],
+						[ 'ca', 'cb', 'cc' ],
+						[ { colspan: 2, contents: 'da' }, 'dc' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ 'aa', { colspan: 2, contents: 'ab' }, '03' ],
+						[ { colspan: 3, contents: 'ba' }, '13' ],
+						[ 'ca', 'cb', 'cc', '23' ],
+						[ { colspan: 2, contents: 'da' }, 'dc', '33' ]
+					] ) );
+
+					/* eslint-disable no-multi-spaces */
+					assertSelectedCells( model, [
+						[ 1, 1,    0 ],
+						[ 1,       0 ],
+						[ 1, 1, 1, 0 ],
+						[ 1,    1, 0 ]
+					] );
+					/* eslint-enable no-multi-spaces */
+				} );
+
+				it( 'handles pasting table that has cell with rowspan', () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 1, 1 ] ),
+						modelRoot.getNodeByPath( [ 0, 2, 2 ] )
+					);
+
+					pasteTable( [
+						[ { rowspan: 2, contents: 'aa' }, 'ab' ],
+						[ 'bb' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02', '03' ],
+						[ '10', { rowspan: 2, contents: 'aa' }, 'ab', '13' ],
+						[ '20', 'bb', '23' ],
+						[ '30', '31', '32', '33' ]
+					] ) );
+
+					/* eslint-disable no-multi-spaces */
+					assertSelectedCells( model, [
+						[ 0, 0, 0, 0 ],
+						[ 0, 1, 1, 0 ],
+						[ 0,    1, 0 ],
+						[ 0, 0, 0, 0 ]
+					] );
+					/* eslint-enable no-multi-spaces */
+				} );
+
+				it( 'handles pasting table that has many cells with various rowspan', () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+						modelRoot.getNodeByPath( [ 0, 2, 3 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', { rowspan: 3, contents: 'ab' }, { rowspan: 2, contents: 'ac' }, 'ad' ],
+						[ { rowspan: 2, contents: 'ba' }, 'bd' ],
+						[ 'cc', 'cd' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ 'aa', { rowspan: 3, contents: 'ab' }, { rowspan: 2, contents: 'ac' }, 'ad' ],
+						[ { rowspan: 2, contents: 'ba' }, 'bd' ],
+						[ 'cc', 'cd' ],
+						[ '30', '31', '32', '33' ]
+					] ) );
+
+					/* eslint-disable no-multi-spaces */
+					assertSelectedCells( model, [
+						[ 1, 1, 1, 1 ],
+						[ 1,       1 ],
+						[       1, 1 ],
+						[ 0, 0, 0, 0 ]
+					] );
+					/* eslint-enable no-multi-spaces */
+				} );
+
+				it( 'handles pasting multi-spanned table', () => {
+					setModelData( model, modelTable( [
+						[ '00', '01', '02', '03', '04', '05' ],
+						[ '10', '11', '12', '13', '14', '15' ],
+						[ '20', '21', '22', '23', '24', '25' ],
+						[ '30', '31', '32', '33', '34', '35' ],
+						[ '40', '41', '42', '43', '44', '45' ]
+					] ) );
+
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+						modelRoot.getNodeByPath( [ 0, 3, 4 ] )
+					);
+
+					// +----+----+----+----+----+
+					// | aa      | ac | ad | ae |
+					// +----+----+----+----+    +
+					// | ba | bb           |    |
+					// +----+              +----+
+					// | ca |              | ce |
+					// +    +----+----+----+----+
+					// |    | db | dc | dd      |
+					// +----+----+----+----+----+
+					pasteTable( [
+						[ { contents: 'aa', colspan: 2 }, 'ac', 'ad', { contents: 'ae', rowspan: 2 } ],
+						[ 'ba', { contents: 'bb', colspan: 3, rowspan: 2 } ],
+						[ { contents: 'ca', rowspan: 2 }, 'ce' ],
+						[ 'db', 'dc', { contents: 'dd', colspan: 2 } ]
+					] );
+
+					// +----+----+----+----+----+----+
+					// | aa      | ac | ad | ae | 05 |
+					// +----+----+----+----+    +----+
+					// | ba | bb           |    | 15 |
+					// +----+              +----+----+
+					// | ca |              | ce | 25 |
+					// +    +----+----+----+----+----+
+					// |    | db | dc | dd      | 35 |
+					// +----+----+----+----+----+----+
+					// | 40 | 41 | 42 | 43 | 44 | 45 |
+					// +----+----+----+----+----+----+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ { contents: 'aa', colspan: 2 }, 'ac', 'ad', { contents: 'ae', rowspan: 2 }, '05' ],
+						[ 'ba', { contents: 'bb', colspan: 3, rowspan: 2 }, '15' ],
+						[ { contents: 'ca', rowspan: 2 }, 'ce', '25' ],
+						[ 'db', 'dc', { contents: 'dd', colspan: 2 }, '35' ],
+						[ '40', '41', '42', '43', '44', '45' ]
+					] ) );
+
+					/* eslint-disable no-multi-spaces */
+					assertSelectedCells( model, [
+						[ 1,    1, 1, 1, 0 ],
+						[ 1, 1,          0 ],
+						[ 1,          1, 0 ],
+						[    1, 1, 1,    0 ],
+						[ 0, 0, 0, 0, 0, 0 ]
+					] );
+					/* eslint-enable no-multi-spaces */
+				} );
+			} );
+
+			describe( 'content table has spans', () => {
+				it( 'handles pasting simple table over a table with colspans (no colspan exceeds selection)', () => {
+					setModelData( model, modelTable( [
+						[ '00[]', '01', '02', '03' ],
+						[ { colspan: 3, contents: '10' }, '13' ],
+						[ { colspan: 2, contents: '20' }, '22', '23' ],
+						[ '30', '31', { colspan: 2, contents: '31' } ]
+					] ) );
+
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+						modelRoot.getNodeByPath( [ 0, 2, 1 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab', 'ac' ],
+						[ 'ba', 'bb', 'bc' ],
+						[ 'ca', 'cb', 'cc' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ 'aa', 'ab', 'ac', '03' ],
+						[ 'ba', 'bb', 'bc', '13' ],
+						[ 'ca', 'cb', 'cc', '23' ],
+						[ '30', '31', { colspan: 2, contents: '31' } ]
+					] ) );
+
+					/* eslint-disable no-multi-spaces */
+					assertSelectedCells( model, [
+						[ 1, 1, 1, 0 ],
+						[ 1, 1, 1, 0 ],
+						[ 1, 1, 1, 0 ],
+						[ 0, 0, 0    ]
+					] );
+					/* eslint-enable no-multi-spaces */
+				} );
+
+				it( 'handles pasting simple table over a table with rowspans (no rowspan exceeds selection)', () => {
+					setModelData( model, modelTable( [
+						[ '00', { rowspan: 3, contents: '01' }, { rowspan: 2, contents: '02' }, '03' ],
+						[ { rowspan: 2, contents: '10' }, '13' ],
+						[ '22', '23' ],
+						[ '30', '31', '32', '33' ]
+					] ) );
+
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+						modelRoot.getNodeByPath( [ 0, 2, 0 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab', 'ac' ],
+						[ 'ba', 'bb', 'bc' ],
+						[ 'ca', 'cb', 'cc' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ 'aa', 'ab', 'ac', '03' ],
+						[ 'ba', 'bb', 'bc', '13' ],
+						[ 'ca', 'cb', 'cc', '23' ],
+						[ '30', '31', '32', '33' ]
+					] ) );
+
+					assertSelectedCells( model, [
+						[ 1, 1, 1, 0 ],
+						[ 1, 1, 1, 0 ],
+						[ 1, 1, 1, 0 ],
+						[ 0, 0, 0, 0 ]
+					] );
+				} );
+
+				it( 'handles pasting simple table over table with multi-spans (no span exceeds selection)', () => {
+					// +----+----+----+----+----+----+
+					// | 00      | 02 | 03      | 05 |
+					// +         +    +         +----+
+					// |         |    |         | 15 |
+					// +----+----+----+         +----+
+					// | 20 | 21      |         | 25 |
+					// +    +----+----+----+----+----+
+					// |    | 31 | 32      | 34 | 35 |
+					// +----+----+----+----+----+----+
+					// | 40 | 41 | 42 | 43 | 44 | 45 |
+					// +----+----+----+----+----+----+
+					setModelData( model, modelTable( [
+						[
+							{ contents: '00', colspan: 2, rowspan: 2 },
+							{ contents: '02', rowspan: 2 },
+							{ contents: '03', colspan: 2, rowspan: 3 },
+							'05'
+						],
+						[ '15' ],
+						[ { contents: '20', rowspan: 2 }, { contents: '21', colspan: 2 }, '25' ],
+						[ '31', { contents: '32', colspan: 2 }, '34', '35' ],
+						[ '40', '41', '42', '43', '44', '45' ]
+					] ) );
+
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+						modelRoot.getNodeByPath( [ 0, 3, 2 ] ) // Cell 34.
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab', 'ac', 'ad', 'ae' ],
+						[ 'ba', 'bb', 'bc', 'bd', 'be' ],
+						[ 'ca', 'cb', 'cc', 'cd', 'ce' ],
+						[ 'da', 'db', 'dc', 'dd', 'de' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ 'aa', 'ab', 'ac', 'ad', 'ae', '05' ],
+						[ 'ba', 'bb', 'bc', 'bd', 'be', '15' ],
+						[ 'ca', 'cb', 'cc', 'cd', 'ce', '25' ],
+						[ 'da', 'db', 'dc', 'dd', 'de', '35' ],
+						[ '40', '41', '42', '43', '44', '45' ]
+					] ) );
+
+					assertSelectedCells( model, [
+						[ 1, 1, 1, 1, 1, 0 ],
+						[ 1, 1, 1, 1, 1, 0 ],
+						[ 1, 1, 1, 1, 1, 0 ],
+						[ 1, 1, 1, 1, 1, 0 ],
+						[ 0, 0, 0, 0, 0, 0 ]
+					] );
+				} );
+
+				it( 'handles pasting simple table over a table with rowspan (rowspan before selection)', () => {
+					// +----+----+----+----+----+
+					// | 00 | 01 | 02 | 03 | 04 |
+					// +----+----+----+----+----+
+					// | 10 | 11 | 12 | 13 | 14 |
+					// +----+    +----+----+----+
+					// | 20 |    | 22 | 23 | 24 |
+					// +----+    +----+----+----+
+					// | 30 |    | 32 | 33 | 34 |
+					// +----+----+----+----+----+
+					// | 40 | 41 | 42 | 43 | 44 |
+					// +----+----+----+----+----+
+					setModelData( model, modelTable( [
+						[ '00', '01', '02', '03', '04' ],
+						[ '10', { contents: '11', rowspan: 3 }, '12', '13', '14' ],
+						[ '20', '22', '23', '24' ],
+						[ '30', '32', '33', '34' ],
+						[ '40', '41', '42', '43', '44' ]
+					] ) );
+
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 1, 2 ] ),
+						modelRoot.getNodeByPath( [ 0, 3, 2 ] ) // Cell 33.
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab' ],
+						[ 'ba', 'bb' ],
+						[ 'ca', 'cb' ]
+					] );
+
+					// +----+----+----+----+----+
+					// | 00 | 01 | 02 | 03 | 04 |
+					// +----+----+----+----+----+
+					// | 10 | 11 | aa | ab | 14 |
+					// +----+    +----+----+----+
+					// | 20 |    | ba | bb | 24 |
+					// +----+    +----+----+----+
+					// | 30 |    | ca | cb | 34 |
+					// +----+----+----+----+----+
+					// | 40 | 41 | 42 | 43 | 44 |
+					// +----+----+----+----+----+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02', '03', '04' ],
+						[ '10', { contents: '11', rowspan: 3 }, 'aa', 'ab', '14' ],
+						[ '20', 'ba', 'bb', '24' ],
+						[ '30', 'ca', 'cb', '34' ],
+						[ '40', '41', '42', '43', '44' ]
+					] ) );
+
+					/* eslint-disable no-multi-spaces */
+					assertSelectedCells( model, [
+						[ 0, 0, 0, 0, 0 ],
+						[ 0, 0, 1, 1, 0 ],
+						[ 0,    1, 1, 0 ],
+						[ 0,    1, 1, 0 ],
+						[ 0, 0, 0, 0, 0 ]
+					] );
+					/* eslint-enable no-multi-spaces */
+				} );
+
+				it( 'handles pasting simple table over a table with rowspans (rowspan before selection)', () => {
+					// +----+----+----+----+----+----+
+					// | 00 | 01 | 02 | 03 | 04 | 05 |
+					// +----+----+----+----+----+----+
+					// | 10      | 12 | 13 | 14 | 15 |
+					// +----+----+----+----+----+----+
+					// | 20           | 23 | 24 | 25 |
+					// +----+----+----+----+----+----+
+					// | 30 | 31 | 32 | 33 | 34 | 35 |
+					// +----+----+----+----+----+----+
+					setModelData( model, modelTable( [
+						[ '00', '01', '02', '03', '04', '05' ],
+						[ { contents: '10', colspan: 2 }, '12', '13', '14', '15' ],
+						[ { contents: '20', colspan: 3 }, '23', '24', '25' ],
+						[ '30', '31', '32', '33', '34', '35' ]
+					] ) );
+
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 1, 2 ] ), // Cell 13.
+						modelRoot.getNodeByPath( [ 0, 2, 2 ] ) // Cell 24.
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab' ],
+						[ 'ba', 'bb' ]
+					] );
+
+					// +----+----+----+----+----+----+
+					// | 00 | 01 | 02 | 03 | 04 | 05 |
+					// +----+----+----+----+----+----+
+					// | 10      | 12 | aa | ab | 15 |
+					// +----+----+----+----+----+----+
+					// | 20           | ba | bb | 25 |
+					// +----+----+----+----+----+----+
+					// | 30 | 31 | 32 | 33 | 34 | 35 |
+					// +----+----+----+----+----+----+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02', '03', '04', '05' ],
+						[ { contents: '10', colspan: 2 }, '12', 'aa', 'ab', '15' ],
+						[ { contents: '20', colspan: 3 }, 'ba', 'bb', '25' ],
+						[ '30', '31', '32', '33', '34', '35' ]
+					] ) );
+
+					/* eslint-disable no-multi-spaces */
+					assertSelectedCells( model, [
+						[ 0, 0, 0, 0, 0, 0 ],
+						[ 0,    0, 1, 1, 0 ],
+						[ 0,       1, 1, 0 ],
+						[ 0, 0, 0, 0, 0, 0 ]
+					] );
+					/* eslint-enable no-multi-spaces */
+				} );
+
+				it( 'handles pasting table that has cell with colspan (last row in selection is spanned)', () => {
+					// +----+----+----+----+
+					// | 00 | 01 | 02 | 03 |
+					// +----+----+----+----+
+					// | 10 | 11      | 13 |
+					// +    +         +----+
+					// |    |         | 23 |
+					// +----+----+----+----+
+					// | 30 | 31 | 32 | 33 |
+					// +----+----+----+----+
+					setModelData( model, modelTable( [
+						[ '00', '01', '02', '03' ],
+						[ { contents: '10', rowspan: 2 }, { contents: '11', colspan: 2, rowspan: 2 }, '13' ],
+						[ '23' ],
+						[ '30', '31', '32', '33' ]
+					] ) );
+
+					// Select 02 -> 10 (selection 3x3)
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 2 ] ),
+						modelRoot.getNodeByPath( [ 0, 1, 0 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab', 'ac' ],
+						[ 'ba', 'bb', 'bc' ],
+						[ 'ca', 'cb', 'cc' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ 'aa', 'ab', 'ac', '03' ],
+						[ 'ba', 'bb', 'bc', '13' ],
+						[ 'ca', 'cb', 'cc', '23' ],
+						[ '30', '31', '32', '33' ]
+					] ) );
+
+					assertSelectedCells( model, [
+						[ 1, 1, 1, 0 ],
+						[ 1, 1, 1, 0 ],
+						[ 1, 1, 1, 0 ],
+						[ 0, 0, 0, 0 ]
+					] );
+				} );
+
+				it( 'handles pasting table that has cell with colspan (multiple ending rows in the selection are spanned)', () => {
+					// +----+----+----+
+					// | 00 | 01 | 02 |
+					// +----+    +    +
+					// | 10 |    |    |
+					// +----+    +    +
+					// | 20 |    |    |
+					// +----+----+----+
+					// | 30 | 31 | 32 |
+					// +----+----+----+
+					setModelData( model, modelTable( [
+						[ '00', { contents: '01', rowspan: 3 }, { contents: '02', rowspan: 3 } ],
+						[ '10' ],
+						[ '20' ],
+						[ '30', '31', '32' ]
+					] ) );
+
+					// Select 01 -> 02 (selection 2x2)
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
+						modelRoot.getNodeByPath( [ 0, 0, 2 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab' ],
+						[ 'ba', 'bb' ],
+						[ 'ca', 'cb' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', 'aa', 'ab' ],
+						[ '10', 'ba', 'bb' ],
+						[ '20', 'ca', 'cb' ],
+						[ '30', '31', '32' ]
+					] ) );
+
+					assertSelectedCells( model, [
+						[ 0, 1, 1 ],
+						[ 0, 1, 1 ],
+						[ 0, 1, 1 ],
+						[ 0, 0, 0 ]
+					] );
+				} );
+
+				it( 'handles pasting table that has cell with colspan (last column in selection is spanned)', () => {
+					// +----+----+----+----+
+					// | 00 | 01      | 03 |
+					// +----+----+----+----+
+					// | 10 | 11      | 13 |
+					// +----+         +----+
+					// | 20 |         | 23 |
+					// +----+----+----+----+
+					// | 30 | 31 | 32 | 33 |
+					// +----+----+----+----+
+					setModelData( model, modelTable( [
+						[ '00', { contents: '01', colspan: 2 }, '03' ],
+						[ '10', { contents: '11', colspan: 2, rowspan: 2 }, '13' ],
+						[ '20', '23' ],
+						[ '30', '31', '32', '33' ]
+					] ) );
+
+					// Select 20 -> 01 (selection 3x3)
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 2, 0 ] ),
+						modelRoot.getNodeByPath( [ 0, 0, 1 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab', 'ac' ],
+						[ 'ba', 'bb', 'bc' ],
+						[ 'ca', 'cb', 'cc' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ 'aa', 'ab', 'ac', '03' ],
+						[ 'ba', 'bb', 'bc', '13' ],
+						[ 'ca', 'cb', 'cc', '23' ],
+						[ '30', '31', '32', '33' ]
+					] ) );
+
+					assertSelectedCells( model, [
+						[ 1, 1, 1, 0 ],
+						[ 1, 1, 1, 0 ],
+						[ 1, 1, 1, 0 ],
+						[ 0, 0, 0, 0 ]
+					] );
+				} );
+
+				it( 'handles pasting table that has cell with colspan (multiple ending columns in the selection are spanned)', () => {
+					// +----+----+----+----+
+					// | 00 | 01 | 02 | 03 |
+					// +----+----+----+----+
+					// | 10           | 13 |
+					// +----+----+----+----+
+					// | 20           | 23 |
+					// +----+----+----+----+
+					setModelData( model, modelTable( [
+						[ '00', '01', '02', '03' ],
+						[ { contents: '10', colspan: 3 }, '13' ],
+						[ { contents: '20', colspan: 3 }, '23' ]
+					] ) );
+
+					// Select 10 -> 20 (selection 3x2)
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
+						modelRoot.getNodeByPath( [ 0, 2, 0 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab', 'ac' ],
+						[ 'ba', 'bb', 'bc' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02', '03' ],
+						[ 'aa', 'ab', 'ac', '13' ],
+						[ 'ba', 'bb', 'bc', '23' ]
+					] ) );
+
+					assertSelectedCells( model, [
+						[ 0, 0, 0, 0 ],
+						[ 1, 1, 1, 0 ],
+						[ 1, 1, 1, 0 ]
+					] );
+				} );
+			} );
+
+			describe( 'content and paste tables have spans', () => {
+				it( 'handles pasting colspanned table over table with colspans (no colspan exceeds selection)', () => {
+					setModelData( model, modelTable( [
+						[ '00[]', '01', '02', '03' ],
+						[ { colspan: 3, contents: '10' }, '13' ],
+						[ { colspan: 2, contents: '20' }, '22', '23' ],
+						[ '30', '31', { colspan: 2, contents: '31' } ]
+					] ) );
+
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+						modelRoot.getNodeByPath( [ 0, 2, 1 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', { colspan: 2, contents: 'ab' } ],
+						[ { colspan: 3, contents: 'ba' } ],
+						[ 'ca', 'cb', 'cc' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ 'aa', { colspan: 2, contents: 'ab' }, '03' ],
+						[ { colspan: 3, contents: 'ba' }, '13' ],
+						[ 'ca', 'cb', 'cc', '23' ],
+						[ '30', '31', { colspan: 2, contents: '31' } ]
+					] ) );
+
+					/* eslint-disable no-multi-spaces */
+					assertSelectedCells( model, [
+						[ 1, 1,    0 ],
+						[ 1,       0 ],
+						[ 1, 1, 1, 0 ],
+						[ 0, 0, 0    ]
+					] );
+					/* eslint-enable no-multi-spaces */
+				} );
+
+				it( 'handles pasting rowspanned table over table with rowspans (no rowspan exceeds selection)', () => {
+					setModelData( model, modelTable( [
+						[ { rowspan: 3, contents: '00' }, { rowspan: 2, contents: '01' }, '02', '03' ],
+						[ { rowspan: 2, contents: '12' }, '13' ],
+						[ '21', '23' ],
+						[ '30', '31', '32', '33' ]
+					] ) );
+
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+						modelRoot.getNodeByPath( [ 0, 2, 1 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', { rowspan: 3, contents: 'ab' }, { rowspan: 2, contents: 'ac' }, 'ad' ],
+						[ { rowspan: 2, contents: 'ba' }, 'bd' ],
+						[ 'cc', 'cd' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ 'aa', { rowspan: 3, contents: 'ab' }, { rowspan: 2, contents: 'ac' }, 'ad' ],
+						[ { rowspan: 2, contents: 'ba' }, 'bd' ],
+						[ 'cc', 'cd' ],
+						[ '30', '31', '32', '33' ]
+					] ) );
+
+					/* eslint-disable no-multi-spaces */
+					assertSelectedCells( model, [
+						[ 1, 1, 1, 1 ],
+						[ 1,       1 ],
+						[       1, 1 ],
+						[ 0, 0, 0, 0 ]
+					] );
+					/* eslint-enable no-multi-spaces */
+				} );
+
+				it( 'handles pasting multi-spanned table over table with multi-spans (no span exceeds selection)', () => {
+					// +----+----+----+----+----+----+
+					// | 00      | 02 | 03      | 05 |
+					// +         +    +         +----+
+					// |         |    |         | 15 |
+					// +----+----+----+         +----+
+					// | 20 | 21      |         | 25 |
+					// +    +----+----+----+----+----+
+					// |    | 31 | 32      | 34 | 35 |
+					// +----+----+----+----+----+----+
+					// | 40 | 41 | 42 | 43 | 44 | 45 |
+					// +----+----+----+----+----+----+
+					setModelData( model, modelTable( [
+						[
+							{ contents: '00', colspan: 2, rowspan: 2 },
+							{ contents: '02', rowspan: 2 },
+							{ contents: '03', colspan: 2, rowspan: 3 },
+							'05'
+						],
+						[ '15' ],
+						[ { contents: '20', rowspan: 2 }, { contents: '21', colspan: 2 }, '25' ],
+						[ '31', { contents: '32', colspan: 2 }, '34', '35' ],
+						[ '40', '41', '42', '43', '44', '45' ]
+					] ) );
+
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+						modelRoot.getNodeByPath( [ 0, 3, 2 ] )
+					);
+
+					// +----+----+----+----+----+
+					// | aa      | ac | ad | ae |
+					// +----+----+----+----+    +
+					// | ba | bb           |    |
+					// +----+              +----+
+					// | ca |              | ce |
+					// +    +----+----+----+----+
+					// |    | db | dc | dd      |
+					// +----+----+----+----+----+
+					pasteTable( [
+						[ { contents: 'aa', colspan: 2 }, 'ac', 'ad', { contents: 'ae', rowspan: 2 } ],
+						[ 'ba', { contents: 'bb', colspan: 3, rowspan: 2 } ],
+						[ { contents: 'ca', rowspan: 2 }, 'ce' ],
+						[ 'db', 'dc', { contents: 'dd', colspan: 2 } ]
+					] );
+
+					// +----+----+----+----+----+----+
+					// | aa      | ac | ad | ae | 05 |
+					// +----+----+----+----+    +----+
+					// | ba | bb           |    | 15 |
+					// +----+              +----+----+
+					// | ca |              | ce | 25 |
+					// +    +----+----+----+----+----+
+					// |    | db | dc | dd      | 35 |
+					// +----+----+----+----+----+----+
+					// | 40 | 41 | 42 | 43 | 44 | 45 |
+					// +----+----+----+----+----+----+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ { contents: 'aa', colspan: 2 }, 'ac', 'ad', { contents: 'ae', rowspan: 2 }, '05' ],
+						[ 'ba', { contents: 'bb', colspan: 3, rowspan: 2 }, '15' ],
+						[ { contents: 'ca', rowspan: 2 }, 'ce', '25' ],
+						[ 'db', 'dc', { contents: 'dd', colspan: 2 }, '35' ],
+						[ '40', '41', '42', '43', '44', '45' ]
+					] ) );
+
+					/* eslint-disable no-multi-spaces */
+					assertSelectedCells( model, [
+						[ 1,    1, 1, 1, 0 ],
+						[ 1, 1,          0 ],
+						[ 1,          1, 0 ],
+						[    1, 1, 1,    0 ],
+						[ 0, 0, 0, 0, 0, 0 ]
+					] );
+					/* eslint-enable no-multi-spaces */
+				} );
+
+				it( 'handles pasting table that has cell with colspan (last row in selection is spanned)', () => {
+					// +----+----+----+----+
+					// | 00 | 01 | 02 | 03 |
+					// +----+----+----+----+
+					// | 10 | 11      | 13 |
+					// +    +         +----+
+					// |    |         | 23 |
+					// +----+----+----+----+
+					// | 30 | 31 | 32 | 33 |
+					// +----+----+----+----+
+					setModelData( model, modelTable( [
+						[ '00', '01', '02', '03' ],
+						[ { contents: '10', rowspan: 2 }, { contents: '11', colspan: 2, rowspan: 2 }, '13' ],
+						[ '23' ],
+						[ '30', '31', '32', '33' ]
+					] ) );
+
+					// Select 02 -> 10 (selection 3x3)
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 2 ] ),
+						modelRoot.getNodeByPath( [ 0, 1, 0 ] )
+					);
+
+					// +----+----+----+
+					// | aa | ab | ac |
+					// +----+----+----+
+					// | ba      | bc |
+					// +         +----+
+					// |         | cc |
+					// +----+----+----+
+					pasteTable( [
+						[ 'aa', 'ab', 'ac' ],
+						[ { contents: 'ba', colspan: 2, rowspan: 2 }, 'bc' ],
+						[ 'cc' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ 'aa', 'ab', 'ac', '03' ],
+						[ { contents: 'ba', colspan: 2, rowspan: 2 }, 'bc', '13' ],
+						[ 'cc', '23' ],
+						[ '30', '31', '32', '33' ]
+					] ) );
+
+					/* eslint-disable no-multi-spaces */
+					assertSelectedCells( model, [
+						[ 1, 1, 1, 0 ],
+						[ 1,    1, 0 ],
+						[       1, 0 ],
+						[ 0, 0, 0, 0 ]
+					] );
+					/* eslint-enable no-multi-spaces */
+				} );
+
+				it( 'handles pasting table that has cell with colspan (last column in selection is spanned)', () => {
+					// +----+----+----+----+
+					// | 00 | 01      | 03 |
+					// +----+----+----+----+
+					// | 10 | 11      | 13 |
+					// +----+         +----+
+					// | 20 |         | 23 |
+					// +----+----+----+----+
+					// | 30 | 31 | 32 | 33 |
+					// +----+----+----+----+
+					setModelData( model, modelTable( [
+						[ '00', { contents: '01', colspan: 2 }, '03' ],
+						[ '10', { contents: '11', colspan: 2, rowspan: 2 }, '13' ],
+						[ '20', '23' ],
+						[ '30', '31', '32', '33' ]
+					] ) );
+
+					// Select 20 -> 01 (selection 3x3)
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 2, 0 ] ),
+						modelRoot.getNodeByPath( [ 0, 0, 1 ] )
+					);
+
+					// +----+----+----+
+					// | aa | ab | ac |
+					// +----+----+----+
+					// | ba      | bc |
+					// +         +----+
+					// |         | cc |
+					// +----+----+----+
+					pasteTable( [
+						[ 'aa', 'ab', 'ac' ],
+						[ { contents: 'ba', colspan: 2, rowspan: 2 }, 'bc' ],
+						[ 'cc' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ 'aa', 'ab', 'ac', '03' ],
+						[ { contents: 'ba', colspan: 2, rowspan: 2 }, 'bc', '13' ],
+						[ 'cc', '23' ],
+						[ '30', '31', '32', '33' ]
+					] ) );
+
+					/* eslint-disable no-multi-spaces */
+					assertSelectedCells( model, [
+						[ 1, 1, 1, 0 ],
+						[ 1,    1, 0 ],
+						[       1, 0 ],
+						[ 0, 0, 0, 0 ]
+					] );
+					/* eslint-enable no-multi-spaces */
+				} );
+			} );
+
+			describe( 'non-rectangular content table selection', () => {
+				it( 'should split cells outside the selected area before pasting (rowspan ends in selection)', () => {
+					// +----+----+----+
+					// | 00 | 01 | 02 |
+					// +----+    +----+
+					// | 10 |    | 12 |
+					// +----+    +----+
+					// | 20 |    | 22 |
+					// +----+    +----+
+					// | 30 |    | 32 |
+					// +----+----+----+
+					// | 40 | 41 | 42 |
+					// +----+----+----+
+					setModelData( model, modelTable( [
+						[ '00', { contents: '01', rowspan: 4 }, '02' ],
+						[ '10', '12' ],
+						[ '20', '22' ],
+						[ '30', '32' ],
+						[ '40', '41', '42' ]
+					] ) );
+
+					// Select 20 -> 32
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 2, 0 ] ),
+						modelRoot.getNodeByPath( [ 0, 3, 1 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab', 'ac' ],
+						[ 'ba', 'bb', 'bc' ]
+					] );
+
+					// +----+----+----+
+					// | 00 | 01 | 02 |
+					// +----+    +----+
+					// | 10 |    | 12 |
+					// +----+----+----+
+					// | aa | ab | ac |
+					// +----+----+----+
+					// | ba | bb | bc |
+					// +----+----+----+
+					// | 40 | 41 | 42 |
+					// +----+----+----+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', { contents: '01', rowspan: 2 }, '02' ],
+						[ '10', '12' ],
+						[ 'aa', 'ab', 'ac' ],
+						[ 'ba', 'bb', 'bc' ],
+						[ '40', '41', '42' ]
+					] ) );
+				} );
+
+				it( 'should split cells outside the selected area before pasting (rowspan ends after the selection)', () => {
+					// +----+----+----+
+					// | 00 | 01 | 02 |
+					// +----+    +----+
+					// | 10 |    | 12 |
+					// +----+    +----+
+					// | 20 |    | 22 |
+					// +----+    +----+
+					// | 30 |    | 32 |
+					// +----+----+----+
+					// | 40 | 41 | 42 |
+					// +----+----+----+
+					setModelData( model, modelTable( [
+						[ '00', { contents: '01', rowspan: 4 }, '02' ],
+						[ '10', '12' ],
+						[ '20', '22' ],
+						[ '30', '32' ],
+						[ '40', '41', '42' ]
+					] ) );
+
+					// Select 10 -> 22
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
+						modelRoot.getNodeByPath( [ 0, 2, 1 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab', 'ac' ],
+						[ 'ba', 'bb', 'bc' ]
+					] );
+
+					// +----+----+----+
+					// | 00 | 01 | 02 |
+					// +----+----+----+
+					// | aa | ab | ac |
+					// +----+----+----+
+					// | ba | bb | bc |
+					// +----+----+----+
+					// | 30 |    | 32 |
+					// +----+----+----+
+					// | 40 | 41 | 42 |
+					// +----+----+----+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02' ],
+						[ 'aa', 'ab', 'ac' ],
+						[ 'ba', 'bb', 'bc' ],
+						[ '30', '', '32' ],
+						[ '40', '41', '42' ]
+					] ) );
+				} );
+
+				it( 'should split cells inside the selected area before pasting (rowspan ends after the selection)', () => {
+					// +----+----+----+
+					// | 00 | 01 | 02 |
+					// +----+    +----+
+					// | 10 |    | 12 |
+					// +----+    +----+
+					// | 20 |    | 22 |
+					// +----+    +----+
+					// | 30 |    | 32 |
+					// +----+----+----+
+					// | 40 | 41 | 42 |
+					// +----+----+----+
+					setModelData( model, modelTable( [
+						[ '00', { contents: '01', rowspan: 4 }, '02' ],
+						[ '10', '12' ],
+						[ '20', '22' ],
+						[ '30', '32' ],
+						[ '40', '41', '42' ]
+					] ) );
+
+					// Select 00 -> 12
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+						modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab', 'ac' ],
+						[ 'ba', 'bb', 'bc' ]
+					] );
+
+					// +----+----+----+
+					// | aa | ab | ac |
+					// +----+----+----+
+					// | ba | bb | bc |
+					// +----+----+----+
+					// | 20 |    | 22 |
+					// +----+    +----+
+					// | 30 |    | 32 |
+					// +----+----+----+
+					// | 40 | 41 | 42 |
+					// +----+----+----+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ 'aa', 'ab', 'ac' ],
+						[ 'ba', 'bb', 'bc' ],
+						[ '20', { rowspan: 2, contents: '' }, '22' ],
+						[ '30', '32' ],
+						[ '40', '41', '42' ]
+					] ) );
+				} );
+
+				it( 'should split cells outside the selected area before pasting (colspan ends in selection)', () => {
+					// +----+----+----+----+----+
+					// | 00 | 01 | 02 | 03 | 04 |
+					// +----+----+----+----+----+
+					// | 10                | 14 |
+					// +----+----+----+----+----+
+					// | 20 | 21 | 22 | 23 | 24 |
+					// +----+----+----+----+----+
+					setModelData( model, modelTable( [
+						[ '00', '01', '02', '03', '04' ],
+						[ { contents: '10', colspan: 4 }, '14' ],
+						[ '20', '21', '22', '23', '24' ]
+					] ) );
+
+					// Select 02 -> 23
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 2 ] ),
+						modelRoot.getNodeByPath( [ 0, 2, 3 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab' ],
+						[ 'ba', 'bb' ],
+						[ 'ca', 'cb' ]
+					] );
+
+					// +----+----+----+----+----+
+					// | 00 | 01 | aa | ab | 04 |
+					// +----+----+----+----+----+
+					// | 10      | ba | bb | 14 |
+					// +----+----+----+----+----+
+					// | 20 | 21 | ca | cb | 24 |
+					// +----+----+----+----+----+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', 'aa', 'ab', '04' ],
+						[ { contents: '10', colspan: 2 }, 'ba', 'bb', '14' ],
+						[ '20', '21', 'ca', 'cb', '24' ]
+					] ) );
+				} );
+
+				it( 'should split cells outside the selected area before pasting (colspan ends after the selection)', () => {
+					// +----+----+----+----+----+
+					// | 00 | 01 | 02 | 03 | 04 |
+					// +----+----+----+----+----+
+					// | 10                | 14 |
+					// +----+----+----+----+----+
+					// | 20 | 21 | 22 | 23 | 24 |
+					// +----+----+----+----+----+
+					setModelData( model, modelTable( [
+						[ '00', '01', '02', '03', '04' ],
+						[ { contents: '10', colspan: 4 }, '14' ],
+						[ '20', '21', '22', '23', '24' ]
+					] ) );
+
+					// Select 01 -> 22
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
+						modelRoot.getNodeByPath( [ 0, 2, 2 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab' ],
+						[ 'ba', 'bb' ],
+						[ 'ca', 'cb' ]
+					] );
+
+					// +----+----+----+----+----+
+					// | 00 | aa | ab | 03 | 04 |
+					// +----+----+----+----+----+
+					// | 10 | ba | bb |    | 14 |
+					// +----+----+----+----+----+
+					// | 20 | ca | cb | 23 | 24 |
+					// +----+----+----+----+----+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', 'aa', 'ab', '03', '04' ],
+						[ '10', 'ba', 'bb', '', '14' ],
+						[ '20', 'ca', 'cb', '23', '24' ]
+					] ) );
+				} );
+
+				it( 'should split cells inside the selected area before pasting (colspan ends after the selection)', () => {
+					// +----+----+----+----+----+
+					// | 00 | 01 | 02 | 03 | 04 |
+					// +----+----+----+----+----+
+					// | 10                | 14 |
+					// +----+----+----+----+----+
+					// | 20 | 21 | 22 | 23 | 24 |
+					// +----+----+----+----+----+
+					setModelData( model, modelTable( [
+						[ '00', '01', '02', '03', '04' ],
+						[ { contents: '10', colspan: 4 }, '14' ],
+						[ '20', '21', '22', '23', '24' ]
+					] ) );
+
+					// Select 00 -> 21
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+						modelRoot.getNodeByPath( [ 0, 2, 1 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab' ],
+						[ 'ba', 'bb' ],
+						[ 'ca', 'cb' ]
+					] );
+
+					// +----+----+----+----+----+
+					// | aa | ab | 02 | 03 | 04 |
+					// +----+----+----+----+----+
+					// | ba | bb |         | 14 |
+					// +----+----+----+----+----+
+					// | ca | cb | 22 | 23 | 24 |
+					// +----+----+----+----+----+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ 'aa', 'ab', '02', '03', '04' ],
+						[ 'ba', 'bb', { colspan: 2, contents: '' }, '14' ],
+						[ 'ca', 'cb', '22', '23', '24' ]
+					] ) );
+				} );
+
+				it( 'should split cells anchored outside selection rectangle that overlaps selection (above selection)', () => {
+					// +----+----+----+
+					// | 00      | 02 |
+					// +         +----+
+					// |         | 12 |
+					// +----+----+----+
+					// | 20 | 21 | 22 |
+					// +----+----+----+
+					setModelData( model, modelTable( [
+						[ { contents: '00', colspan: 2, rowspan: 2 }, '02' ],
+						[ '12' ],
+						[ '20', '21', '22' ]
+					] ) );
+
+					// Select 21 -> 12
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 2, 1 ] ),
+						modelRoot.getNodeByPath( [ 0, 1, 0 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab' ],
+						[ 'ba', 'bb' ]
+					] );
+
+					// +----+----+----+
+					// | 00 |    | 02 |
+					// +    +----+----+
+					// |    | aa | ab |
+					// +----+----+----+
+					// | 20 | ba | bb |
+					// +----+----+----+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ { contents: '00', rowspan: 2 }, '', '02' ],
+						[ 'aa', 'ab' ],
+						[ '20', 'ba', 'bb' ]
+					] ) );
+				} );
+
+				it( 'should split cells anchored outside selection rectangle that overlaps selection (below selection)', () => {
+					// +----+----+----+
+					// | 00 | 01 | 02 |
+					// +----+----+----+
+					// | 10      | 12 |
+					// +         +----+
+					// |         | 22 |
+					// +----+----+----+
+					setModelData( model, modelTable( [
+						[ '00', '01', '02' ],
+						[ { contents: '10', colspan: 2, rowspan: 2 }, '12' ],
+						[ '22' ]
+					] ) );
+
+					// Select 01 -> 12
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
+						modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab' ],
+						[ 'ba', 'bb' ]
+					] );
+
+					// +----+----+----+
+					// | 00 | aa | ab |
+					// +----+----+----+
+					// | 10 | ba | bb |
+					// +    +----+----+
+					// |    |    | 22 |
+					// +----+----+----+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', 'aa', 'ab' ],
+						[ { contents: '10', rowspan: 2 }, 'ba', 'bb' ],
+						[ '', '22' ]
+					] ) );
+				} );
+
+				it( 'should properly handle complex case', () => {
+					// +----+----+----+----+----+----+----+
+					// | 00           | 03 | 04           |
+					// +              +    +----+----+----+
+					// |              |    | 14 | 15 | 16 |
+					// +              +    +----+----+----+
+					// |              |    | 24           |
+					// +----+----+----+----+----+----+----+
+					// | 30 | 31      | 33 | 34 | 35 | 36 |
+					// +    +----+----+    +----+----+----+
+					// |    | 41 | 42 |    | 44 | 45      |
+					// +    +----+----+    +----+         +
+					// |    | 51 | 52 |    | 54 |         |
+					// +----+----+----+    +----+         +
+					// | 60 | 61 | 62 |    | 64 |         |
+					// +----+----+----+----+----+----+----+
+					setModelData( model, modelTable( [
+						[ { contents: '00', colspan: 3, rowspan: 3 }, { contents: '03', rowspan: 3 }, { colspan: 3, contents: '04' } ],
+						[ '14', '15', '16' ],
+						[ { contents: '24', colspan: 3 } ],
+						[
+							{ contents: '30', rowspan: 3 },
+							{ contents: '31', colspan: 2 },
+							{ contents: '33', rowspan: 4 },
+							'34', '35', '36'
+						],
+						[ '41', '42', '44', { contents: '45', colspan: 2, rowspan: 3 } ],
+						[ '51', '52', '54' ],
+						[ '60', '61', '62', '64' ]
+					] ) );
+
+					// Select 42 -> 24 (3x3 selection)
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 4, 1 ] ),
+						modelRoot.getNodeByPath( [ 0, 2, 0 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab', 'ac' ],
+						[ 'ba', 'bb', 'bc' ],
+						[ 'ca', 'cb', 'cc' ]
+					] );
+
+					// +----+----+----+----+----+----+----+
+					// | 00      |    | 03 | 04           |
+					// +         +    +    +----+----+----+
+					// |         |    |    | 14 | 15 | 16 |
+					// +         +----+----+----+----+----+
+					// |         | aa | ab | ac |         |
+					// +----+----+----+----+----+----+----+
+					// | 30 | 31 | ba | bb | bc | 35 | 36 |
+					// +    +----+----+----+----+----+----+
+					// |    | 41 | ca | cb | cc | 45      |
+					// +    +----+----+----+----+         +
+					// |    | 51 | 52 |    | 54 |         |
+					// +----+----+----+    +----+         +
+					// | 60 | 61 | 62 |    | 64 |         |
+					// +----+----+----+----+----+----+----+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[
+							{ contents: '00', colspan: 2, rowspan: 3 },
+							{ contents: '', rowspan: 2 },
+							{ contents: '03', rowspan: 2 },
+							{ contents: '04', colspan: 3 }
+						],
+						[ '14', '15', '16' ],
+						[ 'aa', 'ab', 'ac', { contents: '', colspan: 2 } ],
+						[ { contents: '30', rowspan: 3 }, '31', 'ba', 'bb', 'bc', '35', '36' ],
+						[ '41', 'ca', 'cb', 'cc', { contents: '45', colspan: 2, rowspan: 3 } ],
+						[ '51', '52', { contents: '', rowspan: 2 }, '54' ],
+						[ '60', '61', '62', '64' ]
+					] ) );
+				} );
+			} );
+		} );
+
+		describe( 'pasted table is bigger than the selected area', () => {
+			describe( 'no spans', () => {
+				it( 'handles simple table paste to a simple table fragment - at the beginning of a table', () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+						modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab', 'ac' ],
+						[ 'ba', 'bb', 'bc' ],
+						[ 'ca', 'cb', 'cc' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ 'aa', 'ab', '02', '03' ],
+						[ 'ba', 'bb', '12', '13' ],
+						[ '20', '21', '22', '23' ],
+						[ '30', '31', '32', '33' ]
+					] ) );
+
+					assertSelectedCells( model, [
+						[ 1, 1, 0, 0 ],
+						[ 1, 1, 0, 0 ],
+						[ 0, 0, 0, 0 ],
+						[ 0, 0, 0, 0 ]
+					] );
+				} );
+
+				it( 'handles simple table paste to a simple table fragment - at the end of a table', () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 2, 2 ] ),
+						modelRoot.getNodeByPath( [ 0, 3, 3 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab', 'ac' ],
+						[ 'ba', 'bb', 'bc' ],
+						[ 'ca', 'cb', 'cc' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02', '03' ],
+						[ '10', '11', '12', '13' ],
+						[ '20', '21', 'aa', 'ab' ],
+						[ '30', '31', 'ba', 'bb' ]
+					] ) );
+
+					assertSelectedCells( model, [
+						[ 0, 0, 0, 0 ],
+						[ 0, 0, 0, 0 ],
+						[ 0, 0, 1, 1 ],
+						[ 0, 0, 1, 1 ]
+					] );
+				} );
+
+				it( 'handles simple table paste to a simple table fragment - in the middle of a table', () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 1, 1 ] ),
+						modelRoot.getNodeByPath( [ 0, 2, 2 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab', 'ac' ],
+						[ 'ba', 'bb', 'bc' ],
+						[ 'ca', 'cb', 'cc' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02', '03' ],
+						[ '10', 'aa', 'ab', '13' ],
+						[ '20', 'ba', 'bb', '23' ],
+						[ '30', '31', '32', '33' ]
+					] ) );
+
+					assertSelectedCells( model, [
+						[ 0, 0, 0, 0 ],
+						[ 0, 1, 1, 0 ],
+						[ 0, 1, 1, 0 ],
+						[ 0, 0, 0, 0 ]
+					] );
+				} );
+
+				it( 'handles paste to a simple row fragment - in the middle of a table', () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 1, 1 ] ),
+						modelRoot.getNodeByPath( [ 0, 1, 2 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab', 'ac' ],
+						[ 'ba', 'bb', 'bc' ],
+						[ 'ca', 'cb', 'cc' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02', '03' ],
+						[ '10', 'aa', 'ab', '13' ],
+						[ '20', '21', '22', '23' ],
+						[ '30', '31', '32', '33' ]
+					] ) );
+
+					assertSelectedCells( model, [
+						[ 0, 0, 0, 0 ],
+						[ 0, 1, 1, 0 ],
+						[ 0, 0, 0, 0 ],
+						[ 0, 0, 0, 0 ]
+					] );
+				} );
+
+				it( 'handles paste to a simple column fragment - in the middle of a table', () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 1, 1 ] ),
+						modelRoot.getNodeByPath( [ 0, 2, 1 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab', 'ac' ],
+						[ 'ba', 'bb', 'bc' ],
+						[ 'ca', 'cb', 'cc' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02', '03' ],
+						[ '10', 'aa', '12', '13' ],
+						[ '20', 'ba', '22', '23' ],
+						[ '30', '31', '32', '33' ]
+					] ) );
+
+					assertSelectedCells( model, [
+						[ 0, 0, 0, 0 ],
+						[ 0, 1, 0, 0 ],
+						[ 0, 1, 0, 0 ],
+						[ 0, 0, 0, 0 ]
+					] );
+				} );
+
+				it( 'handles simple table paste to a simple table fragment - whole table selected', () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+						modelRoot.getNodeByPath( [ 0, 3, 3 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab', 'ac', 'ad', 'ae' ],
+						[ 'ba', 'bb', 'bc', 'bd', 'be' ],
+						[ 'ca', 'cb', 'cc', 'cd', 'ce' ],
+						[ 'da', 'db', 'dc', 'dd', 'de' ],
+						[ 'ea', 'eb', 'ec', 'ed', 'ee' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ 'aa', 'ab', 'ac', 'ad' ],
+						[ 'ba', 'bb', 'bc', 'bd' ],
+						[ 'ca', 'cb', 'cc', 'cd' ],
+						[ 'da', 'db', 'dc', 'dd' ]
+					] ) );
+
+					assertSelectedCells( model, [
+						[ 1, 1, 1, 1 ],
+						[ 1, 1, 1, 1 ],
+						[ 1, 1, 1, 1 ],
+						[ 1, 1, 1, 1 ]
+					] );
+				} );
+			} );
+
+			describe( 'pasted table has spans', () => {
+				it( 'handles pasting table that has cell with colspan', () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 1, 1 ] ),
+						modelRoot.getNodeByPath( [ 0, 2, 2 ] )
+					);
+
+					pasteTable( [
+						[ { colspan: 3, contents: 'aa' } ],
+						[ 'ba', 'bb', 'bc' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02', '03' ],
+						[ '10', { colspan: 2, contents: 'aa' }, '13' ],
+						[ '20', 'ba', 'bb', '23' ],
+						[ '30', '31', '32', '33' ]
+					] ) );
+
+					/* eslint-disable no-multi-spaces */
+					assertSelectedCells( model, [
+						[ 0, 0, 0, 0 ],
+						[ 0, 1,    0 ],
+						[ 0, 1, 1, 0 ],
+						[ 0, 0, 0, 0 ]
+					] );
+					/* eslint-enable no-multi-spaces */
+				} );
+
+				it( 'handles pasting table that has many cells with various colspan', () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+						modelRoot.getNodeByPath( [ 0, 3, 2 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', { colspan: 3, contents: 'ab' } ],
+						[ { colspan: 4, contents: 'ba' } ],
+						[ 'ca', 'cb', 'cc', 'cd' ],
+						[ { colspan: 2, contents: 'da' }, 'dc', 'dd' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ 'aa', { colspan: 2, contents: 'ab' }, '03' ],
+						[ { colspan: 3, contents: 'ba' }, '13' ],
+						[ 'ca', 'cb', 'cc', '23' ],
+						[ { colspan: 2, contents: 'da' }, 'dc', '33' ]
+					] ) );
+
+					/* eslint-disable no-multi-spaces */
+					assertSelectedCells( model, [
+						[ 1, 1,    0 ],
+						[ 1,       0 ],
+						[ 1, 1, 1, 0 ],
+						[ 1,    1, 0 ]
+					] );
+					/* eslint-enable no-multi-spaces */
+				} );
+
+				it( 'handles pasting table that has cell with rowspan', () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 1, 1 ] ),
+						modelRoot.getNodeByPath( [ 0, 2, 2 ] )
+					);
+
+					pasteTable( [
+						[ { rowspan: 3, contents: 'aa' }, 'ab' ],
+						[ 'bb' ],
+						[ 'cb' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02', '03' ],
+						[ '10', { rowspan: 2, contents: 'aa' }, 'ab', '13' ],
+						[ '20', 'bb', '23' ],
+						[ '30', '31', '32', '33' ]
+					] ) );
+
+					/* eslint-disable no-multi-spaces */
+					assertSelectedCells( model, [
+						[ 0, 0, 0, 0 ],
+						[ 0, 1, 1, 0 ],
+						[ 0,    1, 0 ],
+						[ 0, 0, 0, 0 ]
+					] );
+					/* eslint-enable no-multi-spaces */
+				} );
+
+				it( 'handles pasting table that has many cells with various rowspan', () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+						modelRoot.getNodeByPath( [ 0, 2, 3 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', { rowspan: 3, contents: 'ab' }, { rowspan: 2, contents: 'ac' }, 'ad', 'ae' ],
+						[ { rowspan: 3, contents: 'ba' }, 'bd', 'be' ],
+						[ 'cc', 'cd', 'ce' ],
+						[ 'da', 'db', 'dc', 'dd' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ 'aa', { rowspan: 3, contents: 'ab' }, { rowspan: 2, contents: 'ac' }, 'ad' ],
+						[ { rowspan: 2, contents: 'ba' }, 'bd' ],
+						[ 'cc', 'cd' ],
+						[ '30', '31', '32', '33' ]
+					] ) );
+
+					/* eslint-disable no-multi-spaces */
+					assertSelectedCells( model, [
+						[ 1, 1, 1, 1 ],
+						[ 1,       1 ],
+						[       1, 1 ],
+						[ 0, 0, 0, 0 ]
+					] );
+					/* eslint-enable no-multi-spaces */
+				} );
+
+				it( 'handles pasting multi-spanned table', () => {
+					setModelData( model, modelTable( [
+						[ '00', '01', '02', '03', '04', '05' ],
+						[ '10', '11', '12', '13', '14', '15' ],
+						[ '20', '21', '22', '23', '24', '25' ],
+						[ '30', '31', '32', '33', '34', '35' ],
+						[ '40', '41', '42', '43', '44', '45' ]
+					] ) );
+
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 1, 1 ] ),
+						modelRoot.getNodeByPath( [ 0, 3, 3 ] )
+					);
+
+					// +----+----+----+----+----+
+					// | aa      | ac | ad | ae |
+					// +----+----+----+----+    +
+					// | ba | bb           |    |
+					// +----+              +----+
+					// | ca |              | ce |
+					// +    +----+----+----+----+
+					// |    | db | dc | dd      |
+					// +----+----+----+----+----+
+					pasteTable( [
+						[ { contents: 'aa', colspan: 2 }, 'ac', 'ad', { contents: 'ae', rowspan: 2 } ],
+						[ 'ba', { contents: 'bb', colspan: 3, rowspan: 2 } ],
+						[ { contents: 'ca', rowspan: 2 }, 'ce' ],
+						[ 'db', 'dc', { contents: 'dd', colspan: 2 } ]
+					] );
+
+					// +----+----+----+----+----+----+
+					// | 00 | 01 | 02 | 03 | 04 | 05 |
+					// +----+----+----+----+----+----+
+					// | 10 | aa      | ac | 14 | 15 |
+					// +----+----+----+----+----+----+
+					// | 20 | ba | bb      | 24 | 25 |
+					// +----+----+         +----+----+
+					// | 30 | ca |         | 34 | 35 |
+					// +----+----+----+----+----+----+
+					// | 40 | 41 | 42 | 43 | 44 | 45 |
+					// +----+----+----+----+----+----+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02', '03', '04', '05' ],
+						[ '10', { contents: 'aa', colspan: 2 }, 'ac', '14', '15' ],
+						[ '20', 'ba', { contents: 'bb', colspan: 2, rowspan: 2 }, '24', '25' ],
+						[ '30', 'ca', '34', '35' ],
+						[ '40', '41', '42', '43', '44', '45' ]
+					] ) );
+
+					/* eslint-disable no-multi-spaces */
+					assertSelectedCells( model, [
+						[ 0, 0, 0, 0, 0, 0 ],
+						[ 0, 1,    1, 0, 0 ],
+						[ 0, 1, 1,    0, 0 ],
+						[ 0, 1,       0, 0 ],
+						[ 0, 0, 0, 0, 0, 0 ]
+					] );
+					/* eslint-enable no-multi-spaces */
+				} );
+			} );
+		} );
+
+		describe( 'pasted table is smaller than the selected area', () => {
+			describe( 'no spans', () => {
+				beforeEach( () => {
+					setModelData( model, modelTable( [
+						[ '00', '01', '02', '03', '04', '05' ],
+						[ '10', '11', '12', '13', '14', '15' ],
+						[ '20', '21', '22', '23', '24', '25' ],
+						[ '30', '31', '32', '33', '34', '35' ],
+						[ '40', '41', '42', '43', '44', '45' ],
+						[ '50', '51', '52', '53', '54', '55' ]
+					] ) );
+				} );
+
+				it( 'should repeat pasted cells horizontally', () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+						modelRoot.getNodeByPath( [ 0, 1, 4 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab', 'ac' ],
+						[ 'ba', 'bb', 'bc' ],
+						[ 'ca', 'cb', 'cc' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ 'aa', 'ab', 'ac', 'aa', 'ab', '05' ],
+						[ 'ba', 'bb', 'bc', 'ba', 'bb', '15' ],
+						[ '20', '21', '22', '23', '24', '25' ],
+						[ '30', '31', '32', '33', '34', '35' ],
+						[ '40', '41', '42', '43', '44', '45' ],
+						[ '50', '51', '52', '53', '54', '55' ]
+					] ) );
+
+					assertSelectedCells( model, [
+						[ 1, 1, 1, 1, 1, 0 ],
+						[ 1, 1, 1, 1, 1, 0 ],
+						[ 0, 0, 0, 0, 0, 0 ],
+						[ 0, 0, 0, 0, 0, 0 ]
+					] );
+				} );
+
+				it( 'should repeat pasted cells vertically', () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+						modelRoot.getNodeByPath( [ 0, 4, 1 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab', 'ac' ],
+						[ 'ba', 'bb', 'bc' ],
+						[ 'ca', 'cb', 'cc' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ 'aa', 'ab', '02', '03', '04', '05' ],
+						[ 'ba', 'bb', '12', '13', '14', '15' ],
+						[ 'ca', 'cb', '22', '23', '24', '25' ],
+						[ 'aa', 'ab', '32', '33', '34', '35' ],
+						[ 'ba', 'bb', '42', '43', '44', '45' ],
+						[ '50', '51', '52', '53', '54', '55' ]
+					] ) );
+
+					assertSelectedCells( model, [
+						[ 1, 1, 0, 0, 0, 0 ],
+						[ 1, 1, 0, 0, 0, 0 ],
+						[ 1, 1, 0, 0, 0, 0 ],
+						[ 1, 1, 0, 0, 0, 0 ],
+						[ 1, 1, 0, 0, 0, 0 ],
+						[ 0, 0, 0, 0, 0, 0 ]
+					] );
+				} );
+
+				it( 'should repeat pasted cells in both directions', () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+						modelRoot.getNodeByPath( [ 0, 4, 4 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab', 'ac' ],
+						[ 'ba', 'bb', 'bc' ],
+						[ 'ca', 'cb', 'cc' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ 'aa', 'ab', 'ac', 'aa', 'ab', '05' ],
+						[ 'ba', 'bb', 'bc', 'ba', 'bb', '15' ],
+						[ 'ca', 'cb', 'cc', 'ca', 'cb', '25' ],
+						[ 'aa', 'ab', 'ac', 'aa', 'ab', '35' ],
+						[ 'ba', 'bb', 'bc', 'ba', 'bb', '45' ],
+						[ '50', '51', '52', '53', '54', '55' ]
+					] ) );
+
+					assertSelectedCells( model, [
+						[ 1, 1, 1, 1, 1, 0 ],
+						[ 1, 1, 1, 1, 1, 0 ],
+						[ 1, 1, 1, 1, 1, 0 ],
+						[ 1, 1, 1, 1, 1, 0 ],
+						[ 1, 1, 1, 1, 1, 0 ],
+						[ 0, 0, 0, 0, 0, 0 ]
+					] );
+				} );
+
+				it( 'should repeat pasted cells in the both directions when pasted on table end', () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 1, 1 ] ),
+						modelRoot.getNodeByPath( [ 0, 5, 5 ] )
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab', 'ac' ],
+						[ 'ba', 'bb', 'bc' ],
+						[ 'ca', 'cb', 'cc' ]
+					] );
+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02', '03', '04', '05' ],
+						[ '10', 'aa', 'ab', 'ac', 'aa', 'ab' ],
+						[ '20', 'ba', 'bb', 'bc', 'ba', 'bb' ],
+						[ '30', 'ca', 'cb', 'cc', 'ca', 'cb' ],
+						[ '40', 'aa', 'ab', 'ac', 'aa', 'ab' ],
+						[ '50', 'ba', 'bb', 'bc', 'ba', 'bb' ]
+					] ) );
+
+					assertSelectedCells( model, [
+						[ 0, 0, 0, 0, 0, 0 ],
+						[ 0, 1, 1, 1, 1, 1 ],
+						[ 0, 1, 1, 1, 1, 1 ],
+						[ 0, 1, 1, 1, 1, 1 ],
+						[ 0, 1, 1, 1, 1, 1 ],
+						[ 0, 1, 1, 1, 1, 1 ]
+					] );
+				} );
+			} );
+
+			describe( 'content table has spans', () => {
+				beforeEach( () => {
+					// +----+----+----+----+----+----+
+					// | 00 | 01 | 02 | 03 | 04 | 05 |
+					// +----+----+----+    +----+----+
+					// | 10 | 11 | 12 |    | 14 | 15 |
+					// +----+----+    +    +----+----+
+					// | 20 | 21 |    |    | 24 | 25 |
+					// +----+----+----+    +    +----+
+					// | 30 | 31      |    |    | 35 |
+					// +----+----+----+----+----+----+
+					// | 40                | 44      |
+					// +----+----+----+----+         +
+					// | 50 | 51 | 52      |         |
+					// +----+----+----+----+----+----+
+					// | 60 | 61 | 62 | 63 | 64 | 65 |
+					// +----+----+----+----+----+----+
+					setModelData( model, modelTable( [
+						[ '00', '01', '02', { contents: '03', rowspan: 4 }, '04', '05' ],
+						[ '10', '11', { contents: '12', rowspan: 2 }, '14', '15' ],
+						[ '20', '21', { contents: '24', rowspan: 2 }, '25' ],
+						[ '30', { contents: '31', colspan: 2 }, '35' ],
+						[ { contents: '40', colspan: 4 }, { contents: '44', colspan: 2, rowspan: 2 } ],
+						[ '50', '51', { contents: '52', colspan: 2 } ],
+						[ '60', '61', '62', '63', '64', '65' ]
+					] ) );
+				} );
+
+				it( 'should split spanned cells on the selection edges (vertical spans)', () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 2, 0 ] ),
+						modelRoot.getNodeByPath( [ 0, 4, 1 ] ) // Cell 44.
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab' ],
+						[ 'ba', 'bb' ]
+					] );
+
+					// +----+----+----+----+----+----+
+					// | 00 | 01 | 02 | 03 | 04 | 05 |
+					// +----+----+----+    +----+----+
+					// | 10 | 11 | 12 |    | 14 | 15 |
+					// +----+----+----+----+----+----+
+					// | aa | ab | aa | ab | aa | 25 |
+					// +----+----+----+----+----+----+
+					// | ba | bb | ba | bb | ba | 35 |
+					// +----+----+----+----+----+----+
+					// | aa | ab | aa | ab | aa |    |
+					// +----+----+----+----+----+    +
+					// | 50 | 51 | 52      |    |    |
+					// +----+----+----+----+----+----+
+					// | 60 | 61 | 62 | 63 | 64 | 65 |
+					// +----+----+----+----+----+----+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02', { contents: '03', rowspan: 2 }, '04', '05' ],
+						[ '10', '11', '12', '14', '15' ],
+						[ 'aa', 'ab', 'aa', 'ab', 'aa', '25' ],
+						[ 'ba', 'bb', 'ba', 'bb', 'ba', '35' ],
+						[ 'aa', 'ab', 'aa', 'ab', 'aa', { contents: '', rowspan: 2 } ],
+						[ '50', '51', { contents: '52', colspan: 2 }, '' ],
+						[ '60', '61', '62', '63', '64', '65' ]
+					] ) );
+				} );
+
+				it( 'should split spanned cells on the selection edges (horizontal spans)', () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 2 ] ),
+						modelRoot.getNodeByPath( [ 0, 4, 1 ] ) // Cell 44.
+					);
+
+					pasteTable( [
+						[ 'aa', 'ab' ],
+						[ 'ba', 'bb' ]
+					] );
+
+					// +----+----+----+----+----+----+
+					// | 00 | 01 | aa | ab | aa | 05 |
+					// +----+----+----+----+----+----+
+					// | 10 | 11 | ba | bb | ba | 15 |
+					// +----+----+----+----+----+----+
+					// | 20 | 21 | aa | ab | aa | 25 |
+					// +----+----+----+----+----+----+
+					// | 30 | 31 | ba | bb | ba | 35 |
+					// +----+----+----+----+----+----+
+					// | 40      | aa | ab | aa |    |
+					// +----+----+----+----+----+    +
+					// | 50 | 51 | 52      |    |    |
+					// +----+----+----+----+----+----+
+					// | 60 | 61 | 62 | 63 | 64 | 65 |
+					// +----+----+----+----+----+----+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', 'aa', 'ab', 'aa', '05' ],
+						[ '10', '11', 'ba', 'bb', 'ba', '15' ],
+						[ '20', '21', 'aa', 'ab', 'aa', '25' ],
+						[ '30', '31', 'ba', 'bb', 'ba', '35' ],
+						[ { contents: '40', colspan: 2 }, 'aa', 'ab', 'aa', { contents: '', rowspan: 2 } ],
+						[ '50', '51', { contents: '52', colspan: 2 }, '' ],
+						[ '60', '61', '62', '63', '64', '65' ]
+					] ) );
+				} );
+			} );
+
+			describe( 'pasted table has spans', () => {
+				beforeEach( () => {
+					setModelData( model, modelTable( [
+						[ '00', '01', '02', '03', '04', '05', '06' ],
+						[ '10', '11', '12', '13', '14', '15', '16' ],
+						[ '20', '21', '22', '23', '24', '25', '26' ],
+						[ '30', '31', '32', '33', '34', '35', '36' ],
+						[ '40', '41', '42', '43', '44', '45', '46' ],
+						[ '50', '51', '52', '53', '54', '55', '56' ],
+						[ '60', '61', '62', '63', '64', '65', '66' ],
+						[ '70', '71', '72', '73', '74', '75', '76' ],
+						[ '80', '81', '82', '83', '84', '85', '86' ]
+					] ) );
+				} );
+
+				it( 'should trim overlapping cells', () => {
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 1, 1 ] ),
+						modelRoot.getNodeByPath( [ 0, 8, 6 ] )
+					);
+
+					// +----+----+----+----+
+					// | aa                |
+					// +----+----+----+----+
+					// | ba | bb           |
+					// +    +----+----+----+
+					// |    | cb | cc      |
+					// +    +    +----+----+
+					// |    |    | dc | dd |
+					// +    +    +    +----+
+					// |    |    |    | ed |
+					// +----+----+----+----+
+					pasteTable( [
+						[ { contents: 'aa', colspan: 4 } ],
+						[ { contents: 'ba', rowspan: 4 }, { contents: 'bb', colspan: 3 } ],
+						[ { contents: 'cb', rowspan: 3 }, { contents: 'cc', colspan: 2 } ],
+						[ { contents: 'dc', rowspan: 2 }, 'dd' ],
+						[ 'ed' ]
+					] );
+
+					// +----+----+----+----+----+----+----+
+					// | 00 | 01 | 02 | 03 | 04 | 05 | 06 |
+					// +----+----+----+----+----+----+----+
+					// | 10 | aa                | aa      |
+					// +----+----+----+----+----+----+----+
+					// | 20 | ba | bb           | ba | bb |
+					// +----+    +----+----+----+    +----+
+					// | 30 |    | cb | cc      |    | cb |
+					// +----+    +    +----+----+    +    +
+					// | 40 |    |    | dc | dd |    |    |
+					// +----+    +    +    +----+    +    +
+					// | 50 |    |    |    | ed |    |    |
+					// +----+----+----+----+----+----+----+
+					// | 60 | aa                | aa      |
+					// +----+----+----+----+----+----+----+
+					// | 70 | ba | bb           | ba | bb |
+					// +----+    +----+----+----+    +----+
+					// | 80 |    | cb | cc      |    | cb |
+					// +----+----+----+----+----+----+----+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ '00', '01', '02', '03', '04', '05', '06' ],
+						[ '10', { contents: 'aa', colspan: 4 }, { contents: 'aa', colspan: 2 } ],
+						[ '20', { contents: 'ba', rowspan: 4 }, { contents: 'bb', colspan: 3 }, { contents: 'ba', rowspan: 4 }, 'bb' ],
+						[ '30', { contents: 'cb', rowspan: 3 }, { contents: 'cc', colspan: 2 }, { contents: 'cb', rowspan: 3 } ],
+						[ '40', { contents: 'dc', rowspan: 2 }, 'dd' ],
+						[ '50', 'ed' ],
+						[ '60', { contents: 'aa', colspan: 4 }, { contents: 'aa', colspan: 2 } ],
+						[ '70', { contents: 'ba', rowspan: 2 }, { contents: 'bb', colspan: 3 }, { contents: 'ba', rowspan: 2 }, 'bb' ],
+						[ '80', 'cb', { contents: 'cc', colspan: 2 }, 'cb' ]
+					] ) );
+				} );
+			} );
+		} );
+
+		describe( 'fixing pasted table broken layout', () => {
+			it( 'should trim pasted cells\' width if they exceeds table width established by the first row', () => {
+				// Select 00 -> 22 (selection 2x3 - equal to expected fixed table)
+				tableSelection.setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+					modelRoot.getNodeByPath( [ 0, 2, 1 ] )
+				);
+
+				// +----+----+
+				// | aa | ab |             <- First row establish table width=2.
+				// +----+----+----+----+
+				// | ba | bb           |   <- Cell "bb" sticks out by 2 slots.
+				// +----+----+----+----+
+				// | ca           |        <- Cell "ca" sticks out by 1 slot.
+				// +----+----+----+
+				pasteTable( [
+					[ 'aa', 'ab' ],
+					[ 'ba', { colspan: 3, contents: 'bb' } ],
+					[ { colspan: 3, contents: 'ca' } ]
+				] );
+
+				// +----+----+----+----+
+				// | aa | ab | 02 | 03 |
+				// +----+----+----+----+
+				// | ba | bb | 12 | 13 |
+				// +----+----+----+----+
+				// | ca      | 22 | 23 |
+				// +----+----+----+----+
+				// | 30 | 31 | 32 | 33 |
+				// +----+----+----+----+
+				assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+					[ 'aa', 'ab', '02', '03' ],
+					[ 'ba', 'bb', '12', '13' ],
+					[ { contents: 'ca', colspan: 2 }, '22', '23' ],
+					[ '30', '31', '32', '33' ]
+				] ) );
+
+				/* eslint-disable no-multi-spaces */
+				assertSelectedCells( model, [
+					[ 1, 1, 0, 0 ],
+					[ 1, 1, 0, 0 ],
+					[ 1,    0, 0 ],
+					[ 0, 0, 0, 0 ]
+				] );
+				/* eslint-enable no-multi-spaces */
+			} );
+
+			it( 'should trim pasted cells\' height if they exceeds table height established by the last row', () => {
+				// Select 00 -> 12 (selection 3x2 - equal to expected fixed table)
+				tableSelection.setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+					modelRoot.getNodeByPath( [ 0, 1, 2 ] )
+				);
+
+				// +----+----+----+
+				// | aa | ab | ac |
+				// +----+----+    +
+				// | ba | bb |    |   <- Last row establish table height=2.
+				// +----+    +    +
+				//      |    |    |   <- Cell "ac" sticks out by 1 slot.
+				//      +    +----+
+				//      |    |        <- Cell "bb" sticks out by 2 slots.
+				//      +----+
+				pasteTable( [
+					[ 'aa', 'ab', { contents: 'ac', rowspan: 3 } ],
+					[ 'ba', { contents: 'bb', rowspan: 3 } ]
+				] );
+
+				// +----+----+----+----+
+				// | aa | ab | ac | 03 |
+				// +----+----+    +----+
+				// | ba | bb |    | 13 |
+				// +----+----+----+----+
+				// | 20 | 21 | 22 | 23 |
+				// +----+----+----+----+
+				// | 30 | 31 | 32 | 33 |
+				// +----+----+----+----+
+				assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+					[ 'aa', 'ab', { rowspan: 2, contents: 'ac' }, '03' ],
+					[ 'ba', 'bb', '13' ],
+					[ '20', '21', '22', '23' ],
+					[ '30', '31', '32', '33' ]
+				] ) );
+
+				/* eslint-disable no-multi-spaces */
+				assertSelectedCells( model, [
+					[ 1, 1, 1, 0 ],
+					[ 1, 1,    0 ],
+					[ 0, 0, 0, 0 ],
+					[ 0, 0, 0, 0 ]
+				] );
+				/* eslint-enable no-multi-spaces */
+			} );
+
+			it( 'should trim pasted cells\' height and width if they exceeds table height and width', () => {
+				// Select 00 -> 11 (selection 2x2 - equal to expected fixed table)
+				tableSelection.setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+					modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+				);
+
+				// +----+----+
+				// | aa | ab |
+				// +----+----+----+----+
+				// | ba | bb           | <- Cell "bb" sticks out by 2 slots in width and by 1 slot in height.
+				// +----+              +
+				//      |              |
+				//      +----+----+----+
+				pasteTable( [
+					[ 'aa', 'ab' ],
+					[ 'ba', { contents: 'bb', colspan: 3, rowspan: 2 } ]
+				] );
+
+				// +----+----+----+----+
+				// | aa | ab | 02 | 03 |
+				// +----+----+----+----+
+				// | ba | bb | 12 | 13 |
+				// +----+----+----+----+
+				// | 20 | 21 | 22 | 23 |
+				// +----+----+----+----+
+				// | 30 | 31 | 32 | 33 |
+				// +----+----+----+----+
+				assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+					[ 'aa', 'ab', '02', '03' ],
+					[ 'ba', 'bb', '12', '13' ],
+					[ '20', '21', '22', '23' ],
+					[ '30', '31', '32', '33' ]
+				] ) );
+
+				/* eslint-disable no-multi-spaces */
+				assertSelectedCells( model, [
+					[ 1, 1, 0, 0 ],
+					[ 1, 1, 0, 0 ],
+					[ 0, 0, 0, 0 ],
+					[ 0, 0, 0, 0 ]
+				] );
+				/* eslint-enable no-multi-spaces */
+			} );
+
+			it(
+				'should trim pasted cells\' width if they exceeds pasted table width (pasted height is bigger then selection height)',
+				() => {
+					// Select 00 -> 11 (selection 2x2 - smaller by height than the expected fixed table)
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+						modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+					);
+
+					// +----+----+
+					// | aa | ab |             <- First row establish table width=2.
+					// +----+----+----+----+
+					// | ba | bb           |   <- Cell "bb" sticks out by 2 slots.
+					// +----+----+----+----+
+					// | ca           |        <- Cell "ca" sticks out by 1 slot.
+					// +----+----+----+
+					pasteTable( [
+						[ 'aa', 'ab' ],
+						[ 'ba', { colspan: 3, contents: 'bb' } ],
+						[ { colspan: 3, contents: 'ca' } ]
+					] );
+
+					// +----+----+----+----+
+					// | aa | ab | 02 | 03 |
+					// +----+----+----+----+
+					// | ba | bb | 12 | 13 |
+					// +----+----+----+----+
+					// | 20 | 21 | 22 | 23 |
+					// +----+----+----+----+
+					// | 30 | 31 | 32 | 33 |
+					// +----+----+----+----+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ 'aa', 'ab', '02', '03' ],
+						[ 'ba', 'bb', '12', '13' ],
+						[ '20', '21', '22', '23' ],
+						[ '30', '31', '32', '33' ]
+					] ) );
+
+					assertSelectedCells( model, [
+						[ 1, 1, 0, 0 ],
+						[ 1, 1, 0, 0 ],
+						[ 0, 0, 0, 0 ],
+						[ 0, 0, 0, 0 ]
+					] );
+				}
+			);
+
+			it(
+				'should trim pasted cells\' height if they exceeds pasted table height (pasted width is bigger then selection width)',
+				() => {
+					// Select 00 -> 11 (selection 2x2 - smaller by width than the expected fixed table)
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+						modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+					);
+
+					// +----+----+----+
+					// | aa | ab | ac |
+					// +----+----+    +
+					// | ba | bb |    |   <- Last row establish table height=2.
+					// +----+    +    +
+					//      |    |    |   <- Cell "ac" sticks out by 1 slot.
+					//      +    +----+
+					//      |    |        <- Cell "bb" sticks out by 2 slots.
+					//      +----+
+					pasteTable( [
+						[ 'aa', 'ab', { contents: 'ac', rowspan: 3 } ],
+						[ 'ba', { contents: 'bb', rowspan: 3 } ]
+					] );
+
+					// +----+----+----+----+
+					// | aa | ab | 02 | 03 |
+					// +----+----+----+----+
+					// | ba | bb | 12 | 13 |
+					// +----+----+----+----+
+					// | 20 | 21 | 22 | 23 |
+					// +----+----+----+----+
+					// | 30 | 31 | 32 | 33 |
+					// +----+----+----+----+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ 'aa', 'ab', '02', '03' ],
+						[ 'ba', 'bb', '12', '13' ],
+						[ '20', '21', '22', '23' ],
+						[ '30', '31', '32', '33' ]
+					] ) );
+
+					/* eslint-disable no-multi-spaces */
+					assertSelectedCells( model, [
+						[ 1, 1, 0, 0 ],
+						[ 1, 1, 0, 0 ],
+						[ 0, 0, 0, 0 ],
+						[ 0, 0, 0, 0 ]
+					] );
+					/* eslint-enable no-multi-spaces */
+				}
+			);
+
+			it(
+				`should trim pasted pasted cells' height and width if they exceeds pasted table dimensions
+				(pasted table is bigger then selection width)`,
+				() => {
+					// Select 00 -> 11 (selection 2x2 - smaller than the expected fixed table)
+					tableSelection.setCellSelection(
+						modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+						modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+					);
+
+					// +----+----+----+
+					// | aa | ab | ac |
+					// +----+----+----+----+
+					// | ba | bb           | <- Cell "bb" sticks out by 1 slots in width and by 1 slot in height.
+					// +----+              +
+					// | ca |              |
+					// +----+              +
+					//      |              |
+					//      +----+----+----+
+					pasteTable( [
+						[ 'aa', 'ab' ],
+						[ 'ba', { contents: 'bb', colspan: 3, rowspan: 2 } ]
+					] );
+
+					// +----+----+----+----+
+					// | aa | ab | 02 | 03 |
+					// +----+----+----+----+
+					// | ba | bb | 12 | 13 |
+					// +----+----+----+----+
+					// | 20 | 21 | 22 | 23 |
+					// +----+----+----+----+
+					// | 30 | 31 | 32 | 33 |
+					// +----+----+----+----+
+					assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+						[ 'aa', 'ab', '02', '03' ],
+						[ 'ba', 'bb', '12', '13' ],
+						[ '20', '21', '22', '23' ],
+						[ '30', '31', '32', '33' ]
+					] ) );
+
+					/* eslint-disable no-multi-spaces */
+					assertSelectedCells( model, [
+						[ 1, 1, 0, 0 ],
+						[ 1, 1, 0, 0 ],
+						[ 0, 0, 0, 0 ],
+						[ 0, 0, 0, 0 ]
+					] );
+					/* eslint-enable no-multi-spaces */
+				}
+			);
+		} );
+	} );
+
+	describe( 'Clipboard integration - paste (content scenarios)', () => {
+		it( 'handles multiple paragraphs in table cell', async () => {
+			await createEditor();
+
+			setModelData( model, modelTable( [
+				[ '00', '01', '02' ],
+				[ '01', '11', '12' ],
+				[ '02', '21', '22' ]
+			] ) );
+
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+			);
+
+			pasteTable( [
+				[ '<p>a</p><p>a</p><p>a</p>', 'ab' ],
+				[ 'ba', 'bb' ]
+			] );
+
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+				[ '<paragraph>a</paragraph><paragraph>a</paragraph><paragraph>a</paragraph>', 'ab', '02' ],
+				[ 'ba', 'bb', '12' ],
+				[ '02', '21', '22' ]
+			] ) );
+		} );
+
+		it( 'handles image in table cell', async () => {
+			await createEditor( [ ImageEditing, ImageCaptionEditing ] );
+
+			setModelData( model, modelTable( [
+				[ '00', '01', '02' ],
+				[ '01', '11', '12' ],
+				[ '02', '21', '22' ]
+			] ) );
+
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+			);
+
+			pasteTable( [
+				[ '<img src="/assets/sample.jpg">', 'ab' ],
+				[ 'ba', 'bb' ]
+			] );
+
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+				[ '<image src="/assets/sample.jpg"><caption></caption></image>', 'ab', '02' ],
+				[ 'ba', 'bb', '12' ],
+				[ '02', '21', '22' ]
+			] ) );
+		} );
+
+		it( 'handles mixed nested content in table cell', async () => {
+			await createEditor( [ ImageEditing, ImageCaptionEditing, BlockQuoteEditing, HorizontalLineEditing, ListEditing ] );
+
+			setModelData( model, modelTable( [
+				[ '00', '01', '02' ],
+				[ '01', '11', '12' ],
+				[ '02', '21', '22' ]
+			] ) );
+
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+			);
+
+			const img = '<img src="/assets/sample.jpg">';
+			const list = '<ul><li>foo</li><li>bar</li></ul>';
+			const blockquote = `<blockquote><p>baz</p>${ list }</blockquote>`;
+
+			pasteTable( [
+				[ `${ img }${ list }${ blockquote }`, 'ab' ],
+				[ 'ba', 'bb' ]
+			] );
+
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+				[
+					'<image src="/assets/sample.jpg"><caption></caption></image>' +
+					'<listItem listIndent="0" listType="bulleted">foo</listItem>' +
+					'<listItem listIndent="0" listType="bulleted">bar</listItem>' +
+					'<blockQuote>' +
+					'<paragraph>baz</paragraph>' +
+					'<listItem listIndent="0" listType="bulleted">foo</listItem>' +
+					'<listItem listIndent="0" listType="bulleted">bar</listItem>' +
+					'</blockQuote>',
+					'ab',
+					'02' ],
+				[ 'ba', 'bb', '12' ],
+				[ '02', '21', '22' ]
+			] ) );
+		} );
+
+		it( 'handles table cell properties', async () => {
+			await createEditor( [ TableCellPropertiesEditing ] );
+
+			setModelData( model, modelTable( [
+				[ '00', '01', '02' ],
+				[ '01', '11', '12' ],
+				[ '02', '21', '22' ]
+			] ) );
+
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+			);
+
+			pasteTable( [
+				[ { contents: 'aa', style: 'border:1px solid #f00;background:#ba7;width:1337px' }, 'ab' ],
+				[ 'ba', 'bb' ]
+			] );
+
+			const tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] );
+
+			expect( tableCell.getAttribute( 'borderColor' ) ).to.deep.equal( {
+				top: '#f00',
+				right: '#f00',
+				bottom: '#f00',
+				left: '#f00'
+			} );
+			expect( tableCell.getAttribute( 'borderStyle' ) ).to.deep.equal( {
+				top: 'solid',
+				right: 'solid',
+				bottom: 'solid',
+				left: 'solid'
+			} );
+			expect( tableCell.getAttribute( 'borderWidth' ) ).to.deep.equal( {
+				top: '1px',
+				right: '1px',
+				bottom: '1px',
+				left: '1px'
+			} );
+			expect( tableCell.getAttribute( 'backgroundColor' ) ).to.equal( '#ba7' );
+			expect( tableCell.getAttribute( 'width' ) ).to.equal( '1337px' );
+		} );
+
+		it( 'discards table properties', async () => {
+			await createEditor( [ TableCellPropertiesEditing ] );
+
+			setModelData( model, modelTable( [
+				[ '00', '01', '02' ],
+				[ '01', '11', '12' ],
+				[ '02', '21', '22' ]
+			] ) );
+
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+			);
+
+			const tableStyle = 'border:1px solid #f00;background:#ba7;width:1337px';
+			const pastedTable = `<table style="${ tableStyle }"><tr><td>aa</td><td>ab</td></tr><tr><td>ba</td><td>bb</td></tr></table>`;
+			const data = {
+				dataTransfer: createDataTransfer(),
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			};
+			data.dataTransfer.setData( 'text/html', pastedTable );
+			viewDocument.fire( 'paste', data );
+
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+				[ 'aa', 'ab', '02' ],
+				[ 'ba', 'bb', '12' ],
+				[ '02', '21', '22' ]
+			] ) );
+		} );
+	} );
+
+	async function createEditor( extraPlugins = [] ) {
+		editor = await ClassicTestEditor.create( element, {
+			plugins: [ TableEditing, TableClipboard, Paragraph, Clipboard, ...extraPlugins ]
+		} );
+
+		model = editor.model;
+		modelRoot = model.document.getRoot();
+		viewDocument = editor.editing.view.document;
+		tableSelection = editor.plugins.get( 'TableSelection' );
+	}
+
+	function pasteTable( tableData ) {
+		const data = {
+			dataTransfer: createDataTransfer(),
+			preventDefault: sinon.spy(),
+			stopPropagation: sinon.spy()
+		};
+		data.dataTransfer.setData( 'text/html', viewTable( tableData ) );
+		viewDocument.fire( 'paste', data );
+
+		return data;
+	}
+
+	function createDataTransfer() {
+		const store = new Map();
+
+		return {
+			setData( type, data ) {
+				store.set( type, data );
+			},
+
+			getData( type ) {
+				return store.get( type );
+			}
+		};
+	}
+
+	function setupBatchWatch() {
+		const createdBatches = new Set();
+
+		model.on( 'applyOperation', ( evt, [ operation ] ) => {
+			if ( operation.isDocumentOperation ) {
+				createdBatches.add( operation.batch );
+			}
+		} );
+
+		return createdBatches;
+	}
+} );

+ 13 - 394
packages/ckeditor5-table/tests/tableclipboard.js

@@ -3,415 +3,34 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
-import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
-import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+import TableSelection from '../src/tableselection';
+import TableUtils from '../src/tableutils';
 
-import TableEditing from '../src/tableediting';
-import { modelTable, viewTable } from './_utils/utils';
-import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
 import TableClipboard from '../src/tableclipboard';
-import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
 describe( 'table clipboard', () => {
-	let editor, model, modelRoot, tableSelection, viewDocument;
+	let editor;
 
 	beforeEach( async () => {
 		editor = await VirtualTestEditor.create( {
-			plugins: [ TableEditing, TableClipboard, Paragraph, Clipboard ]
+			plugins: [ TableClipboard, Paragraph ]
 		} );
-
-		model = editor.model;
-		modelRoot = model.document.getRoot();
-		viewDocument = editor.editing.view.document;
-		tableSelection = editor.plugins.get( 'TableSelection' );
-
-		setModelData( model, modelTable( [
-			[ '00[]', '01', '02' ],
-			[ '10', '11', '12' ],
-			[ '20', '21', '22' ]
-		] ) );
 	} );
 
-	afterEach( async () => {
-		await editor.destroy();
+	afterEach( () => {
+		return editor.destroy();
 	} );
 
-	describe( 'Clipboard integration', () => {
-		describe( 'copy', () => {
-			it( 'should do nothing for normal selection in table', () => {
-				const dataTransferMock = createDataTransfer();
-				const spy = sinon.spy();
-
-				viewDocument.on( 'clipboardOutput', spy );
-
-				viewDocument.fire( 'copy', {
-					dataTransfer: dataTransferMock,
-					preventDefault: sinon.spy()
-				} );
-
-				sinon.assert.calledOnce( spy );
-			} );
-
-			it( 'should copy selected table cells as a standalone table', () => {
-				const preventDefaultSpy = sinon.spy();
-
-				tableSelection.setCellSelection(
-					modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
-					modelRoot.getNodeByPath( [ 0, 1, 2 ] )
-				);
-
-				const data = {
-					dataTransfer: createDataTransfer(),
-					preventDefault: preventDefaultSpy
-				};
-				viewDocument.fire( 'copy', data );
-
-				sinon.assert.calledOnce( preventDefaultSpy );
-				expect( data.dataTransfer.getData( 'text/html' ) ).to.equal( viewTable( [
-					[ '01', '02' ],
-					[ '11', '12' ]
-				] ) );
-			} );
-
-			it( 'should trim selected table to a selection rectangle (inner cell with colspan, no colspan after trim)', () => {
-				setModelData( model, modelTable( [
-					[ '00[]', '01', '02' ],
-					[ '10', { contents: '11', colspan: 2 } ],
-					[ '20', '21', '22' ]
-				] ) );
-
-				tableSelection.setCellSelection(
-					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
-					modelRoot.getNodeByPath( [ 0, 2, 1 ] )
-				);
-
-				assertClipboardContentOnMethod( 'copy', viewTable( [
-					[ '00', '01' ],
-					[ '10', '11' ],
-					[ '20', '21' ]
-				] ) );
-			} );
-
-			it( 'should trim selected table to a selection rectangle (inner cell with colspan, has colspan after trim)', () => {
-				setModelData( model, modelTable( [
-					[ '00[]', '01', '02' ],
-					[ { contents: '10', colspan: 3 } ],
-					[ '20', '21', '22' ]
-				] ) );
-
-				tableSelection.setCellSelection(
-					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
-					modelRoot.getNodeByPath( [ 0, 2, 1 ] )
-				);
-
-				assertClipboardContentOnMethod( 'copy', viewTable( [
-					[ '00', '01' ],
-					[ { contents: '10', colspan: 2 } ],
-					[ '20', '21' ]
-				] ) );
-			} );
-
-			it( 'should trim selected table to a selection rectangle (inner cell with rowspan, no colspan after trim)', () => {
-				setModelData( model, modelTable( [
-					[ '00[]', '01', '02' ],
-					[ '10', { contents: '11', rowspan: 2 }, '12' ],
-					[ '20', '21', '22' ]
-				] ) );
-
-				tableSelection.setCellSelection(
-					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
-					modelRoot.getNodeByPath( [ 0, 1, 2 ] )
-				);
-
-				assertClipboardContentOnMethod( 'copy', viewTable( [
-					[ '00', '01', '02' ],
-					[ '10', '11', '12' ]
-				] ) );
-			} );
-
-			it( 'should trim selected table to a selection rectangle (inner cell with rowspan, has rowspan after trim)', () => {
-				setModelData( model, modelTable( [
-					[ '00[]', { contents: '01', rowspan: 3 }, '02' ],
-					[ '10', '12' ],
-					[ '20', '22' ]
-				] ) );
-
-				tableSelection.setCellSelection(
-					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
-					modelRoot.getNodeByPath( [ 0, 1, 1 ] )
-				);
-
-				assertClipboardContentOnMethod( 'copy', viewTable( [
-					[ '00', { contents: '01', rowspan: 2 }, '02' ],
-					[ '10', '12' ]
-				] ) );
-			} );
-
-			it( 'should prepend spanned columns with empty cells (outside cell with colspan)', () => {
-				setModelData( model, modelTable( [
-					[ '00[]', '01', '02' ],
-					[ { contents: '10', colspan: 2 }, '12' ],
-					[ '20', '21', '22' ]
-				] ) );
-
-				tableSelection.setCellSelection(
-					modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
-					modelRoot.getNodeByPath( [ 0, 2, 2 ] )
-				);
-
-				assertClipboardContentOnMethod( 'copy', viewTable( [
-					[ '01', '02' ],
-					[ '&nbsp;', '12' ],
-					[ '21', '22' ]
-				] ) );
-			} );
-
-			it( 'should prepend spanned columns with empty cells (outside cell with rowspan)', () => {
-				setModelData( model, modelTable( [
-					[ '00[]', { contents: '01', rowspan: 2 }, '02' ],
-					[ '10', '12' ],
-					[ '20', '21', '22' ]
-				] ) );
-
-				tableSelection.setCellSelection(
-					modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
-					modelRoot.getNodeByPath( [ 0, 2, 2 ] )
-				);
-
-				assertClipboardContentOnMethod( 'copy', viewTable( [
-					[ '10', '&nbsp;', '12' ],
-					[ '20', '21', '22' ]
-				] ) );
-			} );
-
-			it( 'should fix selected table to a selection rectangle (hardcore case)', () => {
-				// This test check how previous simple rules run together (mixed prepending and trimming).
-				// In the example below a selection is set from cell "32" to "88"
-				//
-				//                    Input table:                                         Copied table:
-				//
-				//   +----+----+----+----+----+----+----+----+----+
-				//   | 00 | 01 | 02 | 03 | 04      | 06 | 07 | 08 |
-				//   +----+----+    +----+         +----+----+----+
-				//   | 10 | 11 |    | 13 |         | 16 | 17 | 18 |
-				//   +----+----+    +----+         +----+----+----+             +----+----+----+---------+----+----+
-				//   | 20 | 21 |    | 23 |         | 26           |             | 21 |    | 23 |    |    | 26 |    |
-				//   +----+----+    +----+         +----+----+----+             +----+----+----+----+----+----+----+
-				//   | 30 | 31 |    | 33 |         | 36 | 37      |             | 31 |    | 33 |    |    | 36 | 37 |
-				//   +----+----+----+----+         +----+----+----+             +----+----+----+----+----+----+----+
-				//   | 40                |         | 46 | 47 | 48 |             |    |    |    |    |    | 46 | 47 |
-				//   +----+----+----+----+         +----+----+----+     ==>     +----+----+----+----+----+----+----+
-				//   | 50 | 51 | 52 | 53 |         | 56 | 57 | 58 |             | 51 | 52 | 53 |    |    | 56 | 57 |
-				//   +----+----+----+----+----+----+    +----+----+             +----+----+----+----+----+----+----+
-				//   | 60 | 61           | 64 | 65 |    | 67 | 68 |             | 61 |    |    | 64 | 65 |    | 67 |
-				//   +----+----+----+----+----+----+    +----+----+             +----+----+----+----+----+----+----+
-				//   | 70 | 71 | 72 | 73 | 74 | 75 |    | 77 | 78 |             | 71 | 72 | 73 | 74 | 75 |    | 77 |
-				//   +----+    +----+----+----+----+    +----+----+             +----+----+----+----+----+----+----+
-				//   | 80 |    | 82 | 83 | 84 | 85 |    | 87 | 88 |
-				//   +----+----+----+----+----+----+----+----+----+
-				//
-				setModelData( model, modelTable( [
-					[ '00', '01', { contents: '02', rowspan: 4 }, '03', { contents: '04', colspan: 2, rowspan: 7 }, '07', '07', '08' ],
-					[ '10', '11', '13', '17', '17', '18' ],
-					[ '20', '21', '23', { contents: '27', colspan: 3 } ],
-					[ '30', '31', '33', '37', { contents: '37', colspan: 2 } ],
-					[ { contents: '40', colspan: 4 }, '47', '47', '48' ],
-					[ '50', '51', '52', '53', { contents: '57', rowspan: 4 }, '57', '58' ],
-					[ '60', { contents: '61', colspan: 3 }, '67', '68' ],
-					[ '70', { contents: '71', rowspan: 2 }, '72', '73', '74', '75', '77', '78' ],
-					[ '80', '82', '83', '84', '85', '87', '88' ]
-				] ) );
-
-				tableSelection.setCellSelection(
-					modelRoot.getNodeByPath( [ 0, 2, 1 ] ),
-					modelRoot.getNodeByPath( [ 0, 7, 6 ] )
-				);
-
-				assertClipboardContentOnMethod( 'copy', viewTable( [
-					[ '21', '&nbsp;', '23', '&nbsp;', '&nbsp;', { contents: '27', colspan: 2 } ],
-					[ '31', '&nbsp;', '33', '&nbsp;', '&nbsp;', '37', '37' ],
-					[ '&nbsp;', '&nbsp;', '&nbsp;', '&nbsp;', '&nbsp;', '47', '47' ],
-					[ '51', '52', '53', '&nbsp;', '&nbsp;', { contents: '57', rowspan: 3 }, '57' ],
-					[ { contents: '61', colspan: 3 }, '&nbsp;', '&nbsp;', '&nbsp;', '67' ],
-					[ '71', '72', '73', '74', '75', '77' ]
-				] ) );
-			} );
-
-			it( 'should update table heading attributes (selection with headings)', () => {
-				setModelData( model, modelTable( [
-					[ '00', '01', '02', '03', '04' ],
-					[ '10', '11', '12', '13', '14' ],
-					[ '20', '21', '22', '23', '24' ],
-					[ '30', '31', '32', '33', '34' ],
-					[ '40', '41', '42', '43', '44' ]
-				], { headingRows: 3, headingColumns: 2 } ) );
-
-				tableSelection.setCellSelection(
-					modelRoot.getNodeByPath( [ 0, 1, 1 ] ),
-					modelRoot.getNodeByPath( [ 0, 3, 3 ] )
-				);
-
-				assertClipboardContentOnMethod( 'copy', viewTable( [
-					[ '11', '12', '13' ],
-					[ '21', '22', '23' ],
-					[ { contents: '31', isHeading: true }, '32', '33' ] // TODO: bug in viewTable
-				], { headingRows: 2, headingColumns: 1 } ) );
-			} );
-
-			it( 'should update table heading attributes (selection without headings)', () => {
-				setModelData( model, modelTable( [
-					[ '00', '01', '02', '03', '04' ],
-					[ '10', '11', '12', '13', '14' ],
-					[ '20', '21', '22', '23', '24' ],
-					[ '30', '31', '32', '33', '34' ],
-					[ '40', '41', '42', '43', '44' ]
-				], { headingRows: 3, headingColumns: 2 } ) );
-
-				tableSelection.setCellSelection(
-					modelRoot.getNodeByPath( [ 0, 3, 2 ] ),
-					modelRoot.getNodeByPath( [ 0, 4, 4 ] )
-				);
-
-				assertClipboardContentOnMethod( 'copy', viewTable( [
-					[ '32', '33', '34' ],
-					[ '42', '43', '44' ]
-				] ) );
-			} );
+	describe( 'TableClipboard', () => {
+		it( 'should have pluginName', () => {
+			expect( TableClipboard.pluginName ).to.equal( 'TableClipboard' );
 		} );
 
-		describe( 'cut', () => {
-			it( 'should not block clipboardOutput if no multi-cell selection', () => {
-				setModelData( model, modelTable( [
-					[ '[00]', '01', '02' ],
-					[ '10', '11', '12' ],
-					[ '20', '21', '22' ]
-				] ) );
-
-				const dataTransferMock = createDataTransfer();
-
-				viewDocument.fire( 'cut', {
-					dataTransfer: dataTransferMock,
-					preventDefault: sinon.spy()
-				} );
-
-				expect( dataTransferMock.getData( 'text/html' ) ).to.equal( '00' );
-			} );
-
-			it( 'should be preventable', () => {
-				tableSelection.setCellSelection(
-					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
-					modelRoot.getNodeByPath( [ 0, 1, 1 ] )
-				);
-
-				viewDocument.on( 'clipboardOutput', evt => evt.stop(), { priority: 'high' } );
-
-				viewDocument.fire( 'cut', {
-					dataTransfer: createDataTransfer(),
-					preventDefault: sinon.spy()
-				} );
-
-				assertEqualMarkup( getModelData( model ), modelTable( [
-					[ { contents: '00', isSelected: true }, { contents: '01', isSelected: true }, '02' ],
-					[ { contents: '10', isSelected: true }, { contents: '11', isSelected: true }, '12' ],
-					[ '20', '21', '22' ]
-				] ) );
-			} );
-
-			it( 'is clears selected table cells', () => {
-				const spy = sinon.spy();
-
-				viewDocument.on( 'clipboardOutput', spy );
-
-				tableSelection.setCellSelection(
-					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
-					modelRoot.getNodeByPath( [ 0, 1, 1 ] )
-				);
-
-				viewDocument.fire( 'cut', {
-					dataTransfer: createDataTransfer(),
-					preventDefault: sinon.spy()
-				} );
-
-				assertEqualMarkup( getModelData( model ), modelTable( [
-					[ '', '', '02' ],
-					[ '', '[]', '12' ],
-					[ '20', '21', '22' ]
-				] ) );
-			} );
-
-			it( 'should copy selected table cells as a standalone table', () => {
-				const preventDefaultSpy = sinon.spy();
-
-				tableSelection.setCellSelection(
-					modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
-					modelRoot.getNodeByPath( [ 0, 1, 2 ] )
-				);
-
-				const data = {
-					dataTransfer: createDataTransfer(),
-					preventDefault: preventDefaultSpy
-				};
-				viewDocument.fire( 'cut', data );
-
-				sinon.assert.calledOnce( preventDefaultSpy );
-				expect( data.dataTransfer.getData( 'text/html' ) ).to.equal( viewTable( [
-					[ '01', '02' ],
-					[ '11', '12' ]
-				] ) );
-			} );
-
-			it( 'should be disabled in a readonly mode', () => {
-				const preventDefaultStub = sinon.stub();
-
-				editor.isReadOnly = true;
-
-				tableSelection.setCellSelection(
-					modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
-					modelRoot.getNodeByPath( [ 0, 1, 2 ] )
-				);
-
-				const data = {
-					dataTransfer: createDataTransfer(),
-					preventDefault: preventDefaultStub
-				};
-				viewDocument.fire( 'cut', data );
-
-				editor.isReadOnly = false;
-
-				expect( data.dataTransfer.getData( 'text/html' ) ).to.be.undefined;
-				assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
-					[ '00', '01', '02' ],
-					[ '10', '11', '12' ],
-					[ '20', '21', '22' ]
-				] ) );
-
-				sinon.assert.calledOnce( preventDefaultStub );
-			} );
+		it( 'requires TableSelection and TableUtils ', () => {
+			expect( TableClipboard.requires ).to.deep.equal( [ TableSelection, TableUtils ] );
 		} );
 	} );
-
-	function assertClipboardContentOnMethod( method, expectedViewTable ) {
-		const data = {
-			dataTransfer: createDataTransfer(),
-			preventDefault: sinon.spy()
-		};
-		viewDocument.fire( method, data );
-
-		expect( data.dataTransfer.getData( 'text/html' ) ).to.equal( expectedViewTable );
-	}
-
-	function createDataTransfer() {
-		const store = new Map();
-
-		return {
-			setData( type, data ) {
-				store.set( type, data );
-			},
-
-			getData( type ) {
-				return store.get( type );
-			}
-		};
-	}
 } );

+ 82 - 0
packages/ckeditor5-table/tests/tableutils.js

@@ -219,6 +219,88 @@ describe( 'TableUtils', () => {
 				[ '', '' ]
 			] ) );
 		} );
+
+		describe( 'with copyStructureFrom enabled', () => {
+			beforeEach( () => {
+				// +----+----+----+----+----+----+
+				// | 00 | 01      | 03 | 04 | 05 |
+				// +----+         +    +----+----+
+				// | 10 |         |    | 14      |
+				// +----+----+----+----+----+----+
+				setData( model, modelTable( [
+					[ '00', { contents: '01', colspan: 2, rowspan: 2 }, { contents: '03', rowspan: 2 }, '04', '05' ],
+					[ '10', { contents: '14', colspan: 2 } ]
+				] ) );
+			} );
+
+			it( 'should copy structure from the first row', () => {
+				tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 0, rows: 1, copyStructureFromAbove: false } );
+
+				// +----+----+----+----+----+----+
+				// |    |         |    |    |    |
+				// +----+----+----+----+----+----+
+				// | 00 | 01      | 03 | 04 | 05 |
+				// +----+         +    +----+----+
+				// | 10 |         |    | 14      |
+				// +----+----+----+----+----+----+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '', { contents: '', colspan: 2 }, '', '', '' ],
+					[ '00', { contents: '01', colspan: 2, rowspan: 2 }, { contents: '03', rowspan: 2 }, '04', '05' ],
+					[ '10', { contents: '14', colspan: 2 } ]
+				] ) );
+			} );
+
+			it( 'should copy structure from the first row and properly handle row-spanned cells', () => {
+				tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 1, rows: 1, copyStructureFromAbove: true } );
+
+				// +----+----+----+----+----+----+
+				// | 00 | 01      | 03 | 04 | 05 |
+				// +----+         +    +----+----+
+				// |    |         |    |    |    |
+				// +----+         +    +----+----+
+				// | 10 |         |    | 14      |
+				// +----+----+----+----+----+----+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '00', { contents: '01', colspan: 2, rowspan: 3 }, { contents: '03', rowspan: 3 }, '04', '05' ],
+					[ '', '', '' ],
+					[ '10', { contents: '14', colspan: 2 } ]
+				] ) );
+			} );
+
+			it( 'should copy structure from the last row', () => {
+				tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 2, rows: 1, copyStructureFromAbove: true } );
+
+				// +----+----+----+----+----+----+
+				// | 00 | 01      | 03 | 04 | 05 |
+				// +----+         +    +----+----+
+				// | 10 |         |    | 14      |
+				// +----+----+----+----+----+----+
+				// |    |         |    |         |
+				// +----+----+----+----+----+----+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '00', { contents: '01', colspan: 2, rowspan: 2 }, { contents: '03', rowspan: 2 }, '04', '05' ],
+					[ '10', { contents: '14', colspan: 2 } ],
+					[ '', { contents: '', colspan: 2 }, '', { contents: '', colspan: 2 } ]
+				] ) );
+			} );
+
+			it( 'should copy structure from the last row and properly handle row-spanned cells', () => {
+				tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 1, rows: 1, copyStructureFromAbove: false } );
+
+				// +----+----+----+----+----+----+
+				// | 00 | 01      | 03 | 04 | 05 |
+				// +----+         +    +----+----+
+				// |    |         |    |         |
+				// +----+         +    +----+----+
+				// | 10 |         |    | 14      |
+				// +----+----+----+----+----+----+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '00', { contents: '01', colspan: 2, rowspan: 3 }, { contents: '03', rowspan: 3 }, '04', '05' ],
+					[ '', { contents: '', colspan: 2 } ],
+					[ '10', { contents: '14', colspan: 2 } ]
+				] ) );
+			} );
+		} );
 	} );
 
 	describe( 'insertColumns()', () => {

+ 20 - 0
packages/ckeditor5-table/tests/ui/colorinputview.js

@@ -218,6 +218,26 @@ describe( 'ColorInputView', () => {
 				expect( inputView.value ).to.equal( 'bar' );
 			} );
 
+			it(
+				`when the color input value is set to one of defined colors, but with few additional white spaces,
+				should use its label as the text input value`,
+				() => {
+					view.value = 'rgb(0,    255, 0)';
+					expect( inputView.value ).to.equal( 'Green' );
+
+					view.value = '   rgb( 255 0  0)    ';
+					expect( inputView.value ).to.equal( 'Red' );
+
+					view.value = ' 		  rgb(0,  0,  255 )';
+					expect( inputView.value ).to.equal( 'Blue' );
+
+					// Blindly stripping spaces may not work.
+					// rgb(25 50 0) != rgb(255 0 0)
+					view.value = ' 		  rgb(25 50  0)';
+					expect( inputView.value ).to.equal( ' 		  rgb(25 50  0)' );
+				}
+			);
+
 			it( `when the color input value is set to one of defined colors,
 			should use its label as the text input value`, () => {
 				view.value = 'rgb(0,255,0)';

+ 3 - 1
packages/ckeditor5-table/tests/ui/utils.js

@@ -29,6 +29,7 @@ import {
 } from '../../src/ui/utils';
 import Collection from '@ckeditor/ckeditor5-utils/src/collection';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import { centeredBalloonPositionForLongWidgets } from '@ckeditor/ckeditor5-widget/src/utils';
 import { modelTable } from '../_utils/utils';
 
 describe( 'UI Utils', () => {
@@ -139,7 +140,8 @@ describe( 'UI Utils', () => {
 						defaultPositions.northArrowSouthEast,
 						defaultPositions.southArrowNorth,
 						defaultPositions.southArrowNorthWest,
-						defaultPositions.southArrowNorthEast
+						defaultPositions.southArrowNorthEast,
+						centeredBalloonPositionForLongWidgets
 					]
 				} );
 			} );

+ 89 - 1
packages/ckeditor5-table/tests/utils.js

@@ -13,7 +13,7 @@ import { modelTable } from './_utils/utils';
 import {
 	getSelectedTableCells,
 	getTableCellsContainingSelection,
-	getSelectionAffectedTableCells
+	getSelectionAffectedTableCells, getVerticallyOverlappingCells, getHorizontallyOverlappingCells
 } from '../src/utils';
 
 describe( 'table utils', () => {
@@ -359,4 +359,92 @@ describe( 'table utils', () => {
 			expect( getSelectionAffectedTableCells( selection ) ).to.be.empty;
 		} );
 	} );
+
+	describe( 'getVerticallyOverlappingCells()', () => {
+		let table;
+
+		beforeEach( () => {
+			// +----+----+----+----+----+
+			// | 00 | 01 | 02 | 03 | 04 |
+			// +    +    +----+    +----+
+			// |    |    | 12 |    | 14 |
+			// +    +    +    +----+----+
+			// |    |    |    | 23 | 24 |
+			// +    +----+    +    +----+
+			// |    | 31 |    |    | 34 |
+			// +    +    +----+----+----+
+			// |    |    | 42 | 43 | 44 |
+			// +----+----+----+----+----+
+			setModelData( model, modelTable( [
+				[ { contents: '00', rowspan: 5 }, { contents: '01', rowspan: 3 }, '02', { contents: '03', rowspan: 2 }, '04' ],
+				[ { contents: '12', rowspan: 3 }, '14' ],
+				[ { contents: '23', rowspan: 2 }, '24' ],
+				[ { contents: '31', rowspan: 2 }, '34' ],
+				[ '42', '43', '44' ]
+			] ) );
+
+			table = modelRoot.getChild( 0 );
+		} );
+
+		it( 'should return empty array for no overlapping cells', () => {
+			const cellsInfo = getVerticallyOverlappingCells( table, 0 );
+
+			expect( cellsInfo ).to.be.empty;
+		} );
+
+		it( 'should return overlapping cells info for given overlapRow', () => {
+			const cellsInfo = getVerticallyOverlappingCells( table, 2 );
+
+			expect( cellsInfo[ 0 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) ); // Cell 00
+			expect( cellsInfo[ 1 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) ); // Cell 01
+			expect( cellsInfo[ 2 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 1, 0 ] ) ); // Cell 12
+		} );
+
+		it( 'should ignore rows below startRow', () => {
+			const cellsInfo = getVerticallyOverlappingCells( table, 2, 1 );
+
+			expect( cellsInfo[ 0 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 1, 0 ] ) ); // Cell 12
+		} );
+	} );
+
+	describe( 'getHorizontallyOverlappingCells()', () => {
+		let table;
+
+		beforeEach( () => {
+			// +----+----+----+----+----+
+			// | 00                     |
+			// +----+----+----+----+----+
+			// | 10           | 13      |
+			// +----+----+----+----+----+
+			// | 20 | 21           | 24 |
+			// +----+----+----+----+----+
+			// | 30      | 32      | 34 |
+			// +----+----+----+----+----+
+			// | 40 | 41 | 42 | 43 | 44 |
+			// +----+----+----+----+----+
+			setModelData( model, modelTable( [
+				[ { contents: '00', colspan: 5 } ],
+				[ { contents: '10', colspan: 3 }, { contents: '13', colspan: 2 } ],
+				[ '20', { contents: '21', colspan: 3 }, '24' ],
+				[ { contents: '30', colspan: 2 }, { contents: '32', colspan: 2 }, '34' ],
+				[ '40', '41', '42', '43', '44' ]
+			] ) );
+
+			table = modelRoot.getChild( 0 );
+		} );
+
+		it( 'should return empty array for no overlapping cells', () => {
+			const cellsInfo = getHorizontallyOverlappingCells( table, 0 );
+
+			expect( cellsInfo ).to.be.empty;
+		} );
+
+		it( 'should return overlapping cells info for given overlapColumn', () => {
+			const cellsInfo = getHorizontallyOverlappingCells( table, 2 );
+
+			expect( cellsInfo[ 0 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) ); // Cell 00
+			expect( cellsInfo[ 1 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 1, 0 ] ) ); // Cell 10
+			expect( cellsInfo[ 2 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 2, 1 ] ) ); // Cell 21
+		} );
+	} );
 } );

+ 2 - 1
packages/ckeditor5-theme-lark/theme/ckeditor5-ui/globals/_colors.css

@@ -16,7 +16,8 @@
 
 	/* -- Generic colors ------------------------------------------------------------------------ */
 
-	--ck-color-focus-border: 									hsl(208, 79%, 51%);
+	--ck-color-focus-border-coordinates: 						208, 79%, 51%;
+	--ck-color-focus-border: 									hsl(var(--ck-color-focus-border-coordinates));
 	--ck-color-focus-outer-shadow:								hsl(207, 89%, 86%);
 	--ck-color-focus-disabled-shadow:							hsla(209, 90%, 72%,.3);
 	--ck-color-focus-error-shadow:								hsla(9,100%,56%,.3);

+ 6 - 2
packages/ckeditor5-theme-lark/theme/ckeditor5-widget/widget.css

@@ -129,8 +129,12 @@
 
 	&:not(.ck-widget_selected) {
 		/* Disable visual effects of hover/active widget when CKEditor is in readOnly mode.
-		See: https://github.com/ckeditor/ckeditor5/issues/1261 */
-		--ck-widget-outline-thickness: 0;
+		 * See: https://github.com/ckeditor/ckeditor5/issues/1261
+		 *
+		 * Leave the unit because this custom property is used in calc() by other features.
+		 * See: https://github.com/ckeditor/ckeditor5/issues/6775
+		 */
+		--ck-widget-outline-thickness: 0px;
 	}
 
 	&.ck-widget_with-selection-handle {

+ 176 - 0
packages/ckeditor5-theme-lark/theme/ckeditor5-widget/widgettypearound.css

@@ -0,0 +1,176 @@
+/*
+ * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+:root {
+	--ck-widget-type-around-button-size: 20px;
+	--ck-color-widget-type-around-button-active: var(--ck-color-focus-border);
+	--ck-color-widget-type-around-button-hover: var(--ck-color-widget-hover-border);
+	--ck-color-widget-type-around-button-blurred-editable: var(--ck-color-widget-blurred-border);
+	--ck-color-widget-type-around-button-radar-start-alpha: 0;
+	--ck-color-widget-type-around-button-radar-end-alpha: .3;
+	--ck-color-widget-type-around-button-icon: var(--ck-color-base-background);
+}
+
+.ck .ck-widget {
+	/*
+	 * Styles of the type around buttons
+	 */
+	& .ck-widget__type-around__button {
+		width: var(--ck-widget-type-around-button-size);
+		height: var(--ck-widget-type-around-button-size);
+		background: var(--ck-color-widget-type-around-button);
+		border-radius: 100px;
+
+		pointer-events: none;
+		opacity: 0;
+		transition: opacity var(--ck-widget-handler-animation-duration) var(--ck-widget-handler-animation-curve), background var(--ck-widget-handler-animation-duration) var(--ck-widget-handler-animation-curve);
+
+		& svg {
+			width: 10px;
+			height: 8px;
+			transform: translate(-50%,-50%);
+			transition: transform .5s ease;
+			margin-top: 1px;
+
+			& * {
+				stroke-dasharray: 10;
+				stroke-dashoffset: 0;
+
+				fill: none;
+				stroke: var(--ck-color-widget-type-around-button-icon);
+				stroke-width: 1.5px;
+				stroke-linecap: round;
+				stroke-linejoin: round;
+			}
+
+			& line {
+				stroke-dasharray: 7;
+			}
+		}
+
+		&:hover {
+			/*
+			 * Display the "sonar" around the button when hovered.
+			 */
+			animation: ck-widget-type-around-button-sonar 1s ease infinite;
+
+			/*
+			 * Animate active button's icon.
+			 */
+			& svg {
+				& polyline {
+					animation: ck-widget-type-around-arrow-dash 2s linear;
+				}
+
+				& line {
+					animation: ck-widget-type-around-arrow-tip-dash 2s linear;
+				}
+			}
+		}
+	}
+
+	/*
+	 * Show type around buttons when the widget gets selected or being hovered.
+	 */
+	&.ck-widget_selected,
+	&:hover {
+		& > .ck-widget__type-around > .ck-widget__type-around__button {
+			pointer-events: auto;
+			opacity: 1;
+		}
+	}
+
+	/*
+	 * Styles for the buttons when the widget is NOT selected (but the buttons are visible
+	 * and still can be hovered).
+	 */
+	&:not(.ck-widget_selected) > .ck-widget__type-around > .ck-widget__type-around__button {
+		background: var(--ck-color-widget-type-around-button-hover);
+	}
+
+	/*
+	 * Styles for the buttons when:
+	 * - the widget is selected,
+	 * - or the button is being hovered (regardless of the widget state).
+	 */
+	&.ck-widget_selected > .ck-widget__type-around > .ck-widget__type-around__button,
+	& > .ck-widget__type-around > .ck-widget__type-around__button:hover {
+		background: var(--ck-color-widget-type-around-button-active);
+
+		&::after {
+			width: calc(var(--ck-widget-type-around-button-size) - 2px);
+			height: calc(var(--ck-widget-type-around-button-size) - 2px);
+			border-radius: 100px;
+			background: linear-gradient(135deg, hsla(0,0%,100%,0) 0%, hsla(0,0%,100%,.3) 100%);
+		}
+	}
+
+	/*
+	 * Styles for the "before" button when the widget has a selection handle. Because some space
+	 * is consumed by the handle, the button must be moved slightly to the right to let it breathe.
+	 */
+	&.ck-widget_with-selection-handle > .ck-widget__type-around > .ck-widget__type-around__button_before {
+		margin-left: 20px;
+	}
+}
+
+/*
+ * Hide type around buttons when the widget is selected as a child of a selected
+ * nested editable (e.g. mulit-cell table selection).
+ *
+ * See https://github.com/ckeditor/ckeditor5/issues/7263.
+ */
+.ck-editor__nested-editable.ck-editor__editable_selected {
+	& .ck-widget {
+		&.ck-widget_selected,
+		&:hover {
+			& > .ck-widget__type-around > .ck-widget__type-around__button {
+				pointer-events: none;
+				opacity: 0;
+			}
+		}
+	}
+}
+
+/*
+ * Styles for the buttons when the widget is selected but the user clicked outside of the editor (blurred the editor).
+ */
+.ck-editor__editable.ck-blurred .ck-widget.ck-widget_selected > .ck-widget__type-around > .ck-widget__type-around__button:not(:hover) {
+	background: var(--ck-color-widget-type-around-button-blurred-editable);
+
+	& svg * {
+		stroke: hsl(0,0%,60%);
+	}
+}
+
+@keyframes ck-widget-type-around-arrow-dash {
+	0% {
+		stroke-dashoffset: 10;
+	}
+	20%, 100% {
+		stroke-dashoffset: 0;
+	}
+}
+
+@keyframes ck-widget-type-around-arrow-tip-dash {
+	0%, 20% {
+		stroke-dashoffset: 7;
+	}
+	40%, 100% {
+		stroke-dashoffset: 0;
+	}
+}
+
+@keyframes ck-widget-type-around-button-sonar {
+	0% {
+		box-shadow: 0 0 0 0 hsla(var(--ck-color-focus-border-coordinates), var(--ck-color-widget-type-around-button-radar-start-alpha));
+	}
+	50% {
+		box-shadow: 0 0 0 5px hsla(var(--ck-color-focus-border-coordinates), var(--ck-color-widget-type-around-button-radar-end-alpha));
+	}
+	100% {
+		box-shadow: 0 0 0 5px hsla(var(--ck-color-focus-border-coordinates), var(--ck-color-widget-type-around-button-radar-start-alpha));
+	}
+}

+ 5 - 2
packages/ckeditor5-ui/src/colorgrid/utils.js

@@ -64,13 +64,16 @@ export function normalizeColorOptions( options ) {
 }
 
 // Creates a normalized color definition from the user-defined configuration.
+// The "normalization" means it will create full
+// {@link module:ui/colorgrid/colorgrid~ColorDefinition `ColorDefinition-like`}
+// object for string values, and add a `view` property, for each definition.
 //
 // @param {String|module:ui/colorgrid/colorgrid~ColorDefinition}
 // @returns {module:ui/colorgrid/colorgrid~ColorDefinition}
 export function normalizeSingleColorDefinition( color ) {
 	if ( typeof color === 'string' ) {
 		return {
-			model: color.replace( / /g, '' ),
+			model: color,
 			label: color,
 			hasBorder: false,
 			view: {
@@ -82,7 +85,7 @@ export function normalizeSingleColorDefinition( color ) {
 		};
 	} else {
 		return {
-			model: color.color.replace( / /g, '' ),
+			model: color.color,
 			label: color.label || color.color,
 			hasBorder: color.hasBorder === undefined ? false : color.hasBorder,
 			view: {

+ 18 - 4
packages/ckeditor5-utils/src/dom/position.js

@@ -129,9 +129,15 @@ export function getOptimalPosition( { element, target, positions, limiter, fitIn
 // @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} An array containing position name and its Rect.
+// @returns {Array|null} An array containing position name and its Rect (or null if position should be ignored).
 function getPositionNameAndRect( position, targetRect, elementRect ) {
-	const { left, top, name } = position( targetRect, elementRect );
+	const positionData = position( targetRect, elementRect );
+
+	if ( !positionData ) {
+		return null;
+	}
+
+	const { left, top, name } = positionData;
 
 	return [ name, elementRect.clone().moveTo( left, top ) ];
 }
@@ -205,7 +211,13 @@ function processPositionsToAreas( positions, { targetRect, elementRect, limiterR
 	const elementRectArea = elementRect.getArea();
 
 	for ( const position of positions ) {
-		const [ positionName, positionRect ] = getPositionNameAndRect( position, targetRect, elementRect );
+		const positionData = getPositionNameAndRect( position, targetRect, elementRect );
+
+		if ( !positionData ) {
+			continue;
+		}
+
+		const [ positionName, positionRect ] = positionData;
 		let limiterIntersectArea = 0;
 		let viewportIntersectArea = 0;
 
@@ -351,7 +363,7 @@ function getAbsoluteRectCoordinates( { left, top } ) {
 }
 
 /**
- * The `getOptimalPosition` helper options.
+ * The `getOptimalPosition()` helper options.
  *
  * @interface module:utils/dom/position~Options
  */
@@ -372,6 +384,8 @@ 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.
  *
+ * **Note**: If a function returns `null`, it is ignored by the `getOptimalPosition()`.
+ *
  * @member {Array.<Function>} #positions
  */
 

+ 26 - 1
packages/ckeditor5-utils/tests/dom/position.js

@@ -95,6 +95,8 @@ const attachTopRight = ( targetRect, elementRect ) => ( {
 	name: 'top-right'
 } );
 
+const attachNone = () => null;
+
 const allPositions = [
 	attachLeftBottom,
 	attachLeftTop,
@@ -103,7 +105,8 @@ const allPositions = [
 	attachBottomRight,
 	attachBottomLeft,
 	attachTopLeft,
-	attachTopRight
+	attachTopRight,
+	attachNone
 ];
 
 describe( 'getOptimalPosition()', () => {
@@ -291,6 +294,17 @@ describe( 'getOptimalPosition()', () => {
 				name: 'right-bottom'
 			} );
 		} );
+
+		it( 'should allow position function to return null to be ignored', () => {
+			assertPosition( {
+				element, target,
+				positions: [ attachRightBottom, attachNone ]
+			}, {
+				top: 100,
+				left: 110,
+				name: 'right-bottom'
+			} );
+		} );
 	} );
 
 	describe( 'with a limiter', () => {
@@ -392,6 +406,17 @@ describe( 'getOptimalPosition()', () => {
 
 			element.remove();
 		} );
+
+		it( 'should allow position function to return null to be ignored', () => {
+			assertPosition( {
+				element, target, limiter,
+				positions: [ attachLeftBottom, attachNone ]
+			}, {
+				top: 100,
+				left: -20,
+				name: 'left-bottom'
+			} );
+		} );
 	} );
 
 	describe( 'with fitInViewport on', () => {

+ 3 - 1
packages/ckeditor5-widget/lang/contexts.json

@@ -1,3 +1,5 @@
 {
-	"Widget toolbar": "The label used by assistive technologies describing a toolbar attached to a widget."
+	"Widget toolbar": "The label used by assistive technologies describing a toolbar attached to a widget.",
+	"Insert paragraph before block": "The title displayed when a mouse is over a button that inserts a paragraph before a block.",
+	"Insert paragraph after block": "The title displayed when a mouse is over a button that inserts a paragraph after a block."
 }

+ 2 - 0
packages/ckeditor5-widget/package.json

@@ -24,6 +24,8 @@
     "@ckeditor/ckeditor5-enter": "^19.0.0",
     "@ckeditor/ckeditor5-essentials": "^19.0.0",
     "@ckeditor/ckeditor5-heading": "^19.0.0",
+    "@ckeditor/ckeditor5-horizontal-line": "^19.0.0",
+    "@ckeditor/ckeditor5-media-embed": "^19.0.0",
     "@ckeditor/ckeditor5-paragraph": "^19.0.0",
     "@ckeditor/ckeditor5-table": "^19.0.0",
     "@ckeditor/ckeditor5-typing": "^19.0.0",

+ 62 - 0
packages/ckeditor5-widget/src/utils.js

@@ -9,6 +9,9 @@
 
 import HighlightStack from './highlightstack';
 import IconView from '@ckeditor/ckeditor5-ui/src/icon/iconview';
+import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
+import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview';
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 
 import dragHandleIcon from '../theme/icons/drag-handle.svg';
 
@@ -339,6 +342,65 @@ 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}

+ 8 - 0
packages/ckeditor5-widget/src/widget.js

@@ -9,6 +9,7 @@
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import MouseObserver from '@ckeditor/ckeditor5-engine/src/view/observer/mouseobserver';
+import WidgetTypeAround from './widgettypearound/widgettypearound';
 import { getLabel, isWidget, WIDGET_SELECTED_CLASS_NAME } from './utils';
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import env from '@ckeditor/ckeditor5-utils/src/env';
@@ -38,6 +39,13 @@ export default class Widget extends Plugin {
 		return 'Widget';
 	}
 
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ WidgetTypeAround ];
+	}
+
 	/**
 	 * @inheritDoc
 	 */

+ 6 - 2
packages/ckeditor5-widget/src/widgettoolbarrepository.js

@@ -11,7 +11,10 @@ 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 } from './utils';
+import {
+	isWidget,
+	centeredBalloonPositionForLongWidgets
+} from './utils';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 
 /**
@@ -272,7 +275,8 @@ function getBalloonPositionData( editor, relatedElement ) {
 			defaultPositions.northArrowSouthEast,
 			defaultPositions.southArrowNorth,
 			defaultPositions.southArrowNorthWest,
-			defaultPositions.southArrowNorthEast
+			defaultPositions.southArrowNorthEast,
+			centeredBalloonPositionForLongWidgets
 		]
 	};
 }

+ 95 - 0
packages/ckeditor5-widget/src/widgettypearound/utils.js

@@ -0,0 +1,95 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module widget/widgettypearound/utils
+ */
+
+import { isWidget } from '../utils';
+
+/**
+ * Checks if an element is a widget that qualifies to get the type around UI.
+ *
+ * @param {module:engine/view/element~Element} viewElement
+ * @param {module:engine/model/element~Element} modelElement
+ * @param {module:engine/model/schema~Schema} schema
+ * @returns {Boolean}
+ */
+export function isTypeAroundWidget( viewElement, modelElement, schema ) {
+	return viewElement && isWidget( viewElement ) && !schema.isInline( modelElement );
+}
+
+/**
+ * For the passed HTML element, this helper finds the closest type around button ancestor.
+ *
+ * @param {HTMLElement} domElement
+ * @returns {HTMLElement|null}
+ */
+export function getClosestTypeAroundDomButton( domElement ) {
+	return domElement.closest( '.ck-widget__type-around__button' );
+}
+
+/**
+ * For the passed type around button element, this helper determines at which position
+ * the paragraph would be inserted into the content if, for instance, the button was
+ * clicked by the user.
+ *
+ * @param {HTMLElement} domElement
+ * @returns {String} Either `'before'` or `'after'`.
+ */
+export function getTypeAroundButtonPosition( domElement ) {
+	return domElement.classList.contains( 'ck-widget__type-around__button_before' ) ? 'before' : 'after';
+}
+
+/**
+ * For the passed HTML element, this helper returns the closest view widget ancestor.
+ *
+ * @param {HTMLElement} domElement
+ * @param {module:engine/view/domconverter~DomConverter} domConverter
+ * @returns {module:engine/view/element~Element}
+ */
+export function getClosestWidgetViewElement( domElement, domConverter ) {
+	const widgetDomElement = domElement.closest( '.ck-widget' );
+
+	return domConverter.mapDomToView( widgetDomElement );
+}
+
+/**
+ * For the passed widget view element, this helper returns an array of positions which
+ * correspond to the "tight spots" around the widget which cannot be accessed due to
+ * limitations of selection rendering in web browsers.
+ *
+ * @param {module:engine/view/element~Element} widgetViewElement
+ * @returns {Array.<String>}
+ */
+export function getWidgetTypeAroundPositions( widgetViewElement ) {
+	const positions = [];
+
+	if ( isFirstChild( widgetViewElement ) || hasPreviousWidgetSibling( widgetViewElement ) ) {
+		positions.push( 'before' );
+	}
+
+	if ( isLastChild( widgetViewElement ) || hasNextWidgetSibling( widgetViewElement ) ) {
+		positions.push( 'after' );
+	}
+
+	return positions;
+}
+
+function isFirstChild( widget ) {
+	return !widget.previousSibling;
+}
+
+function isLastChild( widget ) {
+	return !widget.nextSibling;
+}
+
+function hasPreviousWidgetSibling( widget ) {
+	return widget.previousSibling && isWidget( widget.previousSibling );
+}
+
+function hasNextWidgetSibling( widget ) {
+	return widget.nextSibling && isWidget( widget.nextSibling );
+}

+ 299 - 0
packages/ckeditor5-widget/src/widgettypearound/widgettypearound.js

@@ -0,0 +1,299 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* global DOMParser */
+
+/**
+ * @module widget/widgettypearound
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Template from '@ckeditor/ckeditor5-ui/src/template';
+
+import {
+	isTypeAroundWidget,
+	getWidgetTypeAroundPositions,
+	getClosestTypeAroundDomButton,
+	getTypeAroundButtonPosition,
+	getClosestWidgetViewElement
+} from './utils';
+
+import returnIcon from '../../theme/icons/return-arrow.svg';
+import '../../theme/widgettypearound.css';
+
+const POSSIBLE_INSERTION_POSITIONS = [ 'before', 'after' ];
+
+// Do the SVG parsing once and then clone the result <svg> DOM element for each new button.
+const RETURN_ARROW_ICON_ELEMENT = new DOMParser().parseFromString( returnIcon, 'image/svg+xml' ).firstChild;
+
+/**
+ * A plugin that allows users to type around widgets where normally it is impossible to place the caret due
+ * to limitations of web browsers. These "tight spots" occur, for instance, before (or after) a widget being
+ * the first (or last) child of its parent or between two block widgets.
+ *
+ * This plugin extends the {@link module:widget/widget~Widget `Widget`} plugin and injects a user interface
+ * with two buttons into each widget instance in the editor. Each of the buttons can be clicked by the
+ * user if the widget is next to the "tight spot". Once clicked, a paragraph is created with the selection anchored
+ * in it so that users can type (or insert content, paste, etc.) straight away.
+ *
+ * @extends module:core/plugin~Plugin
+ * @private
+ */
+export default class WidgetTypeAround extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ Paragraph ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'WidgetTypeAround';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	constructor( editor ) {
+		super( editor );
+
+		/**
+		 * A set containing all widgets in all editor roots that have the type around UI injected in
+		 * {@link #_enableTypeAroundUIInjection}.
+		 *
+		 * Keeping track of them saves time, for instance, when updating their CSS classes.
+		 *
+		 * @private
+		 * @readonly
+		 * @member {Set} #_widgetsWithTypeAroundUI
+		 */
+		this._widgetsWithTypeAroundUI = new Set();
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	destroy() {
+		this._widgetsWithTypeAroundUI.clear();
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		this._enableTypeAroundUIInjection();
+		this._enableDetectionOfTypeAroundWidgets();
+		this._enableInsertingParagraphsOnButtonClick();
+
+		// TODO: This is a quick fix and it should be removed the proper integration arrives.
+		this._enableTemporaryTrackChangesIntegration();
+	}
+
+	/**
+	 * Inserts a new paragraph next to a widget element with the selection anchored in it.
+	 *
+	 * **Note**: This method is heavily user-oriented and will both focus the editing view and scroll
+	 * the viewport to the selection in the inserted paragraph.
+	 *
+	 * @protected
+	 * @param {module:engine/view/element~Element} widgetViewElement The view widget element next to which a paragraph is inserted.
+	 * @param {'before'|'after'} position The position where the paragraph is inserted. Either `'before'` or `'after'` the widget.
+	 */
+	_insertParagraph( widgetViewElement, position ) {
+		const editor = this.editor;
+		const editingView = editor.editing.view;
+		const widgetModelElement = editor.editing.mapper.toModelElement( widgetViewElement );
+		let modelPosition;
+
+		if ( position === 'before' ) {
+			modelPosition = editor.model.createPositionBefore( widgetModelElement );
+		} else {
+			modelPosition = editor.model.createPositionAfter( widgetModelElement );
+		}
+
+		editor.execute( 'insertParagraph', {
+			position: modelPosition
+		} );
+
+		editingView.focus();
+		editingView.scrollToTheSelection();
+	}
+
+	/**
+	 * Creates a listener in the editing conversion pipeline that injects the type around
+	 * UI into every single widget instance created in the editor.
+	 *
+	 * The UI is delivered as a {@link module:engine/view/uielement~UIElement}
+	 * wrapper which renders DOM buttons that users can use to insert paragraphs.
+	 *
+	 * @private
+	 */
+	_enableTypeAroundUIInjection() {
+		const editor = this.editor;
+		const schema = editor.model.schema;
+		const t = editor.locale.t;
+		const buttonTitles = {
+			before: t( 'Insert paragraph before block' ),
+			after: t( 'Insert paragraph after block' )
+		};
+
+		editor.editing.downcastDispatcher.on( 'insert', ( evt, data, conversionApi ) => {
+			const viewElement = conversionApi.mapper.toViewElement( data.item );
+
+			// Filter out non-widgets and inline widgets.
+			if ( isTypeAroundWidget( viewElement, data.item, schema ) ) {
+				injectUIIntoWidget( conversionApi.writer, buttonTitles, viewElement );
+
+				// Keep track of widgets that have the type around UI injected.
+				// In the #_enableDetectionOfTypeAroundWidgets() we will iterate only over these
+				// widgets instead of all children of the root. This should improve the performance.
+				this._widgetsWithTypeAroundUI.add( viewElement );
+			}
+		}, { priority: 'low' } );
+	}
+
+	/**
+	 * Registers an editing view post-fixer which checks all block widgets in the content
+	 * and adds CSS classes to these which should have the typing around (UI) enabled
+	 * and visible for the users.
+	 *
+	 * @private
+	 */
+	_enableDetectionOfTypeAroundWidgets() {
+		const editor = this.editor;
+		const editingView = editor.editing.view;
+
+		function positionToWidgetCssClass( position ) {
+			return `ck-widget_can-type-around_${ position }`;
+		}
+
+		editingView.document.registerPostFixer( writer => {
+			for ( const widgetViewElement of this._widgetsWithTypeAroundUI ) {
+				// If the widget is no longer attached to the root (for instance, because it was removed),
+				// there is no need to update its classes and we can safely forget about it.
+				if ( !widgetViewElement.isAttached() ) {
+					this._widgetsWithTypeAroundUI.delete( widgetViewElement );
+				} else {
+					// Update widgets' classes depending on possible positions for paragraph insertion.
+					const positions = getWidgetTypeAroundPositions( widgetViewElement );
+
+					// Remove all classes. In theory we could remove only these that will not be added a few lines later,
+					// but since there are only two... KISS.
+					writer.removeClass( POSSIBLE_INSERTION_POSITIONS.map( positionToWidgetCssClass ), widgetViewElement );
+
+					// Set CSS classes related to possible positions. They are used so the UI knows which buttons to display.
+					writer.addClass( positions.map( positionToWidgetCssClass ), widgetViewElement );
+				}
+			}
+		} );
+	}
+
+	/**
+	 * Registers a `mousedown` listener for the view document which intercepts events
+	 * coming from the type around UI, which happens when a user clicks one of the buttons
+	 * that insert a paragraph next to a widget.
+	 *
+	 * @private
+	 */
+	_enableInsertingParagraphsOnButtonClick() {
+		const editor = this.editor;
+		const editingView = editor.editing.view;
+
+		editingView.document.on( 'mousedown', ( evt, domEventData ) => {
+			const button = getClosestTypeAroundDomButton( domEventData.domTarget );
+
+			if ( !button ) {
+				return;
+			}
+
+			const buttonPosition = getTypeAroundButtonPosition( button );
+			const widgetViewElement = getClosestWidgetViewElement( button, editingView.domConverter );
+
+			this._insertParagraph( widgetViewElement, buttonPosition );
+
+			domEventData.preventDefault();
+			evt.stop();
+		} );
+	}
+
+	/**
+	 * A quick fix for the integration with features requiring custom handling of the `insertParagraph`
+	 * command such as Track Changes. When the `insertParagraph` command is disabled, this fix adds
+	 * a CSS class to editor roots that makes the UI disappear.
+	 *
+	 * TODO: This is a quick fix and it should be replaced by a proper integration.
+	 *
+	 * @private
+	 */
+	_enableTemporaryTrackChangesIntegration() {
+		const editor = this.editor;
+		const editingView = editor.editing.view;
+		const insertParagraphCommand = this.editor.commands.get( 'insertParagraph' );
+		const className = 'ck-widget_type-around_temp-disabled';
+
+		this.listenTo( insertParagraphCommand, 'change:isEnabled', ( evt, name, isEnabled ) => {
+			editingView.change( writer => {
+				for ( const root of editingView.document.roots ) {
+					if ( isEnabled ) {
+						writer.removeClass( className, root );
+					} else {
+						writer.addClass( className, root );
+					}
+				}
+			} );
+		} );
+	}
+}
+
+// Injects the type around UI into a view widget instance.
+//
+// @param {module:engine/view/downcastwriter~DowncastWriter} viewWriter
+// @param {Object.<String,String>} buttonTitles
+// @param {module:engine/view/element~Element} widgetViewElement
+function injectUIIntoWidget( viewWriter, buttonTitles, widgetViewElement ) {
+	const typeAroundWrapper = viewWriter.createUIElement( 'div', {
+		class: 'ck ck-reset_all ck-widget__type-around'
+	}, function( domDocument ) {
+		const wrapperDomElement = this.toDomElement( domDocument );
+
+		injectButtons( wrapperDomElement, buttonTitles );
+
+		return wrapperDomElement;
+	} );
+
+	// Inject the type around wrapper into the widget's wrapper.
+	viewWriter.insert( viewWriter.createPositionAt( widgetViewElement, 'end' ), typeAroundWrapper );
+}
+
+// FYI: Not using the IconView class because each instance would need to be destroyed to avoid memory leaks
+// and it's pretty hard to figure out when a view (widget) is gone for good so it's cheaper to use raw
+// <svg> here.
+//
+// @param {HTMLElement} wrapperDomElement
+// @param {Object.<String,String>} buttonTitles
+function injectButtons( wrapperDomElement, buttonTitles ) {
+	for ( const position of POSSIBLE_INSERTION_POSITIONS ) {
+		const buttonTemplate = new Template( {
+			tag: 'div',
+			attributes: {
+				class: [
+					'ck',
+					'ck-widget__type-around__button',
+					`ck-widget__type-around__button_${ position }`
+				],
+				title: buttonTitles[ position ]
+			},
+			children: [
+				wrapperDomElement.ownerDocument.importNode( RETURN_ARROW_ICON_ELEMENT, true )
+			]
+		} );
+
+		wrapperDomElement.appendChild( buttonTemplate.render() );
+	}
+}

BIN
packages/ckeditor5-widget/tests/manual/sample.jpg


+ 99 - 0
packages/ckeditor5-widget/tests/manual/type-around.html

@@ -0,0 +1,99 @@
+<head>
+	<style>
+		body {
+			max-width: 800px;
+			margin: 20px auto;
+		}
+
+		/* stylelint-disable-next-line */
+		placeholder {
+			display: inline-block;
+			background: hsla(100,100%,50%);
+			padding: 4px 2px;
+			outline-offset: -2px;
+			margin-right: 1px;
+			margin-left: 3px;
+		}
+
+		/* stylelint-disable-next-line */
+		placeholder::selection {
+			display: none;
+		}
+	</style>
+</head>
+<button id="toggleReadOnly" type="button">Toggle read–only mode</button>
+<hr />
+<div id="editor">
+	<figure class="image"><img src="sample.jpg" alt="bar">
+		<figcaption>Caption</figcaption>
+	</figure>
+	<h2>Heading 1</h2>
+	<hr>
+	<hr>
+	<p><placeholder></placeholder> some text <placeholder></placeholder></p>
+	<figure class="table">
+		<table>
+			<tbody>
+				<tr>
+					<td>&nbsp;</td>
+					<td>&nbsp;</td>
+					<td>
+						<figure class="media">
+							<div data-oembed-url="https://www.youtube.com/watch?v=ZVv7UMQPEWk"><div style="position: relative; padding-bottom: 100%; height: 0; padding-bottom: 56.2493%;"><iframe src="https://www.youtube.com/embed/ZVv7UMQPEWk" style="position: absolute; width: 100%; height: 100%; top: 0; left: 0;" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen=""></iframe></div></div>
+						</figure>
+					</td>
+				</tr>
+				<tr>
+					<td>&nbsp;</td>
+					<td>
+						<p>&nbsp;</p>
+						<figure class="image"><img src="sample.jpg" alt="bar">
+							<figcaption>Caption</figcaption>
+						</figure>
+					</td>
+					<td>
+						<figure class="image"><img src="sample.jpg" alt="bar">
+							<figcaption>Caption</figcaption>
+						</figure>
+					</td>
+				</tr>
+				<tr>
+					<td>&nbsp;</td>
+					<td>e&nbsp;</td>
+					<td>
+						<figure class="image"><img src="sample.jpg" alt="bar">
+							<figcaption>Caption</figcaption>
+						</figure>
+					</td>
+				</tr>
+			</tbody>
+		</table>
+	</figure>
+	<hr>
+	<p><strong>Bold</strong> <i>Italic</i> <a href="https://ckeditor.com">Link</a></p>
+	<ul>
+		<li>UL List <placeholder></placeholder> item 1</li>
+		<li>UL List item 2</li>
+	</ul>
+	<ol>
+		<li>OL List item 1</li>
+		<li>OL List item 2</li>
+	</ol>
+	<figure class="image"><img src="sample.jpg" alt="bar">
+		<figcaption>Caption</figcaption>
+	</figure>
+	<figure class="image"><img src="sample.jpg" alt="bar">
+		<figcaption>Caption</figcaption>
+	</figure>
+	<blockquote>
+		<p>Quote</p>
+		<ul>
+			<li>Quoted UL List item 1</li>
+			<li>Quoted UL List item 2</li>
+		</ul>
+		<p>Quote</p>
+	</blockquote>
+	<figure class="image"><img src="sample.jpg" alt="bar">
+		<figcaption>Caption</figcaption>
+	</figure>
+</div>

+ 136 - 0
packages/ckeditor5-widget/tests/manual/type-around.js

@@ -0,0 +1,136 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals console, window, document */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import HorizontalLine from '@ckeditor/ckeditor5-horizontal-line/src/horizontalline';
+import MediaEmbed from '@ckeditor/ckeditor5-media-embed/src/mediaembed';
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import { toWidget, viewToModelPositionOutsideModelElement } from '../../src/utils';
+
+class InlineWidget extends Plugin {
+	constructor( editor ) {
+		super( editor );
+
+		editor.model.schema.register( 'placeholder', {
+			allowWhere: '$text',
+			isObject: true,
+			isInline: true
+		} );
+
+		editor.conversion.for( 'editingDowncast' ).elementToElement( {
+			model: 'placeholder',
+			view: ( modelItem, viewWriter ) => {
+				const widgetElement = createPlaceholderView( modelItem, viewWriter );
+
+				return toWidget( widgetElement, viewWriter );
+			}
+		} );
+
+		editor.conversion.for( 'dataDowncast' ).elementToElement( {
+			model: 'placeholder',
+			view: createPlaceholderView
+		} );
+
+		editor.conversion.for( 'upcast' ).elementToElement( {
+			view: 'placeholder',
+			model: 'placeholder'
+		} );
+
+		editor.editing.mapper.on(
+			'viewToModelPosition',
+			viewToModelPositionOutsideModelElement( editor.model, viewElement => viewElement.name == 'placeholder' )
+		);
+
+		this._createToolbarButton();
+
+		function createPlaceholderView( modelItem, viewWriter ) {
+			const widgetElement = viewWriter.createContainerElement( 'placeholder' );
+			const viewText = viewWriter.createText( '{inline-widget}' );
+
+			viewWriter.insert( viewWriter.createPositionAt( widgetElement, 0 ), viewText );
+
+			return widgetElement;
+		}
+	}
+
+	_createToolbarButton() {
+		const editor = this.editor;
+		const t = editor.t;
+
+		editor.ui.componentFactory.add( 'placeholder', locale => {
+			const buttonView = new ButtonView( locale );
+
+			buttonView.set( {
+				label: t( 'Insert placeholder' ),
+				tooltip: true,
+				withText: true
+			} );
+
+			this.listenTo( buttonView, 'execute', () => {
+				const model = editor.model;
+
+				model.change( writer => {
+					const placeholder = writer.createElement( 'placeholder' );
+
+					model.insertContent( placeholder );
+
+					writer.setSelection( placeholder, 'on' );
+				} );
+			} );
+
+			return buttonView;
+		} );
+	}
+}
+
+document.querySelector( '#toggleReadOnly' ).addEventListener( 'click', () => {
+	window.editor.isReadOnly = !window.editor.isReadOnly;
+} );
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ ArticlePluginSet, HorizontalLine, InlineWidget, MediaEmbed ],
+		toolbar: [
+			'heading',
+			'|',
+			'bold',
+			'italic',
+			'link',
+			'bulletedList',
+			'numberedList',
+			'|',
+			'outdent',
+			'indent',
+			'|',
+			'blockQuote',
+			'horizontalLine',
+			'insertTable',
+			'mediaEmbed',
+			'undo',
+			'redo'
+		],
+		image: {
+			toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ]
+		},
+		table: {
+			contentToolbar: [
+				'tableColumn',
+				'tableRow',
+				'mergeTableCells'
+			]
+		}
+	} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );
+

+ 22 - 0
packages/ckeditor5-widget/tests/manual/type-around.md

@@ -0,0 +1,22 @@
+# Typing around block widgets
+
+## General idea
+
+1. Move the mouse cursor over various block widgets.
+2. There should be buttons appearing at the top/bottom boundaries of a widget when there is no way to put the caret before/after.
+3. Clicking an individual button should insert a paragraph before/after the widget.
+
+## States
+
+1. Check the look of the buttons when widgets are in a different states:
+	* Not selected but hovered by the cursor.
+	* Selected and hovered by the cursor.
+	* Selected but the editor is blurred (use dev tools console to test that) and hovered by the cursor.
+2. In each of these states, the buttons that insert paragraphs around widgets should blend in with the style of the widget.
+
+## Exceptions
+
+1. Inline widgets should **never** display the UI (buttons) regardless of their position in the document and their surroundings.
+2. Blocks widgets should display the UI only when there's a "tight spot" next to them. If you can type freely before/after a widget, the UI (buttons) related to that position should never appear.
+	* If you can type before the widget (e.g. preceded by a paragraph), the top button should never appear.
+	* If you can type after the widget (e.g. followed by a paragraph), the bottom button should never appear.

+ 136 - 1
packages/ckeditor5-widget/tests/utils.js

@@ -20,7 +20,8 @@ import {
 	setHighlightHandling,
 	findOptimalInsertionPosition,
 	viewToModelPositionOutsideModelElement,
-	WIDGET_CLASS_NAME
+	WIDGET_CLASS_NAME,
+	centeredBalloonPositionForLongWidgets
 } from '../src/utils';
 import UIElement from '@ckeditor/ckeditor5-engine/src/view/uielement';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
@@ -29,6 +30,9 @@ 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;
@@ -489,4 +493,135 @@ 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'
+			} );
+		} );
+	} );
 } );

+ 21 - 11
packages/ckeditor5-widget/tests/widget-integration.js

@@ -42,10 +42,6 @@ describe( 'Widget - integration', () => {
 					inheritAllFrom: '$block',
 					isObject: true
 				} );
-				model.schema.register( 'paragraph', {
-					inheritAllFrom: '$block',
-					allowIn: '$root'
-				} );
 				model.schema.register( 'nested', {
 					allowIn: 'widget',
 					isLimit: true
@@ -63,7 +59,6 @@ describe( 'Widget - integration', () => {
 				} );
 
 				editor.conversion.for( 'downcast' )
-					.elementToElement( { model: 'paragraph', view: 'p' } )
 					.elementToElement( { model: 'inline', view: 'figure' } )
 					.elementToElement( { model: 'image', view: 'img' } )
 					.elementToElement( {
@@ -117,7 +112,11 @@ describe( 'Widget - integration', () => {
 		sinon.assert.notCalled( preventDefault );
 
 		expect( getViewData( view ) ).to.equal(
-			'<p>[]</p><div class="ck-widget" contenteditable="false"><figcaption contenteditable="true">foo bar</figcaption></div>'
+			'<p>[]</p>' +
+			'<div class="ck-widget ck-widget_can-type-around_after" contenteditable="false">' +
+				'<figcaption contenteditable="true">foo bar</figcaption>' +
+				'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
+			'</div>'
 		);
 
 		expect( getModelData( model ) ).to.equal( '<paragraph>[]</paragraph><widget><nested>foo bar</nested></widget>' );
@@ -140,7 +139,10 @@ describe( 'Widget - integration', () => {
 		sinon.assert.called( preventDefault );
 
 		expect( getViewData( view ) ).to.equal(
-			'<div class="ck-widget" contenteditable="false"><figcaption contenteditable="true">{foo bar}</figcaption></div>'
+			'<div class="ck-widget ck-widget_can-type-around_after ck-widget_can-type-around_before" contenteditable="false">' +
+				'<figcaption contenteditable="true">{foo bar}</figcaption>' +
+				'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
+			'</div>'
 		);
 		expect( getModelData( model ) ).to.equal( '<widget><nested>[foo bar]</nested></widget>' );
 	} );
@@ -162,9 +164,10 @@ describe( 'Widget - integration', () => {
 		sinon.assert.called( preventDefault );
 
 		expect( getViewData( view ) ).to.equal(
-			'<div class="ck-widget" contenteditable="false">' +
+			'<div class="ck-widget ck-widget_can-type-around_after ck-widget_can-type-around_before" contenteditable="false">' +
 				'<figcaption contenteditable="true">foo</figcaption>' +
 				'<figcaption contenteditable="true">{bar}</figcaption>' +
+				'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 			'</div>'
 		);
 
@@ -188,7 +191,10 @@ describe( 'Widget - integration', () => {
 		sinon.assert.called( preventDefault );
 
 		expect( getViewData( view ) ).to.equal(
-			'<div class="ck-widget" contenteditable="false"><figcaption contenteditable="true">{foo bar}</figcaption></div>'
+			'<div class="ck-widget ck-widget_can-type-around_after ck-widget_can-type-around_before" contenteditable="false">' +
+				'<figcaption contenteditable="true">{foo bar}</figcaption>' +
+				'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
+			'</div>'
 		);
 
 		expect( getModelData( model ) ).to.equal( '<widget><nested>[foo bar]</nested></widget>' );
@@ -217,7 +223,7 @@ describe( 'Widget - integration', () => {
 		expect( getModelData( model ) ).to.equal( '<paragraph>Foo[<inline-widget>foo bar</inline-widget>]Bar</paragraph>' );
 	} );
 
-	it( 'should does nothing for non-Safari browser', () => {
+	it( 'should do nothing for non-Safari browser', () => {
 		testUtils.sinon.stub( env, 'isSafari' ).get( () => false );
 
 		setModelData( model, '<paragraph>[]</paragraph><widget><nested>foo bar</nested></widget>' );
@@ -236,7 +242,11 @@ describe( 'Widget - integration', () => {
 		sinon.assert.notCalled( preventDefault );
 
 		expect( getViewData( view ) ).to.equal(
-			'<p>[]</p><div class="ck-widget" contenteditable="false"><figcaption contenteditable="true">foo bar</figcaption></div>'
+			'<p>[]</p>' +
+			'<div class="ck-widget ck-widget_can-type-around_after" contenteditable="false">' +
+				'<figcaption contenteditable="true">foo bar</figcaption>' +
+				'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
+			'</div>'
 		);
 
 		expect( getModelData( model ) ).to.equal( '<paragraph>[]</paragraph><widget><nested>foo bar</nested></widget>' );

+ 179 - 73
packages/ckeditor5-widget/tests/widget.js

@@ -3,8 +3,11 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
-import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+/* global document */
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
 import Widget from '../src/widget';
+import WidgetTypeAround from '../src/widgettypearound/widgettypearound';
 import Typing from '@ckeditor/ckeditor5-typing/src/typing';
 import MouseObserver from '@ckeditor/ckeditor5-engine/src/view/observer/mouseobserver';
 import { toWidget } from '../src/utils';
@@ -14,15 +17,19 @@ import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 
-/* global document */
-
 describe( 'Widget', () => {
-	let editor, model, view, viewDocument;
+	let element, editor, model, view, viewDocument;
 
 	testUtils.createSinonSandbox();
 
 	beforeEach( () => {
-		return VirtualTestEditor.create( { plugins: [ Widget, Typing ] } )
+		element = document.createElement( 'div' );
+		document.body.appendChild( element );
+
+		return ClassicTestEditor
+			.create( element, {
+				plugins: [ Widget, Typing ]
+			} )
 			.then( newEditor => {
 				editor = newEditor;
 				model = editor.model;
@@ -33,8 +40,7 @@ describe( 'Widget', () => {
 					inheritAllFrom: '$block',
 					isObject: true
 				} );
-				model.schema.register( 'paragraph', {
-					inheritAllFrom: '$block',
+				model.schema.extend( 'paragraph', {
 					allowIn: 'div'
 				} );
 				model.schema.register( 'inline', {
@@ -76,7 +82,6 @@ describe( 'Widget', () => {
 				} );
 
 				editor.conversion.for( 'downcast' )
-					.elementToElement( { model: 'paragraph', view: 'p' } )
 					.elementToElement( { model: 'inline', view: 'figure' } )
 					.elementToElement( { model: 'image', view: 'img' } )
 					.elementToElement( { model: 'blockQuote', view: 'blockquote' } )
@@ -110,6 +115,12 @@ describe( 'Widget', () => {
 			} );
 	} );
 
+	afterEach( () => {
+		element.remove();
+
+		return editor.destroy();
+	} );
+
 	it( 'should be loaded', () => {
 		expect( editor.plugins.get( Widget ) ).to.be.instanceOf( Widget );
 	} );
@@ -118,63 +129,67 @@ describe( 'Widget', () => {
 		expect( view.getObserver( MouseObserver ) ).to.be.instanceof( MouseObserver );
 	} );
 
+	it( 'should require the WidgetTypeAround plugin', () => {
+		expect( Widget.requires ).to.have.members( [ WidgetTypeAround ] );
+	} );
+
 	it( 'should create selection over clicked widget', () => {
 		setModelData( model, '[]<widget></widget>' );
 		const viewDiv = viewDocument.getRoot().getChild( 0 );
-		const domEventDataMock = {
-			target: viewDiv,
+		const domEventDataMock = new DomEventData( view, {
+			target: view.domConverter.mapViewToDom( viewDiv ),
 			preventDefault: sinon.spy()
-		};
+		} );
 
 		viewDocument.fire( 'mousedown', domEventDataMock );
 
 		expect( getModelData( model ) ).to.equal( '[<widget></widget>]' );
-		sinon.assert.calledOnce( domEventDataMock.preventDefault );
+		sinon.assert.calledOnce( domEventDataMock.domEvent.preventDefault );
 	} );
 
 	it( 'should create selection when clicked in nested element', () => {
 		setModelData( model, '[]<widget></widget>' );
 		const viewDiv = viewDocument.getRoot().getChild( 0 );
 		const viewB = viewDiv.getChild( 0 );
-		const domEventDataMock = {
-			target: viewB,
+		const domEventDataMock = new DomEventData( view, {
+			target: view.domConverter.mapViewToDom( viewB ),
 			preventDefault: sinon.spy()
-		};
+		} );
 
 		viewDocument.fire( 'mousedown', domEventDataMock );
 
 		expect( getModelData( model ) ).to.equal( '[<widget></widget>]' );
-		sinon.assert.calledOnce( domEventDataMock.preventDefault );
+		sinon.assert.calledOnce( domEventDataMock.domEvent.preventDefault );
 	} );
 
 	it( 'should do nothing if clicked in non-widget element', () => {
 		setModelData( model, '<paragraph>[]foo bar</paragraph><widget></widget>' );
 		const viewP = viewDocument.getRoot().getChild( 0 );
-		const domEventDataMock = {
-			target: viewP,
+		const domEventDataMock = new DomEventData( view, {
+			target: view.domConverter.mapViewToDom( viewP ),
 			preventDefault: sinon.spy()
-		};
+		} );
 
 		view.focus();
 		viewDocument.fire( 'mousedown', domEventDataMock );
 
 		expect( getModelData( model ) ).to.equal( '<paragraph>[]foo bar</paragraph><widget></widget>' );
-		sinon.assert.notCalled( domEventDataMock.preventDefault );
+		sinon.assert.notCalled( domEventDataMock.domEvent.preventDefault );
 	} );
 
 	it( 'should not focus editable if already is focused', () => {
 		setModelData( model, '<widget></widget>' );
 		const widget = viewDocument.getRoot().getChild( 0 );
-		const domEventDataMock = {
-			target: widget,
+		const domEventDataMock = new DomEventData( view, {
+			target: view.domConverter.mapViewToDom( widget ),
 			preventDefault: sinon.spy()
-		};
+		} );
 		const focusSpy = sinon.spy( view, 'focus' );
 
 		viewDocument.isFocused = true;
 		viewDocument.fire( 'mousedown', domEventDataMock );
 
-		sinon.assert.calledOnce( domEventDataMock.preventDefault );
+		sinon.assert.calledOnce( domEventDataMock.domEvent.preventDefault );
 		sinon.assert.notCalled( focusSpy );
 		expect( getModelData( model ) ).to.equal( '[<widget></widget>]' );
 	} );
@@ -183,7 +198,12 @@ describe( 'Widget', () => {
 		setModelData( model, '[<widget>foo bar</widget>]' );
 
 		expect( getViewData( view ) ).to.equal(
-			'[<div class="ck-widget ck-widget_selected" contenteditable="false">foo bar<b></b></div>]'
+			'[<div class="ck-widget ck-widget_can-type-around_after ck-widget_can-type-around_before ck-widget_selected" ' +
+			'contenteditable="false">' +
+				'foo bar' +
+				'<b></b>' +
+				'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
+			'</div>]'
 		);
 		expect( viewDocument.selection.isFake ).to.be.true;
 	} );
@@ -201,9 +221,15 @@ describe( 'Widget', () => {
 		expect( getViewData( view ) ).to.equal(
 
 			'<p>{foo</p>' +
-			'<div class="ck-widget ck-widget_selected" contenteditable="false"><b></b></div>' +
-			'<div class="ck-widget ck-widget_selected" contenteditable="false"><b></b></div>' +
-			']'
+			'<div class="ck-widget ck-widget_can-type-around_after ck-widget_selected" contenteditable="false">' +
+				'<b></b>' +
+				'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
+			'</div>' +
+			'<div class="ck-widget ck-widget_can-type-around_after ck-widget_can-type-around_before ck-widget_selected" ' +
+			'contenteditable="false">' +
+				'<b></b>' +
+				'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
+			'</div>]'
 		);
 	} );
 
@@ -217,7 +243,12 @@ describe( 'Widget', () => {
 		setModelData( model, '<paragraph>foo</paragraph>[<widget>foo</widget>]' );
 
 		expect( getViewData( view ) ).to.equal(
-			'<p>foo</p>[<div class="ck-widget ck-widget_selected" contenteditable="false">foo<b></b></div>]'
+			'<p>foo</p>' +
+			'[<div class="ck-widget ck-widget_can-type-around_after ck-widget_selected" contenteditable="false">' +
+				'foo' +
+				'<b></b>' +
+				'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
+			'</div>]'
 		);
 
 		model.change( writer => {
@@ -225,7 +256,12 @@ describe( 'Widget', () => {
 		} );
 
 		expect( getViewData( view ) ).to.equal(
-			'<p>{}foo</p><div class="ck-widget" contenteditable="false">foo<b></b></div>'
+			'<p>{}foo</p>' +
+			'<div class="ck-widget ck-widget_can-type-around_after" contenteditable="false">' +
+				'foo' +
+				'<b></b>' +
+				'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
+			'</div>'
 		);
 	} );
 
@@ -233,9 +269,10 @@ describe( 'Widget', () => {
 		setModelData( model, '<widget><editable>foo bar</editable></widget><editable>[baz]</editable>' );
 
 		expect( getViewData( view ) ).to.equal(
-			'<div class="ck-widget" contenteditable="false">' +
+			'<div class="ck-widget ck-widget_can-type-around_before" contenteditable="false">' +
 				'<figcaption contenteditable="true">foo bar</figcaption>' +
 				'<b></b>' +
+				'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 			'</div>' +
 			'<figcaption contenteditable="true">{baz}</figcaption>'
 		);
@@ -1229,8 +1266,16 @@ describe( 'Widget', () => {
 	} );
 
 	describe( 'selection handle', () => {
+		let element, editor;
+
 		beforeEach( () => {
-			return VirtualTestEditor.create( { plugins: [ Widget, Typing ] } )
+			element = document.createElement( 'div' );
+			document.body.appendChild( element );
+
+			return ClassicTestEditor
+				.create( element, {
+					plugins: [ Widget, Typing ]
+				} )
 				.then( newEditor => {
 					editor = newEditor;
 					model = editor.model;
@@ -1242,9 +1287,6 @@ describe( 'Widget', () => {
 						allowIn: 'widget',
 						isObject: true
 					} );
-					model.schema.register( 'paragraph', {
-						inheritAllFrom: '$block'
-					} );
 					model.schema.register( 'nested', {
 						allowIn: 'widget',
 						isLimit: true
@@ -1270,15 +1312,21 @@ describe( 'Widget', () => {
 				} );
 		} );
 
+		afterEach( () => {
+			element.remove();
+
+			return editor.destroy();
+		} );
+
 		it( 'should select a widget on mouse click', () => {
 			setModelData( model, '<paragraph>bar</paragraph><widget></widget><paragraph>foo[]</paragraph>' );
 
 			const viewWidgetSelectionHandle = viewDocument.getRoot().getChild( 1 ).getChild( 0 );
 
-			const domEventDataMock = {
-				target: viewWidgetSelectionHandle,
+			const domEventDataMock = new DomEventData( view, {
+				target: view.domConverter.mapViewToDom( viewWidgetSelectionHandle ),
 				preventDefault: sinon.spy()
-			};
+			} );
 
 			viewDocument.fire( 'mousedown', domEventDataMock );
 
@@ -1291,22 +1339,33 @@ describe( 'Widget', () => {
 			// The top-outer widget.
 			const viewWidgetSelectionHandle = viewDocument.getRoot().getChild( 0 );
 
-			const domEventDataMock = {
-				target: viewWidgetSelectionHandle,
+			const domEventDataMock = new DomEventData( view, {
+				target: view.domConverter.mapViewToDom( viewWidgetSelectionHandle ),
 				preventDefault: sinon.spy()
-			};
+			} );
 
 			viewDocument.fire( 'mousedown', domEventDataMock );
 
 			expect( getViewData( view ) ).to.equal(
-				'[<div class="ck-widget ck-widget_selected ck-widget_with-selection-handle" contenteditable="false">' +
-					'<div class="ck-widget ck-widget_with-selection-handle" contenteditable="false">' +
+				'[<div class="' +
+					'ck-widget ' +
+					'ck-widget_can-type-around_after ck-widget_can-type-around_before ' +
+					'ck-widget_selected ck-widget_with-selection-handle" contenteditable="false"' +
+				'>' +
+					'<div class="' +
+						'ck-widget ' +
+						'ck-widget_can-type-around_after ck-widget_can-type-around_before ' +
+						'ck-widget_with-selection-handle" contenteditable="false"' +
+					'>' +
 						'<div class="ck ck-widget__selection-handle"></div>' +
+						'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 					'</div>' +
-					'<div class="ck-widget ck-widget_with-selection-handle" contenteditable="false">' +
+					'<div class="ck-widget ck-widget_can-type-around_before ck-widget_with-selection-handle" contenteditable="false">' +
 						'<div class="ck ck-widget__selection-handle"></div>' +
+						'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 					'</div>' +
 					'<div class="ck ck-widget__selection-handle"></div>' +
+					'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 				'</div>]'
 			);
 		} );
@@ -1323,28 +1382,43 @@ describe( 'Widget', () => {
 
 			const viewWidgetSelectionHandle = viewDocument.getRoot().getChild( 1 );
 
-			const domEventDataMock = {
-				target: viewWidgetSelectionHandle,
+			const domEventDataMock = new DomEventData( view, {
+				target: view.domConverter.mapViewToDom( viewWidgetSelectionHandle ),
 				preventDefault: sinon.spy()
-			};
+			} );
 
 			viewDocument.fire( 'mousedown', domEventDataMock );
 
 			expect( getViewData( view ) ).to.equal(
-				'<div class="ck-widget ck-widget_with-selection-handle" contenteditable="false">' +
+				'<div class="ck-widget ck-widget_can-type-around_after ck-widget_can-type-around_before ck-widget_with-selection-handle" ' +
+				'contenteditable="false">' +
 					'<div class="ck ck-widget__selection-handle"></div>' +
+					'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 				'</div>' +
-				'[<div class="ck-widget ck-widget_selected ck-widget_with-selection-handle" contenteditable="false">' +
-					'<div class="ck-widget ck-widget_with-selection-handle" contenteditable="false">' +
-						'<div class="ck ck-widget__selection-handle"></div>' +
-					'</div>' +
-					'<div class="ck-widget ck-widget_with-selection-handle" contenteditable="false">' +
-						'<div class="ck ck-widget__selection-handle"></div>' +
-					'</div>' +
+				'[<div class="' +
+					'ck-widget ' +
+					'ck-widget_can-type-around_after ck-widget_can-type-around_before ' +
+					'ck-widget_selected ck-widget_with-selection-handle" contenteditable="false"' +
+				'>' +
+					'<div ' +
+					'class="ck-widget ck-widget_can-type-around_after ck-widget_can-type-around_before ck-widget_with-selection-handle" ' +
+					'contenteditable="false">' +
 					'<div class="ck ck-widget__selection-handle"></div>' +
+					'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
+				'</div>' +
+					'<div class="ck-widget ck-widget_can-type-around_before ck-widget_with-selection-handle" ' +
+					'contenteditable="false">' +
+					'<div class="ck ck-widget__selection-handle"></div>' +
+					'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
+				'</div>' +
+					'<div class="ck ck-widget__selection-handle"></div>' +
+					'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 				'</div>]' +
-				'<div class="ck-widget ck-widget_with-selection-handle" contenteditable="false">' +
+					'<div ' +
+					'class="ck-widget ck-widget_can-type-around_after ck-widget_can-type-around_before ck-widget_with-selection-handle" ' +
+					'contenteditable="false">' +
 					'<div class="ck ck-widget__selection-handle"></div>' +
+					'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 				'</div>'
 			);
 		} );
@@ -1359,20 +1433,26 @@ describe( 'Widget', () => {
 
 			const viewWidgetSelectionHandle = viewDocument.getRoot().getChild( 0 );
 
-			const domEventDataMock = {
-				target: viewWidgetSelectionHandle,
+			const domEventDataMock = new DomEventData( view, {
+				target: view.domConverter.mapViewToDom( viewWidgetSelectionHandle ),
 				preventDefault: sinon.spy()
-			};
+			} );
 
 			viewDocument.fire( 'mousedown', domEventDataMock );
 
 			expect( getViewData( view ) ).to.equal(
-				'[<div class="ck-widget ck-widget_selected ck-widget_with-selection-handle" contenteditable="false">' +
+				'[<div class="' +
+					'ck-widget ' +
+					'ck-widget_can-type-around_after ck-widget_can-type-around_before ' +
+					'ck-widget_selected ck-widget_with-selection-handle" contenteditable="false"' +
+				'>' +
 					'<figcaption contenteditable="true">foo bar</figcaption>' +
 					'<div class="ck-widget ck-widget_with-selection-handle" contenteditable="false">' +
 						'<div class="ck ck-widget__selection-handle"></div>' +
+						'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 					'</div>' +
 					'<div class="ck ck-widget__selection-handle"></div>' +
+					'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 				'</div>]'
 			);
 		} );
@@ -1389,25 +1469,41 @@ describe( 'Widget', () => {
 
 			const viewWidgetSelectionHandle = viewDocument.getRoot().getChild( 0 ).getChild( 1 );
 
-			const domEventDataMock = {
-				target: viewWidgetSelectionHandle,
+			const domEventDataMock = new DomEventData( view, {
+				target: view.domConverter.mapViewToDom( viewWidgetSelectionHandle ),
 				preventDefault: sinon.spy()
-			};
+			} );
 
 			viewDocument.fire( 'mousedown', domEventDataMock );
 
 			expect( getViewData( view ) ).to.equal(
-				'<div class="ck-widget ck-widget_with-selection-handle" contenteditable="false">' +
-					'<div class="ck-widget ck-widget_with-selection-handle" contenteditable="false">' +
+				'<div class="' +
+					'ck-widget ' +
+					'ck-widget_can-type-around_after ck-widget_can-type-around_before ' +
+					'ck-widget_with-selection-handle" contenteditable="false"' +
+				'>' +
+					'<div class="' +
+						'ck-widget ' +
+						'ck-widget_can-type-around_after ck-widget_can-type-around_before ' +
+						'ck-widget_with-selection-handle" contenteditable="false"' +
+					'>' +
 						'<div class="ck ck-widget__selection-handle"></div>' +
+						'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 					'</div>' +
-					'[<div class="ck-widget ck-widget_selected ck-widget_with-selection-handle" contenteditable="false">' +
-						'<div class="ck-widget ck-widget_with-selection-handle" contenteditable="false">' +
+					'[<div class="' +
+						'ck-widget ' +
+						'ck-widget_can-type-around_before ' +
+						'ck-widget_selected ck-widget_with-selection-handle" contenteditable="false"' +
+					'>' +
+						'<div class="ck-widget ck-widget_can-type-around_before ck-widget_with-selection-handle" contenteditable="false">' +
 							'<div class="ck ck-widget__selection-handle"></div>' +
+							'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 						'</div>' +
 						'<div class="ck ck-widget__selection-handle"></div>' +
+						'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 					'</div>]' +
 					'<div class="ck ck-widget__selection-handle"></div>' +
+					'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 				'</div>'
 			);
 		} );
@@ -1419,21 +1515,31 @@ describe( 'Widget', () => {
 
 			const widgetInEditable = viewDocument.getRoot().getChild( 0 ).getChild( 0 ).getChild( 0 );
 
-			const domEventDataMock = {
-				target: widgetInEditable,
+			const domEventDataMock = new DomEventData( view, {
+				target: view.domConverter.mapViewToDom( widgetInEditable ),
 				preventDefault: sinon.spy()
-			};
+			} );
 
 			viewDocument.fire( 'mousedown', domEventDataMock );
 
 			expect( getViewData( view ) ).to.equal(
-				'<div class="ck-widget ck-widget_with-selection-handle" contenteditable="false">' +
-					'<figcaption contenteditable="true">' +
-						'[<div class="ck-widget ck-widget_selected ck-widget_with-selection-handle" contenteditable="false">' +
+				'<div class="' +
+					'ck-widget ' +
+					'ck-widget_can-type-around_after ck-widget_can-type-around_before ' +
+					'ck-widget_with-selection-handle" contenteditable="false"' +
+				'>' +
+					'<figcaption contenteditable="true">[' +
+						'<div class="' +
+							'ck-widget ' +
+							'ck-widget_can-type-around_after ck-widget_can-type-around_before ' +
+							'ck-widget_selected ck-widget_with-selection-handle" contenteditable="false"' +
+						'>' +
 							'<div class="ck ck-widget__selection-handle"></div>' +
+							'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 						'</div>]' +
 					'</figcaption>' +
 					'<div class="ck ck-widget__selection-handle"></div>' +
+					'<div class="ck ck-reset_all ck-widget__type-around"></div>' +
 				'</div>'
 			);
 		} );

+ 40 - 1
packages/ckeditor5-widget/tests/widgettoolbarrepository.js

@@ -7,13 +7,18 @@
 
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
 import BalloonEditor from '@ckeditor/ckeditor5-editor-balloon/src/ballooneditor';
+import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 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 } from '../src/utils';
+import {
+	isWidget,
+	toWidget,
+	centeredBalloonPositionForLongWidgets
+} from '../src/utils';
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 import View from '@ckeditor/ckeditor5-ui/src/view';
 
@@ -470,6 +475,40 @@ describe( 'WidgetToolbarRepository', () => {
 
 			expect( balloon.view.pin.lastCall.args[ 0 ].target ).to.equal( newFakeDomElement );
 		} );
+
+		it( 'toolbar should use one of pre-defined positions when attaching to a widget', () => {
+			const editingView = editor.editing.view;
+			const balloonAddSpy = sinon.spy( balloon, 'add' );
+			const defaultPositions = BalloonPanelView.defaultPositions;
+
+			widgetToolbarRepository.register( 'fake', {
+				items: editor.config.get( 'fake.toolbar' ),
+				getRelatedElement: getSelectedFakeWidget
+			} );
+
+			setData( model, '<paragraph>foo</paragraph>[<fake-widget></fake-widget>]' );
+
+			const fakeWidgetToolbarView = widgetToolbarRepository._toolbarDefinitions.get( 'fake' ).view;
+			const widgetViewElement = editingView.document.getRoot().getChild( 1 );
+
+			sinon.assert.calledOnce( balloonAddSpy );
+			sinon.assert.calledWithExactly( balloonAddSpy, {
+				view: fakeWidgetToolbarView,
+				position: {
+					target: editingView.domConverter.mapViewToDom( widgetViewElement ),
+					positions: [
+						defaultPositions.northArrowSouth,
+						defaultPositions.northArrowSouthWest,
+						defaultPositions.northArrowSouthEast,
+						defaultPositions.southArrowNorth,
+						defaultPositions.southArrowNorthWest,
+						defaultPositions.southArrowNorthEast,
+						centeredBalloonPositionForLongWidgets
+					]
+				},
+				balloonClassName: 'ck-toolbar-container'
+			} );
+		} );
 	} );
 } );
 

+ 382 - 0
packages/ckeditor5-widget/tests/widgettypearound/widgettypearound.js

@@ -0,0 +1,382 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata';
+import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo';
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+
+import Widget from '../../src/widget';
+import WidgetTypeAround from '../../src/widgettypearound/widgettypearound';
+import { toWidget } from '../../src/utils';
+
+import { setData as setModelData, getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+describe( 'WidgetTypeAround', () => {
+	let element, plugin, editor, editingView, viewDocument, viewRoot;
+
+	beforeEach( async () => {
+		element = global.document.createElement( 'div' );
+		global.document.body.appendChild( element );
+
+		editor = await ClassicEditor.create( element, {
+			plugins: [
+				ArticlePluginSet, Widget,
+
+				blockWidgetPlugin, inlineWidgetPlugin
+			]
+		} );
+
+		editingView = editor.editing.view;
+		viewDocument = editingView.document;
+		viewRoot = viewDocument.getRoot();
+		plugin = editor.plugins.get( WidgetTypeAround );
+	} );
+
+	afterEach( async () => {
+		element.remove();
+
+		await editor.destroy();
+	} );
+
+	describe( 'plugin', () => {
+		it( 'is loaded', () => {
+			expect( editor.plugins.get( WidgetTypeAround ) ).to.be.instanceOf( WidgetTypeAround );
+		} );
+
+		it( 'requires the Paragraph plugin', () => {
+			expect( WidgetTypeAround.requires ).to.deep.equal( [ Paragraph ] );
+		} );
+	} );
+
+	describe( '_insertParagraph()', () => {
+		let executeSpy;
+
+		beforeEach( () => {
+			executeSpy = sinon.spy( editor, 'execute' );
+		} );
+
+		it( 'should execute the "insertParagraph" command when inserting a paragraph before the widget', () => {
+			setModelData( editor.model, '<blockWidget></blockWidget>' );
+
+			plugin._insertParagraph( viewRoot.getChild( 0 ), 'before' );
+
+			const spyExecutePosition = executeSpy.firstCall.args[ 1 ].position;
+			const positionBeforeWidget = editor.model.createPositionBefore( editor.model.document.getRoot().getChild( 0 ) );
+
+			sinon.assert.calledOnce( executeSpy );
+			sinon.assert.calledWith( executeSpy, 'insertParagraph' );
+
+			expect( spyExecutePosition.isEqual( positionBeforeWidget ) ).to.be.true;
+
+			expect( getModelData( editor.model ) ).to.equal( '<paragraph>[]</paragraph><blockWidget></blockWidget>' );
+		} );
+
+		it( 'should execute the "insertParagraph" command when inserting a paragraph after the widget', () => {
+			setModelData( editor.model, '<blockWidget></blockWidget>' );
+
+			plugin._insertParagraph( viewRoot.getChild( 0 ), 'after' );
+
+			const spyExecutePosition = executeSpy.firstCall.args[ 1 ].position;
+			const positionAfterWidget = editor.model.createPositionAfter( editor.model.document.getRoot().getChild( 0 ) );
+
+			sinon.assert.calledOnce( executeSpy );
+			sinon.assert.calledWith( executeSpy, 'insertParagraph' );
+
+			expect( spyExecutePosition.isEqual( positionAfterWidget ) ).to.be.true;
+
+			expect( getModelData( editor.model ) ).to.equal( '<blockWidget></blockWidget><paragraph>[]</paragraph>' );
+		} );
+
+		it( 'should focus the editing view', () => {
+			const spy = sinon.spy( editor.editing.view, 'focus' );
+
+			setModelData( editor.model, '<blockWidget></blockWidget>' );
+
+			plugin._insertParagraph( viewRoot.getChild( 0 ), 'after' );
+
+			sinon.assert.calledOnce( spy );
+		} );
+
+		it( 'should scroll the editing view to the selection in an inserted paragraph', () => {
+			const spy = sinon.spy( editor.editing.view, 'scrollToTheSelection' );
+
+			setModelData( editor.model, '<blockWidget></blockWidget>' );
+
+			plugin._insertParagraph( viewRoot.getChild( 0 ), 'after' );
+
+			sinon.assert.calledOnce( spy );
+		} );
+	} );
+
+	describe( 'UI to type around view widgets', () => {
+		it( 'should be injected in block widgets', () => {
+			setModelData( editor.model,
+				'<paragraph>foo</paragraph>' +
+				'<blockWidget></blockWidget>' +
+				'<paragraph>bar</paragraph>' +
+				'<blockWidget></blockWidget>'
+			);
+
+			const firstViewWidget = viewRoot.getChild( 1 );
+			const lastViewWidget = viewRoot.getChild( 3 );
+
+			expect( firstViewWidget.childCount ).to.equal( 2 );
+			expect( firstViewWidget.getChild( 1 ).hasClass( 'ck-widget__type-around' ) ).to.be.true;
+
+			expect( lastViewWidget.childCount ).to.equal( 2 );
+			expect( lastViewWidget.getChild( 1 ).hasClass( 'ck-widget__type-around' ) ).to.be.true;
+		} );
+
+		it( 'should not be injected in inline widgets', () => {
+			setModelData( editor.model,
+				'<paragraph>foo<inlineWidget></inlineWidget></paragraph>' +
+				'<paragraph><inlineWidget></inlineWidget>bar</paragraph>'
+			);
+
+			const firstViewWidget = viewRoot.getChild( 0 ).getChild( 1 );
+			const lastViewWidget = viewRoot.getChild( 1 ).getChild( 0 );
+
+			expect( firstViewWidget.childCount ).to.equal( 1 );
+			expect( firstViewWidget.getChild( 0 ).is( 'text' ) ).to.be.true;
+			expect( lastViewWidget.childCount ).to.equal( 1 );
+			expect( lastViewWidget.getChild( 0 ).is( 'text' ) ).to.be.true;
+		} );
+
+		it( 'should inject buttons into the wrapper', () => {
+			setModelData( editor.model, '<blockWidget></blockWidget>' );
+
+			const viewWidget = viewRoot.getChild( 0 );
+
+			expect( viewWidget.getChild( 1 ).is( 'uiElement' ) ).to.be.true;
+			expect( viewWidget.getChild( 1 ).hasClass( 'ck' ) ).to.be.true;
+			expect( viewWidget.getChild( 1 ).hasClass( 'ck-reset_all' ) ).to.be.true;
+			expect( viewWidget.getChild( 1 ).hasClass( 'ck-widget__type-around' ) ).to.be.true;
+
+			const domWrapper = editingView.domConverter.viewToDom( viewWidget.getChild( 1 ) );
+
+			expect( domWrapper.querySelectorAll( '.ck-widget__type-around__button' ) ).to.have.length( 2 );
+		} );
+
+		describe( 'UI button to type around', () => {
+			let buttonBefore, buttonAfter;
+
+			beforeEach( () => {
+				setModelData( editor.model, '<blockWidget></blockWidget>' );
+
+				const viewWidget = viewRoot.getChild( 0 );
+				const domWrapper = editingView.domConverter.viewToDom( viewWidget.getChild( 1 ) );
+
+				buttonBefore = domWrapper.firstChild;
+				buttonAfter = domWrapper.lastChild;
+			} );
+
+			it( 'should have proper CSS classes', () => {
+				expect( buttonBefore.classList.contains( 'ck' ) ).to.be.true;
+				expect( buttonBefore.classList.contains( 'ck-widget__type-around__button' ) ).to.be.true;
+
+				expect( buttonAfter.classList.contains( 'ck' ) ).to.be.true;
+				expect( buttonAfter.classList.contains( 'ck-widget__type-around__button' ) ).to.be.true;
+			} );
+
+			describe( 'button to type "before" a widget', () => {
+				it( 'should have a specific class', () => {
+					expect( buttonBefore.classList.contains( 'ck-widget__type-around__button_before' ) ).to.be.true;
+				} );
+
+				it( 'should have a specific "title"', () => {
+					expect( buttonBefore.getAttribute( 'title' ) ).to.equal( 'Insert paragraph before block' );
+				} );
+
+				it( 'should execute WidgetTypeAround#_insertParagraph() when clicked', () => {
+					const preventDefaultSpy = sinon.spy();
+					const typeAroundSpy = sinon.spy( plugin, '_insertParagraph' );
+
+					const eventInfo = new EventInfo( viewDocument, 'mousedown' );
+					const stopSpy = sinon.stub( eventInfo, 'stop' );
+					const domEventDataMock = new DomEventData( editingView, {
+						target: buttonBefore,
+						preventDefault: preventDefaultSpy
+					} );
+
+					viewDocument.fire( eventInfo, domEventDataMock );
+
+					sinon.assert.calledOnce( typeAroundSpy );
+					sinon.assert.calledWithExactly( typeAroundSpy, viewRoot.getChild( 1 ), 'before' );
+					sinon.assert.calledOnce( preventDefaultSpy );
+					sinon.assert.calledOnce( stopSpy );
+				} );
+			} );
+
+			describe( 'button to type "after" a widget', () => {
+				it( 'should have a specific class', () => {
+					expect( buttonAfter.classList.contains( 'ck-widget__type-around__button_after' ) ).to.be.true;
+				} );
+
+				it( 'should have a specific "title"', () => {
+					expect( buttonAfter.getAttribute( 'title' ) ).to.equal( 'Insert paragraph after block' );
+				} );
+
+				it( 'should execute WidgetTypeAround#_insertParagraph() when clicked', () => {
+					const preventDefaultSpy = sinon.spy();
+					const typeAroundSpy = sinon.spy( plugin, '_insertParagraph' );
+
+					const eventInfo = new EventInfo( viewDocument, 'mousedown' );
+					const stopSpy = sinon.stub( eventInfo, 'stop' );
+					const domEventDataMock = new DomEventData( editingView, {
+						target: buttonAfter,
+						preventDefault: preventDefaultSpy
+					} );
+
+					viewDocument.fire( eventInfo, domEventDataMock );
+
+					sinon.assert.calledOnce( typeAroundSpy );
+					sinon.assert.calledWithExactly( typeAroundSpy, viewRoot.getChild( 0 ), 'after' );
+					sinon.assert.calledOnce( preventDefaultSpy );
+					sinon.assert.calledOnce( stopSpy );
+				} );
+			} );
+
+			it( 'should have an icon', () => {
+				const icon = buttonBefore.firstChild;
+
+				expect( icon.tagName.toLowerCase() ).to.equal( 'svg' );
+				expect( icon.getAttribute( 'viewBox' ) ).to.equal( '0 0 10 8' );
+			} );
+		} );
+	} );
+
+	describe( 'detection and CSS classes of widgets needing the typing around support', () => {
+		it( 'should detect widgets that are a first child of the parent container', () => {
+			setModelData( editor.model, '<blockWidget></blockWidget><paragraph>foo</paragraph>' );
+
+			const viewWidget = viewRoot.getChild( 0 );
+
+			assertIsTypeAroundBefore( viewWidget, true );
+			assertIsTypeAroundAfter( viewWidget, false );
+		} );
+
+		it( 'should detect widgets that are a last child of the parent container', () => {
+			setModelData( editor.model, '<paragraph>foo</paragraph><blockWidget></blockWidget>' );
+
+			const viewWidget = viewRoot.getChild( 1 );
+
+			assertIsTypeAroundBefore( viewWidget, false );
+			assertIsTypeAroundAfter( viewWidget, true );
+		} );
+
+		it( 'should not detect widgets that are surrounded by sibling which allow the selection', () => {
+			setModelData( editor.model, '<paragraph>foo</paragraph><blockWidget></blockWidget><paragraph>bar</paragraph>' );
+
+			const viewWidget = viewRoot.getChild( 1 );
+
+			assertIsTypeAroundBefore( viewWidget, false );
+			assertIsTypeAroundAfter( viewWidget, false );
+		} );
+
+		it( 'should detect widgets that have another block widget as a next sibling', () => {
+			setModelData( editor.model, '<blockWidget></blockWidget><blockWidget></blockWidget>' );
+
+			const firstViewWidget = viewRoot.getChild( 0 );
+
+			assertIsTypeAroundBefore( firstViewWidget, true );
+			assertIsTypeAroundAfter( firstViewWidget, true );
+		} );
+
+		it( 'should detect widgets that have another block widget as a previous sibling', () => {
+			setModelData( editor.model, '<blockWidget></blockWidget><blockWidget></blockWidget>' );
+
+			const lastViewWidget = viewRoot.getChild( 1 );
+
+			assertIsTypeAroundBefore( lastViewWidget, true );
+			assertIsTypeAroundAfter( lastViewWidget, true );
+		} );
+
+		it( 'should not detect inline widgets even if they fall in previous categories', () => {
+			setModelData( editor.model,
+				'<paragraph><inlineWidget></inlineWidget><inlineWidget></inlineWidget></paragraph>'
+			);
+
+			const firstViewWidget = viewRoot.getChild( 0 ).getChild( 0 );
+			const lastViewWidget = viewRoot.getChild( 0 ).getChild( 1 );
+
+			assertIsTypeAroundBefore( firstViewWidget, false );
+			assertIsTypeAroundAfter( firstViewWidget, false );
+
+			assertIsTypeAroundBefore( lastViewWidget, false );
+			assertIsTypeAroundAfter( lastViewWidget, false );
+		} );
+	} );
+
+	describe( '(TEMP) "Integration" with Track Changes using CSS class on roots', () => {
+		it( 'should toggle the CSS class when the InsertParagraphCommand#isEnabled changes', () => {
+			const command = editor.commands.get( 'insertParagraph' );
+
+			expect( command.isEnabled ).to.be.true;
+			expect( viewRoot.hasClass( 'ck-widget_type-around_temp-disabled' ) ).to.be.false;
+
+			command.isEnabled = false;
+			expect( viewRoot.hasClass( 'ck-widget_type-around_temp-disabled' ) ).to.be.true;
+
+			command.isEnabled = true;
+			expect( viewRoot.hasClass( 'ck-widget_type-around_temp-disabled' ) ).to.be.false;
+		} );
+	} );
+
+	function assertIsTypeAroundBefore( viewWidget, expected ) {
+		expect( viewWidget.hasClass( 'ck-widget_can-type-around_before' ) ).to.equal( expected );
+	}
+
+	function assertIsTypeAroundAfter( viewWidget, expected ) {
+		expect( viewWidget.hasClass( 'ck-widget_can-type-around_after' ) ).to.equal( expected );
+	}
+
+	function blockWidgetPlugin( editor ) {
+		editor.model.schema.register( 'blockWidget', {
+			inheritAllFrom: '$block',
+			isObject: true
+		} );
+
+		editor.conversion.for( 'downcast' )
+			.elementToElement( {
+				model: 'blockWidget',
+				view: ( modelItem, viewWriter ) => {
+					const container = viewWriter.createContainerElement( 'div' );
+					const viewText = viewWriter.createText( 'block-widget' );
+
+					viewWriter.insert( viewWriter.createPositionAt( container, 0 ), viewText );
+
+					return toWidget( container, viewWriter, {
+						label: 'block widget'
+					} );
+				}
+			} );
+	}
+
+	function inlineWidgetPlugin( editor ) {
+		editor.model.schema.register( 'inlineWidget', {
+			allowWhere: '$text',
+			isObject: true,
+			isInline: true
+		} );
+
+		editor.conversion.for( 'downcast' )
+			.elementToElement( {
+				model: 'inlineWidget',
+				view: ( modelItem, viewWriter ) => {
+					const container = viewWriter.createContainerElement( 'inlineWidget' );
+					const viewText = viewWriter.createText( 'inline-widget' );
+
+					viewWriter.insert( viewWriter.createPositionAt( container, 0 ), viewText );
+
+					return toWidget( container, viewWriter, {
+						label: 'inline widget'
+					} );
+				}
+			} );
+	}
+} );

+ 1 - 0
packages/ckeditor5-widget/theme/icons/return-arrow.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 10 8" xmlns="http://www.w3.org/2000/svg"><polyline points="8.05541992 0.263427734 8.05541992 4.23461914 1.28417969 4.23461914" transform="translate(1,0)"></polyline><line x1="0" y1="4.21581031" x2="2" y2="2.17810059" transform="translate(1, 0)"></line><line x1="0" y1="6.21581031" x2="2" y2="4.17810059" transform="translate(2, 5.196955) scale(1, -1) translate(-1, -5.196955)"></line></svg>

+ 5 - 0
packages/ckeditor5-widget/theme/widget.css

@@ -17,6 +17,11 @@
 	--ck-color-resizer-tooltip-text: hsl(0, 0%, 95%);
 }
 
+.ck .ck-widget {
+	/* This is neccessary for type around UI to be positioned properly. */
+	position: relative;
+}
+
 .ck .ck-widget.ck-widget_with-selection-handle {
 	/* Make the widget wrapper a relative positioning container for the drag handle. */
 	position: relative;

+ 90 - 0
packages/ckeditor5-widget/theme/widgettypearound.css

@@ -0,0 +1,90 @@
+/*
+ * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+.ck .ck-widget {
+	/*
+	 * Styles of the type around buttons
+	 */
+	& .ck-widget__type-around__button {
+		display: block;
+		position: absolute;
+		overflow: hidden;
+		z-index: var(--ck-z-default);
+
+		& svg {
+			position: absolute;
+			top: 50%;
+			left: 50%;
+			z-index: calc(var(--ck-z-default) + 2);
+		}
+
+		&.ck-widget__type-around__button_before {
+			/* Place it in the middle of the outline */
+			top: calc(-0.5 * var(--ck-widget-outline-thickness));
+			left: min(10%, 30px);
+
+			transform: translateY(-50%);
+		}
+
+		&.ck-widget__type-around__button_after {
+			/* Place it in the middle of the outline */
+			bottom: calc(-0.5 * var(--ck-widget-outline-thickness));
+			right: min(10%, 30px);
+
+			transform: translateY(50%);
+		}
+	}
+
+	/*
+	 * Hide the type around buttons depending on which directions the widget supports.
+	 */
+	&:not(.ck-widget_can-type-around_before) > .ck-widget__type-around > .ck-widget__type-around__button_before {
+		display: none;
+	}
+
+	&:not(.ck-widget_can-type-around_after) > .ck-widget__type-around > .ck-widget__type-around__button_after {
+		display: none;
+	}
+
+	/*
+	 * Styles for the buttons when:
+	 * - the widget is selected,
+	 * - or the button is being hovered (regardless of the widget state).
+	 */
+	&.ck-widget_selected > .ck-widget__type-around > .ck-widget__type-around__button,
+	& > .ck-widget__type-around > .ck-widget__type-around__button:hover {
+		&::after {
+			content: "";
+			display: block;
+			position: absolute;
+			top: 1px;
+			left: 1px;
+			z-index: calc(var(--ck-z-default) + 1);
+		}
+	}
+}
+
+/*
+ * Integration with the read-only mode of the editor.
+ */
+.ck.ck-editor__editable.ck-read-only .ck-widget__type-around {
+	display: none;
+}
+
+/*
+ * Integration with the restricted editing mode (feature) of the editor.
+ */
+.ck.ck-editor__editable.ck-restricted-editing_mode_restricted .ck-widget__type-around {
+	display: none;
+}
+
+/*
+ * A quick fix for integration with track changes.
+ *
+ * TODO: To be removed when the proper integration arrives.
+ */
+.ck.ck-editor__editable.ck-widget_type-around_temp-disabled .ck-widget__type-around {
+	display: none;
+}

+ 10 - 1
scripts/clean-up-svg-icons.sh

@@ -12,7 +12,16 @@
 # To optimize the entire project:
 #	yarn clean-up-svg-icons packages/**/theme/icons
 
+# A list of icons that should not NOT be cleaned up. Their internal structure should not be changed
+# because, for instance, CSS animations may depend on it.
+BLACKLIST=("return-arrow.svg")
+
 for i in "$@"
 do
-	svgo --config=./scripts/svgo.config.json -i $i
+	if [[ " ${BLACKLIST[@]} " =~ " $(basename $i) " ]]
+	then
+		echo "\x1B[33m[clean-up-svg-icons]\x1B[0m Note: \"$i\" is blacklisted, skipping."
+	else
+		svgo --config=./scripts/svgo.config.json -i $i
+	fi
 done

+ 4 - 1
scripts/docs/buildapi.js

@@ -18,7 +18,10 @@ module.exports = function buildApiDocs() {
 			sourceFiles: [
 				process.cwd() + '/packages/@(ckeditor|ckeditor5)-*/src/**/*.@(js|jsdoc)',
 				'!' + process.cwd() + '/packages/@(ckeditor|ckeditor5)-*/src/lib/**/*.js',
-				'!' + process.cwd() + '/packages/ckeditor5-build-*/src/**/*.js'
+				'!' + process.cwd() + '/packages/ckeditor5-build-*/src/**/*.js',
+				process.cwd() + '/external/*/packages/@(ckeditor|ckeditor5)-*/src/**/*.@(js|jsdoc)',
+				'!' + process.cwd() + '/external/*/packages/@(ckeditor|ckeditor5)-*/src/lib/**/*.js',
+				'!' + process.cwd() + '/external/*/packages/ckeditor5-build-*/src/**/*.js'
 			],
 			validateOnly: process.argv.includes( '--validate-only' )
 		} );

+ 22 - 19
scripts/release/changelog.js

@@ -9,28 +9,31 @@
 
 'use strict';
 
-// In order to use the same version for all packages (including builds and ckeditor5 itself), you can call:
-// yarn run changelog [newVersion]
-
 const devEnv = require( '@ckeditor/ckeditor5-dev-env' );
-const commonOptions = {
-	cwd: process.cwd(),
-	packages: 'packages'
-};
-const editorBuildsGlob = '@ckeditor/ckeditor5-build-*';
-
-const optionsForDependencies = Object.assign( {}, commonOptions, {
-	skipPackages: editorBuildsGlob,
-	skipMainRepository: true
-} );
-
-const optionsForBuilds = Object.assign( {}, commonOptions, {
-	scope: editorBuildsGlob
-} );
 
 Promise.resolve()
-	.then( () => devEnv.generateChangelogForSubRepositories( optionsForDependencies ) )
-	.then( response => devEnv.generateSummaryChangelog( Object.assign( optionsForBuilds, response ) ) )
+	.then( () => devEnv.generateChangelogForMonoRepository( {
+		cwd: process.cwd(),
+		packages: 'packages',
+		highlightsPlaceholder: true,
+		collaborationFeatures: true,
+		from: '87c56114028c00b1e45b6ecba3bead575c6c1afe', // TODO: Remove the line after the nearest release.
+		transformScope: name => {
+			if ( name === 'ckeditor5' ) {
+				return 'https://www.npmjs.com/package/ckeditor5';
+			}
+
+			if ( name === 'build-*' ) {
+				return 'https://www.npmjs.com/search?q=keywords%3Ackeditor5-build%20maintainer%3Ackeditor';
+			}
+
+			if ( name === 'cloud-services-core' ) {
+				return 'https://www.npmjs.com/package/@ckeditor/ckeditor-cloud-services-core';
+			}
+
+			return 'https://www.npmjs.com/package/@ckeditor/ckeditor5-' + name;
+		}
+	} ) )
 	.then( () => {
 		console.log( 'Done!' );
 	} )

+ 49 - 28
yarn.lock

@@ -827,10 +827,10 @@
     request "^2.83.0"
     semver "^6.2.0"
 
-"@ckeditor/ckeditor5-dev-tests@^19.1.0":
-  version "19.1.0"
-  resolved "https://registry.yarnpkg.com/@ckeditor/ckeditor5-dev-tests/-/ckeditor5-dev-tests-19.1.0.tgz#0ab130a89c7e9c3c37a87ee4e138db096c9021ae"
-  integrity sha512-J/M58BefbA2GuYtj6U/gZVqNKbvh1YWZeyEeEmPFOeiVhudLry54XgRgw5wtZTLXjXabl37RilLZ/URXifXSFQ==
+"@ckeditor/ckeditor5-dev-tests@^19.2.0":
+  version "19.2.0"
+  resolved "https://registry.yarnpkg.com/@ckeditor/ckeditor5-dev-tests/-/ckeditor5-dev-tests-19.2.0.tgz#8cf0dda0ba1982834e3c09fa275db6f0805d6faf"
+  integrity sha512-7wPA9XvQCAXGs/kKtXQLgIw/+eVlVj//5zvql1DGStHRnFdZY3sSJCBBpZ5tPfxVUHgv4/cftaBOs3xgQQ+Tgw==
   dependencies:
     "@babel/core" "^7.0.0"
     "@ckeditor/ckeditor5-dev-utils" "^13.0.1"
@@ -2607,9 +2607,9 @@ caniuse-api@^3.0.0:
     lodash.uniq "^4.5.0"
 
 caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001039, caniuse-lite@^1.0.30001043:
-  version "1.0.30001055"
-  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001055.tgz#7b52c3537f7a8c0408aca867e83d2b04268b54cd"
-  integrity sha512-MbwsBmKrBSKIWldfdIagO5OJWZclpJtS4h0Jrk/4HFrXJxTdVdH23Fd+xCiHriVGvYcWyW8mR/CPsYajlH8Iuw==
+  version "1.0.30001058"
+  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001058.tgz#9f8a318389e28f060272274ac93a661d17f8bf0d"
+  integrity sha512-UiRZmBYd1HdVVdFKy7PuLVx9e2NS7SMyx7QpWvFjiklYrLJKpLd19cRnRNqlw4zYa7vVejS3c8JUVobX241zHQ==
 
 caseless@~0.12.0:
   version "0.12.0"
@@ -3365,6 +3365,17 @@ cosmiconfig@^5.0.0, cosmiconfig@^5.0.2, cosmiconfig@^5.0.7, cosmiconfig@^5.2.0:
     js-yaml "^3.13.1"
     parse-json "^4.0.0"
 
+coveralls@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/coveralls/-/coveralls-3.1.0.tgz#13c754d5e7a2dd8b44fe5269e21ca394fb4d615b"
+  integrity sha512-sHxOu2ELzW8/NC1UP5XVLbZDzO4S3VxfFye3XYCznopHy02YjNkHcj5bKaVw2O7hVaBdBjEdQGpie4II1mWhuQ==
+  dependencies:
+    js-yaml "^3.13.1"
+    lcov-parse "^1.0.0"
+    log-driver "^1.2.7"
+    minimist "^1.2.5"
+    request "^2.88.2"
+
 cpx@^1.5.0:
   version "1.5.0"
   resolved "https://registry.yarnpkg.com/cpx/-/cpx-1.5.0.tgz#185be018511d87270dedccc293171e37655ab88f"
@@ -4127,9 +4138,9 @@ ee-first@1.1.1:
   integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
 
 electron-to-chromium@^1.3.413:
-  version "1.3.435"
-  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.435.tgz#22a7008e8f5a317a6d2d80802bddacebb19ae025"
-  integrity sha512-BVXnq+NCefidU7GOFPx4CPBfPcccLCRBKZYSbvBJMSn2kwGD7ML+eUA9tqfHAumRqy3oX5zaeTI1Bpt7qVat0Q==
+  version "1.3.437"
+  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.437.tgz#110f1cd407e5d09b43d5585e5f237b71063412cf"
+  integrity sha512-PBQn2q68ErqMyBUABh9Gh8R6DunGky8aB5y3N5lPM7OVpldwyUbAK5AX9WcwE/5F6ceqvQ+iQLYkJYRysAs6Bg==
 
 elegant-spinner@^1.0.1:
   version "1.0.1"
@@ -6839,7 +6850,7 @@ jsbn@~0.1.0:
   resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
   integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM=
 
-"jsdoc@github:ckeditor/jsdoc#node12":
+jsdoc@ckeditor/jsdoc#node12:
   version "3.4.3"
   resolved "https://codeload.github.com/ckeditor/jsdoc/tar.gz/e6c213ab53c20457d2778953a6d3fc54311f9a67"
   dependencies:
@@ -7129,6 +7140,11 @@ lcid@^2.0.0:
   dependencies:
     invert-kv "^2.0.0"
 
+lcov-parse@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/lcov-parse/-/lcov-parse-1.0.0.tgz#eb0d46b54111ebc561acb4c408ef9363bdc8f7e0"
+  integrity sha1-6w1GtUER68VhrLTECO+TY73I9+A=
+
 lead@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/lead/-/lead-1.0.0.tgz#6f14f99a37be3a9dd784f5495690e5903466ee42"
@@ -7446,6 +7462,11 @@ lodash@^4.0.0, lodash@^4.17.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
   integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
 
+log-driver@^1.2.7:
+  version "1.2.7"
+  resolved "https://registry.yarnpkg.com/log-driver/-/log-driver-1.2.7.tgz#63b95021f0702fedfa2c9bb0a24e7797d71871d8"
+  integrity sha512-U7KCmLdqsGHBLeWqYlFA0V0Sl6P08EE1ZrmA9cxjUE0WVqT9qnyVDPz1kzpFEP0jdJuFnasWIfSd7fsaNXkpbg==
+
 log-symbols@2.2.0, log-symbols@^2.1.0, log-symbols@^2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-2.2.0.tgz#5740e1c5d6f0dfda4ad9323b5332107ef6b4c40a"
@@ -8028,9 +8049,9 @@ minipass-flush@^1.0.5:
     minipass "^3.0.0"
 
 minipass-pipeline@^1.2.2:
-  version "1.2.2"
-  resolved "https://registry.yarnpkg.com/minipass-pipeline/-/minipass-pipeline-1.2.2.tgz#3dcb6bb4a546e32969c7ad710f2c79a86abba93a"
-  integrity sha512-3JS5A2DKhD2g0Gg8x3yamO0pj7YeKGwVlDS90pF++kxptwx/F+B//roxf9SqYil5tQo65bijy+dAuAFZmYOouA==
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/minipass-pipeline/-/minipass-pipeline-1.2.3.tgz#55f7839307d74859d6e8ada9c3ebe72cec216a34"
+  integrity sha512-cFOknTvng5vqnwOpDsZTWhNll6Jf8o2x+/diplafmxpuIymAjzoOolZG0VvQf3V2HgqzJNhnuKHYp2BqDgz8IQ==
   dependencies:
     minipass "^3.0.0"
 
@@ -10303,7 +10324,7 @@ replace-ext@^1.0.0:
   resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.1.tgz#2d6d996d04a15855d967443631dd5f77825b016a"
   integrity sha512-yD5BHCe7quCgBph4rMQ+0KkIRKwWCrHDOX1p1Gp6HwjPM5kVoCdKGNhN7ydqqsX6lJEnQDKZ/tFMiEdQ1dvPEw==
 
-request@^2.51.0, request@^2.83.0, request@^2.87.0, request@^2.88.0:
+request@^2.51.0, request@^2.83.0, request@^2.87.0, request@^2.88.0, request@^2.88.2:
   version "2.88.2"
   resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3"
   integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==
@@ -10992,9 +11013,9 @@ spdx-exceptions@^2.1.0:
   integrity sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==
 
 spdx-expression-parse@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz#99e119b7a5da00e05491c9fa338b7904823b41d0"
-  integrity sha512-Yg6D3XpRD4kkOmTpdgbUiEJFKghJH03fiC1OPll5h/0sO6neh2jqRDVHOQ4o/LMea0tgCkbMgea5ip/e+MkWyg==
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz#cf70f50482eefdc98e3ce0a6833e4a53ceeba679"
+  integrity sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==
   dependencies:
     spdx-exceptions "^2.1.0"
     spdx-license-ids "^3.0.0"
@@ -11812,9 +11833,9 @@ trough@^1.0.0:
     glob "^7.1.2"
 
 tslib@^1.9.0:
-  version "1.12.0"
-  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.12.0.tgz#d1fc9cacd06a1456c62f2902b361573e83d66473"
-  integrity sha512-5rxCQkP0kytf4H1T4xz1imjxaUUPMvc5aWp0rJ/VMIN7ClRiH1FwFvBt8wOeMasp/epeUnmSW6CixSIePtiLqA==
+  version "1.13.0"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"
+  integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==
 
 tty-browserify@0.0.0:
   version "0.0.0"
@@ -11921,9 +11942,9 @@ uglify-js@^2.6.1:
     uglify-to-browserify "~1.0.0"
 
 uglify-js@^3.1.4:
-  version "3.9.2"
-  resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.9.2.tgz#012b74fb6a2e440d9ba1f79110a479d3b1f2d48d"
-  integrity sha512-zGVwKslUAD/EeqOrD1nQaBmXIHl1Vw371we8cvS8I6mYK9rmgX5tv8AAeJdfsQ3Kk5mGax2SVV/AizxdNGhl7Q==
+  version "3.9.3"
+  resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.9.3.tgz#4a285d1658b8a2ebaef9e51366b3a0f7acd79ec2"
+  integrity sha512-r5ImcL6QyzQGVimQoov3aL2ZScywrOgBXGndbWrdehKoSvGe/RmiE5Jpw/v+GvxODt6l2tpBXwA7n+qZVlHBMA==
   dependencies:
     commander "~2.20.3"
 
@@ -11937,10 +11958,10 @@ ultron@~1.1.0:
   resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.1.1.tgz#9fe1536a10a664a65266a1e3ccf85fd36302bc9c"
   integrity sha512-UIEXBNeYmKptWH6z8ZnqTeS8fV74zG0/eRU9VGkpzz+LIJNs8W/zM/L+7ctCkRrgbNnnR0xxw4bKOr0cW0N0Og==
 
-umberto@^1.2.0:
-  version "1.2.4"
-  resolved "https://registry.yarnpkg.com/umberto/-/umberto-1.2.4.tgz#7a540cb9bed77af3b96ae87ff45c11ecde85d0b4"
-  integrity sha512-ntdxZ2ADjca9sTpvNJuvKSzEr1u4Yhv6Rghtg86UC+k4x9zWCZCNi+Lvt6XglJBSVDPwvznWd7t1qxTXlhrp3w==
+umberto@^1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/umberto/-/umberto-1.3.0.tgz#36ab30cc683dc36967e674576ffad1177ce63ecd"
+  integrity sha512-M4nDYX3qqxIik04DGgU0lZAgD+nTJs46GrX3PIaaYyNm8UGDOA2BPHIRQv5ICr8eu55h6o0SG1aEXyaL2dvB8w==
   dependencies:
     "@babel/core" "^7.1.2"
     "@babel/polyfill" "^7.0.0"