浏览代码

Merge branch 'master' into i/6587

Piotrek Koszuliński 5 年之前
父节点
当前提交
1dff42c762
共有 68 个文件被更改,包括 3226 次插入1220 次删除
  1. 3 1
      .travis.yml
  2. 2 8
      README.md
  3. 0 2
      docs/builds/guides/support/getting-support.md
  4. 6 1
      docs/builds/guides/support/reporting-issues.md
  5. 12 12
      docs/framework/guides/contributing/contributing.md
  6. 13 165
      docs/framework/guides/contributing/development-environment.md
  7. 6 5
      docs/framework/guides/contributing/testing-environment.md
  8. 2 2
      docs/framework/guides/overview.md
  9. 5 2
      package.json
  10. 31 14
      packages/ckeditor5-engine/tests/view/view/view.js
  11. 5 10
      packages/ckeditor5-media-embed/src/mediaembedui.js
  12. 9 0
      packages/ckeditor5-media-embed/tests/mediaembedui.js
  13. 1 1
      packages/ckeditor5-select-all/docs/features/select-all.md
  14. 2 2
      packages/ckeditor5-table/lang/contexts.json
  15. 3 1
      packages/ckeditor5-table/package.json
  16. 5 6
      packages/ckeditor5-table/src/commands/insertcolumncommand.js
  17. 6 8
      packages/ckeditor5-table/src/commands/insertrowcommand.js
  18. 7 26
      packages/ckeditor5-table/src/commands/mergecellcommand.js
  19. 22 34
      packages/ckeditor5-table/src/commands/mergecellscommand.js
  20. 4 0
      packages/ckeditor5-table/src/converters/table-cell-paragraph-post-fixer.js
  21. 19 3
      packages/ckeditor5-table/src/converters/table-cell-refresh-post-fixer.js
  22. 35 12
      packages/ckeditor5-table/src/converters/table-layout-post-fixer.js
  23. 22 5
      packages/ckeditor5-table/src/converters/upcasttable.js
  24. 23 11
      packages/ckeditor5-table/src/tablecellproperties/tablecellpropertiesui.js
  25. 2 1
      packages/ckeditor5-table/src/tableediting.js
  26. 72 37
      packages/ckeditor5-table/src/tablenavigation.js
  27. 21 9
      packages/ckeditor5-table/src/tableproperties/tablepropertiesui.js
  28. 78 45
      packages/ckeditor5-table/src/tableselection.js
  29. 4 4
      packages/ckeditor5-table/src/tableui.js
  30. 27 16
      packages/ckeditor5-table/src/tableutils.js
  31. 64 11
      packages/ckeditor5-table/src/ui/colorinputview.js
  32. 4 4
      packages/ckeditor5-table/src/utils.js
  33. 348 0
      packages/ckeditor5-table/tests/_utils-tests/table-ascii-art.js
  34. 151 0
      packages/ckeditor5-table/tests/_utils/utils.js
  35. 20 3
      packages/ckeditor5-table/tests/commands/insertcolumncommand.js
  36. 21 3
      packages/ckeditor5-table/tests/commands/insertrowcommand.js
  37. 94 0
      packages/ckeditor5-table/tests/commands/mergecellcommand.js
  38. 108 11
      packages/ckeditor5-table/tests/commands/mergecellscommand.js
  39. 12 12
      packages/ckeditor5-table/tests/commands/removecolumncommand.js
  40. 16 16
      packages/ckeditor5-table/tests/commands/removerowcommand.js
  41. 14 14
      packages/ckeditor5-table/tests/commands/selectcolumncommand.js
  42. 15 15
      packages/ckeditor5-table/tests/commands/selectrowcommand.js
  43. 13 13
      packages/ckeditor5-table/tests/commands/setheadercolumncommand.js
  44. 13 13
      packages/ckeditor5-table/tests/commands/setheaderrowcommand.js
  45. 2 2
      packages/ckeditor5-table/tests/commands/splitcellcommand.js
  46. 76 7
      packages/ckeditor5-table/tests/converters/table-cell-refresh-post-fixer.js
  47. 19 0
      packages/ckeditor5-table/tests/converters/table-layout-post-fixer.js
  48. 26 0
      packages/ckeditor5-table/tests/converters/upcasttable.js
  49. 53 0
      packages/ckeditor5-table/tests/manual/tablemocking.html
  50. 133 0
      packages/ckeditor5-table/tests/manual/tablemocking.js
  51. 26 0
      packages/ckeditor5-table/tests/manual/tablemocking.md
  52. 22 0
      packages/ckeditor5-table/tests/tablecellproperties/tablecellpropertiesui.js
  53. 14 14
      packages/ckeditor5-table/tests/tableclipboard.js
  54. 688 209
      packages/ckeditor5-table/tests/tablenavigation.js
  55. 20 0
      packages/ckeditor5-table/tests/tableproperties/tablepropertiesui.js
  56. 6 6
      packages/ckeditor5-table/tests/tableselection-integration.js
  57. 161 14
      packages/ckeditor5-table/tests/tableselection.js
  58. 3 3
      packages/ckeditor5-table/tests/tableui.js
  59. 14 0
      packages/ckeditor5-table/tests/tableutils.js
  60. 87 3
      packages/ckeditor5-table/tests/ui/colorinputview.js
  61. 5 5
      packages/ckeditor5-table/tests/utils.js
  62. 21 8
      packages/ckeditor5-ui/src/colorgrid/colorgridview.js
  63. 0 102
      scripts/continuous-integration-run-tests.sh
  64. 152 0
      scripts/continuous-integration-script.js
  65. 1 1
      scripts/docs/build-content-styles.js
  66. 2 2
      tests/_utils/utils.js
  67. 1 1
      tests/manual/wproofreader.js
  68. 354 285
      yarn.lock

+ 3 - 1
.travis.yml

@@ -17,7 +17,9 @@ before_install:
 install:
 - yarn install
 script:
-- ./scripts/continuous-integration-run-tests.sh
+- node ./scripts/continuous-integration-script.js
+- yarn run lint
+- yarn run stylelint
 - yarn run docs:api --validate-only
 - 'if [ $TRAVIS_TEST_RESULT -eq 0 ]; then
     travis_wait 30 yarn run docs:build-and-publish-nightly;

+ 2 - 8
README.md

@@ -14,12 +14,6 @@ A set of ready-to-use rich text editors created with a powerful framework. Made
 
 ![CKEditor 5 Classic rich text editor build screenshot](https://c.cksource.com/a/1/img/npm/ckeditor5-build-classic.png)
 
-## ⚠ This package does not contain any source code
-
-CKEditor 5 is distributed as [several ready-to-use rich text editor builds](https://ckeditor.com/docs/ckeditor5/latest/builds/guides/overview.html#available-builds) which you can [install from npm](https://ckeditor.com/docs/ckeditor5/latest/builds/guides/integration/installation.html#npm).
-
-You can also [customize the existing builds](https://ckeditor.com/docs/ckeditor5/latest/builds/guides/development/custom-builds.html) or build your own editors and features by using the [CKEditor 5 Framework](https://ckeditor.com/docs/ckeditor5/latest/framework/guides/overview.html).
-
 ## Table of contents
 
 * [Quick start](#quick-start)
@@ -113,11 +107,11 @@ For a high-level overview of the project see the [CKEditor Ecosystem website](ht
 
 ### Ideas and discussions
 
-The main development repository of CKEditor 5 is located at [https://github.com/ckeditor/ckeditor5](https://github.com/ckeditor/ckeditor5). This is the best place for bringing opinions and contributions. Letting the core team know if they are going in the right or wrong direction is great feedback and will be much appreciated!
+The development repository of CKEditor 5 is located at [https://github.com/ckeditor/ckeditor5](https://github.com/ckeditor/ckeditor5). This is the best place for bringing opinions and contributions. Letting the core team know if they are going in the right or wrong direction is great feedback and will be much appreciated!
 
 ### Development
 
-CKEditor 5 is a modular, multi-package, multi-repository project. It consists of several packages that create the editing framework, based on which the feature packages are implemented.
+CKEditor 5 is a modular, multi-package, [monorepo](https://en.wikipedia.org/wiki/Monorepo) project. It consists of several packages that create the editing framework, based on which the feature packages are implemented.
 
 The [ckeditor5](https://github.com/ckeditor/ckeditor5) repository is the place that centralizes the development of CKEditor 5. It bundles different packages into a single place, adding the necessary helper tools for the development workflow, like the builder and the test runner. [Basic information on how to set up the development environment](https://ckeditor.com/docs/ckeditor5/latest/framework/guides/contributing/development-environment.html) can be found in the documentation.
 

+ 0 - 2
docs/builds/guides/support/getting-support.md

@@ -9,8 +9,6 @@ order: 30
 
 You can report issues and feature requests on [GitHub](https://github.com/ckeditor/ckeditor5). This is also a good place to discuss architecture decisions and things which are not yet covered by the documentation.
 
-[Each CKEditor 5 repository](https://github.com/ckeditor?utf8=%E2%9C%93&q=ckeditor5&type=&language=) handles its issues independently. If you are not sure where your ticket belongs, it is best to report it in the [main repository](https://github.com/ckeditor/ckeditor5).
-
 Refer to {@link builds/guides/support/reporting-issues Reporting issues} guide for more details.
 
 ## Stack Overflow

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

@@ -22,7 +22,12 @@ 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.
+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):
+
+* [Angular integration](https://github.com/ckeditor/ckeditor5-angular),
+* [React integration](https://github.com/ckeditor/ckeditor5-react),
+* [Vue integration](https://github.com/ckeditor/ckeditor5-vue),
+* [CKEditor 5 inspector](https://github.com/ckeditor/ckeditor5-inspector).
 
 Things to keep in mind:
 

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

@@ -41,21 +41,25 @@ Read more about our {@link framework/guides/contributing/testing-environment tes
 	GitHub provides an [excellent documentation about pull requests](https://help.github.com/categories/collaborating-with-issues-and-pull-requests/). If you are not sure what to do, this is the right place to start.
 </info-box>
 
-Assuming that you would like to propose some changes in the [Link](https://github.com/ckeditor/ckeditor5-link) feature, these are the steps you should take to create a pull request:
+<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).
+</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 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.). You can report this ticket in the specific repository in which you will make a pull request or in https://github.com/ckeditor/ckeditor5.
+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 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) ([ckeditor5-link](https://github.com/ckeditor/ckeditor5-link) in this particular case). The forked repository will appear in your GitHub account as `https://github.com/YOUR-USERNAME/ckeditor5-link`.
+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:
 
 	```shell
-	$ cd path/to/ckeditor5/packages/ckeditor5-link
+	$ cd path/to/ckeditor5
 	```
 
-1. Start a new branch for your code. We use the `t/GITHUB-ISSUE-NUMBER` convention for branch names:
+1. Start a new branch for your code. We use the `i/GITHUB-ISSUE-NUMBER` convention for branch names:
 
 	```shell
-	$ git checkout -b t/GITHUB-ISSUE-NUMBER
+	$ git checkout -b i/GITHUB-ISSUE-NUMBER
 	```
 
 1. Make the changes. Stick to the [code-style guidelines](#code-style) and remember about [tests and 100% code coverage](#tests)!
@@ -68,13 +72,13 @@ Assuming that you would like to propose some changes in the [Link](https://githu
 1. Now it is time to make your changes public. First, you need to let `git` know about the fork you created by adding the remote:
 
 	```shell
-	$ git remote add my-fork https://github.com/YOUR-USERNAME/ckeditor5-link
+	$ git remote add my-fork https://github.com/YOUR-USERNAME/ckeditor5
 	```
 
 1. Push your changes to your forked repository:
 
 	```shell
-	$ git push my-fork t/GITHUB-ISSUE-NUMBER
+	$ git push my-fork i/GITHUB-ISSUE-NUMBER
 	```
 
 1. Go to your forked repository on GitHub. Use the [pull request button](https://help.github.com/articles/about-pull-requests/) and follow the instructions. Make sure to include a merge commit message text matches the {@link framework/guides/contributing/git-commit-message-convention convention}
@@ -86,10 +90,6 @@ Some additional things you should keep in mind:
 * When making a pull request on GitHub, make sure to specify which ticket(s) your pull request resolves. It is also recommended to provide more information, like how to test the patch, issues that you encountered, decisions you had to make, known problems, etc.
 * Make sure you signed the [Contributor License Agreement (CLA)](#contributor-license-agreement-cla) and that tests pass. Test your changes!
 
-<info-box>
-	If want your changes to be permanent in your development environment, make sure your `mrgit.json` file {@link framework/guides/contributing/development-environment#using-mrgit-for-custom-packages points to your forked version of the repository} so next time you execute `mrgit sync` to refresh the project, the utility will use your fork.
-</info-box>
-
 ## Translating
 
 CKEditor 5 is a project with global impact, so contributing translations is both an easy and powerful way to help.

+ 13 - 165
docs/framework/guides/contributing/development-environment.md

@@ -5,39 +5,42 @@ order: 10
 
 # Development environment
 
-The CKEditor 5 codebase is divided into multiple [npm](http://npmjs.com/) packages, each developed in a separate Git repository. The main package is [`ckeditor5`](https://github.com/ckeditor/ckeditor5) which installs all project dependencies and various development-related resources such as:
+The CKEditor 5 codebase is divided into multiple [npm](http://npmjs.com/) packages. The main package is [`ckeditor5`](https://github.com/ckeditor/ckeditor5) which installs all project dependencies and various development-related resources such as:
 
 * the testing environment setup,
-* configuration for [Mr. Git](https://www.npmjs.com/package/mrgit) (a multi-repo management tool) and [Yarn](https://yarnpkg.com/) (a dependency management tool),
+* configuration for [Yarn](https://yarnpkg.com/),
 * translation management tools,
 * documentation generator,
 * and release tools.
 
+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).
 
+<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.
+</info-box>
+
 ## Requirements
 
 In order to start developing CKEditor 5 you will require:
 
-* [Node.js](https://nodejs.org/en/) 6.9.0+
+* [Node.js](https://nodejs.org/en/) 8.0.0+
 * [Git](https://git-scm.com/)
 
 ## Setting up the CKEditor development environment
 
-First, you need to install a couple of tools which you will be using later:
+First, you need to install  [Yarn](https://yarnpkg.com/) that will be used for dependency management.
 
-* [mrgit](https://www.npmjs.com/package/mrgit) &mdash; A multi-repo management tool,
-* [Yarn](https://yarnpkg.com/) &mdash; A dependency management tool.
-
-It is best to install them globally in your system for an easier use later on:
+It is best to install it globally in your system for an easier use later on:
 
 ```bash
-npm install -g yarn mrgit
+npm install -g yarn
 ```
 
 **Note:** [Read how to avoid using `sudo` to install packages globally](https://github.com/sindresorhus/guides/blob/master/npm-global-without-sudo.md) or use [nvm](https://github.com/creationix/nvm).
 
-Then clone the [main repository](https://github.com/ckeditor/ckeditor5):
+Then clone the [CKEditor 5 repository](https://github.com/ckeditor/ckeditor5):
 
 ```bash
 git clone https://github.com/ckeditor/ckeditor5.git
@@ -46,129 +49,10 @@ cd ckeditor5
 
 And install all CKEditor 5 packages from the [npm registry](http://npmjs.com/).
 
-**Note:** If you plan to use the developement version of CKEditor 5 packages (see the [next section](#switching-to-development-version-of-packages)), you can skip this step to save time.
-
-```bash
-yarn install
-```
-
-### Switching to development version of packages
-
-The steps above should install all the packages from npm, which means that you will have the latest releases of all of them. They are available in `node_modules/@ckeditor/` (we are using [scoped packages](https://docs.npmjs.com/misc/scope), hence the unusual directory).
-
-In order to work with development versions of all the official packages, it is recommended to use `mrgit`. This tool will clone all package repositories to the `packages/` directory. Then, those packages need to be installed in a way understandable by Node.js-compliant tools (like webpack or Browserify). In order to achieve that we use Yarn's feature called [workspaces](https://yarnpkg.com/lang/en/docs/workspaces/) which creates symlinks to these packages.
-
-First, clone all the repositories:
-
-```bash
-mrgit sync
-```
-
-Expected result:
-
-```bash
-(master 340feac) p@m /workspace/ckeditor5> tree -L 1 packages/
-packages/
-├── ckeditor5-autoformat
-├── ckeditor5-basic-styles
-├── ckeditor5-clipboard
-...
-└── ckeditor5-utils
-```
-
-Finally, install all external dependencies (such as the test runner) and create symlinks to CKEditor 5 packages available in the `packages/` directory with this one command:
-
-```bash
-yarn install
-```
-
-You can check that CKEditor 5 packages which are part of the development environment (so those except [development tools](https://github.com/ckeditor/ckeditor5-dev) and some closed source packages) should be linked:
-
-```bash
-(master ae6f43a) p@m /workspace/misc/ckeditor5> ls -la node_modules/\@ckeditor/
-total 0
-drwxr-xr-x    54 p  staff   1728 11 sty 14:41 .
-drwxr-xr-x  1115 p  staff  35680 11 sty 14:41 ..
-drwxr-xr-x     7 p  staff    224 11 sty 14:41 ckeditor-cloud-services-collaboration
-drwxr-xr-x     6 p  staff    192 11 sty 14:41 ckeditor-cloud-services-core
-lrwxr-xr-x     1 p  staff     41 11 sty 14:41 ckeditor5-adapter-ckfinder -> ../../packages/ckeditor5-adapter-ckfinder
-lrwxr-xr-x     1 p  staff     34 11 sty 14:41 ckeditor5-alignment -> ../../packages/ckeditor5-alignment
-lrwxr-xr-x     1 p  staff     35 11 sty 14:41 ckeditor5-autoformat -> ../../packages/ckeditor5-autoformat
-lrwxr-xr-x     1 p  staff     33 11 sty 14:41 ckeditor5-autosave -> ../../packages/ckeditor5-autosave
-lrwxr-xr-x     1 p  staff     37 11 sty 14:41 ckeditor5-basic-styles -> ../../packages/ckeditor5-basic-styles
-lrwxr-xr-x     1 p  staff     36 11 sty 14:41 ckeditor5-block-quote -> ../../packages/ckeditor5-block-quote
-lrwxr-xr-x     1 p  staff     38 11 sty 14:41 ckeditor5-build-balloon -> ../../packages/ckeditor5-build-balloon
-lrwxr-xr-x     1 p  staff     38 11 sty 14:41 ckeditor5-build-classic -> ../../packages/ckeditor5-build-classic
-lrwxr-xr-x     1 p  staff     49 11 sty 14:41 ckeditor5-build-decoupled-document -> ../../packages/ckeditor5-build-decoupled-document
-lrwxr-xr-x     1 p  staff     37 11 sty 14:41 ckeditor5-build-inline -> ../../packages/ckeditor5-build-inline
-lrwxr-xr-x     1 p  staff     33 11 sty 14:41 ckeditor5-ckfinder -> ../../packages/ckeditor5-ckfinder
-lrwxr-xr-x     1 p  staff     34 11 sty 14:41 ckeditor5-clipboard -> ../../packages/ckeditor5-clipboard
-lrwxr-xr-x     1 p  staff     39 11 sty 14:41 ckeditor5-cloud-services -> ../../packages/ckeditor5-cloud-services
-drwxr-xr-x     7 p  staff    224 11 sty 14:41 ckeditor5-collaboration-core
-drwxr-xr-x     7 p  staff    224 11 sty 14:41 ckeditor5-comments
-lrwxr-xr-x     1 p  staff     29 11 sty 14:41 ckeditor5-core -> ../../packages/ckeditor5-core
-drwxr-xr-x     8 p  staff    256 11 sty 14:41 ckeditor5-dev-docs
-drwxr-xr-x     9 p  staff    288 11 sty 14:41 ckeditor5-dev-env
-drwxr-xr-x     9 p  staff    288 11 sty 14:41 ckeditor5-dev-tests
-drwxr-xr-x     8 p  staff    256 11 sty 14:41 ckeditor5-dev-utils
-drwxr-xr-x     8 p  staff    256 11 sty 14:41 ckeditor5-dev-webpack-plugin
-lrwxr-xr-x     1 p  staff     35 11 sty 14:41 ckeditor5-easy-image -> ../../packages/ckeditor5-easy-image
-lrwxr-xr-x     1 p  staff     39 11 sty 14:41 ckeditor5-editor-balloon -> ../../packages/ckeditor5-editor-balloon
-lrwxr-xr-x     1 p  staff     39 11 sty 14:41 ckeditor5-editor-classic -> ../../packages/ckeditor5-editor-classic
-lrwxr-xr-x     1 p  staff     41 11 sty 14:41 ckeditor5-editor-decoupled -> ../../packages/ckeditor5-editor-decoupled
-lrwxr-xr-x     1 p  staff     38 11 sty 14:41 ckeditor5-editor-inline -> ../../packages/ckeditor5-editor-inline
-lrwxr-xr-x     1 p  staff     31 11 sty 14:41 ckeditor5-engine -> ../../packages/ckeditor5-engine
-lrwxr-xr-x     1 p  staff     30 11 sty 14:41 ckeditor5-enter -> ../../packages/ckeditor5-enter
-
-... and so on
-```
-
-If everything worked correctly, you should be able to run some tests:
-
-```bash
-yarn run test --files=core
-```
-
-### Fetching changes
-
-Whenever you want to update all packages to their latest versions call:
-
 ```bash
-# To update the ckeditor5 repository itself:
-git pull
-
-# To update pull changes to all the packages:
-mrgit sync
-
-# To install missing dependencies:
 yarn install
 ```
 
-From time to time, if the list of dependencies in any of the packages changed, new package has been added to `ckeditor5` or you just want to make sure that you have the repository up to date, run the `reinstall` script:
-
-```bash
-git pull
-yarn run reinstall
-```
-
-The `reinstall` script first calls `yarn run clean` to remove `node_modules/` directories from all packages (including `ckeditor5`) and then `yarn run bootstrap` which is a shorthand for `mrgit sync && yarn install`.
-
-### Working with multiple repositories
-
-Mr. Git implements many useful commands, such as:
-
-* `mrgit exec 'command'` – executing a shell command in all repositories,
-* `mrgit checkout <branch>` – checking all repositories to given branch (or hash),
-* `mrgit status` – displaying information about all repositories.
-
-Read more about those commands in [mrgit's documentation](https://github.com/cksource/mrgit).
-
-Mr. Git has been developed by the [CKSource team](https://cksource.com/) and we are relying on it heavily, hence you can expect more features and improvements to come. However, it is not a CKEditor-specific tool and should be suitable for any multi-repository project (though it best fits JavaScript projects).
-
-### Using `mrgit` for custom packages
-
-If you are developing custom packages or forked any of the official packages and want `mrgit` to work with it, change the dependencies configuration in [`mrgit.json`](https://github.com/ckeditor/ckeditor5/blob/master/mrgit.json). Note that `mrgit` is able to clone the package from any Git URL. Refer to [its documentation](https://github.com/cksource/mrgit) for more details.
-
 ## Running tests
 
 In order to run tests you need to use the `test` and `manual` tasks.
@@ -240,42 +124,6 @@ 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.
 
-## Bisecting through a multi-repository
-
-CKEditor 5 is a multi-repository project. It means that [`git bisect`](https://git-scm.com/docs/git-bisect) (which is super handy when tracking which commit introduced a bug) will not work out of the box.
-
-Fortunately, every commit made to any of the `master` branches of all CKEditor 5 subpackages will update this subpackage's hash in `mrgit.json` in the [`master-revisions`](https://github.com/ckeditor/ckeditor5/commits/master-revisions) branch.
-
-Thanks to that, `master-revisions` contains an ordered history of all changes which makes it possible to go back to any point in history:
-
-```bash
-# Make sure to update this branch.
-git co master-revisions
-git pull
-
-# Check out 30 commits back.
-git co master-revisions~30
-
-# Check out subpackages to correct hashes.
-mrgit co
-```
-
-Once you found the point in history which interests you, you can go straight to a commit in a subpackage and PR. For example:
-
-```bash
-(detached:bisect/bad~1 cb2feef ?1) p@m /workspace/ckeditor5> git bisect good
-ab5b5494e5eba7beb4a3bac4ea9c5e6d59a610f5 is the first bad commit
-commit ab5b5494e5eba7beb4a3bac4ea9c5e6d59a610f5
-Author: Travis CI User <travis@example.org>
-Date:   Tue Apr 11 16:45:47 2017 +0000
-
-    Revision: https://github.com/ckeditor/ckeditor5-image/commit/02869eb4478a2f538006c128b30b9727617af665
-
-:100644 100644 611526d5c4beae7046b0a08ec36843cbbe642175 35acdd8ba971d8127dd462a9c8c1162a54f00d58 M	mgit.json
-```
-
-Leads to [`ckeditor/ckeditor5-image@02869eb`](https://github.com/ckeditor/ckeditor5-image/commit/02869eb) which leads to [ckeditor/ckeditor5-image#95](https://github.com/ckeditor/ckeditor5-image/pull/95).
-
 ## Additional information for contributors
 
 ### SVG icons

+ 6 - 5
docs/framework/guides/contributing/testing-environment.md

@@ -11,7 +11,7 @@ Before reading this article we recommend getting familiar with the CKEditor 5 {@
 
 The CKEditor 5 testing environment uses a popular setup with [Karma](https://karma-runner.github.io), [webpack](https://webpack.github.io/), [babel-loader](https://github.com/babel/babel-loader) and [Istanbul](https://github.com/gotwarlost/istanbul). We created some [npm scripts](https://docs.npmjs.com/cli/run-script) which glue all these pieces and special requirements for CKEditor together.
 
-Each CKEditor 5 package has its own tests suite (see for example the [engine's tests](https://github.com/ckeditor/ckeditor5-engine/tree/master/tests)), however, the test runner is available in the [`ckeditor5`](https://github.com/ckeditor/ckeditor5) package which is the central development environment. The actual code of the test runner is implemented in the [`@ckeditor/ckeditor5-dev-tests`](https://www.npmjs.com/package/@ckeditor/ckeditor5-dev-tests) package and can be easily reused outside of `ckeditor5`.
+Each CKEditor 5 package has its own tests suite (see for example the [engine's tests](https://github.com/ckeditor/ckeditor5/tree/master/packages/ckeditor5-engine/tests)), however, the test runner is available in the root of the [`ckeditor5`](https://github.com/ckeditor/ckeditor5) repository which is the central development environment. The actual code of the test runner is implemented in the [`@ckeditor/ckeditor5-dev-tests`](https://www.npmjs.com/package/@ckeditor/ckeditor5-dev-tests) package and can be easily reused outside of `ckeditor5`.
 
 ## Running automated tests
 
@@ -29,19 +29,19 @@ It accepts the following arguments that must be passed after the `--` option:
 
 ### Examples
 
-Run all tests with the code coverage check of the [`ckeditor5-core`](https://github.com/ckeditor/ckeditor5-core) package:
+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-engine/tree/master/tests/view) and all the tests in [`ckeditor5-typing`](https://github.com/ckeditor/ckeditor5-typing):
+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-basic-styles) package:
+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
@@ -54,9 +54,10 @@ In order to start the manual tests server, use the `yarn run manual` task.
 The task accepts the following options:
 
 * `--source-map` (alias `-s`) &ndash; Whether to generate useful source maps for the code.
-* `--files` &ndash; Specifies test files to run. Accepts a package name or a glob. For example `--files=ckeditor5` will run tests from the main repository. 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).
+* `--files` &ndash; Specifies test files to run. Accepts a package name or a glob. For example `--files=ckeditor5` will only run tests from the CKEditor 5 main 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).
 * `--additionalLanguages="ar,pl,..."` &ndash; Specifies extra languages to the [CKEditor 5 webpack plugin](https://www.npmjs.com/package/@ckeditor/ckeditor5-dev-webpack-plugin). Check out the {@link features/ui-language UI language guide} to learn more.
 * `--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 `8125`.
 
 It starts the server available at http://localhost:8125.
 

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

@@ -37,9 +37,9 @@ The framework was designed to be a highly flexible and universal platform for cr
 
 ## Framework structure
 
-The framework is made of several [npm packages](https://npmjs.com). Every package is developed in its own repository, making CKEditor 5 a multi-repository project. The main repository that glues all of them and contains the CKEditor 5 development environment is https://github.com/ckeditor/ckeditor5.
+The framework is made of several [npm packages](https://npmjs.com). Each package is available in the `/packages` directory of the https://github.com/ckeditor/ckeditor5 repository.
 
-A complete list of official packages that constitute the framework is available in the [main repository's README](https://github.com/ckeditor/ckeditor5#packages).
+A complete list of official packages that constitute the framework is available in the [CKEditor 5 repository's README](https://github.com/ckeditor/ckeditor5#packages).
 
 There are a few groups of packages:
 

+ 5 - 2
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.0.0",
+    "@ckeditor/ckeditor5-dev-tests": "^19.1.0",
     "@ckeditor/ckeditor5-dev-utils": "^13.0.0",
     "@ckeditor/ckeditor5-dev-webpack-plugin": "^9.0.0",
     "@ckeditor/ckeditor5-inspector": "^2.0.0",
@@ -88,6 +88,7 @@
     "@ckeditor/ckeditor5-track-changes": "^19.0.1",
     "@wiris/mathtype-ckeditor5": "^7.19.0",
     "babel-standalone": "^6.26.0",
+    "coveralls": "^3.1.0",
     "css-loader": "^1.0.0",
     "eslint": "^5.5.0",
     "eslint-config-ckeditor5": "^2.0.0",
@@ -130,7 +131,7 @@
     "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",
-    "reset": "rm -rf ./packages ./node_modules && yarn run bootstrap",
+    "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",
     "docs:api": "node ./scripts/docs/build-api-docs.js",
@@ -159,6 +160,8 @@
   },
   "eslintIgnore": [
     "build/**",
+    "packages/*/build/**",
+    "packages/*/src/lib/**",
     "coverage/**"
   ],
   "workspaces": {

+ 31 - 14
packages/ckeditor5-engine/tests/view/view/view.js

@@ -16,6 +16,7 @@ import FocusObserver from '../../../src/view/observer/focusobserver';
 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 ViewPosition from '../../../src/view/position';
 import ViewSelection from '../../../src/view/selection';
 import { StylesProcessor } from '../../../src/view/stylesmap';
@@ -527,7 +528,10 @@ describe( 'view', () => {
 
 			view.attachDomRoot( domRoot );
 
-			viewRoot._appendChild( new ViewElement( viewDocument, 'p' ) );
+			// 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' ) );
 			view.forceRender();
 
 			domElement = createElement( document, 'div', { contenteditable: 'true' } );
@@ -542,27 +546,40 @@ describe( 'view', () => {
 		} );
 
 		it( 'should be true if selection is inside a DOM root element', done => {
-			domSelection.collapse( domP, 0 );
+			domRoot.focus();
 
-			// Wait for async selectionchange event on DOM document.
 			setTimeout( () => {
-				expect( view.hasDomSelection ).to.be.true;
+				domSelection.collapse( domP, 0 );
 
-				done();
-			}, 100 );
+				// Wait for async selectionchange event on DOM document.
+				setTimeout( () => {
+					expect( view.hasDomSelection ).to.be.true;
+
+					done();
+				}, 10 );
+			}, 10 );
 		} );
 
 		it( 'should be true if selection is inside a DOM root element - no focus', done => {
-			domSelection.collapse( domP, 0 );
-			domRoot.blur();
+			domRoot.focus();
 
-			// Wait for async selectionchange event on DOM document.
 			setTimeout( () => {
-				expect( view.hasDomSelection ).to.be.true;
-				expect( view.document.isFocused ).to.be.false;
-
-				done();
-			}, 100 );
+				domSelection.collapse( domP, 0 );
+
+				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 );
 		} );
 
 		it( 'should be false if selection is outside DOM root element', done => {

+ 5 - 10
packages/ckeditor5-media-embed/src/mediaembedui.js

@@ -41,19 +41,14 @@ export default class MediaEmbedUI extends Plugin {
 		const command = editor.commands.get( 'mediaEmbed' );
 		const registry = editor.plugins.get( MediaEmbedEditing ).registry;
 
-		/**
-		 * The form view displayed inside the drop-down.
-		 *
-		 * @member {module:media-embed/ui/mediaformview~MediaFormView}
-		 */
-		this.form = new MediaFormView( getFormValidators( editor.t, registry ), editor.locale );
-
 		// Setup `imageUpload` button.
 		editor.ui.componentFactory.add( 'mediaEmbed', locale => {
 			const dropdown = createDropdown( locale );
 
-			this._setUpDropdown( dropdown, this.form, command, editor );
-			this._setUpForm( this.form, dropdown, command );
+			const mediaForm = new MediaFormView( getFormValidators( editor.t, registry ), editor.locale );
+
+			this._setUpDropdown( dropdown, mediaForm, command, editor );
+			this._setUpForm( dropdown, mediaForm, command );
 
 			return dropdown;
 		} );
@@ -103,7 +98,7 @@ export default class MediaEmbedUI extends Plugin {
 		}
 	}
 
-	_setUpForm( form, dropdown, command ) {
+	_setUpForm( dropdown, form, command ) {
 		form.delegate( 'submit', 'cancel' ).to( dropdown );
 		form.urlInputView.bind( 'value' ).to( command, 'value' );
 

+ 9 - 0
packages/ckeditor5-media-embed/tests/mediaembedui.js

@@ -53,6 +53,15 @@ describe( 'MediaEmbedUI', () => {
 		expect( dropdown ).to.be.instanceOf( DropdownView );
 	} );
 
+	it( 'should allow creating two instances', () => {
+		let secondInstance;
+
+		expect( function createSecondInstance() {
+			secondInstance = editor.ui.componentFactory.create( 'mediaEmbed' );
+		} ).not.to.throw();
+		expect( dropdown ).to.not.equal( secondInstance );
+	} );
+
 	describe( 'dropdown', () => {
 		it( 'should bind #isEnabled to the command', () => {
 			const command = editor.commands.get( 'mediaEmbed' );

+ 1 - 1
packages/ckeditor5-select-all/docs/features/select-all.md

@@ -9,7 +9,7 @@ The {@link module:select-all/selectall~SelectAll} feature allows selecting the e
 
 ## Demo
 
-Press <kbd>Ctrl/⌘</kbd>+<kbd>A</kbd> or use the toolbar button to select the entire content of the editor. Note that when editing an image caption, the selection will only expand to the boundaries of the caption.
+Press <kbd>Ctrl/⌘</kbd>+<kbd>A</kbd> or use the toolbar button to select the entire content of the editor. Note that when editing an {@link features/image#image-captions image caption}, the selection will only expand to the boundaries of the caption.
 
 {@snippet features/select-all}
 

+ 2 - 2
packages/ckeditor5-table/lang/contexts.json

@@ -4,13 +4,13 @@
 	"Insert column left": "Label for the insert table column to the left of the current one button.",
 	"Insert column right": "Label for the insert table column to the right of the current one button.",
 	"Delete column": "Label for the delete table column button.",
-	"Select column": "Label for the select table entire column button.",
+	"Select column": "Label for the select the entire table column button.",
 	"Column": "Label for the table column dropdown button.",
 	"Header row": "Label for the set/unset table header row button.",
 	"Insert row below": "Label for the insert row below button.",
 	"Insert row above": "Label for the insert row above button.",
 	"Delete row": "Label for the delete table row button.",
-	"Select row": "Label for the select table entire row button.",
+	"Select row": "Label for the select the entire table row button.",
 	"Row": "Label for the table row dropdown button.",
 	"Merge cell up": "Label for the merge table cell up button.",
 	"Merge cell right": "Label for the merge table cell right button.",

+ 3 - 1
packages/ckeditor5-table/package.json

@@ -29,7 +29,9 @@
     "@ckeditor/ckeditor5-paragraph": "^19.0.0",
     "@ckeditor/ckeditor5-typing": "^19.0.0",
     "@ckeditor/ckeditor5-undo": "^19.0.0",
-    "@ckeditor/ckeditor5-utils": "^19.0.0"
+    "@ckeditor/ckeditor5-utils": "^19.0.0",
+    "json-diff": "^0.5.4",
+    "lodash-es": "^4.17.10"
   },
   "engines": {
     "node": ">=8.0.0",

+ 5 - 6
packages/ckeditor5-table/src/commands/insertcolumncommand.js

@@ -9,6 +9,7 @@
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
 import { findAncestor } from './utils';
+import { getColumnIndexes, getSelectionAffectedTableCells } from '../utils';
 
 /**
  * The insert column command.
@@ -72,13 +73,11 @@ export default class InsertColumnCommand extends Command {
 		const tableUtils = editor.plugins.get( 'TableUtils' );
 		const insertBefore = this.order === 'left';
 
-		const referencePosition = insertBefore ? selection.getFirstPosition() : selection.getLastPosition();
-		const referenceRange = insertBefore ? selection.getFirstRange() : selection.getLastRange();
+		const affectedTableCells = getSelectionAffectedTableCells( selection );
+		const columnIndexes = getColumnIndexes( affectedTableCells );
 
-		const tableCell = referenceRange.getContainedElement() || findAncestor( 'tableCell', referencePosition );
-		const table = tableCell.parent.parent;
-
-		const { column } = tableUtils.getCellLocation( tableCell );
+		const column = insertBefore ? columnIndexes.first : columnIndexes.last;
+		const table = findAncestor( 'table', affectedTableCells[ 0 ] );
 
 		tableUtils.insertColumns( table, { columns: 1, at: insertBefore ? column : column + 1 } );
 	}

+ 6 - 8
packages/ckeditor5-table/src/commands/insertrowcommand.js

@@ -9,6 +9,7 @@
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
 import { findAncestor } from './utils';
+import { getRowIndexes, getSelectionAffectedTableCells } from '../utils';
 
 /**
  * The insert row command.
@@ -71,15 +72,12 @@ export default class InsertRowCommand extends Command {
 		const tableUtils = editor.plugins.get( 'TableUtils' );
 		const insertAbove = this.order === 'above';
 
-		const referencePosition = insertAbove ? selection.getFirstPosition() : selection.getLastPosition();
-		const referenceRange = insertAbove ? selection.getFirstRange() : selection.getLastRange();
+		const affectedTableCells = getSelectionAffectedTableCells( selection );
+		const rowIndexes = getRowIndexes( affectedTableCells );
 
-		const tableCell = referenceRange.getContainedElement() || findAncestor( 'tableCell', referencePosition );
-		const tableRow = tableCell.parent;
-		const table = tableRow.parent;
+		const row = insertAbove ? rowIndexes.first : rowIndexes.last;
+		const table = findAncestor( 'table', affectedTableCells[ 0 ] );
 
-		const row = table.getChildIndex( tableRow );
-
-		tableUtils.insertRows( table, { rows: 1, at: this.order === 'below' ? row + 1 : row } );
+		tableUtils.insertRows( table, { rows: 1, at: insertAbove ? row : row + 1 } );
 	}
 }

+ 7 - 26
packages/ckeditor5-table/src/commands/mergecellcommand.js

@@ -9,10 +9,7 @@
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
 import TableWalker from '../tablewalker';
-import {
-	updateNumericAttribute,
-	isHeadingColumnCell
-} from './utils';
+import { isHeadingColumnCell, findAncestor } from './utils';
 import { getTableCellsContainingSelection } from '../utils';
 
 /**
@@ -83,6 +80,7 @@ export default class MergeCellCommand extends Command {
 		const model = this.editor.model;
 		const doc = model.document;
 		const tableCell = getTableCellsContainingSelection( doc.selection )[ 0 ];
+
 		const cellToMerge = this.value;
 		const direction = this.direction;
 
@@ -108,7 +106,10 @@ export default class MergeCellCommand extends Command {
 
 			// Remove empty row after merging.
 			if ( !removedTableCellRow.childCount ) {
-				removeEmptyRow( removedTableCellRow, writer );
+				const tableUtils = this.editor.plugins.get( 'TableUtils' );
+				const table = findAncestor( 'table', removedTableCellRow );
+
+				tableUtils.removeRows( table, { at: removedTableCellRow.index, batch: writer.batch } );
 			}
 		} );
 	}
@@ -243,28 +244,8 @@ function getVerticalCell( tableCell, direction ) {
 	return cellToMergeData && cellToMergeData.cell;
 }
 
-// Properly removes an empty row from a table. It will update the `rowspan` attribute of cells that overlap the removed row.
-//
-// @param {module:engine/model/element~Element} removedTableCellRow
-// @param {module:engine/model/writer~Writer} writer
-function removeEmptyRow( removedTableCellRow, writer ) {
-	const table = removedTableCellRow.parent;
-
-	const removedRowIndex = table.getChildIndex( removedTableCellRow );
-
-	for ( const { cell, row, rowspan } of new TableWalker( table, { endRow: removedRowIndex } ) ) {
-		const overlapsRemovedRow = row + rowspan - 1 >= removedRowIndex;
-
-		if ( overlapsRemovedRow ) {
-			updateNumericAttribute( 'rowspan', rowspan - 1, cell, writer );
-		}
-	}
-
-	writer.remove( removedTableCellRow );
-}
-
 // Merges two table cells. It will ensure that after merging cells with an empty paragraph, the resulting table cell will only have one
-// paragraph. If one of the merged table cell is empty, the merged table cell will have the contents of the non-empty table cell.
+// paragraph. If one of the merged table cells is empty, the merged table cell will have the contents of the non-empty table cell.
 // If both are empty, the merged table cell will have only one empty paragraph.
 //
 // @param {module:engine/model/element~Element} cellToRemove

+ 22 - 34
packages/ckeditor5-table/src/commands/mergecellscommand.js

@@ -8,7 +8,6 @@
  */
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
-import TableWalker from '../tablewalker';
 import { findAncestor, updateNumericAttribute } from './utils';
 import TableUtils from '../tableutils';
 import { getColumnIndexes, getRowIndexes, getSelectedTableCells } from '../utils';
@@ -16,7 +15,7 @@ import { getColumnIndexes, getRowIndexes, getSelectedTableCells } from '../utils
 /**
  * The merge cells command.
  *
- * The command is registered by the {@link module:table/tableediting~TableEditing} as `'mergeTableCells'` editor command.
+ * The command is registered by {@link module:table/tableediting~TableEditing} as the `'mergeTableCells'` editor command.
  *
  * For example, to merge selected table cells:
  *
@@ -44,7 +43,7 @@ export default class MergeCellsCommand extends Command {
 		model.change( writer => {
 			const selectedTableCells = getSelectedTableCells( model.document.selection );
 
-			// All cells will be merge into the first one.
+			// All cells will be merged into the first one.
 			const firstTableCell = selectedTableCells.shift();
 
 			// Set the selection in cell that other cells are being merged to prevent model-selection-range-intersects error in undo.
@@ -57,42 +56,31 @@ export default class MergeCellsCommand extends Command {
 			updateNumericAttribute( 'colspan', mergeWidth, firstTableCell, writer );
 			updateNumericAttribute( 'rowspan', mergeHeight, firstTableCell, writer );
 
+			const emptyRowsIndexes = [];
+
 			for ( const tableCell of selectedTableCells ) {
 				const tableRow = tableCell.parent;
-				mergeTableCells( tableCell, firstTableCell, writer );
-				removeRowIfEmpty( tableRow, writer );
-			}
 
-			writer.setSelection( firstTableCell, 'in' );
-		} );
-	}
-}
+				mergeTableCells( tableCell, firstTableCell, writer );
 
-// Properly removes the empty row from a table. Updates the `rowspan` attribute of cells that overlap the removed row.
-//
-// @param {module:engine/model/element~Element} row
-// @param {module:engine/model/writer~Writer} writer
-function removeRowIfEmpty( row, writer ) {
-	if ( row.childCount ) {
-		return;
-	}
+				if ( !tableRow.childCount ) {
+					emptyRowsIndexes.push( tableRow.index );
+				}
+			}
 
-	const table = row.parent;
-	const removedRowIndex = table.getChildIndex( row );
+			if ( emptyRowsIndexes.length ) {
+				const table = findAncestor( 'table', firstTableCell );
 
-	for ( const { cell, row, rowspan } of new TableWalker( table, { endRow: removedRowIndex } ) ) {
-		const overlapsRemovedRow = row + rowspan - 1 >= removedRowIndex;
+				emptyRowsIndexes.reverse().forEach( row => tableUtils.removeRows( table, { at: row, batch: writer.batch } ) );
+			}
 
-		if ( overlapsRemovedRow ) {
-			updateNumericAttribute( 'rowspan', rowspan - 1, cell, writer );
-		}
+			writer.setSelection( firstTableCell, 'in' );
+		} );
 	}
-
-	writer.remove( row );
 }
 
-// Merges two table cells - will ensure that after merging cells with empty paragraphs the result table cell will only have one paragraph.
-// If one of the merged table cells is empty, the merged table cell will have contents of the non-empty table cell.
+// Merges two table cells. It will ensure that after merging cells with empty paragraphs the resulting table cell will only have one
+// paragraph. If one of the merged table cells is empty, the merged table cell will have contents of the non-empty table cell.
 // If both are empty, the merged table cell will have only one empty paragraph.
 //
 // @param {module:engine/model/element~Element} cellBeingMerged
@@ -119,7 +107,7 @@ function isEmpty( tableCell ) {
 	return tableCell.childCount == 1 && tableCell.getChild( 0 ).is( 'paragraph' ) && tableCell.getChild( 0 ).isEmpty;
 }
 
-// Checks if the selection contains mergeable cells.
+// Checks if the selection contains cells that can be merged.
 //
 // In a table below:
 //
@@ -138,8 +126,8 @@ function isEmpty( tableCell ) {
 //   - c, d, f (cell d spans over a cell in the row below)
 //
 // While an invalid selection would be:
-//   - a, c (cell "b" not selected creates a gap)
-//   - f, g, h (cell "d" spans over a cell from row of "f" cell - thus creates a gap)
+//   - 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
@@ -187,7 +175,7 @@ function canMergeCells( selection, tableUtils ) {
 	return areaOfValidSelection == areaOfSelectedCells;
 }
 
-// Calculates the area of a maximum rectangle that can span over provided row & column indexes.
+// Calculates the area of a maximum rectangle that can span over the provided row & column indexes.
 //
 // @param {Array.<Number>} rows
 // @param {Array.<Number>} columns
@@ -204,7 +192,7 @@ function getBiggestRectangleArea( rows, columns ) {
 	return ( lastRow - firstRow + 1 ) * ( lastColumn - firstColumn + 1 );
 }
 
-// Checks if the selection does not mix header (column or row) with other cells.
+// 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.

+ 4 - 0
packages/ckeditor5-table/src/converters/table-cell-paragraph-post-fixer.js

@@ -100,6 +100,8 @@ function fixTableRow( tableRow, writer ) {
 function fixTableCellContent( tableCell, writer ) {
 	// Insert paragraph to an empty table cell.
 	if ( tableCell.childCount == 0 ) {
+		// @if CK_DEBUG_TABLE // console.log( 'Post-fixing table: insert paragraph in empty cell.' );
+
 		writer.insertElement( 'paragraph', tableCell );
 
 		return true;
@@ -109,6 +111,8 @@ function fixTableCellContent( tableCell, writer ) {
 	// Temporary solution. See https://github.com/ckeditor/ckeditor5/issues/1464.
 	const textNodes = Array.from( tableCell.getChildren() ).filter( child => child.is( 'text' ) );
 
+	// @if CK_DEBUG_TABLE // textNodes.length && console.log( 'Post-fixing table: wrap cell content with paragraph.' );
+
 	for ( const child of textNodes ) {
 		writer.wrap( writer.createRangeOn( child ), 'paragraph' );
 	}

+ 19 - 3
packages/ckeditor5-table/src/converters/table-cell-refresh-post-fixer.js

@@ -28,15 +28,28 @@ function tableCellRefreshPostFixer( model ) {
 	// Stores cells to be refreshed so the table cell will be refreshed once for multiple changes.
 	const cellsToRefresh = new Set();
 
+	// Counting the paragraph inserts to verify if it increased to more than one paragraph in the current differ.
+	let insertCount = 0;
+
 	for ( const change of differ.getChanges() ) {
 		const parent = change.type == 'insert' || change.type == 'remove' ? change.position.parent : change.range.start.parent;
 
-		if ( parent.is( 'tableCell' ) && checkRefresh( parent, change.type ) ) {
+		if ( !parent.is( 'tableCell' ) ) {
+			continue;
+		}
+
+		if ( change.type == 'insert' ) {
+			insertCount++;
+		}
+
+		if ( checkRefresh( parent, change.type, insertCount ) ) {
 			cellsToRefresh.add( parent );
 		}
 	}
 
 	if ( cellsToRefresh.size ) {
+		// @if CK_DEBUG_TABLE // console.log( `Post-fixing table: refreshing cells (${ cellsToRefresh.size }).` );
+
 		for ( const tableCell of cellsToRefresh.values() ) {
 			differ.refreshItem( tableCell );
 		}
@@ -60,7 +73,8 @@ function tableCellRefreshPostFixer( model ) {
 //
 // @param {module:engine/model/element~Element} tableCell The table cell to check.
 // @param {String} type Type of change.
-function checkRefresh( tableCell, type ) {
+// @param {Number} insertCount The number of inserts in differ.
+function checkRefresh( tableCell, type, insertCount ) {
 	const hasInnerParagraph = Array.from( tableCell.getChildren() ).some( child => child.is( 'paragraph' ) );
 
 	// If there is no paragraph in table cell then the view doesn't require refreshing.
@@ -83,5 +97,7 @@ function checkRefresh( tableCell, type ) {
 	//
 	// - another element is added to a single paragraph (childCount becomes >= 2)
 	// - another element is removed and a single paragraph is left (childCount == 1)
-	return tableCell.childCount <= ( type == 'insert' ? 2 : 1 );
+	//
+	// Change is not needed if there were multiple blocks before change.
+	return tableCell.childCount <= ( type == 'insert' ? insertCount + 1 : 1 );
 }

+ 35 - 12
packages/ckeditor5-table/src/converters/table-layout-post-fixer.js

@@ -271,6 +271,8 @@ function fixTableCellsRowspan( table, writer ) {
 	const cellsToTrim = findCellsToTrim( table );
 
 	if ( cellsToTrim.length ) {
+		// @if CK_DEBUG_TABLE // console.log( `Post-fixing table: trimming cells row-spans (${ cellsToTrim.length }).` );
+
 		wasFixed = true;
 
 		for ( const data of cellsToTrim ) {
@@ -290,14 +292,38 @@ function fixTableRowsSizes( table, writer ) {
 	let wasFixed = false;
 
 	const rowsLengths = getRowsLengths( table );
-	const tableSize = rowsLengths[ 0 ];
+	const rowsToRemove = [];
+
+	// Find empty rows.
+	for ( const [ rowIndex, size ] of rowsLengths.entries() ) {
+		if ( !size ) {
+			rowsToRemove.push( rowIndex );
+		}
+	}
+
+	// Remove empty rows.
+	if ( rowsToRemove.length ) {
+		// @if CK_DEBUG_TABLE // console.log( `Post-fixing table: remove empty rows (${ rowsToRemove.length }).` );
 
-	const isValid = Object.values( rowsLengths ).every( length => length === tableSize );
+		wasFixed = true;
+
+		for ( const rowIndex of rowsToRemove.reverse() ) {
+			writer.remove( table.getChild( rowIndex ) );
+			rowsLengths.splice( rowIndex, 1 );
+		}
+	}
+
+	// Verify if all the rows have the same number of columns.
+	const tableSize = rowsLengths[ 0 ];
+	const isValid = rowsLengths.every( length => length === tableSize );
 
 	if ( !isValid ) {
-		const maxColumns = Object.values( rowsLengths ).reduce( ( prev, current ) => current > prev ? current : prev, 0 );
+		// @if CK_DEBUG_TABLE // console.log( 'Post-fixing table: adding missing cells.' );
 
-		for ( const [ rowIndex, size ] of Object.entries( rowsLengths ) ) {
+		// Find the maximum number of columns.
+		const maxColumns = rowsLengths.reduce( ( prev, current ) => current > prev ? current : prev, 0 );
+
+		for ( const [ rowIndex, size ] of rowsLengths.entries() ) {
 			const columnsToInsert = maxColumns - size;
 
 			if ( columnsToInsert ) {
@@ -346,19 +372,16 @@ function findCellsToTrim( table ) {
 	return cellsToTrim;
 }
 
-// Returns an object with lengths of rows assigned to the corresponding row index.
+// Returns an array with lengths of rows assigned to the corresponding row index.
 //
 // @param {module:engine/model/element~Element} table
-// @returns {Object}
+// @returns {Array.<Number>}
 function getRowsLengths( table ) {
-	const lengths = {};
+	// TableWalker will not provide items for the empty rows, we need to pre-fill this array.
+	const lengths = new Array( table.childCount ).fill( 0 );
 
 	for ( const { row } of new TableWalker( table, { includeSpanned: true } ) ) {
-		if ( !lengths[ row ] ) {
-			lengths[ row ] = 0;
-		}
-
-		lengths[ row ] += 1;
+		lengths[ row ]++;
 	}
 
 	return lengths;

+ 22 - 5
packages/ckeditor5-table/src/converters/upcasttable.js

@@ -52,11 +52,11 @@ export default function upcastTable() {
 			conversionApi.writer.insert( table, splitResult.position );
 			conversionApi.consumable.consume( viewTable, { name: true } );
 
-			if ( rows.length ) {
-				// Upcast table rows in proper order (heading rows first).
-				rows.forEach( row => conversionApi.convertItem( row, conversionApi.writer.createPositionAt( table, 'end' ) ) );
-			} else {
-				// Create one row and one table cell for empty table.
+			// Upcast table rows in proper order (heading rows first).
+			rows.forEach( row => conversionApi.convertItem( row, conversionApi.writer.createPositionAt( table, 'end' ) ) );
+
+			// Create one row and one table cell for empty table.
+			if ( table.isEmpty ) {
 				const row = conversionApi.writer.createElement( 'tableRow' );
 				conversionApi.writer.insert( row, conversionApi.writer.createPositionAt( table, 'end' ) );
 
@@ -90,6 +90,23 @@ export default function upcastTable() {
 	};
 }
 
+/**
+ * Conversion helper that skips empty <tr> from upcasting.
+ *
+ * Empty row is considered a table model error.
+ *
+ * @returns {Function} Conversion helper.
+ */
+export function skipEmptyTableRow() {
+	return dispatcher => {
+		dispatcher.on( 'element:tr', ( evt, data ) => {
+			if ( data.viewItem.isEmpty ) {
+				evt.stop();
+			}
+		}, { priority: 'high' } );
+	};
+}
+
 export function upcastTableCell( elementName ) {
 	return dispatcher => {
 		dispatcher.on( `element:${ elementName }`, ( evt, data, conversionApi ) => {

+ 23 - 11
packages/ckeditor5-table/src/tablecellproperties/tablecellpropertiesui.js

@@ -32,6 +32,19 @@ import { debounce } from 'lodash-es';
 
 const ERROR_TEXT_TIMEOUT = 500;
 
+// Map of view properties and related commands.
+const propertyToCommandMap = {
+	borderStyle: 'tableCellBorderStyle',
+	borderColor: 'tableCellBorderColor',
+	borderWidth: 'tableCellBorderWidth',
+	width: 'tableCellWidth',
+	height: 'tableCellHeight',
+	padding: 'tableCellPadding',
+	backgroundColor: 'tableCellBackgroundColor',
+	horizontalAlignment: 'tableCellHorizontalAlignment',
+	verticalAlignment: 'tableCellVerticalAlignment'
+};
+
 /**
  * The table cell properties UI plugin. It introduces the `'tableCellProperties'` button
  * that opens a form allowing to specify the visual styling of a table cell.
@@ -110,6 +123,13 @@ export default class TableCellPropertiesUI extends Plugin {
 
 			this.listenTo( view, 'execute', () => this._showView() );
 
+			const commands = Object.values( propertyToCommandMap )
+				.map( commandName => editor.commands.get( commandName ) );
+
+			view.bind( 'isEnabled' ).toMany( commands, 'isEnabled', ( ...areEnabled ) => (
+				areEnabled.some( isCommandEnabled => isCommandEnabled )
+			) );
+
 			return view;
 		} );
 	}
@@ -256,17 +276,9 @@ export default class TableCellPropertiesUI extends Plugin {
 	_fillViewFormFromCommandValues() {
 		const commands = this.editor.commands;
 
-		this.view.set( {
-			borderStyle: commands.get( 'tableCellBorderStyle' ).value || '',
-			borderColor: commands.get( 'tableCellBorderColor' ).value || '',
-			borderWidth: commands.get( 'tableCellBorderWidth' ).value || '',
-			width: commands.get( 'tableCellWidth' ).value || '',
-			height: commands.get( 'tableCellHeight' ).value || '',
-			padding: commands.get( 'tableCellPadding' ).value || '',
-			backgroundColor: commands.get( 'tableCellBackgroundColor' ).value || '',
-			horizontalAlignment: commands.get( 'tableCellHorizontalAlignment' ).value || '',
-			verticalAlignment: commands.get( 'tableCellVerticalAlignment' ).value || ''
-		} );
+		Object.entries( propertyToCommandMap )
+			.map( ( [ property, commandName ] ) => [ property, commands.get( commandName ).value || '' ] )
+			.forEach( ( [ property, value ] ) => this.view.set( property, value ) );
 	}
 
 	/**

+ 2 - 1
packages/ckeditor5-table/src/tableediting.js

@@ -9,7 +9,7 @@
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 
-import upcastTable, { upcastTableCell } from './converters/upcasttable';
+import upcastTable, { upcastTableCell, skipEmptyTableRow } from './converters/upcasttable';
 import {
 	downcastInsertCell,
 	downcastInsertRow,
@@ -98,6 +98,7 @@ export default class TableEditing extends Plugin {
 
 		// Table row conversion.
 		conversion.for( 'upcast' ).elementToElement( { model: 'tableRow', view: 'tr' } );
+		conversion.for( 'upcast' ).add( skipEmptyTableRow() );
 
 		conversion.for( 'editingDowncast' ).add( downcastInsertRow( { asWidget: true } ) );
 		conversion.for( 'dataDowncast' ).add( downcastInsertRow() );

+ 72 - 37
packages/ckeditor5-table/src/tablenavigation.js

@@ -7,13 +7,15 @@
  * @module table/tablenavigation
  */
 
-import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
-import { getSelectedTableCells, getTableCellsContainingSelection } from './utils';
-import { findAncestor } from './commands/utils';
+import TableSelection from './tableselection';
 import TableWalker from './tablewalker';
+import { findAncestor } from './commands/utils';
+import { getSelectedTableCells, getTableCellsContainingSelection } from './utils';
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
-import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import priorities from '@ckeditor/ckeditor5-utils/src/priorities';
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 
 /**
  * This plugin enables keyboard navigation for tables.
@@ -29,6 +31,13 @@ export default class TableNavigation extends Plugin {
 		return 'TableNavigation';
 	}
 
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ TableSelection ];
+	}
+
 	/**
 	 * @inheritDoc
 	 */
@@ -77,7 +86,7 @@ export default class TableNavigation extends Plugin {
 
 	/**
 	 * Returns a handler for {@link module:engine/view/document~Document#event:keydown keydown} events for the <kbd>Tab</kbd> key executed
-	 * inside table cell.
+	 * inside table cells.
 	 *
 	 * @private
 	 * @param {Boolean} isForward Whether this handler will move the selection to the next or the previous cell.
@@ -160,7 +169,8 @@ export default class TableNavigation extends Plugin {
 			return;
 		}
 
-		const wasHandled = this._handleArrowKeys( getDirectionFromKeyCode( keyCode, this.editor.locale.contentLanguageDirection ) );
+		const direction = getDirectionFromKeyCode( keyCode, this.editor.locale.contentLanguageDirection );
+		const wasHandled = this._handleArrowKeys( direction, domEventData.shiftKey );
 
 		if ( wasHandled ) {
 			domEventData.preventDefault();
@@ -170,13 +180,14 @@ export default class TableNavigation extends Plugin {
 	}
 
 	/**
-	 * Handles arrow keys to move the selection around a table.
+	 * Handles arrow keys to move the selection around the table.
 	 *
 	 * @private
 	 * @param {'left'|'up'|'right'|'down'} direction The direction of the arrow key.
+	 * @param {Boolean} expandSelection If the current selection should be expanded.
 	 * @returns {Boolean} Returns `true` if key was handled.
 	 */
-	_handleArrowKeys( direction ) {
+	_handleArrowKeys( direction, expandSelection ) {
 		const model = this.editor.model;
 		const selection = model.document.selection;
 		const isForward = [ 'right', 'down' ].includes( direction );
@@ -186,9 +197,15 @@ export default class TableNavigation extends Plugin {
 		const selectedCells = getSelectedTableCells( selection );
 
 		if ( selectedCells.length ) {
-			const tableCell = isForward ? selectedCells[ selectedCells.length - 1 ] : selectedCells[ 0 ];
+			let focusCell;
+
+			if ( expandSelection ) {
+				focusCell = this.editor.plugins.get( 'TableSelection' ).getFocusCell();
+			} else {
+				focusCell = isForward ? selectedCells[ selectedCells.length - 1 ] : selectedCells[ 0 ];
+			}
 
-			this._navigateFromCellInDirection( tableCell, direction );
+			this._navigateFromCellInDirection( focusCell, direction, expandSelection );
 
 			return true;
 		}
@@ -204,7 +221,7 @@ export default class TableNavigation extends Plugin {
 
 		// Let's check if the selection is at the beginning/end of the cell.
 		if ( this._isSelectionAtCellEdge( selection, isForward ) ) {
-			this._navigateFromCellInDirection( tableCell, direction );
+			this._navigateFromCellInDirection( tableCell, direction, expandSelection );
 
 			return true;
 		}
@@ -226,7 +243,7 @@ export default class TableNavigation extends Plugin {
 		const textRange = this._findTextRangeFromSelection( cellRange, selection, isForward );
 
 		if ( !textRange ) {
-			this._navigateFromCellInDirection( tableCell, direction );
+			this._navigateFromCellInDirection( tableCell, direction, expandSelection );
 
 			return true;
 		}
@@ -242,7 +259,16 @@ export default class TableNavigation extends Plugin {
 		// of wrapped line (it's at the same time at the end of one line and at the start of the next line).
 		if ( this._isSingleLineRange( textRange, isForward ) ) {
 			model.change( writer => {
-				writer.setSelection( isForward ? cellRange.end : cellRange.start );
+				const newPosition = isForward ? cellRange.end : cellRange.start;
+
+				if ( expandSelection ) {
+					const newSelection = model.createSelection( selection.anchor );
+					newSelection.setFocus( newPosition );
+
+					writer.setSelection( newSelection );
+				} else {
+					writer.setSelection( newPosition );
+				}
 			} );
 
 			return true;
@@ -250,7 +276,7 @@ export default class TableNavigation extends Plugin {
 	}
 
 	/**
-	 * Returns true if the selection is at the boundary of a table cell according to the navigation direction.
+	 * Returns `true` if the selection is at the boundary of a table cell according to the navigation direction.
 	 *
 	 * @private
 	 * @param {module:engine/model/selection~Selection} selection The current selection.
@@ -279,12 +305,12 @@ export default class TableNavigation extends Plugin {
 
 	/**
 	 * Checks if there is an {@link module:engine/model/element~Element element} next to the current
-	 * {@link module:engine/model/selection~Selection model selection} marked in
+	 * {@link module:engine/model/selection~Selection model selection} marked in the
 	 * {@link module:engine/model/schema~Schema schema} as an `object`.
 	 *
 	 * @private
 	 * @param {module:engine/model/selection~Selection} modelSelection The selection.
-	 * @param {Boolean} isForward Direction of checking.
+	 * @param {Boolean} isForward The direction of checking.
 	 * @returns {Boolean}
 	 */
 	_isObjectElementNextToSelection( modelSelection, isForward ) {
@@ -300,12 +326,12 @@ export default class TableNavigation extends Plugin {
 
 	/**
 	 * Truncates the range so that it spans from the last selection position
-	 * to the last allowed $text position (mirrored if isForward is false).
+	 * to the last allowed `$text` position (mirrored if `isForward` is false).
 	 *
-	 * Returns `null` if resulting range can't contain $text element (according to schema).
+	 * Returns `null` if, according to the schema, the resulting range cannot contain a `$text` element.
 	 *
 	 * @private
-	 * @param {module:engine/model/range~Range} range Current table cell content range.
+	 * @param {module:engine/model/range~Range} range The current table cell content range.
 	 * @param {module:engine/model/selection~Selection} selection The current selection.
 	 * @param {Boolean} isForward The expected navigation direction.
 	 * @returns {module:engine/model/range~Range|null}
@@ -335,13 +361,13 @@ export default class TableNavigation extends Plugin {
 	}
 
 	/**
-	 * Basing on provided range, finds first/last (depending on `direction`) position inside the range
+	 * Basing on the provided range, finds the first or last (depending on `direction`) position inside the range
 	 * that can contain `$text` (according to schema) and is visible in the view.
 	 *
 	 * @private
-	 * @param {module:engine/model/range~Range} range The range to find position in.
+	 * @param {module:engine/model/range~Range} range The range to find the position in.
 	 * @param {'forward'|'backward'} direction Search direction.
-	 * @returns {module:engine/model/position~Position} Nearest selection range.
+	 * @returns {module:engine/model/position~Position} The nearest selection range.
 	 */
 	_getNearestVisibleTextPosition( range, direction ) {
 		const schema = this.editor.model.schema;
@@ -359,11 +385,11 @@ export default class TableNavigation extends Plugin {
 	}
 
 	/**
-	 * Checks if the DOM range corresponding to provided model range renders as a single line by analyzing DOMRects
+	 * Checks if the DOM range corresponding to the provided model range renders as a single line by analyzing DOMRects
 	 * (verifying if they visually wrap content to the next line).
 	 *
 	 * @private
-	 * @param {module:engine/model/range~Range} modelRange Current table cell content range.
+	 * @param {module:engine/model/range~Range} modelRange The current table cell content range.
 	 * @param {Boolean} isForward The expected navigation direction.
 	 * @returns {Boolean}
 	 */
@@ -415,18 +441,19 @@ export default class TableNavigation extends Plugin {
 	/**
 	 * Moves the selection from the given table cell in the specified direction.
 	 *
-	 * @private
-	 * @param {module:engine/model/element~Element} tableCell The table cell to start the selection navigation.
+	 * @protected
+	 * @param {module:engine/model/element~Element} focusCell The table cell that is current multi-cell selection focus.
 	 * @param {'left'|'up'|'right'|'down'} direction Direction in which selection should move.
+	 * @param {Boolean} [expandSelection=false] If the current selection should be expanded.
 	 */
-	_navigateFromCellInDirection( tableCell, direction ) {
+	_navigateFromCellInDirection( focusCell, direction, expandSelection = false ) {
 		const model = this.editor.model;
 
-		const table = findAncestor( 'table', tableCell );
+		const table = findAncestor( 'table', focusCell );
 		const tableMap = [ ...new TableWalker( table, { includeSpanned: true } ) ];
 		const { row: lastRow, column: lastColumn } = tableMap[ tableMap.length - 1 ];
 
-		const currentCellInfo = tableMap.find( ( { cell } ) => cell == tableCell );
+		const currentCellInfo = tableMap.find( ( { cell } ) => cell == focusCell );
 		let { row, column } = currentCellInfo;
 
 		switch ( direction ) {
@@ -463,24 +490,32 @@ export default class TableNavigation extends Plugin {
 		}
 
 		if ( column < 0 ) {
-			column = lastColumn;
+			column = expandSelection ? 0 : lastColumn;
 			row--;
 		} else if ( column > lastColumn ) {
-			column = 0;
+			column = expandSelection ? lastColumn : 0;
 			row++;
 		}
 
 		const cellToSelect = tableMap.find( cellInfo => cellInfo.row == row && cellInfo.column == column ).cell;
 		const isForward = [ 'right', 'down' ].includes( direction );
-		const positionToSelect = model.createPositionAt( cellToSelect, isForward ? 0 : 'end' );
 
-		model.change( writer => {
-			writer.setSelection( positionToSelect );
-		} );
+		if ( expandSelection ) {
+			const tableSelection = this.editor.plugins.get( 'TableSelection' );
+			const anchorCell = tableSelection.getAnchorCell() || focusCell;
+
+			tableSelection.setCellSelection( anchorCell, cellToSelect );
+		} else {
+			const positionToSelect = model.createPositionAt( cellToSelect, isForward ? 0 : 'end' );
+
+			model.change( writer => {
+				writer.setSelection( positionToSelect );
+			} );
+		}
 	}
 }
 
-// Returns 'true' if provided key code represents one of the arrow keys.
+// Returns `true` if the provided key code represents one of the arrow keys.
 //
 // @private
 // @param {Number} keyCode
@@ -492,7 +527,7 @@ function isArrowKeyCode( keyCode ) {
 		keyCode == keyCodes.arrowdown;
 }
 
-// Returns direction name from `keyCode`.
+// Returns the direction name from `keyCode`.
 //
 // @private
 // @param {Number} keyCode

+ 21 - 9
packages/ckeditor5-table/src/tableproperties/tablepropertiesui.js

@@ -32,6 +32,17 @@ import { debounce } from 'lodash-es';
 
 const ERROR_TEXT_TIMEOUT = 500;
 
+// Map of view properties and related commands.
+const propertyToCommandMap = {
+	borderStyle: 'tableBorderStyle',
+	borderColor: 'tableBorderColor',
+	borderWidth: 'tableBorderWidth',
+	backgroundColor: 'tableBackgroundColor',
+	width: 'tableWidth',
+	height: 'tableHeight',
+	alignment: 'tableAlignment'
+};
+
 /**
  * The table properties UI plugin. It introduces the `'tableProperties'` button
  * that opens a form allowing to specify visual styling of an entire table.
@@ -110,6 +121,13 @@ export default class TablePropertiesUI extends Plugin {
 
 			this.listenTo( view, 'execute', () => this._showView() );
 
+			const commands = Object.values( propertyToCommandMap )
+				.map( commandName => editor.commands.get( commandName ) );
+
+			view.bind( 'isEnabled' ).toMany( commands, 'isEnabled', ( ...areEnabled ) => (
+				areEnabled.some( isCommandEnabled => isCommandEnabled )
+			) );
+
 			return view;
 		} );
 	}
@@ -248,15 +266,9 @@ export default class TablePropertiesUI extends Plugin {
 	_fillViewFormFromCommandValues() {
 		const commands = this.editor.commands;
 
-		this.view.set( {
-			borderStyle: commands.get( 'tableBorderStyle' ).value || '',
-			borderColor: commands.get( 'tableBorderColor' ).value || '',
-			borderWidth: commands.get( 'tableBorderWidth' ).value || '',
-			backgroundColor: commands.get( 'tableBackgroundColor' ).value || '',
-			width: commands.get( 'tableWidth' ).value || '',
-			height: commands.get( 'tableHeight' ).value || '',
-			alignment: commands.get( 'tableAlignment' ).value || ''
-		} );
+		Object.entries( propertyToCommandMap )
+			.map( ( [ property, commandName ] ) => [ property, commands.get( commandName ).value || '' ] )
+			.forEach( ( [ property, value ] ) => this.view.set( property, value ) );
 	}
 
 	/**

+ 78 - 45
packages/ckeditor5-table/src/tableselection.js

@@ -8,6 +8,7 @@
  */
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import first from '@ckeditor/ckeditor5-utils/src/first';
 
 import TableWalker from './tablewalker';
 import TableUtils from './tableutils';
@@ -106,6 +107,65 @@ export default class TableSelection extends Plugin {
 		} );
 	}
 
+	/**
+	 * Sets the model selection based on given anchor and target cells (can be the same cell).
+	 * Takes care of setting the backward flag.
+	 *
+	 *		const modelRoot = editor.model.document.getRoot();
+	 *		const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
+	 *		const lastCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] );
+	 *
+	 *		const tableSelection = editor.plugins.get( 'TableSelection' );
+	 *		tableSelection.setCellSelection( firstCell, lastCell );
+	 *
+	 * @param {module:engine/model/element~Element} anchorCell
+	 * @param {module:engine/model/element~Element} targetCell
+	 */
+	setCellSelection( anchorCell, targetCell ) {
+		const cellsToSelect = this._getCellsToSelect( anchorCell, targetCell );
+
+		this.editor.model.change( writer => {
+			writer.setSelection(
+				cellsToSelect.cells.map( cell => writer.createRangeOn( cell ) ),
+				{ backward: cellsToSelect.backward }
+			);
+		} );
+	}
+
+	/**
+	 * Returns the focus cell from the current selection.
+	 *
+	 * @returns {module:engine/model/element~Element}
+	 */
+	getFocusCell() {
+		const selection = this.editor.model.document.selection;
+		const focusCellRange = [ ...selection.getRanges() ].pop();
+		const element = focusCellRange.getContainedElement();
+
+		if ( element && element.is( 'tableCell' ) ) {
+			return element;
+		}
+
+		return null;
+	}
+
+	/**
+	 * Returns the anchor cell from the current selection.
+	 *
+	 * @returns {module:engine/model/element~Element} anchorCell
+	 */
+	getAnchorCell() {
+		const selection = this.editor.model.document.selection;
+		const anchorCellRange = first( selection.getRanges() );
+		const element = anchorCellRange.getContainedElement();
+
+		if ( element && element.is( 'tableCell' ) ) {
+			return element;
+		}
+
+		return null;
+	}
+
 	/**
 	 * Defines a selection converter which marks the selected cells with a specific class.
 	 *
@@ -171,7 +231,7 @@ export default class TableSelection extends Plugin {
 				return;
 			}
 
-			const anchorCell = getTableCellsContainingSelection( editor.model.document.selection )[ 0 ];
+			const anchorCell = this.getAnchorCell() || getTableCellsContainingSelection( editor.model.document.selection )[ 0 ];
 
 			if ( !anchorCell ) {
 				return;
@@ -181,7 +241,7 @@ export default class TableSelection extends Plugin {
 
 			if ( targetCell && haveSameTableParent( anchorCell, targetCell ) ) {
 				blockSelectionChange = true;
-				this._setCellSelection( anchorCell, targetCell );
+				this.setCellSelection( anchorCell, targetCell );
 
 				domEventData.preventDefault();
 			}
@@ -272,7 +332,7 @@ export default class TableSelection extends Plugin {
 			}
 
 			blockSelectionChange = true;
-			this._setCellSelection( anchorCell, targetCell );
+			this.setCellSelection( anchorCell, targetCell );
 
 			domEventData.preventDefault();
 		} );
@@ -363,25 +423,6 @@ export default class TableSelection extends Plugin {
 		} );
 	}
 
-	/**
-	 * Sets the model selection based on given anchor and target cells (can be the same cell).
-	 * Takes care of setting the backward flag.
-	 *
-	 * @protected
-	 * @param {module:engine/model/element~Element} anchorCell
-	 * @param {module:engine/model/element~Element} targetCell
-	 */
-	_setCellSelection( anchorCell, targetCell ) {
-		const cellsToSelect = this._getCellsToSelect( anchorCell, targetCell );
-
-		this.editor.model.change( writer => {
-			writer.setSelection(
-				cellsToSelect.cells.map( cell => writer.createRangeOn( cell ) ),
-				{ backward: cellsToSelect.backward }
-			);
-		} );
-	}
-
 	/**
 	 * Returns the model table cell element based on the target element of the passed DOM event.
 	 *
@@ -425,39 +466,31 @@ export default class TableSelection extends Plugin {
 		const startColumn = Math.min( startLocation.column, endLocation.column );
 		const endColumn = Math.max( startLocation.column, endLocation.column );
 
-		const cells = [];
+		// 2-dimensional array of the selected cells to ease flipping the order of cells for backward selections.
+		const selectionMap = new Array( endRow - startRow + 1 ).fill( null ).map( () => [] );
 
 		for ( const cellInfo of new TableWalker( findAncestor( 'table', anchorCell ), { startRow, endRow } ) ) {
 			if ( cellInfo.column >= startColumn && cellInfo.column <= endColumn ) {
-				cells.push( cellInfo.cell );
+				selectionMap[ cellInfo.row - startRow ].push( cellInfo.cell );
 			}
 		}
 
-		// A selection started in the bottom right corner and finished in the upper left corner
-		// should have it ranges returned in the reverse order.
-		// However, this is only half of the story because the selection could be made to the left (then the left cell is a focus)
-		// or to the right (then the right cell is a focus), while being a forward selection in both cases
-		// (because it was made from top to bottom). This isn't handled.
-		// This method would need to be smarter, but the ROI is microscopic, so I skip this.
-		if ( checkIsBackward( startLocation, endLocation ) ) {
-			return { cells: cells.reverse(), backward: true };
-		}
+		const flipVertically = endLocation.row < startLocation.row;
+		const flipHorizontally = endLocation.column < startLocation.column;
 
-		return { cells, backward: false };
-	}
-}
+		if ( flipVertically ) {
+			selectionMap.reverse();
+		}
 
-// Naively check whether the selection should be backward or not. See the longer explanation where this function is used.
-function checkIsBackward( startLocation, endLocation ) {
-	if ( startLocation.row > endLocation.row ) {
-		return true;
-	}
+		if ( flipHorizontally ) {
+			selectionMap.forEach( row => row.reverse() );
+		}
 
-	if ( startLocation.row == endLocation.row && startLocation.column > endLocation.column ) {
-		return true;
+		return {
+			cells: selectionMap.flat(),
+			backward: flipVertically || flipHorizontally
+		};
 	}
-
-	return false;
 }
 
 function haveSameTableParent( cellA, cellB ) {

+ 4 - 4
packages/ckeditor5-table/src/tableui.js

@@ -141,15 +141,15 @@ export default class TableUI extends Plugin {
 				{
 					type: 'button',
 					model: {
-						commandName: 'insertTableRowBelow',
-						label: t( 'Insert row below' )
+						commandName: 'insertTableRowAbove',
+						label: t( 'Insert row above' )
 					}
 				},
 				{
 					type: 'button',
 					model: {
-						commandName: 'insertTableRowAbove',
-						label: t( 'Insert row above' )
+						commandName: 'insertTableRowBelow',
+						label: t( 'Insert row below' )
 					}
 				},
 				{

+ 27 - 16
packages/ckeditor5-table/src/tableutils.js

@@ -289,17 +289,21 @@ export default class TableUtils extends Plugin {
 		const last = first + rowsToRemove - 1;
 		const batch = options.batch || 'default';
 
-		// Removing rows from table requires most calculations to be done prior to changing table structure.
+		model.enqueueChange( batch, writer => {
+			// Removing rows from the table require that most calculations to be done prior to changing table structure.
+			// Preparations must be done in the same enqueueChange callback to use the current table structure.
 
-		// 1. Preparation - get row-spanned cells that have to be modified after removing rows.
-		const { cellsToMove, cellsToTrim } = getCellsToMoveAndTrimOnRemoveRow( table, first, last );
+			// 1. Preparation - get row-spanned cells that have to be modified after removing rows.
+			const { cellsToMove, cellsToTrim } = getCellsToMoveAndTrimOnRemoveRow( table, first, last );
+
+			// 2. Execution
 
-		// 2. Execution
-		model.enqueueChange( batch, writer => {
 			// 2a. Move cells from removed rows that extends over a removed section - must be done before removing rows.
 			// This will fill any gaps in a rows below that previously were empty because of row-spanned cells.
-			const rowAfterRemovedSection = last + 1;
-			moveCellsToRow( table, rowAfterRemovedSection, cellsToMove, writer );
+			if ( cellsToMove.size ) {
+				const rowAfterRemovedSection = last + 1;
+				moveCellsToRow( table, rowAfterRemovedSection, cellsToMove, writer );
+			}
 
 			// 2b. Remove all required rows.
 			for ( let i = last; i >= first; i-- ) {
@@ -354,6 +358,8 @@ export default class TableUtils extends Plugin {
 		model.change( writer => {
 			adjustHeadingColumns( table, { first, last }, writer );
 
+			const emptyRowsIndexes = [];
+
 			for ( let removedColumnIndex = last; removedColumnIndex >= first; removedColumnIndex-- ) {
 				for ( const { cell, column, colspan } of [ ...new TableWalker( table ) ] ) {
 					// If colspaned cell overlaps removed column decrease its span.
@@ -368,11 +374,13 @@ export default class TableUtils extends Plugin {
 						// If the cell was the last one in the row, get rid of the entire row.
 						// https://github.com/ckeditor/ckeditor5/issues/6429
 						if ( !cellRow.childCount ) {
-							this.removeRows( table, { at: cellRow.index } );
+							emptyRowsIndexes.push( cellRow.index );
 						}
 					}
 				}
 			}
+
+			emptyRowsIndexes.reverse().forEach( row => this.removeRows( table, { at: row, batch: writer.batch } ) );
 		} );
 	}
 
@@ -753,17 +761,20 @@ function adjustHeadingColumns( table, removedColumnIndexes, writer ) {
 
 // Calculates a new heading rows value for removing rows from heading section.
 function updateHeadingRows( table, first, last, model, batch ) {
-	const headingRows = table.getAttribute( 'headingRows' ) || 0;
+	// Must be done after the changes in table structure (removing rows).
+	// Otherwise the downcast converter for headingRows attribute will fail.
+	// See https://github.com/ckeditor/ckeditor5/issues/6391.
+	//
+	// Must be completely wrapped in enqueueChange to get the current table state (after applying other enqueued changes).
+	model.enqueueChange( batch, writer => {
+		const headingRows = table.getAttribute( 'headingRows' ) || 0;
 
-	if ( first < headingRows ) {
-		const newRows = last < headingRows ? headingRows - ( last - first + 1 ) : first;
+		if ( first < headingRows ) {
+			const newRows = last < headingRows ? headingRows - ( last - first + 1 ) : first;
 
-		// Must be done after the changes in table structure (removing rows).
-		// Otherwise the downcast converter for headingRows attribute will fail. ckeditor/ckeditor5#6391.
-		model.enqueueChange( batch, writer => {
 			updateNumericAttribute( 'headingRows', newRows, table, writer, 0 );
-		} );
-	}
+		}
+	} );
 }
 
 // Finds cells that will be:

+ 64 - 11
packages/ckeditor5-table/src/ui/colorinputview.js

@@ -101,10 +101,20 @@ export default class ColorInputView extends View {
 		 * An instance of the input allowing the user to type a color value.
 		 *
 		 * @protected
-		 * @member {module:ui/dropdown/dropdown~DropdownView}
+		 * @member {module:ui/inputtext/inputtextview~InputTextView}
 		 */
 		this._inputView = this._createInputTextView( locale );
 
+		/**
+		 * The flag that indicates whether the user is still typing.
+		 * If set to true, it means that the text input field ({@link #_inputView}) still has the focus.
+		 * So, we should interrupt the user by replacing the input's value.
+		 *
+		 * @protected
+		 * @member {Boolean}
+		 */
+		this._stillTyping = false;
+
 		this.setTemplate( {
 			tag: 'div',
 			attributes: {
@@ -122,6 +132,8 @@ export default class ColorInputView extends View {
 				this._dropdownView
 			]
 		} );
+
+		this.on( 'change:value', ( evt, name, inputValue ) => this._setInputValue( inputValue ) );
 	}
 
 	/**
@@ -186,25 +198,42 @@ export default class ColorInputView extends View {
 	}
 
 	/**
-	 * Creates and configures the {@link #_inputView}.
+	 * Creates and configures an instance of {@link module:ui/inputtext/inputtextview~InputTextView}.
 	 *
 	 * @private
+	 * @returns {module:ui/inputtext/inputtextview~InputTextView} A configured instance to be set as {@link #_inputView}.
 	 */
 	_createInputTextView() {
 		const locale = this.locale;
-		const input = new InputTextView( locale );
+		const inputView = new InputTextView( locale );
+
+		inputView.extendTemplate( {
+			on: {
+				blur: inputView.bindTemplate.to( 'blur' )
+			}
+		} );
+
+		inputView.value = this.value;
+		inputView.bind( 'isReadOnly' ).to( this );
+		inputView.bind( 'hasError' ).to( this );
 
-		input.bind( 'value' ).to( this );
-		input.bind( 'isReadOnly' ).to( this );
-		input.bind( 'hasError' ).to( this );
+		inputView.on( 'input', () => {
+			const inputValue = inputView.element.value;
+			// Check if the value matches one of our defined colors' label.
+			const mappedColor = this.options.colorDefinitions.find( def => inputValue === def.label );
 
-		input.on( 'input', () => {
-			this.value = input.element.value;
+			this._stillTyping = true;
+			this.value = mappedColor && mappedColor.color || inputValue;
 		} );
 
-		input.delegate( 'input' ).to( this );
+		inputView.on( 'blur', () => {
+			this._stillTyping = false;
+			this._setInputValue( inputView.element.value );
+		} );
 
-		return input;
+		inputView.delegate( 'input' ).to( this );
+
+		return inputView;
 	}
 
 	/**
@@ -246,9 +275,33 @@ export default class ColorInputView extends View {
 			this._dropdownView.isOpen = false;
 			this.fire( 'input' );
 		} );
-
 		colorGrid.bind( 'selectedColor' ).to( this, 'value' );
 
 		return colorGrid;
 	}
+
+	/**
+	 * Sets {@link #_inputView}'s value property to the color value or color label,
+	 * if there is one and the user is not typing.
+	 *
+	 * Handles cases like:
+	 *
+	 * * Someone picks the color in the grid.
+	 * * The color is set from the plugin level.
+	 *
+	 * @private
+	 * @param {String} inputValue Color value to be set.
+	 */
+	_setInputValue( inputValue ) {
+		if ( !this._stillTyping ) {
+			// Check if the value matches one of our defined colors.
+			const mappedColor = this.options.colorDefinitions.find( def => inputValue === def.color );
+
+			if ( mappedColor ) {
+				this._inputView.value = mappedColor.label;
+			} else {
+				this._inputView.value = inputValue || '';
+			}
+		}
+	}
 }

+ 4 - 4
packages/ckeditor5-table/src/utils.js

@@ -139,7 +139,7 @@ export function getSelectionAffectedTableCells( selection ) {
 }
 
 /**
- * Returns an object with `first` and `last` row index contained in the given `tableCells`.
+ * Returns an object with the `first` and `last` row index contained in the given `tableCells`.
  *
  *		const selectedTableCells = getSelectedTableCells( editor.model.document.selection );
  *
@@ -148,7 +148,7 @@ export function getSelectionAffectedTableCells( selection ) {
  *		console.log( `Selected rows: ${ first } to ${ last }` );
  *
  * @param {Array.<module:engine/model/element~Element>} tableCells
- * @returns {Object} Returns an object with `first` and `last` table row indexes.
+ * @returns {Object} Returns an object with the `first` and `last` table row indexes.
  */
 export function getRowIndexes( tableCells ) {
 	const indexes = tableCells.map( cell => cell.parent.index );
@@ -157,7 +157,7 @@ export function getRowIndexes( tableCells ) {
 }
 
 /**
- * Returns an object with `first` and `last` column index contained in the given `tableCells`.
+ * Returns an object with the `first` and `last` column index contained in the given `tableCells`.
  *
  *		const selectedTableCells = getSelectedTableCells( editor.model.document.selection );
  *
@@ -166,7 +166,7 @@ export function getRowIndexes( tableCells ) {
  *		console.log( `Selected columns: ${ first } to ${ last }` );
  *
  * @param {Array.<module:engine/model/element~Element>} tableCells
- * @returns {Object} Returns an object with `first` and `last` table column indexes.
+ * @returns {Object} Returns an object with the `first` and `last` table column indexes.
  */
 export function getColumnIndexes( tableCells ) {
 	const table = findAncestor( 'table', tableCells[ 0 ] );

+ 348 - 0
packages/ckeditor5-table/tests/_utils-tests/table-ascii-art.js

@@ -0,0 +1,348 @@
+/**
+ * @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 { createTableAsciiArt, modelTable, prepareModelTableInput, prettyFormatModelTableInput } from '../_utils/utils';
+import TableEditing from '../../src/tableediting';
+
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+describe( 'table ascii-art and model helpers', () => {
+	let editor, model, modelRoot;
+
+	beforeEach( () => {
+		return VirtualTestEditor
+			.create( {
+				plugins: [ TableEditing, Paragraph ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+
+				model = editor.model;
+				modelRoot = model.document.getRoot();
+			} );
+	} );
+
+	afterEach( () => {
+		editor.destroy();
+	} );
+
+	describe( 'for the table with only one cell', () => {
+		let table, tableData;
+
+		beforeEach( () => {
+			tableData = [
+				[ '00' ]
+			];
+
+			setModelData( model, modelTable( tableData ) );
+
+			table = modelRoot.getChild( 0 );
+		} );
+
+		it( 'should create proper ascii-art', () => {
+			const asciiArt = createTableAsciiArt( model, table );
+
+			expect( asciiArt ).to.equal( [
+				'+----+',
+				'| 00 |',
+				'+----+'
+			].join( '\n' ) );
+		} );
+
+		it( 'should create proper tableData', () => {
+			const modelData = prepareModelTableInput( model, table );
+			const modelDataString = prettyFormatModelTableInput( modelData );
+
+			expect( modelData ).to.deep.equal( tableData );
+
+			assertSameCodeString( modelDataString,
+				`[
+					[ '00' ]
+				]`
+			);
+		} );
+	} );
+
+	describe( 'for the table containing only one row', () => {
+		let table, tableData;
+
+		beforeEach( () => {
+			tableData = [
+				[ '00', '01' ]
+			];
+
+			setModelData( model, modelTable( tableData ) );
+
+			table = modelRoot.getChild( 0 );
+		} );
+
+		it( 'should create proper ascii-art', () => {
+			const asciiArt = createTableAsciiArt( model, table );
+
+			expect( asciiArt ).to.equal( [
+				'+----+----+',
+				'| 00 | 01 |',
+				'+----+----+'
+			].join( '\n' ) );
+		} );
+
+		it( 'should create proper tableData', () => {
+			const modelData = prepareModelTableInput( model, table );
+			const modelDataString = prettyFormatModelTableInput( modelData );
+
+			expect( modelData ).to.deep.equal( tableData );
+
+			assertSameCodeString( modelDataString,
+				`[
+					[ '00', '01' ]
+				]`
+			);
+		} );
+	} );
+
+	describe( 'for the table containing only one column', () => {
+		let table, tableData;
+
+		beforeEach( () => {
+			tableData = [
+				[ '00' ],
+				[ '10' ]
+			];
+
+			setModelData( model, modelTable( tableData ) );
+
+			table = modelRoot.getChild( 0 );
+		} );
+
+		it( 'should create proper ascii-art', () => {
+			const asciiArt = createTableAsciiArt( model, table );
+
+			expect( asciiArt ).to.equal( [
+				'+----+',
+				'| 00 |',
+				'+----+',
+				'| 10 |',
+				'+----+'
+			].join( '\n' ) );
+		} );
+
+		it( 'should create proper tableData', () => {
+			const modelData = prepareModelTableInput( model, table );
+			const modelDataString = prettyFormatModelTableInput( modelData );
+
+			expect( modelData ).to.deep.equal( tableData );
+
+			assertSameCodeString( modelDataString,
+				`[
+					[ '00' ], 
+					[ '10' ]
+				]`
+			);
+		} );
+	} );
+
+	describe( 'for the table containing two rows and two columns', () => {
+		let table, tableData;
+
+		beforeEach( () => {
+			tableData = [
+				[ '00', '01' ],
+				[ '10', '11' ]
+			];
+
+			setModelData( model, modelTable( tableData ) );
+
+			table = modelRoot.getChild( 0 );
+		} );
+
+		it( 'should create proper ascii-art', () => {
+			const asciiArt = createTableAsciiArt( model, table );
+
+			expect( asciiArt ).to.equal( [
+				'+----+----+',
+				'| 00 | 01 |',
+				'+----+----+',
+				'| 10 | 11 |',
+				'+----+----+'
+			].join( '\n' ) );
+		} );
+
+		it( 'should create proper tableData', () => {
+			const modelData = prepareModelTableInput( model, table );
+			const modelDataString = prettyFormatModelTableInput( modelData );
+
+			expect( modelData ).to.deep.equal( tableData );
+
+			assertSameCodeString( modelDataString,
+				`[
+					[ '00', '01' ],
+					[ '10', '11' ]
+				]`
+			);
+		} );
+	} );
+
+	describe( 'for the table containing column and row-spanned cells', () => {
+		let table, tableData;
+
+		beforeEach( () => {
+			tableData = [
+				[ { contents: '00', colspan: 2, rowspan: 2 }, { contents: '02', rowspan: 2 }, '03' ],
+				[ '13' ],
+				[ { contents: '20', colspan: 2 }, { contents: '22', colspan: 2, rowspan: 2 } ],
+				[ '30', '31' ]
+			];
+
+			setModelData( model, modelTable( structuredClone( tableData ) ) );
+
+			table = modelRoot.getChild( 0 );
+		} );
+
+		it( 'should create proper ascii-art', () => {
+			const asciiArt = createTableAsciiArt( model, table );
+
+			expect( asciiArt ).to.equal( [
+				'+----+----+----+----+',
+				'| 00      | 02 | 03 |',
+				'+         +    +----+',
+				'|         |    | 13 |',
+				'+----+----+----+----+',
+				'| 20      | 22      |',
+				'+----+----+         +',
+				'| 30 | 31 |         |',
+				'+----+----+----+----+'
+			].join( '\n' ) );
+		} );
+
+		it( 'should create proper tableData', () => {
+			const modelData = prepareModelTableInput( model, table );
+			const modelDataString = prettyFormatModelTableInput( modelData );
+
+			expect( modelData ).to.deep.equal( tableData );
+
+			assertSameCodeString( modelDataString,
+				`[
+					[ { contents: '00', colspan: 2, rowspan: 2 }, { contents: '02', rowspan: 2 }, '03' ],
+					[ '13' ],
+					[ { contents: '20', colspan: 2 }, { contents: '22', colspan: 2, rowspan: 2 } ],
+					[ '30', '31' ]
+				]`
+			);
+		} );
+	} );
+
+	describe( 'for the table containing larger column and row-spanned cells', () => {
+		let table, tableData;
+
+		beforeEach( () => {
+			tableData = [
+				[ '00', { contents: '01', rowspan: 2 }, { contents: '02', rowspan: 3 }, { contents: '03', rowspan: 4 } ],
+				[ '10' ],
+				[ { contents: '20', colspan: 2 } ],
+				[ { contents: '30', colspan: 3 } ],
+				[ { contents: '40', colspan: 4 } ]
+			];
+
+			setModelData( model, modelTable( structuredClone( tableData ) ) );
+
+			table = modelRoot.getChild( 0 );
+		} );
+
+		it( 'should create proper ascii-art', () => {
+			const asciiArt = createTableAsciiArt( model, table );
+
+			expect( asciiArt ).to.equal( [
+				'+----+----+----+----+',
+				'| 00 | 01 | 02 | 03 |',
+				'+----+    +    +    +',
+				'| 10 |    |    |    |',
+				'+----+----+    +    +',
+				'| 20      |    |    |',
+				'+----+----+----+    +',
+				'| 30           |    |',
+				'+----+----+----+----+',
+				'| 40                |',
+				'+----+----+----+----+'
+			].join( '\n' ) );
+		} );
+
+		it( 'should create proper tableData', () => {
+			const modelData = prepareModelTableInput( model, table );
+			const modelDataString = prettyFormatModelTableInput( modelData );
+
+			expect( modelData ).to.deep.equal( tableData );
+
+			assertSameCodeString( modelDataString,
+				`[
+					[ '00', { contents: '01', rowspan: 2 }, { contents: '02', rowspan: 3 }, { contents: '03', rowspan: 4 } ],
+					[ '10' ],
+					[ { contents: '20', colspan: 2 } ],
+					[ { contents: '30', colspan: 3 } ],
+					[ { contents: '40', colspan: 4 } ]
+				]`
+			);
+		} );
+	} );
+
+	describe( 'with cells\' content not matching cell\'s coordinates', () => {
+		let table, tableData;
+
+		beforeEach( () => {
+			tableData = [
+				[ '', 'x' ],
+				[ '10', 'foobar' ]
+			];
+
+			setModelData( model, modelTable( tableData ) );
+
+			table = modelRoot.getChild( 0 );
+		} );
+
+		it( 'should create proper ascii-art', () => {
+			const asciiArt = createTableAsciiArt( model, table );
+
+			expect( asciiArt ).to.equal( [
+				'+----+----+',
+				'|    | x  |',
+				'+----+----+',
+				'| 10 | fo |',
+				'+----+----+'
+			].join( '\n' ) );
+		} );
+
+		it( 'should create proper tableData', () => {
+			const modelData = prepareModelTableInput( model, table );
+			const modelDataString = prettyFormatModelTableInput( modelData );
+
+			tableData = [
+				[ '', 'x' ],
+				[ '10', 'foobar' ]
+			];
+
+			expect( modelData ).to.deep.equal( tableData );
+
+			assertSameCodeString( modelDataString,
+				`[
+					[ '', 'x' ],
+					[ '10', 'foobar' ]
+				]`
+			);
+		} );
+	} );
+
+	function structuredClone( data ) {
+		return JSON.parse( JSON.stringify( data ) );
+	}
+
+	function assertSameCodeString( actual, expected ) {
+		expect( trimLines( actual ) ).to.equal( trimLines( expected ) );
+	}
+
+	function trimLines( string ) {
+		return string.replace( /^\s+|\s+$/gm, '' );
+	}
+} );

+ 151 - 0
packages/ckeditor5-table/tests/_utils/utils.js

@@ -14,6 +14,7 @@ import {
 import upcastTable, { upcastTableCell } from '../../src/converters/upcasttable';
 import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import TableWalker from '../../src/tablewalker';
 
 const WIDGET_TABLE_CELL_CLASS = 'ck-editor__editable ck-editor__nested-editable';
 const BORDER_REG_EXP = /[\s\S]+/;
@@ -488,3 +489,153 @@ function getClassToSet( attributes ) {
 		.sort()
 		.join( ' ' );
 }
+
+/**
+ * Returns ascii-art visualization of the table.
+ *
+ * @param {module:engine/model/model~Model} model The editor model.
+ * @param {module:engine/model/element~Element} table The table model element.
+ * @returns {String}
+ */
+export function createTableAsciiArt( model, table ) {
+	const tableMap = [ ...new TableWalker( table, { includeSpanned: true } ) ];
+
+	if ( !tableMap.length ) {
+		return '';
+	}
+
+	const { row: lastRow, column: lastColumn } = tableMap[ tableMap.length - 1 ];
+	const columns = lastColumn + 1;
+
+	const headingRows = parseInt( table.getAttribute( 'headingRows' ) ) || 0;
+	const headingColumns = parseInt( table.getAttribute( 'headingColumns' ) ) || 0;
+
+	let result = '';
+
+	for ( let row = 0; row <= lastRow; row++ ) {
+		let gridLine = '';
+		let contentLine = '';
+
+		for ( let column = 0; column <= lastColumn; column++ ) {
+			const cellInfo = tableMap[ row * columns + column ];
+
+			if ( cellInfo.rowspan > 1 || cellInfo.colspan > 1 ) {
+				for ( let subRow = row; subRow < row + cellInfo.rowspan; subRow++ ) {
+					for ( let subColumn = column; subColumn < column + cellInfo.colspan; subColumn++ ) {
+						const subCellInfo = tableMap[ subRow * columns + subColumn ];
+
+						subCellInfo.isColSpan = subColumn > column;
+						subCellInfo.isRowSpan = subRow > row;
+					}
+				}
+			}
+
+			gridLine += !cellInfo.isColSpan || !cellInfo.isRowSpan ? '+' : ' ';
+			gridLine += !cellInfo.isRowSpan ? '----' : '    ';
+
+			let contents = getElementPlainText( model, cellInfo.cell ).substring( 0, 2 );
+			contents += ' '.repeat( 2 - contents.length );
+
+			contentLine += !cellInfo.isColSpan ? '|' : ' ';
+			contentLine += !cellInfo.isColSpan && !cellInfo.isRowSpan ? ` ${ contents } ` : '    ';
+
+			if ( column == lastColumn ) {
+				gridLine += '+';
+				contentLine += '|';
+
+				if ( headingRows && row == headingRows ) {
+					gridLine += ' <-- heading rows';
+				}
+			}
+		}
+		result += gridLine + '\n';
+		result += contentLine + '\n';
+
+		if ( row == lastRow ) {
+			result += `+${ '----+'.repeat( columns ) }`;
+
+			if ( headingRows && row == headingRows - 1 ) {
+				result += ' <-- heading rows';
+			}
+
+			if ( headingColumns > 0 ) {
+				result += `\n${ '     '.repeat( headingColumns ) }^-- heading columns`;
+			}
+		}
+	}
+
+	return result;
+}
+
+/**
+ * Generates input data for `modelTable` helper method.
+ *
+ * @param {module:engine/model/model~Model} model The editor model.
+ * @param {module:engine/model/element~Element} table The table model element.
+ * @returns {Array.<Array.<String|Object>>}
+ */
+export function prepareModelTableInput( model, table ) {
+	const result = [];
+	let row = [];
+
+	for ( const cellInfo of new TableWalker( table, { includeSpanned: true } ) ) {
+		if ( cellInfo.column == 0 && cellInfo.row > 0 ) {
+			result.push( row );
+			row = [];
+		}
+
+		if ( cellInfo.isSpanned ) {
+			continue;
+		}
+
+		const contents = getElementPlainText( model, cellInfo.cell );
+
+		if ( cellInfo.colspan > 1 || cellInfo.rowspan > 1 ) {
+			row.push( {
+				contents,
+				...( cellInfo.colspan > 1 ? { colspan: cellInfo.colspan } : null ),
+				...( cellInfo.rowspan > 1 ? { rowspan: cellInfo.rowspan } : null )
+			} );
+		} else {
+			row.push( contents );
+		}
+	}
+
+	result.push( row );
+
+	return result;
+}
+
+/**
+ * Pretty formats `modelTable` input data.
+ *
+ * @param {Array.<Array.<String|Object>>} data
+ * @returns {String}
+ */
+export function prettyFormatModelTableInput( data ) {
+	const rowsStringified = data.map( row => {
+		const cellsStringified = row.map( cell => {
+			if ( typeof cell == 'string' ) {
+				return `'${ cell }'`;
+			}
+
+			const fieldsStringified = Object.entries( cell ).map( ( [ key, value ] ) => {
+				return `${ key }: ${ typeof value == 'string' ? `'${ value }'` : value }`;
+			} );
+
+			return `{ ${ fieldsStringified.join( ', ' ) } }`;
+		} );
+
+		return '\t[ ' + cellsStringified.join( ', ' ) + ' ]';
+	} );
+
+	return `[\n${ rowsStringified.join( ',\n' ) }\n]`;
+}
+
+// Returns all the text content from element.
+function getElementPlainText( model, element ) {
+	return [ ...model.createRangeIn( element ).getWalker() ]
+		.filter( ( { type } ) => type == 'text' )
+		.map( ( { item: { data } } ) => data )
+		.join( '' );
+}

+ 20 - 3
packages/ckeditor5-table/tests/commands/insertcolumncommand.js

@@ -4,6 +4,7 @@
  */
 
 import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import HorizontalLineEditing from '@ckeditor/ckeditor5-horizontal-line/src/horizontallineediting';
 import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
 import InsertColumnCommand from '../../src/commands/insertcolumncommand';
@@ -18,7 +19,7 @@ describe( 'InsertColumnCommand', () => {
 	beforeEach( () => {
 		return ModelTestEditor
 			.create( {
-				plugins: [ TableUtils, TableSelection ]
+				plugins: [ TableUtils, TableSelection, HorizontalLineEditing ]
 			} )
 			.then( newEditor => {
 				editor = newEditor;
@@ -100,7 +101,7 @@ describe( 'InsertColumnCommand', () => {
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
 
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 1, 1 ] )
 				);
@@ -181,6 +182,22 @@ describe( 'InsertColumnCommand', () => {
 					[ { colspan: 5, contents: '31' }, { colspan: 2, contents: '34' } ]
 				], { headingColumns: 5 } ) );
 			} );
+
+			it( 'should insert a column when a widget in the table cell is selected', () => {
+				setData( model, modelTable( [
+					[ '11', '12' ],
+					[ '21', '22' ],
+					[ '31', '[<horizontalLine></horizontalLine>]' ]
+				] ) );
+
+				command.execute();
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '11', '12', '' ],
+					[ '21', '22', '' ],
+					[ '31', '<horizontalLine></horizontalLine>', '' ]
+				] ) );
+			} );
 		} );
 	} );
 
@@ -251,7 +268,7 @@ describe( 'InsertColumnCommand', () => {
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
 
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 1, 1 ] )
 				);

+ 21 - 3
packages/ckeditor5-table/tests/commands/insertrowcommand.js

@@ -4,6 +4,7 @@
  */
 
 import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import HorizontalLineEditing from '@ckeditor/ckeditor5-horizontal-line/src/horizontallineediting';
 import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
 import InsertRowCommand from '../../src/commands/insertrowcommand';
@@ -18,7 +19,7 @@ describe( 'InsertRowCommand', () => {
 	beforeEach( () => {
 		return ModelTestEditor
 			.create( {
-				plugins: [ TableUtils, TableSelection ]
+				plugins: [ TableUtils, TableSelection, HorizontalLineEditing ]
 			} )
 			.then( newEditor => {
 				editor = newEditor;
@@ -193,7 +194,7 @@ describe( 'InsertRowCommand', () => {
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
 
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 1, 1 ] )
 				);
@@ -214,6 +215,23 @@ describe( 'InsertRowCommand', () => {
 					[ 0, 0 ]
 				] );
 			} );
+
+			it( 'should insert a row when a widget in the table cell is selected', () => {
+				setData( model, modelTable( [
+					[ '11', '12' ],
+					[ '21', '22' ],
+					[ '31', '[<horizontalLine></horizontalLine>]' ]
+				] ) );
+
+				command.execute();
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '11', '12' ],
+					[ '21', '22' ],
+					[ '31', '<horizontalLine></horizontalLine>' ],
+					[ '', '' ]
+				] ) );
+			} );
 		} );
 	} );
 
@@ -328,7 +346,7 @@ describe( 'InsertRowCommand', () => {
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
 
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 1, 1 ] )
 				);

+ 94 - 0
packages/ckeditor5-table/tests/commands/mergecellcommand.js

@@ -701,6 +701,53 @@ describe( 'MergeCellCommand', () => {
 					[ '40', '41' ]
 				] ) );
 			} );
+
+			it( 'should adjust heading rows if empty row was removed ', () => {
+				// +----+----+
+				// | 00 | 01 |
+				// +    +----+
+				// |    | 11 |
+				// +----+----+ <-- heading rows
+				// | 20 | 21 |
+				// +----+----+
+				setData( model, modelTable( [
+					[ { contents: '00', rowspan: 2 }, '[]01' ],
+					[ '11' ],
+					[ '20', '21' ]
+				], { headingRows: 2 } ) );
+
+				command.execute();
+
+				assertEqualMarkup( getData( model ), modelTable( [
+					[ '00', '<paragraph>[01</paragraph><paragraph>11]</paragraph>' ],
+					[ '20', '21' ]
+				], { headingRows: 1 } ) );
+			} );
+
+			it( 'should create one undo step (1 batch)', () => {
+				// +----+----+
+				// | 00 | 01 |
+				// +    +----+
+				// |    | 11 |
+				// +----+----+ <-- heading rows
+				// | 20 | 21 |
+				// +----+----+
+				setData( model, modelTable( [
+					[ { contents: '00', rowspan: 2 }, '[]01' ],
+					[ '11' ],
+					[ '20', '21' ]
+				], { headingRows: 2 } ) );
+
+				const createdBatches = new Set();
+
+				model.on( 'applyOperation', ( evt, [ operation ] ) => {
+					createdBatches.add( operation.batch );
+				} );
+
+				command.execute();
+
+				expect( createdBatches.size ).to.equal( 1 );
+			} );
 		} );
 	} );
 
@@ -959,6 +1006,53 @@ describe( 'MergeCellCommand', () => {
 					[ '40', '41' ]
 				] ) );
 			} );
+
+			it( 'should adjust heading rows if empty row was removed ', () => {
+				// +----+----+
+				// | 00 | 01 |
+				// +    +----+
+				// |    | 11 |
+				// +----+----+ <-- heading rows
+				// | 20 | 21 |
+				// +----+----+
+				setData( model, modelTable( [
+					[ { contents: '00', rowspan: 2 }, '01' ],
+					[ '[]11' ],
+					[ '20', '21' ]
+				], { headingRows: 2 } ) );
+
+				command.execute();
+
+				assertEqualMarkup( getData( model ), modelTable( [
+					[ '00', '<paragraph>[01</paragraph><paragraph>11]</paragraph>' ],
+					[ '20', '21' ]
+				], { headingRows: 1 } ) );
+			} );
+
+			it( 'should create one undo step (1 batch)', () => {
+				// +----+----+
+				// | 00 | 01 |
+				// +    +----+
+				// |    | 11 |
+				// +----+----+ <-- heading rows
+				// | 20 | 21 |
+				// +----+----+
+				setData( model, modelTable( [
+					[ { contents: '00', rowspan: 2 }, '01' ],
+					[ '[]11' ],
+					[ '20', '21' ]
+				], { headingRows: 2 } ) );
+
+				const createdBatches = new Set();
+
+				model.on( 'applyOperation', ( evt, [ operation ] ) => {
+					createdBatches.add( operation.batch );
+				} );
+
+				command.execute();
+
+				expect( createdBatches.size ).to.equal( 1 );
+			} );
 		} );
 	} );
 } );

+ 108 - 11
packages/ckeditor5-table/tests/commands/mergecellscommand.js

@@ -201,7 +201,7 @@ describe( 'MergeCellsCommand', () => {
 				[ '10', '11' ]
 			], { headingRows: 1 } ) );
 
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				root.getNodeByPath( [ 0, 0, 0 ] ),
 				root.getNodeByPath( [ 0, 1, 0 ] )
 			);
@@ -230,7 +230,7 @@ describe( 'MergeCellsCommand', () => {
 
 			const tableSelection = editor.plugins.get( TableSelection );
 			const modelRoot = model.document.getRoot();
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
 				modelRoot.getNodeByPath( [ 0, 12, 0 ] )
 			);
@@ -244,7 +244,7 @@ describe( 'MergeCellsCommand', () => {
 				[ '10', '11', '12', '13' ]
 			], { headingColumns: 2 } ) );
 
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				root.getNodeByPath( [ 0, 0, 0 ] ),
 				root.getNodeByPath( [ 0, 1, 1 ] )
 			);
@@ -258,7 +258,7 @@ describe( 'MergeCellsCommand', () => {
 				[ '10', '11', '12', '13' ]
 			], { headingColumns: 2 } ) );
 
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				root.getNodeByPath( [ 0, 0, 0 ] ),
 				root.getNodeByPath( [ 0, 1, 2 ] )
 			);
@@ -272,7 +272,7 @@ describe( 'MergeCellsCommand', () => {
 				[ '10', '11', '12', '13' ]
 			], { headingColumns: 2, headingRows: 1 } ) );
 
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				root.getNodeByPath( [ 0, 0, 0 ] ),
 				root.getNodeByPath( [ 0, 0, 1 ] )
 			);
@@ -286,7 +286,7 @@ describe( 'MergeCellsCommand', () => {
 				[ '10', '11', '12', '13' ]
 			], { headingColumns: 2, headingRows: 1 } ) );
 
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				root.getNodeByPath( [ 0, 0, 0 ] ),
 				root.getNodeByPath( [ 0, 0, 2 ] )
 			);
@@ -300,7 +300,7 @@ describe( 'MergeCellsCommand', () => {
 				[ '10', '11', '12', '13' ]
 			], { headingColumns: 2, headingRows: 1 } ) );
 
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				root.getNodeByPath( [ 0, 0, 0 ] ),
 				root.getNodeByPath( [ 0, 1, 2 ] )
 			);
@@ -315,7 +315,7 @@ describe( 'MergeCellsCommand', () => {
 				[ '[]00', '01' ]
 			] ) );
 
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				root.getNodeByPath( [ 0, 0, 0 ] ),
 				root.getNodeByPath( [ 0, 0, 1 ] )
 			);
@@ -334,7 +334,7 @@ describe( 'MergeCellsCommand', () => {
 				[ '20', '22' ]
 			] ) );
 
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				root.getNodeByPath( [ 0, 1, 0 ] ),
 				root.getNodeByPath( [ 0, 2, 1 ] )
 			);
@@ -358,7 +358,7 @@ describe( 'MergeCellsCommand', () => {
 				[ '20', '22' ]
 			] ) );
 
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				root.getNodeByPath( [ 0, 2, 1 ] ),
 				root.getNodeByPath( [ 0, 1, 0 ] )
 			);
@@ -383,7 +383,7 @@ describe( 'MergeCellsCommand', () => {
 				[ '30', '31', '32', '33' ]
 			] ) );
 
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				root.getNodeByPath( [ 0, 2, 1 ] ),
 				root.getNodeByPath( [ 0, 1, 2 ] )
 			);
@@ -514,6 +514,103 @@ describe( 'MergeCellsCommand', () => {
 				] ) );
 			} );
 
+			it( 'should decrease heading rows if some heading rows were removed', () => {
+				setData( model, modelTable( [
+					[ '00' ],
+					[ '10' ],
+					[ '20' ]
+				], { headingRows: 2 } ) );
+
+				selectNodes( [
+					[ 0, 0, 0 ],
+					[ 0, 1, 0 ]
+				] );
+
+				command.execute();
+
+				assertEqualMarkup( getData( model ), modelTable( [
+					[
+						'<paragraph>[00</paragraph><paragraph>10]</paragraph>'
+					],
+					[ '20' ]
+				], { headingRows: 1 } ) );
+			} );
+
+			it( 'should decrease heading rows if multiple heading rows were removed', () => {
+				// +----+----+
+				// | 00 | 01 |
+				// +    +----+
+				// |    | 11 |
+				// +----+----+
+				// | 20 | 21 |
+				// +----+----+
+				// | 30 | 31 |
+				// +    +----+
+				// |    | 41 |
+				// +----+----+ <-- heading rows
+				// | 50 | 51 |
+				// +----+----+
+				setData( model, modelTable( [
+					[ { contents: '00', rowspan: 2 }, '01' ],
+					[ '11' ],
+					[ '20', '21' ],
+					[ { contents: '30', rowspan: 2 }, '31' ],
+					[ '41' ],
+					[ '50', '51' ]
+				], { headingRows: 5 } ) );
+
+				selectNodes( [
+					[ 0, 0, 1 ],
+					[ 0, 1, 0 ],
+					[ 0, 2, 1 ],
+					[ 0, 3, 1 ],
+					[ 0, 4, 0 ]
+				] );
+
+				command.execute();
+
+				const contents = [ '[01', '11', '21', '31', '41]' ].map( content => `<paragraph>${ content }</paragraph>` ).join( '' );
+
+				// +----+----+
+				// | 00 | 01 |
+				// +----+    +
+				// | 20 |    |
+				// +----+    +
+				// | 30 |    |
+				// +----+----+ <-- heading rows
+				// | 50 | 51 |
+				// +----+----+
+				assertEqualMarkup( getData( model ), modelTable( [
+					[ '00', { contents, rowspan: 3 } ],
+					[ '20' ],
+					[ '30' ],
+					[ '50', '51' ]
+				], { headingRows: 3 } ) );
+			} );
+
+			it( 'should create one undo step (1 batch)', () => {
+				setData( model, modelTable( [
+					[ '00' ],
+					[ '10' ],
+					[ '20' ]
+				], { headingRows: 2 } ) );
+
+				selectNodes( [
+					[ 0, 0, 0 ],
+					[ 0, 1, 0 ]
+				] );
+
+				const createdBatches = new Set();
+
+				model.on( 'applyOperation', ( evt, [ operation ] ) => {
+					createdBatches.add( operation.batch );
+				} );
+
+				command.execute();
+
+				expect( createdBatches.size ).to.equal( 1 );
+			} );
+
 			it( 'should decrease rowspan if cell overlaps removed row', () => {
 				setData( model, modelTable( [
 					[ '00', { rowspan: 2, contents: '01' }, { rowspan: 3, contents: '02' } ],

+ 12 - 12
packages/ckeditor5-table/tests/commands/removecolumncommand.js

@@ -52,7 +52,7 @@ describe( 'RemoveColumnCommand', () => {
 
 			const tableSelection = editor.plugins.get( TableSelection );
 			const modelRoot = model.document.getRoot();
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 				modelRoot.getNodeByPath( [ 0, 0, 1 ] )
 			);
@@ -78,7 +78,7 @@ describe( 'RemoveColumnCommand', () => {
 
 			const tableSelection = editor.plugins.get( TableSelection );
 			const modelRoot = model.document.getRoot();
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 				modelRoot.getNodeByPath( [ 0, 0, 2 ] )
 			);
@@ -93,7 +93,7 @@ describe( 'RemoveColumnCommand', () => {
 
 			const tableSelection = editor.plugins.get( TableSelection );
 			const modelRoot = model.document.getRoot();
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 				modelRoot.getNodeByPath( [ 0, 0, 12 ] )
 			);
@@ -152,7 +152,7 @@ describe( 'RemoveColumnCommand', () => {
 
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 2, 0 ] )
 				);
@@ -177,7 +177,7 @@ describe( 'RemoveColumnCommand', () => {
 
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 1, 1 ] ),
 					modelRoot.getNodeByPath( [ 0, 2, 1 ] )
 				);
@@ -202,7 +202,7 @@ describe( 'RemoveColumnCommand', () => {
 
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 1, 1 ] ),
 					modelRoot.getNodeByPath( [ 0, 2, 1 ] )
 				);
@@ -227,7 +227,7 @@ describe( 'RemoveColumnCommand', () => {
 
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 1, 1 ] )
 				);
@@ -252,7 +252,7 @@ describe( 'RemoveColumnCommand', () => {
 
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 1, 1 ] ),
 					modelRoot.getNodeByPath( [ 0, 2, 2 ] )
 				);
@@ -277,7 +277,7 @@ describe( 'RemoveColumnCommand', () => {
 
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 2, 2 ] ),
 					modelRoot.getNodeByPath( [ 0, 1, 1 ] )
 				);
@@ -303,7 +303,7 @@ describe( 'RemoveColumnCommand', () => {
 
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 1, 1 ] ),
 					modelRoot.getNodeByPath( [ 0, 2, 2 ] )
 				);
@@ -327,7 +327,7 @@ describe( 'RemoveColumnCommand', () => {
 
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
 					modelRoot.getNodeByPath( [ 0, 1, 3 ] )
 				);
@@ -349,7 +349,7 @@ describe( 'RemoveColumnCommand', () => {
 
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 1, 1 ] )
 				);

+ 16 - 16
packages/ckeditor5-table/tests/commands/removerowcommand.js

@@ -47,7 +47,7 @@ describe( 'RemoveRowCommand', () => {
 
 			const tableSelection = editor.plugins.get( TableSelection );
 			const modelRoot = model.document.getRoot();
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 				modelRoot.getNodeByPath( [ 0, 0, 1 ] )
 			);
@@ -71,7 +71,7 @@ describe( 'RemoveRowCommand', () => {
 
 			const tableSelection = editor.plugins.get( TableSelection );
 			const modelRoot = model.document.getRoot();
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 				modelRoot.getNodeByPath( [ 0, 1, 0 ] )
 			);
@@ -95,7 +95,7 @@ describe( 'RemoveRowCommand', () => {
 
 			const tableSelection = editor.plugins.get( TableSelection );
 			const modelRoot = model.document.getRoot();
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 				modelRoot.getNodeByPath( [ 0, 2, 0 ] )
 			);
@@ -122,7 +122,7 @@ describe( 'RemoveRowCommand', () => {
 
 			const tableSelection = editor.plugins.get( TableSelection );
 			const modelRoot = model.document.getRoot();
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 				modelRoot.getNodeByPath( [ 0, 12, 0 ] )
 			);
@@ -158,7 +158,7 @@ describe( 'RemoveRowCommand', () => {
 
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 2, 0 ] )
 				);
@@ -181,7 +181,7 @@ describe( 'RemoveRowCommand', () => {
 
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 2, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 1, 0 ] )
 				);
@@ -204,7 +204,7 @@ describe( 'RemoveRowCommand', () => {
 
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 2, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 3, 0 ] )
 				);
@@ -227,7 +227,7 @@ describe( 'RemoveRowCommand', () => {
 
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 1, 0 ] )
 				);
@@ -250,7 +250,7 @@ describe( 'RemoveRowCommand', () => {
 
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 1, 0 ] )
 				);
@@ -275,7 +275,7 @@ describe( 'RemoveRowCommand', () => {
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
 
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 3, 0 ] )
 				);
@@ -303,7 +303,7 @@ describe( 'RemoveRowCommand', () => {
 
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 1, 0 ] )
 				);
@@ -324,7 +324,7 @@ describe( 'RemoveRowCommand', () => {
 
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 1, 0 ] )
 				);
@@ -354,7 +354,7 @@ describe( 'RemoveRowCommand', () => {
 
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 1, 0 ] )
 				);
@@ -385,7 +385,7 @@ describe( 'RemoveRowCommand', () => {
 
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 12, 0 ] )
 				);
@@ -410,7 +410,7 @@ describe( 'RemoveRowCommand', () => {
 
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 1, 1 ] )
 				);
@@ -431,7 +431,7 @@ describe( 'RemoveRowCommand', () => {
 
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
 					modelRoot.getNodeByPath( [ 0, 0, 0 ] )
 				);

+ 14 - 14
packages/ckeditor5-table/tests/commands/selectcolumncommand.js

@@ -48,7 +48,7 @@ describe( 'SelectColumnCommand', () => {
 				[ '20', '21' ]
 			] ) );
 
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 				modelRoot.getNodeByPath( [ 0, 0, 1 ] )
 			);
@@ -150,7 +150,7 @@ describe( 'SelectColumnCommand', () => {
 
 			it( 'should select only one column if only one cell is selected', () => {
 				// Selection in cell 10.
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 1, 0 ] )
 				);
@@ -180,7 +180,7 @@ describe( 'SelectColumnCommand', () => {
 
 			it( 'should not select col-spanned columns that start in other column', () => {
 				// Selection in cell 42.
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 4, 2 ] ),
 					modelRoot.getNodeByPath( [ 0, 4, 2 ] )
 				);
@@ -210,7 +210,7 @@ describe( 'SelectColumnCommand', () => {
 
 			it( 'should not select col-spanned columns that start in other column but include those that start in selected column', () => {
 				// Selection in cell 41.
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 4, 1 ] ),
 					modelRoot.getNodeByPath( [ 0, 4, 1 ] )
 				);
@@ -240,7 +240,7 @@ describe( 'SelectColumnCommand', () => {
 
 			it( 'should select properly for multiple not spanned cells selected', () => {
 				// Selection in cells 40 - 41.
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 4, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 4, 1 ] )
 				);
@@ -270,7 +270,7 @@ describe( 'SelectColumnCommand', () => {
 
 			it( 'should select properly for multiple cells selected including spanned one', () => {
 				// Selection in cells 31 - 33.
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 3, 1 ] ),
 					modelRoot.getNodeByPath( [ 0, 3, 2 ] )
 				);
@@ -309,7 +309,7 @@ describe( 'SelectColumnCommand', () => {
 			} );
 
 			it( 'should properly select middle columns', () => {
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
 					modelRoot.getNodeByPath( [ 0, 0, 2 ] )
 				);
@@ -324,7 +324,7 @@ describe( 'SelectColumnCommand', () => {
 			} );
 
 			it( 'should properly select middle columns in reversed order', () => {
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 2 ] ),
 					modelRoot.getNodeByPath( [ 0, 0, 1 ] )
 				);
@@ -339,7 +339,7 @@ describe( 'SelectColumnCommand', () => {
 			} );
 
 			it( 'should properly select tailing columns', () => {
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 2 ] ),
 					modelRoot.getNodeByPath( [ 0, 0, 3 ] )
 				);
@@ -354,7 +354,7 @@ describe( 'SelectColumnCommand', () => {
 			} );
 
 			it( 'should properly select beginning columns', () => {
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 0, 1 ] )
 				);
@@ -369,7 +369,7 @@ describe( 'SelectColumnCommand', () => {
 			} );
 
 			it( 'should properly select multiple columns from square selection', () => {
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
 					modelRoot.getNodeByPath( [ 0, 1, 2 ] )
 				);
@@ -390,7 +390,7 @@ describe( 'SelectColumnCommand', () => {
 					[ '20', '21', '22', '23' ]
 				], { headingRows: 1 } ) );
 
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 0, 1 ] )
 				);
@@ -413,7 +413,7 @@ describe( 'SelectColumnCommand', () => {
 					[ '20', '21', '22' ]
 				] ) );
 
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
 					modelRoot.getNodeByPath( [ 0, 2, 1 ] )
 				);
@@ -433,7 +433,7 @@ describe( 'SelectColumnCommand', () => {
 					[ '10', '11' ]
 				] ) );
 
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 0, 0 ] )
 				);

+ 15 - 15
packages/ckeditor5-table/tests/commands/selectrowcommand.js

@@ -48,7 +48,7 @@ describe( 'SelectRowCommand', () => {
 				[ '20', '21' ]
 			] ) );
 
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 				modelRoot.getNodeByPath( [ 0, 0, 1 ] )
 			);
@@ -147,7 +147,7 @@ describe( 'SelectRowCommand', () => {
 
 			it( 'should select only one row if only one cell is selected', () => {
 				// Selection in cell 01.
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
 					modelRoot.getNodeByPath( [ 0, 0, 1 ] )
 				);
@@ -175,7 +175,7 @@ describe( 'SelectRowCommand', () => {
 
 			it( 'should not select row-spanned rows that start in other row', () => {
 				// Selection in cell 24.
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 2, 1 ] ),
 					modelRoot.getNodeByPath( [ 0, 2, 1 ] )
 				);
@@ -203,7 +203,7 @@ describe( 'SelectRowCommand', () => {
 
 			it( 'should not select row-spanned rows that start in other row but include those that start in selected row', () => {
 				// Selection in cell 14.
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 1, 1 ] ),
 					modelRoot.getNodeByPath( [ 0, 1, 1 ] )
 				);
@@ -231,7 +231,7 @@ describe( 'SelectRowCommand', () => {
 
 			it( 'should select properly for multiple not spanned cells selected', () => {
 				// Selection in cells 04 - 14.
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 4 ] ),
 					modelRoot.getNodeByPath( [ 0, 1, 1 ] )
 				);
@@ -259,7 +259,7 @@ describe( 'SelectRowCommand', () => {
 
 			it( 'should select properly for multiple cells selected including spanned one', () => {
 				// Selection in cells 13 - 33.
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 3, 2 ] )
 				);
@@ -295,7 +295,7 @@ describe( 'SelectRowCommand', () => {
 					[ '30', '31' ]
 				] ) );
 
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 2, 0 ] )
 				);
@@ -318,7 +318,7 @@ describe( 'SelectRowCommand', () => {
 					[ '30', '31' ]
 				] ) );
 
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 2, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 1, 0 ] )
 				);
@@ -341,7 +341,7 @@ describe( 'SelectRowCommand', () => {
 					[ '30', '31' ]
 				] ) );
 
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 2, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 3, 0 ] )
 				);
@@ -364,7 +364,7 @@ describe( 'SelectRowCommand', () => {
 					[ '30', '31' ]
 				] ) );
 
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 1, 0 ] )
 				);
@@ -387,7 +387,7 @@ describe( 'SelectRowCommand', () => {
 					[ '30', '31', '32' ]
 				] ) );
 
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 2, 1 ] )
 				);
@@ -409,7 +409,7 @@ describe( 'SelectRowCommand', () => {
 					[ '20', '21' ]
 				], { headingColumns: 1 } ) );
 
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 1, 0 ] )
 				);
@@ -444,7 +444,7 @@ describe( 'SelectRowCommand', () => {
 
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 12, 0 ] )
 				);
@@ -479,7 +479,7 @@ describe( 'SelectRowCommand', () => {
 					[ '20', '21' ]
 				] ) );
 
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 1, 1 ] )
 				);
@@ -499,7 +499,7 @@ describe( 'SelectRowCommand', () => {
 					[ '10', '11' ]
 				] ) );
 
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
 					modelRoot.getNodeByPath( [ 0, 0, 0 ] )
 				);

+ 13 - 13
packages/ckeditor5-table/tests/commands/setheadercolumncommand.js

@@ -52,7 +52,7 @@ describe( 'SetHeaderColumnCommand', () => {
 
 			const tableSelection = editor.plugins.get( TableSelection );
 			const modelRoot = model.document.getRoot();
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 				modelRoot.getNodeByPath( [ 0, 0, 1 ] )
 			);
@@ -67,7 +67,7 @@ describe( 'SetHeaderColumnCommand', () => {
 
 			const tableSelection = editor.plugins.get( TableSelection );
 			const modelRoot = model.document.getRoot();
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 				modelRoot.getNodeByPath( [ 0, 0, 1 ] )
 			);
@@ -102,7 +102,7 @@ describe( 'SetHeaderColumnCommand', () => {
 
 			const tableSelection = editor.plugins.get( TableSelection );
 			const modelRoot = model.document.getRoot();
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 				modelRoot.getNodeByPath( [ 0, 0, 1 ] )
 			);
@@ -117,7 +117,7 @@ describe( 'SetHeaderColumnCommand', () => {
 
 			const tableSelection = editor.plugins.get( TableSelection );
 			const modelRoot = model.document.getRoot();
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
 				modelRoot.getNodeByPath( [ 0, 0, 0 ] )
 			);
@@ -141,7 +141,7 @@ describe( 'SetHeaderColumnCommand', () => {
 
 			const tableSelection = editor.plugins.get( TableSelection );
 			const modelRoot = model.document.getRoot();
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
 				modelRoot.getNodeByPath( [ 0, 0, 2 ] )
 			);
@@ -170,7 +170,7 @@ describe( 'SetHeaderColumnCommand', () => {
 
 			const tableSelection = editor.plugins.get( TableSelection );
 			const modelRoot = model.document.getRoot();
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
 				modelRoot.getNodeByPath( [ 0, 0, 1 ] )
 			);
@@ -207,7 +207,7 @@ describe( 'SetHeaderColumnCommand', () => {
 
 					const tableSelection = editor.plugins.get( TableSelection );
 					const modelRoot = model.document.getRoot();
-					tableSelection._setCellSelection(
+					tableSelection.setCellSelection(
 						modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
 						modelRoot.getNodeByPath( [ 0, 0, 2 ] )
 					);
@@ -233,7 +233,7 @@ describe( 'SetHeaderColumnCommand', () => {
 
 					const tableSelection = editor.plugins.get( TableSelection );
 					const modelRoot = model.document.getRoot();
-					tableSelection._setCellSelection(
+					tableSelection.setCellSelection(
 						modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
 						modelRoot.getNodeByPath( [ 0, 1, 1 ] )
 					);
@@ -260,7 +260,7 @@ describe( 'SetHeaderColumnCommand', () => {
 
 					const tableSelection = editor.plugins.get( TableSelection );
 					const modelRoot = model.document.getRoot();
-					tableSelection._setCellSelection(
+					tableSelection.setCellSelection(
 						modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
 						modelRoot.getNodeByPath( [ 0, 0, 2 ] )
 					);
@@ -287,7 +287,7 @@ describe( 'SetHeaderColumnCommand', () => {
 
 					const tableSelection = editor.plugins.get( TableSelection );
 					const modelRoot = model.document.getRoot();
-					tableSelection._setCellSelection(
+					tableSelection.setCellSelection(
 						modelRoot.getNodeByPath( [ 0, 0, 2 ] ),
 						modelRoot.getNodeByPath( [ 0, 0, 1 ] )
 					);
@@ -316,7 +316,7 @@ describe( 'SetHeaderColumnCommand', () => {
 
 					const tableSelection = editor.plugins.get( TableSelection );
 					const modelRoot = model.document.getRoot();
-					tableSelection._setCellSelection(
+					tableSelection.setCellSelection(
 						modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
 						modelRoot.getNodeByPath( [ 0, 0, 2 ] )
 					);
@@ -345,7 +345,7 @@ describe( 'SetHeaderColumnCommand', () => {
 
 					const tableSelection = editor.plugins.get( TableSelection );
 					const modelRoot = model.document.getRoot();
-					tableSelection._setCellSelection(
+					tableSelection.setCellSelection(
 						modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
 						modelRoot.getNodeByPath( [ 0, 0, 2 ] )
 					);
@@ -372,7 +372,7 @@ describe( 'SetHeaderColumnCommand', () => {
 
 					const tableSelection = editor.plugins.get( TableSelection );
 					const modelRoot = model.document.getRoot();
-					tableSelection._setCellSelection(
+					tableSelection.setCellSelection(
 						modelRoot.getNodeByPath( [ 0, 0, 2 ] ),
 						modelRoot.getNodeByPath( [ 0, 0, 13 ] )
 					);

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

@@ -51,7 +51,7 @@ describe( 'SetHeaderRowCommand', () => {
 
 			const tableSelection = editor.plugins.get( TableSelection );
 			const modelRoot = model.document.getRoot();
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 				modelRoot.getNodeByPath( [ 0, 0, 1 ] )
 			);
@@ -66,7 +66,7 @@ describe( 'SetHeaderRowCommand', () => {
 
 			const tableSelection = editor.plugins.get( TableSelection );
 			const modelRoot = model.document.getRoot();
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 				modelRoot.getNodeByPath( [ 0, 0, 1 ] )
 			);
@@ -120,7 +120,7 @@ describe( 'SetHeaderRowCommand', () => {
 
 			const tableSelection = editor.plugins.get( TableSelection );
 			const modelRoot = model.document.getRoot();
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
 				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
 			);
@@ -136,7 +136,7 @@ describe( 'SetHeaderRowCommand', () => {
 
 			const tableSelection = editor.plugins.get( TableSelection );
 			const modelRoot = model.document.getRoot();
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 1, 1 ] ),
 				modelRoot.getNodeByPath( [ 0, 0, 1 ] )
 			);
@@ -152,7 +152,7 @@ describe( 'SetHeaderRowCommand', () => {
 
 			const tableSelection = editor.plugins.get( TableSelection );
 			const modelRoot = model.document.getRoot();
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 				modelRoot.getNodeByPath( [ 0, 1, 0 ] )
 			);
@@ -254,7 +254,7 @@ describe( 'SetHeaderRowCommand', () => {
 
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 2, 0 ] )
 				);
@@ -288,7 +288,7 @@ describe( 'SetHeaderRowCommand', () => {
 
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 2, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 1, 0 ] )
 				);
@@ -322,7 +322,7 @@ describe( 'SetHeaderRowCommand', () => {
 
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 1, 1 ] ),
 					modelRoot.getNodeByPath( [ 0, 2, 2 ] )
 				);
@@ -367,7 +367,7 @@ describe( 'SetHeaderRowCommand', () => {
 
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 2, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 13, 0 ] )
 				);
@@ -414,7 +414,7 @@ describe( 'SetHeaderRowCommand', () => {
 
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 13, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 2, 1 ] )
 				);
@@ -450,7 +450,7 @@ describe( 'SetHeaderRowCommand', () => {
 
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 2, 0 ] )
 				);
@@ -486,7 +486,7 @@ describe( 'SetHeaderRowCommand', () => {
 
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 2, 0 ] )
 				);
@@ -524,7 +524,7 @@ describe( 'SetHeaderRowCommand', () => {
 
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 2, 0 ] )
 				);

+ 2 - 2
packages/ckeditor5-table/tests/commands/splitcellcommand.js

@@ -55,7 +55,7 @@ describe( 'SplitCellCommand', () => {
 
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 0, 0 ] )
 				);
@@ -70,7 +70,7 @@ describe( 'SplitCellCommand', () => {
 
 				const tableSelection = editor.plugins.get( TableSelection );
 				const modelRoot = model.document.getRoot();
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 0, 1 ] )
 				);

+ 76 - 7
packages/ckeditor5-table/tests/converters/table-cell-refresh-post-fixer.js

@@ -55,18 +55,16 @@ describe( 'Table cell refresh post-fixer', () => {
 		return editor.destroy();
 	} );
 
-	it( 'should rename <span> to <p> when adding more <paragraph> elements to the same table cell', () => {
+	it( 'should rename <span> to <p> when adding <paragraph> element to the same table cell', () => {
 		editor.setData( viewTable( [ [ '<p>00</p>' ] ] ) );
 
 		const table = root.getChild( 0 );
 
 		model.change( writer => {
 			const nodeByPath = table.getNodeByPath( [ 0, 0, 0 ] );
-
 			const paragraph = writer.createElement( 'paragraph' );
 
 			writer.insert( paragraph, nodeByPath, 'after' );
-
 			writer.setSelection( nodeByPath.nextSibling, 0 );
 		} );
 
@@ -76,18 +74,37 @@ describe( 'Table cell refresh post-fixer', () => {
 		sinon.assert.calledOnce( refreshItemSpy );
 	} );
 
-	it( 'should rename <span> to <p> on adding other block element to the same table cell', () => {
+	it( 'should rename <span> to <p> when adding more <paragraph> elements to the same table cell', () => {
 		editor.setData( viewTable( [ [ '<p>00</p>' ] ] ) );
 
 		const table = root.getChild( 0 );
 
 		model.change( writer => {
 			const nodeByPath = table.getNodeByPath( [ 0, 0, 0 ] );
+			const paragraph1 = writer.createElement( 'paragraph' );
+			const paragraph2 = writer.createElement( 'paragraph' );
 
-			const paragraph = writer.createElement( 'block' );
+			writer.insert( paragraph1, nodeByPath, 'after' );
+			writer.insert( paragraph2, nodeByPath, 'after' );
+			writer.setSelection( nodeByPath.nextSibling, 0 );
+		} );
 
-			writer.insert( paragraph, nodeByPath, 'after' );
+		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
+			[ '<p>00</p><p></p><p></p>' ]
+		], { asWidget: true } ) );
+		sinon.assert.calledOnce( refreshItemSpy );
+	} );
 
+	it( 'should rename <span> to <p> on adding other block element to the same table cell', () => {
+		editor.setData( viewTable( [ [ '<p>00</p>' ] ] ) );
+
+		const table = root.getChild( 0 );
+
+		model.change( writer => {
+			const nodeByPath = table.getNodeByPath( [ 0, 0, 0 ] );
+			const block = writer.createElement( 'block' );
+
+			writer.insert( block, nodeByPath, 'after' );
 			writer.setSelection( nodeByPath.nextSibling, 0 );
 		} );
 
@@ -97,6 +114,27 @@ describe( 'Table cell refresh post-fixer', () => {
 		sinon.assert.calledOnce( refreshItemSpy );
 	} );
 
+	it( 'should rename <span> to <p> on adding multiple other block elements to the same table cell', () => {
+		editor.setData( viewTable( [ [ '<p>00</p>' ] ] ) );
+
+		const table = root.getChild( 0 );
+
+		model.change( writer => {
+			const nodeByPath = table.getNodeByPath( [ 0, 0, 0 ] );
+			const block1 = writer.createElement( 'block' );
+			const block2 = writer.createElement( 'block' );
+
+			writer.insert( block1, nodeByPath, 'after' );
+			writer.insert( block2, nodeByPath, 'after' );
+			writer.setSelection( nodeByPath.nextSibling, 0 );
+		} );
+
+		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
+			[ '<p>00</p><div></div><div></div>' ]
+		], { asWidget: true } ) );
+		sinon.assert.calledOnce( refreshItemSpy );
+	} );
+
 	it( 'should properly rename the same element on consecutive changes', () => {
 		editor.setData( viewTable( [ [ '<p>00</p>' ] ] ) );
 
@@ -140,7 +178,7 @@ describe( 'Table cell refresh post-fixer', () => {
 		sinon.assert.calledOnce( refreshItemSpy );
 	} );
 
-	it( 'should rename <p> to <span> when removing all but one paragraph inside table cell', () => {
+	it( 'should rename <p> to <span> when removing one of two paragraphs inside table cell', () => {
 		editor.setData( viewTable( [ [ '<p>00</p><p>foo</p>' ] ] ) );
 
 		const table = root.getChild( 0 );
@@ -155,6 +193,22 @@ describe( 'Table cell refresh post-fixer', () => {
 		sinon.assert.calledOnce( refreshItemSpy );
 	} );
 
+	it( 'should rename <p> to <span> when removing all but one paragraph inside table cell', () => {
+		editor.setData( viewTable( [ [ '<p>00</p><p>foo</p><p>bar</p>' ] ] ) );
+
+		const table = root.getChild( 0 );
+
+		model.change( writer => {
+			writer.remove( table.getNodeByPath( [ 0, 0, 1 ] ) );
+			writer.remove( table.getNodeByPath( [ 0, 0, 1 ] ) );
+		} );
+
+		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
+			[ '00' ]
+		], { asWidget: true } ) );
+		sinon.assert.calledOnce( refreshItemSpy );
+	} );
+
 	it( 'should rename <p> to <span> when removing attribute from <paragraph>', () => {
 		editor.setData( '<table><tr><td><p foo="bar">00</p></td></tr></table>' );
 
@@ -250,6 +304,21 @@ describe( 'Table cell refresh post-fixer', () => {
 		sinon.assert.notCalled( refreshItemSpy );
 	} );
 
+	it( 'should do nothing on adding <paragraph> to existing paragraphs', () => {
+		editor.setData( viewTable( [ [ '<p>a</p><p>b</p>' ] ] ) );
+
+		const table = root.getChild( 0 );
+
+		model.change( writer => {
+			writer.insertElement( 'paragraph', table.getNodeByPath( [ 0, 0, 1 ] ), 'after' );
+		} );
+
+		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
+			[ '<p>a</p><p>b</p><p></p>' ]
+		], { asWidget: true } ) );
+		sinon.assert.notCalled( refreshItemSpy );
+	} );
+
 	it( 'should do nothing when setting attribute on block item other then <paragraph>', () => {
 		editor.setData( viewTable( [ [ '<div>foo</div>' ] ] ) );
 

+ 19 - 0
packages/ckeditor5-table/tests/converters/table-layout-post-fixer.js

@@ -89,6 +89,25 @@ describe( 'Table layout post-fixer', () => {
 			] ) );
 		} );
 
+		it( 'should remove empty rows', () => {
+			const parsed = parse( modelTable( [
+				[ '00', '01' ],
+				[ ],
+				[ '20', '21', '22' ],
+				[ ]
+			] ), model.schema );
+
+			model.change( writer => {
+				writer.remove( writer.createRangeIn( root ) );
+				writer.insert( parsed, root );
+			} );
+
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+				[ '00', '01', '' ],
+				[ '20', '21', '22' ]
+			] ) );
+		} );
+
 		it( 'should fix the wrong rowspan attribute of a table cell inside the header', () => {
 			const parsed = parse( modelTable( [
 				[ { rowspan: 2, contents: '00' }, { rowspan: 3, contents: '01' }, '02' ],

+ 26 - 0
packages/ckeditor5-table/tests/converters/upcasttable.js

@@ -180,6 +180,32 @@ describe( 'upcastTable()', () => {
 		);
 	} );
 
+	it( 'should create valid table model from all empty rows', () => {
+		editor.setData(
+			'<table>' +
+				'<tr></tr>' +
+				'<tr></tr>' +
+			'</table>'
+		);
+
+		expectModel(
+			'<table><tableRow><tableCell><paragraph></paragraph></tableCell></tableRow></table>'
+		);
+	} );
+
+	it( 'should skip empty table rows', () => {
+		editor.setData(
+			'<table>' +
+				'<tr></tr>' +
+				'<tr><td>bar</td></tr>' +
+			'</table>'
+		);
+
+		expectModel(
+			'<table><tableRow><tableCell><paragraph>bar</paragraph></tableCell></tableRow></table>'
+		);
+	} );
+
 	it( 'should skip unknown table children', () => {
 		editor.setData(
 			'<table>' +

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

@@ -0,0 +1,53 @@
+<style>
+	body {
+		font-family: Helvetica, Arial, sans-serif;
+		font-size: 14px;
+	}
+	textarea#model-data {
+		white-space: pre;
+		font-family: monospace;
+		display: block;
+		width: 100%;
+		height: 100px;
+		box-sizing: border-box;
+		margin: 10px 0;
+	}
+	pre,code {
+		font-size: 11px;
+		font-family: Menlo, Consolas, Lucida Console, Courier New, dejavu sans mono, monospace;
+	}
+	.diff-add {
+		color: hsl( 120, 70%, 35% );
+	}
+	.diff-del {
+		color: hsl( 0, 80%, 45% );
+	}
+	#input-status {
+		color: hsl( 0, 90%, 50% );
+	}
+</style>
+
+<div style="margin-bottom: 10px;">
+	<label for="model-data">Table data as expected by <a href="https://github.com/ckeditor/ckeditor5-table/blob/1004f9106110be9de125825afd491a1618b71271/tests/_utils/utils.js#L48">modelTable</a> helper function:</label>
+	<textarea id="model-data">
+[
+	[ '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' ]
+]
+	</textarea>
+
+	<button type="button" id="clear-content">Clear editor</button>
+	<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>
+
+	<span id="input-status"></span>
+</div>
+
+<div id="editor">
+</div>
+
+<pre id="ascii-art"></pre>

+ 133 - 0
packages/ckeditor5-table/tests/manual/tablemocking.js

@@ -0,0 +1,133 @@
+/**
+ * @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 { createTableAsciiArt, modelTable, prepareModelTableInput, prettyFormatModelTableInput } from '../_utils/utils';
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import { diffString } from 'json-diff';
+import { debounce } from 'lodash-es';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import TableWalker from '../../src/tablewalker';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ ArticlePluginSet ],
+		toolbar: [
+			'insertTable', 'undo', 'redo'
+		],
+		table: {
+			contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells' ]
+		}
+	} )
+	.then( editor => {
+		window.editor = editor;
+
+		const asciiOut = document.getElementById( 'ascii-art' );
+		const modelData = document.getElementById( 'model-data' );
+
+		document.getElementById( 'clear-content' ).addEventListener( 'click', () => {
+			editor.setData( '' );
+		} );
+
+		document.getElementById( 'set-model-data' ).addEventListener( 'click', () => {
+			updateInputStatus();
+
+			const inputModelData = parseModelData( modelData.value );
+			setModelData( editor.model, inputModelData ? modelTable( inputModelData ) : '' );
+		} );
+
+		document.getElementById( 'get-model-data' ).addEventListener( 'click', () => {
+			updateInputStatus();
+
+			const table = findTable( editor );
+			modelData.value = table ? prettyFormatModelTableInput( prepareModelTableInput( editor.model, table ) ) : '';
+
+			updateAsciiAndDiff();
+		} );
+
+		document.getElementById( 'renumber-cells' ).addEventListener( 'click', () => {
+			const table = findTable( editor );
+
+			if ( !table ) {
+				return;
+			}
+
+			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 );
+				}
+			} );
+
+			updateAsciiAndDiff();
+		} );
+
+		editor.model.document.on( 'change:data', debounce( () => updateAsciiAndDiff(), 100 ) );
+		updateAsciiAndDiff();
+
+		function updateAsciiAndDiff() {
+			const table = findTable( editor );
+
+			if ( !table ) {
+				asciiOut.innerText = '-- table not found --';
+				return;
+			}
+
+			const inputModelData = parseModelData( modelData.value );
+			const currentModelData = prepareModelTableInput( editor.model, table );
+
+			const diffOutput = inputModelData ? diffString( inputModelData, currentModelData, {
+				theme: {
+					' ': string => string,
+					'+': string => `<span class="diff-add">${ string }</span>`,
+					'-': string => `<span class="diff-del">${ string }</span>`
+				}
+			} ) : '-- no input --';
+
+			asciiOut.innerHTML = createTableAsciiArt( editor.model, table ) + '\n\n' +
+				'Diff: input vs post-fixed model:\n' + ( diffOutput ? diffOutput : '-- no differences --' );
+		}
+
+		function findTable( editor ) {
+			const range = editor.model.createRangeIn( editor.model.document.getRoot() );
+
+			for ( const element of range.getItems() ) {
+				if ( element.is( 'table' ) ) {
+					return element;
+				}
+			}
+
+			return null;
+		}
+
+		function parseModelData( string ) {
+			if ( !string.trim() ) {
+				return null;
+			}
+
+			const jsonString = string
+				.replace( /'/g, '"' )
+				.replace( /([a-z0-9$_]+)\s*:/gi, '"$1":' );
+
+			try {
+				return JSON.parse( jsonString );
+			} catch ( error ) {
+				updateInputStatus( error.message );
+			}
+
+			return null;
+		}
+
+		function updateInputStatus( message = '' ) {
+			document.getElementById( 'input-status' ).innerText = message;
+		}
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 26 - 0
packages/ckeditor5-table/tests/manual/tablemocking.md

@@ -0,0 +1,26 @@
+### Table mocking tools
+
+Helper tools for preparing test cases like this:
+ 
+```javascript
+// +----+----+----+----+----+
+// | 00 | 01 | 02 | 03 | 04 |
+// +----+----+----+----+----+
+// | 10 | 11      | 13 | 14 |
+// +----+         +    +----+
+// | 20 |         |    | 24 |
+// +----+----+----+----+----+
+// | 30 | 31      | 33 | 34 |
+// +----+----+----+----+----+
+// | 40 | 41 | 42 | 43 | 44 |
+// +----+----+----+----+----+
+setModelData( model, modelTable( [
+    [ '00', '01', '02', '03', '04' ],
+    [ '10', { contents: '11', colspan: 2, rowspan: 2 }, { contents: '13', rowspan: 2 }, '14' ],
+    [ '20', '24' ],
+    [ '30', { contents: '31', colspan: 2 }, '33', '34' ],
+    [ '40', '41', '42', '43', '44' ]
+] ) );
+```
+
+**Note:** Cell content is ignored while generating ASCII-art and `modelTableData`.  

+ 22 - 0
packages/ckeditor5-table/tests/tablecellproperties/tablecellpropertiesui.js

@@ -121,6 +121,28 @@ describe( 'table cell properties', () => {
 					tableCellPropertiesButton.fire( 'execute' );
 					sinon.assert.calledOnce( spy );
 				} );
+
+				it( 'should be disabled if all of the table cell properties commands are disabled', () => {
+					[
+						'tableCellBorderStyle',
+						'tableCellBorderColor',
+						'tableCellBorderWidth',
+						'tableCellWidth',
+						'tableCellHeight',
+						'tableCellPadding',
+						'tableCellBackgroundColor',
+						'tableCellHorizontalAlignment',
+						'tableCellVerticalAlignment'
+					].forEach( command => {
+						editor.commands.get( command ).isEnabled = false;
+					} );
+
+					expect( tableCellPropertiesButton.isEnabled ).to.be.false;
+
+					editor.commands.get( 'tableCellBackgroundColor' ).isEnabled = true;
+
+					expect( tableCellPropertiesButton.isEnabled ).to.be.true;
+				} );
 			} );
 		} );
 

+ 14 - 14
packages/ckeditor5-table/tests/tableclipboard.js

@@ -56,7 +56,7 @@ describe( 'table clipboard', () => {
 			it( 'should copy selected table cells as a standalone table', () => {
 				const preventDefaultSpy = sinon.spy();
 
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
 					modelRoot.getNodeByPath( [ 0, 1, 2 ] )
 				);
@@ -81,7 +81,7 @@ describe( 'table clipboard', () => {
 					[ '20', '21', '22' ]
 				] ) );
 
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 2, 1 ] )
 				);
@@ -100,7 +100,7 @@ describe( 'table clipboard', () => {
 					[ '20', '21', '22' ]
 				] ) );
 
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 2, 1 ] )
 				);
@@ -119,7 +119,7 @@ describe( 'table clipboard', () => {
 					[ '20', '21', '22' ]
 				] ) );
 
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 1, 2 ] )
 				);
@@ -137,7 +137,7 @@ describe( 'table clipboard', () => {
 					[ '20', '22' ]
 				] ) );
 
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 1, 1 ] )
 				);
@@ -155,7 +155,7 @@ describe( 'table clipboard', () => {
 					[ '20', '21', '22' ]
 				] ) );
 
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
 					modelRoot.getNodeByPath( [ 0, 2, 2 ] )
 				);
@@ -174,7 +174,7 @@ describe( 'table clipboard', () => {
 					[ '20', '21', '22' ]
 				] ) );
 
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 2, 2 ] )
 				);
@@ -223,7 +223,7 @@ describe( 'table clipboard', () => {
 					[ '80', '82', '83', '84', '85', '87', '88' ]
 				] ) );
 
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 2, 1 ] ),
 					modelRoot.getNodeByPath( [ 0, 7, 6 ] )
 				);
@@ -247,7 +247,7 @@ describe( 'table clipboard', () => {
 					[ '40', '41', '42', '43', '44' ]
 				], { headingRows: 3, headingColumns: 2 } ) );
 
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 1, 1 ] ),
 					modelRoot.getNodeByPath( [ 0, 3, 3 ] )
 				);
@@ -268,7 +268,7 @@ describe( 'table clipboard', () => {
 					[ '40', '41', '42', '43', '44' ]
 				], { headingRows: 3, headingColumns: 2 } ) );
 
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 3, 2 ] ),
 					modelRoot.getNodeByPath( [ 0, 4, 4 ] )
 				);
@@ -299,7 +299,7 @@ describe( 'table clipboard', () => {
 			} );
 
 			it( 'should be preventable', () => {
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 1, 1 ] )
 				);
@@ -323,7 +323,7 @@ describe( 'table clipboard', () => {
 
 				viewDocument.on( 'clipboardOutput', spy );
 
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 					modelRoot.getNodeByPath( [ 0, 1, 1 ] )
 				);
@@ -343,7 +343,7 @@ describe( 'table clipboard', () => {
 			it( 'should copy selected table cells as a standalone table', () => {
 				const preventDefaultSpy = sinon.spy();
 
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
 					modelRoot.getNodeByPath( [ 0, 1, 2 ] )
 				);
@@ -366,7 +366,7 @@ describe( 'table clipboard', () => {
 
 				editor.isReadOnly = true;
 
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
 					modelRoot.getNodeByPath( [ 0, 1, 2 ] )
 				);

文件差异内容过多而无法显示
+ 688 - 209
packages/ckeditor5-table/tests/tablenavigation.js


+ 20 - 0
packages/ckeditor5-table/tests/tableproperties/tablepropertiesui.js

@@ -120,6 +120,26 @@ describe( 'table properties', () => {
 					tablePropertiesButton.fire( 'execute' );
 					sinon.assert.calledOnce( spy );
 				} );
+
+				it( 'should be disabled if all of the table properties commands are disabled', () => {
+					[
+						'tableBorderStyle',
+						'tableBorderColor',
+						'tableBorderWidth',
+						'tableBackgroundColor',
+						'tableWidth',
+						'tableHeight',
+						'tableAlignment'
+					].forEach( command => {
+						editor.commands.get( command ).isEnabled = false;
+					} );
+
+					expect( tablePropertiesButton.isEnabled ).to.be.false;
+
+					editor.commands.get( 'tableBackgroundColor' ).isEnabled = true;
+
+					expect( tablePropertiesButton.isEnabled ).to.be.true;
+				} );
 			} );
 		} );
 

+ 6 - 6
packages/ckeditor5-table/tests/tableselection-integration.js

@@ -40,7 +40,7 @@ describe( 'TableSelection - integration', () => {
 		} );
 
 		it( 'should clear contents of the selected table cells and put selection in last cell on backward delete', () => {
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
 			);
@@ -62,7 +62,7 @@ describe( 'TableSelection - integration', () => {
 		} );
 
 		it( 'should clear contents of the selected table cells and put selection in last cell on forward delete', () => {
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
 			);
@@ -113,7 +113,7 @@ describe( 'TableSelection - integration', () => {
 		} );
 
 		it( 'should clear contents of the selected table cells and put selection in last cell on user input', () => {
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
 			);
@@ -170,7 +170,7 @@ describe( 'TableSelection - integration', () => {
 		} );
 
 		it( 'allows pasting over multi-cell selection', () => {
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
 			);
@@ -192,7 +192,7 @@ describe( 'TableSelection - integration', () => {
 		} );
 
 		it( 'allows inserting a horizontal line over a multi-range selection', () => {
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
 			);
@@ -231,7 +231,7 @@ describe( 'TableSelection - integration', () => {
 		it( 'works with merge cells command', () => {
 			setModelData( editor.model, modelTable( [ [ '00', '01' ] ] ) );
 
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 				modelRoot.getNodeByPath( [ 0, 0, 1 ] )
 			);

+ 161 - 14
packages/ckeditor5-table/tests/tableselection.js

@@ -53,7 +53,7 @@ describe( 'table selection', () => {
 				const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
 				const lastCell = modelRoot.getNodeByPath( [ 0, 1, 1 ] );
 
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					firstCell,
 					lastCell
 				);
@@ -74,7 +74,7 @@ describe( 'table selection', () => {
 				const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
 				const lastCell = modelRoot.getNodeByPath( [ 0, 1, 1 ] );
 
-				tableSelection._setCellSelection(
+				tableSelection.setCellSelection(
 					firstCell,
 					lastCell
 				);
@@ -227,6 +227,39 @@ describe( 'table selection', () => {
 			expect( preventDefault.called ).to.equal( true );
 		} );
 
+		it( 'should use the anchor cell from the selection if possible', () => {
+			const preventDefault = sinon.spy();
+
+			const domEventDataMock = new DomEventData( view, {
+				shiftKey: true,
+				target: view.domConverter.mapViewToDom(
+					// figure > table > tbody > tr > td
+					viewDocument.getRoot().getChild( 0 ).getChild( 1 ).getChild( 0 ).getChild( 0 ).getChild( 2 )
+				),
+				preventDefault
+			} );
+
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 2, 1 ] )
+			);
+			assertSelectedCells( model, [
+				[ 0, 0, 0 ],
+				[ 1, 1, 0 ],
+				[ 1, 1, 0 ]
+			] );
+
+			viewDocument.fire( 'mousedown', domEventDataMock );
+
+			assertSelectedCells( model, [
+				[ 1, 1, 1 ],
+				[ 1, 1, 1 ],
+				[ 0, 0, 0 ]
+			] );
+
+			expect( preventDefault.called ).to.equal( true );
+		} );
+
 		it( 'should ignore `selectionChange` event when selecting cells', () => {
 			const consoleLog = sinon.stub( console, 'log' );
 			const preventDefault = sinon.spy();
@@ -585,7 +618,7 @@ describe( 'table selection', () => {
 			const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
 			const lastCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] );
 
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				firstCell,
 				lastCell
 			);
@@ -599,7 +632,7 @@ describe( 'table selection', () => {
 			const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
 			const lastCell = modelRoot.getNodeByPath( [ 0, 1, 1 ] );
 
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				firstCell,
 				lastCell
 			);
@@ -613,7 +646,7 @@ describe( 'table selection', () => {
 			const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
 			const lastCell = modelRoot.getNodeByPath( [ 0, 0, 2 ] );
 
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				firstCell,
 				lastCell
 			);
@@ -627,7 +660,7 @@ describe( 'table selection', () => {
 			const firstCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] );
 			const lastCell = modelRoot.getNodeByPath( [ 0, 2, 1 ] );
 
-			tableSelection._setCellSelection( firstCell, lastCell );
+			tableSelection.setCellSelection( firstCell, lastCell );
 
 			expect( tableSelection.getSelectedTableCells() ).to.deep.equal( [
 				firstCell, modelRoot.getNodeByPath( [ 0, 1, 1 ] ), lastCell
@@ -638,7 +671,7 @@ describe( 'table selection', () => {
 			const firstCell = modelRoot.getNodeByPath( [ 0, 0, 2 ] );
 			const lastCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] );
 
-			tableSelection._setCellSelection( firstCell, lastCell );
+			tableSelection.setCellSelection( firstCell, lastCell );
 
 			expect( tableSelection.getSelectedTableCells() ).to.deep.equal( [
 				lastCell, firstCell
@@ -667,7 +700,7 @@ describe( 'table selection', () => {
 		} );
 
 		it( 'should return document fragment for selected table cells', () => {
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
 			);
@@ -676,7 +709,7 @@ describe( 'table selection', () => {
 		} );
 
 		it( 'should return cells in the source order in case of forward selection', () => {
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
 			);
@@ -688,7 +721,7 @@ describe( 'table selection', () => {
 		} );
 
 		it( 'should return cells in the source order in case of backward selection', () => {
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 1, 1 ] ),
 				modelRoot.getNodeByPath( [ 0, 0, 0 ] )
 			);
@@ -719,7 +752,7 @@ describe( 'table selection', () => {
 		} );
 
 		it( 'should put selection in the last selected cell after removing content (backward delete)', () => {
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getChild( 0 ).getChild( 0 ).getChild( 0 ),
 				modelRoot.getChild( 0 ).getChild( 1 ).getChild( 1 )
 			);
@@ -734,7 +767,7 @@ describe( 'table selection', () => {
 		} );
 
 		it( 'should put selection in the last selected cell after removing content (forward delete)', () => {
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getChild( 0 ).getChild( 0 ).getChild( 0 ),
 				modelRoot.getChild( 0 ).getChild( 1 ).getChild( 1 )
 			);
@@ -749,7 +782,7 @@ describe( 'table selection', () => {
 		} );
 
 		it( 'should clear single cell if selected', () => {
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getChild( 0 ).getChild( 0 ).getChild( 0 ),
 				modelRoot.getChild( 0 ).getChild( 0 ).getChild( 0 ),
 			);
@@ -764,7 +797,7 @@ describe( 'table selection', () => {
 		} );
 
 		it( 'should work with document selection passed to Model#deleteContent()', () => {
-			tableSelection._setCellSelection(
+			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
 			);
@@ -831,6 +864,120 @@ describe( 'table selection', () => {
 		} );
 	} );
 
+	describe( 'getAnchorCell() and getFocusCell()', () => {
+		beforeEach( async () => {
+			editor = await createEditor();
+			model = editor.model;
+			modelRoot = model.document.getRoot();
+			tableSelection = editor.plugins.get( TableSelection );
+
+			setModelData( model, modelTable( [
+				[ '[]00', '01', '02' ],
+				[ '10', '11', '12' ],
+				[ '20', '21', '22' ]
+			] ) );
+		} );
+
+		it( 'should return null if no table cell is selected', () => {
+			expect( tableSelection.getAnchorCell() ).to.be.null;
+			expect( tableSelection.getFocusCell() ).to.be.null;
+		} );
+
+		it( 'getAnchorCell() should return the table cell from the first range in the selection', () => {
+			const anchorCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
+			const focusCell = modelRoot.getNodeByPath( [ 0, 1, 1 ] );
+
+			tableSelection.setCellSelection( anchorCell, focusCell );
+
+			expect( tableSelection.getAnchorCell() ).to.equal( anchorCell );
+		} );
+
+		it( 'getFocusCell() should return the table cell from the last range in the selection', () => {
+			const anchorCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
+			const focusCell = modelRoot.getNodeByPath( [ 0, 1, 1 ] );
+
+			tableSelection.setCellSelection( anchorCell, focusCell );
+
+			expect( tableSelection.getFocusCell() ).to.equal( focusCell );
+		} );
+	} );
+
+	describe( 'the selection ranges order', () => {
+		let selection, table;
+
+		beforeEach( async () => {
+			editor = await createEditor();
+			model = editor.model;
+			selection = model.document.selection;
+			modelRoot = model.document.getRoot();
+			tableSelection = editor.plugins.get( TableSelection );
+
+			setModelData( model, modelTable( [
+				[ '00', '01', '02' ],
+				[ '10', '11', '12' ],
+				[ '20', '21', '22' ]
+			] ) );
+
+			table = modelRoot.getChild( 0 );
+		} );
+
+		it( 'should be to below right', () => {
+			const anchorCell = table.getChild( 1 ).getChild( 1 );
+			const focusCell = table.getChild( 2 ).getChild( 2 );
+
+			tableSelection.setCellSelection( anchorCell, focusCell );
+
+			assertSelection( anchorCell, focusCell, 4 );
+			expect( tableSelection.getFocusCell() ).to.equal( focusCell );
+			expect( tableSelection.getAnchorCell() ).to.equal( anchorCell );
+			expect( selection.isBackward ).to.be.false;
+		} );
+
+		it( 'should be to below left', () => {
+			const anchorCell = table.getChild( 1 ).getChild( 1 );
+			const focusCell = table.getChild( 2 ).getChild( 0 );
+
+			tableSelection.setCellSelection( anchorCell, focusCell );
+
+			assertSelection( anchorCell, focusCell, 4 );
+			expect( tableSelection.getFocusCell() ).to.equal( focusCell );
+			expect( tableSelection.getAnchorCell() ).to.equal( anchorCell );
+			expect( selection.isBackward ).to.be.true;
+		} );
+
+		it( 'should be to above left', () => {
+			const anchorCell = table.getChild( 1 ).getChild( 1 );
+			const focusCell = table.getChild( 0 ).getChild( 0 );
+
+			tableSelection.setCellSelection( anchorCell, focusCell );
+
+			assertSelection( anchorCell, focusCell, 4 );
+			expect( tableSelection.getFocusCell() ).to.equal( focusCell );
+			expect( tableSelection.getAnchorCell() ).to.equal( anchorCell );
+			expect( selection.isBackward ).to.be.true;
+		} );
+
+		it( 'should be to above right', () => {
+			const anchorCell = table.getChild( 1 ).getChild( 1 );
+			const focusCell = table.getChild( 0 ).getChild( 2 );
+
+			tableSelection.setCellSelection( anchorCell, focusCell );
+
+			assertSelection( anchorCell, focusCell, 4 );
+			expect( tableSelection.getFocusCell() ).to.equal( focusCell );
+			expect( tableSelection.getAnchorCell() ).to.equal( anchorCell );
+			expect( selection.isBackward ).to.be.true;
+		} );
+
+		function assertSelection( anchorCell, focusCell, count ) {
+			const cells = [ ...selection.getRanges() ].map( range => range.getContainedElement() );
+
+			expect( selection.rangeCount ).to.equal( count );
+			expect( cells[ 0 ] ).to.equal( anchorCell );
+			expect( cells[ cells.length - 1 ] ).to.equal( focusCell );
+		}
+	} );
+
 	function createEditor() {
 		return ClassicTestEditor.create( editorElement, {
 			plugins: [ TableEditing, TableSelection, Paragraph, Typing ]

+ 3 - 3
packages/ckeditor5-table/tests/tableui.js

@@ -139,7 +139,7 @@ describe( 'TableUI', () => {
 			const labels = listView.items.map( item => item instanceof ListSeparatorView ? '|' : item.children.first.label );
 
 			expect( labels ).to.deep.equal(
-				[ 'Header row', '|', 'Insert row below', 'Insert row above', 'Delete row', 'Select row' ]
+				[ 'Header row', '|', 'Insert row above', 'Insert row below', 'Delete row', 'Select row' ]
 			);
 		} );
 
@@ -170,12 +170,12 @@ describe( 'TableUI', () => {
 			expect( items.first.children.first.isEnabled ).to.be.false;
 			expect( dropdown.buttonView.isEnabled ).to.be.true;
 
-			insertRowBelowCommand.isEnabled = false;
+			insertRowAboveCommand.isEnabled = false;
 
 			expect( items.get( 2 ).children.first.isEnabled ).to.be.false;
 			expect( dropdown.buttonView.isEnabled ).to.be.true;
 
-			insertRowAboveCommand.isEnabled = false;
+			insertRowBelowCommand.isEnabled = false;
 			expect( items.get( 3 ).children.first.isEnabled ).to.be.false;
 			expect( dropdown.buttonView.isEnabled ).to.be.true;
 

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

@@ -1368,6 +1368,20 @@ describe( 'TableUtils', () => {
 					[ '21', '22' ]
 				] ) );
 			} );
+
+			it( 'should remove the column properly when multiple rows should be removed (because of to row-spans)', () => {
+				setData( model, modelTable( [
+					[ '00', { contents: '01', rowspan: 3 }, { contents: '02', rowspan: 3 } ],
+					[ '10' ],
+					[ '20' ]
+				] ) );
+
+				tableUtils.removeColumns( root.getNodeByPath( [ 0 ] ), { at: 0 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '01', '02' ]
+				] ) );
+			} );
 		} );
 
 		describe( 'multiple columns', () => {

+ 87 - 3
packages/ckeditor5-table/tests/ui/colorinputview.js

@@ -3,6 +3,8 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
 
+/* global Event */
+
 import ColorInputView from '../../src/ui/colorinputview';
 import InputTextView from '@ckeditor/ckeditor5-ui/src/inputtext/inputtextview';
 import ColorGridView from '@ckeditor/ckeditor5-ui/src/colorgrid/colorgridview';
@@ -138,7 +140,7 @@ describe( 'ColorInputView', () => {
 				expect( colorGridView ).to.be.instanceOf( ColorGridView );
 			} );
 
-			it( 'should set #value upon #execute', () => {
+			it( 'should set ColorInputView#value upon ColorTileView#execute', () => {
 				expect( view.value ).to.equal( '' );
 
 				colorGridView.items.last.fire( 'execute' );
@@ -146,7 +148,15 @@ describe( 'ColorInputView', () => {
 				expect( view.value ).to.equal( 'rgb(0,0,255)' );
 			} );
 
-			it( 'should close the dropdown upon #execute', () => {
+			it( 'should set InputTextView#value to the selected color\'s label upon ColorTileView#execute', () => {
+				expect( inputView.value ).to.equal( '' );
+
+				colorGridView.items.last.fire( 'execute' );
+
+				expect( inputView.value ).to.equal( 'Blue' );
+			} );
+
+			it( 'should close the dropdown upon ColorTileView#execute', () => {
 				view._dropdownView.isOpen = true;
 
 				colorGridView.items.last.fire( 'execute' );
@@ -154,7 +164,7 @@ describe( 'ColorInputView', () => {
 				expect( view._dropdownView.isOpen ).to.be.false;
 			} );
 
-			it( 'should fire #input upon #execute', () => {
+			it( 'should fire the ColorInputView#input event upon ColorTileView#execute', () => {
 				const spy = sinon.spy( view, 'fire' );
 
 				colorGridView.items.last.fire( 'execute' );
@@ -208,6 +218,15 @@ describe( 'ColorInputView', () => {
 				expect( inputView.value ).to.equal( 'bar' );
 			} );
 
+			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)';
+				expect( inputView.value ).to.equal( 'Green' );
+
+				view.value = 'rgb(255,0,0)';
+				expect( inputView.value ).to.equal( 'Red' );
+			} );
+
 			it( 'should have #isReadOnly bound to the color input', () => {
 				view.isReadOnly = true;
 				expect( inputView.isReadOnly ).to.equal( true );
@@ -236,6 +255,71 @@ describe( 'ColorInputView', () => {
 				expect( view.value ).to.equal( 'bar' );
 			} );
 
+			it(
+				`when any defined color label is given as the text input #value (case-sensitive),
+				should set the color as #value on #input event`,
+				() => {
+					inputView.element.value = 'Red';
+					inputView.fire( 'input' );
+
+					expect( view.value ).to.equal( 'rgb(255,0,0)' );
+
+					inputView.element.value = 'Green';
+					inputView.fire( 'input' );
+
+					expect( view.value ).to.equal( 'rgb(0,255,0)' );
+
+					inputView.element.value = 'blue';
+					inputView.fire( 'input' );
+
+					expect( view.value ).to.equal( 'blue' );
+				}
+			);
+
+			it(
+				`when any defined color label is given as the text input #value (case-sensitive),
+				then a non-defined value is set to the color input,
+				the latter value should be set to text input`,
+				() => {
+					inputView.element.value = 'Red';
+					inputView.fire( 'input' );
+
+					expect( view.value ).to.equal( 'rgb(255,0,0)' );
+
+					view.value = 'rgb(0,0,255)';
+
+					expect( view.value ).to.equal( 'rgb(0,0,255)' );
+				}
+			);
+
+			it(
+				`when any defined color value is given as the text input #value (case-sensitive),
+				its value should be set to color and text inputs after input event`,
+				() => {
+					inputView.element.value = 'rgb(255,0,0)';
+					inputView.fire( 'input' );
+
+					expect( view.value ).to.equal( 'rgb(255,0,0)' );
+					expect( inputView.element.value ).to.equal( 'rgb(255,0,0)' );
+				}
+			);
+
+			it(
+				`when any defined color value is given as the text input #value (case-sensitive),
+				its label should be set to text inputs after blur event on input view input element`,
+				() => {
+					inputView.element.value = 'rgb(255,0,0)';
+
+					inputView.fire( 'input' );
+
+					expect( inputView.element.value ).to.equal( 'rgb(255,0,0)' );
+
+					inputView.element.dispatchEvent( new Event( 'blur' ) );
+
+					expect( inputView.element.value ).to.equal( 'Red' );
+				}
+			);
+
 			it( 'should have #input event delegated to the color input', () => {
 				const spy = sinon.spy();
 				view.on( 'input', spy );

+ 5 - 5
packages/ckeditor5-table/tests/utils.js

@@ -92,7 +92,7 @@ describe( 'table utils', () => {
 			const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
 			const lastCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] );
 
-			tableSelection._setCellSelection( firstCell, lastCell );
+			tableSelection.setCellSelection( firstCell, lastCell );
 
 			expect( getSelectedTableCells( selection ) ).to.have.ordered.members( [
 				firstCell, lastCell
@@ -103,7 +103,7 @@ describe( 'table utils', () => {
 			const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
 			const lastCell = modelRoot.getNodeByPath( [ 0, 1, 1 ] );
 
-			tableSelection._setCellSelection( firstCell, lastCell );
+			tableSelection.setCellSelection( firstCell, lastCell );
 
 			expect( getSelectedTableCells( selection ) ).to.have.ordered.members( [
 				firstCell,
@@ -117,7 +117,7 @@ describe( 'table utils', () => {
 			const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
 			const lastCell = modelRoot.getNodeByPath( [ 0, 0, 2 ] );
 
-			tableSelection._setCellSelection( firstCell, lastCell );
+			tableSelection.setCellSelection( firstCell, lastCell );
 
 			expect( getSelectedTableCells( selection ) ).to.have.ordered.members( [
 				firstCell,
@@ -130,7 +130,7 @@ describe( 'table utils', () => {
 			const firstCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] );
 			const lastCell = modelRoot.getNodeByPath( [ 0, 2, 1 ] );
 
-			tableSelection._setCellSelection( firstCell, lastCell );
+			tableSelection.setCellSelection( firstCell, lastCell );
 
 			expect( getSelectedTableCells( selection ) ).to.have.ordered.members( [
 				firstCell,
@@ -315,7 +315,7 @@ describe( 'table utils', () => {
 			const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
 			const lastCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] );
 
-			tableSelection._setCellSelection( firstCell, lastCell );
+			tableSelection.setCellSelection( firstCell, lastCell );
 
 			expect( Array.from( getSelectionAffectedTableCells( selection ) ) ).to.have.ordered.members( [
 				firstCell, lastCell

+ 21 - 8
packages/ckeditor5-ui/src/colorgrid/colorgridview.js

@@ -95,21 +95,21 @@ export default class ColorGridView extends View {
 			colorTile.isOn = colorTile.color === this.selectedColor;
 		} );
 
-		colorDefinitions.forEach( item => {
+		colorDefinitions.forEach( color => {
 			const colorTile = new ColorTileView();
 
 			colorTile.set( {
-				color: item.color,
-				label: item.label,
+				color: color.color,
+				label: color.label,
 				tooltip: true,
-				hasBorder: item.options.hasBorder
+				hasBorder: color.options.hasBorder
 			} );
 
 			colorTile.on( 'execute', () => {
 				this.fire( 'execute', {
-					value: item.color,
-					hasBorder: item.options.hasBorder,
-					label: item.label
+					value: color.color,
+					hasBorder: color.options.hasBorder,
+					label: color.label
 				} );
 			} );
 
@@ -175,13 +175,26 @@ export default class ColorGridView extends View {
 		// Start listening for the keystrokes coming from #element.
 		this.keystrokes.listenTo( this.element );
 	}
+
+	/**
+	 * Fired when the `ColorTileView` for the picked item is executed.
+	 *
+	 * @event execute
+	 * @param {Object} data Additional information about the event.
+	 * @param {String} data.value The value of the selected color
+	 * ({@link module:ui/colorgrid/colorgrid~ColorDefinition#color `color.color`}).
+	 * @param {Boolean} data.hasBorder The `hasBorder` property of the selected color
+	 * ({@link module:ui/colorgrid/colorgrid~ColorDefinition#options `color.options.hasBorder`}).
+	 * @param {String} data.Label The label of the selected color
+	 * ({@link module:ui/colorgrid/colorgrid~ColorDefinition#label `color.label`})
+	 */
 }
 
 /**
  * A color definition used to create a {@link module:ui/colorgrid/colortile~ColorTileView}.
  *
  *		{
- *			color: hsl(0, 0%, 75%),
+ *			color: 'hsl(0, 0%, 75%)',
  *			label: 'Light Grey',
  *			options: {
  *				hasBorder: true

+ 0 - 102
scripts/continuous-integration-run-tests.sh

@@ -1,102 +0,0 @@
-#!/bin/bash
-
-# @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
-# For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
-
-packages=$(ls packages -1 | sed -e 's#^ckeditor5\?-\(.\+\)$#\1#')
-
-errorOccured=0
-
-rm -r -f .nyc_output
-mkdir .nyc_output
-
-failedTestsPackages=""
-failedCoveragePackages=""
-
-RED='\033[0;31m'
-NC='\033[0m'
-
-# Travis functions inspired by https://github.com/travis-ci/travis-rubies/blob/a10ba31e3f508650204017332a608ef9bce2c733/build.sh.
-function travis_nanoseconds() {
-  local cmd="date"
-  local format="+%s%N"
-  local os=$(uname)
-
-  if hash gdate > /dev/null 2>&1; then
-    cmd="gdate" # use gdate if available
-  elif [[ "$os" = Darwin ]]; then
-    format="+%s000000000" # fallback to second precision on darwin (does not support %N)
-  fi
-
-  $cmd -u $format
-}
-
-travis_time_start() {
-  travis_timer_id=$(printf %08x $(( RANDOM * RANDOM )))
-  travis_start_time=$(travis_nanoseconds)
-  echo -en "travis_time:start:$travis_timer_id\r${ANSI_CLEAR}"
-}
-
-travis_time_finish() {
-  local result=$?
-  travis_end_time=$(travis_nanoseconds)
-  local duration=$(($travis_end_time-$travis_start_time))
-  echo -en "\ntravis_time:end:$travis_timer_id:start=$travis_start_time,finish=$travis_end_time,duration=$duration\r${ANSI_CLEAR}"
-  return $result
-}
-
-
-fold_start() {
-  echo -e "travis_fold:start:$1\033[33;1m$2\033[0m"
-  travis_time_start
-}
-
-fold_end() {
-  travis_time_finish
-  echo -e "\ntravis_fold:end:$1\n"
-
-}
-
-for package in $packages; do
-
-  fold_start "pkg-$package" "Testing $package${NC}"
-
-  yarn run test -f $package --reporter=dots --production --coverage
-
-  if [ "$?" -ne "0" ]; then
-    echo
-
-    echo -e "💥 ${RED}$package${NC} failed to pass unit tests 💥"
-    failedTestsPackages="$failedTestsPackages $package"
-    errorOccured=1
-  fi
-
-  cp coverage/*/coverage-final.json .nyc_output
-
-  npx nyc check-coverage --branches 100 --functions 100 --lines 100 --statements 100
-
-  if [ "$?" -ne "0" ]; then
-    echo -e "💥 ${RED}$package${NC} doesn't have required code coverage 💥"
-    failedCoveragePackages="$failedCoveragePackages $package"
-    errorOccured=1
-  fi
-
-  fold_end "pkg-$package"
-done;
-
-if [ "$errorOccured" -eq "1" ]; then
-  echo
-  echo "---"
-  echo
-
-  if ! [[ -z $failedTestsPackages ]]; then
-    echo -e "Following packages did not pass unit tests:${RED}$failedTestsPackages${NC}"
-  fi
-
-  if ! [[ -z $failedCoveragePackages ]]; then
-    echo -e "Following packages did not provide required code coverage:${RED}$failedCoveragePackages${NC}"
-  fi
-
-  echo
-  exit 1 # Will break the CI build
-fi

+ 152 - 0
scripts/continuous-integration-script.js

@@ -0,0 +1,152 @@
+#!/usr/bin/env node
+
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* eslint-env node */
+
+'use strict';
+
+const childProcess = require( 'child_process' );
+const crypto = require( 'crypto' );
+const fs = require( 'fs' );
+const path = require( 'path' );
+const glob = require( 'glob' );
+
+const failedChecks = {
+	dependency: new Set(),
+	unitTests: new Set(),
+	codeCoverage: new Set()
+};
+
+const RED = '\x1B[0;31m';
+const YELLOW = '\x1B[33;1m';
+const NO_COLOR = '\x1B[0m';
+
+const travis = {
+	_lastTimerId: null,
+	_lastStartTime: null,
+
+	foldStart( packageName, foldLabel ) {
+		console.log( `travis_fold:start:${ packageName }${ YELLOW }${ foldLabel }${ NO_COLOR }` );
+		this._timeStart();
+	},
+
+	foldEnd( packageName ) {
+		this._timeFinish();
+		console.log( `\ntravis_fold:end:${ packageName }\n` );
+	},
+
+	_timeStart() {
+		const nanoSeconds = process.hrtime.bigint();
+
+		this._lastTimerId = crypto.createHash( 'md5' ).update( nanoSeconds.toString() ).digest( 'hex' );
+		this._lastStartTime = nanoSeconds;
+
+		// Intentional direct write to stdout, to manually control EOL.
+		process.stdout.write( `travis_time:start:${ this._lastTimerId }\r\n` );
+	},
+
+	_timeFinish() {
+		const travisEndTime = process.hrtime.bigint();
+		const duration = travisEndTime - this._lastStartTime;
+
+		// Intentional direct write to stdout, to manually control EOL.
+		process.stdout.write( `\ntravis_time:end:${ this._lastTimerId }:start=${ this._lastStartTime },` +
+			`finish=${ travisEndTime },duration=${ duration }\r\n` );
+	}
+};
+
+childProcess.execSync( 'rm -r -f .nyc_output' );
+childProcess.execSync( 'mkdir .nyc_output' );
+childProcess.execSync( 'rm -r -f .out' );
+childProcess.execSync( 'mkdir .out' );
+
+const packages = childProcess.execSync( 'ls packages -1', {
+	encoding: 'utf8'
+} ).toString().trim().split( '\n' );
+
+for ( const fullPackageName of packages ) {
+	const simplePackageName = fullPackageName.replace( /^ckeditor5?-/, '' );
+	const foldLabelName = 'pkg-' + simplePackageName;
+
+	travis.foldStart( foldLabelName, `Testing ${ fullPackageName }${ NO_COLOR }` );
+
+	appendCoverageReport();
+
+	runSubprocess( 'npx', [ 'ckeditor5-dev-tests-check-dependencies', `packages/${ fullPackageName }` ], simplePackageName, 'dependency',
+		'have a dependency problem' );
+
+	const testArguments = [ 'run', 'test', '-f', simplePackageName, '--reporter=dots', '--production', '--coverage' ];
+	runSubprocess( 'yarn', testArguments, simplePackageName, 'unitTests', 'failed to pass unit tests' );
+
+	childProcess.execSync( 'cp coverage/*/coverage-final.json .nyc_output' );
+
+	const nyc = [ 'nyc', 'check-coverage', '--branches', '100', '--functions', '100', '--lines', '100', '--statements', '100' ];
+	runSubprocess( 'npx', nyc, simplePackageName, 'codeCoverage', 'doesn\'t have required code coverage' );
+
+	travis.foldEnd( foldLabelName );
+}
+
+console.log( 'Uploading combined code coverage report…' );
+childProcess.execSync( 'npx coveralls < .out/combined_lcov.info' );
+console.log( 'Done' );
+
+if ( Object.values( failedChecks ).some( checksSet => checksSet.size > 0 ) ) {
+	console.log( '\n---\n' );
+
+	showFailedCheck( 'dependency', 'The following packages have dependencies that are not included in its package.json' );
+	showFailedCheck( 'unitTests', 'The following packages did not pass unit tests' );
+	showFailedCheck( 'codeCoverage', 'The following packages did not provide required code coverage' );
+
+	process.exit( 1 ); // Exit code 1 will break the CI build.
+}
+
+/*
+ * @param {String} binaryName - Name of a CLI binary to be called.
+ * @param {String[]} cliArguments - An array of arguments to be passed to the `binaryName`.
+ * @param {String} packageName - Checked package name.
+ * @param {String} checkName - A key associated with the problem in the `failedChecks` dictionary.
+ * @param {String} failMessage - Message to be shown if check failed.
+ */
+function runSubprocess( binaryName, cliArguments, packageName, checkName, failMessage ) {
+	const subprocess = childProcess.spawnSync( binaryName, cliArguments, {
+		encoding: 'utf8',
+		shell: true
+	} );
+
+	console.log( subprocess.stdout );
+
+	if ( subprocess.stderr ) {
+		console.log( subprocess.stderr );
+	}
+
+	if ( subprocess.status !== 0 ) {
+		failedChecks.unitTests.add( packageName );
+		console.log( `💥 ${ RED }${ packageName }${ NO_COLOR } ` + failMessage + ' 💥' );
+	}
+}
+
+function showFailedCheck( checkKey, errorMessage ) {
+	const failedPackages = failedChecks[ checkKey ];
+
+	if ( failedPackages.size ) {
+		console.log( `${ errorMessage }: ${ RED }${ Array.from( failedPackages.values() ).join( ', ' ) }${ NO_COLOR }` );
+	}
+}
+
+function appendCoverageReport() {
+	// Appends coverage data to the combined code coverage info file. It's used because all the results
+	// needs to be uploaded at once (#6742).
+	const matches = glob.sync( 'coverage/*/lcov.info' );
+
+	matches.forEach( filePath => {
+		const buffer = fs.readFileSync( filePath );
+
+		fs.writeFileSync( [ '.out', 'combined_lcov.info' ].join( path.sep ), buffer, {
+			flag: 'as'
+		} );
+	} );
+}

+ 1 - 1
scripts/docs/build-content-styles.js

@@ -103,7 +103,7 @@ runWebpack( webpackConfig )
 			` * CKEditor 5 (v${ version }) content styles.`,
 			` * Generated on ${ new Date().toUTCString() }.`,
 			` * For more information, check out ${ DOCUMENTATION_URL }`,
-			' */\n\n',
+			' */\n\n'
 		].join( '\n' );
 
 		data += ':root {\n';

+ 2 - 2
tests/_utils/utils.js

@@ -37,7 +37,7 @@ export function renderPerformanceDataButtons( container, extraLabels ) {
 		'medium': 'medium (semantic)',
 		'large': 'long (semantic)',
 		'smallInlineCss': 'short (styled)',
-		'fullWebsitesStyled': 'full websites (styled)',
+		'fullWebsitesStyled': 'full websites (styled)'
 	};
 
 	if ( extraLabels ) {
@@ -66,7 +66,7 @@ export function getPerformanceData() {
 		medium: mediumFixture,
 		large: largeFixture,
 		smallInlineCss: smallInlineCssFixture,
-		fullWebsitesStyled: fullWebsitesStyledFixture,
+		fullWebsitesStyled: fullWebsitesStyledFixture
 	};
 }
 

+ 1 - 1
tests/manual/wproofreader.js

@@ -40,7 +40,7 @@ ClassicEditor
 			Indent,
 			Mention,
 			PasteFromOffice,
-			RemoveFormat,
+			RemoveFormat
 		],
 		toolbar: [
 			'heading', 'fontFamily', 'fontSize', 'fontColor', 'fontBackgroundColor', 'highlight', 'alignment', '|',

文件差异内容过多而无法显示
+ 354 - 285
yarn.lock


部分文件因为文件数量过多而无法显示