Selaa lähdekoodia

Merge branch 'master' into memory-test

Maciej Gołaszewski 5 vuotta sitten
vanhempi
sitoutus
edb4eb8c73
58 muutettua tiedostoa jossa 1976 lisäystä ja 1875 poistoa
  1. 2 2
      packages/ckeditor5-table/src/commands/insertcolumncommand.js
  2. 2 2
      packages/ckeditor5-table/src/commands/insertrowcommand.js
  3. 2 2
      packages/ckeditor5-table/src/commands/mergecellcommand.js
  4. 2 2
      packages/ckeditor5-table/src/commands/mergecellscommand.js
  5. 2 2
      packages/ckeditor5-table/src/commands/removecolumncommand.js
  6. 2 2
      packages/ckeditor5-table/src/commands/removerowcommand.js
  7. 2 2
      packages/ckeditor5-table/src/commands/selectcolumncommand.js
  8. 2 2
      packages/ckeditor5-table/src/commands/selectrowcommand.js
  9. 7 2
      packages/ckeditor5-table/src/commands/setheadercolumncommand.js
  10. 3 2
      packages/ckeditor5-table/src/commands/setheaderrowcommand.js
  11. 1 1
      packages/ckeditor5-table/src/commands/splitcellcommand.js
  12. 15 2
      packages/ckeditor5-table/src/converters/downcast.js
  13. 1 1
      packages/ckeditor5-table/src/converters/table-layout-post-fixer.js
  14. 1 1
      packages/ckeditor5-table/src/converters/upcasttable.js
  15. 1 1
      packages/ckeditor5-table/src/tablecellproperties.js
  16. 1 1
      packages/ckeditor5-table/src/tablecellproperties/commands/tablecellbordercolorcommand.js
  17. 1 1
      packages/ckeditor5-table/src/tablecellproperties/commands/tablecellborderstylecommand.js
  18. 1 1
      packages/ckeditor5-table/src/tablecellproperties/commands/tablecellborderwidthcommand.js
  19. 1 1
      packages/ckeditor5-table/src/tablecellproperties/commands/tablecellheightcommand.js
  20. 1 1
      packages/ckeditor5-table/src/tablecellproperties/commands/tablecellpaddingcommand.js
  21. 1 1
      packages/ckeditor5-table/src/tablecellproperties/commands/tablecellpropertycommand.js
  22. 1 1
      packages/ckeditor5-table/src/tablecellproperties/commands/tablecellwidthcommand.js
  23. 4 5
      packages/ckeditor5-table/src/tablecellproperties/tablecellpropertiesui.js
  24. 1 1
      packages/ckeditor5-table/src/tablecellproperties/ui/tablecellpropertiesview.js
  25. 10 10
      packages/ckeditor5-table/src/tableclipboard.js
  26. 2 2
      packages/ckeditor5-table/src/tablenavigation.js
  27. 1 1
      packages/ckeditor5-table/src/tableproperties.js
  28. 1 1
      packages/ckeditor5-table/src/tableproperties/commands/tablebordercolorcommand.js
  29. 1 1
      packages/ckeditor5-table/src/tableproperties/commands/tableborderstylecommand.js
  30. 1 1
      packages/ckeditor5-table/src/tableproperties/commands/tableborderwidthcommand.js
  31. 1 1
      packages/ckeditor5-table/src/tableproperties/commands/tableheightcommand.js
  32. 1 1
      packages/ckeditor5-table/src/tableproperties/commands/tablepropertycommand.js
  33. 1 1
      packages/ckeditor5-table/src/tableproperties/commands/tablewidthcommand.js
  34. 3 4
      packages/ckeditor5-table/src/tableproperties/tablepropertiesui.js
  35. 1 1
      packages/ckeditor5-table/src/tableproperties/ui/tablepropertiesview.js
  36. 4 3
      packages/ckeditor5-table/src/tableselection.js
  37. 1 1
      packages/ckeditor5-table/src/tabletoolbar.js
  38. 1 1
      packages/ckeditor5-table/src/tableutils.js
  39. 0 495
      packages/ckeditor5-table/src/utils.js
  40. 1 59
      packages/ckeditor5-table/src/utils/common.js
  41. 271 0
      packages/ckeditor5-table/src/utils/selection.js
  42. 166 2
      packages/ckeditor5-table/src/utils/structure.js
  43. 66 0
      packages/ckeditor5-table/src/utils/table-properties.js
  44. 145 0
      packages/ckeditor5-table/src/utils/ui/contextualballoon.js
  45. 2 136
      packages/ckeditor5-table/src/utils/ui/table-properties.js
  46. 51 0
      packages/ckeditor5-table/src/utils/ui/widget.js
  47. 0 93
      packages/ckeditor5-table/tests/commands/utils.js
  48. 2 2
      packages/ckeditor5-table/tests/manual/tablemocking.js
  49. 1 1
      packages/ckeditor5-table/tests/tablecellproperties/tablecellpropertiesui.js
  50. 1 1
      packages/ckeditor5-table/tests/tablenavigation.js
  51. 1 1
      packages/ckeditor5-table/tests/tableproperties/tablepropertiesui.js
  52. 0 567
      packages/ckeditor5-table/tests/ui/utils.js
  53. 0 450
      packages/ckeditor5-table/tests/utils.js
  54. 94 0
      packages/ckeditor5-table/tests/utils/common.js
  55. 360 0
      packages/ckeditor5-table/tests/utils/selection.js
  56. 119 0
      packages/ckeditor5-table/tests/utils/structure.js
  57. 268 0
      packages/ckeditor5-table/tests/utils/ui/contextualballoon.js
  58. 342 0
      packages/ckeditor5-table/tests/utils/ui/table-properties.js

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

@@ -8,8 +8,8 @@
  */
 
 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.

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

@@ -8,8 +8,8 @@
  */
 
 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.

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

@@ -9,8 +9,8 @@
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
 import TableWalker from '../tablewalker';
-import { isHeadingColumnCell, findAncestor } from './utils';
-import { getTableCellsContainingSelection } from '../utils';
+import { getTableCellsContainingSelection } from '../utils/selection';
+import { findAncestor, isHeadingColumnCell } from '../utils/common';
 
 /**
  * The merge cell command.

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

@@ -9,8 +9,8 @@
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
 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';
 
 /**
  * The merge cells command.

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

@@ -10,8 +10,8 @@
 import Command from '@ckeditor/ckeditor5-core/src/command';
 
 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.

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

@@ -9,8 +9,8 @@
 
 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.

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

@@ -10,8 +10,8 @@
 import Command from '@ckeditor/ckeditor5-core/src/command';
 
 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.

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

@@ -9,8 +9,8 @@
 
 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.

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

@@ -9,8 +9,13 @@
 
 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.

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

@@ -9,8 +9,9 @@
 
 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.

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

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

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

@@ -8,8 +8,7 @@
  */
 
 import TableWalker from './../tablewalker';
-import { toWidgetEditable } from '@ckeditor/ckeditor5-widget/src/utils';
-import { toTableWidget } from '../utils';
+import { toWidget, toWidgetEditable } from '@ckeditor/ckeditor5-widget/src/utils';
 
 /**
  * Model table element to view table element conversion helper.
@@ -307,6 +306,20 @@ export function downcastRemoveRow() {
 	}, { 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.
 //
 // **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
  */
 
-import { createEmptyTableCell, findAncestor, updateNumericAttribute } from './../commands/utils';
 import TableWalker from './../tablewalker';
+import { createEmptyTableCell, findAncestor, updateNumericAttribute } from '../utils/common';
 
 /**
  * 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
  */
 
-import { createEmptyTableCell } from '../commands/utils';
+import { createEmptyTableCell } from '../utils/common';
 
 /**
  * View table element to model table element conversion helper.

+ 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.
  *
  * 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
  * {@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 { getSingleValue } from '../../commands/utils';
+import { getSingleValue } from '../../utils/table-properties';
 
 /**
  * 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 { getSingleValue } from '../../commands/utils';
+import { getSingleValue } from '../../utils/table-properties';
 
 /**
  * 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
  */
 
-import { addDefaultUnitToNumericValue, getSingleValue } from '../../commands/utils';
 import TableCellPropertyCommand from './tablecellpropertycommand';
+import { addDefaultUnitToNumericValue, getSingleValue } from '../../utils/table-properties';
 
 /**
  * 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
  */
 
-import { addDefaultUnitToNumericValue } from '../../commands/utils';
 import TableCellPropertyCommand from './tablecellpropertycommand';
+import { addDefaultUnitToNumericValue } from '../../utils/table-properties';
 
 /**
  * The table cell height command.

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

@@ -7,8 +7,8 @@
  * @module table/tablecellproperties/commands/tablecellpaddingcommand
  */
 
-import { addDefaultUnitToNumericValue, getSingleValue } from '../../commands/utils';
 import TableCellPropertyCommand from './tablecellpropertycommand';
+import { addDefaultUnitToNumericValue, getSingleValue } from '../../utils/table-properties';
 
 /**
  * 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 { getSelectionAffectedTableCells } from '../../utils';
+import { getSelectionAffectedTableCells } from '../../utils/selection';
 
 /**
  * The table cell attribute command.

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

@@ -7,8 +7,8 @@
  * @module table/tablecellproperties/commands/tablecellwidthcommand
  */
 
-import { addDefaultUnitToNumericValue } from '../../commands/utils';
 import TableCellPropertyCommand from './tablecellpropertycommand';
+import { addDefaultUnitToNumericValue } from '../../utils/table-properties';
 
 /**
  * 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 ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
-import { getTableWidgetAncestor } from '../utils';
 import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsidehandler';
 import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
 import TableCellPropertiesView from './ui/tablecellpropertiesview';
 import tableCellProperties from './../../theme/icons/table-cell-properties.svg';
 import {
 	colorFieldValidator,
-	getBalloonCellPositionData,
 	getLocalizedColorErrorText,
 	getLocalizedLengthErrorText,
 	defaultColors,
 	lengthFieldValidator,
-	lineWidthFieldValidator,
-	repositionContextualBalloon
-} from '../ui/utils';
+	lineWidthFieldValidator
+} from '../utils/ui/table-properties';
 import {
 	getLocalizedColorOptions,
 	normalizeColorOptions
 } from '@ckeditor/ckeditor5-ui/src/colorgrid/utils';
 import { debounce } from 'lodash-es';
+import { getTableWidgetAncestor } from '../utils/ui/widget';
+import { getBalloonCellPositionData, repositionContextualBalloon } from '../utils/ui/contextualballoon';
 
 const ERROR_TEXT_TIMEOUT = 500;
 

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

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

+ 10 - 10
packages/ckeditor5-table/src/tableclipboard.js

@@ -12,18 +12,18 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import TableSelection from './tableselection';
 import TableWalker from './tablewalker';
 import {
-	getColumnIndexes,
-	getVerticallyOverlappingCells,
-	getRowIndexes,
-	getSelectionAffectedTableCells,
+	findAncestor
+} from './utils/common';
+import TableUtils from './tableutils';
+import { getColumnIndexes, getRowIndexes, getSelectionAffectedTableCells, isSelectionRectangular } from './utils/selection';
+import {
+	cropTableToDimensions,
 	getHorizontallyOverlappingCells,
-	isSelectionRectangular,
+	getVerticallyOverlappingCells,
 	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.

+ 2 - 2
packages/ckeditor5-table/src/tablenavigation.js

@@ -9,13 +9,13 @@
 
 import TableSelection from './tableselection';
 import TableWalker from './tablewalker';
-import { findAncestor } from './commands/utils';
-import { getSelectedTableCells, getTableCellsContainingSelection } from './utils';
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
 import priorities from '@ckeditor/ckeditor5-utils/src/priorities';
 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.

+ 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.
  *
  * 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
  * {@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 { getSingleValue } from '../../commands/utils';
+import { getSingleValue } from '../../utils/table-properties';
 
 /**
  * The table border color command.

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

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

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

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

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

@@ -8,7 +8,7 @@
  */
 
 import TablePropertyCommand from './tablepropertycommand';
-import { addDefaultUnitToNumericValue } from '../../commands/utils';
+import { addDefaultUnitToNumericValue } from '../../utils/table-properties';
 
 /**
  * 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 { findAncestor } from '../../commands/utils';
+import { findAncestor } from '../../utils/common';
 
 /**
  * The table cell attribute command.

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

@@ -7,8 +7,8 @@
  * @module table/tableproperties/commands/tablewidthcommand
  */
 
-import { addDefaultUnitToNumericValue } from '../../commands/utils';
 import TablePropertyCommand from './tablepropertycommand';
+import { addDefaultUnitToNumericValue } from '../../utils/table-properties';
 
 /**
  * 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 ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
-import { getTableWidgetAncestor } from '../utils';
 import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsidehandler';
 import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
 import TablePropertiesView from './ui/tablepropertiesview';
 import tableProperties from './../../theme/icons/table-properties.svg';
 import {
 	colorFieldValidator,
-	getBalloonTablePositionData,
 	getLocalizedColorErrorText,
 	getLocalizedLengthErrorText,
 	lengthFieldValidator,
 	lineWidthFieldValidator,
-	repositionContextualBalloon,
 	defaultColors
-} from '../ui/utils';
+} from '../utils/ui/table-properties';
 import {
 	getLocalizedColorOptions,
 	normalizeColorOptions
 } from '@ckeditor/ckeditor5-ui/src/colorgrid/utils';
 import { debounce } from 'lodash-es';
+import { getTableWidgetAncestor } from '../utils/ui/widget';
+import { getBalloonTablePositionData, repositionContextualBalloon } from '../utils/ui/contextualballoon';
 
 const ERROR_TEXT_TIMEOUT = 500;
 

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

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

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

@@ -13,9 +13,10 @@ import first from '@ckeditor/ckeditor5-utils/src/first';
 import TableWalker from './tablewalker';
 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, getTableCellsContainingSelection } from './utils/selection';
 
 import '../theme/tableselection.css';
 

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

@@ -8,8 +8,8 @@
  */
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
-import { getSelectedTableWidget, getTableWidgetAncestor } from './utils';
 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).

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

@@ -10,7 +10,7 @@
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 
 import TableWalker from './tablewalker';
-import { createEmptyTableCell, updateNumericAttribute } from './commands/utils';
+import { createEmptyTableCell, updateNumericAttribute } from './utils/common';
 
 /**
  * The table utilities plugin.

+ 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.
  *
@@ -59,62 +57,6 @@ export function createEmptyTableCell( writer, insertPosition, attributes = {} )
 	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.
  *

+ 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;
+}

+ 166 - 2
packages/ckeditor5-table/src/tableselection/croptable.js → packages/ckeditor5-table/src/utils/structure.js

@@ -4,11 +4,11 @@
  */
 
 /**
- * @module table/tableselection/croptable
+ * @module table/utils/structure
  */
 
-import { createEmptyTableCell, updateNumericAttribute } from '../commands/utils';
 import TableWalker from '../tablewalker';
+import { createEmptyTableCell, updateNumericAttribute } from './common';
 
 /**
  * Returns a cropped table according to given dimensions.
@@ -91,6 +91,170 @@ export function cropTableToDimensions( sourceTable, cropDimensions, 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.
  *

+ 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 Collection from '@ckeditor/ckeditor5-utils/src/collection';
 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 { 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 === '';
 
-/**
- * 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
  * 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 );
+}

+ 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;
-		} );
-	} );
-} );

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

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

+ 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 TableCellPropertiesUI from '../../src/tablecellproperties/tablecellpropertiesui';
 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';
 
 describe( 'table cell properties', () => {

+ 1 - 1
packages/ckeditor5-table/tests/tablenavigation.js

@@ -8,7 +8,7 @@ import Table from '../src/table';
 import TableEditing from '../src/tableediting';
 import TableSelection from '../src/tableselection';
 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 Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';

+ 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 TablePropertiesUI from '../../src/tableproperties/tablepropertiesui';
 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( 'TablePropertiesUI', () => {

+ 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( '' );
+			} );
+		} );
+	} );
+} );