Browse Source

Merge branch 'master' into i/7360

# Conflicts:
#	packages/ckeditor5-table/src/converters/downcast.js
Kuba Niegowski 5 năm trước cách đây
mục cha
commit
8e4c919496
93 tập tin đã thay đổi với 4381 bổ sung3219 xóa
  1. 1 1
      CHANGELOG.md
  2. 1 1
      packages/ckeditor5-build-decoupled-document/tests/ckeditor.js
  3. 8 8
      packages/ckeditor5-engine/docs/framework/guides/deep-dive/schema.md
  4. 1 1
      packages/ckeditor5-engine/src/model/element.js
  5. 39 39
      packages/ckeditor5-engine/src/model/schema.js
  6. 297 47
      packages/ckeditor5-engine/src/model/utils/deletecontent.js
  7. 1 1
      packages/ckeditor5-engine/src/model/utils/getselectedcontent.js
  8. 12 0
      packages/ckeditor5-engine/src/model/writer.js
  9. 249 97
      packages/ckeditor5-engine/tests/model/utils/deletecontent.js
  10. 35 0
      packages/ckeditor5-engine/tests/model/writer.js
  11. 1 1
      packages/ckeditor5-font/src/fontfamily.js
  12. 1 1
      packages/ckeditor5-font/src/fontfamily/utils.js
  13. 6 4
      packages/ckeditor5-font/tests/fontfamily/fontfamilyediting.js
  14. 50 28
      packages/ckeditor5-font/tests/fontfamily/fontfamilyui.js
  15. 2 2
      packages/ckeditor5-font/tests/fontfamily/utils.js
  16. 2 1
      packages/ckeditor5-font/tests/integration.js
  17. 2 1
      packages/ckeditor5-font/tests/manual/font-family.md
  18. 2 2
      packages/ckeditor5-table/src/commands/insertcolumncommand.js
  19. 2 2
      packages/ckeditor5-table/src/commands/insertrowcommand.js
  20. 7 8
      packages/ckeditor5-table/src/commands/mergecellcommand.js
  21. 6 14
      packages/ckeditor5-table/src/commands/mergecellscommand.js
  22. 2 2
      packages/ckeditor5-table/src/commands/removecolumncommand.js
  23. 2 2
      packages/ckeditor5-table/src/commands/removerowcommand.js
  24. 2 2
      packages/ckeditor5-table/src/commands/selectcolumncommand.js
  25. 2 2
      packages/ckeditor5-table/src/commands/selectrowcommand.js
  26. 7 2
      packages/ckeditor5-table/src/commands/setheadercolumncommand.js
  27. 3 2
      packages/ckeditor5-table/src/commands/setheaderrowcommand.js
  28. 1 1
      packages/ckeditor5-table/src/commands/splitcellcommand.js
  29. 15 2
      packages/ckeditor5-table/src/converters/downcast.js
  30. 1 1
      packages/ckeditor5-table/src/converters/table-layout-post-fixer.js
  31. 1 1
      packages/ckeditor5-table/src/converters/upcasttable.js
  32. 5 3
      packages/ckeditor5-table/src/table.js
  33. 1 1
      packages/ckeditor5-table/src/tablecellproperties.js
  34. 1 1
      packages/ckeditor5-table/src/tablecellproperties/commands/tablecellbordercolorcommand.js
  35. 1 1
      packages/ckeditor5-table/src/tablecellproperties/commands/tablecellborderstylecommand.js
  36. 1 1
      packages/ckeditor5-table/src/tablecellproperties/commands/tablecellborderwidthcommand.js
  37. 1 1
      packages/ckeditor5-table/src/tablecellproperties/commands/tablecellheightcommand.js
  38. 1 1
      packages/ckeditor5-table/src/tablecellproperties/commands/tablecellpaddingcommand.js
  39. 1 1
      packages/ckeditor5-table/src/tablecellproperties/commands/tablecellpropertycommand.js
  40. 1 1
      packages/ckeditor5-table/src/tablecellproperties/commands/tablecellwidthcommand.js
  41. 4 5
      packages/ckeditor5-table/src/tablecellproperties/tablecellpropertiesui.js
  42. 1 1
      packages/ckeditor5-table/src/tablecellproperties/ui/tablecellpropertiesview.js
  43. 146 116
      packages/ckeditor5-table/src/tableclipboard.js
  44. 5 5
      packages/ckeditor5-table/src/tablekeyboard.js
  45. 223 0
      packages/ckeditor5-table/src/tablemouse.js
  46. 0 0
      packages/ckeditor5-table/src/tablemouse/mouseeventsobserver.js
  47. 1 1
      packages/ckeditor5-table/src/tableproperties.js
  48. 1 1
      packages/ckeditor5-table/src/tableproperties/commands/tablebordercolorcommand.js
  49. 1 1
      packages/ckeditor5-table/src/tableproperties/commands/tableborderstylecommand.js
  50. 1 1
      packages/ckeditor5-table/src/tableproperties/commands/tableborderwidthcommand.js
  51. 1 1
      packages/ckeditor5-table/src/tableproperties/commands/tableheightcommand.js
  52. 1 1
      packages/ckeditor5-table/src/tableproperties/commands/tablepropertycommand.js
  53. 1 1
      packages/ckeditor5-table/src/tableproperties/commands/tablewidthcommand.js
  54. 3 4
      packages/ckeditor5-table/src/tableproperties/tablepropertiesui.js
  55. 1 1
      packages/ckeditor5-table/src/tableproperties/ui/tablepropertiesview.js
  56. 4 177
      packages/ckeditor5-table/src/tableselection.js
  57. 0 143
      packages/ckeditor5-table/src/tableselection/croptable.js
  58. 1 1
      packages/ckeditor5-table/src/tabletoolbar.js
  59. 8 13
      packages/ckeditor5-table/src/tableutils.js
  60. 0 495
      packages/ckeditor5-table/src/utils.js
  61. 1 59
      packages/ckeditor5-table/src/utils/common.js
  62. 271 0
      packages/ckeditor5-table/src/utils/selection.js
  63. 440 0
      packages/ckeditor5-table/src/utils/structure.js
  64. 66 0
      packages/ckeditor5-table/src/utils/table-properties.js
  65. 145 0
      packages/ckeditor5-table/src/utils/ui/contextualballoon.js
  66. 2 136
      packages/ckeditor5-table/src/utils/ui/table-properties.js
  67. 51 0
      packages/ckeditor5-table/src/utils/ui/widget.js
  68. 62 18
      packages/ckeditor5-table/tests/commands/mergecellcommand.js
  69. 54 11
      packages/ckeditor5-table/tests/commands/mergecellscommand.js
  70. 13 0
      packages/ckeditor5-table/tests/commands/removerowcommand.js
  71. 0 93
      packages/ckeditor5-table/tests/commands/utils.js
  72. 12 2
      packages/ckeditor5-table/tests/manual/tablemocking.html
  73. 2 2
      packages/ckeditor5-table/tests/manual/tablemocking.js
  74. 6 3
      packages/ckeditor5-table/tests/table.js
  75. 1 1
      packages/ckeditor5-table/tests/tablecellproperties/tablecellpropertiesui.js
  76. 162 3
      packages/ckeditor5-table/tests/tableclipboard-paste.js
  77. 64 64
      packages/ckeditor5-table/tests/tablekeyboard.js
  78. 569 0
      packages/ckeditor5-table/tests/tablemouse.js
  79. 1 1
      packages/ckeditor5-table/tests/tableproperties/tablepropertiesui.js
  80. 8 3
      packages/ckeditor5-table/tests/tableselection-integration.js
  81. 7 506
      packages/ckeditor5-table/tests/tableselection.js
  82. 1 1
      packages/ckeditor5-table/tests/tableselection/mouseeventsobserver.js
  83. 0 567
      packages/ckeditor5-table/tests/ui/utils.js
  84. 0 450
      packages/ckeditor5-table/tests/utils.js
  85. 94 0
      packages/ckeditor5-table/tests/utils/common.js
  86. 360 0
      packages/ckeditor5-table/tests/utils/selection.js
  87. 119 0
      packages/ckeditor5-table/tests/utils/structure.js
  88. 268 0
      packages/ckeditor5-table/tests/utils/ui/contextualballoon.js
  89. 342 0
      packages/ckeditor5-table/tests/utils/ui/table-properties.js
  90. 1 0
      packages/ckeditor5-ui/package.json
  91. 29 1
      packages/ckeditor5-ui/src/toolbar/balloon/balloontoolbar.js
  92. 11 1
      packages/ckeditor5-ui/tests/toolbar/balloon/balloontoolbar.js
  93. 42 42
      yarn.lock

+ 1 - 1
CHANGELOG.md

@@ -44,7 +44,7 @@ But we did not stop there, as the release comes with several bug fixes, too:
 * [Potential editor crash when removing a column](https://github.com/ckeditor/ckeditor5/issues/6789).
 * [Potential editor crash when removing a column](https://github.com/ckeditor/ckeditor5/issues/6789).
 * [Editor crash when inserting a table row or column with another widget selected in the cell](https://github.com/ckeditor/ckeditor5/issues/6607).
 * [Editor crash when inserting a table row or column with another widget selected in the cell](https://github.com/ckeditor/ckeditor5/issues/6607).
 
 
-Blog post coming soon...
+Read more in the blog post: https://ckeditor.com/blog/CKEditor-5-v19.1.1-with-table-enhancements-typing-around-widgets-and-print-to-PDF-feature/
 
 
 ### Collaboration features
 ### Collaboration features
 
 

+ 1 - 1
packages/ckeditor5-build-decoupled-document/tests/ckeditor.js

@@ -200,7 +200,7 @@ describe( 'DecoupledEditor build', () => {
 
 
 				editor.setData( data );
 				editor.setData( data );
 				expect( editor.getData() ).to.equal( data );
 				expect( editor.getData() ).to.equal( data );
-				expect( editor.model.document.selection.getAttribute( 'fontFamily' ) ).to.equal( 'Georgia' );
+				expect( editor.model.document.selection.getAttribute( 'fontFamily' ) ).to.equal( 'Georgia, serif' );
 			} );
 			} );
 
 
 			it( 'font background color works', () => {
 			it( 'font background color works', () => {

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

@@ -46,7 +46,7 @@ While this would be incorrect:
 
 
 ## Defining additional semantics
 ## Defining additional semantics
 
 
-In addition to setting allowed structures, the schema can also define additional traits of model elements. By using the `is*` properties a feature author may declare how a certain element should be treated by other features and the engine.
+In addition to setting allowed structures, the schema can also define additional traits of model elements. By using the `is*` properties, a feature author may declare how a certain element should be treated by other features and the engine.
 
 
 ### Limit elements
 ### Limit elements
 
 
@@ -55,9 +55,9 @@ Consider a feature like an image caption. The caption text area should construct
 * A selection that starts inside should not end outside.
 * A selection that starts inside should not end outside.
 * Pressing <kbd>Backspace</kbd> or <kbd>Delete</kbd> should not delete the area. Pressing <kbd>Enter</kbd> should not split the area.
 * Pressing <kbd>Backspace</kbd> or <kbd>Delete</kbd> should not delete the area. Pressing <kbd>Enter</kbd> should not split the area.
 
 
-It should also act as a boundary for external actions. This is mostly enforced by a selection post-fixer that ensures that a selection that starts outside, should not end inside. That means that most actions will either apply to the "outside" of such an element or to a content inside it.
+It should also act as a boundary for external actions. This is mostly enforced by a selection post-fixer that ensures that a selection that starts outside, should not end inside. It means that most actions will either apply to the "outside" of such an element or to the content inside it.
 
 
-Taken these characteristics, the image caption should be defined as limit element by using the {@link module:engine/model/schema~SchemaItemDefinition#isLimit `isLimit`} property.
+Taken these characteristics, the image caption should be defined as a limit element by using the {@link module:engine/model/schema~SchemaItemDefinition#isLimit `isLimit`} property.
 
 
 ```js
 ```js
 schema.register( 'myCaption', {
 schema.register( 'myCaption', {
@@ -73,9 +73,9 @@ The engine and various features then check it via {@link module:engine/model/sch
 
 
 ### Object elements
 ### Object elements
 
 
-For the image caption as in the example above it does not make much sense to select the caption box, then copy or drag it somewhere else.
+For an image caption like in the example above it does not make much sense to select the caption box, then copy or drag it somewhere else.
 
 
-A caption without the image that it describes does not make much sense. However, the image is more self-sufficient. Most likely users should be able to select the entire image (with all its internals), then copy or move it around. {@link module:engine/model/schema~SchemaItemDefinition#isObject `isObject`} should be used to mark such behavior.
+A caption without the image that it describes makes little sense. However, the image is more self-sufficient. Most likely users should be able to select the entire image (with all its internals), then copy or move it around. The {@link module:engine/model/schema~SchemaItemDefinition#isObject `isObject`} property should be used to mark such behavior.
 
 
 ```js
 ```js
 schema.register( 'myImage', {
 schema.register( 'myImage', {
@@ -95,7 +95,7 @@ The {@link module:engine/model/schema~Schema#isObject `Schema#isObject()`} can l
 
 
 Generally speaking, content is usually made out of blocks like paragraphs, list items, images, headings, etc. All these elements should be marked as blocks by using {@link module:engine/model/schema~SchemaItemDefinition#isBlock `isBlock`}.
 Generally speaking, content is usually made out of blocks like paragraphs, list items, images, headings, etc. All these elements should be marked as blocks by using {@link module:engine/model/schema~SchemaItemDefinition#isBlock `isBlock`}.
 
 
-It is important to remember that a block should not allow another block inside. Container elements like `<blockQuote>` which can contain other block elements should not be marked as blocks.
+It is important to remember that a block should not allow another block inside. Container elements like `<blockQuote>`, which can contain other block elements, should not be marked as blocks.
 
 
 <info-box>
 <info-box>
 	There is also the `$block` generic item which has `isBlock` set to `true`. Most block type items will inherit from `$block` (through `inheritAllFrom`).
 	There is also the `$block` generic item which has `isBlock` set to `true`. Most block type items will inherit from `$block` (through `inheritAllFrom`).
@@ -103,11 +103,11 @@ It is important to remember that a block should not allow another block inside.
 
 
 ### Inline elements
 ### Inline elements
 
 
-In the editor, all HTML formatting elements such as `<strong>` or `<code>` are represented by text attributes. Therefore, inline model elements are not to be used for this scenarios.
+In the editor, all HTML formatting elements such as `<strong>` or `<code>` are represented by text attributes. Therefore, inline model elements are not supposed to be used for these scenarios.
 
 
 Currently, the {@link module:engine/model/schema~SchemaItemDefinition#isInline `isInline`} property is used for the `$text` token (so, text nodes) and elements such as `<softBreak>` or placeholder elements such as in the {@link framework/guides/tutorials/implementing-an-inline-widget Implementing an inline widget} tutorial.
 Currently, the {@link module:engine/model/schema~SchemaItemDefinition#isInline `isInline`} property is used for the `$text` token (so, text nodes) and elements such as `<softBreak>` or placeholder elements such as in the {@link framework/guides/tutorials/implementing-an-inline-widget Implementing an inline widget} tutorial.
 
 
-The support for inline elements in CKEditor 5 is so far limited to self-contained elements. This is &mdash; all elements marked with `isInline` should also be marked with `isObject`.
+The support for inline elements in CKEditor 5 is so far limited to self-contained elements. Because of this, all elements marked with `isInline` should also be marked with `isObject`.
 
 
 ## Generic items
 ## Generic items
 
 

+ 1 - 1
packages/ckeditor5-engine/src/model/element.js

@@ -232,7 +232,7 @@ export default class Element extends Node {
 
 
 	/**
 	/**
 	 * Creates a copy of this element and returns it. Created element has the same name and attributes as the original element.
 	 * Creates a copy of this element and returns it. Created element has the same name and attributes as the original element.
-	 * If clone is deep, the original element's children are also cloned. If not, then empty element is removed.
+	 * If clone is deep, the original element's children are also cloned. If not, then empty element is returned.
 	 *
 	 *
 	 * @protected
 	 * @protected
 	 * @param {Boolean} [deep=false] If set to `true` clones element and all its children recursively. When set to `false`,
 	 * @param {Boolean} [deep=false] If set to `true` clones element and all its children recursively. When set to `false`,

+ 39 - 39
packages/ckeditor5-engine/src/model/schema.js

@@ -26,9 +26,9 @@ import TreeWalker from './treewalker';
  *
  *
  * Read more about the schema in:
  * Read more about the schema in:
  *
  *
- * * {@glink framework/guides/architecture/editing-engine#schema "Schema"} section of the
- * {@glink framework/guides/architecture/editing-engine Introduction to the "Editing engine architecture"}.
- * * {@glink framework/guides/deep-dive/schema "Schema" deep dive} guide.
+ * * {@glink framework/guides/architecture/editing-engine#schema Schema} section of the
+ * {@glink framework/guides/architecture/editing-engine Introduction to the Editing engine architecture}.
+ * * {@glink framework/guides/deep-dive/schema Schema deep dive} guide.
  *
  *
  * @mixes module:utils/observablemixin~ObservableMixin
  * @mixes module:utils/observablemixin~ObservableMixin
  */
  */
@@ -171,7 +171,7 @@ export default class Schema {
 	}
 	}
 
 
 	/**
 	/**
-	 * Returns a definition of the given item or `undefined` if item is not registered.
+	 * Returns a definition of the given item or `undefined` if an item is not registered.
 	 *
 	 *
 	 * This method should normally be used for reflection purposes (e.g. defining a clone of a certain element,
 	 * This method should normally be used for reflection purposes (e.g. defining a clone of a certain element,
 	 * checking a list of all block elements, etc).
 	 * checking a list of all block elements, etc).
@@ -212,7 +212,7 @@ export default class Schema {
 
 
 	/**
 	/**
 	 * Returns `true` if the given item is defined to be
 	 * Returns `true` if the given item is defined to be
-	 * a block by {@link module:engine/model/schema~SchemaItemDefinition}'s `isBlock` property.
+	 * a block by the {@link module:engine/model/schema~SchemaItemDefinition}'s `isBlock` property.
 	 *
 	 *
 	 *		schema.isBlock( 'paragraph' ); // -> true
 	 *		schema.isBlock( 'paragraph' ); // -> true
 	 *		schema.isBlock( '$root' ); // -> false
 	 *		schema.isBlock( '$root' ); // -> false
@@ -220,7 +220,7 @@ export default class Schema {
 	 *		const paragraphElement = writer.createElement( 'paragraph' );
 	 *		const paragraphElement = writer.createElement( 'paragraph' );
 	 *		schema.isBlock( paragraphElement ); // -> true
 	 *		schema.isBlock( paragraphElement ); // -> true
 	 *
 	 *
-	 * See the {@glink framework/guides/deep-dive/schema#block-elements Block elements} section of the "Schema" deep dive}
+	 * See the {@glink framework/guides/deep-dive/schema#block-elements Block elements} section of the Schema deep dive}
 	 * guide for more details.
 	 * guide for more details.
 	 *
 	 *
 	 * @param {module:engine/model/item~Item|module:engine/model/schema~SchemaContextItem|String} item
 	 * @param {module:engine/model/item~Item|module:engine/model/schema~SchemaContextItem|String} item
@@ -234,18 +234,18 @@ export default class Schema {
 	/**
 	/**
 	 * Returns `true` if the given item should be treated as a limit element.
 	 * Returns `true` if the given item should be treated as a limit element.
 	 *
 	 *
-	 * It considers the item to be a limit element if its
+	 * It considers an item to be a limit element if its
 	 * {@link module:engine/model/schema~SchemaItemDefinition}'s
 	 * {@link module:engine/model/schema~SchemaItemDefinition}'s
 	 * {@link module:engine/model/schema~SchemaItemDefinition#isLimit `isLimit`} or
 	 * {@link module:engine/model/schema~SchemaItemDefinition#isLimit `isLimit`} or
 	 * {@link module:engine/model/schema~SchemaItemDefinition#isObject `isObject`} property
 	 * {@link module:engine/model/schema~SchemaItemDefinition#isObject `isObject`} property
-	 * were set to `true`.
+	 * was set to `true`.
 	 *
 	 *
 	 *		schema.isLimit( 'paragraph' ); // -> false
 	 *		schema.isLimit( 'paragraph' ); // -> false
 	 *		schema.isLimit( '$root' ); // -> true
 	 *		schema.isLimit( '$root' ); // -> true
 	 *		schema.isLimit( editor.model.document.getRoot() ); // -> true
 	 *		schema.isLimit( editor.model.document.getRoot() ); // -> true
 	 *		schema.isLimit( 'image' ); // -> true
 	 *		schema.isLimit( 'image' ); // -> true
 	 *
 	 *
-	 * See the {@glink framework/guides/deep-dive/schema#limit-elements Limit elements} section of the "Schema" deep dive}
+	 * See the {@glink framework/guides/deep-dive/schema#limit-elements Limit elements} section of the Schema deep dive}
 	 * guide for more details.
 	 * guide for more details.
 	 *
 	 *
 	 * @param {module:engine/model/item~Item|module:engine/model/schema~SchemaContextItem|String} item
 	 * @param {module:engine/model/item~Item|module:engine/model/schema~SchemaContextItem|String} item
@@ -261,12 +261,12 @@ export default class Schema {
 	}
 	}
 
 
 	/**
 	/**
-	 * Returns `true` if the given item is should be treated as an object element.
+	 * Returns `true` if the given item should be treated as an object element.
 	 *
 	 *
-	 * It considers the item to be an object element if its
+	 * It considers an item to be an object element if its
 	 * {@link module:engine/model/schema~SchemaItemDefinition}'s
 	 * {@link module:engine/model/schema~SchemaItemDefinition}'s
 	 * {@link module:engine/model/schema~SchemaItemDefinition#isObject `isObject`} property
 	 * {@link module:engine/model/schema~SchemaItemDefinition#isObject `isObject`} property
-	 * were set to `true`.
+	 * was set to `true`.
 	 *
 	 *
 	 *		schema.isObject( 'paragraph' ); // -> false
 	 *		schema.isObject( 'paragraph' ); // -> false
 	 *		schema.isObject( 'image' ); // -> true
 	 *		schema.isObject( 'image' ); // -> true
@@ -274,7 +274,7 @@ export default class Schema {
 	 *		const imageElement = writer.createElement( 'image' );
 	 *		const imageElement = writer.createElement( 'image' );
 	 *		schema.isObject( imageElement ); // -> true
 	 *		schema.isObject( imageElement ); // -> true
 	 *
 	 *
-	 * See the {@glink framework/guides/deep-dive/schema#object-elements Object elements} section of the "Schema" deep dive}
+	 * See the {@glink framework/guides/deep-dive/schema#object-elements Object elements} section of the Schema deep dive}
 	 * guide for more details.
 	 * guide for more details.
 	 *
 	 *
 	 * @param {module:engine/model/item~Item|module:engine/model/schema~SchemaContextItem|String} item
 	 * @param {module:engine/model/item~Item|module:engine/model/schema~SchemaContextItem|String} item
@@ -287,7 +287,7 @@ export default class Schema {
 
 
 	/**
 	/**
 	 * Returns `true` if the given item is defined to be
 	 * Returns `true` if the given item is defined to be
-	 * an inline element by {@link module:engine/model/schema~SchemaItemDefinition}'s `isInline` property.
+	 * an inline element by the {@link module:engine/model/schema~SchemaItemDefinition}'s `isInline` property.
 	 *
 	 *
 	 *		schema.isInline( 'paragraph' ); // -> false
 	 *		schema.isInline( 'paragraph' ); // -> false
 	 *		schema.isInline( 'softBreak' ); // -> true
 	 *		schema.isInline( 'softBreak' ); // -> true
@@ -295,7 +295,7 @@ export default class Schema {
 	 *		const text = writer.createText('foo' );
 	 *		const text = writer.createText('foo' );
 	 *		schema.isInline( text ); // -> true
 	 *		schema.isInline( text ); // -> true
 	 *
 	 *
-	 * See the {@glink framework/guides/deep-dive/schema#inline-elements Inline elements} section of the "Schema" deep dive}
+	 * See the {@glink framework/guides/deep-dive/schema#inline-elements Inline elements} section of the Schema deep dive}
 	 * guide for more details.
 	 * guide for more details.
 	 *
 	 *
 	 * @param {module:engine/model/item~Item|module:engine/model/schema~SchemaContextItem|String} item
 	 * @param {module:engine/model/item~Item|module:engine/model/schema~SchemaContextItem|String} item
@@ -737,13 +737,13 @@ export default class Schema {
 	}
 	}
 
 
 	/**
 	/**
-	 * Tries to find position ancestors that allows to insert given node.
+	 * Tries to find position ancestors that allow to insert a given node.
 	 * It starts searching from the given position and goes node by node to the top of the model tree
 	 * It starts searching from the given position and goes node by node to the top of the model tree
-	 * as long as {@link module:engine/model/schema~Schema#isLimit limit element},
-	 * {@link module:engine/model/schema~Schema#isObject object element} or top-most ancestor won't be reached.
+	 * as long as a {@link module:engine/model/schema~Schema#isLimit limit element}, an
+	 * {@link module:engine/model/schema~Schema#isObject object element} or a topmost ancestor is not reached.
 	 *
 	 *
-	 * @param {module:engine/model/position~Position} position Position from searching will start.
-	 * @param {module:engine/model/node~Node|String} node Node for which allowed parent should be found or its name.
+	 * @param {module:engine/model/position~Position} position The position that the search will start from.
+	 * @param {module:engine/model/node~Node|String} node The node for which an allowed parent should be found or its name.
 	 * @returns {module:engine/model/element~Element|null} element Allowed parent or null if nothing was found.
 	 * @returns {module:engine/model/element~Element|null} element Allowed parent or null if nothing was found.
 	 */
 	 */
 	findAllowedParent( position, node ) {
 	findAllowedParent( position, node ) {
@@ -873,7 +873,7 @@ export default class Schema {
 	 * This is a helper function for {@link ~Schema#getValidRanges}.
 	 * This is a helper function for {@link ~Schema#getValidRanges}.
 	 *
 	 *
 	 * @private
 	 * @private
-	 * @param {module:engine/model/range~Range} range Range to process.
+	 * @param {module:engine/model/range~Range} range The range to process.
 	 * @param {String} attribute The name of the attribute to check.
 	 * @param {String} attribute The name of the attribute to check.
 	 * @returns {Iterable.<module:engine/model/range~Range>} Ranges in which the attribute is allowed.
 	 * @returns {Iterable.<module:engine/model/range~Range>} Ranges in which the attribute is allowed.
 	 */
 	 */
@@ -907,7 +907,7 @@ mix( Schema, ObservableMixin );
 
 
 /**
 /**
  * Event fired when the {@link #checkChild} method is called. It allows plugging in
  * Event fired when the {@link #checkChild} method is called. It allows plugging in
- * additional behavior – e.g. implementing rules which cannot be defined using the declarative
+ * additional behavior, for example implementing rules which cannot be defined using the declarative
  * {@link module:engine/model/schema~SchemaItemDefinition} interface.
  * {@link module:engine/model/schema~SchemaItemDefinition} interface.
  *
  *
  * **Note:** The {@link #addChildCheck} method is a more handy way to register callbacks. Internally,
  * **Note:** The {@link #addChildCheck} method is a more handy way to register callbacks. Internally,
@@ -916,7 +916,7 @@ mix( Schema, ObservableMixin );
  *
  *
  * The {@link #checkChild} method fires an event because it is
  * The {@link #checkChild} method fires an event because it is
  * {@link module:utils/observablemixin~ObservableMixin#decorate decorated} with it. Thanks to that you can
  * {@link module:utils/observablemixin~ObservableMixin#decorate decorated} with it. Thanks to that you can
- * use this event in a various way, but the most important use case is overriding standard behaviour of the
+ * use this event in various ways, but the most important use case is overriding standard behavior of the
  * `checkChild()` method. Let's see a typical listener template:
  * `checkChild()` method. Let's see a typical listener template:
  *
  *
  *		schema.on( 'checkChild', ( evt, args ) => {
  *		schema.on( 'checkChild', ( evt, args ) => {
@@ -927,12 +927,12 @@ mix( Schema, ObservableMixin );
  * The listener is added with a `high` priority to be executed before the default method is really called. The `args` callback
  * The listener is added with a `high` priority to be executed before the default method is really called. The `args` callback
  * parameter contains arguments passed to `checkChild( context, child )`. However, the `context` parameter is already
  * parameter contains arguments passed to `checkChild( context, child )`. However, the `context` parameter is already
  * normalized to a {@link module:engine/model/schema~SchemaContext} instance and `child` to a
  * normalized to a {@link module:engine/model/schema~SchemaContext} instance and `child` to a
- * {@link module:engine/model/schema~SchemaCompiledItemDefinition} instance, so you don't have to worry about
+ * {@link module:engine/model/schema~SchemaCompiledItemDefinition} instance, so you do not have to worry about
  * the various ways how `context` and `child` may be passed to `checkChild()`.
  * the various ways how `context` and `child` may be passed to `checkChild()`.
  *
  *
  * **Note:** `childDefinition` may be `undefined` if `checkChild()` was called with a non-registered element.
  * **Note:** `childDefinition` may be `undefined` if `checkChild()` was called with a non-registered element.
  *
  *
- * So, in order to implement a rule "disallow `heading1` in `blockQuote`" you can add such a listener:
+ * So, in order to implement a rule "disallow `heading1` in `blockQuote`", you can add such a listener:
  *
  *
  *		schema.on( 'checkChild', ( evt, args ) => {
  *		schema.on( 'checkChild', ( evt, args ) => {
  *			const context = args[ 0 ];
  *			const context = args[ 0 ];
@@ -946,8 +946,8 @@ mix( Schema, ObservableMixin );
  *			}
  *			}
  *		}, { priority: 'high' } );
  *		}, { priority: 'high' } );
  *
  *
- * Allowing elements in specific contexts will be a far less common use case, because it's normally handled by
- * `allowIn` rule from {@link module:engine/model/schema~SchemaItemDefinition} but if you have a complex scenario
+ * Allowing elements in specific contexts will be a far less common use case, because it is normally handled by the
+ * `allowIn` rule from {@link module:engine/model/schema~SchemaItemDefinition}. But if you have a complex scenario
  * where `listItem` should be allowed only in element `foo` which must be in element `bar`, then this would be the way:
  * where `listItem` should be allowed only in element `foo` which must be in element `bar`, then this would be the way:
  *
  *
  *		schema.on( 'checkChild', ( evt, args ) => {
  *		schema.on( 'checkChild', ( evt, args ) => {
@@ -968,16 +968,16 @@ mix( Schema, ObservableMixin );
 
 
 /**
 /**
  * Event fired when the {@link #checkAttribute} method is called. It allows plugging in
  * Event fired when the {@link #checkAttribute} method is called. It allows plugging in
- * additional behavior – e.g. implementing rules which cannot be defined using the declarative
+ * additional behavior, for example implementing rules which cannot be defined using the declarative
  * {@link module:engine/model/schema~SchemaItemDefinition} interface.
  * {@link module:engine/model/schema~SchemaItemDefinition} interface.
  *
  *
  * **Note:** The {@link #addAttributeCheck} method is a more handy way to register callbacks. Internally,
  * **Note:** The {@link #addAttributeCheck} method is a more handy way to register callbacks. Internally,
  * it registers a listener to this event but comes with a simpler API and it is the recommended choice
  * it registers a listener to this event but comes with a simpler API and it is the recommended choice
  * in most of the cases.
  * in most of the cases.
  *
  *
- * The {@link #checkAttribute} method fires an event because it's
+ * The {@link #checkAttribute} method fires an event because it is
  * {@link module:utils/observablemixin~ObservableMixin#decorate decorated} with it. Thanks to that you can
  * {@link module:utils/observablemixin~ObservableMixin#decorate decorated} with it. Thanks to that you can
- * use this event in a various way, but the most important use case is overriding standard behaviour of the
+ * use this event in various ways, but the most important use case is overriding the standard behavior of the
  * `checkAttribute()` method. Let's see a typical listener template:
  * `checkAttribute()` method. Let's see a typical listener template:
  *
  *
  *		schema.on( 'checkAttribute', ( evt, args ) => {
  *		schema.on( 'checkAttribute', ( evt, args ) => {
@@ -987,10 +987,10 @@ mix( Schema, ObservableMixin );
  *
  *
  * The listener is added with a `high` priority to be executed before the default method is really called. The `args` callback
  * The listener is added with a `high` priority to be executed before the default method is really called. The `args` callback
  * parameter contains arguments passed to `checkAttribute( context, attributeName )`. However, the `context` parameter is already
  * parameter contains arguments passed to `checkAttribute( context, attributeName )`. However, the `context` parameter is already
- * normalized to a {@link module:engine/model/schema~SchemaContext} instance, so you don't have to worry about
+ * normalized to a {@link module:engine/model/schema~SchemaContext} instance, so you do not have to worry about
  * the various ways how `context` may be passed to `checkAttribute()`.
  * the various ways how `context` may be passed to `checkAttribute()`.
  *
  *
- * So, in order to implement a rule "disallow `bold` in a text which is in a `heading1` you can add such a listener:
+ * So, in order to implement a rule "disallow `bold` in a text which is in a `heading1`, you can add such a listener:
  *
  *
  *		schema.on( 'checkAttribute', ( evt, args ) => {
  *		schema.on( 'checkAttribute', ( evt, args ) => {
  *			const context = args[ 0 ];
  *			const context = args[ 0 ];
@@ -1004,8 +1004,8 @@ mix( Schema, ObservableMixin );
  *			}
  *			}
  *		}, { priority: 'high' } );
  *		}, { priority: 'high' } );
  *
  *
- * Allowing attributes in specific contexts will be a far less common use case, because it's normally handled by
- * `allowAttributes` rule from {@link module:engine/model/schema~SchemaItemDefinition} but if you have a complex scenario
+ * Allowing attributes in specific contexts will be a far less common use case, because it is normally handled by the
+ * `allowAttributes` rule from {@link module:engine/model/schema~SchemaItemDefinition}. But if you have a complex scenario
  * where `bold` should be allowed only in element `foo` which must be in element `bar`, then this would be the way:
  * where `bold` should be allowed only in element `foo` which must be in element `bar`, then this would be the way:
  *
  *
  *		schema.on( 'checkAttribute', ( evt, args ) => {
  *		schema.on( 'checkAttribute', ( evt, args ) => {
@@ -1056,7 +1056,7 @@ mix( Schema, ObservableMixin );
  * {@link module:engine/model/schema~Schema#isLimit `isLimit()`} returns `true` for object elements automatically.
  * {@link module:engine/model/schema~Schema#isLimit `isLimit()`} returns `true` for object elements automatically.
  *
  *
  * Read more about the meaning of these types in the
  * Read more about the meaning of these types in the
- * {@glink framework/guides/deep-dive/schema#defining-additional-semantics Dedicated section of the "Schema" deep dive} guide.
+ * {@glink framework/guides/deep-dive/schema#defining-additional-semantics dedicated section of the Schema deep dive} guide.
  *
  *
  * # Generic items
  * # Generic items
  *
  *
@@ -1159,14 +1159,14 @@ mix( Schema, ObservableMixin );
  * Most block type items will inherit from `$block` (through `inheritAllFrom`).
  * Most block type items will inherit from `$block` (through `inheritAllFrom`).
  *
  *
  * Read more about the block elements in the
  * Read more about the block elements in the
- * {@glink framework/guides/deep-dive/schema#block-elements Block elements} section of the "Schema" deep dive} guide.
+ * {@glink framework/guides/deep-dive/schema#block-elements Block elements} section of the Schema deep dive} guide.
  *
  *
  * @property {Boolean} isInline
  * @property {Boolean} isInline
  * Whether an item is "text-like" and should be treated as an inline node. Examples of inline elements:
  * Whether an item is "text-like" and should be treated as an inline node. Examples of inline elements:
  * `$text`, `softBreak` (`<br>`), etc.
  * `$text`, `softBreak` (`<br>`), etc.
  *
  *
  * Read more about the inline elements in the
  * Read more about the inline elements in the
- * {@glink framework/guides/deep-dive/schema#inline-elements Inline elements} section of the "Schema" deep dive} guide.
+ * {@glink framework/guides/deep-dive/schema#inline-elements Inline elements} section of the Schema deep dive} guide.
  *
  *
  * @property {Boolean} isLimit
  * @property {Boolean} isLimit
  * It can be understood as whether this element should not be split by <kbd>Enter</kbd>.
  * It can be understood as whether this element should not be split by <kbd>Enter</kbd>.
@@ -1174,7 +1174,7 @@ mix( Schema, ObservableMixin );
  * a limit element are limited to its content.
  * a limit element are limited to its content.
  *
  *
  * Read more about the limit elements in the
  * Read more about the limit elements in the
- * {@glink framework/guides/deep-dive/schema#limit-elements Limit elements} section of the "Schema" deep dive} guide.
+ * {@glink framework/guides/deep-dive/schema#limit-elements Limit elements} section of the Schema deep dive} guide.
  *
  *
  * @property {Boolean} isObject
  * @property {Boolean} isObject
  * Whether an item is "self-contained" and should be treated as a whole. Examples of object elements:
  * Whether an item is "self-contained" and should be treated as a whole. Examples of object elements:
@@ -1184,7 +1184,7 @@ mix( Schema, ObservableMixin );
  * {@link module:engine/model/schema~Schema#isLimit `isLimit()`} returns `true` for object elements automatically.
  * {@link module:engine/model/schema~Schema#isLimit `isLimit()`} returns `true` for object elements automatically.
  *
  *
  * Read more about the object elements in the
  * Read more about the object elements in the
- * {@glink framework/guides/deep-dive/schema#object-elements Object elements} section of the "Schema" deep dive} guide.
+ * {@glink framework/guides/deep-dive/schema#object-elements Object elements} section of the Schema deep dive} guide.
  */
  */
 
 
 /**
 /**

+ 297 - 47
packages/ckeditor5-engine/src/model/utils/deletecontent.js

@@ -80,8 +80,8 @@ export default function deleteContent( model, selection, options = {} ) {
 			return;
 			return;
 		}
 		}
 
 
-		const startPos = selRange.start;
-		const endPos = LivePosition.fromPosition( selRange.end, 'toNext' );
+		// Get the live positions for the range adjusted to span only blocks selected from the user perspective.
+		const [ startPosition, endPosition ] = getLivePositionsForSelectedBlocks( selRange );
 
 
 		// 2. Remove the content if there is any.
 		// 2. Remove the content if there is any.
 		if ( !selRange.start.isTouching( selRange.end ) ) {
 		if ( !selRange.start.isTouching( selRange.end ) ) {
@@ -97,7 +97,7 @@ export default function deleteContent( model, selection, options = {} ) {
 		// as it's hard to imagine what should actually be the default behavior. Usually, specific features will
 		// as it's hard to imagine what should actually be the default behavior. Usually, specific features will
 		// want to override that behavior anyway.
 		// want to override that behavior anyway.
 		if ( !options.leaveUnmerged ) {
 		if ( !options.leaveUnmerged ) {
-			mergeBranches( writer, startPos, endPos );
+			mergeBranches( writer, startPosition, endPosition );
 
 
 			// TMP this will be replaced with a postfixer.
 			// TMP this will be replaced with a postfixer.
 			// We need to check and strip disallowed attributes in all nested nodes because after merge
 			// We need to check and strip disallowed attributes in all nested nodes because after merge
@@ -105,81 +105,331 @@ export default function deleteContent( model, selection, options = {} ) {
 			//
 			//
 			// e.g. bold is disallowed for <H1>
 			// e.g. bold is disallowed for <H1>
 			// <h1>Fo{o</h1><p>b}a<b>r</b><p> -> <h1>Fo{}a<b>r</b><h1> -> <h1>Fo{}ar<h1>.
 			// <h1>Fo{o</h1><p>b}a<b>r</b><p> -> <h1>Fo{}a<b>r</b><h1> -> <h1>Fo{}ar<h1>.
-			schema.removeDisallowedAttributes( startPos.parent.getChildren(), writer );
+			schema.removeDisallowedAttributes( startPosition.parent.getChildren(), writer );
 		}
 		}
 
 
-		collapseSelectionAt( writer, selection, startPos );
+		collapseSelectionAt( writer, selection, startPosition );
 
 
 		// 4. Add a paragraph to set selection in it.
 		// 4. Add a paragraph to set selection in it.
 		// Check if a text is allowed in the new container. If not, try to create a new paragraph (if it's allowed here).
 		// Check if a text is allowed in the new container. If not, try to create a new paragraph (if it's allowed here).
 		// If autoparagraphing is off, we assume that you know what you do so we leave the selection wherever it was.
 		// If autoparagraphing is off, we assume that you know what you do so we leave the selection wherever it was.
-		if ( !options.doNotAutoparagraph && shouldAutoparagraph( schema, startPos ) ) {
-			insertParagraph( writer, startPos, selection );
+		if ( !options.doNotAutoparagraph && shouldAutoparagraph( schema, startPosition ) ) {
+			insertParagraph( writer, startPosition, selection );
 		}
 		}
 
 
-		endPos.detach();
+		startPosition.detach();
+		endPosition.detach();
 	} );
 	} );
 }
 }
 
 
+// Returns the live positions for the range adjusted to span only blocks selected from the user perspective. Example:
+//
+//     <heading1>[foo</heading1>
+//     <paragraph>bar</paragraph>
+//     <heading1>]abc</heading1>  <-- this block is not considered as selected
+//
+// This is the same behavior as in Selection#getSelectedBlocks() "special case".
+function getLivePositionsForSelectedBlocks( range ) {
+	const model = range.root.document.model;
+
+	const startPosition = range.start;
+	let endPosition = range.end;
+
+	// If the end of selection is at the start position of last block in the selection, then
+	// shrink it to not include that trailing block. Note that this should happen only for not empty selection.
+	if ( model.hasContent( range ) ) {
+		const endBlock = getParentBlock( endPosition );
+
+		if ( endBlock && endPosition.isTouching( model.createPositionAt( endBlock, 0 ) ) ) {
+			// Create forward selection as a probe to find a valid position after excluding last block from the range.
+			const selection = model.createSelection( range );
+
+			// Modify the forward selection in backward direction to shrink it and remove first position of following block from it.
+			// This is how modifySelection works and here we are making use of it.
+			model.modifySelection( selection, { direction: 'backward' } );
+
+			endPosition = selection.getLastPosition();
+		}
+	}
+
+	return [
+		LivePosition.fromPosition( startPosition, 'toPrevious' ),
+		LivePosition.fromPosition( endPosition, 'toNext' )
+	];
+}
+
+// Finds the lowest element in position's ancestors which is a block.
+// Returns null if a limit element is encountered before reaching a block element.
+function getParentBlock( position ) {
+	const element = position.parent;
+	const schema = element.root.document.model.schema;
+	const ancestors = element.getAncestors( { parentFirst: true, includeSelf: true } );
+
+	for ( const element of ancestors ) {
+		if ( schema.isLimit( element ) ) {
+			return null;
+		}
+
+		if ( schema.isBlock( element ) ) {
+			return element;
+		}
+	}
+}
+
 // This function is a result of reaching the Ballmer's peak for just the right amount of time.
 // This function is a result of reaching the Ballmer's peak for just the right amount of time.
 // Even I had troubles documenting it after a while and after reading it again I couldn't believe that it really works.
 // Even I had troubles documenting it after a while and after reading it again I couldn't believe that it really works.
-function mergeBranches( writer, startPos, endPos ) {
-	const startParent = startPos.parent;
-	const endParent = endPos.parent;
+function mergeBranches( writer, startPosition, endPosition ) {
+	const model = writer.model;
 
 
-	// If both positions ended up in the same parent, then there's nothing more to merge:
-	// <$root><p>x[]</p><p>{}y</p></$root> => <$root><p>xy</p>[]{}</$root>
-	if ( startParent == endParent ) {
+	// Verify if there is a need and possibility to merge.
+	if ( !checkShouldMerge( writer.model.schema, startPosition, endPosition ) ) {
 		return;
 		return;
 	}
 	}
 
 
-	// If one of the positions is a limit element, then there's nothing to merge because we don't want to cross the limit boundaries.
-	if ( writer.model.schema.isLimit( startParent ) || writer.model.schema.isLimit( endParent ) ) {
-		return;
+	// If the start element on the common ancestor level is empty, and the end element on the same level is not empty
+	// then merge those to the right element so that it's properties are preserved (name, attributes).
+	// Because of OT merging is used instead of removing elements.
+	//
+	// Merge left:
+	//     <heading1>foo[</heading1>    ->  <heading1>foo[]bar</heading1>
+	//     <paragraph>]bar</paragraph>  ->               --^
+	//
+	// Merge right:
+	//     <heading1>[</heading1>       ->
+	//     <paragraph>]bar</paragraph>  ->  <paragraph>[]bar</paragraph>
+	//
+	// Merge left:
+	//     <blockQuote>                     ->  <blockQuote>
+	//         <heading1>foo[</heading1>    ->      <heading1>foo[]bar</heading1>
+	//         <paragraph>]bar</paragraph>  ->                   --^
+	//     </blockQuote>                    ->  </blockQuote>
+	//
+	// Merge right:
+	//     <blockQuote>                     ->  <blockQuote>
+	//         <heading1>[</heading1>       ->
+	//         <paragraph>]bar</paragraph>  ->      <paragraph>[]bar</paragraph>
+	//     </blockQuote>                    ->  </blockQuote>
+
+	// Merging should not go deeper than common ancestor.
+	const [ startAncestor, endAncestor ] = getAncestorsJustBelowCommonAncestor( startPosition, endPosition );
+
+	if ( !model.hasContent( startAncestor ) && model.hasContent( endAncestor ) ) {
+		mergeBranchesRight( writer, startPosition, endPosition, startAncestor.parent );
+	} else {
+		mergeBranchesLeft( writer, startPosition, endPosition, startAncestor.parent );
 	}
 	}
+}
 
 
-	// Check if operations we'll need to do won't need to cross object or limit boundaries.
-	// E.g., we can't merge endParent into startParent in this case:
-	// <limit><startParent>x[]</startParent></limit><endParent>{}</endParent>
-	if ( !checkCanBeMerged( startPos, endPos, writer.model.schema ) ) {
+// Merging blocks to the left (properties of the left block are preserved).
+// Simple example:
+//     <heading1>foo[</heading1>    ->  <heading1>foo[bar</heading1>]
+//     <paragraph>]bar</paragraph>  ->              --^
+//
+// Nested example:
+//     <blockQuote>                     ->  <blockQuote>
+//         <heading1>foo[</heading1>    ->      <heading1>foo[bar</heading1>
+//     </blockQuote>                    ->  </blockQuote>]    ^
+//     <blockBlock>                     ->                    |
+//         <paragraph>]bar</paragraph>  ->                 ---
+//     </blockBlock>                    ->
+//
+function mergeBranchesLeft( writer, startPosition, endPosition, commonAncestor ) {
+	const startElement = startPosition.parent;
+	const endElement = endPosition.parent;
+
+	// Merging reached the common ancestor element, stop here.
+	if ( startElement == commonAncestor || endElement == commonAncestor ) {
 		return;
 		return;
 	}
 	}
 
 
-	// Remember next positions to merge. For example:
-	// <a><b>x[]</b></a><c><d>{}y</d></c>
-	// will become:
-	// <a><b>xy</b>[]</a><c>{}</c>
-	startPos = writer.createPositionAfter( startParent );
-	endPos = writer.createPositionBefore( endParent );
+	// Remember next positions to merge in next recursive step (also used as modification points pointers).
+	startPosition = writer.createPositionAfter( startElement );
+	endPosition = writer.createPositionBefore( endElement );
 
 
-	if ( !endPos.isEqual( startPos ) ) {
-		// In this case, before we merge, we need to move `endParent` to the `startPos`:
-		// <a><b>x[]</b></a><c><d>{}y</d></c>
-		// becomes:
-		// <a><b>x</b>[]<d>y</d></a><c>{}</c>
-		writer.insert( endParent, startPos );
+	// Move endElement just after startElement if they aren't siblings.
+	if ( !endPosition.isEqual( startPosition ) ) {
+		//
+		//     <blockQuote>                     ->  <blockQuote>
+		//         <heading1>foo[</heading1>    ->      <heading1>foo</heading1>[<paragraph>bar</paragraph>
+		//     </blockQuote>                    ->  </blockQuote>                ^
+		//     <blockBlock>                     ->  <blockBlock>                 |
+		//         <paragraph>]bar</paragraph>  ->      ]                     ---
+		//     </blockBlock>                    ->  </blockBlock>
+		//
+		writer.insert( endElement, startPosition );
 	}
 	}
 
 
-	// Merge two siblings:
-	// <a>x</a>[]<b>y</b> -> <a>xy</a> (the usual case)
-	// <a><b>x</b>[]<d>y</d></a><c></c> -> <a><b>xy</b>[]</a><c></c> (this is the "move parent" case shown above)
-	writer.merge( startPos );
+	// Merge two siblings (nodes on sides of startPosition):
+	//
+	//     <blockQuote>                                             ->  <blockQuote>
+	//         <heading1>foo</heading1>[<paragraph>bar</paragraph>  ->      <heading1>foo[bar</heading1>
+	//     </blockQuote>                                            ->  </blockQuote>
+	//     <blockBlock>                                             ->  <blockBlock>
+	//         ]                                                    ->      ]
+	//     </blockBlock>                                            ->  </blockBlock>
+	//
+	// Or in simple case (without moving elements in above if):
+	//     <heading1>foo</heading1>[<paragraph>bar</paragraph>]  ->  <heading1>foo[bar</heading1>]
+	//
+	writer.merge( startPosition );
 
 
 	// Remove empty end ancestors:
 	// Remove empty end ancestors:
-	// <a>fo[o</a><b><a><c>bar]</c></a></b>
-	// becomes:
-	// <a>fo[]</a><b><a>{}</a></b>
-	// So we can remove <a> and <b>.
-	while ( endPos.parent.isEmpty ) {
-		const parentToRemove = endPos.parent;
+	//
+	//     <blockQuote>                      ->  <blockQuote>
+	//         <heading1>foo[bar</heading1>  ->      <heading1>foo[bar</heading1>
+	//     </blockQuote>                     ->  </blockQuote>
+	//     <blockBlock>                      ->
+	//         ]                             ->  ]
+	//     </blockBlock>                     ->
+	//
+	while ( endPosition.parent.isEmpty ) {
+		const parentToRemove = endPosition.parent;
+
+		endPosition = writer.createPositionBefore( parentToRemove );
 
 
-		endPos = writer.createPositionBefore( parentToRemove );
+		writer.remove( parentToRemove );
+	}
+
+	// Verify if there is a need and possibility to merge next level.
+	if ( !checkShouldMerge( writer.model.schema, startPosition, endPosition ) ) {
+		return;
+	}
+
+	// Continue merging next level (blockQuote with blockBlock in the examples above if it would not be empty and got removed).
+	mergeBranchesLeft( writer, startPosition, endPosition, commonAncestor );
+}
+
+// Merging blocks to the right (properties of the right block are preserved).
+// Simple example:
+//     <heading1>foo[</heading1>    ->            --v
+//     <paragraph>]bar</paragraph>  ->  [<paragraph>foo]bar</paragraph>
+//
+// Nested example:
+//     <blockQuote>                     ->
+//         <heading1>foo[</heading1>    ->              ---
+//     </blockQuote>                    ->                 |
+//     <blockBlock>                     ->  [<blockBlock>  v
+//         <paragraph>]bar</paragraph>  ->      <paragraph>foo]bar</paragraph>
+//     </blockBlock>                    ->  </blockBlock>
+//
+function mergeBranchesRight( writer, startPosition, endPosition, commonAncestor ) {
+	const startElement = startPosition.parent;
+	const endElement = endPosition.parent;
+
+	// Merging reached the common ancestor element, stop here.
+	if ( startElement == commonAncestor || endElement == commonAncestor ) {
+		return;
+	}
+
+	// Remember next positions to merge in next recursive step (also used as modification points pointers).
+	startPosition = writer.createPositionAfter( startElement );
+	endPosition = writer.createPositionBefore( endElement );
+
+	// Move startElement just before endElement if they aren't siblings.
+	if ( !endPosition.isEqual( startPosition ) ) {
+		//
+		//     <blockQuote>                     ->  <blockQuote>
+		//         <heading1>foo[</heading1>    ->      [                   ---
+		//     </blockQuote>                    ->  </blockQuote>              |
+		//     <blockBlock>                     ->  <blockBlock>               v
+		//         <paragraph>]bar</paragraph>  ->      <heading1>foo</heading1>]<paragraph>bar</paragraph>
+		//     </blockBlock>                    ->  </blockBlock>
+		//
+		writer.insert( startElement, endPosition );
+	}
+
+	// Remove empty end ancestors:
+	//
+	//     <blockQuote>                                             ->
+	//         [                                                    ->  [
+	//     </blockQuote>                                            ->
+	//     <blockBlock>                                             ->  <blockBlock>
+	//         <heading1>foo</heading1>]<paragraph>bar</paragraph>  ->      <heading1>foo</heading1>]<paragraph>bar</paragraph>
+	//     </blockBlock>                                            ->  </blockBlock>
+	//
+	while ( startPosition.parent.isEmpty ) {
+		const parentToRemove = startPosition.parent;
+
+		startPosition = writer.createPositionBefore( parentToRemove );
 
 
 		writer.remove( parentToRemove );
 		writer.remove( parentToRemove );
 	}
 	}
 
 
-	// Continue merging next level.
-	mergeBranches( writer, startPos, endPos );
+	// Update endPosition after inserting and removing elements.
+	endPosition = writer.createPositionBefore( endElement );
+
+	// Merge right two siblings (nodes on sides of endPosition):
+	//                                                              ->
+	//     [                                                        ->  [
+	//                                                              ->
+	//     <blockBlock>                                             ->  <blockBlock>
+	//         <heading1>foo</heading1>]<paragraph>bar</paragraph>  ->      <paragraph>foo]bar</paragraph>
+	//     </blockBlock>                                            ->  </blockBlock>
+	//
+	// Or in simple case (without moving elements in above if):
+	//     [<heading1>foo</heading1>]<paragraph>bar</paragraph>  ->  [<heading1>foo]bar</heading1>
+	//
+	mergeRight( writer, endPosition );
+
+	// Verify if there is a need and possibility to merge next level.
+	if ( !checkShouldMerge( writer.model.schema, startPosition, endPosition ) ) {
+		return;
+	}
+
+	// Continue merging next level (blockQuote with blockBlock in the examples above if it would not be empty and got removed).
+	mergeBranchesRight( writer, startPosition, endPosition, commonAncestor );
+}
+
+// There is no right merge operation so we need to simulate it.
+function mergeRight( writer, position ) {
+	const startElement = position.nodeBefore;
+	const endElement = position.nodeAfter;
+
+	if ( startElement.name != endElement.name ) {
+		writer.rename( startElement, endElement.name );
+	}
+
+	writer.clearAttributes( startElement );
+	writer.setAttributes( Object.fromEntries( endElement.getAttributes() ), startElement );
+
+	writer.merge( position );
+}
+
+// Verifies if merging is needed and possible. It's not needed if both positions are in the same element
+// and it's not possible if some element is a limit or the range crosses a limit element.
+function checkShouldMerge( schema, startPosition, endPosition ) {
+	const startElement = startPosition.parent;
+	const endElement = endPosition.parent;
+
+	// If both positions ended up in the same parent, then there's nothing more to merge:
+	// <$root><p>x[</p><p>]y</p></$root> => <$root><p>xy</p>[]</$root>
+	if ( startElement == endElement ) {
+		return false;
+	}
+
+	// If one of the positions is a limit element, then there's nothing to merge because we don't want to cross the limit boundaries.
+	if ( schema.isLimit( startElement ) || schema.isLimit( endElement ) ) {
+		return false;
+	}
+
+	// Check if operations we'll need to do won't need to cross object or limit boundaries.
+	// E.g., we can't merge endElement into startElement in this case:
+	// <limit><startElement>x[</startElement></limit><endElement>]</endElement>
+	return isCrossingLimitElement( startPosition, endPosition, schema );
+}
+
+// Returns the elements that are the ancestors of the provided positions that are direct children of the common ancestor.
+function getAncestorsJustBelowCommonAncestor( positionA, positionB ) {
+	const ancestorsA = positionA.getAncestors();
+	const ancestorsB = positionB.getAncestors();
+
+	let i = 0;
+
+	while ( ancestorsA[ i ] && ancestorsA[ i ] == ancestorsB[ i ] ) {
+		i++;
+	}
+
+	return [ ancestorsA[ i ], ancestorsB[ i ] ];
 }
 }
 
 
 function shouldAutoparagraph( schema, position ) {
 function shouldAutoparagraph( schema, position ) {
@@ -195,7 +445,7 @@ function shouldAutoparagraph( schema, position ) {
 // E.g. in <bQ><p>x[]</p></bQ><widget><caption>{}</caption></widget>
 // E.g. in <bQ><p>x[]</p></bQ><widget><caption>{}</caption></widget>
 // we'll check <p>, <bQ>, <widget> and <caption>.
 // we'll check <p>, <bQ>, <widget> and <caption>.
 // Usually, widget and caption are marked as objects/limits in the schema, so in this case merging will be blocked.
 // Usually, widget and caption are marked as objects/limits in the schema, so in this case merging will be blocked.
-function checkCanBeMerged( leftPos, rightPos, schema ) {
+function isCrossingLimitElement( leftPos, rightPos, schema ) {
 	const rangeToCheck = new Range( leftPos, rightPos );
 	const rangeToCheck = new Range( leftPos, rightPos );
 
 
 	for ( const value of rangeToCheck.getWalker() ) {
 	for ( const value of rangeToCheck.getWalker() ) {

+ 1 - 1
packages/ckeditor5-engine/src/model/utils/getselectedcontent.js

@@ -73,7 +73,7 @@ export default function getSelectedContent( model, selection ) {
 			if ( item.is( 'textProxy' ) ) {
 			if ( item.is( 'textProxy' ) ) {
 				writer.appendText( item.data, item.getAttributes(), frag );
 				writer.appendText( item.data, item.getAttributes(), frag );
 			} else {
 			} else {
-				writer.append( item._clone( true ), frag );
+				writer.append( writer.cloneElement( item, true ), frag );
 			}
 			}
 		}
 		}
 
 

+ 12 - 0
packages/ckeditor5-engine/src/model/writer.js

@@ -116,6 +116,18 @@ export default class Writer {
 		return new DocumentFragment();
 		return new DocumentFragment();
 	}
 	}
 
 
+	/**
+	 * Creates a copy of the element and returns it. Created element has the same name and attributes as the original element.
+	 * If clone is deep, the original element's children are also cloned. If not, then empty element is returned.
+	 *
+	 * @param {module:engine/model/element~Element} element The element to clone.
+	 * @param {Boolean} [deep=true] If set to `true` clones element and all its children recursively. When set to `false`,
+	 * element will be cloned without any child.
+	 */
+	cloneElement( element, deep = true ) {
+		return element._clone( deep );
+	}
+
 	/**
 	/**
 	 * Inserts item on given position.
 	 * Inserts item on given position.
 	 *
 	 *

+ 249 - 97
packages/ckeditor5-engine/tests/model/utils/deletecontent.js

@@ -216,11 +216,13 @@ describe( 'DataController utils', () => {
 					allowIn: 'pparent',
 					allowIn: 'pparent',
 					allowAttributes: 'align'
 					allowAttributes: 'align'
 				} );
 				} );
-				schema.register( 'heading1', { inheritAllFrom: '$block' } );
+				schema.register( 'heading1', { inheritAllFrom: '$block', allowIn: 'pparent' } );
 				schema.register( 'image', { inheritAllFrom: '$text' } );
 				schema.register( 'image', { inheritAllFrom: '$text' } );
 				schema.register( 'pchild', { allowIn: 'paragraph' } );
 				schema.register( 'pchild', { allowIn: 'paragraph' } );
 				schema.register( 'pparent', { allowIn: '$root' } );
 				schema.register( 'pparent', { allowIn: '$root' } );
-				schema.extend( '$text', { allowIn: [ 'pchild', 'pparent' ] } );
+				schema.register( 'hchild', { allowIn: 'heading1' } );
+				schema.register( 'widget', { isObject: true, allowWhere: '$text', isInline: true } );
+				schema.extend( '$text', { allowIn: [ 'pchild', 'pparent', 'hchild' ] } );
 			} );
 			} );
 
 
 			test(
 			test(
@@ -235,6 +237,30 @@ describe( 'DataController utils', () => {
 				'<paragraph>x</paragraph><paragraph>fo[]ar</paragraph><paragraph>y</paragraph>'
 				'<paragraph>x</paragraph><paragraph>fo[]ar</paragraph><paragraph>y</paragraph>'
 			);
 			);
 
 
+			test(
+				'merges first element into the second element (it would become empty but second element would not) (same name)',
+				'<paragraph>x</paragraph><paragraph>[foo</paragraph><paragraph>b]ar</paragraph><paragraph>y</paragraph>',
+				'<paragraph>x</paragraph><paragraph>[]ar</paragraph><paragraph>y</paragraph>'
+			);
+
+			test(
+				'do not remove end block if selection ends at start position of it',
+				'<paragraph>x</paragraph><paragraph>[foo</paragraph><paragraph>]bar</paragraph><paragraph>y</paragraph>',
+				'<paragraph>x</paragraph><paragraph>[]</paragraph><paragraph>bar</paragraph><paragraph>y</paragraph>'
+			);
+
+			test(
+				'removes empty element (merges it into second element)',
+				'<paragraph>x</paragraph><paragraph>[</paragraph><paragraph>]bar</paragraph><paragraph>y</paragraph>',
+				'<paragraph>x</paragraph><paragraph>[]bar</paragraph><paragraph>y</paragraph>'
+			);
+
+			test(
+				'treats inline widget elements as content so parent element is not considered as empty after merging (same name)',
+				'<paragraph>x</paragraph><paragraph><widget></widget>[foo</paragraph><paragraph>b]ar</paragraph><paragraph>y</paragraph>',
+				'<paragraph>x</paragraph><paragraph><widget></widget>[]ar</paragraph><paragraph>y</paragraph>'
+			);
+
 			test(
 			test(
 				'does not merge second element into the first one (same name, !option.merge)',
 				'does not merge second element into the first one (same name, !option.merge)',
 				'<paragraph>x</paragraph><paragraph>fo[o</paragraph><paragraph>b]ar</paragraph><paragraph>y</paragraph>',
 				'<paragraph>x</paragraph><paragraph>fo[o</paragraph><paragraph>b]ar</paragraph><paragraph>y</paragraph>',
@@ -242,12 +268,25 @@ describe( 'DataController utils', () => {
 				{ leaveUnmerged: true }
 				{ leaveUnmerged: true }
 			);
 			);
 
 
+			test(
+				'does not remove first empty element when it\'s empty but second element is not empty (same name, !option.merge)',
+				'<paragraph>x</paragraph><paragraph>[foo</paragraph><paragraph>b]ar</paragraph><paragraph>y</paragraph>',
+				'<paragraph>x</paragraph><paragraph>[]</paragraph><paragraph>ar</paragraph><paragraph>y</paragraph>',
+				{ leaveUnmerged: true }
+			);
+
 			test(
 			test(
 				'merges second element into the first one (different name)',
 				'merges second element into the first one (different name)',
 				'<paragraph>x</paragraph><heading1>fo[o</heading1><paragraph>b]ar</paragraph><paragraph>y</paragraph>',
 				'<paragraph>x</paragraph><heading1>fo[o</heading1><paragraph>b]ar</paragraph><paragraph>y</paragraph>',
 				'<paragraph>x</paragraph><heading1>fo[]ar</heading1><paragraph>y</paragraph>'
 				'<paragraph>x</paragraph><heading1>fo[]ar</heading1><paragraph>y</paragraph>'
 			);
 			);
 
 
+			test(
+				'removes first element when it\'s empty but second element is not empty (different name)',
+				'<paragraph>x</paragraph><heading1>[foo</heading1><paragraph>b]ar</paragraph><paragraph>y</paragraph>',
+				'<paragraph>x</paragraph><paragraph>[]ar</paragraph><paragraph>y</paragraph>'
+			);
+
 			// Note: in all these cases we ignore the direction of merge.
 			// Note: in all these cases we ignore the direction of merge.
 			// If https://github.com/ckeditor/ckeditor5-engine/issues/470 was fixed we could differently treat
 			// If https://github.com/ckeditor/ckeditor5-engine/issues/470 was fixed we could differently treat
 			// forward and backward delete.
 			// forward and backward delete.
@@ -270,9 +309,9 @@ describe( 'DataController utils', () => {
 			);
 			);
 
 
 			test(
 			test(
-				'merges second element to an empty first element',
+				'removes empty first element',
 				'<paragraph>x</paragraph><heading1>[</heading1><paragraph>fo]o</paragraph><paragraph>y</paragraph>',
 				'<paragraph>x</paragraph><heading1>[</heading1><paragraph>fo]o</paragraph><paragraph>y</paragraph>',
-				'<paragraph>x</paragraph><heading1>[]o</heading1><paragraph>y</paragraph>'
+				'<paragraph>x</paragraph><paragraph>[]o</paragraph><paragraph>y</paragraph>'
 			);
 			);
 
 
 			test(
 			test(
@@ -283,8 +322,20 @@ describe( 'DataController utils', () => {
 
 
 			test(
 			test(
 				'leaves just one element when all selected',
 				'leaves just one element when all selected',
+				'<paragraph>[x</paragraph><paragraph>foo</paragraph><paragraph>y]bar</paragraph>',
+				'<paragraph>[]bar</paragraph>'
+			);
+
+			test(
+				'leaves just one (last) element when all selected (first block would become empty) (different name)',
 				'<heading1>[x</heading1><paragraph>foo</paragraph><paragraph>y]bar</paragraph>',
 				'<heading1>[x</heading1><paragraph>foo</paragraph><paragraph>y]bar</paragraph>',
-				'<heading1>[]bar</heading1>'
+				'<paragraph>[]bar</paragraph>'
+			);
+
+			test(
+				'leaves just one (first) element when all selected (first block would not become empty) (different name)',
+				'<heading1>foo[x</heading1><paragraph>bar</paragraph><paragraph>y]</paragraph>',
+				'<heading1>foo[]</heading1>'
 			);
 			);
 
 
 			it( 'uses merge operation even if merged element is empty', () => {
 			it( 'uses merge operation even if merged element is empty', () => {
@@ -317,6 +368,36 @@ describe( 'DataController utils', () => {
 				expect( mergeSpy.called ).to.be.true;
 				expect( mergeSpy.called ).to.be.true;
 			} );
 			} );
 
 
+			it( 'uses "merge" operation (from OT) if first element is empty (because of content delete) and last is not', () => {
+				let mergeSpy;
+
+				setData( model, '<paragraph>[abcd</paragraph><paragraph>ef]gh</paragraph>' );
+
+				model.change( writer => {
+					mergeSpy = sinon.spy( writer, 'merge' );
+					deleteContent( model, doc.selection );
+				} );
+
+				expect( getData( model ) ).to.equal( '<paragraph>[]gh</paragraph>' );
+
+				expect( mergeSpy.called ).to.be.true;
+			} );
+
+			it( 'uses merge operation if first element is empty and last is not', () => {
+				let mergeSpy;
+
+				setData( model, '<paragraph>[</paragraph><paragraph>ef]gh</paragraph>' );
+
+				model.change( writer => {
+					mergeSpy = sinon.spy( writer, 'merge' );
+					deleteContent( model, doc.selection );
+				} );
+
+				expect( getData( model ) ).to.equal( '<paragraph>[]gh</paragraph>' );
+
+				expect( mergeSpy.called ).to.be.true;
+			} );
+
 			it( 'does not try to move the second block if not needed', () => {
 			it( 'does not try to move the second block if not needed', () => {
 				let mergeSpy, moveSpy;
 				let mergeSpy, moveSpy;
 
 
@@ -344,47 +425,41 @@ describe( 'DataController utils', () => {
 					'<paragraph>x<pchild>fo[]ar</pchild>y</paragraph>'
 					'<paragraph>x<pchild>fo[]ar</pchild>y</paragraph>'
 				);
 				);
 
 
-				it( 'merges elements when deep nested (3rd level)', () => {
-					const root = doc.getRoot();
-
-					// We need to use the raw API due to https://github.com/ckeditor/ckeditor5-engine/issues/905.
-					// <pparent>x<paragraph>x<pchild>fo[o</pchild></paragraph></pparent>
-					// <pparent><paragraph><pchild>b]ar</pchild>y</paragraph>y</pparent>
-
-					root._appendChild(
-						new Element( 'pparent', null, [
-							'x',
-							new Element( 'paragraph', null, [
-								'x',
-								new Element( 'pchild', null, 'foo' )
-							] )
-						] )
-					);
+				test(
+					'merges block element to the right (with nested element)',
+					'<paragraph><pchild>[foo</pchild></paragraph><paragraph><pchild>b]ar</pchild></paragraph>',
+					'<paragraph><pchild>[]ar</pchild></paragraph>'
+				);
 
 
-					root._appendChild(
-						new Element( 'pparent', null, [
-							new Element( 'paragraph', null, [
-								new Element( 'pchild', null, 'bar' ),
-								'y'
-							] ),
-							'y'
-						] )
-					);
+				test(
+					'does not remove block element with nested element and object',
+					'<paragraph><pchild><widget></widget>[foo</pchild></paragraph><paragraph><pchild>b]ar</pchild></paragraph>',
+					'<paragraph><pchild><widget></widget>[]ar</pchild></paragraph>'
+				);
 
 
-					const range = new Range(
-						new Position( doc.getRoot(), [ 0, 1, 1, 2 ] ), // fo[o
-						new Position( doc.getRoot(), [ 1, 0, 0, 1 ] ) // b]ar
-					);
+				test(
+					'merges nested elements',
+					'<heading1><hchild>x[foo</hchild></heading1><paragraph><pchild>b]ar</pchild></paragraph>',
+					'<heading1><hchild>x[]ar</hchild></heading1>'
+				);
 
 
-					model.change( writer => {
-						writer.setSelection( range );
-					} );
+				test(
+					'merges nested elements on multiple levels',
+					'<heading1><hchild>x[foo</hchild></heading1><paragraph><pchild>b]ar</pchild>abc</paragraph>',
+					'<heading1><hchild>x[]ar</hchild>abc</heading1>'
+				);
 
 
-					deleteContent( model, doc.selection );
+				test(
+					'merges nested elements to the right if left side element would become empty',
+					'<heading1><hchild>[foo</hchild></heading1><paragraph><pchild>b]ar</pchild></paragraph>',
+					'<paragraph><pchild>[]ar</pchild></paragraph>'
+				);
 
 
-					expect( getData( model ) )
-						.to.equal( '<pparent>x<paragraph>x<pchild>fo[]ar</pchild>y</paragraph>y</pparent>' );
-				} );
+				test(
+					'merges to the left if first element contains object (considers it as a content of that element)',
+					'<heading1><hchild><widget></widget>[foo</hchild></heading1><paragraph><pchild>b]ar</pchild></paragraph>',
+					'<heading1><hchild><widget></widget>[]ar</hchild></heading1>'
+				);
 
 
 				test(
 				test(
 					'merges elements when left end deep nested',
 					'merges elements when left end deep nested',
@@ -392,90 +467,167 @@ describe( 'DataController utils', () => {
 					'<paragraph>x<pchild>fo[]ary</pchild></paragraph><paragraph>x</paragraph>'
 					'<paragraph>x<pchild>fo[]ary</pchild></paragraph><paragraph>x</paragraph>'
 				);
 				);
 
 
+				test(
+					'merges nested elements to the right (on multiple levels) if left side element would become empty',
+					'<heading1><hchild>[foo</hchild></heading1><paragraph><pchild>b]ar</pchild>abc</paragraph>',
+					'<paragraph><pchild>[]ar</pchild>abc</paragraph>'
+				);
+
+				test(
+					'merges to the right element when left end deep nested and will be empty',
+					'<paragraph><pchild>[foo</pchild></paragraph><paragraph>b]ar</paragraph><paragraph>x</paragraph>',
+					'<paragraph>[]ar</paragraph><paragraph>x</paragraph>'
+				);
+
 				test(
 				test(
 					'merges elements when right end deep nested',
 					'merges elements when right end deep nested',
 					'<paragraph>x</paragraph><paragraph>fo[o</paragraph><paragraph><pchild>b]ar</pchild>x</paragraph>',
 					'<paragraph>x</paragraph><paragraph>fo[o</paragraph><paragraph><pchild>b]ar</pchild>x</paragraph>',
 					'<paragraph>x</paragraph><paragraph>fo[]ar</paragraph><paragraph>x</paragraph>'
 					'<paragraph>x</paragraph><paragraph>fo[]ar</paragraph><paragraph>x</paragraph>'
 				);
 				);
 
 
-				it( 'merges elements when left end deep nested (3rd level)', () => {
-					const root = doc.getRoot();
+				test(
+					'removes element when right end deep nested but left end would be empty',
+					'<paragraph>x</paragraph><paragraph>[foo</paragraph><paragraph><pchild>b]ar</pchild></paragraph>',
+					'<paragraph>x</paragraph><paragraph><pchild>[]ar</pchild></paragraph>'
+				);
 
 
-					// We need to use the raw API due to https://github.com/ckeditor/ckeditor5-engine/issues/905.
-					// <pparent>x<paragraph>foo<pchild>ba[r</pchild></paragraph></pparent><paragraph>b]om</paragraph>
+				test(
+					'merges elements when right end deep nested (in an empty container)',
+					'<paragraph>fo[o</paragraph><paragraph><pchild>bar]</pchild></paragraph>',
+					'<paragraph>fo[]</paragraph>'
+				);
 
 
-					root._appendChild(
-						new Element( 'pparent', null, [
-							'x',
-							new Element( 'paragraph', null, [
-								'foo',
-								new Element( 'pchild', null, 'bar' )
-							] )
-						] )
-					);
+				test(
+					'removes elements when left end deep nested (in an empty container)',
+					'<paragraph><pchild>[foo</pchild></paragraph><paragraph>b]ar</paragraph><paragraph>x</paragraph>',
+					'<paragraph>[]ar</paragraph><paragraph>x</paragraph>'
+				);
 
 
-					root._appendChild(
-						new Element( 'paragraph', null, 'bom' )
+				describe( 'with 3rd level of nesting', () => {
+					test(
+						'merges elements when deep nested (same name)',
+						'<pparent>x<paragraph>x<pchild>fo[o</pchild></paragraph></pparent>' +
+						'<pparent><paragraph><pchild>b]ar</pchild>y</paragraph>y</pparent>',
+						'<pparent>x<paragraph>x<pchild>fo[]ar</pchild>y</paragraph>y</pparent>'
 					);
 					);
 
 
-					const range = new Range(
-						new Position( doc.getRoot(), [ 0, 1, 3, 2 ] ), // ba[r
-						new Position( doc.getRoot(), [ 1, 1 ] ) // b]om
+					test(
+						'removes elements when deep nested (same name)',
+						'<pparent><paragraph><pchild>[foo</pchild></paragraph></pparent>' +
+						'<pparent><paragraph><pchild>b]ar</pchild>y</paragraph>y</pparent>',
+						'<pparent><paragraph><pchild>[]ar</pchild>y</paragraph>y</pparent>'
 					);
 					);
 
 
-					model.change( writer => {
-						writer.setSelection( range );
-					} );
+					test(
+						'removes elements up to common ancestor when deep nested (same name)',
+						'<pparent>' +
+							'<paragraph><pchild>[foo</pchild></paragraph>' +
+							'<paragraph><pchild>b]ar</pchild>y</paragraph>y' +
+						'</pparent>',
+						'<pparent><paragraph><pchild>[]ar</pchild>y</paragraph>y</pparent>'
+					);
 
 
-					deleteContent( model, doc.selection );
+					test(
+						'merges elements when deep nested (different name)',
+						'<pparent>x<heading1>x<hchild>fo[o</hchild></heading1></pparent>' +
+						'<pparent><paragraph><pchild>b]ar</pchild>y</paragraph>y</pparent>',
+						'<pparent>x<heading1>x<hchild>fo[]ar</hchild>y</heading1>y</pparent>'
+					);
 
 
-					expect( getData( model ) )
-						.to.equal( '<pparent>x<paragraph>foo<pchild>ba[]om</pchild></paragraph></pparent>' );
-				} );
+					test(
+						'removes elements when deep nested (different name)',
+						'<pparent><heading1><hchild>[foo</hchild></heading1></pparent>' +
+						'<pparent><paragraph><pchild>b]ar</pchild>y</paragraph>y</pparent>',
+						'<pparent><paragraph><pchild>[]ar</pchild>y</paragraph>y</pparent>'
+					);
 
 
-				test(
-					'merges elements when right end deep nested (in an empty container)',
-					'<paragraph>fo[o</paragraph><paragraph><pchild>bar]</pchild></paragraph>',
-					'<paragraph>fo[]</paragraph>'
-				);
+					test(
+						'merges elements up to common ancestor when deep nested (different names)',
+						'<pparent>' +
+							'<heading1><hchild>fo[o</hchild></heading1>' +
+							'<paragraph><pchild>b]ar</pchild></paragraph>' +
+						'</pparent>',
+						'<pparent><heading1><hchild>fo[]ar</hchild></heading1></pparent>'
+					);
 
 
-				test(
-					'merges elements when left end deep nested (in an empty container)',
-					'<paragraph><pchild>[foo</pchild></paragraph><paragraph>b]ar</paragraph><paragraph>x</paragraph>',
-					'<paragraph><pchild>[]ar</pchild></paragraph><paragraph>x</paragraph>'
-				);
+					test(
+						'removes elements up to common ancestor when deep nested (different names)',
+						'<pparent>' +
+							'<heading1><hchild>[foo</hchild></heading1>' +
+							'<paragraph><pchild>b]ar</pchild>y</paragraph>y' +
+						'</pparent>',
+						'<pparent><paragraph><pchild>[]ar</pchild>y</paragraph>y</pparent>'
+					);
+				} );
 
 
-				it( 'merges elements when right end deep nested (3rd level)', () => {
-					const root = doc.getRoot();
+				describe( 'with 3rd level of nesting o the left end', () => {
+					test(
+						'merges elements',
+						'<pparent>x<paragraph>foo<pchild>ba[r</pchild></paragraph></pparent>' +
+						'<paragraph>b]om</paragraph>',
+						'<pparent>x<paragraph>foo<pchild>ba[]om</pchild></paragraph></pparent>'
+					);
 
 
-					// We need to use the raw API due to https://github.com/ckeditor/ckeditor5-engine/issues/905.
-					// <paragraph>fo[o</paragraph><pparent><paragraph><pchild>bar]</pchild></paragraph></pparent>
+					test(
+						'merges elements (different names)',
+						'<pparent>x<heading1>foo<hchild>ba[r</hchild></heading1></pparent>' +
+						'<paragraph>b]om</paragraph>',
+						'<pparent>x<heading1>foo<hchild>ba[]om</hchild></heading1></pparent>'
+					);
 
 
-					root._appendChild(
-						new Element( 'paragraph', null, 'foo' )
+					test(
+						'removes elements',
+						'<pparent><paragraph><pchild>[bar</pchild></paragraph></pparent>' +
+						'<paragraph>b]om</paragraph>',
+						'<paragraph>[]om</paragraph>'
 					);
 					);
 
 
-					root._appendChild(
-						new Element( 'pparent', null, [
-							new Element( 'paragraph', null, [
-								new Element( 'pchild', null, 'bar' )
-							] )
-						] )
+					test(
+						'removes elements up to common ancestor (different names)',
+						'<pparent>' +
+							'<heading1><hchild>[foo</hchild></heading1>' +
+							'<paragraph>b]ar</paragraph>y' +
+						'</pparent>',
+						'<pparent><paragraph>[]ar</paragraph>y</pparent>'
 					);
 					);
+				} );
 
 
-					const range = new Range(
-						new Position( doc.getRoot(), [ 0, 2 ] ), // f[oo
-						new Position( doc.getRoot(), [ 1, 0, 0, 3 ] ) // bar]
+				describe( 'with 3rd level of nesting o the right end', () => {
+					test(
+						'merges elements',
+						'<paragraph>b[om</paragraph>' +
+						'<pparent><paragraph><pchild>ba]r</pchild></paragraph></pparent>',
+						'<paragraph>b[]r</paragraph>'
 					);
 					);
 
 
-					model.change( writer => {
-						writer.setSelection( range );
-					} );
+					test(
+						'merges elements (different names)',
+						'<paragraph>bo[m</paragraph>' +
+						'<pparent><heading1><hchild>b]ar</hchild></heading1></pparent>',
+						'<paragraph>bo[]ar</paragraph>'
+					);
+					test(
+						'merges elements (different names, reversed)',
+						'<heading1>bo[m</heading1>' +
+						'<pparent><paragraph><pchild>b]ar</pchild></paragraph></pparent>',
+						'<heading1>bo[]ar</heading1>'
+					);
 
 
-					deleteContent( model, doc.selection );
+					test(
+						'removes elements',
+						'<paragraph>[bom</paragraph>' +
+						'<pparent><paragraph><pchild>b]ar</pchild></paragraph></pparent>',
+						'<pparent><paragraph><pchild>[]ar</pchild></paragraph></pparent>'
+					);
 
 
-					expect( getData( model ) )
-						.to.equal( '<paragraph>fo[]</paragraph>' );
+					test(
+						'removes elements up to common ancestor (different names)',
+						'<pparent>' +
+							'<heading1>[bar</heading1>y' +
+							'<paragraph><pchild>f]oo</pchild></paragraph>' +
+						'</pparent>',
+						'<pparent><paragraph><pchild>[]oo</pchild></paragraph></pparent>'
+					);
 				} );
 				} );
 			} );
 			} );
 
 

+ 35 - 0
packages/ckeditor5-engine/tests/model/writer.js

@@ -86,6 +86,35 @@ describe( 'Writer', () => {
 		} );
 		} );
 	} );
 	} );
 
 
+	describe( 'cloneElement()', () => {
+		it( 'should make deep copy of element', () => {
+			const element = createElement( 'foo', { 'abc': '123' } );
+
+			insertElement( createElement( 'bar', { 'xyz': '789' } ), element );
+
+			const clonedElement = cloneElement( element );
+
+			expect( clonedElement ).to.not.equal( element );
+			expect( clonedElement.getChild( 0 ) ).to.not.equal( element.getChild( 0 ) );
+			expect( clonedElement.toJSON() ).to.deep.equal( element.toJSON() );
+		} );
+
+		it( 'should make shallow copy of element', () => {
+			const element = createElement( 'foo', { 'abc': '123' } );
+
+			insertElement( createElement( 'bar', { 'xyz': '789' } ), element );
+
+			const elementJson = element.toJSON();
+			delete elementJson.children;
+
+			const clonedElement = cloneElement( element, false );
+
+			expect( clonedElement ).to.not.equal( element );
+			expect( clonedElement.childCount ).to.equal( 0 );
+			expect( clonedElement.toJSON() ).to.deep.equal( elementJson );
+		} );
+	} );
+
 	describe( 'insert()', () => {
 	describe( 'insert()', () => {
 		it( 'should insert node at given position', () => {
 		it( 'should insert node at given position', () => {
 			const parent = createDocumentFragment();
 			const parent = createDocumentFragment();
@@ -2902,6 +2931,12 @@ describe( 'Writer', () => {
 		} );
 		} );
 	}
 	}
 
 
+	function cloneElement( element, deep ) {
+		return model.change( writer => {
+			return writer.cloneElement( element, deep );
+		} );
+	}
+
 	function insert( item, itemOrPosition, offset ) {
 	function insert( item, itemOrPosition, offset ) {
 		model.enqueueChange( batch, writer => {
 		model.enqueueChange( batch, writer => {
 			writer.insert( item, itemOrPosition, offset );
 			writer.insert( item, itemOrPosition, offset );

+ 1 - 1
packages/ckeditor5-font/src/fontfamily.js

@@ -120,7 +120,7 @@ export default class FontFamily extends Plugin {
  *
  *
  * You can preserve pasted font family values by switching the `supportAllValues` option to `true`:
  * You can preserve pasted font family values by switching the `supportAllValues` option to `true`:
  *
  *
- *		const fontSizeConfig = {
+ *		const fontFamilyConfig = {
  *			supportAllValues: true
  *			supportAllValues: true
  *		};
  *		};
  *
  *

+ 1 - 1
packages/ckeditor5-font/src/fontfamily/utils.js

@@ -66,7 +66,7 @@ function generateFontPreset( fontDefinition ) {
 
 
 	return {
 	return {
 		title: firstFontName,
 		title: firstFontName,
-		model: firstFontName,
+		model: cssFontNames,
 		view: {
 		view: {
 			name: 'span',
 			name: 'span',
 			styles: {
 			styles: {

+ 6 - 4
packages/ckeditor5-font/tests/fontfamily/fontfamilyediting.js

@@ -104,9 +104,9 @@ describe( 'FontFamilyEditing', () => {
 				} );
 				} );
 
 
 				it( 'should convert defined fontFamily attribute values', () => {
 				it( 'should convert defined fontFamily attribute values', () => {
-					setModelData( doc, '<paragraph>f<$text fontFamily="Arial">o</$text>o</paragraph>' );
+					setModelData( doc, '<paragraph>f<$text fontFamily="Arial, Helvetica, sans-serif">o</$text>o</paragraph>' );
 
 
-					expect( editor.getData() ).to.equal( '<p>f<span style="font-family:Arial;">o</span>o</p>' );
+					expect( editor.getData() ).to.equal( '<p>f<span style="font-family:Arial, Helvetica, sans-serif;">o</span>o</p>' );
 				} );
 				} );
 			} );
 			} );
 
 
@@ -174,10 +174,12 @@ describe( 'FontFamilyEditing', () => {
 		} );
 		} );
 
 
 		it( 'should convert fontFamily attribute to configured complex preset', () => {
 		it( 'should convert fontFamily attribute to configured complex preset', () => {
-			setModelData( doc, '<paragraph>f<$text fontFamily="Lucida Sans Unicode">o</$text>o</paragraph>' );
+			const fontFamily = '\'Lucida Sans Unicode\', \'Lucida Grande\', sans-serif';
+
+			setModelData( doc, `<paragraph>f<$text fontFamily="${ fontFamily }">o</$text>o</paragraph>` );
 
 
 			expect( editor.getData() )
 			expect( editor.getData() )
-				.to.equal( '<p>f<span style="font-family:\'Lucida Sans Unicode\', \'Lucida Grande\', sans-serif;">o</span>o</p>' );
+				.to.equal( `<p>f<span style="font-family:${ fontFamily };">o</span>o</p>` );
 		} );
 		} );
 
 
 		it( 'should convert fontFamily attribute from user defined settings', () => {
 		it( 'should convert fontFamily attribute from user defined settings', () => {

+ 50 - 28
packages/ckeditor5-font/tests/fontfamily/fontfamilyui.js

@@ -10,9 +10,11 @@ import FontFamilyUI from '../../src/fontfamily/fontfamilyui';
 
 
 import fontFamilyIcon from '../../theme/icons/font-family.svg';
 import fontFamilyIcon from '../../theme/icons/font-family.svg';
 
 
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import { add as addTranslations, _clear as clearTranslations } from '@ckeditor/ckeditor5-utils/src/translation-service';
 import { add as addTranslations, _clear as clearTranslations } from '@ckeditor/ckeditor5-utils/src/translation-service';
+import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
 
 describe( 'FontFamilyUI', () => {
 describe( 'FontFamilyUI', () => {
 	let editor, command, element;
 	let editor, command, element;
@@ -97,47 +99,67 @@ describe( 'FontFamilyUI', () => {
 			expect( listView.items.map( item => item.children.first.isOn ) )
 			expect( listView.items.map( item => item.children.first.isOn ) )
 				.to.deep.equal( [ true, false, false, false, false, false, false, false, false ] );
 				.to.deep.equal( [ true, false, false, false, false, false, false, false, false ] );
 
 
-			command.value = 'Arial';
+			command.value = 'Arial, Helvetica, sans-serif';
 
 
 			// The second item is 'Arial' font family.
 			// The second item is 'Arial' font family.
 			expect( listView.items.map( item => item.children.first.isOn ) )
 			expect( listView.items.map( item => item.children.first.isOn ) )
 				.to.deep.equal( [ false, true, false, false, false, false, false, false, false ] );
 				.to.deep.equal( [ false, true, false, false, false, false, false, false, false ] );
 		} );
 		} );
 
 
-		it( 'should activate current option in dropdown for full font family definitions', () => {
-			const element = document.createElement( 'div' );
-			document.body.appendChild( element );
+		describe( 'with supportAllValues=true', () => {
+			let editor, element, command, dropdown;
 
 
-			return ClassicTestEditor
-				.create( element, {
-					plugins: [ FontFamilyEditing, FontFamilyUI ],
-					fontSize: {
-						supportAllValues: true
-					}
-				} )
-				.then( editor => {
-					const command = editor.commands.get( 'fontFamily' );
-					const dropdown = editor.ui.componentFactory.create( 'fontFamily' );
+			beforeEach( async () => {
+				element = document.createElement( 'div' );
+				document.body.appendChild( element );
+
+				editor = await ClassicTestEditor
+					.create( element, {
+						plugins: [ Paragraph, FontFamilyEditing, FontFamilyUI ],
+						fontSize: {
+							supportAllValues: true
+						}
+					} );
+
+				command = editor.commands.get( 'fontFamily' );
+				dropdown = editor.ui.componentFactory.create( 'fontFamily' );
+			} );
+
+			afterEach( async () => {
+				await editor.destroy();
+				element.remove();
+			} );
 
 
-					const listView = dropdown.listView;
+			it( 'should activate the current option in the dropdown for full font family definitions', () => {
+				const listView = dropdown.listView;
+
+				command.value = undefined;
+
+				// The first item is 'default' font family.
+				expect( listView.items.map( item => item.children.first.isOn ) )
+					.to.deep.equal( [ true, false, false, false, false, false, false, false, false ] );
+
+				command.value = '\'Courier New\', Courier, monospace';
 
 
-					command.value = undefined;
+				// The third item is 'Courier New' font family.
+				expect( listView.items.map( item => item.children.first.isOn ) )
+					.to.deep.equal( [ false, false, true, false, false, false, false, false, false ] );
+			} );
+
+			it( 'should apply the complete font-family value (list of font-families)', () => {
+				const listView = dropdown.listView;
+				const fontFamilyArialButton = listView.items.get( 1 ).children.first;
 
 
-					// The first item is 'default' font family.
-					expect( listView.items.map( item => item.children.first.isOn ) )
-						.to.deep.equal( [ true, false, false, false, false, false, false, false, false ] );
+				setModelData( editor.model, '<paragraph>f[oo]</paragraph>' );
 
 
-					command.value = '\'Courier New\', Courier, monospace';
+				fontFamilyArialButton.fire( 'execute' );
 
 
-					// The third item is 'Courier New' font family.
-					expect( listView.items.map( item => item.children.first.isOn ) )
-						.to.deep.equal( [ false, false, true, false, false, false, false, false, false ] );
+				expect( getModelData( editor.model ) ).to.equal(
+					'<paragraph>f[<$text fontFamily="Arial, Helvetica, sans-serif">oo</$text>]</paragraph>'
+				);
 
 
-					return editor.destroy();
-				} )
-				.then( () => {
-					element.remove();
-				} );
+				expect( editor.getData() ).to.equal( '<p>f<span style="font-family:Arial, Helvetica, sans-serif;">oo</span></p>' );
+			} );
 		} );
 		} );
 
 
 		describe( 'model to command binding', () => {
 		describe( 'model to command binding', () => {

+ 2 - 2
packages/ckeditor5-font/tests/fontfamily/utils.js

@@ -62,7 +62,7 @@ describe( 'FontFamily utils', () => {
 					},
 					},
 					{
 					{
 						title: 'Comic Sans MS',
 						title: 'Comic Sans MS',
-						model: 'Comic Sans MS',
+						model: '\'Comic Sans MS\', sans-serif',
 						view: {
 						view: {
 							name: 'span',
 							name: 'span',
 							styles: {
 							styles: {
@@ -73,7 +73,7 @@ describe( 'FontFamily utils', () => {
 					},
 					},
 					{
 					{
 						title: 'Lucida Console',
 						title: 'Lucida Console',
-						model: 'Lucida Console',
+						model: '\'Lucida Console\', \'Courier New\', Courier, monospace',
 						view: {
 						view: {
 							name: 'span',
 							name: 'span',
 							styles: {
 							styles: {

+ 2 - 1
packages/ckeditor5-font/tests/integration.js

@@ -37,7 +37,8 @@ describe( 'Integration test Font', () => {
 		it( 'should render one span element for all types of font features', () => {
 		it( 'should render one span element for all types of font features', () => {
 			setModelData( model,
 			setModelData( model,
 				'<paragraph>' +
 				'<paragraph>' +
-					'<$text fontColor="#123456" fontBackgroundColor="rgb(10,20,30)" fontSize="big" fontFamily="Arial">foo</$text>' +
+					'<$text fontColor="#123456" fontBackgroundColor="rgb(10,20,30)" fontSize="big" ' +
+						'fontFamily="Arial, Helvetica, sans-serif">foo</$text>' +
 				'</paragraph>'
 				'</paragraph>'
 			);
 			);
 
 

+ 2 - 1
packages/ckeditor5-font/tests/manual/font-family.md

@@ -14,7 +14,8 @@ Try to:
 ### Converters mode
 ### Converters mode
 
 
 The "Restricted value matching" option means that all font-family values that aren't defined in the plugin's configuration will be removed (e.g. when pasted from Google Docs).
 The "Restricted value matching" option means that all font-family values that aren't defined in the plugin's configuration will be removed (e.g. when pasted from Google Docs).
-This behaviour can be disabled by selecting the "Disabled value matching" option.
+
+This behaviour can be disabled by selecting the "Disabled value matching" option, which sets ["`supportAllValues: true`"](https://ckeditor.com/docs/ckeditor5/latest/api/module_font_fontfamily-FontFamilyConfig.html#member-supportAllValues) in the font family configuration.
 
 
 The `Docs-Roboto, Arial` font-family is not specified in the plugin's configuration and should be restored to default font when the "Restricted value matching" option is selected.
 The `Docs-Roboto, Arial` font-family is not specified in the plugin's configuration and should be restored to default font when the "Restricted value matching" option is selected.
 
 

+ 2 - 2
packages/ckeditor5-table/src/commands/insertcolumncommand.js

@@ -8,8 +8,8 @@
  */
  */
 
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
 import Command from '@ckeditor/ckeditor5-core/src/command';
-import { findAncestor } from './utils';
-import { getColumnIndexes, getSelectionAffectedTableCells } from '../utils';
+import { getColumnIndexes, getSelectionAffectedTableCells } from '../utils/selection';
+import { findAncestor } from '../utils/common';
 
 
 /**
 /**
  * The insert column command.
  * The insert column command.

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

@@ -8,8 +8,8 @@
  */
  */
 
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
 import Command from '@ckeditor/ckeditor5-core/src/command';
-import { findAncestor } from './utils';
-import { getRowIndexes, getSelectionAffectedTableCells } from '../utils';
+import { getRowIndexes, getSelectionAffectedTableCells } from '../utils/selection';
+import { findAncestor } from '../utils/common';
 
 
 /**
 /**
  * The insert row command.
  * The insert row command.

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

@@ -9,8 +9,9 @@
 
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
 import Command from '@ckeditor/ckeditor5-core/src/command';
 import TableWalker from '../tablewalker';
 import TableWalker from '../tablewalker';
-import { isHeadingColumnCell, findAncestor } from './utils';
-import { getTableCellsContainingSelection } from '../utils';
+import { getTableCellsContainingSelection } from '../utils/selection';
+import { findAncestor, isHeadingColumnCell } from '../utils/common';
+import { removeEmptyRowsColumns } from '../utils/structure';
 
 
 /**
 /**
  * The merge cell command.
  * The merge cell command.
@@ -104,13 +105,11 @@ export default class MergeCellCommand extends Command {
 			writer.setAttribute( spanAttribute, cellSpan + cellToMergeSpan, cellToExpand );
 			writer.setAttribute( spanAttribute, cellSpan + cellToMergeSpan, cellToExpand );
 			writer.setSelection( writer.createRangeIn( cellToExpand ) );
 			writer.setSelection( writer.createRangeIn( cellToExpand ) );
 
 
-			// Remove empty row after merging.
-			if ( !removedTableCellRow.childCount ) {
-				const tableUtils = this.editor.plugins.get( 'TableUtils' );
-				const table = findAncestor( 'table', removedTableCellRow );
+			const tableUtils = this.editor.plugins.get( 'TableUtils' );
+			const table = findAncestor( 'table', removedTableCellRow );
 
 
-				tableUtils.removeRows( table, { at: removedTableCellRow.index, batch: writer.batch } );
-			}
+			// Remove empty rows and columns after merging.
+			removeEmptyRowsColumns( table, tableUtils, writer.batch );
 		} );
 		} );
 	}
 	}
 
 

+ 6 - 14
packages/ckeditor5-table/src/commands/mergecellscommand.js

@@ -9,8 +9,9 @@
 
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
 import Command from '@ckeditor/ckeditor5-core/src/command';
 import TableUtils from '../tableutils';
 import TableUtils from '../tableutils';
-import { findAncestor, updateNumericAttribute } from './utils';
-import { isSelectionRectangular, getSelectedTableCells } from '../utils';
+import { getSelectedTableCells, isSelectionRectangular } from '../utils/selection';
+import { findAncestor, updateNumericAttribute } from '../utils/common';
+import { removeEmptyRowsColumns } from '../utils/structure';
 
 
 /**
 /**
  * The merge cells command.
  * The merge cells command.
@@ -57,23 +58,14 @@ export default class MergeCellsCommand extends Command {
 			updateNumericAttribute( 'colspan', mergeWidth, firstTableCell, writer );
 			updateNumericAttribute( 'colspan', mergeWidth, firstTableCell, writer );
 			updateNumericAttribute( 'rowspan', mergeHeight, firstTableCell, writer );
 			updateNumericAttribute( 'rowspan', mergeHeight, firstTableCell, writer );
 
 
-			const emptyRowsIndexes = [];
-
 			for ( const tableCell of selectedTableCells ) {
 			for ( const tableCell of selectedTableCells ) {
-				const tableRow = tableCell.parent;
-
 				mergeTableCells( tableCell, firstTableCell, writer );
 				mergeTableCells( tableCell, firstTableCell, writer );
-
-				if ( !tableRow.childCount ) {
-					emptyRowsIndexes.push( tableRow.index );
-				}
 			}
 			}
 
 
-			if ( emptyRowsIndexes.length ) {
-				const table = findAncestor( 'table', firstTableCell );
+			const table = findAncestor( 'table', firstTableCell );
 
 
-				emptyRowsIndexes.reverse().forEach( row => tableUtils.removeRows( table, { at: row, batch: writer.batch } ) );
-			}
+			// Remove rows and columns that become empty (have no anchored cells).
+			removeEmptyRowsColumns( table, tableUtils, writer.batch );
 
 
 			writer.setSelection( firstTableCell, 'in' );
 			writer.setSelection( firstTableCell, 'in' );
 		} );
 		} );

+ 2 - 2
packages/ckeditor5-table/src/commands/removecolumncommand.js

@@ -10,8 +10,8 @@
 import Command from '@ckeditor/ckeditor5-core/src/command';
 import Command from '@ckeditor/ckeditor5-core/src/command';
 
 
 import TableWalker from '../tablewalker';
 import TableWalker from '../tablewalker';
-import { getColumnIndexes, getSelectionAffectedTableCells } from '../utils';
-import { findAncestor } from './utils';
+import { getColumnIndexes, getSelectionAffectedTableCells } from '../utils/selection';
+import { findAncestor } from '../utils/common';
 
 
 /**
 /**
  * The remove column command.
  * The remove column command.

+ 2 - 2
packages/ckeditor5-table/src/commands/removerowcommand.js

@@ -9,8 +9,8 @@
 
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
 import Command from '@ckeditor/ckeditor5-core/src/command';
 
 
-import { findAncestor } from './utils';
-import { getRowIndexes, getSelectionAffectedTableCells } from '../utils';
+import { getRowIndexes, getSelectionAffectedTableCells } from '../utils/selection';
+import { findAncestor } from '../utils/common';
 
 
 /**
 /**
  * The remove row command.
  * The remove row command.

+ 2 - 2
packages/ckeditor5-table/src/commands/selectcolumncommand.js

@@ -10,8 +10,8 @@
 import Command from '@ckeditor/ckeditor5-core/src/command';
 import Command from '@ckeditor/ckeditor5-core/src/command';
 
 
 import TableWalker from '../tablewalker';
 import TableWalker from '../tablewalker';
-import { findAncestor } from './utils';
-import { getSelectionAffectedTableCells } from '../utils';
+import { getSelectionAffectedTableCells } from '../utils/selection';
+import { findAncestor } from '../utils/common';
 
 
 /**
 /**
  * The select column command.
  * The select column command.

+ 2 - 2
packages/ckeditor5-table/src/commands/selectrowcommand.js

@@ -9,8 +9,8 @@
 
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
 import Command from '@ckeditor/ckeditor5-core/src/command';
 
 
-import { findAncestor } from './utils';
-import { getRowIndexes, getSelectionAffectedTableCells } from '../utils';
+import { getRowIndexes, getSelectionAffectedTableCells } from '../utils/selection';
+import { findAncestor } from '../utils/common';
 
 
 /**
 /**
  * The select row command.
  * The select row command.

+ 7 - 2
packages/ckeditor5-table/src/commands/setheadercolumncommand.js

@@ -9,8 +9,13 @@
 
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
 import Command from '@ckeditor/ckeditor5-core/src/command';
 
 
-import { findAncestor, isHeadingColumnCell, updateNumericAttribute } from './utils';
-import { getColumnIndexes, getSelectionAffectedTableCells, getHorizontallyOverlappingCells, splitVertically } from '../utils';
+import {
+	findAncestor,
+	isHeadingColumnCell,
+	updateNumericAttribute
+} from '../utils/common';
+import { getColumnIndexes, getSelectionAffectedTableCells } from '../utils/selection';
+import { getHorizontallyOverlappingCells, splitVertically } from '../utils/structure';
 
 
 /**
 /**
  * The header column command.
  * The header column command.

+ 3 - 2
packages/ckeditor5-table/src/commands/setheaderrowcommand.js

@@ -9,8 +9,9 @@
 
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
 import Command from '@ckeditor/ckeditor5-core/src/command';
 
 
-import { findAncestor, updateNumericAttribute } from './utils';
-import { getVerticallyOverlappingCells, getRowIndexes, getSelectionAffectedTableCells, splitHorizontally } from '../utils';
+import { findAncestor, updateNumericAttribute } from '../utils/common';
+import { getRowIndexes, getSelectionAffectedTableCells } from '../utils/selection';
+import { getVerticallyOverlappingCells, splitHorizontally } from '../utils/structure';
 
 
 /**
 /**
  * The header row command.
  * The header row command.

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

@@ -8,7 +8,7 @@
  */
  */
 
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
 import Command from '@ckeditor/ckeditor5-core/src/command';
-import { getSelectionAffectedTableCells } from '../utils';
+import { getSelectionAffectedTableCells } from '../utils/selection';
 
 
 /**
 /**
  * The split cell command.
  * The split cell command.

+ 15 - 2
packages/ckeditor5-table/src/converters/downcast.js

@@ -8,8 +8,7 @@
  */
  */
 
 
 import TableWalker from './../tablewalker';
 import TableWalker from './../tablewalker';
-import { toWidgetEditable, setHighlightHandling } from '@ckeditor/ckeditor5-widget/src/utils';
-import { toTableWidget } from '../utils';
+import { toWidget, toWidgetEditable, setHighlightHandling } from '@ckeditor/ckeditor5-widget/src/utils';
 
 
 /**
 /**
  * Model table element to view table element conversion helper.
  * Model table element to view table element conversion helper.
@@ -307,6 +306,20 @@ export function downcastRemoveRow() {
 	}, { priority: 'higher' } );
 	}, { priority: 'higher' } );
 }
 }
 
 
+// Converts a given {@link module:engine/view/element~Element} to a table widget:
+// * Adds a {@link module:engine/view/element~Element#_setCustomProperty custom property} allowing to recognize the table widget element.
+// * Calls the {@link module:widget/utils~toWidget} function with the proper element's label creator.
+//
+// @param {module:engine/view/element~Element} viewElement
+// @param {module:engine/view/downcastwriter~DowncastWriter} writer An instance of the view writer.
+// @param {String} label The element's label. It will be concatenated with the table `alt` attribute if one is present.
+// @returns {module:engine/view/element~Element}
+function toTableWidget( viewElement, writer ) {
+	writer.setCustomProperty( 'table', true, viewElement );
+
+	return toWidget( viewElement, writer, { hasSelectionHandle: true } );
+}
+
 // Renames an existing table cell in the view to a given element name.
 // Renames an existing table cell in the view to a given element name.
 //
 //
 // **Note** This method will not do anything if a view table cell has not been converted yet.
 // **Note** This method will not do anything if a view table cell has not been converted yet.

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

@@ -7,8 +7,8 @@
  * @module table/converters/table-layout-post-fixer
  * @module table/converters/table-layout-post-fixer
  */
  */
 
 
-import { createEmptyTableCell, findAncestor, updateNumericAttribute } from './../commands/utils';
 import TableWalker from './../tablewalker';
 import TableWalker from './../tablewalker';
+import { createEmptyTableCell, findAncestor, updateNumericAttribute } from '../utils/common';
 
 
 /**
 /**
  * Injects a table layout post-fixer into the model.
  * Injects a table layout post-fixer into the model.

+ 1 - 1
packages/ckeditor5-table/src/converters/upcasttable.js

@@ -7,7 +7,7 @@
  * @module table/converters/upcasttable
  * @module table/converters/upcasttable
  */
  */
 
 
-import { createEmptyTableCell } from '../commands/utils';
+import { createEmptyTableCell } from '../utils/common';
 
 
 /**
 /**
  * View table element to model table element conversion helper.
  * View table element to model table element conversion helper.

+ 5 - 3
packages/ckeditor5-table/src/table.js

@@ -13,7 +13,8 @@ import TableEditing from './tableediting';
 import TableUI from './tableui';
 import TableUI from './tableui';
 import TableSelection from './tableselection';
 import TableSelection from './tableselection';
 import TableClipboard from './tableclipboard';
 import TableClipboard from './tableclipboard';
-import TableNavigation from './tablenavigation';
+import TableKeyboard from './tablekeyboard';
+import TableMouse from './tablemouse';
 import Widget from '@ckeditor/ckeditor5-widget/src/widget';
 import Widget from '@ckeditor/ckeditor5-widget/src/widget';
 
 
 import '../theme/table.css';
 import '../theme/table.css';
@@ -27,7 +28,8 @@ import '../theme/table.css';
  *
  *
  * * {@link module:table/tableediting~TableEditing editing feature},
  * * {@link module:table/tableediting~TableEditing editing feature},
  * * {@link module:table/tableselection~TableSelection selection feature},
  * * {@link module:table/tableselection~TableSelection selection feature},
- * * {@link module:table/tablenavigation~TableNavigation keyboard navigation feature},
+ * * {@link module:table/tablekeyboard~TableKeyboard keyboard navigation feature},
+ * * {@link module:table/tablemouse~TableMouse mouse selection feature},
  * * {@link module:table/tableclipboard~TableClipboard clipboard feature},
  * * {@link module:table/tableclipboard~TableClipboard clipboard feature},
  * * {@link module:table/tableui~TableUI UI feature}.
  * * {@link module:table/tableui~TableUI UI feature}.
  *
  *
@@ -38,7 +40,7 @@ export default class Table extends Plugin {
 	 * @inheritDoc
 	 * @inheritDoc
 	 */
 	 */
 	static get requires() {
 	static get requires() {
-		return [ TableEditing, TableUI, TableSelection, TableClipboard, TableNavigation, Widget ];
+		return [ TableEditing, TableUI, TableSelection, TableMouse, TableKeyboard, TableClipboard, Widget ];
 	}
 	}
 
 
 	/**
 	/**

+ 1 - 1
packages/ckeditor5-table/src/tablecellproperties.js

@@ -69,7 +69,7 @@ export default class TableCellProperties extends Plugin {
  * allowing users to pick colors in a more convenient way.
  * allowing users to pick colors in a more convenient way.
  *
  *
  * The default color palettes for the cell background and the cell border are the same
  * The default color palettes for the cell background and the cell border are the same
- * ({@link module:table/ui/utils~defaultColors check out their content}).
+ * ({@link module:table/utils/ui/table-properties~defaultColors check out their content}).
  *
  *
  * Both color palette configurations must follow the
  * Both color palette configurations must follow the
  * {@link module:table/table~TableColorConfig table color configuration format}.
  * {@link module:table/table~TableColorConfig table color configuration format}.

+ 1 - 1
packages/ckeditor5-table/src/tablecellproperties/commands/tablecellbordercolorcommand.js

@@ -8,7 +8,7 @@
  */
  */
 
 
 import TableCellPropertyCommand from './tablecellpropertycommand';
 import TableCellPropertyCommand from './tablecellpropertycommand';
-import { getSingleValue } from '../../commands/utils';
+import { getSingleValue } from '../../utils/table-properties';
 
 
 /**
 /**
  * The table cell border color command.
  * The table cell border color command.

+ 1 - 1
packages/ckeditor5-table/src/tablecellproperties/commands/tablecellborderstylecommand.js

@@ -8,7 +8,7 @@
  */
  */
 
 
 import TableCellPropertyCommand from './tablecellpropertycommand';
 import TableCellPropertyCommand from './tablecellpropertycommand';
-import { getSingleValue } from '../../commands/utils';
+import { getSingleValue } from '../../utils/table-properties';
 
 
 /**
 /**
  * The table cell border style command.
  * The table cell border style command.

+ 1 - 1
packages/ckeditor5-table/src/tablecellproperties/commands/tablecellborderwidthcommand.js

@@ -7,8 +7,8 @@
  * @module table/tablecellproperties/commands/tablecellborderwidthcommand
  * @module table/tablecellproperties/commands/tablecellborderwidthcommand
  */
  */
 
 
-import { addDefaultUnitToNumericValue, getSingleValue } from '../../commands/utils';
 import TableCellPropertyCommand from './tablecellpropertycommand';
 import TableCellPropertyCommand from './tablecellpropertycommand';
+import { addDefaultUnitToNumericValue, getSingleValue } from '../../utils/table-properties';
 
 
 /**
 /**
  * The table cell border width command.
  * The table cell border width command.

+ 1 - 1
packages/ckeditor5-table/src/tablecellproperties/commands/tablecellheightcommand.js

@@ -7,8 +7,8 @@
  * @module table/tablecellproperties/commands/tablecellheightcommand
  * @module table/tablecellproperties/commands/tablecellheightcommand
  */
  */
 
 
-import { addDefaultUnitToNumericValue } from '../../commands/utils';
 import TableCellPropertyCommand from './tablecellpropertycommand';
 import TableCellPropertyCommand from './tablecellpropertycommand';
+import { addDefaultUnitToNumericValue } from '../../utils/table-properties';
 
 
 /**
 /**
  * The table cell height command.
  * The table cell height command.

+ 1 - 1
packages/ckeditor5-table/src/tablecellproperties/commands/tablecellpaddingcommand.js

@@ -7,8 +7,8 @@
  * @module table/tablecellproperties/commands/tablecellpaddingcommand
  * @module table/tablecellproperties/commands/tablecellpaddingcommand
  */
  */
 
 
-import { addDefaultUnitToNumericValue, getSingleValue } from '../../commands/utils';
 import TableCellPropertyCommand from './tablecellpropertycommand';
 import TableCellPropertyCommand from './tablecellpropertycommand';
+import { addDefaultUnitToNumericValue, getSingleValue } from '../../utils/table-properties';
 
 
 /**
 /**
  * The table cell padding command.
  * The table cell padding command.

+ 1 - 1
packages/ckeditor5-table/src/tablecellproperties/commands/tablecellpropertycommand.js

@@ -8,7 +8,7 @@
  */
  */
 
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
 import Command from '@ckeditor/ckeditor5-core/src/command';
-import { getSelectionAffectedTableCells } from '../../utils';
+import { getSelectionAffectedTableCells } from '../../utils/selection';
 
 
 /**
 /**
  * The table cell attribute command.
  * The table cell attribute command.

+ 1 - 1
packages/ckeditor5-table/src/tablecellproperties/commands/tablecellwidthcommand.js

@@ -7,8 +7,8 @@
  * @module table/tablecellproperties/commands/tablecellwidthcommand
  * @module table/tablecellproperties/commands/tablecellwidthcommand
  */
  */
 
 
-import { addDefaultUnitToNumericValue } from '../../commands/utils';
 import TableCellPropertyCommand from './tablecellpropertycommand';
 import TableCellPropertyCommand from './tablecellpropertycommand';
+import { addDefaultUnitToNumericValue } from '../../utils/table-properties';
 
 
 /**
 /**
  * The table cell width command.
  * The table cell width command.

+ 4 - 5
packages/ckeditor5-table/src/tablecellproperties/tablecellpropertiesui.js

@@ -9,26 +9,25 @@
 
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
-import { getTableWidgetAncestor } from '../utils';
 import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsidehandler';
 import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsidehandler';
 import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
 import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
 import TableCellPropertiesView from './ui/tablecellpropertiesview';
 import TableCellPropertiesView from './ui/tablecellpropertiesview';
 import tableCellProperties from './../../theme/icons/table-cell-properties.svg';
 import tableCellProperties from './../../theme/icons/table-cell-properties.svg';
 import {
 import {
 	colorFieldValidator,
 	colorFieldValidator,
-	getBalloonCellPositionData,
 	getLocalizedColorErrorText,
 	getLocalizedColorErrorText,
 	getLocalizedLengthErrorText,
 	getLocalizedLengthErrorText,
 	defaultColors,
 	defaultColors,
 	lengthFieldValidator,
 	lengthFieldValidator,
-	lineWidthFieldValidator,
-	repositionContextualBalloon
-} from '../ui/utils';
+	lineWidthFieldValidator
+} from '../utils/ui/table-properties';
 import {
 import {
 	getLocalizedColorOptions,
 	getLocalizedColorOptions,
 	normalizeColorOptions
 	normalizeColorOptions
 } from '@ckeditor/ckeditor5-ui/src/colorgrid/utils';
 } from '@ckeditor/ckeditor5-ui/src/colorgrid/utils';
 import { debounce } from 'lodash-es';
 import { debounce } from 'lodash-es';
+import { getTableWidgetAncestor } from '../utils/ui/widget';
+import { getBalloonCellPositionData, repositionContextualBalloon } from '../utils/ui/contextualballoon';
 
 
 const ERROR_TEXT_TIMEOUT = 500;
 const ERROR_TEXT_TIMEOUT = 500;
 
 

+ 1 - 1
packages/ckeditor5-table/src/tablecellproperties/ui/tablecellpropertiesview.js

@@ -26,7 +26,7 @@ import {
 	getBorderStyleDefinitions,
 	getBorderStyleDefinitions,
 	getBorderStyleLabels,
 	getBorderStyleLabels,
 	getLabeledColorInputCreator
 	getLabeledColorInputCreator
-} from '../../ui/utils';
+} from '../../utils/ui/table-properties';
 import FormRowView from '../../ui/formrowview';
 import FormRowView from '../../ui/formrowview';
 
 
 import FormHeaderView from '@ckeditor/ckeditor5-ui/src/formheader/formheaderview';
 import FormHeaderView from '@ckeditor/ckeditor5-ui/src/formheader/formheaderview';

+ 146 - 116
packages/ckeditor5-table/src/tableclipboard.js

@@ -12,18 +12,18 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import TableSelection from './tableselection';
 import TableSelection from './tableselection';
 import TableWalker from './tablewalker';
 import TableWalker from './tablewalker';
 import {
 import {
-	getColumnIndexes,
-	getVerticallyOverlappingCells,
-	getRowIndexes,
-	getSelectionAffectedTableCells,
+	findAncestor
+} from './utils/common';
+import TableUtils from './tableutils';
+import { getColumnIndexes, getRowIndexes, getSelectionAffectedTableCells, isSelectionRectangular } from './utils/selection';
+import {
+	cropTableToDimensions,
 	getHorizontallyOverlappingCells,
 	getHorizontallyOverlappingCells,
-	isSelectionRectangular,
+	getVerticallyOverlappingCells,
 	splitHorizontally,
 	splitHorizontally,
-	splitVertically
-} from './utils';
-import { findAncestor } from './commands/utils';
-import { cropTableToDimensions, trimTableCellIfNeeded } from './tableselection/croptable';
-import TableUtils from './tableutils';
+	splitVertically,
+	trimTableCellIfNeeded
+} from './utils/structure';
 
 
 /**
 /**
  * This plugin adds support for copying/cutting/pasting fragments of tables.
  * This plugin adds support for copying/cutting/pasting fragments of tables.
@@ -120,7 +120,7 @@ export default class TableClipboard extends Plugin {
 		}
 		}
 
 
 		// We might need to crop table before inserting so reference might change.
 		// We might need to crop table before inserting so reference might change.
-		let pastedTable = getTableIfOnlyTableInContent( content );
+		let pastedTable = getTableIfOnlyTableInContent( content, model );
 
 
 		if ( !pastedTable ) {
 		if ( !pastedTable ) {
 			return;
 			return;
@@ -130,66 +130,18 @@ export default class TableClipboard extends Plugin {
 		evt.stop();
 		evt.stop();
 
 
 		model.change( writer => {
 		model.change( writer => {
-			const columnIndexes = getColumnIndexes( selectedTableCells );
-			const rowIndexes = getRowIndexes( selectedTableCells );
-
-			let { first: firstColumnOfSelection, last: lastColumnOfSelection } = columnIndexes;
-			let { first: firstRowOfSelection, last: lastRowOfSelection } = rowIndexes;
-
-			const pasteHeight = tableUtils.getRows( pastedTable );
-			const pasteWidth = tableUtils.getColumns( pastedTable );
-
-			// Content table to which we insert a pasted table.
-			const selectedTable = findAncestor( 'table', selectedTableCells[ 0 ] );
-
-			// Single cell selected - expand selection to pasted table dimensions.
-			const shouldExpandSelection = selectedTableCells.length === 1;
-
-			if ( shouldExpandSelection ) {
-				lastRowOfSelection += pasteHeight - 1;
-				lastColumnOfSelection += pasteWidth - 1;
-
-				expandTableSize( selectedTable, lastRowOfSelection + 1, lastColumnOfSelection + 1, writer, tableUtils );
-			}
+			const pastedDimensions = {
+				width: tableUtils.getColumns( pastedTable ),
+				height: tableUtils.getRows( pastedTable )
+			};
 
 
-			// In case of expanding selection we do not reset the selection so in this case we will always try to fix selection
-			// like in the case of a non-rectangular area. This might be fixed by re-setting selected cells array but this shortcut is safe.
-			if ( shouldExpandSelection || !isSelectionRectangular( selectedTableCells, tableUtils ) ) {
-				const splitDimensions = {
-					firstRow: firstRowOfSelection,
-					lastRow: lastRowOfSelection,
-					firstColumn: firstColumnOfSelection,
-					lastColumn: lastColumnOfSelection
-				};
-
-				// For a non-rectangular selection (ie in which some cells sticks out from a virtual selection rectangle) we need to create
-				// a table layout that has a rectangular selection. This will split cells so the selection become rectangular.
-				// Beyond this point we will operate on fixed content table.
-				splitCellsToRectangularSelection( selectedTable, splitDimensions, writer );
-			}
-			// However a selected table fragment might be invalid if examined alone. Ie such table fragment:
-			//
-			//    +---+---+---+---+
-			//  0 | a | b | c | d |
-			//    +   +   +---+---+
-			//  1 |   | e | f | g |
-			//    +   +---+   +---+
-			//  2 |   | h |   | i | <- last row, each cell has rowspan = 2,
-			//    +   +   +   +   +    so we need to return 3, not 2
-			//  3 |   |   |   |   |
-			//    +---+---+---+---+
-			//
-			// is invalid as the cells "h" and "i" have rowspans.
-			// This case needs only adjusting the selection dimension as the rest of the algorithm operates on empty slots also.
-			else {
-				lastRowOfSelection = adjustLastRowIndex( selectedTable, rowIndexes, columnIndexes );
-				lastColumnOfSelection = adjustLastColumnIndex( selectedTable, rowIndexes, columnIndexes );
-			}
+			// Prepare the table for pasting.
+			const selection = prepareTableForPasting( selectedTableCells, pastedDimensions, writer, tableUtils );
 
 
 			// Beyond this point we operate on a fixed content table with rectangular selection and proper last row/column values.
 			// Beyond this point we operate on a fixed content table with rectangular selection and proper last row/column values.
 
 
-			const selectionHeight = lastRowOfSelection - firstRowOfSelection + 1;
-			const selectionWidth = lastColumnOfSelection - firstColumnOfSelection + 1;
+			const selectionHeight = selection.lastRow - selection.firstRow + 1;
+			const selectionWidth = selection.lastColumn - selection.firstColumn + 1;
 
 
 			// Crop pasted table if:
 			// Crop pasted table if:
 			// - Pasted table dimensions exceeds selection area.
 			// - Pasted table dimensions exceeds selection area.
@@ -201,28 +153,86 @@ export default class TableClipboard extends Plugin {
 			const cropDimensions = {
 			const cropDimensions = {
 				startRow: 0,
 				startRow: 0,
 				startColumn: 0,
 				startColumn: 0,
-				endRow: Math.min( selectionHeight - 1, pasteHeight - 1 ),
-				endColumn: Math.min( selectionWidth - 1, pasteWidth - 1 )
+				endRow: Math.min( selectionHeight, pastedDimensions.height ) - 1,
+				endColumn: Math.min( selectionWidth, pastedDimensions.width ) - 1
 			};
 			};
 
 
 			pastedTable = cropTableToDimensions( pastedTable, cropDimensions, writer );
 			pastedTable = cropTableToDimensions( pastedTable, cropDimensions, writer );
 
 
-			const pastedDimensions = {
-				width: pasteWidth,
-				height: pasteHeight
-			};
-			const selectionDimensions = {
-				firstColumnOfSelection,
-				firstRowOfSelection,
-				lastColumnOfSelection,
-				lastRowOfSelection
-			};
+			// Content table to which we insert a pasted table.
+			const selectedTable = findAncestor( 'table', selectedTableCells[ 0 ] );
 
 
-			replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selectedTable, selectionDimensions, writer );
+			replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selectedTable, selection, writer );
 		} );
 		} );
 	}
 	}
 }
 }
 
 
+// Prepares a table for pasting and returns adjusted selection dimensions.
+//
+// @param {Array.<module:engine/model/element~Element>} selectedTableCells
+// @param {Object} pastedDimensions
+// @param {Number} pastedDimensions.height
+// @param {Number} pastedDimensions.width
+// @param {module:engine/model/writer~Writer} writer
+// @param {module:table/tableutils~TableUtils} tableUtils
+// @returns {Object} selection
+// @returns {Number} selection.firstColumn
+// @returns {Number} selection.firstRow
+// @returns {Number} selection.lastColumn
+// @returns {Number} selection.lastRow
+function prepareTableForPasting( selectedTableCells, pastedDimensions, writer, tableUtils ) {
+	const selectedTable = findAncestor( 'table', selectedTableCells[ 0 ] );
+
+	const columnIndexes = getColumnIndexes( selectedTableCells );
+	const rowIndexes = getRowIndexes( selectedTableCells );
+
+	const selection = {
+		firstColumn: columnIndexes.first,
+		lastColumn: columnIndexes.last,
+		firstRow: rowIndexes.first,
+		lastRow: rowIndexes.last
+	};
+
+	// Single cell selected - expand selection to pasted table dimensions.
+	const shouldExpandSelection = selectedTableCells.length === 1;
+
+	if ( shouldExpandSelection ) {
+		selection.lastRow += pastedDimensions.height - 1;
+		selection.lastColumn += pastedDimensions.width - 1;
+
+		expandTableSize( selectedTable, selection.lastRow + 1, selection.lastColumn + 1, writer, tableUtils );
+	}
+
+	// In case of expanding selection we do not reset the selection so in this case we will always try to fix selection
+	// like in the case of a non-rectangular area. This might be fixed by re-setting selected cells array but this shortcut is safe.
+	if ( shouldExpandSelection || !isSelectionRectangular( selectedTableCells, tableUtils ) ) {
+		// For a non-rectangular selection (ie in which some cells sticks out from a virtual selection rectangle) we need to create
+		// a table layout that has a rectangular selection. This will split cells so the selection become rectangular.
+		// Beyond this point we will operate on fixed content table.
+		splitCellsToRectangularSelection( selectedTable, selection, writer );
+	}
+	// However a selected table fragment might be invalid if examined alone. Ie such table fragment:
+	//
+	//    +---+---+---+---+
+	//  0 | a | b | c | d |
+	//    +   +   +---+---+
+	//  1 |   | e | f | g |
+	//    +   +---+   +---+
+	//  2 |   | h |   | i | <- last row, each cell has rowspan = 2,
+	//    +   +   +   +   +    so we need to return 3, not 2
+	//  3 |   |   |   |   |
+	//    +---+---+---+---+
+	//
+	// is invalid as the cells "h" and "i" have rowspans.
+	// This case needs only adjusting the selection dimension as the rest of the algorithm operates on empty slots also.
+	else {
+		selection.lastRow = adjustLastRowIndex( selectedTable, selection );
+		selection.lastColumn = adjustLastColumnIndex( selectedTable, selection );
+	}
+
+	return selection;
+}
+
 // Replaces the part of selectedTable with pastedTable.
 // Replaces the part of selectedTable with pastedTable.
 //
 //
 // @param {module:engine/model/element~Element} pastedTable
 // @param {module:engine/model/element~Element} pastedTable
@@ -230,28 +240,23 @@ export default class TableClipboard extends Plugin {
 // @param {Number} pastedDimensions.height
 // @param {Number} pastedDimensions.height
 // @param {Number} pastedDimensions.width
 // @param {Number} pastedDimensions.width
 // @param {module:engine/model/element~Element} selectedTable
 // @param {module:engine/model/element~Element} selectedTable
-// @param {Object} selectionDimensions
-// @param {Number} selectionDimensions.firstColumnOfSelection
-// @param {Number} selectionDimensions.firstRowOfSelection
-// @param {Number} selectionDimensions.lastColumnOfSelection
-// @param {Number} selectionDimensions.lastRowOfSelection
+// @param {Object} selection
+// @param {Number} selection.firstColumn
+// @param {Number} selection.firstRow
+// @param {Number} selection.lastColumn
+// @param {Number} selection.lastRow
 // @param {module:engine/model/writer~Writer} writer
 // @param {module:engine/model/writer~Writer} writer
-function replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selectedTable, selectionDimensions, writer ) {
-	const {
-		firstColumnOfSelection, lastColumnOfSelection,
-		firstRowOfSelection, lastRowOfSelection
-	} = selectionDimensions;
-
+function replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selectedTable, selection, writer ) {
 	const { width: pastedWidth, height: pastedHeight } = pastedDimensions;
 	const { width: pastedWidth, height: pastedHeight } = pastedDimensions;
 
 
 	// Holds two-dimensional array that is addressed by [ row ][ column ] that stores cells anchored at given location.
 	// Holds two-dimensional array that is addressed by [ row ][ column ] that stores cells anchored at given location.
 	const pastedTableLocationMap = createLocationMap( pastedTable, pastedWidth, pastedHeight );
 	const pastedTableLocationMap = createLocationMap( pastedTable, pastedWidth, pastedHeight );
 
 
 	const selectedTableMap = [ ...new TableWalker( selectedTable, {
 	const selectedTableMap = [ ...new TableWalker( selectedTable, {
-		startRow: firstRowOfSelection,
-		endRow: lastRowOfSelection,
-		startColumn: firstColumnOfSelection,
-		endColumn: lastColumnOfSelection,
+		startRow: selection.firstRow,
+		endRow: selection.lastRow,
+		startColumn: selection.firstColumn,
+		endColumn: selection.lastColumn,
 		includeAllSlots: true
 		includeAllSlots: true
 	} ) ];
 	} ) ];
 
 
@@ -271,7 +276,7 @@ function replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selected
 		const { row, column, cell, isAnchor } = tableSlot;
 		const { row, column, cell, isAnchor } = tableSlot;
 
 
 		// Save the insert position for current row start.
 		// Save the insert position for current row start.
-		if ( column === firstColumnOfSelection ) {
+		if ( column === selection.firstColumn ) {
 			insertPosition = tableSlot.getPositionBefore();
 			insertPosition = tableSlot.getPositionBefore();
 		}
 		}
 
 
@@ -284,8 +289,8 @@ function replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selected
 		}
 		}
 
 
 		// Map current table slot location to an pasted table slot location.
 		// Map current table slot location to an pasted table slot location.
-		const pastedRow = row - firstRowOfSelection;
-		const pastedColumn = column - firstColumnOfSelection;
+		const pastedRow = row - selection.firstRow;
+		const pastedColumn = column - selection.firstColumn;
 		const pastedCell = pastedTableLocationMap[ pastedRow % pastedHeight ][ pastedColumn % pastedWidth ];
 		const pastedCell = pastedTableLocationMap[ pastedRow % pastedHeight ][ pastedColumn % pastedWidth ];
 
 
 		// There is no cell to insert (might be spanned by other cell in a pasted table) - advance to the next content table slot.
 		// There is no cell to insert (might be spanned by other cell in a pasted table) - advance to the next content table slot.
@@ -295,10 +300,10 @@ function replaceSelectedCellsWithPasted( pastedTable, pastedDimensions, selected
 
 
 		// Clone cell to insert (to duplicate its attributes and children).
 		// Clone cell to insert (to duplicate its attributes and children).
 		// Cloning is required to support repeating pasted table content when inserting to a bigger selection.
 		// Cloning is required to support repeating pasted table content when inserting to a bigger selection.
-		const cellToInsert = pastedCell._clone( true );
+		const cellToInsert = writer.cloneElement( pastedCell );
 
 
 		// Trim the cell if it's row/col-spans would exceed selection area.
 		// Trim the cell if it's row/col-spans would exceed selection area.
-		trimTableCellIfNeeded( cellToInsert, row, column, lastRowOfSelection, lastColumnOfSelection, writer );
+		trimTableCellIfNeeded( cellToInsert, row, column, selection.lastRow, selection.lastColumn, writer );
 
 
 		writer.insert( cellToInsert, insertPosition );
 		writer.insert( cellToInsert, insertPosition );
 		cellsToSelect.push( cellToInsert );
 		cellsToSelect.push( cellToInsert );
@@ -331,7 +336,7 @@ function expandTableSize( table, expectedHeight, expectedWidth, writer, tableUti
 	}
 	}
 }
 }
 
 
-function getTableIfOnlyTableInContent( content ) {
+function getTableIfOnlyTableInContent( content, model ) {
 	// Table passed directly.
 	// Table passed directly.
 	if ( content.is( 'table' ) ) {
 	if ( content.is( 'table' ) ) {
 		return content;
 		return content;
@@ -339,11 +344,36 @@ function getTableIfOnlyTableInContent( content ) {
 
 
 	// We do not support mixed content when pasting table into table.
 	// We do not support mixed content when pasting table into table.
 	// See: https://github.com/ckeditor/ckeditor5/issues/6817.
 	// See: https://github.com/ckeditor/ckeditor5/issues/6817.
-	if ( content.childCount != 1 || !content.getChild( 0 ).is( 'table' ) ) {
-		return null;
+	if ( content.childCount == 1 && content.getChild( 0 ).is( 'table' ) ) {
+		return content.getChild( 0 );
+	}
+
+	// If there are only whitespaces around a table then use that table for pasting.
+
+	const contentRange = model.createRangeIn( content );
+
+	for ( const element of contentRange.getItems() ) {
+		if ( element.is( 'table' ) ) {
+			// Stop checking if there is some content before table.
+			const rangeBefore = model.createRange( contentRange.start, model.createPositionBefore( element ) );
+
+			if ( model.hasContent( rangeBefore, { ignoreWhitespaces: true } ) ) {
+				return null;
+			}
+
+			// Stop checking if there is some content after table.
+			const rangeAfter = model.createRange( model.createPositionAfter( element ), contentRange.end );
+
+			if ( model.hasContent( rangeAfter, { ignoreWhitespaces: true } ) ) {
+				return null;
+			}
+
+			// There wasn't any content neither before nor after.
+			return element;
+		}
 	}
 	}
 
 
-	return content.getChild( 0 );
+	return null;
 }
 }
 
 
 // Returns two-dimensional array that is addressed by [ row ][ column ] that stores cells anchored at given location.
 // Returns two-dimensional array that is addressed by [ row ][ column ] that stores cells anchored at given location.
@@ -498,23 +528,23 @@ function isAffectedBySelection( index, span, limit ) {
 //    +   +   +   +   +    so we need to return 3, not 2
 //    +   +   +   +   +    so we need to return 3, not 2
 //  3 |   |   |   |   |
 //  3 |   |   |   |   |
 //    +---+---+---+---+
 //    +---+---+---+---+
-function adjustLastRowIndex( table, rowIndexes, columnIndexes ) {
+function adjustLastRowIndex( table, dimensions ) {
 	const lastRowMap = Array.from( new TableWalker( table, {
 	const lastRowMap = Array.from( new TableWalker( table, {
-		startColumn: columnIndexes.first,
-		endColumn: columnIndexes.last,
-		row: rowIndexes.last
+		startColumn: dimensions.firstColumn,
+		endColumn: dimensions.lastColumn,
+		row: dimensions.lastRow
 	} ) );
 	} ) );
 
 
 	const everyCellHasSingleRowspan = lastRowMap.every( ( { cellHeight } ) => cellHeight === 1 );
 	const everyCellHasSingleRowspan = lastRowMap.every( ( { cellHeight } ) => cellHeight === 1 );
 
 
 	// It is a "flat" row, so the last row index is OK.
 	// It is a "flat" row, so the last row index is OK.
 	if ( everyCellHasSingleRowspan ) {
 	if ( everyCellHasSingleRowspan ) {
-		return rowIndexes.last;
+		return dimensions.lastRow;
 	}
 	}
 
 
 	// Otherwise get any cell's rowspan and adjust the last row index.
 	// Otherwise get any cell's rowspan and adjust the last row index.
 	const rowspanAdjustment = lastRowMap[ 0 ].cellHeight - 1;
 	const rowspanAdjustment = lastRowMap[ 0 ].cellHeight - 1;
-	return rowIndexes.last + rowspanAdjustment;
+	return dimensions.lastRow + rowspanAdjustment;
 }
 }
 
 
 // Returns adjusted last column index if selection covers part of a column with empty slots (spanned by other cells).
 // Returns adjusted last column index if selection covers part of a column with empty slots (spanned by other cells).
@@ -534,21 +564,21 @@ function adjustLastRowIndex( table, rowIndexes, columnIndexes ) {
 // +---+---+---+---+
 // +---+---+---+---+
 //           ^
 //           ^
 //          last column, each cell has colspan = 2, so we need to return 3, not 2
 //          last column, each cell has colspan = 2, so we need to return 3, not 2
-function adjustLastColumnIndex( table, rowIndexes, columnIndexes ) {
+function adjustLastColumnIndex( table, dimensions ) {
 	const lastColumnMap = Array.from( new TableWalker( table, {
 	const lastColumnMap = Array.from( new TableWalker( table, {
-		startRow: rowIndexes.first,
-		endRow: rowIndexes.last,
-		column: columnIndexes.last
+		startRow: dimensions.firstRow,
+		endRow: dimensions.lastRow,
+		column: dimensions.lastColumn
 	} ) );
 	} ) );
 
 
 	const everyCellHasSingleColspan = lastColumnMap.every( ( { cellWidth } ) => cellWidth === 1 );
 	const everyCellHasSingleColspan = lastColumnMap.every( ( { cellWidth } ) => cellWidth === 1 );
 
 
 	// It is a "flat" column, so the last column index is OK.
 	// It is a "flat" column, so the last column index is OK.
 	if ( everyCellHasSingleColspan ) {
 	if ( everyCellHasSingleColspan ) {
-		return columnIndexes.last;
+		return dimensions.lastColumn;
 	}
 	}
 
 
 	// Otherwise get any cell's colspan and adjust the last column index.
 	// Otherwise get any cell's colspan and adjust the last column index.
 	const colspanAdjustment = lastColumnMap[ 0 ].cellWidth - 1;
 	const colspanAdjustment = lastColumnMap[ 0 ].cellWidth - 1;
-	return columnIndexes.last + colspanAdjustment;
+	return dimensions.lastColumn + colspanAdjustment;
 }
 }

+ 5 - 5
packages/ckeditor5-table/src/tablenavigation.js → packages/ckeditor5-table/src/tablekeyboard.js

@@ -4,18 +4,18 @@
  */
  */
 
 
 /**
 /**
- * @module table/tablenavigation
+ * @module table/tablekeyboard
  */
  */
 
 
 import TableSelection from './tableselection';
 import TableSelection from './tableselection';
 import TableWalker from './tablewalker';
 import TableWalker from './tablewalker';
-import { findAncestor } from './commands/utils';
-import { getSelectedTableCells, getTableCellsContainingSelection } from './utils';
 
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
 import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
 import priorities from '@ckeditor/ckeditor5-utils/src/priorities';
 import priorities from '@ckeditor/ckeditor5-utils/src/priorities';
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
+import { getSelectedTableCells, getTableCellsContainingSelection } from './utils/selection';
+import { findAncestor } from './utils/common';
 
 
 /**
 /**
  * This plugin enables keyboard navigation for tables.
  * This plugin enables keyboard navigation for tables.
@@ -23,12 +23,12 @@ import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
  *
  *
  * @extends module:core/plugin~Plugin
  * @extends module:core/plugin~Plugin
  */
  */
-export default class TableNavigation extends Plugin {
+export default class TableKeyboard extends Plugin {
 	/**
 	/**
 	 * @inheritDoc
 	 * @inheritDoc
 	 */
 	 */
 	static get pluginName() {
 	static get pluginName() {
-		return 'TableNavigation';
+		return 'TableKeyboard';
 	}
 	}
 
 
 	/**
 	/**

+ 223 - 0
packages/ckeditor5-table/src/tablemouse.js

@@ -0,0 +1,223 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tablemouse
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+
+import TableSelection from './tableselection';
+import MouseEventsObserver from './tablemouse/mouseeventsobserver';
+
+import { findAncestor } from './utils/common';
+import { getTableCellsContainingSelection } from './utils/selection';
+
+/**
+ * This plugin enables a table cells' selection with the mouse.
+ * It is loaded automatically by the {@link module:table/table~Table} plugin.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class TableMouse extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'TableMouse';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ TableSelection ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+
+		// Currently the MouseObserver only handles `mouseup` events.
+		// TODO move to the engine?
+		editor.editing.view.addObserver( MouseEventsObserver );
+
+		this._enableShiftClickSelection();
+		this._enableMouseDragSelection();
+	}
+
+	/**
+	 * Enables making cells selection by <kbd>Shift</kbd>+click. Creates a selection from the cell which previously held
+	 * the selection to the cell which was clicked. It can be the same cell, in which case it selects a single cell.
+	 *
+	 * @private
+	 */
+	_enableShiftClickSelection() {
+		const editor = this.editor;
+		let blockSelectionChange = false;
+
+		const tableSelection = editor.plugins.get( TableSelection );
+
+		this.listenTo( editor.editing.view.document, 'mousedown', ( evt, domEventData ) => {
+			if ( !this.isEnabled || !tableSelection.isEnabled ) {
+				return;
+			}
+
+			if ( !domEventData.domEvent.shiftKey ) {
+				return;
+			}
+
+			const anchorCell = tableSelection.getAnchorCell() || getTableCellsContainingSelection( editor.model.document.selection )[ 0 ];
+
+			if ( !anchorCell ) {
+				return;
+			}
+
+			const targetCell = this._getModelTableCellFromDomEvent( domEventData );
+
+			if ( targetCell && haveSameTableParent( anchorCell, targetCell ) ) {
+				blockSelectionChange = true;
+				tableSelection.setCellSelection( anchorCell, targetCell );
+
+				domEventData.preventDefault();
+			}
+		} );
+
+		this.listenTo( editor.editing.view.document, 'mouseup', () => {
+			blockSelectionChange = false;
+		} );
+
+		// We need to ignore a `selectionChange` event that is fired after we render our new table cells selection.
+		// When downcasting table cells selection to the view, we put the view selection in the last selected cell
+		// in a place that may not be natively a "correct" location. This is – we put it directly in the `<td>` element.
+		// All browsers fire the native `selectionchange` event.
+		// However, all browsers except Safari return the selection in the exact place where we put it
+		// (even though it's visually normalized). Safari returns `<td><p>^foo` that makes our selection observer
+		// fire our `selectionChange` event (because the view selection that we set in the first step differs from the DOM selection).
+		// Since `selectionChange` is fired, we automatically update the model selection that moves it that paragraph.
+		// This breaks our dear cells selection.
+		//
+		// Theoretically this issue concerns only Safari that is the only browser that do normalize the selection.
+		// However, to avoid code branching and to have a good coverage for this event blocker, I enabled it for all browsers.
+		//
+		// Note: I'm keeping the `blockSelectionChange` state separately for shift+click and mouse drag (exact same logic)
+		// so I don't have to try to analyze whether they don't overlap in some weird cases. Probably they don't.
+		// But I have other things to do, like writing this comment.
+		this.listenTo( editor.editing.view.document, 'selectionChange', evt => {
+			if ( blockSelectionChange ) {
+				// @if CK_DEBUG // console.log( 'Blocked selectionChange to avoid breaking table cells selection.' );
+
+				evt.stop();
+			}
+		}, { priority: 'highest' } );
+	}
+
+	/**
+	 * Enables making cells selection by dragging.
+	 *
+	 * The selection is made only on mousemove. Mouse tracking is started on mousedown.
+	 * However, the cells selection is enabled only after the mouse cursor left the anchor cell.
+	 * Thanks to that normal text selection within one cell works just fine. However, you can still select
+	 * just one cell by leaving the anchor cell and moving back to it.
+	 *
+	 * @private
+	 */
+	_enableMouseDragSelection() {
+		const editor = this.editor;
+		let anchorCell, targetCell;
+		let beganCellSelection = false;
+		let blockSelectionChange = false;
+
+		const tableSelection = editor.plugins.get( TableSelection );
+
+		this.listenTo( editor.editing.view.document, 'mousedown', ( evt, domEventData ) => {
+			if ( !this.isEnabled || !tableSelection.isEnabled ) {
+				return;
+			}
+
+			// Make sure to not conflict with the shift+click listener and any other possible handler.
+			if ( domEventData.domEvent.shiftKey || domEventData.domEvent.ctrlKey || domEventData.domEvent.altKey ) {
+				return;
+			}
+
+			anchorCell = this._getModelTableCellFromDomEvent( domEventData );
+		} );
+
+		this.listenTo( editor.editing.view.document, 'mousemove', ( evt, domEventData ) => {
+			if ( !domEventData.domEvent.buttons ) {
+				return;
+			}
+
+			if ( !anchorCell ) {
+				return;
+			}
+
+			const newTargetCell = this._getModelTableCellFromDomEvent( domEventData );
+
+			if ( newTargetCell && haveSameTableParent( anchorCell, newTargetCell ) ) {
+				targetCell = newTargetCell;
+
+				// Switch to the cell selection mode after the mouse cursor left the anchor cell.
+				// Switch off only on mouseup (makes selecting a single cell possible).
+				if ( !beganCellSelection && targetCell != anchorCell ) {
+					beganCellSelection = true;
+				}
+			}
+
+			// Yep, not making a cell selection yet. See method docs.
+			if ( !beganCellSelection ) {
+				return;
+			}
+
+			blockSelectionChange = true;
+			tableSelection.setCellSelection( anchorCell, targetCell );
+
+			domEventData.preventDefault();
+		} );
+
+		this.listenTo( editor.editing.view.document, 'mouseup', () => {
+			beganCellSelection = false;
+			blockSelectionChange = false;
+			anchorCell = null;
+			targetCell = null;
+		} );
+
+		// See the explanation in `_enableShiftClickSelection()`.
+		this.listenTo( editor.editing.view.document, 'selectionChange', evt => {
+			if ( blockSelectionChange ) {
+				// @if CK_DEBUG // console.log( 'Blocked selectionChange to avoid breaking table cells selection.' );
+
+				evt.stop();
+			}
+		}, { priority: 'highest' } );
+	}
+
+	/**
+	 * Returns the model table cell element based on the target element of the passed DOM event.
+	 *
+	 * @private
+	 * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData
+	 * @returns {module:engine/model/element~Element|undefined} Returns the table cell or `undefined`.
+	 */
+	_getModelTableCellFromDomEvent( domEventData ) {
+		// Note: Work with positions (not element mapping) because the target element can be an attribute or other non-mapped element.
+		const viewTargetElement = domEventData.target;
+		const viewPosition = this.editor.editing.view.createPositionAt( viewTargetElement, 0 );
+		const modelPosition = this.editor.editing.mapper.toModelPosition( viewPosition );
+		const modelElement = modelPosition.parent;
+
+		if ( modelElement.is( 'tableCell' ) ) {
+			return modelElement;
+		}
+
+		return findAncestor( 'tableCell', modelElement );
+	}
+}
+
+function haveSameTableParent( cellA, cellB ) {
+	return cellA.parent.parent == cellB.parent.parent;
+}

+ 0 - 0
packages/ckeditor5-table/src/tableselection/mouseeventsobserver.js → packages/ckeditor5-table/src/tablemouse/mouseeventsobserver.js


+ 1 - 1
packages/ckeditor5-table/src/tableproperties.js

@@ -70,7 +70,7 @@ export default class TableProperties extends Plugin {
  * allowing users to pick colors in a more convenient way.
  * allowing users to pick colors in a more convenient way.
  *
  *
  * The default color palettes for the table background and the table border are the same
  * The default color palettes for the table background and the table border are the same
- * ({@link module:table/ui/utils~defaultColors check out their content}).
+ * ({@link module:table/utils/ui/table-properties~defaultColors check out their content}).
  *
  *
  * Both color palette configurations must follow the
  * Both color palette configurations must follow the
  * {@link module:table/table~TableColorConfig table color configuration format}.
  * {@link module:table/table~TableColorConfig table color configuration format}.

+ 1 - 1
packages/ckeditor5-table/src/tableproperties/commands/tablebordercolorcommand.js

@@ -8,7 +8,7 @@
  */
  */
 
 
 import TablePropertyCommand from './tablepropertycommand';
 import TablePropertyCommand from './tablepropertycommand';
-import { getSingleValue } from '../../commands/utils';
+import { getSingleValue } from '../../utils/table-properties';
 
 
 /**
 /**
  * The table border color command.
  * The table border color command.

+ 1 - 1
packages/ckeditor5-table/src/tableproperties/commands/tableborderstylecommand.js

@@ -8,7 +8,7 @@
  */
  */
 
 
 import TablePropertyCommand from './tablepropertycommand';
 import TablePropertyCommand from './tablepropertycommand';
-import { getSingleValue } from '../../commands/utils';
+import { getSingleValue } from '../../utils/table-properties';
 
 
 /**
 /**
  * The table style border command.
  * The table style border command.

+ 1 - 1
packages/ckeditor5-table/src/tableproperties/commands/tableborderwidthcommand.js

@@ -7,8 +7,8 @@
  * @module table/tableproperties/commands/tableborderwidthcommand
  * @module table/tableproperties/commands/tableborderwidthcommand
  */
  */
 
 
-import { addDefaultUnitToNumericValue, getSingleValue } from '../../commands/utils';
 import TablePropertyCommand from './tablepropertycommand';
 import TablePropertyCommand from './tablepropertycommand';
+import { addDefaultUnitToNumericValue, getSingleValue } from '../../utils/table-properties';
 
 
 /**
 /**
  * The table width border command.
  * The table width border command.

+ 1 - 1
packages/ckeditor5-table/src/tableproperties/commands/tableheightcommand.js

@@ -8,7 +8,7 @@
  */
  */
 
 
 import TablePropertyCommand from './tablepropertycommand';
 import TablePropertyCommand from './tablepropertycommand';
-import { addDefaultUnitToNumericValue } from '../../commands/utils';
+import { addDefaultUnitToNumericValue } from '../../utils/table-properties';
 
 
 /**
 /**
  * The table height command.
  * The table height command.

+ 1 - 1
packages/ckeditor5-table/src/tableproperties/commands/tablepropertycommand.js

@@ -9,7 +9,7 @@
 
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
 import Command from '@ckeditor/ckeditor5-core/src/command';
 
 
-import { findAncestor } from '../../commands/utils';
+import { findAncestor } from '../../utils/common';
 
 
 /**
 /**
  * The table cell attribute command.
  * The table cell attribute command.

+ 1 - 1
packages/ckeditor5-table/src/tableproperties/commands/tablewidthcommand.js

@@ -7,8 +7,8 @@
  * @module table/tableproperties/commands/tablewidthcommand
  * @module table/tableproperties/commands/tablewidthcommand
  */
  */
 
 
-import { addDefaultUnitToNumericValue } from '../../commands/utils';
 import TablePropertyCommand from './tablepropertycommand';
 import TablePropertyCommand from './tablepropertycommand';
+import { addDefaultUnitToNumericValue } from '../../utils/table-properties';
 
 
 /**
 /**
  * The table width command.
  * The table width command.

+ 3 - 4
packages/ckeditor5-table/src/tableproperties/tablepropertiesui.js

@@ -9,26 +9,25 @@
 
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
-import { getTableWidgetAncestor } from '../utils';
 import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsidehandler';
 import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsidehandler';
 import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
 import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
 import TablePropertiesView from './ui/tablepropertiesview';
 import TablePropertiesView from './ui/tablepropertiesview';
 import tableProperties from './../../theme/icons/table-properties.svg';
 import tableProperties from './../../theme/icons/table-properties.svg';
 import {
 import {
 	colorFieldValidator,
 	colorFieldValidator,
-	getBalloonTablePositionData,
 	getLocalizedColorErrorText,
 	getLocalizedColorErrorText,
 	getLocalizedLengthErrorText,
 	getLocalizedLengthErrorText,
 	lengthFieldValidator,
 	lengthFieldValidator,
 	lineWidthFieldValidator,
 	lineWidthFieldValidator,
-	repositionContextualBalloon,
 	defaultColors
 	defaultColors
-} from '../ui/utils';
+} from '../utils/ui/table-properties';
 import {
 import {
 	getLocalizedColorOptions,
 	getLocalizedColorOptions,
 	normalizeColorOptions
 	normalizeColorOptions
 } from '@ckeditor/ckeditor5-ui/src/colorgrid/utils';
 } from '@ckeditor/ckeditor5-ui/src/colorgrid/utils';
 import { debounce } from 'lodash-es';
 import { debounce } from 'lodash-es';
+import { getTableWidgetAncestor } from '../utils/ui/widget';
+import { getBalloonTablePositionData, repositionContextualBalloon } from '../utils/ui/contextualballoon';
 
 
 const ERROR_TEXT_TIMEOUT = 500;
 const ERROR_TEXT_TIMEOUT = 500;
 
 

+ 1 - 1
packages/ckeditor5-table/src/tableproperties/ui/tablepropertiesview.js

@@ -26,7 +26,7 @@ import {
 	getBorderStyleDefinitions,
 	getBorderStyleDefinitions,
 	getBorderStyleLabels,
 	getBorderStyleLabels,
 	getLabeledColorInputCreator
 	getLabeledColorInputCreator
-} from '../../ui/utils';
+} from '../../utils/ui/table-properties';
 import FormRowView from '../../ui/formrowview';
 import FormRowView from '../../ui/formrowview';
 
 
 import FormHeaderView from '@ckeditor/ckeditor5-ui/src/formheader/formheaderview';
 import FormHeaderView from '@ckeditor/ckeditor5-ui/src/formheader/formheaderview';

+ 4 - 177
packages/ckeditor5-table/src/tableselection.js

@@ -12,10 +12,10 @@ import first from '@ckeditor/ckeditor5-utils/src/first';
 
 
 import TableWalker from './tablewalker';
 import TableWalker from './tablewalker';
 import TableUtils from './tableutils';
 import TableUtils from './tableutils';
-import MouseEventsObserver from './tableselection/mouseeventsobserver';
-import { getColumnIndexes, getRowIndexes, getSelectedTableCells, getTableCellsContainingSelection } from './utils';
-import { findAncestor } from './commands/utils';
-import { cropTableToDimensions } from './tableselection/croptable';
+
+import { findAncestor } from './utils/common';
+import { cropTableToDimensions } from './utils/structure';
+import { getColumnIndexes, getRowIndexes, getSelectedTableCells } from './utils/selection';
 
 
 import '../theme/tableselection.css';
 import '../theme/tableselection.css';
 
 
@@ -49,13 +49,7 @@ export default class TableSelection extends Plugin {
 
 
 		this.listenTo( model, 'deleteContent', ( evt, args ) => this._handleDeleteContent( evt, args ), { priority: 'high' } );
 		this.listenTo( model, 'deleteContent', ( evt, args ) => this._handleDeleteContent( evt, args ), { priority: 'high' } );
 
 
-		// Currently the MouseObserver only handles `mouseup` events.
-		// TODO move to the engine?
-		editor.editing.view.addObserver( MouseEventsObserver );
-
 		this._defineSelectionConverter();
 		this._defineSelectionConverter();
-		this._enableShiftClickSelection();
-		this._enableMouseDragSelection();
 		this._enablePluginDisabling(); // sic!
 		this._enablePluginDisabling(); // sic!
 	}
 	}
 
 
@@ -222,148 +216,6 @@ export default class TableSelection extends Plugin {
 		}
 		}
 	}
 	}
 
 
-	/**
-	 * Enables making cells selection by <kbd>Shift</kbd>+click. Creates a selection from the cell which previously held
-	 * the selection to the cell which was clicked. It can be the same cell, in which case it selects a single cell.
-	 *
-	 * @private
-	 */
-	_enableShiftClickSelection() {
-		const editor = this.editor;
-		let blockSelectionChange = false;
-
-		this.listenTo( editor.editing.view.document, 'mousedown', ( evt, domEventData ) => {
-			if ( !this.isEnabled ) {
-				return;
-			}
-
-			if ( !domEventData.domEvent.shiftKey ) {
-				return;
-			}
-
-			const anchorCell = this.getAnchorCell() || getTableCellsContainingSelection( editor.model.document.selection )[ 0 ];
-
-			if ( !anchorCell ) {
-				return;
-			}
-
-			const targetCell = this._getModelTableCellFromDomEvent( domEventData );
-
-			if ( targetCell && haveSameTableParent( anchorCell, targetCell ) ) {
-				blockSelectionChange = true;
-				this.setCellSelection( anchorCell, targetCell );
-
-				domEventData.preventDefault();
-			}
-		} );
-
-		this.listenTo( editor.editing.view.document, 'mouseup', () => {
-			blockSelectionChange = false;
-		} );
-
-		// We need to ignore a `selectionChange` event that is fired after we render our new table cells selection.
-		// When downcasting table cells selection to the view, we put the view selection in the last selected cell
-		// in a place that may not be natively a "correct" location. This is – we put it directly in the `<td>` element.
-		// All browsers fire the native `selectionchange` event.
-		// However, all browsers except Safari return the selection in the exact place where we put it
-		// (even though it's visually normalized). Safari returns `<td><p>^foo` that makes our selection observer
-		// fire our `selectionChange` event (because the view selection that we set in the first step differs from the DOM selection).
-		// Since `selectionChange` is fired, we automatically update the model selection that moves it that paragraph.
-		// This breaks our dear cells selection.
-		//
-		// Theoretically this issue concerns only Safari that is the only browser that do normalize the selection.
-		// However, to avoid code branching and to have a good coverage for this event blocker, I enabled it for all browsers.
-		//
-		// Note: I'm keeping the `blockSelectionChange` state separately for shift+click and mouse drag (exact same logic)
-		// so I don't have to try to analyze whether they don't overlap in some weird cases. Probably they don't.
-		// But I have other things to do, like writing this comment.
-		this.listenTo( editor.editing.view.document, 'selectionChange', evt => {
-			if ( blockSelectionChange ) {
-				// @if CK_DEBUG // console.log( 'Blocked selectionChange to avoid breaking table cells selection.' );
-
-				evt.stop();
-			}
-		}, { priority: 'highest' } );
-	}
-
-	/**
-	 * Enables making cells selection by dragging.
-	 *
-	 * The selection is made only on mousemove. Mouse tracking is started on mousedown.
-	 * However, the cells selection is enabled only after the mouse cursor left the anchor cell.
-	 * Thanks to that normal text selection within one cell works just fine. However, you can still select
-	 * just one cell by leaving the anchor cell and moving back to it.
-	 *
-	 * @private
-	 */
-	_enableMouseDragSelection() {
-		const editor = this.editor;
-		let anchorCell, targetCell;
-		let beganCellSelection = false;
-		let blockSelectionChange = false;
-
-		this.listenTo( editor.editing.view.document, 'mousedown', ( evt, domEventData ) => {
-			if ( !this.isEnabled ) {
-				return;
-			}
-
-			// Make sure to not conflict with the shift+click listener and any other possible handler.
-			if ( domEventData.domEvent.shiftKey || domEventData.domEvent.ctrlKey || domEventData.domEvent.altKey ) {
-				return;
-			}
-
-			anchorCell = this._getModelTableCellFromDomEvent( domEventData );
-		} );
-
-		this.listenTo( editor.editing.view.document, 'mousemove', ( evt, domEventData ) => {
-			if ( !domEventData.domEvent.buttons ) {
-				return;
-			}
-
-			if ( !anchorCell ) {
-				return;
-			}
-
-			const newTargetCell = this._getModelTableCellFromDomEvent( domEventData );
-
-			if ( newTargetCell && haveSameTableParent( anchorCell, newTargetCell ) ) {
-				targetCell = newTargetCell;
-
-				// Switch to the cell selection mode after the mouse cursor left the anchor cell.
-				// Switch off only on mouseup (makes selecting a single cell possible).
-				if ( !beganCellSelection && targetCell != anchorCell ) {
-					beganCellSelection = true;
-				}
-			}
-
-			// Yep, not making a cell selection yet. See method docs.
-			if ( !beganCellSelection ) {
-				return;
-			}
-
-			blockSelectionChange = true;
-			this.setCellSelection( anchorCell, targetCell );
-
-			domEventData.preventDefault();
-		} );
-
-		this.listenTo( editor.editing.view.document, 'mouseup', () => {
-			beganCellSelection = false;
-			blockSelectionChange = false;
-			anchorCell = null;
-			targetCell = null;
-		} );
-
-		// See the explanation in `_enableShiftClickSelection()`.
-		this.listenTo( editor.editing.view.document, 'selectionChange', evt => {
-			if ( blockSelectionChange ) {
-				// @if CK_DEBUG // console.log( 'Blocked selectionChange to avoid breaking table cells selection.' );
-
-				evt.stop();
-			}
-		}, { priority: 'highest' } );
-	}
-
 	/**
 	/**
 	 * Creates a listener that reacts to changes in {@link #isEnabled} and, if the plugin was disabled,
 	 * Creates a listener that reacts to changes in {@link #isEnabled} and, if the plugin was disabled,
 	 * it collapses the multi-cell selection to a regular selection placed inside a table cell.
 	 * it collapses the multi-cell selection to a regular selection placed inside a table cell.
@@ -433,27 +285,6 @@ export default class TableSelection extends Plugin {
 		} );
 		} );
 	}
 	}
 
 
-	/**
-	 * Returns the model table cell element based on the target element of the passed DOM event.
-	 *
-	 * @private
-	 * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData
-	 * @returns {module:engine/model/element~Element|undefined} Returns the table cell or `undefined`.
-	 */
-	_getModelTableCellFromDomEvent( domEventData ) {
-		// Note: Work with positions (not element mapping) because the target element can be an attribute or other non-mapped element.
-		const viewTargetElement = domEventData.target;
-		const viewPosition = this.editor.editing.view.createPositionAt( viewTargetElement, 0 );
-		const modelPosition = this.editor.editing.mapper.toModelPosition( viewPosition );
-		const modelElement = modelPosition.parent;
-
-		if ( modelElement.is( 'tableCell' ) ) {
-			return modelElement;
-		}
-
-		return findAncestor( 'tableCell', modelElement );
-	}
-
 	/**
 	/**
 	 * Returns an array of table cells that should be selected based on the
 	 * Returns an array of table cells that should be selected based on the
 	 * given anchor cell and target (focus) cell.
 	 * given anchor cell and target (focus) cell.
@@ -507,7 +338,3 @@ export default class TableSelection extends Plugin {
 		};
 		};
 	}
 	}
 }
 }
-
-function haveSameTableParent( cellA, cellB ) {
-	return cellA.parent.parent == cellB.parent.parent;
-}

+ 0 - 143
packages/ckeditor5-table/src/tableselection/croptable.js

@@ -1,143 +0,0 @@
-/**
- * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
- */
-
-/**
- * @module table/tableselection/croptable
- */
-
-import { createEmptyTableCell, updateNumericAttribute } from '../commands/utils';
-import TableWalker from '../tablewalker';
-
-/**
- * Returns a cropped table according to given dimensions.
-
- * To return a cropped table that starts at first row and first column and end in third row and column:
- *
- *		const croppedTable = cropTableToDimensions( table, {
- *			startRow: 1,
- *			endRow: 1,
- *			startColumn: 3,
- *			endColumn: 3
- *		}, writer );
- *
- * Calling the code above for the table below:
- *
- *		      0   1   2   3   4                      0   1   2
- *		    ┌───┬───┬───┬───┬───┐
- *		 0  │ a │ b │ c │ d │ e │
- *		    ├───┴───┤   ├───┴───┤                  ┌───┬───┬───┐
- *		 1  │ f     │   │ g     │                  │   │   │ g │  0
- *		    ├───┬───┴───┼───┬───┤   will return:   ├───┴───┼───┤
- *		 2  │ h │ i     │ j │ k │                  │ i     │ j │  1
- *		    ├───┤       ├───┤   │                  │       ├───┤
- *		 3  │ l │       │ m │   │                  │       │ m │  2
- *		    ├───┼───┬───┤   ├───┤                  └───────┴───┘
- *		 4  │ n │ o │ p │   │ q │
- *		    └───┴───┴───┴───┴───┘
- *
- * @param {module:engine/model/element~Element} sourceTable
- * @param {Object} cropDimensions
- * @param {Number} cropDimensions.startRow
- * @param {Number} cropDimensions.startColumn
- * @param {Number} cropDimensions.endRow
- * @param {Number} cropDimensions.endColumn
- * @param {module:engine/model/writer~Writer} writer
- * @returns {module:engine/model/element~Element}
- */
-export function cropTableToDimensions( sourceTable, cropDimensions, writer ) {
-	const { startRow, startColumn, endRow, endColumn } = cropDimensions;
-
-	// Create empty table with empty rows equal to crop height.
-	const croppedTable = writer.createElement( 'table' );
-	const cropHeight = endRow - startRow + 1;
-
-	for ( let i = 0; i < cropHeight; i++ ) {
-		writer.insertElement( 'tableRow', croppedTable, 'end' );
-	}
-
-	const tableMap = [ ...new TableWalker( sourceTable, { startRow, endRow, startColumn, endColumn, includeAllSlots: true } ) ];
-
-	// Iterate over source table slots (including empty - spanned - ones).
-	for ( const { row: sourceRow, column: sourceColumn, cell: tableCell, isAnchor, cellAnchorRow, cellAnchorColumn } of tableMap ) {
-		// Row index in cropped table.
-		const rowInCroppedTable = sourceRow - startRow;
-		const row = croppedTable.getChild( rowInCroppedTable );
-
-		// For empty slots: fill the gap with empty table cell.
-		if ( !isAnchor ) {
-			// But fill the gap only if the spanning cell is anchored outside cropped area.
-			// In the table from method jsdoc those cells are: "c" & "f".
-			if ( cellAnchorRow < startRow || cellAnchorColumn < startColumn ) {
-				createEmptyTableCell( writer, writer.createPositionAt( row, 'end' ) );
-			}
-		}
-		// Otherwise clone the cell with all children and trim if it exceeds cropped area.
-		else {
-			const tableCellCopy = tableCell._clone( true );
-
-			writer.append( tableCellCopy, row );
-
-			// Trim table if it exceeds cropped area.
-			// In the table from method jsdoc those cells are: "g" & "m".
-			trimTableCellIfNeeded( tableCellCopy, sourceRow, sourceColumn, endRow, endColumn, writer );
-		}
-	}
-
-	// Adjust heading rows & columns in cropped table if crop selection includes headings parts.
-	addHeadingsToCroppedTable( croppedTable, sourceTable, startRow, startColumn, writer );
-
-	return croppedTable;
-}
-
-/**
- * Adjusts table cell dimensions to not exceed limit row and column.
- *
- * If table cell width (or height) covers a column (or row) that is after a limit column (or row)
- * this method will trim "colspan" (or "rowspan") attribute so the table cell will fit in a defined limits.
- *
- * @param {module:engine/model/element~Element} tableCell
- * @param {Number} cellRow
- * @param {Number} cellColumn
- * @param {Number} limitRow
- * @param {Number} limitColumn
- * @param {module:engine/model/writer~Writer} writer
- */
-export function trimTableCellIfNeeded( tableCell, cellRow, cellColumn, limitRow, limitColumn, writer ) {
-	const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
-	const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
-
-	const endColumn = cellColumn + colspan - 1;
-
-	if ( endColumn > limitColumn ) {
-		const trimmedSpan = limitColumn - cellColumn + 1;
-
-		updateNumericAttribute( 'colspan', trimmedSpan, tableCell, writer, 1 );
-	}
-
-	const endRow = cellRow + rowspan - 1;
-
-	if ( endRow > limitRow ) {
-		const trimmedSpan = limitRow - cellRow + 1;
-
-		updateNumericAttribute( 'rowspan', trimmedSpan, tableCell, writer, 1 );
-	}
-}
-
-// Sets proper heading attributes to a cropped table.
-function addHeadingsToCroppedTable( croppedTable, sourceTable, startRow, startColumn, writer ) {
-	const headingRows = parseInt( sourceTable.getAttribute( 'headingRows' ) || 0 );
-
-	if ( headingRows > 0 ) {
-		const headingRowsInCrop = headingRows - startRow;
-		updateNumericAttribute( 'headingRows', headingRowsInCrop, croppedTable, writer, 0 );
-	}
-
-	const headingColumns = parseInt( sourceTable.getAttribute( 'headingColumns' ) || 0 );
-
-	if ( headingColumns > 0 ) {
-		const headingColumnsInCrop = headingColumns - startColumn;
-		updateNumericAttribute( 'headingColumns', headingColumnsInCrop, croppedTable, writer, 0 );
-	}
-}

+ 1 - 1
packages/ckeditor5-table/src/tabletoolbar.js

@@ -8,8 +8,8 @@
  */
  */
 
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
-import { getSelectedTableWidget, getTableWidgetAncestor } from './utils';
 import WidgetToolbarRepository from '@ckeditor/ckeditor5-widget/src/widgettoolbarrepository';
 import WidgetToolbarRepository from '@ckeditor/ckeditor5-widget/src/widgettoolbarrepository';
+import { getSelectedTableWidget, getTableWidgetAncestor } from './utils/ui/widget';
 
 
 /**
 /**
  * The table toolbar class. It creates toolbars for the table feature and its content (for now only for the table cell content).
  * The table toolbar class. It creates toolbars for the table feature and its content (for now only for the table cell content).

+ 8 - 13
packages/ckeditor5-table/src/tableutils.js

@@ -10,7 +10,8 @@
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 
 
 import TableWalker from './tablewalker';
 import TableWalker from './tablewalker';
-import { createEmptyTableCell, updateNumericAttribute } from './commands/utils';
+import { createEmptyTableCell, updateNumericAttribute } from './utils/common';
+import { removeEmptyColumns, removeEmptyRows } from './utils/structure';
 
 
 /**
 /**
  * The table utilities plugin.
  * The table utilities plugin.
@@ -336,7 +337,10 @@ export default class TableUtils extends Plugin {
 				updateNumericAttribute( 'rowspan', rowspan, cell, writer );
 				updateNumericAttribute( 'rowspan', rowspan, cell, writer );
 			}
 			}
 
 
-			// 2d. Adjust heading rows if removed rows were in a heading section.
+			// 2d. Remove empty columns (without anchored cells) if there are any.
+			removeEmptyColumns( table, this );
+
+			// 2e. Adjust heading rows if removed rows were in a heading section.
 			updateHeadingRows( table, first, last, model, batch );
 			updateHeadingRows( table, first, last, model, batch );
 		} );
 		} );
 	}
 	}
@@ -379,29 +383,20 @@ export default class TableUtils extends Plugin {
 		model.change( writer => {
 		model.change( writer => {
 			adjustHeadingColumns( table, { first, last }, writer );
 			adjustHeadingColumns( table, { first, last }, writer );
 
 
-			const emptyRowsIndexes = [];
-
 			for ( let removedColumnIndex = last; removedColumnIndex >= first; removedColumnIndex-- ) {
 			for ( let removedColumnIndex = last; removedColumnIndex >= first; removedColumnIndex-- ) {
 				for ( const { cell, column, cellWidth } of [ ...new TableWalker( table ) ] ) {
 				for ( const { cell, column, cellWidth } of [ ...new TableWalker( table ) ] ) {
 					// If colspaned cell overlaps removed column decrease its span.
 					// If colspaned cell overlaps removed column decrease its span.
 					if ( column <= removedColumnIndex && cellWidth > 1 && column + cellWidth > removedColumnIndex ) {
 					if ( column <= removedColumnIndex && cellWidth > 1 && column + cellWidth > removedColumnIndex ) {
 						updateNumericAttribute( 'colspan', cellWidth - 1, cell, writer );
 						updateNumericAttribute( 'colspan', cellWidth - 1, cell, writer );
 					} else if ( column === removedColumnIndex ) {
 					} else if ( column === removedColumnIndex ) {
-						const cellRow = cell.parent;
-
 						// The cell in removed column has colspan of 1.
 						// The cell in removed column has colspan of 1.
 						writer.remove( cell );
 						writer.remove( cell );
-
-						// 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 ) {
-							emptyRowsIndexes.push( cellRow.index );
-						}
 					}
 					}
 				}
 				}
 			}
 			}
 
 
-			emptyRowsIndexes.reverse().forEach( row => this.removeRows( table, { at: row, batch: writer.batch } ) );
+			// Remove empty rows that could appear after removing columns.
+			removeEmptyRows( table, this, writer.batch );
 		} );
 		} );
 	}
 	}
 
 

+ 0 - 495
packages/ckeditor5-table/src/utils.js

@@ -1,495 +0,0 @@
-/**
- * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
- */
-
-/**
- * @module table/utils
- */
-
-import { isWidget, toWidget } from '@ckeditor/ckeditor5-widget/src/utils';
-import { createEmptyTableCell, findAncestor, updateNumericAttribute } from './commands/utils';
-import TableWalker from './tablewalker';
-
-/**
- * Converts a given {@link module:engine/view/element~Element} to a table widget:
- * * Adds a {@link module:engine/view/element~Element#_setCustomProperty custom property} allowing to recognize the table widget element.
- * * Calls the {@link module:widget/utils~toWidget} function with the proper element's label creator.
- *
- * @param {module:engine/view/element~Element} viewElement
- * @param {module:engine/view/downcastwriter~DowncastWriter} writer An instance of the view writer.
- * @param {String} label The element's label. It will be concatenated with the table `alt` attribute if one is present.
- * @returns {module:engine/view/element~Element}
- */
-export function toTableWidget( viewElement, writer ) {
-	writer.setCustomProperty( 'table', true, viewElement );
-
-	return toWidget( viewElement, writer, { hasSelectionHandle: true } );
-}
-
-/**
- * Checks if a given view element is a table widget.
- *
- * @param {module:engine/view/element~Element} viewElement
- * @returns {Boolean}
- */
-export function isTableWidget( viewElement ) {
-	return !!viewElement.getCustomProperty( 'table' ) && isWidget( viewElement );
-}
-
-/**
- * Returns a table widget editing view element if one is selected.
- *
- * @param {module:engine/view/selection~Selection|module:engine/view/documentselection~DocumentSelection} selection
- * @returns {module:engine/view/element~Element|null}
- */
-export function getSelectedTableWidget( selection ) {
-	const viewElement = selection.getSelectedElement();
-
-	if ( viewElement && isTableWidget( viewElement ) ) {
-		return viewElement;
-	}
-
-	return null;
-}
-
-/**
- * Returns a table widget editing view element if one is among the selection's ancestors.
- *
- * @param {module:engine/view/selection~Selection|module:engine/view/documentselection~DocumentSelection} selection
- * @returns {module:engine/view/element~Element|null}
- */
-export function getTableWidgetAncestor( selection ) {
-	const parentTable = findAncestor( 'table', selection.getFirstPosition() );
-
-	if ( parentTable && isTableWidget( parentTable.parent ) ) {
-		return parentTable.parent;
-	}
-
-	return null;
-}
-
-/**
- * Returns all model table cells that are fully selected (from the outside)
- * within the provided model selection's ranges.
- *
- * To obtain the cells selected from the inside, use
- * {@link module:table/utils~getTableCellsContainingSelection}.
- *
- * @param {module:engine/model/selection~Selection} selection
- * @returns {Array.<module:engine/model/element~Element>}
- */
-export function getSelectedTableCells( selection ) {
-	const cells = [];
-
-	for ( const range of sortRanges( selection.getRanges() ) ) {
-		const element = range.getContainedElement();
-
-		if ( element && element.is( 'tableCell' ) ) {
-			cells.push( element );
-		}
-	}
-
-	return cells;
-}
-
-/**
- * Returns all model table cells that the provided model selection's ranges
- * {@link module:engine/model/range~Range#start} inside.
- *
- * To obtain the cells selected from the outside, use
- * {@link module:table/utils~getSelectedTableCells}.
- *
- * @param {module:engine/model/selection~Selection} selection
- * @returns {Array.<module:engine/model/element~Element>}
- */
-export function getTableCellsContainingSelection( selection ) {
-	const cells = [];
-
-	for ( const range of selection.getRanges() ) {
-		const cellWithSelection = findAncestor( 'tableCell', range.start );
-
-		if ( cellWithSelection ) {
-			cells.push( cellWithSelection );
-		}
-	}
-
-	return cells;
-}
-
-/**
- * Returns all model table cells that are either completely selected
- * by selection ranges or host selection range
- * {@link module:engine/model/range~Range#start start positions} inside them.
- *
- * Combines {@link module:table/utils~getTableCellsContainingSelection} and
- * {@link module:table/utils~getSelectedTableCells}.
- *
- * @param {module:engine/model/selection~Selection} selection
- * @returns {Array.<module:engine/model/element~Element>}
- */
-export function getSelectionAffectedTableCells( selection ) {
-	const selectedCells = getSelectedTableCells( selection );
-
-	if ( selectedCells.length ) {
-		return selectedCells;
-	}
-
-	return getTableCellsContainingSelection( selection );
-}
-
-/**
- * Returns an object with the `first` and `last` row index contained in the given `tableCells`.
- *
- *		const selectedTableCells = getSelectedTableCells( editor.model.document.selection );
- *
- *		const { first, last } = getRowIndexes( selectedTableCells );
- *
- *		console.log( `Selected rows: ${ first } to ${ last }` );
- *
- * @param {Array.<module:engine/model/element~Element>} tableCells
- * @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 );
-
-	return getFirstLastIndexesObject( indexes );
-}
-
-/**
- * Returns an object with the `first` and `last` column index contained in the given `tableCells`.
- *
- *		const selectedTableCells = getSelectedTableCells( editor.model.document.selection );
- *
- *		const { first, last } = getColumnIndexes( selectedTableCells );
- *
- *		console.log( `Selected columns: ${ first } to ${ last }` );
- *
- * @param {Array.<module:engine/model/element~Element>} tableCells
- * @returns {Object} Returns an object with the `first` and `last` table column indexes.
- */
-export function getColumnIndexes( tableCells ) {
-	const table = findAncestor( 'table', tableCells[ 0 ] );
-	const tableMap = [ ...new TableWalker( table ) ];
-
-	const indexes = tableMap
-		.filter( entry => tableCells.includes( entry.cell ) )
-		.map( entry => entry.column );
-
-	return getFirstLastIndexesObject( indexes );
-}
-
-/**
- * Checks if the selection contains cells that do not exceed rectangular selection.
- *
- * In a table below:
- *
- *   ┌───┬───┬───┬───┐
- *   │ a │ b │ c │ d │
- *   ├───┴───┼───┤   │
- *   │ e     │ f │   │
- *   │       ├───┼───┤
- *   │       │ g │ h │
- *   └───────┴───┴───┘
- *
- * Valid selections are these which create a solid rectangle (without gaps), such as:
- *   - a, b (two horizontal cells)
- *   - c, f (two vertical cells)
- *   - a, b, e (cell "e" spans over four cells)
- *   - c, d, f (cell d spans over a cell in the row below)
- *
- * While an invalid selection would be:
- *   - a, c (the unselected cell "b" creates a gap)
- *   - f, g, h (cell "d" spans over a cell from the row of "f" cell - thus creates a gap)
- *
- * @param {Array.<module:engine/model/element~Element>} selectedTableCells
- * @param {module:table/tableutils~TableUtils} tableUtils
- * @returns {Boolean}
- */
-export function isSelectionRectangular( selectedTableCells, tableUtils ) {
-	if ( selectedTableCells.length < 2 || !areCellInTheSameTableSection( selectedTableCells ) ) {
-		return false;
-	}
-
-	// A valid selection is a fully occupied rectangle composed of table cells.
-	// Below we will calculate the area of a selected table cells and the area of valid selection.
-	// The area of a valid selection is defined by top-left and bottom-right cells.
-	const rows = new Set();
-	const columns = new Set();
-
-	let areaOfSelectedCells = 0;
-
-	for ( const tableCell of selectedTableCells ) {
-		const { row, column } = tableUtils.getCellLocation( tableCell );
-		const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
-		const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
-
-		// Record row & column indexes of current cell.
-		rows.add( row );
-		columns.add( column );
-
-		// For cells that spans over multiple rows add also the last row that this cell spans over.
-		if ( rowspan > 1 ) {
-			rows.add( row + rowspan - 1 );
-		}
-
-		// For cells that spans over multiple columns add also the last column that this cell spans over.
-		if ( colspan > 1 ) {
-			columns.add( column + colspan - 1 );
-		}
-
-		areaOfSelectedCells += ( rowspan * colspan );
-	}
-
-	// We can only merge table cells that are in adjacent rows...
-	const areaOfValidSelection = getBiggestRectangleArea( rows, columns );
-
-	return areaOfValidSelection == areaOfSelectedCells;
-}
-
-/**
- * Returns slot info of cells that starts above and overlaps a given row.
- *
- * In a table below, passing `overlapRow = 3`
- *
- *       ┌───┬───┬───┬───┬───┐
- *    0  │ a │ b │ c │ d │ e │
- *       │   ├───┼───┼───┼───┤
- *    1  │   │ f │ g │ h │ i │
- *       ├───┤   ├───┼───┤   │
- *    2  │ j │   │ k │ l │   │
- *       │   │   │   ├───┼───┤
- *    3  │   │   │   │ m │ n │  <- overlap row to check
- *       ├───┼───┤   │   ├───│
- *    4  │ o │ p │   │   │ q │
- *       └───┴───┴───┴───┴───┘
- *
- * will return slot info for cells: "j", "f", "k".
- *
- * @param {module:engine/model/element~Element} table The table to check.
- * @param {Number} overlapRow The index of the row to check.
- * @param {Number} [startRow=0] A row to start analysis. Use it when it is known that the cells above that row will not overlap.
- * @returns {Array.<module:table/tablewalker~TableSlot>}
- */
-export function getVerticallyOverlappingCells( table, overlapRow, startRow = 0 ) {
-	const cells = [];
-
-	const tableWalker = new TableWalker( table, { startRow, endRow: overlapRow - 1 } );
-
-	for ( const slotInfo of tableWalker ) {
-		const { row, cellHeight } = slotInfo;
-		const cellEndRow = row + cellHeight - 1;
-
-		if ( row < overlapRow && overlapRow <= cellEndRow ) {
-			cells.push( slotInfo );
-		}
-	}
-
-	return cells;
-}
-
-/**
- * Splits the table cell horizontally.
- *
- * @param {module:engine/model/element~Element} tableCell
- * @param {Number} splitRow
- * @param {module:engine/model/writer~Writer} writer
- */
-export function splitHorizontally( tableCell, splitRow, writer ) {
-	const tableRow = tableCell.parent;
-	const table = tableRow.parent;
-	const rowIndex = tableRow.index;
-
-	const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) );
-	const newRowspan = splitRow - rowIndex;
-
-	const newCellAttributes = {};
-	const newCellRowSpan = rowspan - newRowspan;
-
-	if ( newCellRowSpan > 1 ) {
-		newCellAttributes.rowspan = newCellRowSpan;
-	}
-
-	const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
-
-	if ( colspan > 1 ) {
-		newCellAttributes.colspan = colspan;
-	}
-
-	const startRow = rowIndex;
-	const endRow = startRow + newRowspan;
-	const tableMap = [ ...new TableWalker( table, { startRow, endRow, includeAllSlots: true } ) ];
-
-	let columnIndex;
-
-	for ( const tableSlot of tableMap ) {
-		const { row, column, cell } = tableSlot;
-
-		if ( cell === tableCell && columnIndex === undefined ) {
-			columnIndex = column;
-		}
-
-		if ( columnIndex !== undefined && columnIndex === column && row === endRow ) {
-			createEmptyTableCell( writer, tableSlot.getPositionBefore(), newCellAttributes );
-		}
-	}
-
-	// Update the rowspan attribute after updating table.
-	updateNumericAttribute( 'rowspan', newRowspan, tableCell, writer );
-}
-
-/**
- * Returns slot info of cells that starts before and overlaps a given column.
- *
- * In a table below, passing `overlapColumn = 3`
- *
- *      0   1   2   3   4
- *    ┌───────┬───────┬───┐
- *    │ a     │ b     │ c │
- *    │───┬───┴───────┼───┤
- *    │ d │ e         │ f │
- *    ├───┼───┬───────┴───┤
- *    │ g │ h │ i         │
- *    ├───┼───┼───┬───────┤
- *    │ j │ k │ l │ m     │
- *    ├───┼───┴───┼───┬───┤
- *    │ n │ o     │ p │ q │
- *    └───┴───────┴───┴───┘
- *                  ^
- *                  Overlap column to check
- *
- * will return slot info for cells: "b", "e", "i".
- *
- * @param {module:engine/model/element~Element} table The table to check.
- * @param {Number} overlapColumn The index of the column to check.
- * @returns {Array.<module:table/tablewalker~TableSlot>}
- */
-export function getHorizontallyOverlappingCells( table, overlapColumn ) {
-	const cellsToSplit = [];
-
-	const tableWalker = new TableWalker( table );
-
-	for ( const slotInfo of tableWalker ) {
-		const { column, cellWidth } = slotInfo;
-		const cellEndColumn = column + cellWidth - 1;
-
-		if ( column < overlapColumn && overlapColumn <= cellEndColumn ) {
-			cellsToSplit.push( slotInfo );
-		}
-	}
-
-	return cellsToSplit;
-}
-
-/**
- * Splits the table cell vertically.
- *
- * @param {module:engine/model/element~Element} tableCell
- * @param {Number} columnIndex The table cell column index.
- * @param {Number} splitColumn The index of column to split cell on.
- * @param {module:engine/model/writer~Writer} writer
- */
-export function splitVertically( tableCell, columnIndex, splitColumn, writer ) {
-	const colspan = parseInt( tableCell.getAttribute( 'colspan' ) );
-	const newColspan = splitColumn - columnIndex;
-
-	const newCellAttributes = {};
-	const newCellColSpan = colspan - newColspan;
-
-	if ( newCellColSpan > 1 ) {
-		newCellAttributes.colspan = newCellColSpan;
-	}
-
-	const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
-
-	if ( rowspan > 1 ) {
-		newCellAttributes.rowspan = rowspan;
-	}
-
-	createEmptyTableCell( writer, writer.createPositionAfter( tableCell ), newCellAttributes );
-	// Update the colspan attribute after updating table.
-	updateNumericAttribute( 'colspan', newColspan, tableCell, writer );
-}
-
-// Helper method to get an object with `first` and `last` indexes from an unsorted array of indexes.
-function getFirstLastIndexesObject( indexes ) {
-	const allIndexesSorted = indexes.sort( ( indexA, indexB ) => indexA - indexB );
-
-	const first = allIndexesSorted[ 0 ];
-	const last = allIndexesSorted[ allIndexesSorted.length - 1 ];
-
-	return { first, last };
-}
-
-function sortRanges( rangesIterator ) {
-	return Array.from( rangesIterator ).sort( compareRangeOrder );
-}
-
-function compareRangeOrder( rangeA, rangeB ) {
-	// Since table cell ranges are disjoint, it's enough to check their start positions.
-	const posA = rangeA.start;
-	const posB = rangeB.start;
-
-	// Checking for equal position (returning 0) is not needed because this would be either:
-	// a. Intersecting range (not allowed by model)
-	// b. Collapsed range on the same position (allowed by model but should not happen).
-	return posA.isBefore( posB ) ? -1 : 1;
-}
-
-// Calculates the area of a maximum rectangle that can span over the provided row & column indexes.
-//
-// @param {Array.<Number>} rows
-// @param {Array.<Number>} columns
-// @returns {Number}
-function getBiggestRectangleArea( rows, columns ) {
-	const rowsIndexes = Array.from( rows.values() );
-	const columnIndexes = Array.from( columns.values() );
-
-	const lastRow = Math.max( ...rowsIndexes );
-	const firstRow = Math.min( ...rowsIndexes );
-	const lastColumn = Math.max( ...columnIndexes );
-	const firstColumn = Math.min( ...columnIndexes );
-
-	return ( lastRow - firstRow + 1 ) * ( lastColumn - firstColumn + 1 );
-}
-
-// Checks if the selection does not mix a header (column or row) with other cells.
-//
-// For instance, in the table below valid selections consist of cells with the same letter only.
-// So, a-a (same heading row and column) or d-d (body cells) are valid while c-d or a-b are not.
-//
-//    header columns
-//     ↓   ↓
-//   ┌───┬───┬───┬───┐
-//   │ a │ a │ b │ b │  ← header row
-//   ├───┼───┼───┼───┤
-//   │ c │ c │ d │ d │
-//   ├───┼───┼───┼───┤
-//   │ c │ c │ d │ d │
-//   └───┴───┴───┴───┘
-//
-function areCellInTheSameTableSection( tableCells ) {
-	const table = findAncestor( 'table', tableCells[ 0 ] );
-
-	const rowIndexes = getRowIndexes( tableCells );
-	const headingRows = parseInt( table.getAttribute( 'headingRows' ) || 0 );
-
-	// Calculating row indexes is a bit cheaper so if this check fails we can't merge.
-	if ( !areIndexesInSameSection( rowIndexes, headingRows ) ) {
-		return false;
-	}
-
-	const headingColumns = parseInt( table.getAttribute( 'headingColumns' ) || 0 );
-	const columnIndexes = getColumnIndexes( tableCells );
-
-	// Similarly cells must be in same column section.
-	return areIndexesInSameSection( columnIndexes, headingColumns );
-}
-
-// Unified check if table rows/columns indexes are in the same heading/body section.
-function areIndexesInSameSection( { first, last }, headingSectionSize ) {
-	const firstCellIsInHeading = first < headingSectionSize;
-	const lastCellIsInHeading = last < headingSectionSize;
-
-	return firstCellIsInHeading === lastCellIsInHeading;
-}

+ 1 - 59
packages/ckeditor5-table/src/commands/utils.js → packages/ckeditor5-table/src/utils/common.js

@@ -4,11 +4,9 @@
  */
  */
 
 
 /**
 /**
- * @module table/commands/utils
+ * @module table/utils/common
  */
  */
 
 
-import { isObject } from 'lodash-es';
-
 /**
 /**
  * Returns the parent element of the given name. Returns undefined if the position or the element is not inside the desired parent.
  * Returns the parent element of the given name. Returns undefined if the position or the element is not inside the desired parent.
  *
  *
@@ -59,62 +57,6 @@ export function createEmptyTableCell( writer, insertPosition, attributes = {} )
 	writer.insert( tableCell, insertPosition );
 	writer.insert( tableCell, insertPosition );
 }
 }
 
 
-/**
- * Returns a string if all four values of box sides are equal.
- *
- * If a string is passed, it is treated as a single value (pass-through).
- *
- *		// Returns 'foo':
- *		getSingleValue( { top: 'foo', right: 'foo', bottom: 'foo', left: 'foo' } );
- *		getSingleValue( 'foo' );
- *
- *		// Returns undefined:
- *		getSingleValue( { top: 'foo', right: 'foo', bottom: 'bar', left: 'foo' } );
- *		getSingleValue( { top: 'foo', right: 'foo' } );
- *
- * @param objectOrString
- * @returns {module:engine/view/stylesmap~BoxSides|String}
- */
-export function getSingleValue( objectOrString ) {
-	if ( !objectOrString || !isObject( objectOrString ) ) {
-		return objectOrString;
-	}
-
-	const { top, right, bottom, left } = objectOrString;
-
-	if ( top == right && right == bottom && bottom == left ) {
-		return top;
-	}
-}
-
-/**
- * Adds a unit to a value if the value is a number or a string representing a number.
- *
- * **Note**: It does nothing to non-numeric values.
- *
- *		getSingleValue( 25, 'px' );		// '25px'
- *		getSingleValue( 25, 'em' );		// '25em'
- *		getSingleValue( '25em', 'px' );	// '25em'
- *		getSingleValue( 'foo', 'px' );	// 'foo'
- *
- * @param {*} value
- * @param {String} defaultUnit A default unit added to a numeric value.
- * @returns {String|*}
- */
-export function addDefaultUnitToNumericValue( value, defaultUnit ) {
-	const numericValue = parseFloat( value );
-
-	if ( Number.isNaN( numericValue ) ) {
-		return value;
-	}
-
-	if ( String( numericValue ) !== String( value ) ) {
-		return value;
-	}
-
-	return `${ numericValue }${ defaultUnit }`;
-}
-
 /**
 /**
  * Checks if a table cell belongs to the heading column section.
  * Checks if a table cell belongs to the heading column section.
  *
  *

+ 271 - 0
packages/ckeditor5-table/src/utils/selection.js

@@ -0,0 +1,271 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/utils/selection
+ */
+
+import TableWalker from '../tablewalker';
+import { findAncestor } from './common';
+
+/**
+ * Returns all model table cells that are fully selected (from the outside)
+ * within the provided model selection's ranges.
+ *
+ * To obtain the cells selected from the inside, use
+ * {@link module:table/utils/selection~getTableCellsContainingSelection}.
+ *
+ * @param {module:engine/model/selection~Selection} selection
+ * @returns {Array.<module:engine/model/element~Element>}
+ */
+export function getSelectedTableCells( selection ) {
+	const cells = [];
+
+	for ( const range of sortRanges( selection.getRanges() ) ) {
+		const element = range.getContainedElement();
+
+		if ( element && element.is( 'tableCell' ) ) {
+			cells.push( element );
+		}
+	}
+
+	return cells;
+}
+
+/**
+ * Returns all model table cells that the provided model selection's ranges
+ * {@link module:engine/model/range~Range#start} inside.
+ *
+ * To obtain the cells selected from the outside, use
+ * {@link module:table/utils/selection~getSelectedTableCells}.
+ *
+ * @param {module:engine/model/selection~Selection} selection
+ * @returns {Array.<module:engine/model/element~Element>}
+ */
+export function getTableCellsContainingSelection( selection ) {
+	const cells = [];
+
+	for ( const range of selection.getRanges() ) {
+		const cellWithSelection = findAncestor( 'tableCell', range.start );
+
+		if ( cellWithSelection ) {
+			cells.push( cellWithSelection );
+		}
+	}
+
+	return cells;
+}
+
+/**
+ * Returns all model table cells that are either completely selected
+ * by selection ranges or host selection range
+ * {@link module:engine/model/range~Range#start start positions} inside them.
+ *
+ * Combines {@link module:table/utils/selection~getTableCellsContainingSelection} and
+ * {@link module:table/utils/selection~getSelectedTableCells}.
+ *
+ * @param {module:engine/model/selection~Selection} selection
+ * @returns {Array.<module:engine/model/element~Element>}
+ */
+export function getSelectionAffectedTableCells( selection ) {
+	const selectedCells = getSelectedTableCells( selection );
+
+	if ( selectedCells.length ) {
+		return selectedCells;
+	}
+
+	return getTableCellsContainingSelection( selection );
+}
+
+/**
+ * Returns an object with the `first` and `last` row index contained in the given `tableCells`.
+ *
+ *		const selectedTableCells = getSelectedTableCells( editor.model.document.selection );
+ *
+ *		const { first, last } = getRowIndexes( selectedTableCells );
+ *
+ *		console.log( `Selected rows: ${ first } to ${ last }` );
+ *
+ * @param {Array.<module:engine/model/element~Element>} tableCells
+ * @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 );
+
+	return getFirstLastIndexesObject( indexes );
+}
+
+/**
+ * Returns an object with the `first` and `last` column index contained in the given `tableCells`.
+ *
+ *		const selectedTableCells = getSelectedTableCells( editor.model.document.selection );
+ *
+ *		const { first, last } = getColumnIndexes( selectedTableCells );
+ *
+ *		console.log( `Selected columns: ${ first } to ${ last }` );
+ *
+ * @param {Array.<module:engine/model/element~Element>} tableCells
+ * @returns {Object} Returns an object with the `first` and `last` table column indexes.
+ */
+export function getColumnIndexes( tableCells ) {
+	const table = findAncestor( 'table', tableCells[ 0 ] );
+	const tableMap = [ ...new TableWalker( table ) ];
+
+	const indexes = tableMap
+		.filter( entry => tableCells.includes( entry.cell ) )
+		.map( entry => entry.column );
+
+	return getFirstLastIndexesObject( indexes );
+}
+
+/**
+ * Checks if the selection contains cells that do not exceed rectangular selection.
+ *
+ * In a table below:
+ *
+ *		┌───┬───┬───┬───┐
+ *		│ a │ b │ c │ d │
+ *		├───┴───┼───┤   │
+ *		│ e     │ f │   │
+ *		│       ├───┼───┤
+ *		│       │ g │ h │
+ *		└───────┴───┴───┘
+ *
+ * Valid selections are these which create a solid rectangle (without gaps), such as:
+ *   - a, b (two horizontal cells)
+ *   - c, f (two vertical cells)
+ *   - a, b, e (cell "e" spans over four cells)
+ *   - c, d, f (cell d spans over a cell in the row below)
+ *
+ * While an invalid selection would be:
+ *   - a, c (the unselected cell "b" creates a gap)
+ *   - f, g, h (cell "d" spans over a cell from the row of "f" cell - thus creates a gap)
+ *
+ * @param {Array.<module:engine/model/element~Element>} selectedTableCells
+ * @param {module:table/tableutils~TableUtils} tableUtils
+ * @returns {Boolean}
+ */
+export function isSelectionRectangular( selectedTableCells, tableUtils ) {
+	if ( selectedTableCells.length < 2 || !areCellInTheSameTableSection( selectedTableCells ) ) {
+		return false;
+	}
+
+	// A valid selection is a fully occupied rectangle composed of table cells.
+	// Below we will calculate the area of a selected table cells and the area of valid selection.
+	// The area of a valid selection is defined by top-left and bottom-right cells.
+	const rows = new Set();
+	const columns = new Set();
+
+	let areaOfSelectedCells = 0;
+
+	for ( const tableCell of selectedTableCells ) {
+		const { row, column } = tableUtils.getCellLocation( tableCell );
+		const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
+		const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
+
+		// Record row & column indexes of current cell.
+		rows.add( row );
+		columns.add( column );
+
+		// For cells that spans over multiple rows add also the last row that this cell spans over.
+		if ( rowspan > 1 ) {
+			rows.add( row + rowspan - 1 );
+		}
+
+		// For cells that spans over multiple columns add also the last column that this cell spans over.
+		if ( colspan > 1 ) {
+			columns.add( column + colspan - 1 );
+		}
+
+		areaOfSelectedCells += ( rowspan * colspan );
+	}
+
+	// We can only merge table cells that are in adjacent rows...
+	const areaOfValidSelection = getBiggestRectangleArea( rows, columns );
+
+	return areaOfValidSelection == areaOfSelectedCells;
+}
+
+// Helper method to get an object with `first` and `last` indexes from an unsorted array of indexes.
+function getFirstLastIndexesObject( indexes ) {
+	const allIndexesSorted = indexes.sort( ( indexA, indexB ) => indexA - indexB );
+
+	const first = allIndexesSorted[ 0 ];
+	const last = allIndexesSorted[ allIndexesSorted.length - 1 ];
+
+	return { first, last };
+}
+
+function sortRanges( rangesIterator ) {
+	return Array.from( rangesIterator ).sort( compareRangeOrder );
+}
+
+function compareRangeOrder( rangeA, rangeB ) {
+	// Since table cell ranges are disjoint, it's enough to check their start positions.
+	const posA = rangeA.start;
+	const posB = rangeB.start;
+
+	// Checking for equal position (returning 0) is not needed because this would be either:
+	// a. Intersecting range (not allowed by model)
+	// b. Collapsed range on the same position (allowed by model but should not happen).
+	return posA.isBefore( posB ) ? -1 : 1;
+}
+
+// Calculates the area of a maximum rectangle that can span over the provided row & column indexes.
+//
+// @param {Array.<Number>} rows
+// @param {Array.<Number>} columns
+// @returns {Number}
+function getBiggestRectangleArea( rows, columns ) {
+	const rowsIndexes = Array.from( rows.values() );
+	const columnIndexes = Array.from( columns.values() );
+
+	const lastRow = Math.max( ...rowsIndexes );
+	const firstRow = Math.min( ...rowsIndexes );
+	const lastColumn = Math.max( ...columnIndexes );
+	const firstColumn = Math.min( ...columnIndexes );
+
+	return ( lastRow - firstRow + 1 ) * ( lastColumn - firstColumn + 1 );
+}
+
+// Checks if the selection does not mix a header (column or row) with other cells.
+//
+// For instance, in the table below valid selections consist of cells with the same letter only.
+// So, a-a (same heading row and column) or d-d (body cells) are valid while c-d or a-b are not.
+//
+//		 header columns
+//		  ↓   ↓
+//		┌───┬───┬───┬───┐
+//		│ a │ a │ b │ b │  ← header row
+//		├───┼───┼───┼───┤
+//		│ c │ c │ d │ d │
+//		├───┼───┼───┼───┤
+//		│ c │ c │ d │ d │
+//		└───┴───┴───┴───┘
+function areCellInTheSameTableSection( tableCells ) {
+	const table = findAncestor( 'table', tableCells[ 0 ] );
+
+	const rowIndexes = getRowIndexes( tableCells );
+	const headingRows = parseInt( table.getAttribute( 'headingRows' ) || 0 );
+
+	// Calculating row indexes is a bit cheaper so if this check fails we can't merge.
+	if ( !areIndexesInSameSection( rowIndexes, headingRows ) ) {
+		return false;
+	}
+
+	const headingColumns = parseInt( table.getAttribute( 'headingColumns' ) || 0 );
+	const columnIndexes = getColumnIndexes( tableCells );
+
+	// Similarly cells must be in same column section.
+	return areIndexesInSameSection( columnIndexes, headingColumns );
+}
+
+// Unified check if table rows/columns indexes are in the same heading/body section.
+function areIndexesInSameSection( { first, last }, headingSectionSize ) {
+	const firstCellIsInHeading = first < headingSectionSize;
+	const lastCellIsInHeading = last < headingSectionSize;
+
+	return firstCellIsInHeading === lastCellIsInHeading;
+}

+ 440 - 0
packages/ckeditor5-table/src/utils/structure.js

@@ -0,0 +1,440 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/utils/structure
+ */
+
+import TableWalker from '../tablewalker';
+import { createEmptyTableCell, updateNumericAttribute } from './common';
+
+/**
+ * Returns a cropped table according to given dimensions.
+
+ * To return a cropped table that starts at first row and first column and end in third row and column:
+ *
+ *		const croppedTable = cropTableToDimensions( table, {
+ *			startRow: 1,
+ *			endRow: 1,
+ *			startColumn: 3,
+ *			endColumn: 3
+ *		}, writer );
+ *
+ * Calling the code above for the table below:
+ *
+ *		      0   1   2   3   4                      0   1   2
+ *		    ┌───┬───┬───┬───┬───┐
+ *		 0  │ a │ b │ c │ d │ e │
+ *		    ├───┴───┤   ├───┴───┤                  ┌───┬───┬───┐
+ *		 1  │ f     │   │ g     │                  │   │   │ g │  0
+ *		    ├───┬───┴───┼───┬───┤   will return:   ├───┴───┼───┤
+ *		 2  │ h │ i     │ j │ k │                  │ i     │ j │  1
+ *		    ├───┤       ├───┤   │                  │       ├───┤
+ *		 3  │ l │       │ m │   │                  │       │ m │  2
+ *		    ├───┼───┬───┤   ├───┤                  └───────┴───┘
+ *		 4  │ n │ o │ p │   │ q │
+ *		    └───┴───┴───┴───┴───┘
+ *
+ * @param {module:engine/model/element~Element} sourceTable
+ * @param {Object} cropDimensions
+ * @param {Number} cropDimensions.startRow
+ * @param {Number} cropDimensions.startColumn
+ * @param {Number} cropDimensions.endRow
+ * @param {Number} cropDimensions.endColumn
+ * @param {module:engine/model/writer~Writer} writer
+ * @returns {module:engine/model/element~Element}
+ */
+export function cropTableToDimensions( sourceTable, cropDimensions, writer ) {
+	const { startRow, startColumn, endRow, endColumn } = cropDimensions;
+
+	// Create empty table with empty rows equal to crop height.
+	const croppedTable = writer.createElement( 'table' );
+	const cropHeight = endRow - startRow + 1;
+
+	for ( let i = 0; i < cropHeight; i++ ) {
+		writer.insertElement( 'tableRow', croppedTable, 'end' );
+	}
+
+	const tableMap = [ ...new TableWalker( sourceTable, { startRow, endRow, startColumn, endColumn, includeAllSlots: true } ) ];
+
+	// Iterate over source table slots (including empty - spanned - ones).
+	for ( const { row: sourceRow, column: sourceColumn, cell: tableCell, isAnchor, cellAnchorRow, cellAnchorColumn } of tableMap ) {
+		// Row index in cropped table.
+		const rowInCroppedTable = sourceRow - startRow;
+		const row = croppedTable.getChild( rowInCroppedTable );
+
+		// For empty slots: fill the gap with empty table cell.
+		if ( !isAnchor ) {
+			// But fill the gap only if the spanning cell is anchored outside cropped area.
+			// In the table from method jsdoc those cells are: "c" & "f".
+			if ( cellAnchorRow < startRow || cellAnchorColumn < startColumn ) {
+				createEmptyTableCell( writer, writer.createPositionAt( row, 'end' ) );
+			}
+		}
+		// Otherwise clone the cell with all children and trim if it exceeds cropped area.
+		else {
+			const tableCellCopy = writer.cloneElement( tableCell );
+
+			writer.append( tableCellCopy, row );
+
+			// Trim table if it exceeds cropped area.
+			// In the table from method jsdoc those cells are: "g" & "m".
+			trimTableCellIfNeeded( tableCellCopy, sourceRow, sourceColumn, endRow, endColumn, writer );
+		}
+	}
+
+	// Adjust heading rows & columns in cropped table if crop selection includes headings parts.
+	addHeadingsToCroppedTable( croppedTable, sourceTable, startRow, startColumn, writer );
+
+	return croppedTable;
+}
+
+/**
+ * Returns slot info of cells that starts above and overlaps a given row.
+ *
+ * In a table below, passing `overlapRow = 3`
+ *
+ *		   ┌───┬───┬───┬───┬───┐
+ *		0  │ a │ b │ c │ d │ e │
+ *		   │   ├───┼───┼───┼───┤
+ *		1  │   │ f │ g │ h │ i │
+ *		   ├───┤   ├───┼───┤   │
+ *		2  │ j │   │ k │ l │   │
+ *		   │   │   │   ├───┼───┤
+ *		3  │   │   │   │ m │ n │  <- overlap row to check
+ *		   ├───┼───┤   │   ├───│
+ *		4  │ o │ p │   │   │ q │
+ *		   └───┴───┴───┴───┴───┘
+ *
+ * will return slot info for cells: "j", "f", "k".
+ *
+ * @param {module:engine/model/element~Element} table The table to check.
+ * @param {Number} overlapRow The index of the row to check.
+ * @param {Number} [startRow=0] A row to start analysis. Use it when it is known that the cells above that row will not overlap.
+ * @returns {Array.<module:table/tablewalker~TableSlot>}
+ */
+export function getVerticallyOverlappingCells( table, overlapRow, startRow = 0 ) {
+	const cells = [];
+
+	const tableWalker = new TableWalker( table, { startRow, endRow: overlapRow - 1 } );
+
+	for ( const slotInfo of tableWalker ) {
+		const { row, cellHeight } = slotInfo;
+		const cellEndRow = row + cellHeight - 1;
+
+		if ( row < overlapRow && overlapRow <= cellEndRow ) {
+			cells.push( slotInfo );
+		}
+	}
+
+	return cells;
+}
+
+/**
+ * Splits the table cell horizontally.
+ *
+ * @param {module:engine/model/element~Element} tableCell
+ * @param {Number} splitRow
+ * @param {module:engine/model/writer~Writer} writer
+ */
+export function splitHorizontally( tableCell, splitRow, writer ) {
+	const tableRow = tableCell.parent;
+	const table = tableRow.parent;
+	const rowIndex = tableRow.index;
+
+	const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) );
+	const newRowspan = splitRow - rowIndex;
+
+	const newCellAttributes = {};
+	const newCellRowSpan = rowspan - newRowspan;
+
+	if ( newCellRowSpan > 1 ) {
+		newCellAttributes.rowspan = newCellRowSpan;
+	}
+
+	const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
+
+	if ( colspan > 1 ) {
+		newCellAttributes.colspan = colspan;
+	}
+
+	const startRow = rowIndex;
+	const endRow = startRow + newRowspan;
+	const tableMap = [ ...new TableWalker( table, { startRow, endRow, includeAllSlots: true } ) ];
+
+	let columnIndex;
+
+	for ( const tableSlot of tableMap ) {
+		const { row, column, cell } = tableSlot;
+
+		if ( cell === tableCell && columnIndex === undefined ) {
+			columnIndex = column;
+		}
+
+		if ( columnIndex !== undefined && columnIndex === column && row === endRow ) {
+			createEmptyTableCell( writer, tableSlot.getPositionBefore(), newCellAttributes );
+		}
+	}
+
+	// Update the rowspan attribute after updating table.
+	updateNumericAttribute( 'rowspan', newRowspan, tableCell, writer );
+}
+
+/**
+ * Returns slot info of cells that starts before and overlaps a given column.
+ *
+ * In a table below, passing `overlapColumn = 3`
+ *
+ *		  0   1   2   3   4
+ *		┌───────┬───────┬───┐
+ *		│ a     │ b     │ c │
+ *		│───┬───┴───────┼───┤
+ *		│ d │ e         │ f │
+ *		├───┼───┬───────┴───┤
+ *		│ g │ h │ i         │
+ *		├───┼───┼───┬───────┤
+ *		│ j │ k │ l │ m     │
+ *		├───┼───┴───┼───┬───┤
+ *		│ n │ o     │ p │ q │
+ *		└───┴───────┴───┴───┘
+ *		              ^
+ *		              Overlap column to check
+ *
+ * will return slot info for cells: "b", "e", "i".
+ *
+ * @param {module:engine/model/element~Element} table The table to check.
+ * @param {Number} overlapColumn The index of the column to check.
+ * @returns {Array.<module:table/tablewalker~TableSlot>}
+ */
+export function getHorizontallyOverlappingCells( table, overlapColumn ) {
+	const cellsToSplit = [];
+
+	const tableWalker = new TableWalker( table );
+
+	for ( const slotInfo of tableWalker ) {
+		const { column, cellWidth } = slotInfo;
+		const cellEndColumn = column + cellWidth - 1;
+
+		if ( column < overlapColumn && overlapColumn <= cellEndColumn ) {
+			cellsToSplit.push( slotInfo );
+		}
+	}
+
+	return cellsToSplit;
+}
+
+/**
+ * Splits the table cell vertically.
+ *
+ * @param {module:engine/model/element~Element} tableCell
+ * @param {Number} columnIndex The table cell column index.
+ * @param {Number} splitColumn The index of column to split cell on.
+ * @param {module:engine/model/writer~Writer} writer
+ */
+export function splitVertically( tableCell, columnIndex, splitColumn, writer ) {
+	const colspan = parseInt( tableCell.getAttribute( 'colspan' ) );
+	const newColspan = splitColumn - columnIndex;
+
+	const newCellAttributes = {};
+	const newCellColSpan = colspan - newColspan;
+
+	if ( newCellColSpan > 1 ) {
+		newCellAttributes.colspan = newCellColSpan;
+	}
+
+	const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
+
+	if ( rowspan > 1 ) {
+		newCellAttributes.rowspan = rowspan;
+	}
+
+	createEmptyTableCell( writer, writer.createPositionAfter( tableCell ), newCellAttributes );
+	// Update the colspan attribute after updating table.
+	updateNumericAttribute( 'colspan', newColspan, tableCell, writer );
+}
+
+/**
+ * Adjusts table cell dimensions to not exceed limit row and column.
+ *
+ * If table cell width (or height) covers a column (or row) that is after a limit column (or row)
+ * this method will trim "colspan" (or "rowspan") attribute so the table cell will fit in a defined limits.
+ *
+ * @param {module:engine/model/element~Element} tableCell
+ * @param {Number} cellRow
+ * @param {Number} cellColumn
+ * @param {Number} limitRow
+ * @param {Number} limitColumn
+ * @param {module:engine/model/writer~Writer} writer
+ */
+export function trimTableCellIfNeeded( tableCell, cellRow, cellColumn, limitRow, limitColumn, writer ) {
+	const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
+	const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
+
+	const endColumn = cellColumn + colspan - 1;
+
+	if ( endColumn > limitColumn ) {
+		const trimmedSpan = limitColumn - cellColumn + 1;
+
+		updateNumericAttribute( 'colspan', trimmedSpan, tableCell, writer, 1 );
+	}
+
+	const endRow = cellRow + rowspan - 1;
+
+	if ( endRow > limitRow ) {
+		const trimmedSpan = limitRow - cellRow + 1;
+
+		updateNumericAttribute( 'rowspan', trimmedSpan, tableCell, writer, 1 );
+	}
+}
+
+// Sets proper heading attributes to a cropped table.
+function addHeadingsToCroppedTable( croppedTable, sourceTable, startRow, startColumn, writer ) {
+	const headingRows = parseInt( sourceTable.getAttribute( 'headingRows' ) || 0 );
+
+	if ( headingRows > 0 ) {
+		const headingRowsInCrop = headingRows - startRow;
+		updateNumericAttribute( 'headingRows', headingRowsInCrop, croppedTable, writer, 0 );
+	}
+
+	const headingColumns = parseInt( sourceTable.getAttribute( 'headingColumns' ) || 0 );
+
+	if ( headingColumns > 0 ) {
+		const headingColumnsInCrop = headingColumns - startColumn;
+		updateNumericAttribute( 'headingColumns', headingColumnsInCrop, croppedTable, writer, 0 );
+	}
+}
+
+/**
+ * Removes columns that have no cells anchored.
+ *
+ * In table below:
+ *
+ *     +----+----+----+----+----+----+----+
+ *     | 00 | 01      | 03 | 04      | 06 |
+ *     +----+----+----+----+         +----+
+ *     | 10 | 11      | 13 |         | 16 |
+ *     +----+----+----+----+----+----+----+
+ *     | 20 | 21      | 23 | 24      | 26 |
+ *     +----+----+----+----+----+----+----+
+ *                  ^--- empty ---^
+ *
+ * Will remove columns 2 and 5.
+ *
+ * **Note:** This is a low-level helper method for clearing invalid model state when doing table modifications.
+ * To remove a column from a table use {@link module:table/tableutils~TableUtils#removeColumns `TableUtils.removeColumns()`}.
+ *
+ * @protected
+ * @param {module:engine/model/element~Element} table
+ * @param {module:table/tableutils~TableUtils} tableUtils
+ * @return {Boolean} True if removed some columns.
+ */
+export function removeEmptyColumns( table, tableUtils ) {
+	const width = tableUtils.getColumns( table );
+	const columnsMap = new Array( width ).fill( 0 );
+
+	for ( const { column } of new TableWalker( table ) ) {
+		columnsMap[ column ]++;
+	}
+
+	const emptyColumns = columnsMap.reduce( ( result, cellsCount, column ) => {
+		return cellsCount ? result : [ ...result, column ];
+	}, [] );
+
+	// @if CK_DEBUG_TABLE // emptyColumns.length > 0 && console.log( `Removing empty columns: ${ emptyColumns.join( ', ' ) }.` );
+
+	emptyColumns.reverse().forEach( column => {
+		tableUtils.removeColumns( table, { at: column } );
+	} );
+
+	return emptyColumns.length > 0;
+}
+
+/**
+ * Removes rows that have no cells anchored.
+ *
+ * In table below:
+ *
+ *     +----+----+----+
+ *     | 00 | 01 | 02 |
+ *     +----+----+----+
+ *     | 10 | 11 | 12 |
+ *     +    +    +    +
+ *     |    |    |    | <-- empty
+ *     +----+----+----+
+ *     | 30 | 31 | 32 |
+ *     +----+----+----+
+ *     | 40      | 42 |
+ *     +         +    +
+ *     |         |    | <-- empty
+ *     +----+----+----+
+ *     | 60 | 61 | 62 |
+ *     +----+----+----+
+ *
+ * Will remove rows 2 and 5.
+ *
+ * **Note:** This is a low-level helper method for clearing invalid model state when doing table modifications.
+ * To remove a row from a table use {@link module:table/tableutils~TableUtils#removeRows `TableUtils.removeRows()`}.
+ *
+ * @protected
+ * @param {module:engine/model/element~Element} table
+ * @param {module:table/tableutils~TableUtils} tableUtils
+ * @param {module:engine/model/batch~Batch|null} [batch] Batch that should be used for removing empty rows.
+ * @return {Boolean} True if removed some rows.
+ */
+export function removeEmptyRows( table, tableUtils, batch ) {
+	const emptyRows = [];
+
+	for ( let rowIndex = 0; rowIndex < table.childCount; rowIndex++ ) {
+		const tableRow = table.getChild( rowIndex );
+
+		if ( tableRow.isEmpty ) {
+			emptyRows.push( rowIndex );
+		}
+	}
+
+	// @if CK_DEBUG_TABLE // emptyRows.length > 0 && console.log( `Removing empty rows: ${ emptyRows.join( ', ' ) }.` );
+
+	emptyRows.reverse().forEach( row => {
+		tableUtils.removeRows( table, { at: row, batch } );
+	} );
+
+	return emptyRows.length > 0;
+}
+
+/**
+ * Removes rows and columns that have no cells anchored.
+ *
+ * In table below:
+ *
+ *     +----+----+----+----+
+ *     | 00      | 02      |
+ *     +----+----+         +
+ *     | 10      |         |
+ *     +----+----+----+----+
+ *     | 20      | 22 | 23 |
+ *     +         +    +    +
+ *     |         |    |    | <-- empty row
+ *     +----+----+----+----+
+ *             ^--- empty column
+ *
+ * Will remove row 3 and column 1.
+ *
+ * **Note:** This is a low-level helper method for clearing invalid model state when doing table modifications.
+ * To remove a rows from a table use {@link module:table/tableutils~TableUtils#removeRows `TableUtils.removeRows()`} and
+ * {@link module:table/tableutils~TableUtils#removeColumns `TableUtils.removeColumns()`} to remove a column.
+ *
+ * @protected
+ * @param {module:engine/model/element~Element} table
+ * @param {module:table/tableutils~TableUtils} tableUtils
+ * @param {module:engine/model/batch~Batch|null} [batch] Batch that should be used for removing empty rows.
+ */
+export function removeEmptyRowsColumns( table, tableUtils, batch ) {
+	const removedColumns = removeEmptyColumns( table, tableUtils );
+
+	// If there was some columns removed then cleaning empty rows was already triggered.
+	if ( !removedColumns ) {
+		removeEmptyRows( table, tableUtils, batch );
+	}
+}

+ 66 - 0
packages/ckeditor5-table/src/utils/table-properties.js

@@ -0,0 +1,66 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/utils/table-properties
+ */
+
+import { isObject } from 'lodash-es';
+
+/**
+ * Returns a string if all four values of box sides are equal.
+ *
+ * If a string is passed, it is treated as a single value (pass-through).
+ *
+ *		// Returns 'foo':
+ *		getSingleValue( { top: 'foo', right: 'foo', bottom: 'foo', left: 'foo' } );
+ *		getSingleValue( 'foo' );
+ *
+ *		// Returns undefined:
+ *		getSingleValue( { top: 'foo', right: 'foo', bottom: 'bar', left: 'foo' } );
+ *		getSingleValue( { top: 'foo', right: 'foo' } );
+ *
+ * @param objectOrString
+ * @returns {module:engine/view/stylesmap~BoxSides|String}
+ */
+export function getSingleValue( objectOrString ) {
+	if ( !objectOrString || !isObject( objectOrString ) ) {
+		return objectOrString;
+	}
+
+	const { top, right, bottom, left } = objectOrString;
+
+	if ( top == right && right == bottom && bottom == left ) {
+		return top;
+	}
+}
+
+/**
+ * Adds a unit to a value if the value is a number or a string representing a number.
+ *
+ * **Note**: It does nothing to non-numeric values.
+ *
+ *		getSingleValue( 25, 'px' );		// '25px'
+ *		getSingleValue( 25, 'em' );		// '25em'
+ *		getSingleValue( '25em', 'px' );	// '25em'
+ *		getSingleValue( 'foo', 'px' );	// 'foo'
+ *
+ * @param {*} value
+ * @param {String} defaultUnit A default unit added to a numeric value.
+ * @returns {String|*}
+ */
+export function addDefaultUnitToNumericValue( value, defaultUnit ) {
+	const numericValue = parseFloat( value );
+
+	if ( Number.isNaN( numericValue ) ) {
+		return value;
+	}
+
+	if ( String( numericValue ) !== String( value ) ) {
+		return value;
+	}
+
+	return `${ numericValue }${ defaultUnit }`;
+}

+ 145 - 0
packages/ckeditor5-table/src/utils/ui/contextualballoon.js

@@ -0,0 +1,145 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/utils/ui/contextualballoon
+ */
+
+import { centeredBalloonPositionForLongWidgets } from '@ckeditor/ckeditor5-widget/src/utils';
+import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
+import { getTableWidgetAncestor } from './widget';
+import { findAncestor } from '../common';
+import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview';
+
+const DEFAULT_BALLOON_POSITIONS = BalloonPanelView.defaultPositions;
+
+const BALLOON_POSITIONS = [
+	DEFAULT_BALLOON_POSITIONS.northArrowSouth,
+	DEFAULT_BALLOON_POSITIONS.northArrowSouthWest,
+	DEFAULT_BALLOON_POSITIONS.northArrowSouthEast,
+	DEFAULT_BALLOON_POSITIONS.southArrowNorth,
+	DEFAULT_BALLOON_POSITIONS.southArrowNorthWest,
+	DEFAULT_BALLOON_POSITIONS.southArrowNorthEast
+];
+
+const TABLE_PROPERTIES_BALLOON_POSITIONS = [
+	...BALLOON_POSITIONS,
+	centeredBalloonPositionForLongWidgets
+];
+
+/**
+ * A helper utility that positions the
+ * {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon} instance
+ * with respect to the table in the editor content, if one is selected.
+ *
+ * @param {module:core/editor/editor~Editor} editor The editor instance.
+ * @param {String} target Either "cell" or "table". Determines the target the balloon will
+ * be attached to.
+ */
+export function repositionContextualBalloon( editor, target ) {
+	const balloon = editor.plugins.get( 'ContextualBalloon' );
+
+	if ( getTableWidgetAncestor( editor.editing.view.document.selection ) ) {
+		let position;
+
+		if ( target === 'cell' ) {
+			position = getBalloonCellPositionData( editor );
+		} else {
+			position = getBalloonTablePositionData( editor );
+		}
+
+		balloon.updatePosition( position );
+	}
+}
+
+/**
+ * Returns the positioning options that control the geometry of the
+ * {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon} with respect
+ * to the selected table in the editor content.
+ *
+ * @param {module:core/editor/editor~Editor} editor The editor instance.
+ * @returns {module:utils/dom/position~Options}
+ */
+export function getBalloonTablePositionData( editor ) {
+	const firstPosition = editor.model.document.selection.getFirstPosition();
+	const modelTable = findAncestor( 'table', firstPosition );
+	const viewTable = editor.editing.mapper.toViewElement( modelTable );
+
+	return {
+		target: editor.editing.view.domConverter.viewToDom( viewTable ),
+		positions: TABLE_PROPERTIES_BALLOON_POSITIONS
+	};
+}
+
+/**
+ * Returns the positioning options that control the geometry of the
+ * {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon} with respect
+ * to the selected table cell in the editor content.
+ *
+ * @param {module:core/editor/editor~Editor} editor The editor instance.
+ * @returns {module:utils/dom/position~Options}
+ */
+export function getBalloonCellPositionData( editor ) {
+	const mapper = editor.editing.mapper;
+	const domConverter = editor.editing.view.domConverter;
+	const selection = editor.model.document.selection;
+
+	if ( selection.rangeCount > 1 ) {
+		return {
+			target: () => createBoundingRect( selection.getRanges(), modelRange => {
+				const modelTableCell = getTableCellAtPosition( modelRange.start );
+				const viewTableCell = mapper.toViewElement( modelTableCell );
+				return new Rect( domConverter.viewToDom( viewTableCell ) );
+			} ),
+			positions: BALLOON_POSITIONS
+		};
+	}
+
+	const modelTableCell = getTableCellAtPosition( selection.getFirstPosition() );
+	const viewTableCell = mapper.toViewElement( modelTableCell );
+
+	return {
+		target: domConverter.viewToDom( viewTableCell ),
+		positions: BALLOON_POSITIONS
+	};
+}
+
+// Returns the first selected table cell from a multi-cell or in-cell selection.
+//
+// @param {module:engine/model/position~Position} position Document position.
+// @returns {module:engine/model/element~Element}
+function getTableCellAtPosition( position ) {
+	const isTableCellSelected = position.nodeAfter && position.nodeAfter.is( 'tableCell' );
+
+	return isTableCellSelected ? position.nodeAfter : findAncestor( 'tableCell', position );
+}
+
+// Returns bounding rect for list of rects.
+//
+// @param {Array.<module:utils/dom/rect~Rect>|Array.<*>} list List of `Rect`s or any list to map by `mapFn`.
+// @param {Function} mapFn Mapping function for list elements.
+// @returns {module:utils/dom/rect~Rect}
+function createBoundingRect( list, mapFn ) {
+	const rectData = {
+		left: Number.POSITIVE_INFINITY,
+		top: Number.POSITIVE_INFINITY,
+		right: Number.NEGATIVE_INFINITY,
+		bottom: Number.NEGATIVE_INFINITY
+	};
+
+	for ( const item of list ) {
+		const rect = mapFn( item );
+
+		rectData.left = Math.min( rectData.left, rect.left );
+		rectData.top = Math.min( rectData.top, rect.top );
+		rectData.right = Math.max( rectData.right, rect.right );
+		rectData.bottom = Math.max( rectData.bottom, rect.bottom );
+	}
+
+	rectData.width = rectData.right - rectData.left;
+	rectData.height = rectData.bottom - rectData.top;
+
+	return new Rect( rectData );
+}

+ 2 - 136
packages/ckeditor5-table/src/ui/utils.js → packages/ckeditor5-table/src/utils/ui/table-properties.js

@@ -4,113 +4,17 @@
  */
  */
 
 
 /**
 /**
- * @module table/ui/utils
+ * @module table/utils/ui/table-properties
  */
  */
 
 
-import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview';
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 import Collection from '@ckeditor/ckeditor5-utils/src/collection';
 import Collection from '@ckeditor/ckeditor5-utils/src/collection';
 import Model from '@ckeditor/ckeditor5-ui/src/model';
 import Model from '@ckeditor/ckeditor5-ui/src/model';
-import ColorInputView from './colorinputview';
+import ColorInputView from '../../ui/colorinputview';
 import { isColor, isLength, isPercentage } from '@ckeditor/ckeditor5-engine/src/view/styles/utils';
 import { isColor, isLength, isPercentage } from '@ckeditor/ckeditor5-engine/src/view/styles/utils';
-import { getTableWidgetAncestor } from '../utils';
-import { findAncestor } from '../commands/utils';
-import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
-import { centeredBalloonPositionForLongWidgets } from '@ckeditor/ckeditor5-widget/src/utils';
-
-const DEFAULT_BALLOON_POSITIONS = BalloonPanelView.defaultPositions;
-const BALLOON_POSITIONS = [
-	DEFAULT_BALLOON_POSITIONS.northArrowSouth,
-	DEFAULT_BALLOON_POSITIONS.northArrowSouthWest,
-	DEFAULT_BALLOON_POSITIONS.northArrowSouthEast,
-	DEFAULT_BALLOON_POSITIONS.southArrowNorth,
-	DEFAULT_BALLOON_POSITIONS.southArrowNorthWest,
-	DEFAULT_BALLOON_POSITIONS.southArrowNorthEast
-];
-const TABLE_PROPERTIES_BALLOON_POSITIONS = [
-	...BALLOON_POSITIONS,
-	centeredBalloonPositionForLongWidgets
-];
 
 
 const isEmpty = val => val === '';
 const isEmpty = val => val === '';
 
 
-/**
- * A helper utility that positions the
- * {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon} instance
- * with respect to the table in the editor content, if one is selected.
- *
- * @param {module:core/editor/editor~Editor} editor The editor instance.
- * @param {String} target Either "cell" or "table". Determines the target the balloon will
- * be attached to.
- */
-export function repositionContextualBalloon( editor, target ) {
-	const balloon = editor.plugins.get( 'ContextualBalloon' );
-
-	if ( getTableWidgetAncestor( editor.editing.view.document.selection ) ) {
-		let position;
-
-		if ( target === 'cell' ) {
-			position = getBalloonCellPositionData( editor );
-		} else {
-			position = getBalloonTablePositionData( editor );
-		}
-
-		balloon.updatePosition( position );
-	}
-}
-
-/**
- * Returns the positioning options that control the geometry of the
- * {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon} with respect
- * to the selected table in the editor content.
- *
- * @param {module:core/editor/editor~Editor} editor The editor instance.
- * @returns {module:utils/dom/position~Options}
- */
-export function getBalloonTablePositionData( editor ) {
-	const firstPosition = editor.model.document.selection.getFirstPosition();
-	const modelTable = findAncestor( 'table', firstPosition );
-	const viewTable = editor.editing.mapper.toViewElement( modelTable );
-
-	return {
-		target: editor.editing.view.domConverter.viewToDom( viewTable ),
-		positions: TABLE_PROPERTIES_BALLOON_POSITIONS
-	};
-}
-
-/**
- * Returns the positioning options that control the geometry of the
- * {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon} with respect
- * to the selected table cell in the editor content.
- *
- * @param {module:core/editor/editor~Editor} editor The editor instance.
- * @returns {module:utils/dom/position~Options}
- */
-export function getBalloonCellPositionData( editor ) {
-	const mapper = editor.editing.mapper;
-	const domConverter = editor.editing.view.domConverter;
-	const selection = editor.model.document.selection;
-
-	if ( selection.rangeCount > 1 ) {
-		return {
-			target: () => createBoundingRect( selection.getRanges(), modelRange => {
-				const modelTableCell = getTableCellAtPosition( modelRange.start );
-				const viewTableCell = mapper.toViewElement( modelTableCell );
-				return new Rect( domConverter.viewToDom( viewTableCell ) );
-			} ),
-			positions: BALLOON_POSITIONS
-		};
-	}
-
-	const modelTableCell = getTableCellAtPosition( selection.getFirstPosition() );
-	const viewTableCell = mapper.toViewElement( modelTableCell );
-
-	return {
-		target: domConverter.viewToDom( viewTableCell ),
-		positions: BALLOON_POSITIONS
-	};
-}
-
 /**
 /**
  * Returns an object containing pairs of CSS border style values and their localized UI
  * Returns an object containing pairs of CSS border style values and their localized UI
  * labels. Used by {@link module:table/tablecellproperties/ui/tablecellpropertiesview~TableCellPropertiesView}
  * labels. Used by {@link module:table/tablecellproperties/ui/tablecellpropertiesview~TableCellPropertiesView}
@@ -489,41 +393,3 @@ function colorConfigToColorGridDefinitions( colorConfig ) {
 		}
 		}
 	} ) );
 	} ) );
 }
 }
-
-// Returns the first selected table cell from a multi-cell or in-cell selection.
-//
-// @param {module:engine/model/position~Position} position Document position.
-// @returns {module:engine/model/element~Element}
-function getTableCellAtPosition( position ) {
-	const isTableCellSelected = position.nodeAfter && position.nodeAfter.is( 'tableCell' );
-
-	return isTableCellSelected ? position.nodeAfter : findAncestor( 'tableCell', position );
-}
-
-// Returns bounding rect for list of rects.
-//
-// @param {Array.<module:utils/dom/rect~Rect>|Array.<*>} list List of `Rect`s or any list to map by `mapFn`.
-// @param {Function} mapFn Mapping function for list elements.
-// @returns {module:utils/dom/rect~Rect}
-function createBoundingRect( list, mapFn ) {
-	const rectData = {
-		left: Number.POSITIVE_INFINITY,
-		top: Number.POSITIVE_INFINITY,
-		right: Number.NEGATIVE_INFINITY,
-		bottom: Number.NEGATIVE_INFINITY
-	};
-
-	for ( const item of list ) {
-		const rect = mapFn( item );
-
-		rectData.left = Math.min( rectData.left, rect.left );
-		rectData.top = Math.min( rectData.top, rect.top );
-		rectData.right = Math.max( rectData.right, rect.right );
-		rectData.bottom = Math.max( rectData.bottom, rect.bottom );
-	}
-
-	rectData.width = rectData.right - rectData.left;
-	rectData.height = rectData.bottom - rectData.top;
-
-	return new Rect( rectData );
-}

+ 51 - 0
packages/ckeditor5-table/src/utils/ui/widget.js

@@ -0,0 +1,51 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/utils/ui/widget
+ */
+
+import { isWidget } from '@ckeditor/ckeditor5-widget/src/utils';
+import { findAncestor } from '../common';
+
+/**
+ * Returns a table widget editing view element if one is selected.
+ *
+ * @param {module:engine/view/selection~Selection|module:engine/view/documentselection~DocumentSelection} selection
+ * @returns {module:engine/view/element~Element|null}
+ */
+export function getSelectedTableWidget( selection ) {
+	const viewElement = selection.getSelectedElement();
+
+	if ( viewElement && isTableWidget( viewElement ) ) {
+		return viewElement;
+	}
+
+	return null;
+}
+
+/**
+ * Returns a table widget editing view element if one is among the selection's ancestors.
+ *
+ * @param {module:engine/view/selection~Selection|module:engine/view/documentselection~DocumentSelection} selection
+ * @returns {module:engine/view/element~Element|null}
+ */
+export function getTableWidgetAncestor( selection ) {
+	const parentTable = findAncestor( 'table', selection.getFirstPosition() );
+
+	if ( parentTable && isTableWidget( parentTable.parent ) ) {
+		return parentTable.parent;
+	}
+
+	return null;
+}
+
+// Checks if a given view element is a table widget.
+//
+// @param {module:engine/view/element~Element} viewElement
+// @returns {Boolean}
+function isTableWidget( viewElement ) {
+	return !!viewElement.getCustomProperty( 'table' ) && isWidget( viewElement );
+}

+ 62 - 18
packages/ckeditor5-table/tests/commands/mergecellcommand.js

@@ -188,6 +188,20 @@ describe( 'MergeCellCommand', () => {
 
 
 		describe( 'execute()', () => {
 		describe( 'execute()', () => {
 			it( 'should merge table cells', () => {
 			it( 'should merge table cells', () => {
+				setData( model, modelTable( [
+					[ '[]00', '01' ],
+					[ '10', '11' ]
+				] ) );
+
+				command.execute();
+
+				assertEqualMarkup( getData( model ), modelTable( [
+					[ { colspan: 2, contents: '<paragraph>[00</paragraph><paragraph>01]</paragraph>' } ],
+					[ '10', '11' ]
+				] ) );
+			} );
+
+			it( 'should merge table cells and remove empty columns', () => {
 				setData( model, modelTable( [
 				setData( model, modelTable( [
 					[ '[]00', '01' ]
 					[ '[]00', '01' ]
 				] ) );
 				] ) );
@@ -195,43 +209,49 @@ describe( 'MergeCellCommand', () => {
 				command.execute();
 				command.execute();
 
 
 				assertEqualMarkup( getData( model ), modelTable( [
 				assertEqualMarkup( getData( model ), modelTable( [
-					[ { colspan: 2, contents: '<paragraph>[00</paragraph><paragraph>01]</paragraph>' } ]
+					[ '<paragraph>[00</paragraph><paragraph>01]</paragraph>' ]
 				] ) );
 				] ) );
 			} );
 			} );
 
 
 			it( 'should result in single empty paragraph if both cells are empty', () => {
 			it( 'should result in single empty paragraph if both cells are empty', () => {
 				setData( model, modelTable( [
 				setData( model, modelTable( [
-					[ '[]', '' ]
+					[ '[]', '' ],
+					[ '10', '11' ]
 				] ) );
 				] ) );
 
 
 				command.execute();
 				command.execute();
 
 
 				assertEqualMarkup( getData( model ), modelTable( [
 				assertEqualMarkup( getData( model ), modelTable( [
-					[ { colspan: 2, contents: '<paragraph>[]</paragraph>' } ]
+					[ { colspan: 2, contents: '<paragraph>[]</paragraph>' } ],
+					[ '10', '11' ]
 				] ) );
 				] ) );
 			} );
 			} );
 
 
 			it( 'should result in single paragraph (other cell is empty)', () => {
 			it( 'should result in single paragraph (other cell is empty)', () => {
 				setData( model, modelTable( [
 				setData( model, modelTable( [
-					[ 'foo[]', '' ]
+					[ 'foo[]', '' ],
+					[ '10', '11' ]
 				] ) );
 				] ) );
 
 
 				command.execute();
 				command.execute();
 
 
 				assertEqualMarkup( getData( model ), modelTable( [
 				assertEqualMarkup( getData( model ), modelTable( [
-					[ { colspan: 2, contents: '<paragraph>[foo]</paragraph>' } ]
+					[ { colspan: 2, contents: '<paragraph>[foo]</paragraph>' } ],
+					[ '10', '11' ]
 				] ) );
 				] ) );
 			} );
 			} );
 
 
 			it( 'should result in single paragraph (selection cell is empty)', () => {
 			it( 'should result in single paragraph (selection cell is empty)', () => {
 				setData( model, modelTable( [
 				setData( model, modelTable( [
-					[ '[]', 'foo' ]
+					[ '[]', 'foo' ],
+					[ '10', '11' ]
 				] ) );
 				] ) );
 
 
 				command.execute();
 				command.execute();
 
 
 				assertEqualMarkup( getData( model ), modelTable( [
 				assertEqualMarkup( getData( model ), modelTable( [
-					[ { colspan: 2, contents: '<paragraph>[foo]</paragraph>' } ]
+					[ { colspan: 2, contents: '<paragraph>[foo]</paragraph>' } ],
+					[ '10', '11' ]
 				] ) );
 				] ) );
 			} );
 			} );
 
 
@@ -243,13 +263,15 @@ describe( 'MergeCellCommand', () => {
 				} );
 				} );
 
 
 				setData( model, modelTable( [
 				setData( model, modelTable( [
-					[ '<block>[]</block>', '<block></block>' ]
+					[ '<block>[]</block>', '<block></block>' ],
+					[ '10', '11' ]
 				] ) );
 				] ) );
 
 
 				command.execute();
 				command.execute();
 
 
 				assertEqualMarkup( getData( model ), modelTable( [
 				assertEqualMarkup( getData( model ), modelTable( [
-					[ { colspan: 2, contents: '<block>[</block><block>]</block>' } ]
+					[ { colspan: 2, contents: '<block>[</block><block>]</block>' } ],
+					[ '10', '11' ]
 				] ) );
 				] ) );
 			} );
 			} );
 		} );
 		} );
@@ -402,6 +424,20 @@ describe( 'MergeCellCommand', () => {
 
 
 		describe( 'execute()', () => {
 		describe( 'execute()', () => {
 			it( 'should merge table cells', () => {
 			it( 'should merge table cells', () => {
+				setData( model, modelTable( [
+					[ '00', '[]01' ],
+					[ '10', '11' ]
+				] ) );
+
+				command.execute();
+
+				assertEqualMarkup( getData( model ), modelTable( [
+					[ { colspan: 2, contents: '<paragraph>[00</paragraph><paragraph>01]</paragraph>' } ],
+					[ '10', '11' ]
+				] ) );
+			} );
+
+			it( 'should merge table cells and remove empty columns', () => {
 				setData( model, modelTable( [
 				setData( model, modelTable( [
 					[ '00', '[]01' ]
 					[ '00', '[]01' ]
 				] ) );
 				] ) );
@@ -409,43 +445,49 @@ describe( 'MergeCellCommand', () => {
 				command.execute();
 				command.execute();
 
 
 				assertEqualMarkup( getData( model ), modelTable( [
 				assertEqualMarkup( getData( model ), modelTable( [
-					[ { colspan: 2, contents: '<paragraph>[00</paragraph><paragraph>01]</paragraph>' } ]
+					[ '<paragraph>[00</paragraph><paragraph>01]</paragraph>' ]
 				] ) );
 				] ) );
 			} );
 			} );
 
 
 			it( 'should result in single empty paragraph if both cells are empty', () => {
 			it( 'should result in single empty paragraph if both cells are empty', () => {
 				setData( model, modelTable( [
 				setData( model, modelTable( [
-					[ '', '[]' ]
+					[ '', '[]' ],
+					[ '10', '11' ]
 				] ) );
 				] ) );
 
 
 				command.execute();
 				command.execute();
 
 
 				assertEqualMarkup( getData( model ), modelTable( [
 				assertEqualMarkup( getData( model ), modelTable( [
-					[ { colspan: 2, contents: '<paragraph>[]</paragraph>' } ]
+					[ { colspan: 2, contents: '<paragraph>[]</paragraph>' } ],
+					[ '10', '11' ]
 				] ) );
 				] ) );
 			} );
 			} );
 
 
 			it( 'should result in single paragraph (other cell is empty)', () => {
 			it( 'should result in single paragraph (other cell is empty)', () => {
 				setData( model, modelTable( [
 				setData( model, modelTable( [
-					[ '', 'foo[]' ]
+					[ '', 'foo[]' ],
+					[ '10', '11' ]
 				] ) );
 				] ) );
 
 
 				command.execute();
 				command.execute();
 
 
 				assertEqualMarkup( getData( model ), modelTable( [
 				assertEqualMarkup( getData( model ), modelTable( [
-					[ { colspan: 2, contents: '<paragraph>[foo]</paragraph>' } ]
+					[ { colspan: 2, contents: '<paragraph>[foo]</paragraph>' } ],
+					[ '10', '11' ]
 				] ) );
 				] ) );
 			} );
 			} );
 
 
 			it( 'should result in single paragraph (selection cell is empty)', () => {
 			it( 'should result in single paragraph (selection cell is empty)', () => {
 				setData( model, modelTable( [
 				setData( model, modelTable( [
-					[ 'foo', '[]' ]
+					[ 'foo', '[]' ],
+					[ '10', '11' ]
 				] ) );
 				] ) );
 
 
 				command.execute();
 				command.execute();
 
 
 				assertEqualMarkup( getData( model ), modelTable( [
 				assertEqualMarkup( getData( model ), modelTable( [
-					[ { colspan: 2, contents: '<paragraph>[foo]</paragraph>' } ]
+					[ { colspan: 2, contents: '<paragraph>[foo]</paragraph>' } ],
+					[ '10', '11' ]
 				] ) );
 				] ) );
 			} );
 			} );
 
 
@@ -457,13 +499,15 @@ describe( 'MergeCellCommand', () => {
 				} );
 				} );
 
 
 				setData( model, modelTable( [
 				setData( model, modelTable( [
-					[ '<block></block>', '<block>[]</block>' ]
+					[ '<block></block>', '<block>[]</block>' ],
+					[ '10', '11' ]
 				] ) );
 				] ) );
 
 
 				command.execute();
 				command.execute();
 
 
 				assertEqualMarkup( getData( model ), modelTable( [
 				assertEqualMarkup( getData( model ), modelTable( [
-					[ { colspan: 2, contents: '<block>[</block><block>]</block>' } ]
+					[ { colspan: 2, contents: '<block>[</block><block>]</block>' } ],
+					[ '10', '11' ]
 				] ) );
 				] ) );
 			} );
 			} );
 		} );
 		} );

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

@@ -311,6 +311,25 @@ describe( 'MergeCellsCommand', () => {
 
 
 	describe( 'execute()', () => {
 	describe( 'execute()', () => {
 		it( 'should merge simple table cell selection', () => {
 		it( 'should merge simple table cell selection', () => {
+			setData( model, modelTable( [
+				[ '[]00', '01' ],
+				[ '10', '11' ]
+			] ) );
+
+			tableSelection.setCellSelection(
+				root.getNodeByPath( [ 0, 0, 0 ] ),
+				root.getNodeByPath( [ 0, 0, 1 ] )
+			);
+
+			command.execute();
+
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ { colspan: 2, contents: '<paragraph>[00</paragraph><paragraph>01]</paragraph>' } ],
+				[ '10', '11' ]
+			] ) );
+		} );
+
+		it( 'should merge simple table cell selection and remove empty columns', () => {
 			setData( model, modelTable( [
 			setData( model, modelTable( [
 				[ '[]00', '01' ]
 				[ '[]00', '01' ]
 			] ) );
 			] ) );
@@ -323,7 +342,7 @@ describe( 'MergeCellsCommand', () => {
 			command.execute();
 			command.execute();
 
 
 			assertEqualMarkup( getData( model ), modelTable( [
 			assertEqualMarkup( getData( model ), modelTable( [
-				[ { colspan: 2, contents: '<paragraph>[00</paragraph><paragraph>01]</paragraph>' } ]
+				[ '<paragraph>[00</paragraph><paragraph>01]</paragraph>' ]
 			] ) );
 			] ) );
 		} );
 		} );
 
 
@@ -403,9 +422,17 @@ describe( 'MergeCellsCommand', () => {
 		} );
 		} );
 
 
 		it( 'should merge table cells - extend colspan attribute', () => {
 		it( 'should merge table cells - extend colspan attribute', () => {
+			// +----+----+----+----+
+			// | 00      | 02 | 03 |
+			// +----+----+----+----+
+			// | 10 | 11 | 12 | 13 |
+			// +----+----+----+----+
+			// | 20 | 21 | 22 | 23 |
+			// +----+----+----+----+
 			setData( model, modelTable( [
 			setData( model, modelTable( [
 				[ { colspan: 2, contents: '00' }, '02', '03' ],
 				[ { colspan: 2, contents: '00' }, '02', '03' ],
-				[ '10', '11', '12', '13' ]
+				[ '10', '11', '12', '13' ],
+				[ '20', '21', '22', '23' ]
 			] ) );
 			] ) );
 
 
 			selectNodes( [
 			selectNodes( [
@@ -415,6 +442,13 @@ describe( 'MergeCellsCommand', () => {
 
 
 			command.execute();
 			command.execute();
 
 
+			// +----+----+----+----+
+			// |              | 03 |
+			// +   (merged)   +----+
+			// |              | 13 |
+			// +----+----+----+----+
+			// | 20 | 21 | 22 | 23 |
+			// +----+----+----+----+
 			assertEqualMarkup( getData( model ), modelTable( [
 			assertEqualMarkup( getData( model ), modelTable( [
 				[ {
 				[ {
 					colspan: 3,
 					colspan: 3,
@@ -425,13 +459,15 @@ describe( 'MergeCellsCommand', () => {
 						'<paragraph>11</paragraph>' +
 						'<paragraph>11</paragraph>' +
 						'<paragraph>12]</paragraph>'
 						'<paragraph>12]</paragraph>'
 				}, '03' ],
 				}, '03' ],
-				[ '13' ]
+				[ '13' ],
+				[ '20', '21', '22', '23' ]
 			] ) );
 			] ) );
 		} );
 		} );
 
 
 		it( 'should merge to a single paragraph - every cell is empty', () => {
 		it( 'should merge to a single paragraph - every cell is empty', () => {
 			setData( model, modelTable( [
 			setData( model, modelTable( [
-				[ '[]', '' ]
+				[ '[]', '' ],
+				[ '10', '11' ]
 			] ) );
 			] ) );
 
 
 			selectNodes( [ [ 0, 0, 0 ], [ 0, 0, 1 ] ] );
 			selectNodes( [ [ 0, 0, 0 ], [ 0, 0, 1 ] ] );
@@ -439,13 +475,15 @@ describe( 'MergeCellsCommand', () => {
 			command.execute();
 			command.execute();
 
 
 			assertEqualMarkup( getData( model ), modelTable( [
 			assertEqualMarkup( getData( model ), modelTable( [
-				[ { colspan: 2, contents: '<paragraph>[]</paragraph>' } ]
+				[ { colspan: 2, contents: '<paragraph>[]</paragraph>' } ],
+				[ '10', '11' ]
 			] ) );
 			] ) );
 		} );
 		} );
 
 
 		it( 'should merge to a single paragraph - merged cell is empty', () => {
 		it( 'should merge to a single paragraph - merged cell is empty', () => {
 			setData( model, modelTable( [
 			setData( model, modelTable( [
-				[ 'foo', '' ]
+				[ 'foo', '' ],
+				[ '10', '11' ]
 			] ) );
 			] ) );
 
 
 			selectNodes( [ [ 0, 0, 0 ], [ 0, 0, 1 ] ] );
 			selectNodes( [ [ 0, 0, 0 ], [ 0, 0, 1 ] ] );
@@ -453,13 +491,15 @@ describe( 'MergeCellsCommand', () => {
 			command.execute();
 			command.execute();
 
 
 			assertEqualMarkup( getData( model ), modelTable( [
 			assertEqualMarkup( getData( model ), modelTable( [
-				[ { colspan: 2, contents: '<paragraph>[foo]</paragraph>' } ]
+				[ { colspan: 2, contents: '<paragraph>[foo]</paragraph>' } ],
+				[ '10', '11' ]
 			] ) );
 			] ) );
 		} );
 		} );
 
 
 		it( 'should merge to a single paragraph - cell to which others are merged is empty', () => {
 		it( 'should merge to a single paragraph - cell to which others are merged is empty', () => {
 			setData( model, modelTable( [
 			setData( model, modelTable( [
-				[ '', 'foo' ]
+				[ '', 'foo' ],
+				[ '10', '11' ]
 			] ) );
 			] ) );
 
 
 			selectNodes( [ [ 0, 0, 0 ], [ 0, 0, 1 ] ] );
 			selectNodes( [ [ 0, 0, 0 ], [ 0, 0, 1 ] ] );
@@ -467,7 +507,8 @@ describe( 'MergeCellsCommand', () => {
 			command.execute();
 			command.execute();
 
 
 			assertEqualMarkup( getData( model ), modelTable( [
 			assertEqualMarkup( getData( model ), modelTable( [
-				[ { colspan: 2, contents: '<paragraph>[foo]</paragraph>' } ]
+				[ { colspan: 2, contents: '<paragraph>[foo]</paragraph>' } ],
+				[ '10', '11' ]
 			] ) );
 			] ) );
 		} );
 		} );
 
 
@@ -479,7 +520,8 @@ describe( 'MergeCellsCommand', () => {
 			} );
 			} );
 
 
 			setData( model, modelTable( [
 			setData( model, modelTable( [
-				[ '<block>[]</block>', '<block></block>' ]
+				[ '<block>[]</block>', '<block></block>' ],
+				[ '10', '11' ]
 			] ) );
 			] ) );
 
 
 			selectNodes( [ [ 0, 0, 0 ], [ 0, 0, 1 ] ] );
 			selectNodes( [ [ 0, 0, 0 ], [ 0, 0, 1 ] ] );
@@ -487,7 +529,8 @@ describe( 'MergeCellsCommand', () => {
 			command.execute();
 			command.execute();
 
 
 			assertEqualMarkup( getData( model ), modelTable( [
 			assertEqualMarkup( getData( model ), modelTable( [
-				[ { colspan: 2, contents: '<block>[</block><block>]</block>' } ]
+				[ { colspan: 2, contents: '<block>[</block><block>]</block>' } ],
+				[ '10', '11' ]
 			] ) );
 			] ) );
 		} );
 		} );
 
 

+ 13 - 0
packages/ckeditor5-table/tests/commands/removerowcommand.js

@@ -535,5 +535,18 @@ describe( 'RemoveRowCommand', () => {
 				[ '30', '31', '32' ]
 				[ '30', '31', '32' ]
 			] ) );
 			] ) );
 		} );
 		} );
+
+		it( 'should remove empty columns after removing row', () => {
+			setData( model, modelTable( [
+				[ '00', { contents: '01', colspan: 2 } ],
+				[ '[]10', '11', '12' ]
+			] ) );
+
+			command.execute();
+
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ '[]00', '01' ]
+			] ) );
+		} );
 	} );
 	} );
 } );
 } );

+ 0 - 93
packages/ckeditor5-table/tests/commands/utils.js

@@ -1,93 +0,0 @@
-/**
- * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
- */
-
-import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
-import TableUtils from '../../src/tableutils';
-import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
-import { defaultConversion, defaultSchema, modelTable } from '../_utils/utils';
-import { findAncestor, isHeadingColumnCell } from '../../src/commands/utils';
-
-describe( 'commands utils', () => {
-	let editor, model, modelRoot, tableUtils;
-
-	beforeEach( () => {
-		return ModelTestEditor
-			.create( {
-				plugins: [ TableUtils ]
-			} )
-			.then( newEditor => {
-				editor = newEditor;
-				model = editor.model;
-				modelRoot = model.document.getRoot();
-				tableUtils = editor.plugins.get( TableUtils );
-
-				defaultSchema( model.schema );
-				defaultConversion( editor.conversion );
-			} );
-	} );
-
-	afterEach( () => {
-		return editor.destroy();
-	} );
-
-	describe( 'findAncestor()', () => {
-		it( 'should return undefined if not in table', () => {
-			setData( model, '<paragraph>foo[]</paragraph>' );
-
-			expect( findAncestor( 'table', model.document.selection.focus ) ).to.be.undefined;
-		} );
-
-		it( 'should return table if position is in tableCell', () => {
-			setData( model, modelTable( [ [ '[]' ] ] ) );
-
-			const parentTable = findAncestor( 'table', model.document.selection.focus );
-
-			expect( parentTable ).to.not.be.undefined;
-			expect( parentTable.is( 'table' ) ).to.be.true;
-		} );
-	} );
-
-	describe( 'isHeadingColumnCell()', () => {
-		it( 'should return "true" for a heading column cell', () => {
-			setData( model, modelTable( [
-				[ '00', '01', '02', '03' ]
-			], { headingColumns: 2 } ) );
-
-			const tableCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] );
-
-			expect( isHeadingColumnCell( tableUtils, tableCell ) ).to.be.true;
-		} );
-
-		it( 'should return "true" for a heading column cell with colspan', () => {
-			setData( model, modelTable( [
-				[ { colspan: 2, contents: '00' }, '01', '02', '03' ]
-			], { headingColumns: 2 } ) );
-
-			const tableCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
-
-			expect( isHeadingColumnCell( tableUtils, tableCell ) ).to.be.true;
-		} );
-
-		it( 'should return "false" for a regular column cell', () => {
-			setData( model, modelTable( [
-				[ '00', '01', '02', '03' ]
-			], { headingColumns: 2 } ) );
-
-			const tableCell = modelRoot.getNodeByPath( [ 0, 0, 2 ] );
-
-			expect( isHeadingColumnCell( tableUtils, tableCell ) ).to.be.false;
-		} );
-
-		it( 'should return "false" for a regular column cell with colspan', () => {
-			setData( model, modelTable( [
-				[ '00', { colspan: 2, contents: '01' }, '02', '03' ]
-			], { headingColumns: 1 } ) );
-
-			const tableCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] );
-
-			expect( isHeadingColumnCell( tableUtils, tableCell ) ).to.be.false;
-		} );
-	} );
-} );

+ 12 - 2
packages/ckeditor5-table/tests/manual/tablemocking.html

@@ -25,9 +25,19 @@
 	#input-status {
 	#input-status {
 		color: hsl( 0, 90%, 50% );
 		color: hsl( 0, 90%, 50% );
 	}
 	}
+	#tools {
+		margin-bottom: 10px;
+	}
+	#tools input[type="checkbox"] {
+		vertical-align: middle;
+	}
+	#tools label > span {
+		vertical-align: middle;
+		display: inline-block;
+	}
 </style>
 </style>
 
 
-<div style="margin-bottom: 10px;">
+<div id="tools">
 	<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>
 	<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">
 	<textarea id="model-data">
 [
 [
@@ -43,7 +53,7 @@
 	<button type="button" id="set-model-data">↓ Set model data ↓</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="get-model-data">↑ Get model data ↑</button>
 	<button type="button" id="renumber-cells">Renumber cells</button>
 	<button type="button" id="renumber-cells">Renumber cells</button>
-	<label><input type="checkbox" id="use-letters" checked="false">Use letters</label>
+	<label><input type="checkbox" id="use-letters" checked="false"><span>Use letters</span></label>
 
 
 	<span id="input-status"></span>
 	<span id="input-status"></span>
 </div>
 </div>

+ 2 - 2
packages/ckeditor5-table/tests/manual/tablemocking.js

@@ -14,8 +14,8 @@ import { diffString } from 'json-diff';
 import { debounce } from 'lodash-es';
 import { debounce } from 'lodash-es';
 import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
 import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
 import TableWalker from '../../src/tablewalker';
 import TableWalker from '../../src/tablewalker';
-import { getSelectionAffectedTableCells } from '../../src/utils';
-import { findAncestor } from '../../src/commands/utils';
+import { getSelectionAffectedTableCells } from '../../src/utils/selection';
+import { findAncestor } from '../../src/utils/common';
 
 
 ClassicEditor
 ClassicEditor
 	.create( document.querySelector( '#editor' ), {
 	.create( document.querySelector( '#editor' ), {

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

@@ -8,12 +8,15 @@ import TableEditing from '../src/tableediting';
 import TableUI from '../src/tableui';
 import TableUI from '../src/tableui';
 import TableSelection from '../src/tableselection';
 import TableSelection from '../src/tableselection';
 import TableClipboard from '../src/tableclipboard';
 import TableClipboard from '../src/tableclipboard';
-import TableNavigation from '../src/tablenavigation';
+import TableKeyboard from '../src/tablekeyboard';
 import Widget from '@ckeditor/ckeditor5-widget/src/widget';
 import Widget from '@ckeditor/ckeditor5-widget/src/widget';
+import TableMouse from '../src/tablemouse';
 
 
 describe( 'Table', () => {
 describe( 'Table', () => {
-	it( 'requires TableEditing, TableUI, TableSelection, TableClipboard, TableNavigation and Widget', () => {
-		expect( Table.requires ).to.deep.equal( [ TableEditing, TableUI, TableSelection, TableClipboard, TableNavigation, Widget ] );
+	it( 'requires TableEditing, TableUI, TableSelection, TableMouse, TableKeyboard, TableClipboard and Widget', () => {
+		expect( Table.requires ).to.deep.equal( [
+			TableEditing, TableUI, TableSelection, TableMouse, TableKeyboard, TableClipboard, Widget
+		] );
 	} );
 	} );
 
 
 	it( 'has proper name', () => {
 	it( 'has proper name', () => {

+ 1 - 1
packages/ckeditor5-table/tests/tablecellproperties/tablecellpropertiesui.js

@@ -20,7 +20,7 @@ import Table from '../../src/table';
 import TableCellPropertiesEditing from '../../src/tablecellproperties/tablecellpropertiesediting';
 import TableCellPropertiesEditing from '../../src/tablecellproperties/tablecellpropertiesediting';
 import TableCellPropertiesUI from '../../src/tablecellproperties/tablecellpropertiesui';
 import TableCellPropertiesUI from '../../src/tablecellproperties/tablecellpropertiesui';
 import TableCellPropertiesUIView from '../../src/tablecellproperties/ui/tablecellpropertiesview';
 import TableCellPropertiesUIView from '../../src/tablecellproperties/ui/tablecellpropertiesview';
-import { defaultColors } from '../../src/ui/utils';
+import { defaultColors } from '../../src/utils/ui/table-properties';
 import { modelTable } from '../_utils/utils';
 import { modelTable } from '../_utils/utils';
 
 
 describe( 'table cell properties', () => {
 describe( 'table cell properties', () => {

+ 162 - 3
packages/ckeditor5-table/tests/tableclipboard-paste.js

@@ -178,7 +178,8 @@ describe( 'table clipboard', () => {
 
 
 			const table = viewTable( [
 			const table = viewTable( [
 				[ 'aa', 'ab' ],
 				[ 'aa', 'ab' ],
-				[ 'ba', 'bb' ] ] );
+				[ 'ba', 'bb' ]
+			] );
 
 
 			const data = {
 			const data = {
 				dataTransfer: createDataTransfer(),
 				dataTransfer: createDataTransfer(),
@@ -204,7 +205,8 @@ describe( 'table clipboard', () => {
 
 
 			const table = viewTable( [
 			const table = viewTable( [
 				[ 'aa', 'ab' ],
 				[ 'aa', 'ab' ],
-				[ 'ba', 'bb' ] ] );
+				[ 'ba', 'bb' ]
+			] );
 
 
 			const data = {
 			const data = {
 				dataTransfer: createDataTransfer(),
 				dataTransfer: createDataTransfer(),
@@ -230,7 +232,8 @@ describe( 'table clipboard', () => {
 
 
 			const table = viewTable( [
 			const table = viewTable( [
 				[ 'aa', 'ab' ],
 				[ 'aa', 'ab' ],
-				[ 'ba', 'bb' ] ] );
+				[ 'ba', 'bb' ]
+			] );
 
 
 			const data = {
 			const data = {
 				dataTransfer: createDataTransfer(),
 				dataTransfer: createDataTransfer(),
@@ -248,6 +251,162 @@ describe( 'table clipboard', () => {
 			] ) );
 			] ) );
 		} );
 		} );
 
 
+		it( 'should alter model.insertContent if mixed content is pasted (table + empty paragraph)', () => {
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+			);
+
+			const table = viewTable( [
+				[ 'aa', 'ab' ],
+				[ 'ba', 'bb' ]
+			] );
+
+			const data = {
+				dataTransfer: createDataTransfer(),
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			};
+			data.dataTransfer.setData( 'text/html', `${ table }<p>&nbsp;</p>` );
+			viewDocument.fire( 'paste', data );
+
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+				[ 'aa', 'ab', '02', '03' ],
+				[ 'ba', 'bb', '12', '13' ],
+				[ '20', '21', '22', '23' ],
+				[ '30', '31', '32', '33' ]
+			] ) );
+		} );
+
+		it( 'should alter model.insertContent if mixed content is pasted (table + br)', () => {
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+			);
+
+			const table = viewTable( [
+				[ 'aa', 'ab' ],
+				[ 'ba', 'bb' ]
+			] );
+
+			const data = {
+				dataTransfer: createDataTransfer(),
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			};
+			data.dataTransfer.setData( 'text/html', `${ table }<br>` );
+			viewDocument.fire( 'paste', data );
+
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+				[ 'aa', 'ab', '02', '03' ],
+				[ 'ba', 'bb', '12', '13' ],
+				[ '20', '21', '22', '23' ],
+				[ '30', '31', '32', '33' ]
+			] ) );
+		} );
+
+		it( 'should alter model.insertContent if mixed content is pasted (empty paragraph + table)', () => {
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+			);
+
+			const table = viewTable( [
+				[ 'aa', 'ab' ],
+				[ 'ba', 'bb' ]
+			] );
+
+			const data = {
+				dataTransfer: createDataTransfer(),
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			};
+			data.dataTransfer.setData( 'text/html', `<p>&nbsp;</p>${ table }` );
+			viewDocument.fire( 'paste', data );
+
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+				[ 'aa', 'ab', '02', '03' ],
+				[ 'ba', 'bb', '12', '13' ],
+				[ '20', '21', '22', '23' ],
+				[ '30', '31', '32', '33' ]
+			] ) );
+		} );
+
+		it( 'should alter model.insertContent if mixed content is pasted (br + table)', () => {
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+			);
+
+			const table = viewTable( [
+				[ 'aa', 'ab' ],
+				[ 'ba', 'bb' ]
+			] );
+
+			const data = {
+				dataTransfer: createDataTransfer(),
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			};
+			data.dataTransfer.setData( 'text/html', `<br>${ table }` );
+			viewDocument.fire( 'paste', data );
+
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+				[ 'aa', 'ab', '02', '03' ],
+				[ 'ba', 'bb', '12', '13' ],
+				[ '20', '21', '22', '23' ],
+				[ '30', '31', '32', '33' ]
+			] ) );
+		} );
+
+		it( 'should alter model.insertContent if mixed content is pasted (p + p + table + p + br)', () => {
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+			);
+
+			const table = viewTable( [
+				[ 'aa', 'ab' ],
+				[ 'ba', 'bb' ]
+			] );
+
+			const data = {
+				dataTransfer: createDataTransfer(),
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			};
+			data.dataTransfer.setData( 'text/html', `<p>&nbsp;</p><p>&nbsp;</p>${ table }<p>&nbsp;</p><br>` );
+			viewDocument.fire( 'paste', data );
+
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+				[ 'aa', 'ab', '02', '03' ],
+				[ 'ba', 'bb', '12', '13' ],
+				[ '20', '21', '22', '23' ],
+				[ '30', '31', '32', '33' ]
+			] ) );
+		} );
+
+		it( 'should not alter model.insertContent if element other than a table is passed directly', () => {
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+			);
+
+			model.change( writer => {
+				const element = writer.createElement( 'paragraph' );
+
+				writer.insertText( 'foo', element, 0 );
+				model.insertContent( element );
+			} );
+
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+				[ 'foo', '', '02', '03' ],
+				[ '', '', '12', '13' ],
+				[ '20', '21', '22', '23' ],
+				[ '30', '31', '32', '33' ]
+			] ) );
+		} );
+
 		it( 'should alter model.insertContent if selectable is a document selection', () => {
 		it( 'should alter model.insertContent if selectable is a document selection', () => {
 			tableSelection.setCellSelection(
 			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),

+ 64 - 64
packages/ckeditor5-table/tests/tablenavigation.js → packages/ckeditor5-table/tests/tablekeyboard.js

@@ -3,12 +3,12 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
  */
 
 
-import TableNavigation from '../src/tablenavigation';
+import TableKeyboard from '../src/tablekeyboard';
 import Table from '../src/table';
 import Table from '../src/table';
 import TableEditing from '../src/tableediting';
 import TableEditing from '../src/tableediting';
 import TableSelection from '../src/tableselection';
 import TableSelection from '../src/tableselection';
 import { modelTable } from './_utils/utils';
 import { modelTable } from './_utils/utils';
-import { getTableCellsContainingSelection } from '../src/utils';
+import { getTableCellsContainingSelection } from '../src/utils/selection';
 
 
 import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
 import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
@@ -27,8 +27,8 @@ import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils'
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 import env from '@ckeditor/ckeditor5-utils/src/env';
 import env from '@ckeditor/ckeditor5-utils/src/env';
 
 
-describe( 'TableNavigation', () => {
-	let editor, model, modelRoot, tableSelection, tableNavigation, selection;
+describe( 'TableKeyboard', () => {
+	let editor, model, modelRoot, tableSelection, tableKeyboard, selection;
 
 
 	const imageUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAAAUCAQAAADRyVAeAAAAKklEQVR42u3PAQ0AAAwCI' +
 	const imageUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAAAUCAQAAADRyVAeAAAAKklEQVR42u3PAQ0AAAwCI' +
 		'O0f+u/hoAHNZUJFRERERERERERERERERLYiD9N4FAFj2iK6AAAAAElFTkSuQmCC';
 		'O0f+u/hoAHNZUJFRERERERERERERERERLYiD9N4FAFj2iK6AAAAAElFTkSuQmCC';
@@ -36,7 +36,7 @@ describe( 'TableNavigation', () => {
 	beforeEach( () => {
 	beforeEach( () => {
 		return VirtualTestEditor
 		return VirtualTestEditor
 			.create( {
 			.create( {
-				plugins: [ TableEditing, TableNavigation, TableSelection, Paragraph, ImageEditing, ImageCaptionEditing, MediaEmbedEditing,
+				plugins: [ TableEditing, TableKeyboard, TableSelection, Paragraph, ImageEditing, ImageCaptionEditing, MediaEmbedEditing,
 					HorizontalLineEditing ]
 					HorizontalLineEditing ]
 			} )
 			} )
 			.then( newEditor => {
 			.then( newEditor => {
@@ -46,7 +46,7 @@ describe( 'TableNavigation', () => {
 				selection = model.document.selection;
 				selection = model.document.selection;
 				modelRoot = model.document.getRoot();
 				modelRoot = model.document.getRoot();
 				tableSelection = editor.plugins.get( TableSelection );
 				tableSelection = editor.plugins.get( TableSelection );
-				tableNavigation = editor.plugins.get( TableNavigation );
+				tableKeyboard = editor.plugins.get( TableKeyboard );
 			} );
 			} );
 	} );
 	} );
 
 
@@ -55,7 +55,7 @@ describe( 'TableNavigation', () => {
 	} );
 	} );
 
 
 	it( 'should have pluginName', () => {
 	it( 'should have pluginName', () => {
-		expect( TableNavigation.pluginName ).to.equal( 'TableNavigation' );
+		expect( TableKeyboard.pluginName ).to.equal( 'TableKeyboard' );
 	} );
 	} );
 
 
 	describe( 'Tab key handling', () => {
 	describe( 'Tab key handling', () => {
@@ -465,7 +465,7 @@ describe( 'TableNavigation', () => {
 					} );
 					} );
 
 
 					it( 'should navigate to the start position of the cell on the right when the direction is "right"', () => {
 					it( 'should navigate to the start position of the cell on the right when the direction is "right"', () => {
-						tableNavigation._navigateFromCellInDirection( tableCell, 'right' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'right' );
 
 
 						assertEqualMarkup( getModelData( model ), '<paragraph>foo</paragraph>' + modelTable( [
 						assertEqualMarkup( getModelData( model ), '<paragraph>foo</paragraph>' + modelTable( [
 							[ '00', '[]01', '02' ],
 							[ '00', '[]01', '02' ],
@@ -475,7 +475,7 @@ describe( 'TableNavigation', () => {
 					} );
 					} );
 
 
 					it( 'should navigate to the start position of the cell below when the direction is "down"', () => {
 					it( 'should navigate to the start position of the cell below when the direction is "down"', () => {
-						tableNavigation._navigateFromCellInDirection( tableCell, 'down' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'down' );
 
 
 						assertEqualMarkup( getModelData( model ), '<paragraph>foo</paragraph>' + modelTable( [
 						assertEqualMarkup( getModelData( model ), '<paragraph>foo</paragraph>' + modelTable( [
 							[ '00', '01', '02' ],
 							[ '00', '01', '02' ],
@@ -485,7 +485,7 @@ describe( 'TableNavigation', () => {
 					} );
 					} );
 
 
 					it( 'should select a whole table when the direction is "up"', () => {
 					it( 'should select a whole table when the direction is "up"', () => {
-						tableNavigation._navigateFromCellInDirection( tableCell, 'up' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'up' );
 
 
 						assertEqualMarkup( getModelData( model ), '<paragraph>foo</paragraph>[' + modelTable( [
 						assertEqualMarkup( getModelData( model ), '<paragraph>foo</paragraph>[' + modelTable( [
 							[ '00', '01', '02' ],
 							[ '00', '01', '02' ],
@@ -495,7 +495,7 @@ describe( 'TableNavigation', () => {
 					} );
 					} );
 
 
 					it( 'should select a whole table when the direction is "left"', () => {
 					it( 'should select a whole table when the direction is "left"', () => {
-						tableNavigation._navigateFromCellInDirection( tableCell, 'left' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'left' );
 
 
 						assertEqualMarkup( getModelData( model ), '<paragraph>foo</paragraph>[' + modelTable( [
 						assertEqualMarkup( getModelData( model ), '<paragraph>foo</paragraph>[' + modelTable( [
 							[ '00', '01', '02' ],
 							[ '00', '01', '02' ],
@@ -513,7 +513,7 @@ describe( 'TableNavigation', () => {
 					} );
 					} );
 
 
 					it( 'should navigate to the end position of the cell on the left when the direction is "left"', () => {
 					it( 'should navigate to the end position of the cell on the left when the direction is "left"', () => {
-						tableNavigation._navigateFromCellInDirection( tableCell, 'left' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'left' );
 
 
 						assertEqualMarkup( getModelData( model ), '<paragraph>foo</paragraph>' + modelTable( [
 						assertEqualMarkup( getModelData( model ), '<paragraph>foo</paragraph>' + modelTable( [
 							[ '00', '01', '02' ],
 							[ '00', '01', '02' ],
@@ -523,7 +523,7 @@ describe( 'TableNavigation', () => {
 					} );
 					} );
 
 
 					it( 'should navigate to the end position of the cell above when the direction is "up"', () => {
 					it( 'should navigate to the end position of the cell above when the direction is "up"', () => {
-						tableNavigation._navigateFromCellInDirection( tableCell, 'up' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'up' );
 
 
 						assertEqualMarkup( getModelData( model ), '<paragraph>foo</paragraph>' + modelTable( [
 						assertEqualMarkup( getModelData( model ), '<paragraph>foo</paragraph>' + modelTable( [
 							[ '00', '01', '02' ],
 							[ '00', '01', '02' ],
@@ -533,7 +533,7 @@ describe( 'TableNavigation', () => {
 					} );
 					} );
 
 
 					it( 'should select a whole table when the direction is "down"', () => {
 					it( 'should select a whole table when the direction is "down"', () => {
-						tableNavigation._navigateFromCellInDirection( tableCell, 'down' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'down' );
 
 
 						assertEqualMarkup( getModelData( model ), '<paragraph>foo</paragraph>[' + modelTable( [
 						assertEqualMarkup( getModelData( model ), '<paragraph>foo</paragraph>[' + modelTable( [
 							[ '00', '01', '02' ],
 							[ '00', '01', '02' ],
@@ -543,7 +543,7 @@ describe( 'TableNavigation', () => {
 					} );
 					} );
 
 
 					it( 'should select a whole table when the direction is "right"', () => {
 					it( 'should select a whole table when the direction is "right"', () => {
-						tableNavigation._navigateFromCellInDirection( tableCell, 'right' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'right' );
 
 
 						assertEqualMarkup( getModelData( model ), '<paragraph>foo</paragraph>[' + modelTable( [
 						assertEqualMarkup( getModelData( model ), '<paragraph>foo</paragraph>[' + modelTable( [
 							[ '00', '01', '02' ],
 							[ '00', '01', '02' ],
@@ -561,7 +561,7 @@ describe( 'TableNavigation', () => {
 					} );
 					} );
 
 
 					it( 'should navigate to start position of the cell on the right when the direction is "right"', () => {
 					it( 'should navigate to start position of the cell on the right when the direction is "right"', () => {
-						tableNavigation._navigateFromCellInDirection( tableCell, 'right' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'right' );
 
 
 						assertEqualMarkup( getModelData( model ), '<paragraph>foo</paragraph>' + modelTable( [
 						assertEqualMarkup( getModelData( model ), '<paragraph>foo</paragraph>' + modelTable( [
 							[ '00', '01', '02' ],
 							[ '00', '01', '02' ],
@@ -571,7 +571,7 @@ describe( 'TableNavigation', () => {
 					} );
 					} );
 
 
 					it( 'should navigate to the end position of the cell above when the direction is "up"', () => {
 					it( 'should navigate to the end position of the cell above when the direction is "up"', () => {
-						tableNavigation._navigateFromCellInDirection( tableCell, 'up' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'up' );
 
 
 						assertEqualMarkup( getModelData( model ), '<paragraph>foo</paragraph>' + modelTable( [
 						assertEqualMarkup( getModelData( model ), '<paragraph>foo</paragraph>' + modelTable( [
 							[ '00[]', '01', '02' ],
 							[ '00[]', '01', '02' ],
@@ -581,7 +581,7 @@ describe( 'TableNavigation', () => {
 					} );
 					} );
 
 
 					it( 'should navigate to the start position of the cell below when the direction is "down"', () => {
 					it( 'should navigate to the start position of the cell below when the direction is "down"', () => {
-						tableNavigation._navigateFromCellInDirection( tableCell, 'down' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'down' );
 
 
 						assertEqualMarkup( getModelData( model ), '<paragraph>foo</paragraph>' + modelTable( [
 						assertEqualMarkup( getModelData( model ), '<paragraph>foo</paragraph>' + modelTable( [
 							[ '00', '01', '02' ],
 							[ '00', '01', '02' ],
@@ -591,7 +591,7 @@ describe( 'TableNavigation', () => {
 					} );
 					} );
 
 
 					it( 'should navigate to the end position of the last cell in the previous row when the direction is "left"', () => {
 					it( 'should navigate to the end position of the last cell in the previous row when the direction is "left"', () => {
-						tableNavigation._navigateFromCellInDirection( tableCell, 'left' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'left' );
 
 
 						assertEqualMarkup( getModelData( model ), '<paragraph>foo</paragraph>' + modelTable( [
 						assertEqualMarkup( getModelData( model ), '<paragraph>foo</paragraph>' + modelTable( [
 							[ '00', '01', '02[]' ],
 							[ '00', '01', '02[]' ],
@@ -609,7 +609,7 @@ describe( 'TableNavigation', () => {
 					} );
 					} );
 
 
 					it( 'should navigate to the end position of the cell on the left when the direction is "left"', () => {
 					it( 'should navigate to the end position of the cell on the left when the direction is "left"', () => {
-						tableNavigation._navigateFromCellInDirection( tableCell, 'left' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'left' );
 
 
 						assertEqualMarkup( getModelData( model ), '<paragraph>foo</paragraph>' + modelTable( [
 						assertEqualMarkup( getModelData( model ), '<paragraph>foo</paragraph>' + modelTable( [
 							[ '00', '01', '02' ],
 							[ '00', '01', '02' ],
@@ -619,7 +619,7 @@ describe( 'TableNavigation', () => {
 					} );
 					} );
 
 
 					it( 'should navigate to the end position the cell above when the direction is "up"', () => {
 					it( 'should navigate to the end position the cell above when the direction is "up"', () => {
-						tableNavigation._navigateFromCellInDirection( tableCell, 'up' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'up' );
 
 
 						assertEqualMarkup( getModelData( model ), '<paragraph>foo</paragraph>' + modelTable( [
 						assertEqualMarkup( getModelData( model ), '<paragraph>foo</paragraph>' + modelTable( [
 							[ '00', '01', '02[]' ],
 							[ '00', '01', '02[]' ],
@@ -629,7 +629,7 @@ describe( 'TableNavigation', () => {
 					} );
 					} );
 
 
 					it( 'should navigate to the start position of the cell below when the direction is "down"', () => {
 					it( 'should navigate to the start position of the cell below when the direction is "down"', () => {
-						tableNavigation._navigateFromCellInDirection( tableCell, 'down' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'down' );
 
 
 						assertEqualMarkup( getModelData( model ), '<paragraph>foo</paragraph>' + modelTable( [
 						assertEqualMarkup( getModelData( model ), '<paragraph>foo</paragraph>' + modelTable( [
 							[ '00', '01', '02' ],
 							[ '00', '01', '02' ],
@@ -639,7 +639,7 @@ describe( 'TableNavigation', () => {
 					} );
 					} );
 
 
 					it( 'should navigate to the start position of the first cell in the next row when the direction is "right"', () => {
 					it( 'should navigate to the start position of the first cell in the next row when the direction is "right"', () => {
-						tableNavigation._navigateFromCellInDirection( tableCell, 'right' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'right' );
 
 
 						assertEqualMarkup( getModelData( model ), '<paragraph>foo</paragraph>' + modelTable( [
 						assertEqualMarkup( getModelData( model ), '<paragraph>foo</paragraph>' + modelTable( [
 							[ '00', '01', '02' ],
 							[ '00', '01', '02' ],
@@ -676,7 +676,7 @@ describe( 'TableNavigation', () => {
 					it( 'should navigate to the row-col-spanned cell when approaching from the upper-spanned row', () => {
 					it( 'should navigate to the row-col-spanned cell when approaching from the upper-spanned row', () => {
 						const tableCell = modelRoot.getNodeByPath( [ 0, 1, 0 ] );
 						const tableCell = modelRoot.getNodeByPath( [ 0, 1, 0 ] );
 
 
-						tableNavigation._navigateFromCellInDirection( tableCell, 'right' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'right' );
 
 
 						assertEqualMarkup( getModelData( model ), modelTable( [
 						assertEqualMarkup( getModelData( model ), modelTable( [
 							[ '00', '01', '02', '03', '04' ],
 							[ '00', '01', '02', '03', '04' ],
@@ -690,7 +690,7 @@ describe( 'TableNavigation', () => {
 					it( 'should navigate to the row-col-spanned cell when approaching from the lower-spanned row', () => {
 					it( 'should navigate to the row-col-spanned cell when approaching from the lower-spanned row', () => {
 						const tableCell = modelRoot.getNodeByPath( [ 0, 2, 0 ] );
 						const tableCell = modelRoot.getNodeByPath( [ 0, 2, 0 ] );
 
 
-						tableNavigation._navigateFromCellInDirection( tableCell, 'right' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'right' );
 
 
 						assertEqualMarkup( getModelData( model ), modelTable( [
 						assertEqualMarkup( getModelData( model ), modelTable( [
 							[ '00', '01', '02', '03', '04' ],
 							[ '00', '01', '02', '03', '04' ],
@@ -704,7 +704,7 @@ describe( 'TableNavigation', () => {
 					it( 'should navigate to the row-spanned cell when approaching from the other row-spanned cell', () => {
 					it( 'should navigate to the row-spanned cell when approaching from the other row-spanned cell', () => {
 						const tableCell = modelRoot.getNodeByPath( [ 0, 1, 1 ] );
 						const tableCell = modelRoot.getNodeByPath( [ 0, 1, 1 ] );
 
 
-						tableNavigation._navigateFromCellInDirection( tableCell, 'right' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'right' );
 
 
 						assertEqualMarkup( getModelData( model ), modelTable( [
 						assertEqualMarkup( getModelData( model ), modelTable( [
 							[ '00', '01', '02', '03', '04' ],
 							[ '00', '01', '02', '03', '04' ],
@@ -718,7 +718,7 @@ describe( 'TableNavigation', () => {
 					it( 'should navigate to the cell in the upper-spanned row when approaching from the row-spanned cell', () => {
 					it( 'should navigate to the cell in the upper-spanned row when approaching from the row-spanned cell', () => {
 						const tableCell = modelRoot.getNodeByPath( [ 0, 1, 2 ] ); // Cell 13.
 						const tableCell = modelRoot.getNodeByPath( [ 0, 1, 2 ] ); // Cell 13.
 
 
-						tableNavigation._navigateFromCellInDirection( tableCell, 'right' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'right' );
 
 
 						assertEqualMarkup( getModelData( model ), modelTable( [
 						assertEqualMarkup( getModelData( model ), modelTable( [
 							[ '00', '01', '02', '03', '04' ],
 							[ '00', '01', '02', '03', '04' ],
@@ -732,7 +732,7 @@ describe( 'TableNavigation', () => {
 					it( 'should navigate to the col-spanned cell', () => {
 					it( 'should navigate to the col-spanned cell', () => {
 						const tableCell = modelRoot.getNodeByPath( [ 0, 3, 0 ] );
 						const tableCell = modelRoot.getNodeByPath( [ 0, 3, 0 ] );
 
 
-						tableNavigation._navigateFromCellInDirection( tableCell, 'right' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'right' );
 
 
 						assertEqualMarkup( getModelData( model ), modelTable( [
 						assertEqualMarkup( getModelData( model ), modelTable( [
 							[ '00', '01', '02', '03', '04' ],
 							[ '00', '01', '02', '03', '04' ],
@@ -746,7 +746,7 @@ describe( 'TableNavigation', () => {
 					it( 'should navigate from the col-spanned cell', () => {
 					it( 'should navigate from the col-spanned cell', () => {
 						const tableCell = modelRoot.getNodeByPath( [ 0, 3, 1 ] );
 						const tableCell = modelRoot.getNodeByPath( [ 0, 3, 1 ] );
 
 
-						tableNavigation._navigateFromCellInDirection( tableCell, 'right' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'right' );
 
 
 						assertEqualMarkup( getModelData( model ), modelTable( [
 						assertEqualMarkup( getModelData( model ), modelTable( [
 							[ '00', '01', '02', '03', '04' ],
 							[ '00', '01', '02', '03', '04' ],
@@ -762,7 +762,7 @@ describe( 'TableNavigation', () => {
 					it( 'should navigate to the row-spanned cell when approaching from the upper-spanned row', () => {
 					it( 'should navigate to the row-spanned cell when approaching from the upper-spanned row', () => {
 						const tableCell = modelRoot.getNodeByPath( [ 0, 1, 3 ] ); // Cell 14.
 						const tableCell = modelRoot.getNodeByPath( [ 0, 1, 3 ] ); // Cell 14.
 
 
-						tableNavigation._navigateFromCellInDirection( tableCell, 'left' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'left' );
 
 
 						assertEqualMarkup( getModelData( model ), modelTable( [
 						assertEqualMarkup( getModelData( model ), modelTable( [
 							[ '00', '01', '02', '03', '04' ],
 							[ '00', '01', '02', '03', '04' ],
@@ -776,7 +776,7 @@ describe( 'TableNavigation', () => {
 					it( 'should navigate to the row-spanned cell when approaching from the lower-spanned row', () => {
 					it( 'should navigate to the row-spanned cell when approaching from the lower-spanned row', () => {
 						const tableCell = modelRoot.getNodeByPath( [ 0, 2, 1 ] ); // Cell 24.
 						const tableCell = modelRoot.getNodeByPath( [ 0, 2, 1 ] ); // Cell 24.
 
 
-						tableNavigation._navigateFromCellInDirection( tableCell, 'left' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'left' );
 
 
 						assertEqualMarkup( getModelData( model ), modelTable( [
 						assertEqualMarkup( getModelData( model ), modelTable( [
 							[ '00', '01', '02', '03', '04' ],
 							[ '00', '01', '02', '03', '04' ],
@@ -790,7 +790,7 @@ describe( 'TableNavigation', () => {
 					it( 'should navigate to the row-spanned cell when approaching from the other row-spanned cell', () => {
 					it( 'should navigate to the row-spanned cell when approaching from the other row-spanned cell', () => {
 						const tableCell = modelRoot.getNodeByPath( [ 0, 1, 2 ] ); // Cell 13.
 						const tableCell = modelRoot.getNodeByPath( [ 0, 1, 2 ] ); // Cell 13.
 
 
-						tableNavigation._navigateFromCellInDirection( tableCell, 'left' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'left' );
 
 
 						assertEqualMarkup( getModelData( model ), modelTable( [
 						assertEqualMarkup( getModelData( model ), modelTable( [
 							[ '00', '01', '02', '03', '04' ],
 							[ '00', '01', '02', '03', '04' ],
@@ -804,7 +804,7 @@ describe( 'TableNavigation', () => {
 					it( 'should navigate to the cell in the upper-spanned row when approaching from the row-spanned cell', () => {
 					it( 'should navigate to the cell in the upper-spanned row when approaching from the row-spanned cell', () => {
 						const tableCell = modelRoot.getNodeByPath( [ 0, 1, 1 ] ); // Cell 11.
 						const tableCell = modelRoot.getNodeByPath( [ 0, 1, 1 ] ); // Cell 11.
 
 
-						tableNavigation._navigateFromCellInDirection( tableCell, 'left' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'left' );
 
 
 						assertEqualMarkup( getModelData( model ), modelTable( [
 						assertEqualMarkup( getModelData( model ), modelTable( [
 							[ '00', '01', '02', '03', '04' ],
 							[ '00', '01', '02', '03', '04' ],
@@ -818,7 +818,7 @@ describe( 'TableNavigation', () => {
 					it( 'should navigate to the col-spanned cell', () => {
 					it( 'should navigate to the col-spanned cell', () => {
 						const tableCell = modelRoot.getNodeByPath( [ 0, 3, 2 ] ); // Cell 33.
 						const tableCell = modelRoot.getNodeByPath( [ 0, 3, 2 ] ); // Cell 33.
 
 
-						tableNavigation._navigateFromCellInDirection( tableCell, 'left' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'left' );
 
 
 						assertEqualMarkup( getModelData( model ), modelTable( [
 						assertEqualMarkup( getModelData( model ), modelTable( [
 							[ '00', '01', '02', '03', '04' ],
 							[ '00', '01', '02', '03', '04' ],
@@ -832,7 +832,7 @@ describe( 'TableNavigation', () => {
 					it( 'should navigate from the col-spanned cell', () => {
 					it( 'should navigate from the col-spanned cell', () => {
 						const tableCell = modelRoot.getNodeByPath( [ 0, 3, 1 ] );
 						const tableCell = modelRoot.getNodeByPath( [ 0, 3, 1 ] );
 
 
-						tableNavigation._navigateFromCellInDirection( tableCell, 'left' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'left' );
 
 
 						assertEqualMarkup( getModelData( model ), modelTable( [
 						assertEqualMarkup( getModelData( model ), modelTable( [
 							[ '00', '01', '02', '03', '04' ],
 							[ '00', '01', '02', '03', '04' ],
@@ -848,7 +848,7 @@ describe( 'TableNavigation', () => {
 					it( 'should navigate to the row-col-spanned cell when approaching from the first spanned column', () => {
 					it( 'should navigate to the row-col-spanned cell when approaching from the first spanned column', () => {
 						const tableCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] );
 						const tableCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] );
 
 
-						tableNavigation._navigateFromCellInDirection( tableCell, 'down' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'down' );
 
 
 						assertEqualMarkup( getModelData( model ), modelTable( [
 						assertEqualMarkup( getModelData( model ), modelTable( [
 							[ '00', '01', '02', '03', '04' ],
 							[ '00', '01', '02', '03', '04' ],
@@ -862,7 +862,7 @@ describe( 'TableNavigation', () => {
 					it( 'should navigate to the row-col-spanned cell when approaching from the last spanned column', () => {
 					it( 'should navigate to the row-col-spanned cell when approaching from the last spanned column', () => {
 						const tableCell = modelRoot.getNodeByPath( [ 0, 0, 2 ] );
 						const tableCell = modelRoot.getNodeByPath( [ 0, 0, 2 ] );
 
 
-						tableNavigation._navigateFromCellInDirection( tableCell, 'down' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'down' );
 
 
 						assertEqualMarkup( getModelData( model ), modelTable( [
 						assertEqualMarkup( getModelData( model ), modelTable( [
 							[ '00', '01', '02', '03', '04' ],
 							[ '00', '01', '02', '03', '04' ],
@@ -876,7 +876,7 @@ describe( 'TableNavigation', () => {
 					it( 'should navigate to the row-spanned cell when approaching from the other col-spanned cell', () => {
 					it( 'should navigate to the row-spanned cell when approaching from the other col-spanned cell', () => {
 						const tableCell = modelRoot.getNodeByPath( [ 0, 1, 1 ] );
 						const tableCell = modelRoot.getNodeByPath( [ 0, 1, 1 ] );
 
 
-						tableNavigation._navigateFromCellInDirection( tableCell, 'down' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'down' );
 
 
 						assertEqualMarkup( getModelData( model ), modelTable( [
 						assertEqualMarkup( getModelData( model ), modelTable( [
 							[ '00', '01', '02', '03', '04' ],
 							[ '00', '01', '02', '03', '04' ],
@@ -890,7 +890,7 @@ describe( 'TableNavigation', () => {
 					it( 'should navigate to the cell in the first spanned column when approaching from the col-spanned cell', () => {
 					it( 'should navigate to the cell in the first spanned column when approaching from the col-spanned cell', () => {
 						const tableCell = modelRoot.getNodeByPath( [ 0, 1, 1 ] ); // Cell 11.
 						const tableCell = modelRoot.getNodeByPath( [ 0, 1, 1 ] ); // Cell 11.
 
 
-						tableNavigation._navigateFromCellInDirection( tableCell, 'down' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'down' );
 
 
 						assertEqualMarkup( getModelData( model ), modelTable( [
 						assertEqualMarkup( getModelData( model ), modelTable( [
 							[ '00', '01', '02', '03', '04' ],
 							[ '00', '01', '02', '03', '04' ],
@@ -904,7 +904,7 @@ describe( 'TableNavigation', () => {
 					it( 'should navigate to the row-spanned cell', () => {
 					it( 'should navigate to the row-spanned cell', () => {
 						const tableCell = modelRoot.getNodeByPath( [ 0, 0, 3 ] );
 						const tableCell = modelRoot.getNodeByPath( [ 0, 0, 3 ] );
 
 
-						tableNavigation._navigateFromCellInDirection( tableCell, 'down' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'down' );
 
 
 						assertEqualMarkup( getModelData( model ), modelTable( [
 						assertEqualMarkup( getModelData( model ), modelTable( [
 							[ '00', '01', '02', '03', '04' ],
 							[ '00', '01', '02', '03', '04' ],
@@ -918,7 +918,7 @@ describe( 'TableNavigation', () => {
 					it( 'should navigate from the row-spanned cell', () => {
 					it( 'should navigate from the row-spanned cell', () => {
 						const tableCell = modelRoot.getNodeByPath( [ 0, 1, 2 ] ); // Cell 13.
 						const tableCell = modelRoot.getNodeByPath( [ 0, 1, 2 ] ); // Cell 13.
 
 
-						tableNavigation._navigateFromCellInDirection( tableCell, 'down' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'down' );
 
 
 						assertEqualMarkup( getModelData( model ), modelTable( [
 						assertEqualMarkup( getModelData( model ), modelTable( [
 							[ '00', '01', '02', '03', '04' ],
 							[ '00', '01', '02', '03', '04' ],
@@ -934,7 +934,7 @@ describe( 'TableNavigation', () => {
 					it( 'should navigate to the col-spanned cell when approaching from the first spanned column', () => {
 					it( 'should navigate to the col-spanned cell when approaching from the first spanned column', () => {
 						const tableCell = modelRoot.getNodeByPath( [ 0, 4, 1 ] );
 						const tableCell = modelRoot.getNodeByPath( [ 0, 4, 1 ] );
 
 
-						tableNavigation._navigateFromCellInDirection( tableCell, 'up' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'up' );
 
 
 						assertEqualMarkup( getModelData( model ), modelTable( [
 						assertEqualMarkup( getModelData( model ), modelTable( [
 							[ '00', '01', '02', '03', '04' ],
 							[ '00', '01', '02', '03', '04' ],
@@ -948,7 +948,7 @@ describe( 'TableNavigation', () => {
 					it( 'should navigate to the col-spanned cell when approaching from the last spanned column', () => {
 					it( 'should navigate to the col-spanned cell when approaching from the last spanned column', () => {
 						const tableCell = modelRoot.getNodeByPath( [ 0, 4, 2 ] );
 						const tableCell = modelRoot.getNodeByPath( [ 0, 4, 2 ] );
 
 
-						tableNavigation._navigateFromCellInDirection( tableCell, 'up' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'up' );
 
 
 						assertEqualMarkup( getModelData( model ), modelTable( [
 						assertEqualMarkup( getModelData( model ), modelTable( [
 							[ '00', '01', '02', '03', '04' ],
 							[ '00', '01', '02', '03', '04' ],
@@ -962,7 +962,7 @@ describe( 'TableNavigation', () => {
 					it( 'should navigate to the row-col-spanned cell when approaching from the other col-spanned cell', () => {
 					it( 'should navigate to the row-col-spanned cell when approaching from the other col-spanned cell', () => {
 						const tableCell = modelRoot.getNodeByPath( [ 0, 3, 1 ] );
 						const tableCell = modelRoot.getNodeByPath( [ 0, 3, 1 ] );
 
 
-						tableNavigation._navigateFromCellInDirection( tableCell, 'up' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'up' );
 
 
 						assertEqualMarkup( getModelData( model ), modelTable( [
 						assertEqualMarkup( getModelData( model ), modelTable( [
 							[ '00', '01', '02', '03', '04' ],
 							[ '00', '01', '02', '03', '04' ],
@@ -976,7 +976,7 @@ describe( 'TableNavigation', () => {
 					it( 'should navigate to the cell in the first spanned column when approaching from the col-spanned cell', () => {
 					it( 'should navigate to the cell in the first spanned column when approaching from the col-spanned cell', () => {
 						const tableCell = modelRoot.getNodeByPath( [ 0, 1, 1 ] );
 						const tableCell = modelRoot.getNodeByPath( [ 0, 1, 1 ] );
 
 
-						tableNavigation._navigateFromCellInDirection( tableCell, 'up' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'up' );
 
 
 						assertEqualMarkup( getModelData( model ), modelTable( [
 						assertEqualMarkup( getModelData( model ), modelTable( [
 							[ '00', '01[]', '02', '03', '04' ],
 							[ '00', '01[]', '02', '03', '04' ],
@@ -990,7 +990,7 @@ describe( 'TableNavigation', () => {
 					it( 'should navigate to the row-spanned cell', () => {
 					it( 'should navigate to the row-spanned cell', () => {
 						const tableCell = modelRoot.getNodeByPath( [ 0, 3, 2 ] ); // Cell 33.
 						const tableCell = modelRoot.getNodeByPath( [ 0, 3, 2 ] ); // Cell 33.
 
 
-						tableNavigation._navigateFromCellInDirection( tableCell, 'up' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'up' );
 
 
 						assertEqualMarkup( getModelData( model ), modelTable( [
 						assertEqualMarkup( getModelData( model ), modelTable( [
 							[ '00', '01', '02', '03', '04' ],
 							[ '00', '01', '02', '03', '04' ],
@@ -1004,7 +1004,7 @@ describe( 'TableNavigation', () => {
 					it( 'should navigate from the row-spanned cell', () => {
 					it( 'should navigate from the row-spanned cell', () => {
 						const tableCell = modelRoot.getNodeByPath( [ 0, 1, 2 ] ); // Cell 13.
 						const tableCell = modelRoot.getNodeByPath( [ 0, 1, 2 ] ); // Cell 13.
 
 
-						tableNavigation._navigateFromCellInDirection( tableCell, 'up' );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'up' );
 
 
 						assertEqualMarkup( getModelData( model ), modelTable( [
 						assertEqualMarkup( getModelData( model ), modelTable( [
 							[ '00', '01', '02', '03[]', '04' ],
 							[ '00', '01', '02', '03[]', '04' ],
@@ -1034,7 +1034,7 @@ describe( 'TableNavigation', () => {
 					} );
 					} );
 
 
 					it( 'should expand the selection to the cell on the right when the direction is "right"', () => {
 					it( 'should expand the selection to the cell on the right when the direction is "right"', () => {
-						tableNavigation._navigateFromCellInDirection( tableCell, 'right', true );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'right', true );
 
 
 						expect( tableSelection.getAnchorCell() ).to.equal( tableCell );
 						expect( tableSelection.getAnchorCell() ).to.equal( tableCell );
 						expect( tableSelection.getFocusCell() ).to.equal( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) );
 						expect( tableSelection.getFocusCell() ).to.equal( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) );
@@ -1042,7 +1042,7 @@ describe( 'TableNavigation', () => {
 					} );
 					} );
 
 
 					it( 'should expand the selection to the cell below when the direction is "down"', () => {
 					it( 'should expand the selection to the cell below when the direction is "down"', () => {
-						tableNavigation._navigateFromCellInDirection( tableCell, 'down', true );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'down', true );
 
 
 						expect( tableSelection.getAnchorCell() ).to.equal( tableCell );
 						expect( tableSelection.getAnchorCell() ).to.equal( tableCell );
 						expect( tableSelection.getFocusCell() ).to.equal( modelRoot.getNodeByPath( [ 0, 1, 0 ] ) );
 						expect( tableSelection.getFocusCell() ).to.equal( modelRoot.getNodeByPath( [ 0, 1, 0 ] ) );
@@ -1050,7 +1050,7 @@ describe( 'TableNavigation', () => {
 					} );
 					} );
 
 
 					it( 'should select a whole table when the direction is "up"', () => {
 					it( 'should select a whole table when the direction is "up"', () => {
-						tableNavigation._navigateFromCellInDirection( tableCell, 'up', true );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'up', true );
 
 
 						assertEqualMarkup( getModelData( model ), '[' + modelTable( [
 						assertEqualMarkup( getModelData( model ), '[' + modelTable( [
 							[ '00', '01', '02' ],
 							[ '00', '01', '02' ],
@@ -1060,7 +1060,7 @@ describe( 'TableNavigation', () => {
 					} );
 					} );
 
 
 					it( 'should select a whole table when the direction is "left"', () => {
 					it( 'should select a whole table when the direction is "left"', () => {
-						tableNavigation._navigateFromCellInDirection( tableCell, 'left', true );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'left', true );
 
 
 						assertEqualMarkup( getModelData( model ), '[' + modelTable( [
 						assertEqualMarkup( getModelData( model ), '[' + modelTable( [
 							[ '00', '01', '02' ],
 							[ '00', '01', '02' ],
@@ -1079,7 +1079,7 @@ describe( 'TableNavigation', () => {
 					} );
 					} );
 
 
 					it( 'should expand the selection to the cell on the left when the direction is "left"', () => {
 					it( 'should expand the selection to the cell on the left when the direction is "left"', () => {
-						tableNavigation._navigateFromCellInDirection( tableCell, 'left', true );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'left', true );
 
 
 						expect( tableSelection.getAnchorCell() ).to.equal( tableCell );
 						expect( tableSelection.getAnchorCell() ).to.equal( tableCell );
 						expect( tableSelection.getFocusCell() ).to.equal( modelRoot.getNodeByPath( [ 0, 2, 1 ] ) );
 						expect( tableSelection.getFocusCell() ).to.equal( modelRoot.getNodeByPath( [ 0, 2, 1 ] ) );
@@ -1087,7 +1087,7 @@ describe( 'TableNavigation', () => {
 					} );
 					} );
 
 
 					it( 'should expand the selection to the cell above when the direction is "up"', () => {
 					it( 'should expand the selection to the cell above when the direction is "up"', () => {
-						tableNavigation._navigateFromCellInDirection( tableCell, 'up', true );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'up', true );
 
 
 						expect( tableSelection.getAnchorCell() ).to.equal( tableCell );
 						expect( tableSelection.getAnchorCell() ).to.equal( tableCell );
 						expect( tableSelection.getFocusCell() ).to.equal( modelRoot.getNodeByPath( [ 0, 1, 2 ] ) );
 						expect( tableSelection.getFocusCell() ).to.equal( modelRoot.getNodeByPath( [ 0, 1, 2 ] ) );
@@ -1095,7 +1095,7 @@ describe( 'TableNavigation', () => {
 					} );
 					} );
 
 
 					it( 'should select a whole table when the direction is "down"', () => {
 					it( 'should select a whole table when the direction is "down"', () => {
-						tableNavigation._navigateFromCellInDirection( tableCell, 'down', true );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'down', true );
 
 
 						assertEqualMarkup( getModelData( model ), '[' + modelTable( [
 						assertEqualMarkup( getModelData( model ), '[' + modelTable( [
 							[ '00', '01', '02' ],
 							[ '00', '01', '02' ],
@@ -1105,7 +1105,7 @@ describe( 'TableNavigation', () => {
 					} );
 					} );
 
 
 					it( 'should select a whole table when the direction is "right"', () => {
 					it( 'should select a whole table when the direction is "right"', () => {
-						tableNavigation._navigateFromCellInDirection( tableCell, 'right', true );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'right', true );
 
 
 						assertEqualMarkup( getModelData( model ), '[' + modelTable( [
 						assertEqualMarkup( getModelData( model ), '[' + modelTable( [
 							[ '00', '01', '02' ],
 							[ '00', '01', '02' ],
@@ -1124,7 +1124,7 @@ describe( 'TableNavigation', () => {
 					} );
 					} );
 
 
 					it( 'should expand the selection to the cell on the right when the direction is "right"', () => {
 					it( 'should expand the selection to the cell on the right when the direction is "right"', () => {
-						tableNavigation._navigateFromCellInDirection( tableCell, 'right', true );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'right', true );
 
 
 						expect( tableSelection.getAnchorCell() ).to.equal( tableCell );
 						expect( tableSelection.getAnchorCell() ).to.equal( tableCell );
 						expect( tableSelection.getFocusCell() ).to.equal( modelRoot.getNodeByPath( [ 0, 1, 1 ] ) );
 						expect( tableSelection.getFocusCell() ).to.equal( modelRoot.getNodeByPath( [ 0, 1, 1 ] ) );
@@ -1132,7 +1132,7 @@ describe( 'TableNavigation', () => {
 					} );
 					} );
 
 
 					it( 'should expand the selection to the cell above when the direction is "up"', () => {
 					it( 'should expand the selection to the cell above when the direction is "up"', () => {
-						tableNavigation._navigateFromCellInDirection( tableCell, 'up', true );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'up', true );
 
 
 						expect( tableSelection.getAnchorCell() ).to.equal( tableCell );
 						expect( tableSelection.getAnchorCell() ).to.equal( tableCell );
 						expect( tableSelection.getFocusCell() ).to.equal( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) );
 						expect( tableSelection.getFocusCell() ).to.equal( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) );
@@ -1140,7 +1140,7 @@ describe( 'TableNavigation', () => {
 					} );
 					} );
 
 
 					it( 'should expand the selection to the cell below when the direction is "down"', () => {
 					it( 'should expand the selection to the cell below when the direction is "down"', () => {
-						tableNavigation._navigateFromCellInDirection( tableCell, 'down', true );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'down', true );
 
 
 						expect( tableSelection.getAnchorCell() ).to.equal( tableCell );
 						expect( tableSelection.getAnchorCell() ).to.equal( tableCell );
 						expect( tableSelection.getFocusCell() ).to.equal( modelRoot.getNodeByPath( [ 0, 2, 0 ] ) );
 						expect( tableSelection.getFocusCell() ).to.equal( modelRoot.getNodeByPath( [ 0, 2, 0 ] ) );
@@ -1148,7 +1148,7 @@ describe( 'TableNavigation', () => {
 					} );
 					} );
 
 
 					it( 'should expand the selection to the cell above when the direction is "left"', () => {
 					it( 'should expand the selection to the cell above when the direction is "left"', () => {
-						tableNavigation._navigateFromCellInDirection( tableCell, 'left', true );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'left', true );
 
 
 						expect( tableSelection.getAnchorCell() ).to.equal( tableCell );
 						expect( tableSelection.getAnchorCell() ).to.equal( tableCell );
 						expect( tableSelection.getFocusCell() ).to.equal( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) );
 						expect( tableSelection.getFocusCell() ).to.equal( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) );
@@ -1165,7 +1165,7 @@ describe( 'TableNavigation', () => {
 					} );
 					} );
 
 
 					it( 'should expand the selection to the cell on the left when the direction is "left"', () => {
 					it( 'should expand the selection to the cell on the left when the direction is "left"', () => {
-						tableNavigation._navigateFromCellInDirection( tableCell, 'left', true );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'left', true );
 
 
 						expect( tableSelection.getAnchorCell() ).to.equal( tableCell );
 						expect( tableSelection.getAnchorCell() ).to.equal( tableCell );
 						expect( tableSelection.getFocusCell() ).to.equal( modelRoot.getNodeByPath( [ 0, 1, 1 ] ) );
 						expect( tableSelection.getFocusCell() ).to.equal( modelRoot.getNodeByPath( [ 0, 1, 1 ] ) );
@@ -1173,7 +1173,7 @@ describe( 'TableNavigation', () => {
 					} );
 					} );
 
 
 					it( 'should expand the selection to the cell above when the direction is "up"', () => {
 					it( 'should expand the selection to the cell above when the direction is "up"', () => {
-						tableNavigation._navigateFromCellInDirection( tableCell, 'up', true );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'up', true );
 
 
 						expect( tableSelection.getAnchorCell() ).to.equal( tableCell );
 						expect( tableSelection.getAnchorCell() ).to.equal( tableCell );
 						expect( tableSelection.getFocusCell() ).to.equal( modelRoot.getNodeByPath( [ 0, 0, 2 ] ) );
 						expect( tableSelection.getFocusCell() ).to.equal( modelRoot.getNodeByPath( [ 0, 0, 2 ] ) );
@@ -1181,7 +1181,7 @@ describe( 'TableNavigation', () => {
 					} );
 					} );
 
 
 					it( 'should expand the selection to the cell below when the direction is "down"', () => {
 					it( 'should expand the selection to the cell below when the direction is "down"', () => {
-						tableNavigation._navigateFromCellInDirection( tableCell, 'down', true );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'down', true );
 
 
 						expect( tableSelection.getAnchorCell() ).to.equal( tableCell );
 						expect( tableSelection.getAnchorCell() ).to.equal( tableCell );
 						expect( tableSelection.getFocusCell() ).to.equal( modelRoot.getNodeByPath( [ 0, 2, 2 ] ) );
 						expect( tableSelection.getFocusCell() ).to.equal( modelRoot.getNodeByPath( [ 0, 2, 2 ] ) );
@@ -1189,7 +1189,7 @@ describe( 'TableNavigation', () => {
 					} );
 					} );
 
 
 					it( 'should expand the selection to the cell below when the direction is "right"', () => {
 					it( 'should expand the selection to the cell below when the direction is "right"', () => {
-						tableNavigation._navigateFromCellInDirection( tableCell, 'right', true );
+						tableKeyboard._navigateFromCellInDirection( tableCell, 'right', true );
 
 
 						expect( tableSelection.getAnchorCell() ).to.equal( tableCell );
 						expect( tableSelection.getAnchorCell() ).to.equal( tableCell );
 						expect( tableSelection.getFocusCell() ).to.equal( modelRoot.getNodeByPath( [ 0, 2, 2 ] ) );
 						expect( tableSelection.getFocusCell() ).to.equal( modelRoot.getNodeByPath( [ 0, 2, 2 ] ) );
@@ -2979,7 +2979,7 @@ describe( 'TableNavigation', () => {
 			beforeEach( () => {
 			beforeEach( () => {
 				return VirtualTestEditor
 				return VirtualTestEditor
 					.create( {
 					.create( {
-						plugins: [ TableEditing, TableNavigation, TableSelection, Paragraph, ImageEditing, MediaEmbedEditing ],
+						plugins: [ TableEditing, TableKeyboard, TableSelection, Paragraph, ImageEditing, MediaEmbedEditing ],
 						language: 'ar'
 						language: 'ar'
 					} )
 					} )
 					.then( newEditor => {
 					.then( newEditor => {

+ 569 - 0
packages/ckeditor5-table/tests/tablemouse.js

@@ -0,0 +1,569 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals document, console */
+
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import TableEditing from '../src/tableediting';
+import TableSelection from '../src/tableselection';
+import TableMouse from '../src/tablemouse';
+import { assertSelectedCells, modelTable } from './_utils/utils';
+import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata';
+import Typing from '@ckeditor/ckeditor5-typing/src/typing';
+
+describe( 'TableMouse', () => {
+	let editorElement, editor, model, tableMouse, modelRoot, view, viewDocument;
+
+	beforeEach( () => {
+		editorElement = document.createElement( 'div' );
+		document.body.appendChild( editorElement );
+	} );
+
+	afterEach( async () => {
+		editorElement.remove();
+		await editor.destroy();
+	} );
+
+	describe( 'plugin', () => {
+		beforeEach( async () => {
+			editor = await createEditor();
+		} );
+
+		it( 'should have pluginName', () => {
+			expect( TableMouse.pluginName ).to.equal( 'TableMouse' );
+		} );
+	} );
+
+	describe( 'selection by Shift+click', () => {
+		beforeEach( async () => {
+			editor = await createEditor();
+			model = editor.model;
+			modelRoot = model.document.getRoot();
+			view = editor.editing.view;
+			viewDocument = view.document;
+			tableMouse = editor.plugins.get( TableMouse );
+
+			setModelData( model, modelTable( [
+				[ '11[]', '12', '13' ],
+				[ '21', '22', '23' ],
+				[ '31', '32', '33' ]
+			] ) );
+		} );
+
+		it( 'should do nothing if the plugin is disabled', () => {
+			tableMouse.isEnabled = false;
+
+			viewDocument.fire( 'mousedown', new DomEventData( view, {} ) );
+
+			assertSelectedCells( model, [
+				[ 0, 0, 0 ],
+				[ 0, 0, 0 ],
+				[ 0, 0, 0 ]
+			] );
+		} );
+
+		it( 'should do nothing if the TableSelection plugin is disabled', () => {
+			editor.plugins.get( 'TableSelection' ).isEnabled = false;
+
+			viewDocument.fire( 'mousedown', new DomEventData( view, {} ) );
+
+			assertSelectedCells( model, [
+				[ 0, 0, 0 ],
+				[ 0, 0, 0 ],
+				[ 0, 0, 0 ]
+			] );
+		} );
+
+		it( 'should abort if Shift key was not pressed', () => {
+			viewDocument.fire( 'mousedown', new DomEventData( view, {
+				shiftKey: false,
+				target: view.domConverter.mapViewToDom(
+					// figure > table > tbody > tr > td
+					viewDocument.getRoot().getChild( 0 ).getChild( 1 ).getChild( 0 ).getChild( 0 ).getChild( 2 )
+				)
+			} ) );
+
+			assertSelectedCells( model, [
+				[ 0, 0, 0 ],
+				[ 0, 0, 0 ],
+				[ 0, 0, 0 ]
+			] );
+		} );
+
+		it( 'should abort if Shift+clicked an element outside a table', () => {
+			const preventDefault = sinon.spy();
+
+			model.change( writer => {
+				const paragraph = writer.createElement( 'paragraph' );
+				const text = writer.createText( 'foo' );
+
+				writer.insert( text, paragraph );
+				writer.insert( paragraph, model.document.getRoot(), 'end' );
+				writer.setSelection( paragraph, 'end' );
+			} );
+
+			viewDocument.fire( 'mousedown', new DomEventData( view, {
+				shiftKey: true,
+				target: view.domConverter.mapViewToDom(
+					viewDocument.getRoot().getChild( 1 )
+				),
+				preventDefault
+			} ) );
+
+			assertSelectedCells( model, [
+				[ 0, 0, 0 ],
+				[ 0, 0, 0 ],
+				[ 0, 0, 0 ]
+			] );
+
+			expect( preventDefault.called ).to.equal( false );
+		} );
+
+		it( 'should abort if clicked a cell that belongs to another table', () => {
+			const preventDefault = sinon.spy();
+
+			setModelData( model, [
+				modelTable( [
+					[ '1.11[]', '1.12' ],
+					[ '1.21', '1.22' ]
+				] ),
+				modelTable( [
+					[ '2.11', '2.12' ],
+					[ '2.21', '2.22' ]
+				] )
+			].join( '' ) );
+
+			const domEventDataMock = new DomEventData( view, {
+				shiftKey: true,
+				target: view.domConverter.mapViewToDom(
+					// The second table: figure > table > tbody > tr > td
+					viewDocument.getRoot().getChild( 1 ).getChild( 1 ).getChild( 0 ).getChild( 1 ).getChild( 1 )
+				),
+				preventDefault
+			} );
+
+			viewDocument.fire( 'mousedown', domEventDataMock );
+
+			assertSelectedCells( model, [
+				[ 0, 0 ],
+				[ 0, 0 ]
+			] );
+
+			expect( preventDefault.called ).to.equal( false );
+		} );
+
+		it( 'should select all cells in first row', () => {
+			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
+			} );
+
+			viewDocument.fire( 'mousedown', domEventDataMock );
+
+			assertSelectedCells( model, [
+				[ 1, 1, 1 ],
+				[ 0, 0, 0 ],
+				[ 0, 0, 0 ]
+			] );
+
+			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
+			} );
+
+			editor.plugins.get( '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();
+			const selectionChangeCallback = sinon.spy();
+
+			// Adding a new callback to check whether it will be executed (whether `evt.stop()` is being called).
+			viewDocument.on( 'selectionChange', selectionChangeCallback );
+
+			// Shift+click a cell to create a selection. Should disable listening to `selectionChange`.
+			viewDocument.fire( 'mousedown', 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
+			} ) );
+
+			// Due to browsers "fixing" the selection (e.g. moving it to text nodes), after we set a selection
+			// the browser fill fire native selectionchange, which triggers our selectionChange. We need to ignore it.
+			// See a broader explanation in tablemouse.js.
+			viewDocument.fire( 'selectionChange' );
+
+			// The callback shouldn't be executed because
+			// `selectionChange` event should be canceled.
+			expect( selectionChangeCallback.called ).to.equal( false );
+			expect( consoleLog.called ).to.equal( true );
+			expect( consoleLog.firstCall.args[ 0 ] ).to.equal( 'Blocked selectionChange to avoid breaking table cells selection.' );
+
+			// Enables listening to `selectionChange` event.
+			viewDocument.fire( 'mouseup' );
+
+			viewDocument.fire( 'selectionChange', {
+				newSelection: view.document.selection
+			} );
+
+			expect( selectionChangeCallback.called ).to.equal( true );
+
+			consoleLog.restore();
+		} );
+	} );
+
+	describe( 'selection by mouse drag', () => {
+		let preventDefault;
+
+		beforeEach( async () => {
+			editor = await createEditor();
+			model = editor.model;
+			modelRoot = model.document.getRoot();
+			view = editor.editing.view;
+			viewDocument = view.document;
+			tableMouse = editor.plugins.get( TableMouse );
+
+			setModelData( model, modelTable( [
+				[ '11[]', '12', '13' ],
+				[ '21', '22', '23' ],
+				[ '31', '32', '33' ]
+			] ) );
+
+			preventDefault = sinon.spy();
+		} );
+
+		it( 'should do nothing if the plugin is disabled', () => {
+			tableMouse.isEnabled = false;
+
+			const domEventDataMock = new DomEventData( view, {} );
+
+			viewDocument.fire( 'mousedown', domEventDataMock );
+
+			assertSelectedCells( model, [
+				[ 0, 0, 0 ],
+				[ 0, 0, 0 ],
+				[ 0, 0, 0 ]
+			] );
+		} );
+
+		it( 'should do nothing if the TableSelection plugin is disabled', () => {
+			editor.plugins.get( 'TableSelection' ).isEnabled = false;
+
+			const domEventDataMock = new DomEventData( view, {} );
+
+			viewDocument.fire( 'mousedown', domEventDataMock );
+
+			assertSelectedCells( model, [
+				[ 0, 0, 0 ],
+				[ 0, 0, 0 ],
+				[ 0, 0, 0 ]
+			] );
+		} );
+
+		it( 'should abort if Ctrl is pressed', () => {
+			const domEventDataMock = new DomEventData( view, {
+				ctrlKey: true
+			} );
+
+			viewDocument.fire( 'mousedown', domEventDataMock );
+
+			assertSelectedCells( model, [
+				[ 0, 0, 0 ],
+				[ 0, 0, 0 ],
+				[ 0, 0, 0 ]
+			] );
+		} );
+
+		it( 'should abort if Alt is pressed', () => {
+			const domEventDataMock = new DomEventData( view, {
+				altKey: true
+			} );
+
+			viewDocument.fire( 'mousedown', domEventDataMock );
+
+			assertSelectedCells( model, [
+				[ 0, 0, 0 ],
+				[ 0, 0, 0 ],
+				[ 0, 0, 0 ]
+			] );
+		} );
+
+		it( 'should do nothing if any of mouse buttons was not clicked', () => {
+			viewDocument.fire( 'mousemove', new DomEventData( view, {
+				buttons: 0
+			} ) );
+
+			assertSelectedCells( model, [
+				[ 0, 0, 0 ],
+				[ 0, 0, 0 ],
+				[ 0, 0, 0 ]
+			] );
+		} );
+
+		it( 'should do nothing if started dragging outside of table', () => {
+			model.change( writer => {
+				const paragraph = writer.createElement( 'paragraph' );
+				const text = writer.createText( 'foo' );
+
+				writer.insert( text, paragraph );
+				writer.insert( paragraph, model.document.getRoot(), 'end' );
+				writer.setSelection( paragraph, 'end' );
+			} );
+
+			viewDocument.fire( 'mousedown', new DomEventData( view, {
+				target: view.domConverter.mapViewToDom(
+					viewDocument.getRoot().getChild( 1 )
+				)
+			} ) );
+
+			viewDocument.fire( 'mousemove', new DomEventData( view, {
+				buttons: 1
+			} ) );
+
+			assertSelectedCells( model, [
+				[ 0, 0, 0 ],
+				[ 0, 0, 0 ],
+				[ 0, 0, 0 ]
+			] );
+		} );
+
+		it( 'should do nothing if ended dragging outside of table', () => {
+			model.change( writer => {
+				const paragraph = writer.createElement( 'paragraph' );
+				const text = writer.createText( 'foo' );
+
+				writer.insert( text, paragraph );
+				writer.insert( paragraph, model.document.getRoot(), 'end' );
+				writer.setSelection( paragraph, 'end' );
+			} );
+
+			viewDocument.fire( 'mousedown', new DomEventData( view, {
+				target: view.domConverter.mapViewToDom(
+					viewDocument.getRoot().getChild( 0 ).getChild( 1 ).getChild( 0 ).getChild( 0 ).getChild( 0 )
+				)
+			} ) );
+
+			viewDocument.fire( 'mousemove', new DomEventData( view, {
+				target: view.domConverter.mapViewToDom(
+					viewDocument.getRoot().getChild( 1 )
+				),
+				buttons: 1
+			} ) );
+
+			assertSelectedCells( model, [
+				[ 0, 0, 0 ],
+				[ 0, 0, 0 ],
+				[ 0, 0, 0 ]
+			] );
+		} );
+
+		it( 'should do nothing if ended dragging inside another table', () => {
+			setModelData( model, [
+				modelTable( [
+					[ '1.11[]', '1.12' ],
+					[ '1.21', '1.22' ]
+				] ),
+				modelTable( [
+					[ '2.11', '2.12' ],
+					[ '2.21', '2.22' ]
+				] )
+			].join( '' ) );
+
+			viewDocument.fire( 'mousedown', new DomEventData( view, {
+				target: view.domConverter.mapViewToDom(
+					viewDocument.getRoot().getChild( 0 ).getChild( 1 ).getChild( 0 ).getChild( 0 ).getChild( 0 )
+				)
+			} ) );
+
+			viewDocument.fire( 'mousemove', new DomEventData( view, {
+				target: view.domConverter.mapViewToDom(
+					viewDocument.getRoot().getChild( 1 ).getChild( 1 ).getChild( 0 ).getChild( 1 ).getChild( 1 )
+				),
+				buttons: 1
+			} ) );
+
+			assertSelectedCells( model, [
+				[ 0, 0 ],
+				[ 0, 0 ]
+			] );
+		} );
+
+		it( 'should do nothing if ended in the same cell', () => {
+			viewDocument.fire( 'mousedown', new DomEventData( view, {
+				target: view.domConverter.mapViewToDom(
+					viewDocument.getRoot().getChild( 0 ).getChild( 1 ).getChild( 0 ).getChild( 0 ).getChild( 0 )
+				)
+			} ) );
+
+			viewDocument.fire( 'mousemove', new DomEventData( view, {
+				target: view.domConverter.mapViewToDom(
+					viewDocument.getRoot().getChild( 0 ).getChild( 1 ).getChild( 0 ).getChild( 0 ).getChild( 0 )
+				),
+				buttons: 1
+			} ) );
+
+			assertSelectedCells( model, [
+				[ 0, 0, 0 ],
+				[ 0, 0, 0 ],
+				[ 0, 0, 0 ]
+			] );
+		} );
+
+		it( 'should select started and ended dragging in the same cell but went over its border', () => {
+			viewDocument.fire( 'mousedown', new DomEventData( view, {
+				target: view.domConverter.mapViewToDom(
+					viewDocument.getRoot().getChild( 0 ).getChild( 1 ).getChild( 0 ).getChild( 0 ).getChild( 0 )
+				)
+			} ) );
+
+			// Select the next one.
+			viewDocument.fire( 'mousemove', new DomEventData( view, {
+				target: view.domConverter.mapViewToDom(
+					viewDocument.getRoot().getChild( 0 ).getChild( 1 ).getChild( 0 ).getChild( 0 ).getChild( 1 )
+				),
+				buttons: 1,
+				preventDefault: sinon.spy()
+			} ) );
+
+			// And back to the "started" cell.
+			viewDocument.fire( 'mousemove', new DomEventData( view, {
+				target: view.domConverter.mapViewToDom(
+					viewDocument.getRoot().getChild( 0 ).getChild( 1 ).getChild( 0 ).getChild( 0 ).getChild( 0 )
+				),
+				buttons: 1,
+				preventDefault: sinon.spy()
+			} ) );
+
+			viewDocument.fire( 'mouseup' );
+
+			assertSelectedCells( model, [
+				[ 1, 0, 0 ],
+				[ 0, 0, 0 ],
+				[ 0, 0, 0 ]
+			] );
+		} );
+
+		it( 'should select all cells in first row', () => {
+			viewDocument.fire( 'mousedown', new DomEventData( view, {
+				target: view.domConverter.mapViewToDom(
+					// figure > table > tbody > tr > td
+					viewDocument.getRoot().getChild( 0 ).getChild( 1 ).getChild( 0 ).getChild( 0 ).getChild( 0 )
+				)
+			} ) );
+
+			viewDocument.fire( 'mousemove', new DomEventData( view, {
+				target: view.domConverter.mapViewToDom(
+					// figure > table > tbody > tr > td
+					viewDocument.getRoot().getChild( 0 ).getChild( 1 ).getChild( 0 ).getChild( 0 ).getChild( 2 )
+				),
+				buttons: 1,
+				preventDefault
+			} ) );
+
+			viewDocument.fire( 'mouseup' );
+
+			assertSelectedCells( model, [
+				[ 1, 1, 1 ],
+				[ 0, 0, 0 ],
+				[ 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();
+			const selectionChangeCallback = sinon.spy();
+
+			// Adding a new callback to check whether it will be executed (whether `evt.stop()` is being called).
+			viewDocument.on( 'selectionChange', selectionChangeCallback );
+
+			// Click on a cell.
+			viewDocument.fire( 'mousedown', new DomEventData( view, {
+				target: view.domConverter.mapViewToDom(
+					// figure > table > tbody > tr > td
+					viewDocument.getRoot().getChild( 0 ).getChild( 1 ).getChild( 0 ).getChild( 0 ).getChild( 1 )
+				)
+			} ) );
+
+			// Then move the mouse to another cell. Disables listening to `selectionChange`.
+			viewDocument.fire( 'mousemove', new DomEventData( view, {
+				buttons: 1,
+				target: view.domConverter.mapViewToDom(
+					// figure > table > tbody > tr > td
+					viewDocument.getRoot().getChild( 0 ).getChild( 1 ).getChild( 0 ).getChild( 0 ).getChild( 2 )
+				),
+				preventDefault
+			} ) );
+
+			// See explanation why do we fire it in the similar test for Shift+click.
+			viewDocument.fire( 'selectionChange' );
+
+			// `selectionChange` event should be canceled.
+			expect( selectionChangeCallback.called ).to.equal( false );
+			expect( consoleLog.called ).to.equal( true );
+			expect( consoleLog.firstCall.args[ 0 ] ).to.equal( 'Blocked selectionChange to avoid breaking table cells selection.' );
+
+			// Enables listening to `selectionChange` event.
+			viewDocument.fire( 'mouseup' );
+
+			viewDocument.fire( 'selectionChange', {
+				newSelection: view.document.selection
+			} );
+
+			expect( selectionChangeCallback.called ).to.equal( true );
+
+			consoleLog.restore();
+		} );
+	} );
+
+	function createEditor() {
+		return ClassicTestEditor.create( editorElement, {
+			plugins: [ TableEditing, TableSelection, TableMouse, Paragraph, Typing ]
+		} );
+	}
+} );

+ 1 - 1
packages/ckeditor5-table/tests/tableproperties/tablepropertiesui.js

@@ -20,7 +20,7 @@ import Table from '../../src/table';
 import TablePropertiesEditing from '../../src/tableproperties/tablepropertiesediting';
 import TablePropertiesEditing from '../../src/tableproperties/tablepropertiesediting';
 import TablePropertiesUI from '../../src/tableproperties/tablepropertiesui';
 import TablePropertiesUI from '../../src/tableproperties/tablepropertiesui';
 import TablePropertiesUIView from '../../src/tableproperties/ui/tablepropertiesview';
 import TablePropertiesUIView from '../../src/tableproperties/ui/tablepropertiesview';
-import { defaultColors } from '../../src/ui/utils';
+import { defaultColors } from '../../src/utils/ui/table-properties';
 
 
 describe( 'table properties', () => {
 describe( 'table properties', () => {
 	describe( 'TablePropertiesUI', () => {
 	describe( 'TablePropertiesUI', () => {

+ 8 - 3
packages/ckeditor5-table/tests/tableselection-integration.js

@@ -229,7 +229,10 @@ describe( 'TableSelection - integration', () => {
 
 
 		// See https://github.com/ckeditor/ckeditor5/issues/6634.
 		// See https://github.com/ckeditor/ckeditor5/issues/6634.
 		it( 'works with merge cells command', () => {
 		it( 'works with merge cells command', () => {
-			setModelData( editor.model, modelTable( [ [ '00', '01' ] ] ) );
+			setModelData( editor.model, modelTable( [
+				[ '00', '01' ],
+				[ '10', '11' ]
+			] ) );
 
 
 			tableSelection.setCellSelection(
 			tableSelection.setCellSelection(
 				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
 				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
@@ -239,13 +242,15 @@ describe( 'TableSelection - integration', () => {
 			editor.execute( 'mergeTableCells' );
 			editor.execute( 'mergeTableCells' );
 
 
 			assertEqualMarkup( getModelData( model ), modelTable( [
 			assertEqualMarkup( getModelData( model ), modelTable( [
-				[ { colspan: 2, contents: '<paragraph>[00</paragraph><paragraph>01]</paragraph>' } ]
+				[ { colspan: 2, contents: '<paragraph>[00</paragraph><paragraph>01]</paragraph>' } ],
+				[ '10', '11' ]
 			] ) );
 			] ) );
 
 
 			editor.execute( 'undo' );
 			editor.execute( 'undo' );
 
 
 			assertEqualMarkup( getModelData( model ), modelTable( [
 			assertEqualMarkup( getModelData( model ), modelTable( [
-				[ '[]00', '01' ]
+				[ '[]00', '01' ],
+				[ '10', '11' ]
 			] ) );
 			] ) );
 		} );
 		} );
 	} );
 	} );

+ 7 - 506
packages/ckeditor5-table/tests/tableselection.js

@@ -3,7 +3,7 @@
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  */
  */
 
 
-/* globals document, console */
+/* globals document */
 
 
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
@@ -17,12 +17,11 @@ import TableEditing from '../src/tableediting';
 import TableSelection from '../src/tableselection';
 import TableSelection from '../src/tableselection';
 import { assertSelectedCells, modelTable } from './_utils/utils';
 import { assertSelectedCells, modelTable } from './_utils/utils';
 import DocumentFragment from '@ckeditor/ckeditor5-engine/src/model/documentfragment';
 import DocumentFragment from '@ckeditor/ckeditor5-engine/src/model/documentfragment';
-import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata';
 import Typing from '@ckeditor/ckeditor5-typing/src/typing';
 import Typing from '@ckeditor/ckeditor5-typing/src/typing';
 import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
 
-describe( 'table selection', () => {
-	let editorElement, editor, model, tableSelection, modelRoot, view, viewDocument;
+describe( 'TableSelection', () => {
+	let editorElement, editor, model, tableSelection, modelRoot;
 
 
 	beforeEach( () => {
 	beforeEach( () => {
 		editorElement = document.createElement( 'div' );
 		editorElement = document.createElement( 'div' );
@@ -48,6 +47,10 @@ describe( 'table selection', () => {
 			] ) );
 			] ) );
 		} );
 		} );
 
 
+		it( 'should have pluginName', () => {
+			expect( TableSelection.pluginName ).to.equal( 'TableSelection' );
+		} );
+
 		describe( 'plugin disabling support', () => {
 		describe( 'plugin disabling support', () => {
 			it( 'should collapse multi-cell selection when the plugin gets disabled', () => {
 			it( 'should collapse multi-cell selection when the plugin gets disabled', () => {
 				const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
 				const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
@@ -98,509 +101,11 @@ describe( 'table selection', () => {
 		} );
 		} );
 	} );
 	} );
 
 
-	describe( 'selection by Shift+click', () => {
-		beforeEach( async () => {
-			editor = await createEditor();
-			model = editor.model;
-			modelRoot = model.document.getRoot();
-			view = editor.editing.view;
-			viewDocument = view.document;
-			tableSelection = editor.plugins.get( TableSelection );
-
-			setModelData( model, modelTable( [
-				[ '11[]', '12', '13' ],
-				[ '21', '22', '23' ],
-				[ '31', '32', '33' ]
-			] ) );
-		} );
-
-		it( 'should do nothing if the plugin is disabled', () => {
-			tableSelection.isEnabled = false;
-
-			viewDocument.fire( 'mousedown', new DomEventData( view, {} ) );
-
-			assertSelectedCells( model, [
-				[ 0, 0, 0 ],
-				[ 0, 0, 0 ],
-				[ 0, 0, 0 ]
-			] );
-		} );
-
-		it( 'should abort if Shift key was not pressed', () => {
-			viewDocument.fire( 'mousedown', new DomEventData( view, {
-				shiftKey: false,
-				target: view.domConverter.mapViewToDom(
-					// figure > table > tbody > tr > td
-					viewDocument.getRoot().getChild( 0 ).getChild( 1 ).getChild( 0 ).getChild( 0 ).getChild( 2 )
-				)
-			} ) );
-
-			assertSelectedCells( model, [
-				[ 0, 0, 0 ],
-				[ 0, 0, 0 ],
-				[ 0, 0, 0 ]
-			] );
-		} );
-
-		it( 'should abort if Shift+clicked an element outside a table', () => {
-			const preventDefault = sinon.spy();
-
-			model.change( writer => {
-				const paragraph = writer.createElement( 'paragraph' );
-				const text = writer.createText( 'foo' );
-
-				writer.insert( text, paragraph );
-				writer.insert( paragraph, model.document.getRoot(), 'end' );
-				writer.setSelection( paragraph, 'end' );
-			} );
-
-			viewDocument.fire( 'mousedown', new DomEventData( view, {
-				shiftKey: true,
-				target: view.domConverter.mapViewToDom(
-					viewDocument.getRoot().getChild( 1 )
-				),
-				preventDefault
-			} ) );
-
-			assertSelectedCells( model, [
-				[ 0, 0, 0 ],
-				[ 0, 0, 0 ],
-				[ 0, 0, 0 ]
-			] );
-
-			expect( preventDefault.called ).to.equal( false );
-		} );
-
-		it( 'should abort if clicked a cell that belongs to another table', () => {
-			const preventDefault = sinon.spy();
-
-			setModelData( model, [
-				modelTable( [
-					[ '1.11[]', '1.12' ],
-					[ '1.21', '1.22' ]
-				] ),
-				modelTable( [
-					[ '2.11', '2.12' ],
-					[ '2.21', '2.22' ]
-				] )
-			].join( '' ) );
-
-			const domEventDataMock = new DomEventData( view, {
-				shiftKey: true,
-				target: view.domConverter.mapViewToDom(
-					// The second table: figure > table > tbody > tr > td
-					viewDocument.getRoot().getChild( 1 ).getChild( 1 ).getChild( 0 ).getChild( 1 ).getChild( 1 )
-				),
-				preventDefault
-			} );
-
-			viewDocument.fire( 'mousedown', domEventDataMock );
-
-			assertSelectedCells( model, [
-				[ 0, 0 ],
-				[ 0, 0 ]
-			] );
-
-			expect( preventDefault.called ).to.equal( false );
-		} );
-
-		it( 'should select all cells in first row', () => {
-			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
-			} );
-
-			viewDocument.fire( 'mousedown', domEventDataMock );
-
-			assertSelectedCells( model, [
-				[ 1, 1, 1 ],
-				[ 0, 0, 0 ],
-				[ 0, 0, 0 ]
-			] );
-
-			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();
-			const selectionChangeCallback = sinon.spy();
-
-			// Adding a new callback to check whether it will be executed (whether `evt.stop()` is being called).
-			viewDocument.on( 'selectionChange', selectionChangeCallback );
-
-			// Shift+click a cell to create a selection. Should disable listening to `selectionChange`.
-			viewDocument.fire( 'mousedown', 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
-			} ) );
-
-			// Due to browsers "fixing" the selection (e.g. moving it to text nodes), after we set a selection
-			// the browser fill fire native selectionchange, which triggers our selectionChange. We need to ignore it.
-			// See a broader explanation in tableselection.js.
-			viewDocument.fire( 'selectionChange' );
-
-			// The callback shouldn't be executed because
-			// `selectionChange` event should be canceled.
-			expect( selectionChangeCallback.called ).to.equal( false );
-			expect( consoleLog.called ).to.equal( true );
-			expect( consoleLog.firstCall.args[ 0 ] ).to.equal( 'Blocked selectionChange to avoid breaking table cells selection.' );
-
-			// Enables listening to `selectionChange` event.
-			viewDocument.fire( 'mouseup' );
-
-			viewDocument.fire( 'selectionChange', {
-				newSelection: view.document.selection
-			} );
-
-			expect( selectionChangeCallback.called ).to.equal( true );
-
-			consoleLog.restore();
-		} );
-	} );
-
-	describe( 'selection by mouse drag', () => {
-		let preventDefault;
-
-		beforeEach( async () => {
-			editor = await createEditor();
-			model = editor.model;
-			modelRoot = model.document.getRoot();
-			view = editor.editing.view;
-			viewDocument = view.document;
-			tableSelection = editor.plugins.get( TableSelection );
-
-			setModelData( model, modelTable( [
-				[ '11[]', '12', '13' ],
-				[ '21', '22', '23' ],
-				[ '31', '32', '33' ]
-			] ) );
-
-			preventDefault = sinon.spy();
-		} );
-
-		it( 'should do nothing if the plugin is disabled', () => {
-			tableSelection.isEnabled = false;
-
-			const domEventDataMock = new DomEventData( view, {} );
-
-			viewDocument.fire( 'mousedown', domEventDataMock );
-
-			assertSelectedCells( model, [
-				[ 0, 0, 0 ],
-				[ 0, 0, 0 ],
-				[ 0, 0, 0 ]
-			] );
-		} );
-
-		it( 'should abort if Ctrl is pressed', () => {
-			const domEventDataMock = new DomEventData( view, {
-				ctrlKey: true
-			} );
-
-			viewDocument.fire( 'mousedown', domEventDataMock );
-
-			assertSelectedCells( model, [
-				[ 0, 0, 0 ],
-				[ 0, 0, 0 ],
-				[ 0, 0, 0 ]
-			] );
-		} );
-
-		it( 'should abort if Alt is pressed', () => {
-			const domEventDataMock = new DomEventData( view, {
-				altKey: true
-			} );
-
-			viewDocument.fire( 'mousedown', domEventDataMock );
-
-			assertSelectedCells( model, [
-				[ 0, 0, 0 ],
-				[ 0, 0, 0 ],
-				[ 0, 0, 0 ]
-			] );
-		} );
-
-		it( 'should do nothing if any of mouse buttons was not clicked', () => {
-			viewDocument.fire( 'mousemove', new DomEventData( view, {
-				buttons: 0
-			} ) );
-
-			assertSelectedCells( model, [
-				[ 0, 0, 0 ],
-				[ 0, 0, 0 ],
-				[ 0, 0, 0 ]
-			] );
-		} );
-
-		it( 'should do nothing if started dragging outside of table', () => {
-			model.change( writer => {
-				const paragraph = writer.createElement( 'paragraph' );
-				const text = writer.createText( 'foo' );
-
-				writer.insert( text, paragraph );
-				writer.insert( paragraph, model.document.getRoot(), 'end' );
-				writer.setSelection( paragraph, 'end' );
-			} );
-
-			viewDocument.fire( 'mousedown', new DomEventData( view, {
-				target: view.domConverter.mapViewToDom(
-					viewDocument.getRoot().getChild( 1 )
-				)
-			} ) );
-
-			viewDocument.fire( 'mousemove', new DomEventData( view, {
-				buttons: 1
-			} ) );
-
-			assertSelectedCells( model, [
-				[ 0, 0, 0 ],
-				[ 0, 0, 0 ],
-				[ 0, 0, 0 ]
-			] );
-		} );
-
-		it( 'should do nothing if ended dragging outside of table', () => {
-			model.change( writer => {
-				const paragraph = writer.createElement( 'paragraph' );
-				const text = writer.createText( 'foo' );
-
-				writer.insert( text, paragraph );
-				writer.insert( paragraph, model.document.getRoot(), 'end' );
-				writer.setSelection( paragraph, 'end' );
-			} );
-
-			viewDocument.fire( 'mousedown', new DomEventData( view, {
-				target: view.domConverter.mapViewToDom(
-					viewDocument.getRoot().getChild( 0 ).getChild( 1 ).getChild( 0 ).getChild( 0 ).getChild( 0 )
-				)
-			} ) );
-
-			viewDocument.fire( 'mousemove', new DomEventData( view, {
-				target: view.domConverter.mapViewToDom(
-					viewDocument.getRoot().getChild( 1 )
-				),
-				buttons: 1
-			} ) );
-
-			assertSelectedCells( model, [
-				[ 0, 0, 0 ],
-				[ 0, 0, 0 ],
-				[ 0, 0, 0 ]
-			] );
-		} );
-
-		it( 'should do nothing if ended dragging inside another table', () => {
-			setModelData( model, [
-				modelTable( [
-					[ '1.11[]', '1.12' ],
-					[ '1.21', '1.22' ]
-				] ),
-				modelTable( [
-					[ '2.11', '2.12' ],
-					[ '2.21', '2.22' ]
-				] )
-			].join( '' ) );
-
-			viewDocument.fire( 'mousedown', new DomEventData( view, {
-				target: view.domConverter.mapViewToDom(
-					viewDocument.getRoot().getChild( 0 ).getChild( 1 ).getChild( 0 ).getChild( 0 ).getChild( 0 )
-				)
-			} ) );
-
-			viewDocument.fire( 'mousemove', new DomEventData( view, {
-				target: view.domConverter.mapViewToDom(
-					viewDocument.getRoot().getChild( 1 ).getChild( 1 ).getChild( 0 ).getChild( 1 ).getChild( 1 )
-				),
-				buttons: 1
-			} ) );
-
-			assertSelectedCells( model, [
-				[ 0, 0 ],
-				[ 0, 0 ]
-			] );
-		} );
-
-		it( 'should do nothing if ended in the same cell', () => {
-			viewDocument.fire( 'mousedown', new DomEventData( view, {
-				target: view.domConverter.mapViewToDom(
-					viewDocument.getRoot().getChild( 0 ).getChild( 1 ).getChild( 0 ).getChild( 0 ).getChild( 0 )
-				)
-			} ) );
-
-			viewDocument.fire( 'mousemove', new DomEventData( view, {
-				target: view.domConverter.mapViewToDom(
-					viewDocument.getRoot().getChild( 0 ).getChild( 1 ).getChild( 0 ).getChild( 0 ).getChild( 0 )
-				),
-				buttons: 1
-			} ) );
-
-			assertSelectedCells( model, [
-				[ 0, 0, 0 ],
-				[ 0, 0, 0 ],
-				[ 0, 0, 0 ]
-			] );
-		} );
-
-		it( 'should select started and ended dragging in the same cell but went over its border', () => {
-			viewDocument.fire( 'mousedown', new DomEventData( view, {
-				target: view.domConverter.mapViewToDom(
-					viewDocument.getRoot().getChild( 0 ).getChild( 1 ).getChild( 0 ).getChild( 0 ).getChild( 0 )
-				)
-			} ) );
-
-			// Select the next one.
-			viewDocument.fire( 'mousemove', new DomEventData( view, {
-				target: view.domConverter.mapViewToDom(
-					viewDocument.getRoot().getChild( 0 ).getChild( 1 ).getChild( 0 ).getChild( 0 ).getChild( 1 )
-				),
-				buttons: 1,
-				preventDefault: sinon.spy()
-			} ) );
-
-			// And back to the "started" cell.
-			viewDocument.fire( 'mousemove', new DomEventData( view, {
-				target: view.domConverter.mapViewToDom(
-					viewDocument.getRoot().getChild( 0 ).getChild( 1 ).getChild( 0 ).getChild( 0 ).getChild( 0 )
-				),
-				buttons: 1,
-				preventDefault: sinon.spy()
-			} ) );
-
-			viewDocument.fire( 'mouseup' );
-
-			assertSelectedCells( model, [
-				[ 1, 0, 0 ],
-				[ 0, 0, 0 ],
-				[ 0, 0, 0 ]
-			] );
-		} );
-
-		it( 'should select all cells in first row', () => {
-			viewDocument.fire( 'mousedown', new DomEventData( view, {
-				target: view.domConverter.mapViewToDom(
-					// figure > table > tbody > tr > td
-					viewDocument.getRoot().getChild( 0 ).getChild( 1 ).getChild( 0 ).getChild( 0 ).getChild( 0 )
-				)
-			} ) );
-
-			viewDocument.fire( 'mousemove', new DomEventData( view, {
-				target: view.domConverter.mapViewToDom(
-					// figure > table > tbody > tr > td
-					viewDocument.getRoot().getChild( 0 ).getChild( 1 ).getChild( 0 ).getChild( 0 ).getChild( 2 )
-				),
-				buttons: 1,
-				preventDefault
-			} ) );
-
-			viewDocument.fire( 'mouseup' );
-
-			assertSelectedCells( model, [
-				[ 1, 1, 1 ],
-				[ 0, 0, 0 ],
-				[ 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();
-			const selectionChangeCallback = sinon.spy();
-
-			// Adding a new callback to check whether it will be executed (whether `evt.stop()` is being called).
-			viewDocument.on( 'selectionChange', selectionChangeCallback );
-
-			// Click on a cell.
-			viewDocument.fire( 'mousedown', new DomEventData( view, {
-				target: view.domConverter.mapViewToDom(
-					// figure > table > tbody > tr > td
-					viewDocument.getRoot().getChild( 0 ).getChild( 1 ).getChild( 0 ).getChild( 0 ).getChild( 1 )
-				)
-			} ) );
-
-			// Then move the mouse to another cell. Disables listening to `selectionChange`.
-			viewDocument.fire( 'mousemove', new DomEventData( view, {
-				buttons: 1,
-				target: view.domConverter.mapViewToDom(
-					// figure > table > tbody > tr > td
-					viewDocument.getRoot().getChild( 0 ).getChild( 1 ).getChild( 0 ).getChild( 0 ).getChild( 2 )
-				),
-				preventDefault
-			} ) );
-
-			// See explanation why do we fire it in the similar test for Shift+click.
-			viewDocument.fire( 'selectionChange' );
-
-			// `selectionChange` event should be canceled.
-			expect( selectionChangeCallback.called ).to.equal( false );
-			expect( consoleLog.called ).to.equal( true );
-			expect( consoleLog.firstCall.args[ 0 ] ).to.equal( 'Blocked selectionChange to avoid breaking table cells selection.' );
-
-			// Enables listening to `selectionChange` event.
-			viewDocument.fire( 'mouseup' );
-
-			viewDocument.fire( 'selectionChange', {
-				newSelection: view.document.selection
-			} );
-
-			expect( selectionChangeCallback.called ).to.equal( true );
-
-			consoleLog.restore();
-		} );
-	} );
-
 	describe( 'getSelectedTableCells()', () => {
 	describe( 'getSelectedTableCells()', () => {
 		beforeEach( async () => {
 		beforeEach( async () => {
 			editor = await createEditor();
 			editor = await createEditor();
 			model = editor.model;
 			model = editor.model;
 			modelRoot = model.document.getRoot();
 			modelRoot = model.document.getRoot();
-			view = editor.editing.view;
-			viewDocument = view.document;
 			tableSelection = editor.plugins.get( TableSelection );
 			tableSelection = editor.plugins.get( TableSelection );
 
 
 			setModelData( model, modelTable( [
 			setModelData( model, modelTable( [
@@ -684,8 +189,6 @@ describe( 'table selection', () => {
 			editor = await createEditor();
 			editor = await createEditor();
 			model = editor.model;
 			model = editor.model;
 			modelRoot = model.document.getRoot();
 			modelRoot = model.document.getRoot();
-			view = editor.editing.view;
-			viewDocument = view.document;
 			tableSelection = editor.plugins.get( TableSelection );
 			tableSelection = editor.plugins.get( TableSelection );
 
 
 			setModelData( model, modelTable( [
 			setModelData( model, modelTable( [
@@ -740,8 +243,6 @@ describe( 'table selection', () => {
 			editor = await createEditor();
 			editor = await createEditor();
 			model = editor.model;
 			model = editor.model;
 			modelRoot = model.document.getRoot();
 			modelRoot = model.document.getRoot();
-			view = editor.editing.view;
-			viewDocument = view.document;
 			tableSelection = editor.plugins.get( TableSelection );
 			tableSelection = editor.plugins.get( TableSelection );
 
 
 			setModelData( model, modelTable( [
 			setModelData( model, modelTable( [

+ 1 - 1
packages/ckeditor5-table/tests/tableselection/mouseeventsobserver.js

@@ -6,7 +6,7 @@
 /* globals document */
 /* globals document */
 
 
 import View from '@ckeditor/ckeditor5-engine/src/view/view';
 import View from '@ckeditor/ckeditor5-engine/src/view/view';
-import MouseEventsObserver from '../../src/tableselection/mouseeventsobserver';
+import MouseEventsObserver from '../../src/tablemouse/mouseeventsobserver';
 
 
 describe( 'table selection', () => {
 describe( 'table selection', () => {
 	describe( 'MouseEventsObserver', () => {
 	describe( 'MouseEventsObserver', () => {

+ 0 - 567
packages/ckeditor5-table/tests/ui/utils.js

@@ -1,567 +0,0 @@
-/**
- * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
- */
-
-import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
-import Table from '../../src/table';
-import TableCellProperties from '../../src/tablecellproperties';
-import { findAncestor } from '../../src/commands/utils';
-import { getTableCellsContainingSelection } from '../../src/utils';
-import global from '@ckeditor/ckeditor5-utils/src/dom/global';
-import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
-import View from '@ckeditor/ckeditor5-ui/src/view';
-import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
-import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview';
-import ToolbarView from '@ckeditor/ckeditor5-ui/src/toolbar/toolbarview';
-import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
-import {
-	repositionContextualBalloon,
-	getBalloonCellPositionData,
-	getBorderStyleDefinitions,
-	getBorderStyleLabels,
-	getLocalizedColorErrorText,
-	getLocalizedLengthErrorText,
-	lengthFieldValidator,
-	lineWidthFieldValidator,
-	colorFieldValidator,
-	fillToolbar
-} from '../../src/ui/utils';
-import Collection from '@ckeditor/ckeditor5-utils/src/collection';
-import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
-import { centeredBalloonPositionForLongWidgets } from '@ckeditor/ckeditor5-widget/src/utils';
-import { modelTable } from '../_utils/utils';
-
-describe( 'UI Utils', () => {
-	let editor, editingView, balloon, editorElement;
-
-	testUtils.createSinonSandbox();
-
-	beforeEach( () => {
-		editorElement = global.document.createElement( 'div' );
-		global.document.body.appendChild( editorElement );
-
-		return ClassicEditor
-			.create( editorElement, {
-				plugins: [ Table, TableCellProperties, Paragraph ]
-			} )
-			.then( newEditor => {
-				editor = newEditor;
-				editingView = editor.editing.view;
-				balloon = editor.plugins.get( 'ContextualBalloon' );
-			} );
-	} );
-
-	afterEach( () => {
-		editorElement.remove();
-
-		return editor.destroy();
-	} );
-
-	describe( 'repositionContextualBalloon()', () => {
-		describe( 'with respect to the table cell', () => {
-			it( 'should re-position the ContextualBalloon when the table cell is selected', () => {
-				const spy = sinon.spy( balloon, 'updatePosition' );
-				const defaultPositions = BalloonPanelView.defaultPositions;
-				const view = new View();
-
-				view.element = global.document.createElement( 'div' );
-
-				balloon.add( {
-					view,
-					position: {
-						target: global.document.body
-					}
-				} );
-
-				setData( editor.model,
-					'<table><tableRow>' +
-						'<tableCell><paragraph>foo</paragraph></tableCell>' +
-						'<tableCell><paragraph>[bar]</paragraph></tableCell>' +
-					'</tableRow></table>' );
-				repositionContextualBalloon( editor, 'cell' );
-
-				const modelCell = getTableCellsContainingSelection( editor.model.document.selection )[ 0 ];
-				const viewCell = editor.editing.mapper.toViewElement( modelCell );
-
-				sinon.assert.calledWithExactly( spy, {
-					target: editingView.domConverter.viewToDom( viewCell ),
-					positions: [
-						defaultPositions.northArrowSouth,
-						defaultPositions.northArrowSouthWest,
-						defaultPositions.northArrowSouthEast,
-						defaultPositions.southArrowNorth,
-						defaultPositions.southArrowNorthWest,
-						defaultPositions.southArrowNorthEast
-					]
-				} );
-			} );
-
-			it( 'should not engage with no table is selected', () => {
-				const spy = sinon.spy( balloon, 'updatePosition' );
-
-				setData( editor.model, '<paragraph>foo</paragraph>' );
-
-				repositionContextualBalloon( editor, 'cell' );
-				sinon.assert.notCalled( spy );
-			} );
-		} );
-
-		describe( 'with respect to the entire table', () => {
-			it( 'should re-position the ContextualBalloon when the table is selected', () => {
-				const spy = sinon.spy( balloon, 'updatePosition' );
-				const defaultPositions = BalloonPanelView.defaultPositions;
-				const view = new View();
-
-				view.element = global.document.createElement( 'div' );
-
-				balloon.add( {
-					view,
-					position: {
-						target: global.document.body
-					}
-				} );
-
-				setData( editor.model,
-					'<table><tableRow>' +
-						'<tableCell><paragraph>foo</paragraph></tableCell>' +
-						'<tableCell><paragraph>[bar]</paragraph></tableCell>' +
-					'</tableRow></table>' );
-				repositionContextualBalloon( editor, 'table' );
-
-				const modelTable = findAncestor( 'table', editor.model.document.selection.getFirstPosition() );
-				const viewTable = editor.editing.mapper.toViewElement( modelTable );
-
-				sinon.assert.calledWithExactly( spy, {
-					target: editingView.domConverter.viewToDom( viewTable ),
-					positions: [
-						defaultPositions.northArrowSouth,
-						defaultPositions.northArrowSouthWest,
-						defaultPositions.northArrowSouthEast,
-						defaultPositions.southArrowNorth,
-						defaultPositions.southArrowNorthWest,
-						defaultPositions.southArrowNorthEast,
-						centeredBalloonPositionForLongWidgets
-					]
-				} );
-			} );
-
-			it( 'should not engage with no table is selected', () => {
-				const spy = sinon.spy( balloon, 'updatePosition' );
-
-				setData( editor.model, '<paragraph>foo</paragraph>' );
-
-				repositionContextualBalloon( editor, 'table' );
-				sinon.assert.notCalled( spy );
-			} );
-		} );
-	} );
-
-	describe( 'getBalloonCellPositionData()', () => {
-		let modelRoot;
-
-		beforeEach( () => {
-			setData( editor.model, modelTable( [
-				[ '11[]', '12', '13' ],
-				[ '21', '22', '23' ],
-				[ '31', '32', '33' ]
-			] ) );
-
-			modelRoot = editor.model.document.getRoot();
-
-			for ( let row = 0; row < 3; row++ ) {
-				for ( let col = 0; col < 3; col++ ) {
-					const modelCell = modelRoot.getNodeByPath( [ 0, row, col ] );
-					const viewCell = editor.editing.mapper.toViewElement( modelCell );
-					const cellDomElement = editingView.domConverter.viewToDom( viewCell );
-
-					mockBoundingBox( cellDomElement, {
-						top: 100 + row * 10,
-						left: 100 + col * 10,
-						height: 10,
-						width: 10
-					} );
-				}
-			}
-		} );
-
-		it( 'returns the position data', () => {
-			const defaultPositions = BalloonPanelView.defaultPositions;
-			const data = getBalloonCellPositionData( editor );
-			const modelCell = getTableCellsContainingSelection( editor.model.document.selection )[ 0 ];
-			const viewCell = editor.editing.mapper.toViewElement( modelCell );
-
-			expect( data ).to.deep.equal( {
-				target: editingView.domConverter.viewToDom( viewCell ),
-				positions: [
-					defaultPositions.northArrowSouth,
-					defaultPositions.northArrowSouthWest,
-					defaultPositions.northArrowSouthEast,
-					defaultPositions.southArrowNorth,
-					defaultPositions.southArrowNorthWest,
-					defaultPositions.southArrowNorthEast
-				]
-			} );
-		} );
-
-		it( 'returns the position data for multiple cells selected horizontally', () => {
-			selectTableCells( [
-				[ 0, 0 ],
-				[ 0, 1 ]
-			] );
-
-			const data = getBalloonCellPositionData( editor );
-			const targetData = data.target();
-
-			expect( targetData ).to.deep.equal( {
-				top: 100,
-				left: 100,
-				right: 120,
-				bottom: 110,
-				width: 20,
-				height: 10
-			} );
-		} );
-
-		it( 'returns the position data for multiple cells selected vertically', () => {
-			selectTableCells( [
-				[ 0, 1 ],
-				[ 1, 1 ]
-			] );
-
-			const data = getBalloonCellPositionData( editor );
-			const targetData = data.target();
-
-			expect( targetData ).to.deep.equal( {
-				top: 100,
-				left: 110,
-				right: 120,
-				bottom: 120,
-				width: 10,
-				height: 20
-			} );
-		} );
-
-		it( 'returns the position data for multiple cells selected', () => {
-			selectTableCells( [
-				[ 0, 1 ],
-				[ 1, 0 ],
-				[ 1, 1 ]
-			] );
-
-			const data = getBalloonCellPositionData( editor );
-			const targetData = data.target();
-
-			expect( targetData ).to.deep.equal( {
-				top: 100,
-				left: 100,
-				right: 120,
-				bottom: 120,
-				width: 20,
-				height: 20
-			} );
-		} );
-
-		function selectTableCells( paths ) {
-			editor.model.change( writer => {
-				writer.setSelection( paths.map( path => writer.createRangeOn( modelRoot.getNodeByPath( [ 0, ...path ] ) ) ) );
-			} );
-		}
-
-		function mockBoundingBox( element, data ) {
-			testUtils.sinon.stub( element, 'getBoundingClientRect' ).returns( {
-				...data,
-				right: data.left + data.width,
-				bottom: data.top + data.height
-			} );
-		}
-	} );
-
-	describe( 'getBorderStyleLabels()', () => {
-		it( 'should return labels for different border styles', () => {
-			const t = string => string;
-
-			expect( getBorderStyleLabels( t ) ).to.deep.equal( {
-				none: 'None',
-				solid: 'Solid',
-				dotted: 'Dotted',
-				dashed: 'Dashed',
-				double: 'Double',
-				groove: 'Groove',
-				ridge: 'Ridge',
-				inset: 'Inset',
-				outset: 'Outset'
-			} );
-		} );
-	} );
-
-	describe( 'getLocalizedColorErrorText()', () => {
-		it( 'should return the error text', () => {
-			const t = string => string;
-
-			expect( getLocalizedColorErrorText( t ) ).to.match( /^The color is invalid/ );
-		} );
-	} );
-
-	describe( 'getLocalizedLengthErrorText()', () => {
-		it( 'should return the error text', () => {
-			const t = string => string;
-
-			expect( getLocalizedLengthErrorText( t ) ).to.match( /^The value is invalid/ );
-		} );
-	} );
-
-	describe( 'colorFieldValidator()', () => {
-		it( 'should pass for an empty value', () => {
-			expect( colorFieldValidator( '' ) ).to.be.true;
-		} );
-
-		it( 'should pass for white spaces', () => {
-			expect( colorFieldValidator( '  ' ) ).to.be.true;
-		} );
-
-		it( 'should pass for colors', () => {
-			expect( colorFieldValidator( '#FFF' ) ).to.be.true;
-			expect( colorFieldValidator( '#FFAA11' ) ).to.be.true;
-			expect( colorFieldValidator( 'rgb(255,123,100)' ) ).to.be.true;
-			expect( colorFieldValidator( 'red' ) ).to.be.true;
-		} );
-
-		it( 'should pass for colors surrounded by white spaces', () => {
-			expect( colorFieldValidator( ' #AAA ' ) ).to.be.true;
-			expect( colorFieldValidator( ' rgb(255,123,100) ' ) ).to.be.true;
-		} );
-	} );
-
-	describe( 'lengthFieldValidator()', () => {
-		it( 'should pass for an empty value', () => {
-			expect( lengthFieldValidator( '' ) ).to.be.true;
-		} );
-
-		it( 'should pass for white spaces', () => {
-			expect( lengthFieldValidator( '  ' ) ).to.be.true;
-		} );
-
-		it( 'should pass for lengths', () => {
-			expect( lengthFieldValidator( '1px' ) ).to.be.true;
-			expect( lengthFieldValidator( '12em' ) ).to.be.true;
-			expect( lengthFieldValidator( ' 12em ' ) ).to.be.true;
-			expect( lengthFieldValidator( '45%' ) ).to.be.true;
-		} );
-
-		it( 'should pass for number without unit', () => {
-			expect( lengthFieldValidator( '1' ) ).to.be.true;
-			expect( lengthFieldValidator( '12.1' ) ).to.be.true;
-			expect( lengthFieldValidator( '0.125 ' ) ).to.be.true;
-		} );
-
-		it( 'should not pass for invalid number values', () => {
-			expect( lengthFieldValidator( '.1 ' ) ).to.be.false;
-			expect( lengthFieldValidator( '45. ' ) ).to.be.false;
-			expect( lengthFieldValidator( '45.1.1 ' ) ).to.be.false;
-		} );
-
-		it( 'should pass for lengths surrounded by white spaces', () => {
-			expect( lengthFieldValidator( '3px ' ) ).to.be.true;
-			expect( lengthFieldValidator( ' 12em ' ) ).to.be.true;
-		} );
-	} );
-
-	describe( 'lineWidthFieldValidator()', () => {
-		it( 'should pass for an empty value', () => {
-			expect( lineWidthFieldValidator( '' ) ).to.be.true;
-		} );
-
-		it( 'should pass for white spaces', () => {
-			expect( lineWidthFieldValidator( '  ' ) ).to.be.true;
-		} );
-
-		it( 'should pass for lengths', () => {
-			expect( lineWidthFieldValidator( '1px' ) ).to.be.true;
-			expect( lineWidthFieldValidator( '12em' ) ).to.be.true;
-			expect( lineWidthFieldValidator( ' 12em ' ) ).to.be.true;
-		} );
-
-		it( 'should pass for number without unit', () => {
-			expect( lineWidthFieldValidator( '1' ) ).to.be.true;
-			expect( lineWidthFieldValidator( '12.1' ) ).to.be.true;
-			expect( lineWidthFieldValidator( '0.125 ' ) ).to.be.true;
-		} );
-
-		it( 'should not pass for invalid number values', () => {
-			expect( lineWidthFieldValidator( '.1 ' ) ).to.be.false;
-			expect( lineWidthFieldValidator( '45. ' ) ).to.be.false;
-			expect( lineWidthFieldValidator( '45.1.1 ' ) ).to.be.false;
-			expect( lineWidthFieldValidator( '45%' ) ).to.be.false;
-		} );
-
-		it( 'should pass for lengths surrounded by white spaces', () => {
-			expect( lineWidthFieldValidator( '3px ' ) ).to.be.true;
-			expect( lineWidthFieldValidator( ' 12em ' ) ).to.be.true;
-		} );
-	} );
-
-	describe( 'getBorderStyleDefinitions()', () => {
-		let view, locale, definitions;
-
-		beforeEach( () => {
-			locale = { t: val => val };
-			view = new View( locale );
-			view.set( 'borderStyle', 'none' );
-
-			definitions = getBorderStyleDefinitions( view );
-		} );
-
-		it( 'should return a collection', () => {
-			expect( definitions ).to.be.instanceOf( Collection );
-		} );
-
-		it( 'should create a button definition for each style', () => {
-			expect( definitions.map( ( { type } ) => type ).every( item => item === 'button' ) ).to.be.true;
-		} );
-
-		it( 'should set label of a button for each style', () => {
-			expect( definitions.map( ( { model: { label } } ) => label ) ).to.have.ordered.members( [
-				'None',
-				'Solid',
-				'Dotted',
-				'Dashed',
-				'Double',
-				'Groove',
-				'Ridge',
-				'Inset',
-				'Outset'
-			] );
-		} );
-
-		it( 'should set type of a button for each style', () => {
-			expect( definitions.map( ( { model: { withText } } ) => withText ).every( item => item === true ) ).to.be.true;
-		} );
-
-		it( 'should bind button\'s #isOn to the view #borderStyle property', () => {
-			view.borderStyle = 'dotted';
-
-			expect( definitions.map( ( { model: { isOn } } ) => isOn ) ).to.have.ordered.members( [
-				false,
-				false,
-				true,
-				false,
-				false,
-				false,
-				false,
-				false,
-				false
-			] );
-
-			view.borderStyle = 'inset';
-
-			expect( definitions.map( ( { model: { isOn } } ) => isOn ) ).to.have.ordered.members( [
-				false,
-				false,
-				false,
-				false,
-				false,
-				false,
-				false,
-				true,
-				false
-			] );
-		} );
-	} );
-
-	describe( 'fillToolbar()', () => {
-		let view, locale, toolbar;
-
-		const labels = {
-			first: 'Do something',
-			second: 'Do something else',
-			third: 'Be default'
-		};
-
-		const icons = {
-			first: '<svg viewBox="0 0 21 21" xmlns="http://www.w3.org/2000/svg"><path /></svg>',
-			second: '<svg viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg"><path /></svg>',
-			third: '<svg viewBox="0 0 23 23" xmlns="http://www.w3.org/2000/svg"><path /></svg>'
-		};
-
-		beforeEach( () => {
-			locale = { t: val => val };
-			view = new View( locale );
-			view.set( 'someProperty', 'foo' );
-			toolbar = new ToolbarView( locale );
-
-			fillToolbar( {
-				view, toolbar, icons, labels,
-				propertyName: 'someProperty',
-				nameToValue: name => name === 'third' ? '' : name
-			} );
-		} );
-
-		afterEach( () => {
-			view.destroy();
-		} );
-
-		it( 'should create buttons', () => {
-			expect( toolbar.items ).to.have.length( 3 );
-			expect( toolbar.items.first ).to.be.instanceOf( ButtonView );
-			expect( toolbar.items.get( 1 ) ).to.be.instanceOf( ButtonView );
-			expect( toolbar.items.last ).to.be.instanceOf( ButtonView );
-		} );
-
-		it( 'should set button labels', () => {
-			expect( toolbar.items.first.label ).to.equal( 'Do something' );
-			expect( toolbar.items.get( 1 ).label ).to.equal( 'Do something else' );
-			expect( toolbar.items.last.label ).to.equal( 'Be default' );
-		} );
-
-		it( 'should set button icons', () => {
-			expect( toolbar.items.first.icon ).to.equal( icons.first );
-			expect( toolbar.items.get( 1 ).icon ).to.equal( icons.second );
-			expect( toolbar.items.last.icon ).to.equal( icons.third );
-		} );
-
-		it( 'should set button tooltips', () => {
-			expect( toolbar.items.first.tooltip ).to.equal( labels.first );
-			expect( toolbar.items.get( 1 ).tooltip ).to.equal( labels.second );
-			expect( toolbar.items.last.tooltip ).to.equal( labels.third );
-		} );
-
-		it( 'should bind button #isOn to an observable property', () => {
-			expect( toolbar.items.first.isOn ).to.be.false;
-			expect( toolbar.items.get( 1 ).isOn ).to.be.false;
-			expect( toolbar.items.last.isOn ).to.be.false;
-
-			view.someProperty = 'first';
-
-			expect( toolbar.items.first.isOn ).to.be.true;
-			expect( toolbar.items.get( 1 ).isOn ).to.be.false;
-			expect( toolbar.items.last.isOn ).to.be.false;
-
-			view.someProperty = 'second';
-
-			expect( toolbar.items.first.isOn ).to.be.false;
-			expect( toolbar.items.get( 1 ).isOn ).to.be.true;
-			expect( toolbar.items.last.isOn ).to.be.false;
-
-			view.someProperty = '';
-
-			expect( toolbar.items.first.isOn ).to.be.false;
-			expect( toolbar.items.get( 1 ).isOn ).to.be.false;
-			expect( toolbar.items.last.isOn ).to.be.true;
-		} );
-
-		it( 'should make the buttons change the property value upon execution', () => {
-			toolbar.items.first.fire( 'execute' );
-
-			expect( view.someProperty ).to.equal( 'first' );
-
-			toolbar.items.get( 1 ).fire( 'execute' );
-
-			expect( view.someProperty ).to.equal( 'second' );
-
-			toolbar.items.last.fire( 'execute' );
-
-			expect( view.someProperty ).to.equal( '' );
-		} );
-	} );
-} );

+ 0 - 450
packages/ckeditor5-table/tests/utils.js

@@ -1,450 +0,0 @@
-/**
- * @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 Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
-import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
-import TableSelection from '../src/tableselection';
-import TableEditing from '../src/tableediting';
-
-import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
-import { modelTable } from './_utils/utils';
-import {
-	getSelectedTableCells,
-	getTableCellsContainingSelection,
-	getSelectionAffectedTableCells, getVerticallyOverlappingCells, getHorizontallyOverlappingCells
-} from '../src/utils';
-
-describe( 'table utils', () => {
-	let editor, model, tableSelection, modelRoot;
-
-	beforeEach( async () => {
-		editor = await VirtualTestEditor.create( {
-			plugins: [ TableEditing, TableSelection, Paragraph ]
-		} );
-
-		model = editor.model;
-		modelRoot = model.document.getRoot();
-		tableSelection = editor.plugins.get( TableSelection );
-
-		setModelData( model, modelTable( [
-			[ '11[]', '12', '13' ],
-			[ '21', '22', '23' ],
-			[ '31', '32', '33' ]
-		] ) );
-	} );
-
-	afterEach( async () => {
-		await editor.destroy();
-	} );
-
-	describe( 'getSelectedTableCells()', () => {
-		let selection;
-
-		beforeEach( () => {
-			selection = model.document.selection;
-		} );
-
-		it( 'should return an empty array when a collapsed selection is anchored in a cell', () => {
-			const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
-
-			model.change( writer => {
-				writer.setSelection( writer.createRange( writer.createPositionAt( firstCell, 0 ) ) );
-			} );
-
-			expect( getSelectedTableCells( selection ) ).to.be.empty;
-		} );
-
-		it( 'should return an empty array when a non-collapsed selection is anchored in a cell', () => {
-			const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
-
-			model.change( writer => {
-				writer.setSelection( writer.createRangeIn( firstCell ) );
-			} );
-
-			expect( getSelectedTableCells( selection ) ).to.be.empty;
-		} );
-
-		it( 'should return an empty array when a non-cell node is selected', () => {
-			const paragraph = modelRoot.getNodeByPath( [ 0, 0, 0, 0 ] );
-
-			expect( paragraph.is( 'paragraph' ) ).to.be.true;
-
-			model.change( writer => {
-				writer.setSelection( writer.createRangeOn( paragraph ) );
-			} );
-
-			expect( getSelectedTableCells( selection ) ).to.be.empty;
-		} );
-
-		it( 'should return an empty array when an entire table is selected', () => {
-			const table = modelRoot.getNodeByPath( [ 0 ] );
-
-			model.change( writer => {
-				writer.setSelection( writer.createRangeOn( table ) );
-			} );
-
-			expect( getSelectedTableCells( selection ) ).to.be.empty;
-		} );
-
-		it( 'should return two table cells', () => {
-			const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
-			const lastCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] );
-
-			tableSelection.setCellSelection( firstCell, lastCell );
-
-			expect( getSelectedTableCells( selection ) ).to.have.ordered.members( [
-				firstCell, lastCell
-			] );
-		} );
-
-		it( 'should return four table cells for diagonal selection', () => {
-			const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
-			const lastCell = modelRoot.getNodeByPath( [ 0, 1, 1 ] );
-
-			tableSelection.setCellSelection( firstCell, lastCell );
-
-			expect( getSelectedTableCells( selection ) ).to.have.ordered.members( [
-				firstCell,
-				modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
-				modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
-				lastCell
-			] );
-		} );
-
-		it( 'should return row table cells', () => {
-			const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
-			const lastCell = modelRoot.getNodeByPath( [ 0, 0, 2 ] );
-
-			tableSelection.setCellSelection( firstCell, lastCell );
-
-			expect( getSelectedTableCells( selection ) ).to.have.ordered.members( [
-				firstCell,
-				modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
-				lastCell
-			] );
-		} );
-
-		it( 'should return column table cells', () => {
-			const firstCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] );
-			const lastCell = modelRoot.getNodeByPath( [ 0, 2, 1 ] );
-
-			tableSelection.setCellSelection( firstCell, lastCell );
-
-			expect( getSelectedTableCells( selection ) ).to.have.ordered.members( [
-				firstCell,
-				modelRoot.getNodeByPath( [ 0, 1, 1 ] ),
-				lastCell
-			] );
-		} );
-
-		it( 'should return cells in source order despite backward selection and forward ranges', () => {
-			const leftCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] );
-			const rightCell = modelRoot.getNodeByPath( [ 0, 0, 2 ] );
-
-			editor.model.change( writer => {
-				writer.setSelection(
-					[ writer.createRangeOn( leftCell ), writer.createRangeOn( rightCell ) ],
-					{ backward: true }
-				);
-			} );
-
-			expect( Array.from( tableSelection.getSelectedTableCells() ) ).to.deep.equal( [
-				leftCell, rightCell
-			] );
-		} );
-
-		it( 'should return cells in source order despite backward selection and backward ranges', () => {
-			const leftCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] );
-			const rightCell = modelRoot.getNodeByPath( [ 0, 0, 2 ] );
-
-			editor.model.change( writer => {
-				writer.setSelection(
-					[ writer.createRangeOn( rightCell ), writer.createRangeOn( leftCell ) ],
-					{ backward: true }
-				);
-			} );
-
-			expect( Array.from( tableSelection.getSelectedTableCells() ) ).to.deep.equal( [
-				leftCell, rightCell
-			] );
-		} );
-
-		// Backward direction does not have to equal ranges in the reversed order.
-		it( 'should return cells in source order despite forward selection and backward ranges', () => {
-			const leftCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] );
-			const rightCell = modelRoot.getNodeByPath( [ 0, 0, 2 ] );
-
-			editor.model.change( writer => {
-				writer.setSelection( [ writer.createRangeOn( rightCell ), writer.createRangeOn( leftCell ) ] );
-			} );
-
-			expect( Array.from( tableSelection.getSelectedTableCells() ) ).to.deep.equal( [
-				leftCell, rightCell
-			] );
-		} );
-
-		it( 'should return cells in source order despite selection with mixed range order', () => {
-			const leftCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
-			const midCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] );
-			const rightCell = modelRoot.getNodeByPath( [ 0, 0, 2 ] );
-
-			editor.model.change( writer => {
-				writer.setSelection( [
-					writer.createRangeOn( rightCell ),
-					writer.createRangeOn( leftCell ),
-					writer.createRangeOn( midCell )
-				] );
-			} );
-
-			expect( Array.from( tableSelection.getSelectedTableCells() ) ).to.deep.equal( [
-				leftCell, midCell, rightCell
-			] );
-		} );
-	} );
-
-	describe( 'getTableCellsContainingSelection()', () => {
-		let selection;
-
-		beforeEach( () => {
-			selection = model.document.selection;
-		} );
-
-		it( 'should return an array with a cell when a selection is anchored in it', () => {
-			const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
-
-			model.change( writer => {
-				writer.setSelection( writer.createRange( writer.createPositionAt( firstCell, 0 ) ) );
-			} );
-
-			expect( getTableCellsContainingSelection( selection ) ).to.have.ordered.members( [ firstCell ] );
-		} );
-
-		it( 'should return an array with a cell when a selection range is anchored in its descendant', () => {
-			const cell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
-			const paragraph = modelRoot.getNodeByPath( [ 0, 0, 0, 0 ] );
-
-			model.change( writer => {
-				writer.setSelection( writer.createRange(
-					writer.createPositionAt( paragraph, 0 ),
-					writer.createPositionAt( paragraph, 1 )
-				) );
-			} );
-
-			expect( getTableCellsContainingSelection( selection ) ).to.have.ordered.members( [
-				cell
-			] );
-		} );
-
-		it( 'should return an array with cells when multiple collapsed selection ranges are anchored in them', () => {
-			const cellA = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
-			const cellB = modelRoot.getNodeByPath( [ 0, 1, 0 ] );
-
-			model.change( writer => {
-				writer.setSelection( [
-					writer.createRange( writer.createPositionAt( cellA, 0 ) ),
-					writer.createRange( writer.createPositionAt( cellB, 0 ) )
-				] );
-			} );
-
-			expect( getTableCellsContainingSelection( selection ) ).to.have.ordered.members( [
-				cellA,
-				cellB
-			] );
-		} );
-
-		it( 'should return an array with cells when multiple non–collapsed selection ranges are anchored in them', () => {
-			const cellA = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
-			const cellB = modelRoot.getNodeByPath( [ 0, 1, 0 ] );
-
-			model.change( writer => {
-				writer.setSelection( [
-					writer.createRangeIn( cellA ),
-					writer.createRangeIn( cellB )
-				] );
-			} );
-
-			expect( getTableCellsContainingSelection( selection ) ).to.have.ordered.members( [
-				cellA,
-				cellB
-			] );
-		} );
-
-		it( 'should return an empty array when an entire cell is selected', () => {
-			const cell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
-
-			model.change( writer => {
-				writer.setSelection( writer.createRangeOn( cell ) );
-			} );
-
-			expect( getTableCellsContainingSelection( selection ) ).to.be.empty;
-		} );
-
-		it( 'should return an empty array when an entire table is selected', () => {
-			const table = modelRoot.getNodeByPath( [ 0 ] );
-
-			model.change( writer => {
-				writer.setSelection( writer.createRangeOn( table ) );
-			} );
-
-			expect( getTableCellsContainingSelection( selection ) ).to.be.empty;
-		} );
-
-		it( 'should return an empty array when unrelated elements host selection ranges', () => {
-			setModelData( model, '<paragraph>foo</paragraph>' );
-
-			const paragraph = modelRoot.getNodeByPath( [ 0 ] );
-
-			model.change( writer => {
-				writer.setSelection( writer.createRange( writer.createPositionAt( paragraph, 1 ) ) );
-			} );
-
-			expect( getTableCellsContainingSelection( selection ) ).to.be.empty;
-		} );
-	} );
-
-	describe( 'getSelectionAffectedTableCells()', () => {
-		let selection;
-
-		beforeEach( () => {
-			selection = model.document.selection;
-		} );
-
-		it( 'should return completely selected cells (if there are any)', () => {
-			const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
-			const lastCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] );
-
-			tableSelection.setCellSelection( firstCell, lastCell );
-
-			expect( Array.from( getSelectionAffectedTableCells( selection ) ) ).to.have.ordered.members( [
-				firstCell, lastCell
-			] );
-		} );
-
-		it( 'should return cells when selection ranges are starting in them', () => {
-			const cellA = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
-			const cellB = modelRoot.getNodeByPath( [ 0, 1, 0 ] );
-
-			model.change( writer => {
-				writer.setSelection( [
-					writer.createRange( writer.createPositionAt( cellA, 0 ) ),
-					writer.createRange( writer.createPositionAt( cellB, 0 ) )
-				] );
-			} );
-
-			expect( getSelectionAffectedTableCells( selection ) ).to.have.ordered.members( [
-				cellA,
-				cellB
-			] );
-		} );
-
-		it( 'should return an empty array if no cells are selected and no selection ranges start in any cell', () => {
-			const table = modelRoot.getNodeByPath( [ 0 ] );
-
-			model.change( writer => {
-				writer.setSelection( writer.createRangeOn( table ) );
-			} );
-
-			expect( getSelectionAffectedTableCells( selection ) ).to.be.empty;
-
-			setModelData( model, '<paragraph>foo</paragraph>' );
-
-			const paragraph = modelRoot.getNodeByPath( [ 0 ] );
-
-			model.change( writer => {
-				writer.setSelection( writer.createRange( writer.createPositionAt( paragraph, 1 ) ) );
-			} );
-
-			expect( getSelectionAffectedTableCells( selection ) ).to.be.empty;
-		} );
-	} );
-
-	describe( 'getVerticallyOverlappingCells()', () => {
-		let table;
-
-		beforeEach( () => {
-			// +----+----+----+----+----+
-			// | 00 | 01 | 02 | 03 | 04 |
-			// +    +    +----+    +----+
-			// |    |    | 12 |    | 14 |
-			// +    +    +    +----+----+
-			// |    |    |    | 23 | 24 |
-			// +    +----+    +    +----+
-			// |    | 31 |    |    | 34 |
-			// +    +    +----+----+----+
-			// |    |    | 42 | 43 | 44 |
-			// +----+----+----+----+----+
-			setModelData( model, modelTable( [
-				[ { contents: '00', rowspan: 5 }, { contents: '01', rowspan: 3 }, '02', { contents: '03', rowspan: 2 }, '04' ],
-				[ { contents: '12', rowspan: 3 }, '14' ],
-				[ { contents: '23', rowspan: 2 }, '24' ],
-				[ { contents: '31', rowspan: 2 }, '34' ],
-				[ '42', '43', '44' ]
-			] ) );
-
-			table = modelRoot.getChild( 0 );
-		} );
-
-		it( 'should return empty array for no overlapping cells', () => {
-			const cellsInfo = getVerticallyOverlappingCells( table, 0 );
-
-			expect( cellsInfo ).to.be.empty;
-		} );
-
-		it( 'should return overlapping cells info for given overlapRow', () => {
-			const cellsInfo = getVerticallyOverlappingCells( table, 2 );
-
-			expect( cellsInfo[ 0 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) ); // Cell 00
-			expect( cellsInfo[ 1 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) ); // Cell 01
-			expect( cellsInfo[ 2 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 1, 0 ] ) ); // Cell 12
-		} );
-
-		it( 'should ignore rows below startRow', () => {
-			const cellsInfo = getVerticallyOverlappingCells( table, 2, 1 );
-
-			expect( cellsInfo[ 0 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 1, 0 ] ) ); // Cell 12
-		} );
-	} );
-
-	describe( 'getHorizontallyOverlappingCells()', () => {
-		let table;
-
-		beforeEach( () => {
-			// +----+----+----+----+----+
-			// | 00                     |
-			// +----+----+----+----+----+
-			// | 10           | 13      |
-			// +----+----+----+----+----+
-			// | 20 | 21           | 24 |
-			// +----+----+----+----+----+
-			// | 30      | 32      | 34 |
-			// +----+----+----+----+----+
-			// | 40 | 41 | 42 | 43 | 44 |
-			// +----+----+----+----+----+
-			setModelData( model, modelTable( [
-				[ { contents: '00', colspan: 5 } ],
-				[ { contents: '10', colspan: 3 }, { contents: '13', colspan: 2 } ],
-				[ '20', { contents: '21', colspan: 3 }, '24' ],
-				[ { contents: '30', colspan: 2 }, { contents: '32', colspan: 2 }, '34' ],
-				[ '40', '41', '42', '43', '44' ]
-			] ) );
-
-			table = modelRoot.getChild( 0 );
-		} );
-
-		it( 'should return empty array for no overlapping cells', () => {
-			const cellsInfo = getHorizontallyOverlappingCells( table, 0 );
-
-			expect( cellsInfo ).to.be.empty;
-		} );
-
-		it( 'should return overlapping cells info for given overlapColumn', () => {
-			const cellsInfo = getHorizontallyOverlappingCells( table, 2 );
-
-			expect( cellsInfo[ 0 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) ); // Cell 00
-			expect( cellsInfo[ 1 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 1, 0 ] ) ); // Cell 10
-			expect( cellsInfo[ 2 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 2, 1 ] ) ); // Cell 21
-		} );
-	} );
-} );

+ 94 - 0
packages/ckeditor5-table/tests/utils/common.js

@@ -0,0 +1,94 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import TableUtils from '../../src/tableutils';
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { defaultConversion, defaultSchema, modelTable } from '../_utils/utils';
+import { findAncestor, isHeadingColumnCell } from '../../src/utils/common';
+
+describe( 'table utils', () => {
+	let editor, model, modelRoot, tableUtils;
+	beforeEach( () => {
+		return ModelTestEditor
+			.create( {
+				plugins: [ TableUtils ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				modelRoot = model.document.getRoot();
+				tableUtils = editor.plugins.get( TableUtils );
+
+				defaultSchema( model.schema );
+				defaultConversion( editor.conversion );
+			} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	describe( 'common', () => {
+		describe( 'findAncestor()', () => {
+			it( 'should return undefined if not in table', () => {
+				setData( model, '<paragraph>foo[]</paragraph>' );
+
+				expect( findAncestor( 'table', model.document.selection.focus ) ).to.be.undefined;
+			} );
+
+			it( 'should return table if position is in tableCell', () => {
+				setData( model, modelTable( [ [ '[]' ] ] ) );
+
+				const parentTable = findAncestor( 'table', model.document.selection.focus );
+
+				expect( parentTable ).to.not.be.undefined;
+				expect( parentTable.is( 'table' ) ).to.be.true;
+			} );
+		} );
+
+		describe( 'isHeadingColumnCell()', () => {
+			it( 'should return "true" for a heading column cell', () => {
+				setData( model, modelTable( [
+					[ '00', '01', '02', '03' ]
+				], { headingColumns: 2 } ) );
+
+				const tableCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] );
+
+				expect( isHeadingColumnCell( tableUtils, tableCell ) ).to.be.true;
+			} );
+
+			it( 'should return "true" for a heading column cell with colspan', () => {
+				setData( model, modelTable( [
+					[ { colspan: 2, contents: '00' }, '01', '02', '03' ]
+				], { headingColumns: 2 } ) );
+
+				const tableCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
+
+				expect( isHeadingColumnCell( tableUtils, tableCell ) ).to.be.true;
+			} );
+
+			it( 'should return "false" for a regular column cell', () => {
+				setData( model, modelTable( [
+					[ '00', '01', '02', '03' ]
+				], { headingColumns: 2 } ) );
+
+				const tableCell = modelRoot.getNodeByPath( [ 0, 0, 2 ] );
+
+				expect( isHeadingColumnCell( tableUtils, tableCell ) ).to.be.false;
+			} );
+
+			it( 'should return "false" for a regular column cell with colspan', () => {
+				setData( model, modelTable( [
+					[ '00', { colspan: 2, contents: '01' }, '02', '03' ]
+				], { headingColumns: 1 } ) );
+
+				const tableCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] );
+
+				expect( isHeadingColumnCell( tableUtils, tableCell ) ).to.be.false;
+			} );
+		} );
+	} );
+} );

+ 360 - 0
packages/ckeditor5-table/tests/utils/selection.js

@@ -0,0 +1,360 @@
+/**
+ * @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 Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import TableSelection from '../../src/tableselection';
+import TableEditing from '../../src/tableediting';
+
+import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { modelTable } from '../_utils/utils';
+import { getSelectedTableCells, getSelectionAffectedTableCells, getTableCellsContainingSelection } from '../../src/utils/selection';
+
+describe( 'table utils', () => {
+	let editor, model, tableSelection, modelRoot;
+
+	beforeEach( async () => {
+		editor = await VirtualTestEditor.create( {
+			plugins: [ TableEditing, TableSelection, Paragraph ]
+		} );
+
+		model = editor.model;
+		modelRoot = model.document.getRoot();
+		tableSelection = editor.plugins.get( TableSelection );
+
+		setModelData( model, modelTable( [
+			[ '11[]', '12', '13' ],
+			[ '21', '22', '23' ],
+			[ '31', '32', '33' ]
+		] ) );
+	} );
+
+	afterEach( async () => {
+		await editor.destroy();
+	} );
+
+	describe( 'selection', () => {
+		describe( 'getSelectedTableCells()', () => {
+			let selection;
+
+			beforeEach( () => {
+				selection = model.document.selection;
+			} );
+
+			it( 'should return an empty array when a collapsed selection is anchored in a cell', () => {
+				const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
+
+				model.change( writer => {
+					writer.setSelection( writer.createRange( writer.createPositionAt( firstCell, 0 ) ) );
+				} );
+
+				expect( getSelectedTableCells( selection ) ).to.be.empty;
+			} );
+
+			it( 'should return an empty array when a non-collapsed selection is anchored in a cell', () => {
+				const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
+
+				model.change( writer => {
+					writer.setSelection( writer.createRangeIn( firstCell ) );
+				} );
+
+				expect( getSelectedTableCells( selection ) ).to.be.empty;
+			} );
+
+			it( 'should return an empty array when a non-cell node is selected', () => {
+				const paragraph = modelRoot.getNodeByPath( [ 0, 0, 0, 0 ] );
+
+				expect( paragraph.is( 'paragraph' ) ).to.be.true;
+
+				model.change( writer => {
+					writer.setSelection( writer.createRangeOn( paragraph ) );
+				} );
+
+				expect( getSelectedTableCells( selection ) ).to.be.empty;
+			} );
+
+			it( 'should return an empty array when an entire table is selected', () => {
+				const table = modelRoot.getNodeByPath( [ 0 ] );
+
+				model.change( writer => {
+					writer.setSelection( writer.createRangeOn( table ) );
+				} );
+
+				expect( getSelectedTableCells( selection ) ).to.be.empty;
+			} );
+
+			it( 'should return two table cells', () => {
+				const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
+				const lastCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] );
+
+				tableSelection.setCellSelection( firstCell, lastCell );
+
+				expect( getSelectedTableCells( selection ) ).to.have.ordered.members( [
+					firstCell, lastCell
+				] );
+			} );
+
+			it( 'should return four table cells for diagonal selection', () => {
+				const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
+				const lastCell = modelRoot.getNodeByPath( [ 0, 1, 1 ] );
+
+				tableSelection.setCellSelection( firstCell, lastCell );
+
+				expect( getSelectedTableCells( selection ) ).to.have.ordered.members( [
+					firstCell,
+					modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
+					modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
+					lastCell
+				] );
+			} );
+
+			it( 'should return row table cells', () => {
+				const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
+				const lastCell = modelRoot.getNodeByPath( [ 0, 0, 2 ] );
+
+				tableSelection.setCellSelection( firstCell, lastCell );
+
+				expect( getSelectedTableCells( selection ) ).to.have.ordered.members( [
+					firstCell,
+					modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
+					lastCell
+				] );
+			} );
+
+			it( 'should return column table cells', () => {
+				const firstCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] );
+				const lastCell = modelRoot.getNodeByPath( [ 0, 2, 1 ] );
+
+				tableSelection.setCellSelection( firstCell, lastCell );
+
+				expect( getSelectedTableCells( selection ) ).to.have.ordered.members( [
+					firstCell,
+					modelRoot.getNodeByPath( [ 0, 1, 1 ] ),
+					lastCell
+				] );
+			} );
+
+			it( 'should return cells in source order despite backward selection and forward ranges', () => {
+				const leftCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] );
+				const rightCell = modelRoot.getNodeByPath( [ 0, 0, 2 ] );
+
+				editor.model.change( writer => {
+					writer.setSelection(
+						[ writer.createRangeOn( leftCell ), writer.createRangeOn( rightCell ) ],
+						{ backward: true }
+					);
+				} );
+
+				expect( Array.from( tableSelection.getSelectedTableCells() ) ).to.deep.equal( [
+					leftCell, rightCell
+				] );
+			} );
+
+			it( 'should return cells in source order despite backward selection and backward ranges', () => {
+				const leftCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] );
+				const rightCell = modelRoot.getNodeByPath( [ 0, 0, 2 ] );
+
+				editor.model.change( writer => {
+					writer.setSelection(
+						[ writer.createRangeOn( rightCell ), writer.createRangeOn( leftCell ) ],
+						{ backward: true }
+					);
+				} );
+
+				expect( Array.from( tableSelection.getSelectedTableCells() ) ).to.deep.equal( [
+					leftCell, rightCell
+				] );
+			} );
+
+			// Backward direction does not have to equal ranges in the reversed order.
+			it( 'should return cells in source order despite forward selection and backward ranges', () => {
+				const leftCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] );
+				const rightCell = modelRoot.getNodeByPath( [ 0, 0, 2 ] );
+
+				editor.model.change( writer => {
+					writer.setSelection( [ writer.createRangeOn( rightCell ), writer.createRangeOn( leftCell ) ] );
+				} );
+
+				expect( Array.from( tableSelection.getSelectedTableCells() ) ).to.deep.equal( [
+					leftCell, rightCell
+				] );
+			} );
+
+			it( 'should return cells in source order despite selection with mixed range order', () => {
+				const leftCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
+				const midCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] );
+				const rightCell = modelRoot.getNodeByPath( [ 0, 0, 2 ] );
+
+				editor.model.change( writer => {
+					writer.setSelection( [
+						writer.createRangeOn( rightCell ),
+						writer.createRangeOn( leftCell ),
+						writer.createRangeOn( midCell )
+					] );
+				} );
+
+				expect( Array.from( tableSelection.getSelectedTableCells() ) ).to.deep.equal( [
+					leftCell, midCell, rightCell
+				] );
+			} );
+		} );
+
+		describe( 'getTableCellsContainingSelection()', () => {
+			let selection;
+
+			beforeEach( () => {
+				selection = model.document.selection;
+			} );
+
+			it( 'should return an array with a cell when a selection is anchored in it', () => {
+				const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
+
+				model.change( writer => {
+					writer.setSelection( writer.createRange( writer.createPositionAt( firstCell, 0 ) ) );
+				} );
+
+				expect( getTableCellsContainingSelection( selection ) ).to.have.ordered.members( [ firstCell ] );
+			} );
+
+			it( 'should return an array with a cell when a selection range is anchored in its descendant', () => {
+				const cell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
+				const paragraph = modelRoot.getNodeByPath( [ 0, 0, 0, 0 ] );
+
+				model.change( writer => {
+					writer.setSelection( writer.createRange(
+						writer.createPositionAt( paragraph, 0 ),
+						writer.createPositionAt( paragraph, 1 )
+					) );
+				} );
+
+				expect( getTableCellsContainingSelection( selection ) ).to.have.ordered.members( [
+					cell
+				] );
+			} );
+
+			it( 'should return an array with cells when multiple collapsed selection ranges are anchored in them', () => {
+				const cellA = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
+				const cellB = modelRoot.getNodeByPath( [ 0, 1, 0 ] );
+
+				model.change( writer => {
+					writer.setSelection( [
+						writer.createRange( writer.createPositionAt( cellA, 0 ) ),
+						writer.createRange( writer.createPositionAt( cellB, 0 ) )
+					] );
+				} );
+
+				expect( getTableCellsContainingSelection( selection ) ).to.have.ordered.members( [
+					cellA,
+					cellB
+				] );
+			} );
+
+			it( 'should return an array with cells when multiple non–collapsed selection ranges are anchored in them', () => {
+				const cellA = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
+				const cellB = modelRoot.getNodeByPath( [ 0, 1, 0 ] );
+
+				model.change( writer => {
+					writer.setSelection( [
+						writer.createRangeIn( cellA ),
+						writer.createRangeIn( cellB )
+					] );
+				} );
+
+				expect( getTableCellsContainingSelection( selection ) ).to.have.ordered.members( [
+					cellA,
+					cellB
+				] );
+			} );
+
+			it( 'should return an empty array when an entire cell is selected', () => {
+				const cell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
+
+				model.change( writer => {
+					writer.setSelection( writer.createRangeOn( cell ) );
+				} );
+
+				expect( getTableCellsContainingSelection( selection ) ).to.be.empty;
+			} );
+
+			it( 'should return an empty array when an entire table is selected', () => {
+				const table = modelRoot.getNodeByPath( [ 0 ] );
+
+				model.change( writer => {
+					writer.setSelection( writer.createRangeOn( table ) );
+				} );
+
+				expect( getTableCellsContainingSelection( selection ) ).to.be.empty;
+			} );
+
+			it( 'should return an empty array when unrelated elements host selection ranges', () => {
+				setModelData( model, '<paragraph>foo</paragraph>' );
+
+				const paragraph = modelRoot.getNodeByPath( [ 0 ] );
+
+				model.change( writer => {
+					writer.setSelection( writer.createRange( writer.createPositionAt( paragraph, 1 ) ) );
+				} );
+
+				expect( getTableCellsContainingSelection( selection ) ).to.be.empty;
+			} );
+		} );
+
+		describe( 'getSelectionAffectedTableCells()', () => {
+			let selection;
+
+			beforeEach( () => {
+				selection = model.document.selection;
+			} );
+
+			it( 'should return completely selected cells (if there are any)', () => {
+				const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
+				const lastCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] );
+
+				tableSelection.setCellSelection( firstCell, lastCell );
+
+				expect( Array.from( getSelectionAffectedTableCells( selection ) ) ).to.have.ordered.members( [
+					firstCell, lastCell
+				] );
+			} );
+
+			it( 'should return cells when selection ranges are starting in them', () => {
+				const cellA = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
+				const cellB = modelRoot.getNodeByPath( [ 0, 1, 0 ] );
+
+				model.change( writer => {
+					writer.setSelection( [
+						writer.createRange( writer.createPositionAt( cellA, 0 ) ),
+						writer.createRange( writer.createPositionAt( cellB, 0 ) )
+					] );
+				} );
+
+				expect( getSelectionAffectedTableCells( selection ) ).to.have.ordered.members( [
+					cellA,
+					cellB
+				] );
+			} );
+
+			it( 'should return an empty array if no cells are selected and no selection ranges start in any cell', () => {
+				const table = modelRoot.getNodeByPath( [ 0 ] );
+
+				model.change( writer => {
+					writer.setSelection( writer.createRangeOn( table ) );
+				} );
+
+				expect( getSelectionAffectedTableCells( selection ) ).to.be.empty;
+
+				setModelData( model, '<paragraph>foo</paragraph>' );
+
+				const paragraph = modelRoot.getNodeByPath( [ 0 ] );
+
+				model.change( writer => {
+					writer.setSelection( writer.createRange( writer.createPositionAt( paragraph, 1 ) ) );
+				} );
+
+				expect( getSelectionAffectedTableCells( selection ) ).to.be.empty;
+			} );
+		} );
+	} );
+} );

+ 119 - 0
packages/ckeditor5-table/tests/utils/structure.js

@@ -0,0 +1,119 @@
+/**
+ * @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 Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import TableEditing from '../../src/tableediting';
+
+import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { modelTable } from '../_utils/utils';
+import { getHorizontallyOverlappingCells, getVerticallyOverlappingCells } from '../../src/utils/structure';
+
+describe( 'table utils', () => {
+	let editor, model, modelRoot;
+
+	beforeEach( async () => {
+		editor = await VirtualTestEditor.create( {
+			plugins: [ TableEditing, Paragraph ]
+		} );
+
+		model = editor.model;
+		modelRoot = model.document.getRoot();
+	} );
+
+	afterEach( async () => {
+		await editor.destroy();
+	} );
+
+	describe( 'structure', () => {
+		describe( 'getVerticallyOverlappingCells()', () => {
+			let table;
+
+			beforeEach( () => {
+				// +----+----+----+----+----+
+				// | 00 | 01 | 02 | 03 | 04 |
+				// +    +    +----+    +----+
+				// |    |    | 12 |    | 14 |
+				// +    +    +    +----+----+
+				// |    |    |    | 23 | 24 |
+				// +    +----+    +    +----+
+				// |    | 31 |    |    | 34 |
+				// +    +    +----+----+----+
+				// |    |    | 42 | 43 | 44 |
+				// +----+----+----+----+----+
+				setModelData( model, modelTable( [
+					[ { contents: '00', rowspan: 5 }, { contents: '01', rowspan: 3 }, '02', { contents: '03', rowspan: 2 }, '04' ],
+					[ { contents: '12', rowspan: 3 }, '14' ],
+					[ { contents: '23', rowspan: 2 }, '24' ],
+					[ { contents: '31', rowspan: 2 }, '34' ],
+					[ '42', '43', '44' ]
+				] ) );
+
+				table = modelRoot.getChild( 0 );
+			} );
+
+			it( 'should return empty array for no overlapping cells', () => {
+				const cellsInfo = getVerticallyOverlappingCells( table, 0 );
+
+				expect( cellsInfo ).to.be.empty;
+			} );
+
+			it( 'should return overlapping cells info for given overlapRow', () => {
+				const cellsInfo = getVerticallyOverlappingCells( table, 2 );
+
+				expect( cellsInfo[ 0 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) ); // Cell 00
+				expect( cellsInfo[ 1 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) ); // Cell 01
+				expect( cellsInfo[ 2 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 1, 0 ] ) ); // Cell 12
+			} );
+
+			it( 'should ignore rows below startRow', () => {
+				const cellsInfo = getVerticallyOverlappingCells( table, 2, 1 );
+
+				expect( cellsInfo[ 0 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 1, 0 ] ) ); // Cell 12
+			} );
+		} );
+
+		describe( 'getHorizontallyOverlappingCells()', () => {
+			let table;
+
+			beforeEach( () => {
+				// +----+----+----+----+----+
+				// | 00                     |
+				// +----+----+----+----+----+
+				// | 10           | 13      |
+				// +----+----+----+----+----+
+				// | 20 | 21           | 24 |
+				// +----+----+----+----+----+
+				// | 30      | 32      | 34 |
+				// +----+----+----+----+----+
+				// | 40 | 41 | 42 | 43 | 44 |
+				// +----+----+----+----+----+
+				setModelData( model, modelTable( [
+					[ { contents: '00', colspan: 5 } ],
+					[ { contents: '10', colspan: 3 }, { contents: '13', colspan: 2 } ],
+					[ '20', { contents: '21', colspan: 3 }, '24' ],
+					[ { contents: '30', colspan: 2 }, { contents: '32', colspan: 2 }, '34' ],
+					[ '40', '41', '42', '43', '44' ]
+				] ) );
+
+				table = modelRoot.getChild( 0 );
+			} );
+
+			it( 'should return empty array for no overlapping cells', () => {
+				const cellsInfo = getHorizontallyOverlappingCells( table, 0 );
+
+				expect( cellsInfo ).to.be.empty;
+			} );
+
+			it( 'should return overlapping cells info for given overlapColumn', () => {
+				const cellsInfo = getHorizontallyOverlappingCells( table, 2 );
+
+				expect( cellsInfo[ 0 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) ); // Cell 00
+				expect( cellsInfo[ 1 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 1, 0 ] ) ); // Cell 10
+				expect( cellsInfo[ 2 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 2, 1 ] ) ); // Cell 21
+			} );
+		} );
+	} );
+} );

+ 268 - 0
packages/ckeditor5-table/tests/utils/ui/contextualballoon.js

@@ -0,0 +1,268 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import Table from '../../../src/table';
+import TableCellProperties from '../../../src/tablecellproperties';
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import View from '@ckeditor/ckeditor5-ui/src/view';
+import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview';
+
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import { centeredBalloonPositionForLongWidgets } from '@ckeditor/ckeditor5-widget/src/utils';
+import { modelTable } from '../../_utils/utils';
+import { getTableCellsContainingSelection } from '../../../src/utils/selection';
+import { findAncestor } from '../../../src/utils/common';
+import { getBalloonCellPositionData, repositionContextualBalloon } from '../../../src/utils/ui/contextualballoon';
+
+describe( 'table utils', () => {
+	let editor, editingView, balloon, editorElement;
+
+	testUtils.createSinonSandbox();
+
+	beforeEach( () => {
+		editorElement = global.document.createElement( 'div' );
+		global.document.body.appendChild( editorElement );
+
+		return ClassicEditor
+			.create( editorElement, {
+				plugins: [ Table, TableCellProperties, Paragraph ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				editingView = editor.editing.view;
+				balloon = editor.plugins.get( 'ContextualBalloon' );
+			} );
+	} );
+
+	afterEach( () => {
+		editorElement.remove();
+
+		return editor.destroy();
+	} );
+
+	describe( 'ui - contextual balloon', () => {
+		describe( 'repositionContextualBalloon()', () => {
+			describe( 'with respect to the table cell', () => {
+				it( 'should re-position the ContextualBalloon when the table cell is selected', () => {
+					const spy = sinon.spy( balloon, 'updatePosition' );
+					const defaultPositions = BalloonPanelView.defaultPositions;
+					const view = new View();
+
+					view.element = global.document.createElement( 'div' );
+
+					balloon.add( {
+						view,
+						position: {
+							target: global.document.body
+						}
+					} );
+
+					setData( editor.model,
+						'<table><tableRow>' +
+						'<tableCell><paragraph>foo</paragraph></tableCell>' +
+						'<tableCell><paragraph>[bar]</paragraph></tableCell>' +
+						'</tableRow></table>' );
+					repositionContextualBalloon( editor, 'cell' );
+
+					const modelCell = getTableCellsContainingSelection( editor.model.document.selection )[ 0 ];
+					const viewCell = editor.editing.mapper.toViewElement( modelCell );
+
+					sinon.assert.calledWithExactly( spy, {
+						target: editingView.domConverter.viewToDom( viewCell ),
+						positions: [
+							defaultPositions.northArrowSouth,
+							defaultPositions.northArrowSouthWest,
+							defaultPositions.northArrowSouthEast,
+							defaultPositions.southArrowNorth,
+							defaultPositions.southArrowNorthWest,
+							defaultPositions.southArrowNorthEast
+						]
+					} );
+				} );
+
+				it( 'should not engage with no table is selected', () => {
+					const spy = sinon.spy( balloon, 'updatePosition' );
+
+					setData( editor.model, '<paragraph>foo</paragraph>' );
+
+					repositionContextualBalloon( editor, 'cell' );
+					sinon.assert.notCalled( spy );
+				} );
+			} );
+
+			describe( 'with respect to the entire table', () => {
+				it( 'should re-position the ContextualBalloon when the table is selected', () => {
+					const spy = sinon.spy( balloon, 'updatePosition' );
+					const defaultPositions = BalloonPanelView.defaultPositions;
+					const view = new View();
+
+					view.element = global.document.createElement( 'div' );
+
+					balloon.add( {
+						view,
+						position: {
+							target: global.document.body
+						}
+					} );
+
+					setData( editor.model,
+						'<table><tableRow>' +
+						'<tableCell><paragraph>foo</paragraph></tableCell>' +
+						'<tableCell><paragraph>[bar]</paragraph></tableCell>' +
+						'</tableRow></table>' );
+					repositionContextualBalloon( editor, 'table' );
+
+					const modelTable = findAncestor( 'table', editor.model.document.selection.getFirstPosition() );
+					const viewTable = editor.editing.mapper.toViewElement( modelTable );
+
+					sinon.assert.calledWithExactly( spy, {
+						target: editingView.domConverter.viewToDom( viewTable ),
+						positions: [
+							defaultPositions.northArrowSouth,
+							defaultPositions.northArrowSouthWest,
+							defaultPositions.northArrowSouthEast,
+							defaultPositions.southArrowNorth,
+							defaultPositions.southArrowNorthWest,
+							defaultPositions.southArrowNorthEast,
+							centeredBalloonPositionForLongWidgets
+						]
+					} );
+				} );
+
+				it( 'should not engage with no table is selected', () => {
+					const spy = sinon.spy( balloon, 'updatePosition' );
+
+					setData( editor.model, '<paragraph>foo</paragraph>' );
+
+					repositionContextualBalloon( editor, 'table' );
+					sinon.assert.notCalled( spy );
+				} );
+			} );
+		} );
+
+		describe( 'getBalloonCellPositionData()', () => {
+			let modelRoot;
+
+			beforeEach( () => {
+				setData( editor.model, modelTable( [
+					[ '11[]', '12', '13' ],
+					[ '21', '22', '23' ],
+					[ '31', '32', '33' ]
+				] ) );
+
+				modelRoot = editor.model.document.getRoot();
+
+				for ( let row = 0; row < 3; row++ ) {
+					for ( let col = 0; col < 3; col++ ) {
+						const modelCell = modelRoot.getNodeByPath( [ 0, row, col ] );
+						const viewCell = editor.editing.mapper.toViewElement( modelCell );
+						const cellDomElement = editingView.domConverter.viewToDom( viewCell );
+
+						mockBoundingBox( cellDomElement, {
+							top: 100 + row * 10,
+							left: 100 + col * 10,
+							height: 10,
+							width: 10
+						} );
+					}
+				}
+			} );
+
+			it( 'returns the position data', () => {
+				const defaultPositions = BalloonPanelView.defaultPositions;
+				const data = getBalloonCellPositionData( editor );
+				const modelCell = getTableCellsContainingSelection( editor.model.document.selection )[ 0 ];
+				const viewCell = editor.editing.mapper.toViewElement( modelCell );
+
+				expect( data ).to.deep.equal( {
+					target: editingView.domConverter.viewToDom( viewCell ),
+					positions: [
+						defaultPositions.northArrowSouth,
+						defaultPositions.northArrowSouthWest,
+						defaultPositions.northArrowSouthEast,
+						defaultPositions.southArrowNorth,
+						defaultPositions.southArrowNorthWest,
+						defaultPositions.southArrowNorthEast
+					]
+				} );
+			} );
+
+			it( 'returns the position data for multiple cells selected horizontally', () => {
+				selectTableCells( [
+					[ 0, 0 ],
+					[ 0, 1 ]
+				] );
+
+				const data = getBalloonCellPositionData( editor );
+				const targetData = data.target();
+
+				expect( targetData ).to.deep.equal( {
+					top: 100,
+					left: 100,
+					right: 120,
+					bottom: 110,
+					width: 20,
+					height: 10
+				} );
+			} );
+
+			it( 'returns the position data for multiple cells selected vertically', () => {
+				selectTableCells( [
+					[ 0, 1 ],
+					[ 1, 1 ]
+				] );
+
+				const data = getBalloonCellPositionData( editor );
+				const targetData = data.target();
+
+				expect( targetData ).to.deep.equal( {
+					top: 100,
+					left: 110,
+					right: 120,
+					bottom: 120,
+					width: 10,
+					height: 20
+				} );
+			} );
+
+			it( 'returns the position data for multiple cells selected', () => {
+				selectTableCells( [
+					[ 0, 1 ],
+					[ 1, 0 ],
+					[ 1, 1 ]
+				] );
+
+				const data = getBalloonCellPositionData( editor );
+				const targetData = data.target();
+
+				expect( targetData ).to.deep.equal( {
+					top: 100,
+					left: 100,
+					right: 120,
+					bottom: 120,
+					width: 20,
+					height: 20
+				} );
+			} );
+
+			function selectTableCells( paths ) {
+				editor.model.change( writer => {
+					writer.setSelection( paths.map( path => writer.createRangeOn( modelRoot.getNodeByPath( [ 0, ...path ] ) ) ) );
+				} );
+			}
+
+			function mockBoundingBox( element, data ) {
+				testUtils.sinon.stub( element, 'getBoundingClientRect' ).returns( {
+					...data,
+					right: data.left + data.width,
+					bottom: data.top + data.height
+				} );
+			}
+		} );
+	} );
+} );

+ 342 - 0
packages/ckeditor5-table/tests/utils/ui/table-properties.js

@@ -0,0 +1,342 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import Table from '../../../src/table';
+import TableCellProperties from '../../../src/tablecellproperties';
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import View from '@ckeditor/ckeditor5-ui/src/view';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+
+import ToolbarView from '@ckeditor/ckeditor5-ui/src/toolbar/toolbarview';
+
+import {
+	getBorderStyleDefinitions,
+	getBorderStyleLabels,
+	getLocalizedColorErrorText,
+	getLocalizedLengthErrorText,
+	lengthFieldValidator,
+	lineWidthFieldValidator,
+	colorFieldValidator,
+	fillToolbar
+} from '../../../src/utils/ui/table-properties';
+import Collection from '@ckeditor/ckeditor5-utils/src/collection';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+
+describe( 'table utils', () => {
+	let editor, editorElement;
+
+	testUtils.createSinonSandbox();
+
+	beforeEach( () => {
+		editorElement = global.document.createElement( 'div' );
+		global.document.body.appendChild( editorElement );
+
+		return ClassicEditor
+			.create( editorElement, {
+				plugins: [ Table, TableCellProperties, Paragraph ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+			} );
+	} );
+
+	afterEach( () => {
+		editorElement.remove();
+
+		return editor.destroy();
+	} );
+
+	describe( 'ui - table properties', () => {
+		describe( 'getBorderStyleLabels()', () => {
+			it( 'should return labels for different border styles', () => {
+				const t = string => string;
+
+				expect( getBorderStyleLabels( t ) ).to.deep.equal( {
+					none: 'None',
+					solid: 'Solid',
+					dotted: 'Dotted',
+					dashed: 'Dashed',
+					double: 'Double',
+					groove: 'Groove',
+					ridge: 'Ridge',
+					inset: 'Inset',
+					outset: 'Outset'
+				} );
+			} );
+		} );
+
+		describe( 'getLocalizedColorErrorText()', () => {
+			it( 'should return the error text', () => {
+				const t = string => string;
+
+				expect( getLocalizedColorErrorText( t ) ).to.match( /^The color is invalid/ );
+			} );
+		} );
+
+		describe( 'getLocalizedLengthErrorText()', () => {
+			it( 'should return the error text', () => {
+				const t = string => string;
+
+				expect( getLocalizedLengthErrorText( t ) ).to.match( /^The value is invalid/ );
+			} );
+		} );
+
+		describe( 'colorFieldValidator()', () => {
+			it( 'should pass for an empty value', () => {
+				expect( colorFieldValidator( '' ) ).to.be.true;
+			} );
+
+			it( 'should pass for white spaces', () => {
+				expect( colorFieldValidator( '  ' ) ).to.be.true;
+			} );
+
+			it( 'should pass for colors', () => {
+				expect( colorFieldValidator( '#FFF' ) ).to.be.true;
+				expect( colorFieldValidator( '#FFAA11' ) ).to.be.true;
+				expect( colorFieldValidator( 'rgb(255,123,100)' ) ).to.be.true;
+				expect( colorFieldValidator( 'red' ) ).to.be.true;
+			} );
+
+			it( 'should pass for colors surrounded by white spaces', () => {
+				expect( colorFieldValidator( ' #AAA ' ) ).to.be.true;
+				expect( colorFieldValidator( ' rgb(255,123,100) ' ) ).to.be.true;
+			} );
+		} );
+
+		describe( 'lengthFieldValidator()', () => {
+			it( 'should pass for an empty value', () => {
+				expect( lengthFieldValidator( '' ) ).to.be.true;
+			} );
+
+			it( 'should pass for white spaces', () => {
+				expect( lengthFieldValidator( '  ' ) ).to.be.true;
+			} );
+
+			it( 'should pass for lengths', () => {
+				expect( lengthFieldValidator( '1px' ) ).to.be.true;
+				expect( lengthFieldValidator( '12em' ) ).to.be.true;
+				expect( lengthFieldValidator( ' 12em ' ) ).to.be.true;
+				expect( lengthFieldValidator( '45%' ) ).to.be.true;
+			} );
+
+			it( 'should pass for number without unit', () => {
+				expect( lengthFieldValidator( '1' ) ).to.be.true;
+				expect( lengthFieldValidator( '12.1' ) ).to.be.true;
+				expect( lengthFieldValidator( '0.125 ' ) ).to.be.true;
+			} );
+
+			it( 'should not pass for invalid number values', () => {
+				expect( lengthFieldValidator( '.1 ' ) ).to.be.false;
+				expect( lengthFieldValidator( '45. ' ) ).to.be.false;
+				expect( lengthFieldValidator( '45.1.1 ' ) ).to.be.false;
+			} );
+
+			it( 'should pass for lengths surrounded by white spaces', () => {
+				expect( lengthFieldValidator( '3px ' ) ).to.be.true;
+				expect( lengthFieldValidator( ' 12em ' ) ).to.be.true;
+			} );
+		} );
+
+		describe( 'lineWidthFieldValidator()', () => {
+			it( 'should pass for an empty value', () => {
+				expect( lineWidthFieldValidator( '' ) ).to.be.true;
+			} );
+
+			it( 'should pass for white spaces', () => {
+				expect( lineWidthFieldValidator( '  ' ) ).to.be.true;
+			} );
+
+			it( 'should pass for lengths', () => {
+				expect( lineWidthFieldValidator( '1px' ) ).to.be.true;
+				expect( lineWidthFieldValidator( '12em' ) ).to.be.true;
+				expect( lineWidthFieldValidator( ' 12em ' ) ).to.be.true;
+			} );
+
+			it( 'should pass for number without unit', () => {
+				expect( lineWidthFieldValidator( '1' ) ).to.be.true;
+				expect( lineWidthFieldValidator( '12.1' ) ).to.be.true;
+				expect( lineWidthFieldValidator( '0.125 ' ) ).to.be.true;
+			} );
+
+			it( 'should not pass for invalid number values', () => {
+				expect( lineWidthFieldValidator( '.1 ' ) ).to.be.false;
+				expect( lineWidthFieldValidator( '45. ' ) ).to.be.false;
+				expect( lineWidthFieldValidator( '45.1.1 ' ) ).to.be.false;
+				expect( lineWidthFieldValidator( '45%' ) ).to.be.false;
+			} );
+
+			it( 'should pass for lengths surrounded by white spaces', () => {
+				expect( lineWidthFieldValidator( '3px ' ) ).to.be.true;
+				expect( lineWidthFieldValidator( ' 12em ' ) ).to.be.true;
+			} );
+		} );
+
+		describe( 'getBorderStyleDefinitions()', () => {
+			let view, locale, definitions;
+
+			beforeEach( () => {
+				locale = { t: val => val };
+				view = new View( locale );
+				view.set( 'borderStyle', 'none' );
+
+				definitions = getBorderStyleDefinitions( view );
+			} );
+
+			it( 'should return a collection', () => {
+				expect( definitions ).to.be.instanceOf( Collection );
+			} );
+
+			it( 'should create a button definition for each style', () => {
+				expect( definitions.map( ( { type } ) => type ).every( item => item === 'button' ) ).to.be.true;
+			} );
+
+			it( 'should set label of a button for each style', () => {
+				expect( definitions.map( ( { model: { label } } ) => label ) ).to.have.ordered.members( [
+					'None',
+					'Solid',
+					'Dotted',
+					'Dashed',
+					'Double',
+					'Groove',
+					'Ridge',
+					'Inset',
+					'Outset'
+				] );
+			} );
+
+			it( 'should set type of a button for each style', () => {
+				expect( definitions.map( ( { model: { withText } } ) => withText ).every( item => item === true ) ).to.be.true;
+			} );
+
+			it( 'should bind button\'s #isOn to the view #borderStyle property', () => {
+				view.borderStyle = 'dotted';
+
+				expect( definitions.map( ( { model: { isOn } } ) => isOn ) ).to.have.ordered.members( [
+					false,
+					false,
+					true,
+					false,
+					false,
+					false,
+					false,
+					false,
+					false
+				] );
+
+				view.borderStyle = 'inset';
+
+				expect( definitions.map( ( { model: { isOn } } ) => isOn ) ).to.have.ordered.members( [
+					false,
+					false,
+					false,
+					false,
+					false,
+					false,
+					false,
+					true,
+					false
+				] );
+			} );
+		} );
+
+		describe( 'fillToolbar()', () => {
+			let view, locale, toolbar;
+
+			const labels = {
+				first: 'Do something',
+				second: 'Do something else',
+				third: 'Be default'
+			};
+
+			const icons = {
+				first: '<svg viewBox="0 0 21 21" xmlns="http://www.w3.org/2000/svg"><path /></svg>',
+				second: '<svg viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg"><path /></svg>',
+				third: '<svg viewBox="0 0 23 23" xmlns="http://www.w3.org/2000/svg"><path /></svg>'
+			};
+
+			beforeEach( () => {
+				locale = { t: val => val };
+				view = new View( locale );
+				view.set( 'someProperty', 'foo' );
+				toolbar = new ToolbarView( locale );
+
+				fillToolbar( {
+					view, toolbar, icons, labels,
+					propertyName: 'someProperty',
+					nameToValue: name => name === 'third' ? '' : name
+				} );
+			} );
+
+			afterEach( () => {
+				view.destroy();
+			} );
+
+			it( 'should create buttons', () => {
+				expect( toolbar.items ).to.have.length( 3 );
+				expect( toolbar.items.first ).to.be.instanceOf( ButtonView );
+				expect( toolbar.items.get( 1 ) ).to.be.instanceOf( ButtonView );
+				expect( toolbar.items.last ).to.be.instanceOf( ButtonView );
+			} );
+
+			it( 'should set button labels', () => {
+				expect( toolbar.items.first.label ).to.equal( 'Do something' );
+				expect( toolbar.items.get( 1 ).label ).to.equal( 'Do something else' );
+				expect( toolbar.items.last.label ).to.equal( 'Be default' );
+			} );
+
+			it( 'should set button icons', () => {
+				expect( toolbar.items.first.icon ).to.equal( icons.first );
+				expect( toolbar.items.get( 1 ).icon ).to.equal( icons.second );
+				expect( toolbar.items.last.icon ).to.equal( icons.third );
+			} );
+
+			it( 'should set button tooltips', () => {
+				expect( toolbar.items.first.tooltip ).to.equal( labels.first );
+				expect( toolbar.items.get( 1 ).tooltip ).to.equal( labels.second );
+				expect( toolbar.items.last.tooltip ).to.equal( labels.third );
+			} );
+
+			it( 'should bind button #isOn to an observable property', () => {
+				expect( toolbar.items.first.isOn ).to.be.false;
+				expect( toolbar.items.get( 1 ).isOn ).to.be.false;
+				expect( toolbar.items.last.isOn ).to.be.false;
+
+				view.someProperty = 'first';
+
+				expect( toolbar.items.first.isOn ).to.be.true;
+				expect( toolbar.items.get( 1 ).isOn ).to.be.false;
+				expect( toolbar.items.last.isOn ).to.be.false;
+
+				view.someProperty = 'second';
+
+				expect( toolbar.items.first.isOn ).to.be.false;
+				expect( toolbar.items.get( 1 ).isOn ).to.be.true;
+				expect( toolbar.items.last.isOn ).to.be.false;
+
+				view.someProperty = '';
+
+				expect( toolbar.items.first.isOn ).to.be.false;
+				expect( toolbar.items.get( 1 ).isOn ).to.be.false;
+				expect( toolbar.items.last.isOn ).to.be.true;
+			} );
+
+			it( 'should make the buttons change the property value upon execution', () => {
+				toolbar.items.first.fire( 'execute' );
+
+				expect( view.someProperty ).to.equal( 'first' );
+
+				toolbar.items.get( 1 ).fire( 'execute' );
+
+				expect( view.someProperty ).to.equal( 'second' );
+
+				toolbar.items.last.fire( 'execute' );
+
+				expect( view.someProperty ).to.equal( '' );
+			} );
+		} );
+	} );
+} );

+ 1 - 0
packages/ckeditor5-ui/package.json

@@ -27,6 +27,7 @@
     "@ckeditor/ckeditor5-list": "^19.0.1",
     "@ckeditor/ckeditor5-list": "^19.0.1",
     "@ckeditor/ckeditor5-mention": "^19.0.1",
     "@ckeditor/ckeditor5-mention": "^19.0.1",
     "@ckeditor/ckeditor5-paragraph": "^19.1.0",
     "@ckeditor/ckeditor5-paragraph": "^19.1.0",
+    "@ckeditor/ckeditor5-horizontal-line": "^19.0.1",
     "@ckeditor/ckeditor5-typing": "^19.0.1"
     "@ckeditor/ckeditor5-typing": "^19.0.1"
   },
   },
   "engines": {
   "engines": {

+ 29 - 1
packages/ckeditor5-ui/src/toolbar/balloon/balloontoolbar.js

@@ -209,6 +209,8 @@ export default class BalloonToolbar extends Plugin {
 	 */
 	 */
 	show() {
 	show() {
 		const editor = this.editor;
 		const editor = this.editor;
+		const selection = editor.model.document.selection;
+		const schema = editor.model.schema;
 
 
 		// Do not add the toolbar to the balloon stack twice.
 		// Do not add the toolbar to the balloon stack twice.
 		if ( this._balloon.hasView( this.toolbarView ) ) {
 		if ( this._balloon.hasView( this.toolbarView ) ) {
@@ -216,7 +218,13 @@ export default class BalloonToolbar extends Plugin {
 		}
 		}
 
 
 		// Do not show the toolbar when the selection is collapsed.
 		// Do not show the toolbar when the selection is collapsed.
-		if ( editor.model.document.selection.isCollapsed ) {
+		if ( selection.isCollapsed ) {
+			return;
+		}
+
+		// Do not show the toolbar when there is more than one range in the selection and they fully contain object elements.
+		// See https://github.com/ckeditor/ckeditor5/issues/6443.
+		if ( selectionContainsOnlyMultipleObjects( selection, schema ) ) {
 			return;
 			return;
 		}
 		}
 
 
@@ -356,6 +364,26 @@ function getBalloonPositions( isBackward ) {
 	];
 	];
 }
 }
 
 
+// Returns "true" when the selection has multiple ranges and each range contains an object
+// and nothing else.
+//
+// @private
+// @param {module:engine/model/selection~Selection} selection
+// @param {module:engine/model/schema~Schema} schema
+// @returns {Boolean}
+function selectionContainsOnlyMultipleObjects( selection, schema ) {
+	// It doesn't contain multiple objects if there is only one range.
+	if ( selection.rangeCount === 1 ) {
+		return false;
+	}
+
+	return [ ...selection.getRanges() ].every( range => {
+		const element = range.getContainedElement();
+
+		return element && schema.isObject( element );
+	} );
+}
+
 /**
 /**
  * Contextual toolbar configuration. Used by the {@link module:ui/toolbar/balloon/balloontoolbar~BalloonToolbar}
  * Contextual toolbar configuration. Used by the {@link module:ui/toolbar/balloon/balloontoolbar~BalloonToolbar}
  * feature.
  * feature.

+ 11 - 1
packages/ckeditor5-ui/tests/toolbar/balloon/balloontoolbar.js

@@ -14,6 +14,7 @@ import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
 import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
 import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
 import Underline from '@ckeditor/ckeditor5-basic-styles/src/underline';
 import Underline from '@ckeditor/ckeditor5-basic-styles/src/underline';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import HorizontalLine from '@ckeditor/ckeditor5-horizontal-line/src/horizontalline';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 import ResizeObserver from '@ckeditor/ckeditor5-utils/src/dom/resizeobserver';
 import ResizeObserver from '@ckeditor/ckeditor5-utils/src/dom/resizeobserver';
 
 
@@ -55,7 +56,7 @@ describe( 'BalloonToolbar', () => {
 
 
 		return ClassicTestEditor
 		return ClassicTestEditor
 			.create( editorElement, {
 			.create( editorElement, {
-				plugins: [ Paragraph, Bold, Italic, BalloonToolbar ],
+				plugins: [ Paragraph, Bold, Italic, BalloonToolbar, HorizontalLine ],
 				balloonToolbar: [ 'bold', 'italic' ]
 				balloonToolbar: [ 'bold', 'italic' ]
 			} )
 			} )
 			.then( newEditor => {
 			.then( newEditor => {
@@ -374,6 +375,15 @@ describe( 'BalloonToolbar', () => {
 			sinon.assert.notCalled( balloonAddSpy );
 			sinon.assert.notCalled( balloonAddSpy );
 		} );
 		} );
 
 
+		// https://github.com/ckeditor/ckeditor5/issues/6443
+		it( 'should not add the #toolbarView to the #_balloon when the selection contains more than one fully contained object', () => {
+			// This is for multi cell selection in tables.
+			setData( model, '[<horizontalLine></horizontalLine>]<paragraph>foo</paragraph>[<horizontalLine></horizontalLine>]' );
+
+			balloonToolbar.show();
+			sinon.assert.notCalled( balloonAddSpy );
+		} );
+
 		it( 'should not add #toolbarView to the #_balloon when all components inside #toolbarView are disabled', () => {
 		it( 'should not add #toolbarView to the #_balloon when all components inside #toolbarView are disabled', () => {
 			Array.from( balloonToolbar.toolbarView.items ).forEach( item => {
 			Array.from( balloonToolbar.toolbarView.items ).forEach( item => {
 				item.isEnabled = false;
 				item.isEnabled = false;

+ 42 - 42
yarn.lock

@@ -1080,6 +1080,13 @@
     "@nodelib/fs.scandir" "2.1.3"
     "@nodelib/fs.scandir" "2.1.3"
     fastq "^1.6.0"
     fastq "^1.6.0"
 
 
+"@npmcli/move-file@^1.0.1":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@npmcli/move-file/-/move-file-1.0.1.tgz#de103070dac0f48ce49cf6693c23af59c0f70464"
+  integrity sha512-Uv6h1sT+0DrblvIrolFtbvM1FgWm+/sy4B3pvLp67Zys+thcukzS5ekn7HsZFGpWP4Q3fYJCljbWQE/XivMRLw==
+  dependencies:
+    mkdirp "^1.0.4"
+
 "@octokit/auth-token@^2.4.0":
 "@octokit/auth-token@^2.4.0":
   version "2.4.1"
   version "2.4.1"
   resolved "https://registry.yarnpkg.com/@octokit/auth-token/-/auth-token-2.4.1.tgz#375d79eebd03750e6a9b0299e80b8167c7c85655"
   resolved "https://registry.yarnpkg.com/@octokit/auth-token/-/auth-token-2.4.1.tgz#375d79eebd03750e6a9b0299e80b8167c7c85655"
@@ -1178,9 +1185,9 @@
     "@types/node" ">= 8"
     "@types/node" ">= 8"
 
 
 "@octokit/types@^4.0.1":
 "@octokit/types@^4.0.1":
-  version "4.1.3"
-  resolved "https://registry.yarnpkg.com/@octokit/types/-/types-4.1.3.tgz#9a90f2c2dd2d42105c4dbf5cabcb31e4ac960835"
-  integrity sha512-MMBEO1k+fMa44gATPamxdpZmya9ugPBdcxwBIPgnH8/uD/1FWO3hiQFMGJT8diUk7E5UnnkraTFx00oHfUIFAA==
+  version "4.1.5"
+  resolved "https://registry.yarnpkg.com/@octokit/types/-/types-4.1.5.tgz#465872f9f5f5e6bb85c6ed763053486bba9b251b"
+  integrity sha512-/MKeipxtwMorckj1bfP+SKhbzKhqQimT5JuXKGtwnLazqDwj/noYYSPChpLzstVAwF8JVPygJ7L75cKCK47Ikg==
   dependencies:
   dependencies:
     "@types/node" ">= 8"
     "@types/node" ">= 8"
 
 
@@ -1340,9 +1347,9 @@
   integrity sha1-aaI6OtKcrwCX8G7aWbNh7i8GOfY=
   integrity sha1-aaI6OtKcrwCX8G7aWbNh7i8GOfY=
 
 
 "@types/node@*", "@types/node@>= 8":
 "@types/node@*", "@types/node@>= 8":
-  version "14.0.6"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-14.0.6.tgz#f9e178b2da31a4b0ec60b64649e244c31ce18daf"
-  integrity sha512-FbNmu4F67d3oZMWBV6Y4MaPER+0EpE9eIYf2yaHhCWovc1dlXCZkqGX4NLHfVVr6umt20TNBdRzrNJIzIKfdbw==
+  version "14.0.9"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-14.0.9.tgz#43896ab87fc82bda1dfd600cdf44a0c8a64e11d2"
+  integrity sha512-0sCTiXKXELOBxvZLN4krQ0FPOAA7ij+6WwvD0k/PHd9/KAkr4dXel5J9fh6F4x1FwAQILqAWkmpeuS6mjf1iKA==
 
 
 "@types/node@^13.7.0":
 "@types/node@^13.7.0":
   version "13.13.9"
   version "13.13.9"
@@ -2528,10 +2535,11 @@ cacache@^12.0.2:
     y18n "^4.0.0"
     y18n "^4.0.0"
 
 
 cacache@^15.0.3:
 cacache@^15.0.3:
-  version "15.0.3"
-  resolved "https://registry.yarnpkg.com/cacache/-/cacache-15.0.3.tgz#2225c2d1dd8e872339950d6a39c051e0e9334392"
-  integrity sha512-bc3jKYjqv7k4pWh7I/ixIjfcjPul4V4jme/WbjvwGS5LzoPL/GzXr4C5EgPNLO/QEZl9Oi61iGitYEdwcrwLCQ==
+  version "15.0.4"
+  resolved "https://registry.yarnpkg.com/cacache/-/cacache-15.0.4.tgz#b2c23cf4ac4f5ead004fb15a0efb0a20340741f1"
+  integrity sha512-YlnKQqTbD/6iyoJvEY3KJftjrdBYroCbxxYXzhOzsFLWlp6KX4BOlEf4mTx0cMUfVaTS3ENL2QtDWeRYoGLkkw==
   dependencies:
   dependencies:
+    "@npmcli/move-file" "^1.0.1"
     chownr "^2.0.0"
     chownr "^2.0.0"
     fs-minipass "^2.0.0"
     fs-minipass "^2.0.0"
     glob "^7.1.4"
     glob "^7.1.4"
@@ -2542,7 +2550,6 @@ cacache@^15.0.3:
     minipass-flush "^1.0.5"
     minipass-flush "^1.0.5"
     minipass-pipeline "^1.2.2"
     minipass-pipeline "^1.2.2"
     mkdirp "^1.0.3"
     mkdirp "^1.0.3"
-    move-file "^2.0.0"
     p-map "^4.0.0"
     p-map "^4.0.0"
     promise-inflight "^1.0.1"
     promise-inflight "^1.0.1"
     rimraf "^3.0.2"
     rimraf "^3.0.2"
@@ -2684,9 +2691,9 @@ caniuse-api@^3.0.0:
     lodash.uniq "^4.5.0"
     lodash.uniq "^4.5.0"
 
 
 caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001043, caniuse-lite@^1.0.30001061:
 caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001043, caniuse-lite@^1.0.30001061:
-  version "1.0.30001066"
-  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001066.tgz#0a8a58a10108f2b9bf38e7b65c237b12fd9c5f04"
-  integrity sha512-Gfj/WAastBtfxLws0RCh2sDbTK/8rJuSeZMecrSkNGYxPcv7EzblmDGfWQCFEQcSqYE2BRgQiJh8HOD07N5hIw==
+  version "1.0.30001077"
+  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001077.tgz#5d7da6a120b08d9f4fd94823786ecb454aaa5626"
+  integrity sha512-AEzsGvjBJL0lby/87W96PyEvwN0GsYvk5LHsglLg9tW37K4BqvAvoSCdWIE13OZQ8afupqZ73+oL/1LkedN8hA==
 
 
 caseless@~0.12.0:
 caseless@~0.12.0:
   version "0.12.0"
   version "0.12.0"
@@ -4218,9 +4225,9 @@ ee-first@1.1.1:
   integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
   integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
 
 
 electron-to-chromium@^1.3.413:
 electron-to-chromium@^1.3.413:
-  version "1.3.455"
-  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.455.tgz#fd65a3f5db6ffa83eb7c84f16ea9b1b7396f537d"
-  integrity sha512-4lwnxp+ArqOX9hiLwLpwhfqvwzUHFuDgLz4NTiU3lhygUzWtocIJ/5Vix+mWVNE2HQ9aI1k2ncGe5H/0OktMvA==
+  version "1.3.458"
+  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.458.tgz#4ef179f9a0f1d8a658180c09b21bf73edddfc5eb"
+  integrity sha512-OjRkb0igW0oKE2QbzS7vBYrm7xjW/KRTtIj0OGGx57jr/YhBiKb7oZvdbaojqjfCb/7LbnwsbMbdsYjthdJbAw==
 
 
 elegant-spinner@^2.0.0:
 elegant-spinner@^2.0.0:
   version "2.0.0"
   version "2.0.0"
@@ -5490,9 +5497,9 @@ globby@^10.0.1:
     slash "^3.0.0"
     slash "^3.0.0"
 
 
 globby@^11.0.0:
 globby@^11.0.0:
-  version "11.0.0"
-  resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.0.tgz#56fd0e9f0d4f8fb0c456f1ab0dee96e1380bc154"
-  integrity sha512-iuehFnR3xu5wBBtm4xi0dMe92Ob87ufyu/dHwpDYfbcpYpIbrO5OnS8M1vWvrBhSGEJ3/Ecj7gnX76P8YxpPEg==
+  version "11.0.1"
+  resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.1.tgz#9a2bf107a068f3ffeabc49ad702c79ede8cfd357"
+  integrity sha512-iH9RmgwCmUJHi2z5o2l3eTtGBtXek1OYlHrbcxOYugyHLmAsZrPj43OtHThd62Buh/Vv6VyCBD2bdyWcGNQqoQ==
   dependencies:
   dependencies:
     array-union "^2.1.0"
     array-union "^2.1.0"
     dir-glob "^3.0.1"
     dir-glob "^3.0.1"
@@ -6201,9 +6208,9 @@ interpret@1.2.0:
   integrity sha512-mT34yGKMNceBQUoVn7iCDKDntA7SC6gycMAWzGx1z/CMCTV7b2AAtXlo3nRyHZ1FelRkQbQjprHSYGwzLtkVbw==
   integrity sha512-mT34yGKMNceBQUoVn7iCDKDntA7SC6gycMAWzGx1z/CMCTV7b2AAtXlo3nRyHZ1FelRkQbQjprHSYGwzLtkVbw==
 
 
 interpret@^1.0.0:
 interpret@^1.0.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.3.0.tgz#6f637617cf307760be422ab9f4d13cc8a35eca1a"
-  integrity sha512-RDVhhDkycLoSQtE9o0vpK/vOccVDsCbWVzRxArGYnlQLcihPl2loFbPyiH7CM0m2/ijOJU3+PZbnBPaB6NJ1MA==
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e"
+  integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==
 
 
 invariant@^2.2.2, invariant@^2.2.4:
 invariant@^2.2.2, invariant@^2.2.4:
   version "2.2.4"
   version "2.2.4"
@@ -6297,9 +6304,9 @@ is-buffer@^2.0.0, is-buffer@~2.0.3:
   integrity sha512-Kq1rokWXOPXWuaMAqZiJW4XxsmD9zGx9q4aePabbn3qCRGedtH7Cm+zV8WETitMfu1wdh+Rvd6w5egwSngUX2A==
   integrity sha512-Kq1rokWXOPXWuaMAqZiJW4XxsmD9zGx9q4aePabbn3qCRGedtH7Cm+zV8WETitMfu1wdh+Rvd6w5egwSngUX2A==
 
 
 is-callable@^1.1.4, is-callable@^1.1.5:
 is-callable@^1.1.4, is-callable@^1.1.5:
-  version "1.1.5"
-  resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.5.tgz#f7e46b596890456db74e7f6e976cb3273d06faab"
-  integrity sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q==
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.0.tgz#83336560b54a38e35e3a2df7afd0454d691468bb"
+  integrity sha512-pyVD9AaGLxtg6srb2Ng6ynWJqkHU9bEM087AKck0w8QwDarTfNcpIYoU8x8Hv2Icm8u6kFJM18Dag8lyqGkviw==
 
 
 is-color-stop@^1.0.0:
 is-color-stop@^1.0.0:
   version "1.1.0"
   version "1.1.0"
@@ -7878,9 +7885,9 @@ merge-stream@^2.0.0:
   integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==
   integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==
 
 
 merge2@^1.2.3, merge2@^1.3.0:
 merge2@^1.2.3, merge2@^1.3.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.3.0.tgz#5b366ee83b2f1582c48f87e47cf1a9352103ca81"
-  integrity sha512-2j4DAdlBOkiSZIsaXk4mTE3sRS02yBHAtfy127xRV3bQUFqXkjHCHLW6Scv7DwNRbIWNHH8zpnz9zMaKXIdvYw==
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
+  integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
 
 
 mergesort@0.0.1:
 mergesort@0.0.1:
   version "0.0.1"
   version "0.0.1"
@@ -8106,7 +8113,7 @@ mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@^0.5.3, mkd
   dependencies:
   dependencies:
     minimist "^1.2.5"
     minimist "^1.2.5"
 
 
-mkdirp@^1.0.3, mkdirp@~1.0.3:
+mkdirp@^1.0.3, mkdirp@^1.0.4, mkdirp@~1.0.3:
   version "1.0.4"
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
   resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
   integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
   integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
@@ -8169,13 +8176,6 @@ move-concurrently@^1.0.1:
     rimraf "^2.5.4"
     rimraf "^2.5.4"
     run-queue "^1.0.3"
     run-queue "^1.0.3"
 
 
-move-file@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/move-file/-/move-file-2.0.0.tgz#83ffa309b5d7f69d518b28e1333e2ffadf331e3e"
-  integrity sha512-cdkdhNCgbP5dvS4tlGxZbD+nloio9GIimP57EjqFhwLcMjnU+XJKAZzlmg/TN/AK1LuNAdTSvm3CPPP4Xkv0iQ==
-  dependencies:
-    path-exists "^4.0.0"
-
 ms@2.0.0:
 ms@2.0.0:
   version "2.0.0"
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
@@ -8348,9 +8348,9 @@ node-preload@^0.2.1:
     process-on-spawn "^1.0.0"
     process-on-spawn "^1.0.0"
 
 
 node-releases@^1.1.53:
 node-releases@^1.1.53:
-  version "1.1.57"
-  resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.57.tgz#f6754ce225fad0611e61228df3e09232e017ea19"
-  integrity sha512-ZQmnWS7adi61A9JsllJ2gdj2PauElcjnOwTp2O011iGzoakTxUsDGSe+6vD7wXbKdqhSFymC0OSx35aAMhrSdw==
+  version "1.1.58"
+  resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.58.tgz#8ee20eef30fa60e52755fcc0942def5a734fe935"
+  integrity sha512-NxBudgVKiRh/2aPWMgPR7bPTX0VPmGx5QBwCtdHitnqFE5/O8DeBXuIMH1nwNnw/aMo6AjOrpsHzfY3UbUJ7yg==
 
 
 node-sass-tilde-importer@^1.0.2:
 node-sass-tilde-importer@^1.0.2:
   version "1.0.2"
   version "1.0.2"
@@ -9583,9 +9583,9 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.0.2, postcss-value-parser@^
   integrity sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ==
   integrity sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ==
 
 
 postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.16, postcss@^7.0.17, postcss@^7.0.18, postcss@^7.0.2, postcss@^7.0.21, postcss@^7.0.26, postcss@^7.0.27, postcss@^7.0.30, postcss@^7.0.5, postcss@^7.0.6, postcss@^7.0.7:
 postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.16, postcss@^7.0.17, postcss@^7.0.18, postcss@^7.0.2, postcss@^7.0.21, postcss@^7.0.26, postcss@^7.0.27, postcss@^7.0.30, postcss@^7.0.5, postcss@^7.0.6, postcss@^7.0.7:
-  version "7.0.31"
-  resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.31.tgz#332af45cb73e26c0ee2614d7c7fb02dfcc2bd6dd"
-  integrity sha512-a937VDHE1ftkjk+8/7nj/mrjtmkn69xxzJgRETXdAUU+IgOYPQNJF17haGWbeDxSyk++HA14UA98FurvPyBJOA==
+  version "7.0.32"
+  resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.32.tgz#4310d6ee347053da3433db2be492883d62cec59d"
+  integrity sha512-03eXong5NLnNCD05xscnGKGDZ98CyzoqPSMjOe6SuoQY7Z2hIj0Ld1g/O/UQRuOle2aRtiIRDg9tDcTGAkLfKw==
   dependencies:
   dependencies:
     chalk "^2.4.2"
     chalk "^2.4.2"
     source-map "^0.6.1"
     source-map "^0.6.1"