8
0
Просмотр исходного кода

Merge branch 'master' into i/6501

# Conflicts:
#	src/commands/removerowcommand.js
Maciej Gołaszewski 5 лет назад
Родитель
Сommit
aed6fc399e
27 измененных файлов с 2401 добавлено и 178 удалено
  1. 1 1
      packages/ckeditor5-table/.travis.yml
  2. 10 2
      packages/ckeditor5-table/docs/features/table.md
  3. 4 1
      packages/ckeditor5-table/lang/contexts.json
  4. 241 0
      packages/ckeditor5-table/src/commands/mergecellscommand.js
  5. 10 86
      packages/ckeditor5-table/src/commands/removerowcommand.js
  6. 65 0
      packages/ckeditor5-table/src/commands/selectcolumncommand.js
  7. 57 0
      packages/ckeditor5-table/src/commands/selectrowcommand.js
  8. 8 0
      packages/ckeditor5-table/src/tableediting.js
  9. 90 13
      packages/ckeditor5-table/src/tableui.js
  10. 101 1
      packages/ckeditor5-table/src/tableutils.js
  11. 2 0
      packages/ckeditor5-table/src/ui/colorinputview.js
  12. 39 21
      packages/ckeditor5-table/src/ui/inserttableview.js
  13. 2 1
      packages/ckeditor5-table/src/ui/utils.js
  14. 21 0
      packages/ckeditor5-table/src/utils.js
  15. 37 0
      packages/ckeditor5-table/tests/_utils/utils.js
  16. 480 0
      packages/ckeditor5-table/tests/commands/mergecellscommand.js
  17. 2 2
      packages/ckeditor5-table/tests/commands/removerowcommand.js
  18. 450 0
      packages/ckeditor5-table/tests/commands/selectcolumncommand.js
  19. 469 0
      packages/ckeditor5-table/tests/commands/selectrowcommand.js
  20. 10 0
      packages/ckeditor5-table/tests/tableediting.js
  21. 74 23
      packages/ckeditor5-table/tests/tableui.js
  22. 213 0
      packages/ckeditor5-table/tests/tableutils.js
  23. 1 0
      packages/ckeditor5-table/tests/ui/colorinputview.js
  24. 3 16
      packages/ckeditor5-table/tests/ui/inserttableview.js
  25. 6 0
      packages/ckeditor5-table/tests/ui/utils.js
  26. 0 10
      packages/ckeditor5-table/theme/formheader.css
  27. 5 1
      packages/ckeditor5-table/theme/table.css

+ 1 - 1
packages/ckeditor5-table/.travis.yml

@@ -9,7 +9,7 @@ services:
 cache:
   yarn: true
 node_js:
-- '8'
+- '10'
 branches:
   except:
   - stable

+ 10 - 2
packages/ckeditor5-table/docs/features/table.md

@@ -335,7 +335,7 @@ The table plugins register the following UI components:
 			<td>The <code>'tableRow'</code> dropdown</td>
 		</tr>
 		<tr>
-			<td>The <code>'mergeTableCells'</code> dropdown</td>
+			<td>The <code>'mergeTableCells'</code> split button</td>
 		</tr>
 		<tr>
 			<td>The <code>'tableProperties'</code> button</td>
@@ -351,7 +351,7 @@ The table plugins register the following UI components:
 #### Toolbars
 
 The {@link module:table/tabletoolbar~TableToolbar} plugin introduces two balloon toolbars for tables.
-* The content toolbar shows up when a table cell is selected and it is anchored to the table. It is possible to {@link module:table/table~TableConfig#contentToolbar configure} its content. Normally, the toolbar contains the table-related tools such as `'tableColumn'`, `'tableRow'`, and `'mergeTableCells'` dropdowns.
+* The content toolbar shows up when a table cell is selected and it is anchored to the table. It is possible to {@link module:table/table~TableConfig#contentToolbar configure} its content. Normally, the toolbar contains the table-related tools such as `'tableColumn'` and `'tableRow'` dropdowns and `'mergeTableCells'` split button.
 * The table toolbar shows up when the whole table is selected, for instance using the widget handler. It is possible to {@link module:table/table~TableConfig#tableToolbar configure} its content.
 
 ### Editor commands
@@ -394,6 +394,14 @@ The {@link module:table/tabletoolbar~TableToolbar} plugin introduces two balloon
 			<td><code>'removeTableRow'</code></td>
 			<td>{@link module:table/commands/removerowcommand~RemoveRowCommand}</td>
 		</tr>
+		<tr>
+			<td><code>'selectTableColumn'</code></td>
+			<td>{@link module:table/commands/selectcolumncommand~SelectColumnCommand}</td>
+		</tr>
+		<tr>
+			<td><code>'selectTableRow'</code></td>
+			<td>{@link module:table/commands/selectrowcommand~SelectRowCommand}</td>
+		</tr>
 		<tr>
 			<td><code>'setTableColumnHeader'</code></td>
 			<td>{@link module:table/commands/setheadercolumncommand~SetHeaderColumnCommand}</td>

+ 4 - 1
packages/ckeditor5-table/lang/contexts.json

@@ -4,11 +4,13 @@
 	"Insert column left": "Label for the insert table column to the left of the current one button.",
 	"Insert column right": "Label for the insert table column to the right of the current one button.",
 	"Delete column": "Label for the delete table column button.",
+	"Select column": "Label for the select table entire column button.",
 	"Column": "Label for the table column dropdown button.",
 	"Header row": "Label for the set/unset table header row button.",
 	"Insert row below": "Label for the insert row below button.",
 	"Insert row above": "Label for the insert row above button.",
 	"Delete row": "Label for the delete table row button.",
+	"Select row": "Label for the select table entire row button.",
 	"Row": "Label for the table row dropdown button.",
 	"Merge cell up": "Label for the merge table cell up button.",
 	"Merge cell right": "Label for the merge table cell right button.",
@@ -53,5 +55,6 @@
 	"Center table": "The label used by assistive technologies describing a button that centers the table.",
 	"Align table to the right": "The label used by assistive technologies describing a button that aligns the table to the right.",
 	"The color is invalid. Try \"#FF0000\" or \"rgb(255,0,0)\" or \"red\".": "The localized error string that can be displayed next to color (background, border) fields that have an invalid value",
-	"The value is invalid. Try \"10px\" or \"2em\" or simply \"2\".": "The localized error string that can be displayed next to length (padding, border width) fields that have an invalid value."
+	"The value is invalid. Try \"10px\" or \"2em\" or simply \"2\".": "The localized error string that can be displayed next to length (padding, border width) fields that have an invalid value.",
+	"Color picker": "The label used by assistive technologies describing a button that opens a color picker, where user can choose a configured color for a certain properties (eg.: background color, color, border-color etc.)."
 }

+ 241 - 0
packages/ckeditor5-table/src/commands/mergecellscommand.js

@@ -0,0 +1,241 @@
+/**
+ * @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/commands/mergecellscommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+import TableWalker from '../tablewalker';
+import { findAncestor, updateNumericAttribute } from './utils';
+import TableUtils from '../tableutils';
+import { getRowIndexes, getSelectedTableCells } from '../utils';
+
+/**
+ * The merge cells command.
+ *
+ * The command is registered by the {@link module:table/tableediting~TableEditing} as `'mergeTableCells'` editor command.
+ *
+ * For example, to merge selected table cells:
+ *
+ *		editor.execute( 'mergeTableCells' );
+ *
+ * @extends module:core/command~Command
+ */
+export default class MergeCellsCommand extends Command {
+	/**
+	 * @inheritDoc
+	 */
+	refresh() {
+		this.isEnabled = canMergeCells( this.editor.model.document.selection, this.editor.plugins.get( TableUtils ) );
+	}
+
+	/**
+	 * Executes the command.
+	 *
+	 * @fires execute
+	 */
+	execute() {
+		const model = this.editor.model;
+		const tableUtils = this.editor.plugins.get( TableUtils );
+
+		model.change( writer => {
+			const selectedTableCells = getSelectedTableCells( model.document.selection );
+
+			// All cells will be merge into the first one.
+			const firstTableCell = selectedTableCells.shift();
+
+			// This prevents the "model-selection-range-intersects" error, caused by removing row selected cells.
+			writer.setSelection( firstTableCell, 'on' );
+
+			// Update target cell dimensions.
+			const { mergeWidth, mergeHeight } = getMergeDimensions( firstTableCell, selectedTableCells, tableUtils );
+			updateNumericAttribute( 'colspan', mergeWidth, firstTableCell, writer );
+			updateNumericAttribute( 'rowspan', mergeHeight, firstTableCell, writer );
+
+			for ( const tableCell of selectedTableCells ) {
+				const tableRow = tableCell.parent;
+				mergeTableCells( tableCell, firstTableCell, writer );
+				removeRowIfEmpty( tableRow, writer );
+			}
+
+			writer.setSelection( firstTableCell, 'in' );
+		} );
+	}
+}
+
+// Properly removes the empty row from a table. Updates the `rowspan` attribute of cells that overlap the removed row.
+//
+// @param {module:engine/model/element~Element} row
+// @param {module:engine/model/writer~Writer} writer
+function removeRowIfEmpty( row, writer ) {
+	if ( row.childCount ) {
+		return;
+	}
+
+	const table = row.parent;
+	const removedRowIndex = table.getChildIndex( row );
+
+	for ( const { cell, row, rowspan } of new TableWalker( table, { endRow: removedRowIndex } ) ) {
+		const overlapsRemovedRow = row + rowspan - 1 >= removedRowIndex;
+
+		if ( overlapsRemovedRow ) {
+			updateNumericAttribute( 'rowspan', rowspan - 1, cell, writer );
+		}
+	}
+
+	writer.remove( row );
+}
+
+// Merges two table cells - will ensure that after merging cells with empty paragraphs the result table cell will only have one paragraph.
+// If one of the merged table cells is empty, the merged table cell will have contents of the non-empty table cell.
+// If both are empty, the merged table cell will have only one empty paragraph.
+//
+// @param {module:engine/model/element~Element} cellBeingMerged
+// @param {module:engine/model/element~Element} targetCell
+// @param {module:engine/model/writer~Writer} writer
+function mergeTableCells( cellBeingMerged, targetCell, writer ) {
+	if ( !isEmpty( cellBeingMerged ) ) {
+		if ( isEmpty( targetCell ) ) {
+			writer.remove( writer.createRangeIn( targetCell ) );
+		}
+
+		writer.move( writer.createRangeIn( cellBeingMerged ), writer.createPositionAt( targetCell, 'end' ) );
+	}
+
+	// Remove merged table cell.
+	writer.remove( cellBeingMerged );
+}
+
+// Checks if the passed table cell contains an empty paragraph.
+//
+// @param {module:engine/model/element~Element} tableCell
+// @returns {Boolean}
+function isEmpty( tableCell ) {
+	return tableCell.childCount == 1 && tableCell.getChild( 0 ).is( 'paragraph' ) && tableCell.getChild( 0 ).isEmpty;
+}
+
+// Checks if the selection contains mergeable cells.
+//
+// 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 (cell "b" not selected creates a gap)
+//   - f, g, h (cell "d" spans over a cell from row of "f" cell - thus creates a gap)
+//
+// @param {module:engine/model/selection~Selection} selection
+// @param {module:table/tableUtils~TableUtils} tableUtils
+// @returns {boolean}
+function canMergeCells( selection, tableUtils ) {
+	const selectedTableCells = getSelectedTableCells( selection );
+
+	if ( selectedTableCells.length < 2 || !areCellInTheSameTableSection( selectedTableCells ) ) {
+		return false;
+	}
+
+	// A valid selection is a fully occupied rectangle composed of table cells.
+	// Below we will calculate the area of a selected table cells and the area of valid selection.
+	// The area of a valid selection is defined by top-left and bottom-right cells.
+	const rows = new Set();
+	const columns = new Set();
+
+	let areaOfSelectedCells = 0;
+
+	for ( const tableCell of selectedTableCells ) {
+		const { row, column } = tableUtils.getCellLocation( tableCell );
+		const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
+		const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
+
+		// Record row & column indexes of current cell.
+		rows.add( row );
+		columns.add( column );
+
+		// For cells that spans over multiple rows add also the last row that this cell spans over.
+		if ( rowspan > 1 ) {
+			rows.add( row + rowspan - 1 );
+		}
+
+		// For cells that spans over multiple columns add also the last column that this cell spans over.
+		if ( colspan > 1 ) {
+			columns.add( column + colspan - 1 );
+		}
+
+		areaOfSelectedCells += ( rowspan * colspan );
+	}
+
+	// We can only merge table cells that are in adjacent rows...
+	const areaOfValidSelection = getBiggestRectangleArea( rows, columns );
+
+	return areaOfValidSelection == areaOfSelectedCells;
+}
+
+// Calculates the area of a maximum rectangle that can span over 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 );
+}
+
+function areCellInTheSameTableSection( tableCells ) {
+	const table = findAncestor( 'table', tableCells[ 0 ] );
+
+	const rowIndexes = getRowIndexes( tableCells );
+	const headingRows = parseInt( table.getAttribute( 'headingRows' ) || 0 );
+
+	const firstCellIsInBody = rowIndexes.first > headingRows - 1;
+	const lastCellIsInBody = rowIndexes.last > headingRows - 1;
+
+	return firstCellIsInBody === lastCellIsInBody;
+}
+
+function getMergeDimensions( firstTableCell, selectedTableCells, tableUtils ) {
+	let maxWidthOffset = 0;
+	let maxHeightOffset = 0;
+
+	for ( const tableCell of selectedTableCells ) {
+		const { row, column } = tableUtils.getCellLocation( tableCell );
+
+		maxWidthOffset = getMaxOffset( tableCell, column, maxWidthOffset, 'colspan' );
+		maxHeightOffset = getMaxOffset( tableCell, row, maxHeightOffset, 'rowspan' );
+	}
+
+	// Update table cell span attribute and merge set selection on a merged contents.
+	const { row: firstCellRow, column: firstCellColumn } = tableUtils.getCellLocation( firstTableCell );
+
+	const mergeWidth = maxWidthOffset - firstCellColumn;
+	const mergeHeight = maxHeightOffset - firstCellRow;
+
+	return { mergeWidth, mergeHeight };
+}
+
+function getMaxOffset( tableCell, start, currentMaxOffset, which ) {
+	const dimensionValue = parseInt( tableCell.getAttribute( which ) || 1 );
+
+	return Math.max( currentMaxOffset, start + dimensionValue );
+}

+ 10 - 86
packages/ckeditor5-table/src/commands/removerowcommand.js

@@ -9,9 +9,8 @@
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
 
-import TableWalker from '../tablewalker';
-import { findAncestor, updateNumericAttribute } from './utils';
-import { getSelectionAffectedTableCells } from '../utils';
+import { findAncestor } from './utils';
+import { getRowIndexes, getSelectionAffectedTableCells } from '../utils';
 
 /**
  * The remove row command.
@@ -62,87 +61,21 @@ export default class RemoveRowCommand extends Command {
 		const columnIndexToFocus = this.editor.plugins.get( 'TableUtils' ).getCellLocation( firstCell ).column;
 
 		model.change( writer => {
-			let cellToFocus;
+			// This prevents the "model-selection-range-intersects" error, caused by removing row selected cells.
+			writer.setSelection( writer.createSelection( table, 'on' ) );
 
-			for ( let i = removedRowIndexes.last; i >= removedRowIndexes.first; i-- ) {
-				const removedRowIndex = i;
-				this._removeRow( removedRowIndex, table, writer );
+			const rowsToRemove = removedRowIndexes.last - removedRowIndexes.first + 1;
 
-				cellToFocus = getCellToFocus( table, removedRowIndex, columnIndexToFocus );
-			}
+			this.editor.plugins.get( 'TableUtils' ).removeRows( table, {
+				at: removedRowIndexes.first,
+				rows: rowsToRemove
+			} );
 
-			const model = this.editor.model;
-			const headingRows = table.getAttribute( 'headingRows' ) || 0;
-
-			if ( headingRows && removedRowIndexes.first < headingRows ) {
-				const newRows = getNewHeadingRowsValue( removedRowIndexes, headingRows );
-
-				// Must be done after the changes in table structure (removing rows).
-				// Otherwise the downcast converter for headingRows attribute will fail. ckeditor/ckeditor5#6391.
-				model.enqueueChange( writer.batch, writer => {
-					updateNumericAttribute( 'headingRows', newRows, table, writer, 0 );
-				} );
-			}
+			const cellToFocus = getCellToFocus( table, removedRowIndexes.first, columnIndexToFocus );
 
 			writer.setSelection( writer.createPositionAt( cellToFocus, 0 ) );
 		} );
 	}
-
-	/**
-	 * Removes a row from the given `table`.
-	 *
-	 * @private
-	 * @param {Number} removedRowIndex Index of the row that should be removed.
-	 * @param {module:engine/model/element~Element} table
-	 * @param {module:engine/model/writer~Writer} writer
-	 */
-	_removeRow( removedRowIndex, table, writer ) {
-		const cellsToMove = new Map();
-		const tableRow = table.getChild( removedRowIndex );
-		const tableMap = [ ...new TableWalker( table, { endRow: removedRowIndex } ) ];
-
-		// Get cells from removed row that are spanned over multiple rows.
-		tableMap
-			.filter( ( { row, rowspan } ) => row === removedRowIndex && rowspan > 1 )
-			.forEach( ( { column, cell, rowspan } ) => cellsToMove.set( column, { cell, rowspanToSet: rowspan - 1 } ) );
-
-		// Reduce rowspan on cells that are above removed row and overlaps removed row.
-		tableMap
-			.filter( ( { row, rowspan } ) => row <= removedRowIndex - 1 && row + rowspan > removedRowIndex )
-			.forEach( ( { cell, rowspan } ) => updateNumericAttribute( 'rowspan', rowspan - 1, cell, writer ) );
-
-		// Move cells to another row.
-		const targetRow = removedRowIndex + 1;
-		const tableWalker = new TableWalker( table, { includeSpanned: true, startRow: targetRow, endRow: targetRow } );
-		let previousCell;
-
-		for ( const { row, column, cell } of [ ...tableWalker ] ) {
-			if ( cellsToMove.has( column ) ) {
-				const { cell: cellToMove, rowspanToSet } = cellsToMove.get( column );
-				const targetPosition = previousCell ?
-					writer.createPositionAfter( previousCell ) :
-					writer.createPositionAt( table.getChild( row ), 0 );
-				writer.move( writer.createRangeOn( cellToMove ), targetPosition );
-				updateNumericAttribute( 'rowspan', rowspanToSet, cellToMove, writer );
-				previousCell = cellToMove;
-			}
-			else {
-				previousCell = cell;
-			}
-		}
-
-		writer.remove( tableRow );
-	}
-}
-
-// Returns a helper object with first and last row index contained in given `referenceCells`.
-function getRowIndexes( referenceCells ) {
-	const allIndexesSorted = referenceCells.map( cell => cell.parent.index ).sort();
-
-	return {
-		first: allIndexesSorted[ 0 ],
-		last: allIndexesSorted[ allIndexesSorted.length - 1 ]
-	};
 }
 
 // Returns a cell that should be focused before removing the row, belonging to the same column as the currently focused cell.
@@ -166,12 +99,3 @@ function getCellToFocus( table, removedRowIndex, columnToFocus ) {
 
 	return cellToFocus;
 }
-
-// Calculates a new heading rows value for removing rows from heading section.
-function getNewHeadingRowsValue( removedRowIndexes, headingRows ) {
-	if ( removedRowIndexes.last < headingRows ) {
-		return headingRows - ( ( removedRowIndexes.last - removedRowIndexes.first ) + 1 );
-	}
-
-	return removedRowIndexes.first;
-}

+ 65 - 0
packages/ckeditor5-table/src/commands/selectcolumncommand.js

@@ -0,0 +1,65 @@
+/**
+ * @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/commands/selectcolumncommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+
+import TableWalker from '../tablewalker';
+import { findAncestor } from './utils';
+import { getSelectionAffectedTableCells } from '../utils';
+
+/**
+ * The select column command.
+ *
+ * The command is registered by {@link module:table/tableediting~TableEditing} as the `'selectTableColumn'` editor command.
+ *
+ * To select the columns containing the selected cells, execute the command:
+ *
+ *		editor.execute( 'selectTableColumn' );
+ *
+ * @extends module:core/command~Command
+ */
+export default class SelectColumnCommand extends Command {
+	/**
+	 * @inheritDoc
+	 */
+	refresh() {
+		const selectedCells = getSelectionAffectedTableCells( this.editor.model.document.selection );
+
+		this.isEnabled = selectedCells.length > 0;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	execute() {
+		const model = this.editor.model;
+		const referenceCells = getSelectionAffectedTableCells( model.document.selection );
+		const firstCell = referenceCells[ 0 ];
+		const lastCell = referenceCells.pop();
+
+		const tableUtils = this.editor.plugins.get( 'TableUtils' );
+		const startLocation = tableUtils.getCellLocation( firstCell );
+		const endLocation = tableUtils.getCellLocation( lastCell );
+
+		const startColumn = Math.min( startLocation.column, endLocation.column );
+		const endColumn = Math.max( startLocation.column, endLocation.column );
+
+		const rangesToSelect = [];
+
+		for ( const cellInfo of new TableWalker( findAncestor( 'table', firstCell ) ) ) {
+			if ( cellInfo.column >= startColumn && cellInfo.column <= endColumn ) {
+				rangesToSelect.push( model.createRangeOn( cellInfo.cell ) );
+			}
+		}
+
+		model.change( writer => {
+			writer.setSelection( rangesToSelect );
+		} );
+	}
+}

+ 57 - 0
packages/ckeditor5-table/src/commands/selectrowcommand.js

@@ -0,0 +1,57 @@
+/**
+ * @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/commands/selectrowcommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+
+import { findAncestor } from './utils';
+import { getRowIndexes, getSelectionAffectedTableCells } from '../utils';
+
+/**
+ * The select row command.
+ *
+ * The command is registered by {@link module:table/tableediting~TableEditing} as the `'selectTableRow'` editor command.
+ *
+ * To select the rows containing the selected cells, execute the command:
+ *
+ *		editor.execute( 'selectTableRow' );
+ *
+ * @extends module:core/command~Command
+ */
+export default class SelectRowCommand extends Command {
+	/**
+	 * @inheritDoc
+	 */
+	refresh() {
+		const selectedCells = getSelectionAffectedTableCells( this.editor.model.document.selection );
+
+		this.isEnabled = selectedCells.length > 0;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	execute() {
+		const model = this.editor.model;
+		const referenceCells = getSelectionAffectedTableCells( model.document.selection );
+		const rowIndexes = getRowIndexes( referenceCells );
+
+		const table = findAncestor( 'table', referenceCells[ 0 ] );
+		const rangesToSelect = [];
+
+		for ( let rowIndex = rowIndexes.first; rowIndex <= rowIndexes.last; rowIndex++ ) {
+			for ( const cell of table.getChild( rowIndex ).getChildren() ) {
+				rangesToSelect.push( model.createRangeOn( cell ) );
+			}
+		}
+
+		model.change( writer => {
+			writer.setSelection( rangesToSelect );
+		} );
+	}
+}

+ 8 - 0
packages/ckeditor5-table/src/tableediting.js

@@ -28,6 +28,9 @@ import RemoveRowCommand from './commands/removerowcommand';
 import RemoveColumnCommand from './commands/removecolumncommand';
 import SetHeaderRowCommand from './commands/setheaderrowcommand';
 import SetHeaderColumnCommand from './commands/setheadercolumncommand';
+import MergeCellsCommand from './commands/mergecellscommand';
+import SelectRowCommand from './commands/selectrowcommand';
+import SelectColumnCommand from './commands/selectcolumncommand';
 import { getTableCellsContainingSelection } from './utils';
 import TableUtils from '../src/tableutils';
 
@@ -131,6 +134,8 @@ export default class TableEditing extends Plugin {
 		editor.commands.add( 'splitTableCellVertically', new SplitCellCommand( editor, { direction: 'vertically' } ) );
 		editor.commands.add( 'splitTableCellHorizontally', new SplitCellCommand( editor, { direction: 'horizontally' } ) );
 
+		editor.commands.add( 'mergeTableCells', new MergeCellsCommand( editor ) );
+
 		editor.commands.add( 'mergeTableCellRight', new MergeCellCommand( editor, { direction: 'right' } ) );
 		editor.commands.add( 'mergeTableCellLeft', new MergeCellCommand( editor, { direction: 'left' } ) );
 		editor.commands.add( 'mergeTableCellDown', new MergeCellCommand( editor, { direction: 'down' } ) );
@@ -139,6 +144,9 @@ export default class TableEditing extends Plugin {
 		editor.commands.add( 'setTableColumnHeader', new SetHeaderColumnCommand( editor ) );
 		editor.commands.add( 'setTableRowHeader', new SetHeaderRowCommand( editor ) );
 
+		editor.commands.add( 'selectTableRow', new SelectRowCommand( editor ) );
+		editor.commands.add( 'selectTableColumn', new SelectColumnCommand( editor ) );
+
 		injectTableLayoutPostFixer( model );
 		injectTableCellRefreshPostFixer( model );
 		injectTableCellParagraphPostFixer( model );

+ 90 - 13
packages/ckeditor5-table/src/tableui.js

@@ -9,6 +9,7 @@
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import { addListToDropdown, createDropdown } from '@ckeditor/ckeditor5-ui/src/dropdown/utils';
+import SplitButtonView from '@ckeditor/ckeditor5-ui/src/dropdown/button/splitbuttonview';
 import Model from '@ckeditor/ckeditor5-ui/src/model';
 import Collection from '@ckeditor/ckeditor5-utils/src/collection';
 
@@ -25,7 +26,7 @@ import tableMergeCellIcon from './../theme/icons/table-merge-cell.svg';
  * * The `'insertTable'` dropdown,
  * * The `'tableColumn'` dropdown,
  * * The `'tableRow'` dropdown,
- * * The `'mergeTableCells'` dropdown.
+ * * The `'mergeTableCells'` split button.
  *
  * The `'tableColumn'`, `'tableRow'` and `'mergeTableCells'` dropdowns work best with {@link module:table/tabletoolbar~TableToolbar}.
  *
@@ -113,6 +114,13 @@ export default class TableUI extends Plugin {
 						commandName: 'removeTableColumn',
 						label: t( 'Delete column' )
 					}
+				},
+				{
+					type: 'button',
+					model: {
+						commandName: 'selectTableColumn',
+						label: t( 'Select column' )
+					}
 				}
 			];
 
@@ -150,6 +158,13 @@ export default class TableUI extends Plugin {
 						commandName: 'removeTableRow',
 						label: t( 'Delete row' )
 					}
+				},
+				{
+					type: 'button',
+					model: {
+						commandName: 'selectTableRow',
+						label: t( 'Select row' )
+					}
 				}
 			];
 
@@ -203,7 +218,7 @@ export default class TableUI extends Plugin {
 				}
 			];
 
-			return this._prepareDropdown( t( 'Merge cells' ), tableMergeCellIcon, options, locale );
+			return this._prepareMergeSplitButtonDropdown( t( 'Merge cells' ), tableMergeCellIcon, options, locale );
 		} );
 	}
 
@@ -219,18 +234,8 @@ export default class TableUI extends Plugin {
 	 */
 	_prepareDropdown( label, icon, options, locale ) {
 		const editor = this.editor;
-
 		const dropdownView = createDropdown( locale );
-		const commands = [];
-
-		// Prepare dropdown list items for list dropdown.
-		const itemDefinitions = new Collection();
-
-		for ( const option of options ) {
-			addListOption( option, editor, commands, itemDefinitions );
-		}
-
-		addListToDropdown( dropdownView, itemDefinitions, editor.ui.componentFactory );
+		const commands = this._fillDropdownWithListOptions( dropdownView, options );
 
 		// Decorate dropdown's button.
 		dropdownView.buttonView.set( {
@@ -251,6 +256,78 @@ export default class TableUI extends Plugin {
 
 		return dropdownView;
 	}
+
+	/**
+	 * Creates a dropdown view with a {@link module:ui/dropdown/button/splitbuttonview~SplitButtonView} for
+	 * merge (and split)–related commands.
+	 *
+	 * @private
+	 * @param {String} label The dropdown button label.
+	 * @param {String} icon An icon for the dropdown button.
+	 * @param {Array.<module:ui/dropdown/utils~ListDropdownItemDefinition>} options The list of options for the dropdown.
+	 * @param {module:utils/locale~Locale} locale
+	 * @returns {module:ui/dropdown/dropdownview~DropdownView}
+	 */
+	_prepareMergeSplitButtonDropdown( label, icon, options, locale ) {
+		const editor = this.editor;
+		const dropdownView = createDropdown( locale, SplitButtonView );
+		const mergeCommandName = 'mergeTableCells';
+
+		this._fillDropdownWithListOptions( dropdownView, options );
+
+		dropdownView.buttonView.set( {
+			label,
+			icon,
+			tooltip: true
+		} );
+
+		// The main part of the split button is bound to the "mergeTableCells" command only.
+		dropdownView.bind( 'isEnabled' ).to( editor.commands.get( mergeCommandName ) );
+
+		// The split button dropdown must be **always** enabled and ready to open no matter the state
+		// of the "mergeTableCells" command. You may not be able to merge multiple cells but you may want
+		// to split them. This is also about mobile devices where multi–cell selection will never work
+		// (that's why "Merge cell right", "Merge cell down", etc. are still there in the first place).
+		dropdownView.buttonView.arrowView.unbind( 'isEnabled' );
+		dropdownView.buttonView.arrowView.isEnabled = true;
+
+		// Merge selected table cells when the main part of the split button is clicked.
+		this.listenTo( dropdownView.buttonView, 'execute', () => {
+			editor.execute( mergeCommandName );
+			editor.editing.view.focus();
+		} );
+
+		// Execute commands for events coming from the list in the dropdown panel.
+		this.listenTo( dropdownView, 'execute', evt => {
+			editor.execute( evt.source.commandName );
+			editor.editing.view.focus();
+		} );
+
+		return dropdownView;
+	}
+
+	/**
+	 * Injects a {@link module:ui/list/listview~ListView} into the passed dropdown with buttons
+	 * which execute editor commands as configured in passed options.
+	 *
+	 * @private
+	 * @param {module:ui/dropdown/dropdownview~DropdownView} dropdownView
+	 * @param {Array.<module:ui/dropdown/utils~ListDropdownItemDefinition>} options The list of options for the dropdown.
+	 * @returns {Array.<module:core/command~Command>} Commands the list options are interacting with.
+	 */
+	_fillDropdownWithListOptions( dropdownView, options ) {
+		const editor = this.editor;
+		const commands = [];
+		const itemDefinitions = new Collection();
+
+		for ( const option of options ) {
+			addListOption( option, editor, commands, itemDefinitions );
+		}
+
+		addListToDropdown( dropdownView, itemDefinitions, editor.ui.componentFactory );
+
+		return commands;
+	}
 }
 
 // Adds an option to a list view.

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

@@ -108,7 +108,7 @@ export default class TableUtils extends Plugin {
 	 *		    |   | f | g |                      |   |   |   |
 	 *		  3 +---+---+---+                      +   +---+---+ 3
 	 *		                                       |   | d | e |
-	 *		                                       +---+---+---+ 4
+	 *		                                       +   +---+---+ 4
 	 *		                                       +   + f | g |
 	 *		                                       +---+---+---+ 5
 	 *
@@ -253,6 +253,60 @@ export default class TableUtils extends Plugin {
 		} );
 	}
 
+	/**
+	 * Removes rows from the given `table`.
+	 *
+	 * This method re-calculates the table geometry including `rowspan` attribute of table cells overlapping removed rows
+	 * and table headings values.
+	 *
+	 *		editor.plugins.get( 'TableUtils' ).removeRows( table, { at: 1, rows: 2 } );
+	 *
+	 * Executing the above code in the context of the table on the left will transform its structure as presented on the right:
+	 *
+	 *		row index
+	 *		    ┌───┬───┬───┐        `at` = 1        ┌───┬───┬───┐
+	 *		  0 │ a │ b │ c │        `rows` = 2      │ a │ b │ c │ 0
+	 *		    │   ├───┼───┤                        │   ├───┼───┤
+	 *		  1 │   │ d │ e │  <-- remove from here  │   │ h │ i │ 1
+	 *		    │   ├───┼───┤        will give:      ├───┼───┼───┤
+	 *		  2 │   │ f │ g │                        │ j │ k │ l │ 2
+	 *		    │   ├───┼───┤                        └───┴───┴───┘
+	 *		  3 │   │ h │ i │
+	 *		    ├───┼───┼───┤
+	 *		  4 │ j │ k │ l │
+	 *		    └───┴───┴───┘
+	 *
+	 * @param {module:engine/model/element~Element} table
+	 * @param {Object} options
+	 * @param {Number} options.at The row index at which the removing rows will start.
+	 * @param {Number} [options.rows=1] The number of rows to remove.
+	 */
+	removeRows( table, options ) {
+		const model = this.editor.model;
+		const first = options.at;
+		const rowsToRemove = options.rows || 1;
+
+		const last = first + rowsToRemove - 1;
+
+		model.change( writer => {
+			for ( let i = last; i >= first; i-- ) {
+				removeRow( table, i, writer );
+			}
+
+			const headingRows = table.getAttribute( 'headingRows' ) || 0;
+
+			if ( headingRows && first < headingRows ) {
+				const newRows = getNewHeadingRowsValue( first, last, headingRows );
+
+				// Must be done after the changes in table structure (removing rows).
+				// Otherwise the downcast converter for headingRows attribute will fail. ckeditor/ckeditor5#6391.
+				model.enqueueChange( writer.batch, writer => {
+					updateNumericAttribute( 'headingRows', newRows, table, writer, 0 );
+				} );
+			}
+		} );
+	}
+
 	/**
 	 * Divides a table cell vertically into several ones.
 	 *
@@ -615,3 +669,49 @@ function breakSpanEvenly( span, numberOfCells ) {
 
 	return { newCellsSpan, updatedSpan };
 }
+
+function removeRow( table, rowIndex, writer ) {
+	const cellsToMove = new Map();
+	const tableRow = table.getChild( rowIndex );
+	const tableMap = [ ...new TableWalker( table, { endRow: rowIndex } ) ];
+
+	// Get cells from removed row that are spanned over multiple rows.
+	tableMap
+		.filter( ( { row, rowspan } ) => row === rowIndex && rowspan > 1 )
+		.forEach( ( { column, cell, rowspan } ) => cellsToMove.set( column, { cell, rowspanToSet: rowspan - 1 } ) );
+
+	// Reduce rowspan on cells that are above removed row and overlaps removed row.
+	tableMap
+		.filter( ( { row, rowspan } ) => row <= rowIndex - 1 && row + rowspan > rowIndex )
+		.forEach( ( { cell, rowspan } ) => updateNumericAttribute( 'rowspan', rowspan - 1, cell, writer ) );
+
+	// Move cells to another row.
+	const targetRow = rowIndex + 1;
+	const tableWalker = new TableWalker( table, { includeSpanned: true, startRow: targetRow, endRow: targetRow } );
+	let previousCell;
+
+	for ( const { row, column, cell } of [ ...tableWalker ] ) {
+		if ( cellsToMove.has( column ) ) {
+			const { cell: cellToMove, rowspanToSet } = cellsToMove.get( column );
+			const targetPosition = previousCell ?
+				writer.createPositionAfter( previousCell ) :
+				writer.createPositionAt( table.getChild( row ), 0 );
+			writer.move( writer.createRangeOn( cellToMove ), targetPosition );
+			updateNumericAttribute( 'rowspan', rowspanToSet, cellToMove, writer );
+			previousCell = cellToMove;
+		} else {
+			previousCell = cell;
+		}
+	}
+
+	writer.remove( tableRow );
+}
+
+// Calculates a new heading rows value for removing rows from heading section.
+function getNewHeadingRowsValue( first, last, headingRows ) {
+	if ( last < headingRows ) {
+		return headingRows - ( last - first + 1 );
+	}
+
+	return first;
+}

+ 2 - 0
packages/ckeditor5-table/src/ui/colorinputview.js

@@ -138,6 +138,7 @@ export default class ColorInputView extends View {
 	 */
 	_createDropdownView() {
 		const locale = this.locale;
+		const t = locale.t;
 		const bind = this.bindTemplate;
 		const colorGrid = this._createColorGrid( locale );
 		const dropdown = createDropdown( locale );
@@ -174,6 +175,7 @@ export default class ColorInputView extends View {
 		} );
 
 		dropdown.buttonView.children.add( colorPreview );
+		dropdown.buttonView.tooltip = t( 'Color picker' );
 
 		dropdown.panelPosition = locale.uiLanguageDirection === 'rtl' ? 'se' : 'sw';
 		dropdown.panelView.children.add( removeColorButton );

+ 39 - 21
packages/ckeditor5-table/src/ui/inserttableview.js

@@ -34,7 +34,7 @@ export default class InsertTableView extends View {
 		 * @readonly
 		 * @member {module:ui/viewcollection~ViewCollection}
 		 */
-		this.items = this.createCollection();
+		this.items = this._createGridCollection();
 
 		/**
 		 * The currently selected number of rows of the new table.
@@ -73,6 +73,9 @@ export default class InsertTableView extends View {
 					attributes: {
 						class: [ 'ck-insert-table-dropdown__grid' ]
 					},
+					on: {
+						'mouseover@.ck-insert-table-dropdown-grid-box': bind.to( 'boxover' )
+					},
 					children: this.items
 				},
 				{
@@ -99,23 +102,15 @@ export default class InsertTableView extends View {
 			}
 		} );
 
-		// Add grid boxes to table selection view.
-		for ( let index = 0; index < 100; index++ ) {
-			const boxView = new TableSizeGridBoxView();
-
-			// Listen to box view 'over' event which indicates that mouse is over this box.
-			boxView.on( 'over', () => {
-				// Translate box index to the row & column index.
-				const row = Math.floor( index / 10 );
-				const column = index % 10;
+		this.on( 'boxover', ( evt, domEvt ) => {
+			const { row, column } = domEvt.target.dataset;
 
-				// As row & column indexes are zero-based transform it to number of selected rows & columns.
-				this.set( 'rows', row + 1 );
-				this.set( 'columns', column + 1 );
+			// As row & column indexes are zero-based transform it to number of selected rows & columns.
+			this.set( {
+				rows: parseInt( row ),
+				columns: parseInt( column )
 			} );
-
-			this.items.add( boxView );
-		}
+		} );
 
 		this.on( 'change:columns', () => {
 			this._highlightGridBoxes();
@@ -162,6 +157,30 @@ export default class InsertTableView extends View {
 			boxView.set( 'isOn', isOn );
 		} );
 	}
+
+	/**
+	 * @private
+	 * @returns {module:ui/viewcollection~ViewCollection} A view collection containing boxes to be placed in a table grid.
+	 */
+	_createGridCollection() {
+		const boxes = [];
+
+		// Add grid boxes to table selection view.
+		for ( let index = 0; index < 100; index++ ) {
+			const row = Math.floor( index / 10 );
+			const column = index % 10;
+
+			boxes.push( new TableSizeGridBoxView( this.locale, row + 1, column + 1 ) );
+		}
+
+		return this.createCollection( boxes );
+	}
+
+	/**
+	 * Fired when the mouse hover over one of the {@link #items child grid boxes}.
+	 *
+	 * @event boxover
+	 */
 }
 
 /**
@@ -175,7 +194,7 @@ class TableSizeGridBoxView extends View {
 	/**
 	 * @inheritDoc
 	 */
-	constructor( locale ) {
+	constructor( locale, row, column ) {
 		super( locale );
 
 		const bind = this.bindTemplate;
@@ -194,10 +213,9 @@ class TableSizeGridBoxView extends View {
 				class: [
 					'ck-insert-table-dropdown-grid-box',
 					bind.if( 'isOn', 'ck-on' )
-				]
-			},
-			on: {
-				mouseover: bind.to( 'over' )
+				],
+				'data-row': row,
+				'data-column': column
 			}
 		} );
 	}

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

@@ -256,7 +256,8 @@ export function fillToolbar( { view, icons, toolbar, labels, propertyName, nameT
 
 		button.set( {
 			label: labels[ name ],
-			icon: icons[ name ]
+			icon: icons[ name ],
+			tooltip: labels[ name ]
 		} );
 
 		button.bind( 'isOn' ).to( view, propertyName, value => {

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

@@ -137,6 +137,27 @@ export function getSelectionAffectedTableCells( selection ) {
 	return getTableCellsContainingSelection( selection );
 }
 
+/**
+ * Returns a helper object with `first` and `last` row index contained in given `tableCells`.
+ *
+ *		const selectedTableCells = getSelectedTableCells( editor.model.document.selection );
+ *
+ *		const { first, last } = getRowIndexes( selectedTableCells );
+ *
+ *		console.log( `Selected rows ${ first } to ${ last }` );
+ *
+ * @package {Array.<module:engine/model/element~Element>}
+ * @returns {Object} Returns an object with `first` and `last` table row indexes.
+ */
+export function getRowIndexes( tableCells ) {
+	const allIndexesSorted = tableCells.map( cell => cell.parent.index ).sort();
+
+	return {
+		first: allIndexesSorted[ 0 ],
+		last: allIndexesSorted[ allIndexesSorted.length - 1 ]
+	};
+}
+
 function sortRanges( rangesIterator ) {
 	return Array.from( rangesIterator ).sort( compareRangeOrder );
 }

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

@@ -340,6 +340,43 @@ export function assertTableCellStyle( editor, tableCellStyle ) {
  * The above call will check if table has second column selected (assuming no spans).
  *
  * **Note**: This function operates on child indexes - not rows/columns.
+ *
+ * Examples:
+ *
+ * 		+----+----+----+----+
+ * 		| 00 | 01 | 02 | 03 |
+ * 		+----+    +----+----+
+ * 		|[10]|    |[12]|[13]|
+ * 		+----+----+----+----+
+ * 		| 20 | 21 | 22 | 23 |
+ * 		+----+----+----+----+
+ * 		| 30 | 31      | 33 |
+ * 		+----+----+----+----+
+ *
+ * 		assertSelectedCells( model, [
+ *			[ 0, 0, 0, 0 ],
+ * 			[ 1,    1, 1 ],
+ * 			[ 0, 0, 0, 0 ],
+ *			[ 0, 0,    0 ]
+ *		] );
+ *
+ * 		+----+----+----+----+
+ * 		| 00 |[01]| 02 | 03 |
+ * 		+----+    +----+----+
+ * 		| 10 |    | 12 | 13 |
+ * 		+----+----+----+----+
+ * 		| 20 |[21]| 22 | 23 |
+ * 		+----+----+----+----+
+ * 		| 30 |[31]     | 33 |
+ * 		+----+----+----+----+
+ *
+ * 		assertSelectedCells( model, [
+ *			[ 0, 1, 0, 0 ],
+ * 			[ 0,    0, 0 ],
+ * 			[ 0, 1, 0, 0 ],
+ *			[ 0, 1,    0 ]
+ *		] );
+ *
  */
 export function assertSelectedCells( model, tableMap ) {
 	const tableIndex = 0;

+ 480 - 0
packages/ckeditor5-table/tests/commands/mergecellscommand.js

@@ -0,0 +1,480 @@
+/**
+ * @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 { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import MergeCellsCommand from '../../src/commands/mergecellscommand';
+import { modelTable } from '../_utils/utils';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
+import TableSelection from '../../src/tableselection';
+import TableEditing from '../../src/tableediting';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+describe( 'MergeCellsCommand', () => {
+	let editor, model, command, root, tableSelection;
+
+	beforeEach( async () => {
+		editor = await ModelTestEditor.create( {
+			plugins: [ Paragraph, TableEditing, TableSelection ]
+		} );
+
+		model = editor.model;
+		root = model.document.getRoot( 'main' );
+		tableSelection = editor.plugins.get( TableSelection );
+
+		command = new MergeCellsCommand( editor );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	describe( 'isEnabled', () => {
+		it( 'should be false if collapsed selection in table cell', () => {
+			setData( model, modelTable( [
+				[ '00[]', '01' ]
+			] ) );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be false if only one table cell is selected', () => {
+			setData( model, modelTable( [
+				[ '00', '01' ]
+			] ) );
+
+			selectNodes( [ [ 0, 0, 0 ] ] );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be true if at least two adjacent table cells are selected', () => {
+			setData( model, modelTable( [
+				[ '00', '01' ]
+			] ) );
+
+			selectNodes( [ [ 0, 0, 0 ], [ 0, 0, 1 ] ] );
+
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be true if many table cells are selected', () => {
+			setData( model, modelTable( [
+				[ '00', '01', '02', '03' ],
+				[ '10', '11', '12', '13' ],
+				[ '20', '21', '22', '23' ],
+				[ '30', '31', '32', '33' ]
+			] ) );
+
+			selectNodes( [
+				[ 0, 0, 1 ], [ 0, 0, 2 ],
+				[ 0, 1, 1 ], [ 0, 1, 2 ],
+				[ 0, 2, 1 ], [ 0, 2, 2 ],
+				[ 0, 3, 1 ], [ 0, 3, 2 ]
+			] );
+
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be false if at least one table cell is not selected from an area', () => {
+			setData( model, modelTable( [
+				[ '00', '01', '02', '03' ],
+				[ '10', '11', '12', '13' ],
+				[ '20', '21', '22', '23' ],
+				[ '30', '31', '32', '33' ]
+			] ) );
+
+			selectNodes( [
+				[ 0, 0, 1 ], [ 0, 0, 2 ],
+				[ 0, 1, 2 ], // one table cell not selected from this row
+				[ 0, 2, 1 ], [ 0, 2, 2 ],
+				[ 0, 3, 1 ], [ 0, 3, 2 ]
+			] );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be false if table cells are not in adjacent rows', () => {
+			setData( model, modelTable( [
+				[ '00', '01' ],
+				[ '10', '11' ]
+			] ) );
+
+			selectNodes( [
+				[ 0, 1, 0 ],
+				[ 0, 0, 1 ]
+			] );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be false if table cells are not in adjacent columns', () => {
+			setData( model, modelTable( [
+				[ '00', '01', '02' ]
+			] ) );
+
+			selectNodes( [ [ 0, 0, 0 ], [ 0, 0, 2 ] ] );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be false if any table cell with colspan attribute extends over selection area', () => {
+			setData( model, modelTable( [
+				[ '00', { colspan: 2, contents: '01' } ],
+				[ '10', '11', '12' ]
+			] ) );
+
+			selectNodes( [
+				[ 0, 0, 0 ], [ 0, 0, 1 ],
+				[ 0, 1, 0 ], [ 0, 1, 1 ]
+			] );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be true if none table cell with colspan attribute extends over selection area', () => {
+			setData( model, modelTable( [
+				[ '00', { colspan: 2, contents: '01' } ],
+				[ '10', '11', '12' ]
+			] ) );
+
+			selectNodes( [
+				[ 0, 0, 0 ], [ 0, 0, 1 ],
+				[ 0, 1, 0 ], [ 0, 1, 1 ],
+				[ 0, 1, 2 ]
+			] );
+
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be true if first table cell is inside selection area', () => {
+			setData( model, modelTable( [
+				[ { colspan: 2, rowspan: 2, contents: '00' }, '02', '03' ],
+				[ '12', '13' ]
+			] ) );
+
+			selectNodes( [
+				[ 0, 0, 0 ], [ 0, 0, 1 ],
+				[ 0, 1, 0 ]
+			] );
+
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be false if any table cell with rowspan attribute extends over selection area', () => {
+			setData( model, modelTable( [
+				[ '00', { rowspan: 2, contents: '01' } ],
+				[ '10' ]
+			] ) );
+
+			selectNodes( [ [ 0, 0, 0 ], [ 0, 0, 1 ] ] );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be true if none table cell with rowspan attribute extends over selection area', () => {
+			setData( model, modelTable( [
+				[ '00', { rowspan: 2, contents: '01' } ],
+				[ '10' ]
+			] ) );
+
+			selectNodes( [
+				[ 0, 0, 0 ], [ 0, 0, 1 ],
+				[ 0, 1, 0 ]
+			] );
+
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be false if not in a cell', () => {
+			setData( model, '<paragraph>11[]</paragraph>' );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be false if selection has cells from header and body sections', () => {
+			setData( model, modelTable( [
+				[ '00[]', '01' ],
+				[ '10', '11' ]
+			], { headingRows: 1 } ) );
+
+			tableSelection._setCellSelection(
+				root.getNodeByPath( [ 0, 0, 0 ] ),
+				root.getNodeByPath( [ 0, 1, 0 ] )
+			);
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+	} );
+
+	describe( 'execute()', () => {
+		it( 'should merge simple table cell selection', () => {
+			setData( model, modelTable( [
+				[ '[]00', '01' ]
+			] ) );
+
+			tableSelection._setCellSelection(
+				root.getNodeByPath( [ 0, 0, 0 ] ),
+				root.getNodeByPath( [ 0, 0, 1 ] )
+			);
+
+			command.execute();
+
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ { colspan: 2, contents: '<paragraph>[00</paragraph><paragraph>01]</paragraph>' } ]
+			] ) );
+		} );
+
+		it( 'should merge selection with a cell with rowspan in the selection', () => {
+			setData( model, modelTable( [
+				[ '[]00', '01', '02' ],
+				[ '10', { contents: '11', rowspan: 2 }, '12' ],
+				[ '20', '22' ]
+			] ) );
+
+			tableSelection._setCellSelection(
+				root.getNodeByPath( [ 0, 1, 0 ] ),
+				root.getNodeByPath( [ 0, 2, 1 ] )
+			);
+
+			command.execute();
+
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ '00', '01', '02' ],
+				[ {
+					colspan: 3,
+					contents: '<paragraph>[10</paragraph><paragraph>11</paragraph><paragraph>12</paragraph>' +
+						'<paragraph>20</paragraph><paragraph>22]</paragraph>'
+				} ]
+			] ) );
+		} );
+
+		it( 'should merge selection with a cell with rowspan in the selection (reverse selection)', () => {
+			setData( model, modelTable( [
+				[ '[]00', '01', '02' ],
+				[ '10', { contents: '11', rowspan: 2 }, '12' ],
+				[ '20', '22' ]
+			] ) );
+
+			tableSelection._setCellSelection(
+				root.getNodeByPath( [ 0, 2, 1 ] ),
+				root.getNodeByPath( [ 0, 1, 0 ] )
+			);
+
+			command.execute();
+
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ '00', '01', '02' ],
+				[ {
+					colspan: 3,
+					contents: '<paragraph>[10</paragraph><paragraph>11</paragraph><paragraph>12</paragraph>' +
+						'<paragraph>20</paragraph><paragraph>22]</paragraph>'
+				} ]
+			] ) );
+		} );
+
+		it( 'should merge selection inside a table (properly calculate target rowspan/colspan)', () => {
+			setData( model, modelTable( [
+				[ '[]00', '01', '02', '03' ],
+				[ '10', '11', { contents: '12', rowspan: 2 }, '13' ],
+				[ '20', '21', '23' ],
+				[ '30', '31', '32', '33' ]
+			] ) );
+
+			tableSelection._setCellSelection(
+				root.getNodeByPath( [ 0, 2, 1 ] ),
+				root.getNodeByPath( [ 0, 1, 2 ] )
+			);
+
+			command.execute();
+
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ '00', '01', '02', '03' ],
+				[ '10', {
+					colspan: 2,
+					rowspan: 2,
+					contents: '<paragraph>[11</paragraph><paragraph>12</paragraph><paragraph>21]</paragraph>'
+				}, '13' ],
+				[ '20', '23' ],
+				[ '30', '31', '32', '33' ]
+			] ) );
+		} );
+
+		it( 'should merge table cells - extend colspan attribute', () => {
+			setData( model, modelTable( [
+				[ { colspan: 2, contents: '00' }, '02', '03' ],
+				[ '10', '11', '12', '13' ]
+			] ) );
+
+			selectNodes( [
+				[ 0, 0, 0 ], [ 0, 0, 1 ],
+				[ 0, 1, 0 ], [ 0, 1, 1 ], [ 0, 1, 2 ]
+			] );
+
+			command.execute();
+
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ {
+					colspan: 3,
+					rowspan: 2,
+					contents: '<paragraph>[00</paragraph>' +
+						'<paragraph>02</paragraph>' +
+						'<paragraph>10</paragraph>' +
+						'<paragraph>11</paragraph>' +
+						'<paragraph>12]</paragraph>'
+				}, '03' ],
+				[ '13' ]
+			] ) );
+		} );
+
+		it( 'should merge to a single paragraph - every cell is empty', () => {
+			setData( model, modelTable( [
+				[ '[]', '' ]
+			] ) );
+
+			selectNodes( [ [ 0, 0, 0 ], [ 0, 0, 1 ] ] );
+
+			command.execute();
+
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ { colspan: 2, contents: '<paragraph>[]</paragraph>' } ]
+			] ) );
+		} );
+
+		it( 'should merge to a single paragraph - merged cell is empty', () => {
+			setData( model, modelTable( [
+				[ 'foo', '' ]
+			] ) );
+
+			selectNodes( [ [ 0, 0, 0 ], [ 0, 0, 1 ] ] );
+
+			command.execute();
+
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ { colspan: 2, contents: '<paragraph>[foo]</paragraph>' } ]
+			] ) );
+		} );
+
+		it( 'should merge to a single paragraph - cell to which others are merged is empty', () => {
+			setData( model, modelTable( [
+				[ '', 'foo' ]
+			] ) );
+
+			selectNodes( [ [ 0, 0, 0 ], [ 0, 0, 1 ] ] );
+
+			command.execute();
+
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ { colspan: 2, contents: '<paragraph>[foo]</paragraph>' } ]
+			] ) );
+		} );
+
+		it( 'should not merge empty blocks other then <paragraph> to a single block', () => {
+			model.schema.register( 'block', {
+				allowWhere: '$block',
+				allowContentOf: '$block',
+				isBlock: true
+			} );
+
+			setData( model, modelTable( [
+				[ '<block>[]</block>', '<block></block>' ]
+			] ) );
+
+			selectNodes( [ [ 0, 0, 0 ], [ 0, 0, 1 ] ] );
+
+			command.execute();
+
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ { colspan: 2, contents: '<block>[</block><block>]</block>' } ]
+			] ) );
+		} );
+
+		describe( 'removing empty row', () => {
+			it( 'should remove empty row if merging all table cells from that row', () => {
+				setData( model, modelTable( [
+					[ '00' ],
+					[ '10' ],
+					[ '20' ]
+				] ) );
+
+				selectNodes( [
+					[ 0, 0, 0 ],
+					[ 0, 1, 0 ],
+					[ 0, 2, 0 ]
+				] );
+
+				command.execute();
+
+				assertEqualMarkup( getData( model ), modelTable( [
+					[
+						'<paragraph>[00</paragraph><paragraph>10</paragraph><paragraph>20]</paragraph>'
+					]
+				] ) );
+			} );
+
+			it( 'should decrease rowspan if cell overlaps removed row', () => {
+				setData( model, modelTable( [
+					[ '00', { rowspan: 2, contents: '01' }, { rowspan: 3, contents: '02' } ],
+					[ '10' ],
+					[ '20', '21' ]
+				] ) );
+
+				selectNodes( [
+					[ 0, 0, 0 ],
+					[ 0, 1, 0 ],
+					[ 0, 2, 0 ]
+				] );
+
+				command.execute();
+
+				assertEqualMarkup( getData( model ), modelTable( [
+					[
+						{ rowspan: 2, contents: '<paragraph>[00</paragraph><paragraph>10</paragraph><paragraph>20]</paragraph>' },
+						'01',
+						{ rowspan: 2, contents: '02' }
+					],
+					[ '21' ]
+				] ) );
+			} );
+
+			it( 'should not decrease rowspan if cell from previous row does not overlaps removed row', () => {
+				setData( model, modelTable( [
+					[ '00', { rowspan: 2, contents: '01' } ],
+					[ '10' ],
+					[ '20', '21' ],
+					[ '30', '31' ]
+				] ) );
+
+				selectNodes( [
+					[ 0, 2, 0 ], [ 0, 2, 1 ],
+					[ 0, 3, 0 ], [ 0, 3, 1 ]
+				] );
+
+				command.execute();
+
+				assertEqualMarkup( getData( model ), modelTable( [
+					[ '00', { rowspan: 2, contents: '01' } ],
+					[ '10' ],
+					[
+						{
+							colspan: 2,
+							contents: '<paragraph>[20</paragraph><paragraph>21</paragraph>' +
+								'<paragraph>30</paragraph><paragraph>31]</paragraph>'
+						}
+					]
+				] ) );
+			} );
+		} );
+	} );
+
+	function selectNodes( paths ) {
+		model.change( writer => {
+			const ranges = paths.map( path => writer.createRangeOn( root.getNodeByPath( path ) ) );
+
+			writer.setSelection( ranges );
+		} );
+	}
+} );

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

@@ -464,7 +464,7 @@ describe( 'RemoveRowCommand', () => {
 			setData( model, modelTable( [
 				[ { rowspan: 3, contents: '[]00' }, { rowspan: 2, contents: '01' }, '02' ],
 				[ '12' ],
-				[ '22' ],
+				[ '21', '22' ],
 				[ '30', '31', '32' ]
 			] ) );
 
@@ -472,7 +472,7 @@ describe( 'RemoveRowCommand', () => {
 
 			assertEqualMarkup( getData( model ), modelTable( [
 				[ { rowspan: 2, contents: '[]00' }, '01', '12' ],
-				[ '22' ],
+				[ '21', '22' ],
 				[ '30', '31', '32' ]
 			] ) );
 		} );

+ 450 - 0
packages/ckeditor5-table/tests/commands/selectcolumncommand.js

@@ -0,0 +1,450 @@
+/**
+ * @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 VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import SelectColumnCommand from '../../src/commands/selectcolumncommand';
+import TableSelection from '../../src/tableselection';
+import { assertSelectedCells, defaultConversion, defaultSchema, modelTable } from '../_utils/utils';
+
+describe( 'SelectColumnCommand', () => {
+	let editor, model, modelRoot, command, tableSelection;
+
+	beforeEach( () => {
+		return VirtualTestEditor.create( { plugins: [ TableSelection ] } )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				modelRoot = model.document.getRoot();
+				command = new SelectColumnCommand( editor );
+				tableSelection = editor.plugins.get( TableSelection );
+
+				defaultSchema( model.schema );
+				defaultConversion( editor.conversion );
+			} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	describe( 'isEnabled', () => {
+		it( 'should be true if the selection is inside table cell', () => {
+			setData( model, modelTable( [
+				[ '00[]', '01' ],
+				[ '10', '11' ]
+			] ) );
+
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be true if the selection contains multiple cells', () => {
+			setData( model, modelTable( [
+				[ '00', '01' ],
+				[ '10', '11' ],
+				[ '20', '21' ]
+			] ) );
+
+			tableSelection._setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 0, 1 ] )
+			);
+
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be false if the selection is outside a table', () => {
+			setData( model, '<paragraph>11[]</paragraph>' );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+	} );
+
+	describe( 'execute()', () => {
+		it( 'should select a column of a table cell with a collapsed selection', () => {
+			setData( model, modelTable( [
+				[ '00', '01', '02' ],
+				[ '10', '[]11', '12' ],
+				[ '20', '21', '22' ]
+			] ) );
+
+			command.execute();
+
+			assertSelectedCells( model, [
+				[ 0, 1, 0 ],
+				[ 0, 1, 0 ],
+				[ 0, 1, 0 ]
+			] );
+		} );
+
+		it( 'should select a column of table cell with a collapsed selection in first table cell', () => {
+			setData( model, modelTable( [
+				[ '[]00', '01' ],
+				[ '10', '11' ],
+				[ '20', '21' ]
+			] ) );
+
+			command.execute();
+
+			assertSelectedCells( model, [
+				[ 1, 0 ],
+				[ 1, 0 ],
+				[ 1, 0 ]
+			] );
+		} );
+
+		it( 'should select a column of table cell with a collapsed selection in last cell in the first column', () => {
+			setData( model, modelTable( [
+				[ '00', '01' ],
+				[ '10', '11' ],
+				[ '20[]', '21' ]
+			] ) );
+
+			command.execute();
+
+			assertSelectedCells( model, [
+				[ 1, 0 ],
+				[ 1, 0 ],
+				[ 1, 0 ]
+			] );
+		} );
+
+		it( 'should select a column of table cell with collapsed selection in the first cell of the last column', () => {
+			setData( model, modelTable( [
+				[ '00', '01[]' ],
+				[ '10', '11' ]
+			] ) );
+
+			command.execute();
+
+			assertSelectedCells( model, [
+				[ 0, 1 ],
+				[ 0, 1 ]
+			] );
+		} );
+
+		describe( 'with col-spanned cells', () => {
+			beforeEach( () => {
+				// +----+----+----+----+
+				// | 00                |
+				// +----+----+----+----+
+				// | 10           | 13 |
+				// +----+----+----+----+
+				// | 20      | 22 | 23 |
+				// +----+----+----+----+
+				// | 30 | 31      | 33 |
+				// +----+----+----+----+
+				// | 40 | 41 | 42 | 43 |
+				// +----+----+----+----+
+				setData( model, modelTable( [
+					[ { colspan: 4, contents: '00' } ],
+					[ { colspan: 3, contents: '10' }, '13' ],
+					[ { colspan: 2, contents: '20' }, '22', '23' ],
+					[ '30', { colspan: 2, contents: '31' }, '33' ],
+					[ '40', '41', '42', '43' ]
+				] ) );
+			} );
+
+			it( 'should select only one column if only one cell is selected', () => {
+				// Selection in cell 10.
+				tableSelection._setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
+					modelRoot.getNodeByPath( [ 0, 1, 0 ] )
+				);
+
+				/* eslint-disable no-multi-spaces */
+				assertSelectedCells( model, [
+					[ 0          ],
+					[ 1,       0 ],
+					[ 0,    0, 0 ],
+					[ 0, 0,    0 ],
+					[ 0, 0, 0, 0 ]
+				] );
+				/* eslint-enable no-multi-spaces */
+
+				command.execute();
+
+				/* eslint-disable no-multi-spaces */
+				assertSelectedCells( model, [
+					[ 1          ],
+					[ 1,       0 ],
+					[ 1,    0, 0 ],
+					[ 1, 0,    0 ],
+					[ 1, 0, 0, 0 ]
+				] );
+				/* eslint-enable no-multi-spaces */
+			} );
+
+			it( 'should not select col-spanned columns that start in other column', () => {
+				// Selection in cell 42.
+				tableSelection._setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 4, 2 ] ),
+					modelRoot.getNodeByPath( [ 0, 4, 2 ] )
+				);
+
+				/* eslint-disable no-multi-spaces */
+				assertSelectedCells( model, [
+					[ 0          ],
+					[ 0,       0 ],
+					[ 0,    0, 0 ],
+					[ 0, 0,    0 ],
+					[ 0, 0, 1, 0 ]
+				] );
+				/* eslint-enable no-multi-spaces */
+
+				command.execute();
+
+				/* eslint-disable no-multi-spaces */
+				assertSelectedCells( model, [
+					[ 0          ],
+					[ 0,       0 ],
+					[ 0,    1, 0 ],
+					[ 0, 0,    0 ],
+					[ 0, 0, 1, 0 ]
+				] );
+				/* eslint-enable no-multi-spaces */
+			} );
+
+			it( 'should not select col-spanned columns that start in other column but include those that start in selected column', () => {
+				// Selection in cell 41.
+				tableSelection._setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 4, 1 ] ),
+					modelRoot.getNodeByPath( [ 0, 4, 1 ] )
+				);
+
+				/* eslint-disable no-multi-spaces */
+				assertSelectedCells( model, [
+					[ 0          ],
+					[ 0,       0 ],
+					[ 0,    0, 0 ],
+					[ 0, 0,    0 ],
+					[ 0, 1, 0, 0 ]
+				] );
+				/* eslint-enable no-multi-spaces */
+
+				command.execute();
+
+				/* eslint-disable no-multi-spaces */
+				assertSelectedCells( model, [
+					[ 0          ],
+					[ 0,       0 ],
+					[ 0,    0, 0 ],
+					[ 0, 1,    0 ],
+					[ 0, 1, 0, 0 ]
+				] );
+				/* eslint-enable no-multi-spaces */
+			} );
+
+			it( 'should select properly for multiple not spanned cells selected', () => {
+				// Selection in cells 40 - 41.
+				tableSelection._setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 4, 0 ] ),
+					modelRoot.getNodeByPath( [ 0, 4, 1 ] )
+				);
+
+				/* eslint-disable no-multi-spaces */
+				assertSelectedCells( model, [
+					[ 0          ],
+					[ 0,       0 ],
+					[ 0,    0, 0 ],
+					[ 0, 0,    0 ],
+					[ 1, 1, 0, 0 ]
+				] );
+				/* eslint-enable no-multi-spaces */
+
+				command.execute();
+
+				/* eslint-disable no-multi-spaces */
+				assertSelectedCells( model, [
+					[ 1          ],
+					[ 1,       0 ],
+					[ 1,    0, 0 ],
+					[ 1, 1,    0 ],
+					[ 1, 1, 0, 0 ]
+				] );
+				/* eslint-enable no-multi-spaces */
+			} );
+
+			it( 'should select properly for multiple cells selected including spanned one', () => {
+				// Selection in cells 31 - 33.
+				tableSelection._setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 3, 1 ] ),
+					modelRoot.getNodeByPath( [ 0, 3, 2 ] )
+				);
+
+				/* eslint-disable no-multi-spaces */
+				assertSelectedCells( model, [
+					[ 0          ],
+					[ 0,       0 ],
+					[ 0,    0, 0 ],
+					[ 0, 1,    1 ],
+					[ 0, 0, 0, 0 ]
+				] );
+				/* eslint-enable no-multi-spaces */
+
+				command.execute();
+
+				/* eslint-disable no-multi-spaces */
+				assertSelectedCells( model, [
+					[ 0          ],
+					[ 0,       1 ],
+					[ 0,    1, 1 ],
+					[ 0, 1,    1 ],
+					[ 0, 1, 1, 1 ]
+				] );
+				/* eslint-enable no-multi-spaces */
+			} );
+		} );
+
+		describe( 'with multiple columns selected', () => {
+			beforeEach( () => {
+				setData( model, modelTable( [
+					[ '00', '01', '02', '03' ],
+					[ '10', '11', '12', '13' ],
+					[ '20', '21', '22', '23' ]
+				] ) );
+			} );
+
+			it( 'should properly select middle columns', () => {
+				tableSelection._setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
+					modelRoot.getNodeByPath( [ 0, 0, 2 ] )
+				);
+
+				command.execute();
+
+				assertSelectedCells( model, [
+					[ 0, 1, 1, 0 ],
+					[ 0, 1, 1, 0 ],
+					[ 0, 1, 1, 0 ]
+				] );
+			} );
+
+			it( 'should properly select middle columns in reversed order', () => {
+				tableSelection._setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 0, 2 ] ),
+					modelRoot.getNodeByPath( [ 0, 0, 1 ] )
+				);
+
+				command.execute();
+
+				assertSelectedCells( model, [
+					[ 0, 1, 1, 0 ],
+					[ 0, 1, 1, 0 ],
+					[ 0, 1, 1, 0 ]
+				] );
+			} );
+
+			it( 'should properly select tailing columns', () => {
+				tableSelection._setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 0, 2 ] ),
+					modelRoot.getNodeByPath( [ 0, 0, 3 ] )
+				);
+
+				command.execute();
+
+				assertSelectedCells( model, [
+					[ 0, 0, 1, 1 ],
+					[ 0, 0, 1, 1 ],
+					[ 0, 0, 1, 1 ]
+				] );
+			} );
+
+			it( 'should properly select beginning columns', () => {
+				tableSelection._setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+					modelRoot.getNodeByPath( [ 0, 0, 1 ] )
+				);
+
+				command.execute();
+
+				assertSelectedCells( model, [
+					[ 1, 1, 0, 0 ],
+					[ 1, 1, 0, 0 ],
+					[ 1, 1, 0, 0 ]
+				] );
+			} );
+
+			it( 'should properly select multiple columns from square selection', () => {
+				tableSelection._setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
+					modelRoot.getNodeByPath( [ 0, 1, 2 ] )
+				);
+
+				command.execute();
+
+				assertSelectedCells( model, [
+					[ 0, 1, 1, 0 ],
+					[ 0, 1, 1, 0 ],
+					[ 0, 1, 1, 0 ]
+				] );
+			} );
+
+			it( 'should support selecting mixed heading and cell columns', () => {
+				setData( model, modelTable( [
+					[ '00', '01', '02', '03' ],
+					[ '10', '11', '12', '13' ],
+					[ '20', '21', '22', '23' ]
+				], { headingRows: 1 } ) );
+
+				tableSelection._setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+					modelRoot.getNodeByPath( [ 0, 0, 1 ] )
+				);
+
+				command.execute();
+
+				assertSelectedCells( model, [
+					[ 1, 1, 0, 0 ],
+					[ 1, 1, 0, 0 ],
+					[ 1, 1, 0, 0 ]
+				] );
+			} );
+		} );
+
+		describe( 'with entire column selected', () => {
+			it( 'should select a column if all its cells are selected', () => {
+				setData( model, modelTable( [
+					[ '00', '01', '02' ],
+					[ '10', '11', '12' ],
+					[ '20', '21', '22' ]
+				] ) );
+
+				tableSelection._setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
+					modelRoot.getNodeByPath( [ 0, 2, 1 ] )
+				);
+
+				command.execute();
+
+				assertSelectedCells( model, [
+					[ 0, 1, 0 ],
+					[ 0, 1, 0 ],
+					[ 0, 1, 0 ]
+				] );
+			} );
+
+			it( 'should properly select column if reversed selection is made', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10', '11' ]
+				] ) );
+
+				tableSelection._setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
+					modelRoot.getNodeByPath( [ 0, 0, 0 ] )
+				);
+
+				command.execute();
+
+				assertSelectedCells( model, [
+					[ 1, 0 ],
+					[ 1, 0 ]
+				] );
+			} );
+		} );
+	} );
+} );

+ 469 - 0
packages/ckeditor5-table/tests/commands/selectrowcommand.js

@@ -0,0 +1,469 @@
+/**
+ * @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 VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import SelectRowCommand from '../../src/commands/selectrowcommand';
+import TableSelection from '../../src/tableselection';
+import { assertSelectedCells, defaultConversion, defaultSchema, modelTable } from '../_utils/utils';
+
+describe( 'SelectRowCommand', () => {
+	let editor, model, modelRoot, command, tableSelection;
+
+	beforeEach( () => {
+		return VirtualTestEditor.create( { plugins: [ TableSelection ] } )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				modelRoot = model.document.getRoot();
+				command = new SelectRowCommand( editor );
+				tableSelection = editor.plugins.get( TableSelection );
+
+				defaultSchema( model.schema );
+				defaultConversion( editor.conversion );
+			} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	describe( 'isEnabled', () => {
+		it( 'should be true if the selection is inside table cell', () => {
+			setData( model, modelTable( [
+				[ '00[]', '01' ],
+				[ '10', '11' ]
+			] ) );
+
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be true if the selection contains multiple cells', () => {
+			setData( model, modelTable( [
+				[ '00', '01' ],
+				[ '10', '11' ],
+				[ '20', '21' ]
+			] ) );
+
+			tableSelection._setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 0, 1 ] )
+			);
+
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be false if the selection is outside a table', () => {
+			setData( model, '<paragraph>11[]</paragraph>' );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+	} );
+
+	describe( 'execute()', () => {
+		it( 'should select a row of a table cell with a collapsed selection', () => {
+			setData( model, modelTable( [
+				[ '00', '01' ],
+				[ '[]10', '11' ],
+				[ '20', '21' ]
+			] ) );
+
+			command.execute();
+
+			assertSelectedCells( model, [
+				[ 0, 0 ],
+				[ 1, 1 ],
+				[ 0, 0 ]
+			] );
+		} );
+
+		it( 'should select a row of table cell with a collapsed selection in first table cell', () => {
+			setData( model, modelTable( [
+				[ '[]00', '01' ],
+				[ '10', '11' ],
+				[ '20', '21' ]
+			] ) );
+
+			command.execute();
+
+			assertSelectedCells( model, [
+				[ 1, 1 ],
+				[ 0, 0 ],
+				[ 0, 0 ]
+			] );
+		} );
+
+		it( 'should select a row of table cell with a collapsed selection in last cell in the first column', () => {
+			setData( model, modelTable( [
+				[ '00', '01[]' ],
+				[ '10', '11' ],
+				[ '20', '21' ]
+			] ) );
+
+			command.execute();
+
+			assertSelectedCells( model, [
+				[ 1, 1 ],
+				[ 0, 0 ],
+				[ 0, 0 ]
+			] );
+		} );
+
+		it( 'should select a row of table cell with collapsed selection in the first cell of the last column', () => {
+			setData( model, modelTable( [
+				[ '00', '01' ],
+				[ '[]10', '11' ]
+			] ) );
+
+			command.execute();
+
+			assertSelectedCells( model, [
+				[ 0, 0 ],
+				[ 1, 1 ]
+			] );
+		} );
+
+		describe( 'with row-spanned cells', () => {
+			beforeEach( () => {
+				// +----+----+----+----+----+
+				// | 00 | 01 | 02 | 03 | 04 |
+				// +    +    +    +----+----+
+				// |    |    |    | 13 | 14 |
+				// +    +    +----+    +----+
+				// |    |    | 22 |    | 24 |
+				// +    +----+----+----+----+
+				// |    | 31 | 32 | 33 | 34 |
+				// +----+----+----+----+----+
+				setData( model, modelTable( [
+					[ { rowspan: 4, contents: '00' }, { rowspan: 3, contents: '01' }, { rowspan: 2, contents: '02' }, '03', '04' ],
+					[ { rowspan: 2, contents: '13' }, '14' ],
+					[ '22', '23', '24' ],
+					[ '30', '31', '32', '33', '34' ]
+				] ) );
+			} );
+
+			it( 'should select only one row if only one cell is selected', () => {
+				// Selection in cell 01.
+				tableSelection._setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
+					modelRoot.getNodeByPath( [ 0, 0, 1 ] )
+				);
+
+				/* eslint-disable no-multi-spaces */
+				assertSelectedCells( model, [
+					[ 0, 1, 0, 0, 0 ],
+					[          0, 0 ],
+					[       0,    0 ],
+					[    0, 0, 0, 0 ]
+				] );
+				/* eslint-enable no-multi-spaces */
+
+				command.execute();
+
+				/* eslint-disable no-multi-spaces */
+				assertSelectedCells( model, [
+					[ 1, 1, 1, 1, 1 ],
+					[          0, 0 ],
+					[       0,    0 ],
+					[    0, 0, 0, 0 ]
+				] );
+				/* eslint-enable no-multi-spaces */
+			} );
+
+			it( 'should not select row-spanned rows that start in other row', () => {
+				// Selection in cell 24.
+				tableSelection._setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 2, 1 ] ),
+					modelRoot.getNodeByPath( [ 0, 2, 1 ] )
+				);
+
+				/* eslint-disable no-multi-spaces */
+				assertSelectedCells( model, [
+					[ 0, 0, 0, 0, 0 ],
+					[          0, 0 ],
+					[       0,    1 ],
+					[    0, 0, 0, 0 ]
+				] );
+				/* eslint-enable no-multi-spaces */
+
+				command.execute();
+
+				/* eslint-disable no-multi-spaces */
+				assertSelectedCells( model, [
+					[ 0, 0, 0, 0, 0 ],
+					[          0, 0 ],
+					[       1,    1 ],
+					[    0, 0, 0, 0 ]
+				] );
+				/* eslint-enable no-multi-spaces */
+			} );
+
+			it( 'should not select row-spanned rows that start in other row but include those that start in selected row', () => {
+				// Selection in cell 14.
+				tableSelection._setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 1, 1 ] ),
+					modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+				);
+
+				/* eslint-disable no-multi-spaces */
+				assertSelectedCells( model, [
+					[ 0, 0, 0, 0, 0 ],
+					[          0, 1 ],
+					[       0,    0 ],
+					[    0, 0, 0, 0 ]
+				] );
+				/* eslint-enable no-multi-spaces */
+
+				command.execute();
+
+				/* eslint-disable no-multi-spaces */
+				assertSelectedCells( model, [
+					[ 0, 0, 0, 0, 0 ],
+					[          1, 1 ],
+					[       0,    0 ],
+					[    0, 0, 0, 0 ]
+				] );
+				/* eslint-enable no-multi-spaces */
+			} );
+
+			it( 'should select properly for multiple not spanned cells selected', () => {
+				// Selection in cells 04 - 14.
+				tableSelection._setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 0, 4 ] ),
+					modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+				);
+
+				/* eslint-disable no-multi-spaces */
+				assertSelectedCells( model, [
+					[ 0, 0, 0, 0, 1 ],
+					[          0, 1 ],
+					[       0,    0 ],
+					[    0, 0, 0, 0 ]
+				] );
+				/* eslint-enable no-multi-spaces */
+
+				command.execute();
+
+				/* eslint-disable no-multi-spaces */
+				assertSelectedCells( model, [
+					[ 1, 1, 1, 1, 1 ],
+					[          1, 1 ],
+					[       0,    0 ],
+					[    0, 0, 0, 0 ]
+				] );
+				/* eslint-enable no-multi-spaces */
+			} );
+
+			it( 'should select properly for multiple cells selected including spanned one', () => {
+				// Selection in cells 13 - 33.
+				tableSelection._setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
+					modelRoot.getNodeByPath( [ 0, 3, 2 ] )
+				);
+
+				/* eslint-disable no-multi-spaces */
+				assertSelectedCells( model, [
+					[ 0, 0, 0, 0, 0 ],
+					[          1, 0 ],
+					[       0,    0 ],
+					[    0, 0, 1, 0 ]
+				] );
+				/* eslint-enable no-multi-spaces */
+
+				command.execute();
+
+				/* eslint-disable no-multi-spaces */
+				assertSelectedCells( model, [
+					[ 0, 0, 0, 0, 0 ],
+					[          1, 1 ],
+					[       1,    1 ],
+					[    1, 1, 1, 1 ]
+				] );
+				/* eslint-enable no-multi-spaces */
+			} );
+		} );
+
+		describe( 'with multiple rows selected', () => {
+			it( 'should properly select middle rows', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10', '11' ],
+					[ '20', '21' ],
+					[ '30', '31' ]
+				] ) );
+
+				tableSelection._setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
+					modelRoot.getNodeByPath( [ 0, 2, 0 ] )
+				);
+
+				command.execute();
+
+				assertSelectedCells( model, [
+					[ 0, 0 ],
+					[ 1, 1 ],
+					[ 1, 1 ],
+					[ 0, 0 ]
+				] );
+			} );
+
+			it( 'should properly select middle rows in reversed order', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10', '11' ],
+					[ '20', '21' ],
+					[ '30', '31' ]
+				] ) );
+
+				tableSelection._setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 2, 0 ] ),
+					modelRoot.getNodeByPath( [ 0, 1, 0 ] )
+				);
+
+				command.execute();
+
+				assertSelectedCells( model, [
+					[ 0, 0 ],
+					[ 1, 1 ],
+					[ 1, 1 ],
+					[ 0, 0 ]
+				] );
+			} );
+
+			it( 'should properly select tailing rows', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10', '11' ],
+					[ '20', '21' ],
+					[ '30', '31' ]
+				] ) );
+
+				tableSelection._setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 2, 0 ] ),
+					modelRoot.getNodeByPath( [ 0, 3, 0 ] )
+				);
+
+				command.execute();
+
+				assertSelectedCells( model, [
+					[ 0, 0 ],
+					[ 0, 0 ],
+					[ 1, 1 ],
+					[ 1, 1 ]
+				] );
+			} );
+
+			it( 'should properly select beginning rows', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10', '11' ],
+					[ '20', '21' ],
+					[ '30', '31' ]
+				] ) );
+
+				tableSelection._setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+					modelRoot.getNodeByPath( [ 0, 1, 0 ] )
+				);
+
+				command.execute();
+
+				assertSelectedCells( model, [
+					[ 1, 1 ],
+					[ 1, 1 ],
+					[ 0, 0 ],
+					[ 0, 0 ]
+				] );
+			} );
+
+			it( 'should properly select multiple rows from square selection', () => {
+				setData( model, modelTable( [
+					[ '00', '01', '02' ],
+					[ '10', '11', '12' ],
+					[ '20', '21', '22' ],
+					[ '30', '31', '32' ]
+				] ) );
+
+				tableSelection._setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
+					modelRoot.getNodeByPath( [ 0, 2, 1 ] )
+				);
+
+				command.execute();
+
+				assertSelectedCells( model, [
+					[ 0, 0, 0 ],
+					[ 1, 1, 1 ],
+					[ 1, 1, 1 ],
+					[ 0, 0, 0 ]
+				] );
+			} );
+
+			it( 'should support selecting mixed heading and cell rows', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10', '11' ],
+					[ '20', '21' ]
+				], { headingColumns: 1 } ) );
+
+				tableSelection._setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+					modelRoot.getNodeByPath( [ 0, 1, 0 ] )
+				);
+
+				command.execute();
+
+				assertSelectedCells( model, [
+					[ 1, 1 ],
+					[ 1, 1 ],
+					[ 0, 0 ]
+				] );
+			} );
+		} );
+
+		describe( 'with entire row selected', () => {
+			it( 'should select a row if all its cells are selected', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10', '11' ],
+					[ '20', '21' ]
+				] ) );
+
+				tableSelection._setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
+					modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+				);
+
+				command.execute();
+
+				assertSelectedCells( model, [
+					[ 0, 0 ],
+					[ 1, 1 ],
+					[ 0, 0 ]
+				] );
+			} );
+
+			it( 'should properly select row if reversed selection is made', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10', '11' ]
+				] ) );
+
+				tableSelection._setCellSelection(
+					modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
+					modelRoot.getNodeByPath( [ 0, 0, 0 ] )
+				);
+
+				command.execute();
+
+				assertSelectedCells( model, [
+					[ 1, 1 ],
+					[ 0, 0 ]
+				] );
+			} );
+		} );
+	} );
+} );

+ 10 - 0
packages/ckeditor5-table/tests/tableediting.js

@@ -16,6 +16,8 @@ import InsertTableCommand from '../src/commands/inserttablecommand';
 import InsertColumnCommand from '../src/commands/insertcolumncommand';
 import RemoveRowCommand from '../src/commands/removerowcommand';
 import RemoveColumnCommand from '../src/commands/removecolumncommand';
+import SelectRowCommand from '../src/commands/selectrowcommand';
+import SelectColumnCommand from '../src/commands/selectcolumncommand';
 import SplitCellCommand from '../src/commands/splitcellcommand';
 import MergeCellCommand from '../src/commands/mergecellcommand';
 import SetHeaderRowCommand from '../src/commands/setheaderrowcommand';
@@ -111,6 +113,14 @@ describe( 'TableEditing', () => {
 		expect( editor.commands.get( 'removeTableColumn' ) ).to.be.instanceOf( RemoveColumnCommand );
 	} );
 
+	it( 'adds selectRow command', () => {
+		expect( editor.commands.get( 'selectTableRow' ) ).to.be.instanceOf( SelectRowCommand );
+	} );
+
+	it( 'adds selectColumn command', () => {
+		expect( editor.commands.get( 'selectTableColumn' ) ).to.be.instanceOf( SelectColumnCommand );
+	} );
+
 	it( 'adds splitCellVertically command', () => {
 		expect( editor.commands.get( 'splitTableCellVertically' ) ).to.be.instanceOf( SplitCellCommand );
 	} );

+ 74 - 23
packages/ckeditor5-table/tests/tableui.js

@@ -15,6 +15,7 @@ import InsertTableView from '../src/ui/inserttableview';
 import SwitchButtonView from '@ckeditor/ckeditor5-ui/src/button/switchbuttonview';
 import DropdownView from '@ckeditor/ckeditor5-ui/src/dropdown/dropdownview';
 import ListSeparatorView from '@ckeditor/ckeditor5-ui/src/list/listseparatorview';
+import SplitButtonView from '@ckeditor/ckeditor5-ui/src/dropdown/button/splitbuttonview';
 
 describe( 'TableUI', () => {
 	let editor, element;
@@ -137,7 +138,9 @@ describe( 'TableUI', () => {
 
 			const labels = listView.items.map( item => item instanceof ListSeparatorView ? '|' : item.children.first.label );
 
-			expect( labels ).to.deep.equal( [ 'Header row', '|', 'Insert row below', 'Insert row above', 'Delete row' ] );
+			expect( labels ).to.deep.equal(
+				[ 'Header row', '|', 'Insert row below', 'Insert row above', 'Delete row', 'Select row' ]
+			);
 		} );
 
 		it( 'should bind items in panel to proper commands', () => {
@@ -147,16 +150,19 @@ describe( 'TableUI', () => {
 			const insertRowBelowCommand = editor.commands.get( 'insertTableRowBelow' );
 			const insertRowAboveCommand = editor.commands.get( 'insertTableRowAbove' );
 			const removeRowCommand = editor.commands.get( 'removeTableRow' );
+			const selectRowCommand = editor.commands.get( 'selectTableRow' );
 
 			setRowHeaderCommand.isEnabled = true;
 			insertRowBelowCommand.isEnabled = true;
 			insertRowAboveCommand.isEnabled = true;
 			removeRowCommand.isEnabled = true;
+			selectRowCommand.isEnabled = true;
 
 			expect( items.first.children.first.isEnabled ).to.be.true;
 			expect( items.get( 2 ).children.first.isEnabled ).to.be.true;
 			expect( items.get( 3 ).children.first.isEnabled ).to.be.true;
 			expect( items.get( 4 ).children.first.isEnabled ).to.be.true;
+			expect( items.get( 5 ).children.first.isEnabled ).to.be.true;
 			expect( dropdown.buttonView.isEnabled ).to.be.true;
 
 			setRowHeaderCommand.isEnabled = false;
@@ -176,6 +182,11 @@ describe( 'TableUI', () => {
 			removeRowCommand.isEnabled = false;
 
 			expect( items.get( 4 ).children.first.isEnabled ).to.be.false;
+			expect( dropdown.buttonView.isEnabled ).to.be.true;
+
+			selectRowCommand.isEnabled = false;
+
+			expect( items.get( 5 ).children.first.isEnabled ).to.be.false;
 			expect( dropdown.buttonView.isEnabled ).to.be.false;
 		} );
 
@@ -238,7 +249,9 @@ describe( 'TableUI', () => {
 
 			const labels = listView.items.map( item => item instanceof ListSeparatorView ? '|' : item.children.first.label );
 
-			expect( labels ).to.deep.equal( [ 'Header column', '|', 'Insert column left', 'Insert column right', 'Delete column' ] );
+			expect( labels ).to.deep.equal(
+				[ 'Header column', '|', 'Insert column left', 'Insert column right', 'Delete column', 'Select column' ]
+			);
 		} );
 
 		it( 'should bind items in panel to proper commands (LTR content)', () => {
@@ -248,16 +261,19 @@ describe( 'TableUI', () => {
 			const insertColumnLeftCommand = editor.commands.get( 'insertTableColumnLeft' );
 			const insertColumnRightCommand = editor.commands.get( 'insertTableColumnRight' );
 			const removeColumnCommand = editor.commands.get( 'removeTableColumn' );
+			const selectColumnCommand = editor.commands.get( 'selectTableColumn' );
 
 			setColumnHeaderCommand.isEnabled = true;
 			insertColumnLeftCommand.isEnabled = true;
 			insertColumnRightCommand.isEnabled = true;
 			removeColumnCommand.isEnabled = true;
+			selectColumnCommand.isEnabled = true;
 
 			expect( items.first.children.first.isEnabled ).to.be.true;
 			expect( items.get( 2 ).children.first.isEnabled ).to.be.true;
 			expect( items.get( 3 ).children.first.isEnabled ).to.be.true;
 			expect( items.get( 4 ).children.first.isEnabled ).to.be.true;
+			expect( items.get( 5 ).children.first.isEnabled ).to.be.true;
 			expect( dropdown.buttonView.isEnabled ).to.be.true;
 
 			setColumnHeaderCommand.isEnabled = false;
@@ -275,6 +291,10 @@ describe( 'TableUI', () => {
 
 			removeColumnCommand.isEnabled = false;
 			expect( items.get( 4 ).children.first.isEnabled ).to.be.false;
+
+			selectColumnCommand.isEnabled = false;
+			expect( items.get( 5 ).children.first.isEnabled ).to.be.false;
+
 			expect( dropdown.buttonView.isEnabled ).to.be.false;
 		} );
 
@@ -343,11 +363,12 @@ describe( 'TableUI', () => {
 		} );
 	} );
 
-	describe( 'mergeTableCell dropdown', () => {
-		let dropdown;
+	describe( 'mergeTableCell split button', () => {
+		let dropdown, command;
 
 		beforeEach( () => {
 			dropdown = editor.ui.componentFactory.create( 'mergeTableCells' );
+			command = editor.commands.get( 'mergeTableCells' );
 		} );
 
 		it( 'have button with proper properties set', () => {
@@ -361,6 +382,35 @@ describe( 'TableUI', () => {
 			expect( button.icon ).to.match( /<svg / );
 		} );
 
+		it( 'should have a split button', () => {
+			expect( dropdown.buttonView ).to.be.instanceOf( SplitButtonView );
+		} );
+
+		it( 'should bind #isEnabled to the "mergeTableCells" command', () => {
+			command.isEnabled = false;
+			expect( dropdown.isEnabled ).to.be.false;
+
+			command.isEnabled = true;
+			expect( dropdown.isEnabled ).to.be.true;
+		} );
+
+		it( 'should execute the "mergeTableCells" command when the main part of the split button is clicked', () => {
+			const spy = sinon.stub( editor, 'execute' );
+
+			dropdown.buttonView.fire( 'execute' );
+
+			sinon.assert.calledOnce( spy );
+			sinon.assert.calledWithExactly( spy, 'mergeTableCells' );
+		} );
+
+		it( 'should have the dropdown part of the split button always enabled no matter the "mergeTableCells" command state', () => {
+			command.isEnabled = true;
+			expect( dropdown.buttonView.arrowView.isEnabled ).to.be.true;
+
+			command.isEnabled = false;
+			expect( dropdown.buttonView.arrowView.isEnabled ).to.be.true;
+		} );
+
 		it( 'should have proper items in panel', () => {
 			const listView = dropdown.listView;
 
@@ -394,37 +444,38 @@ describe( 'TableUI', () => {
 			splitCellVerticallyCommand.isEnabled = true;
 			splitCellHorizontallyCommand.isEnabled = true;
 
-			expect( items.first.children.first.isEnabled ).to.be.true;
-			expect( items.get( 1 ).children.first.isEnabled ).to.be.true;
-			expect( items.get( 2 ).children.first.isEnabled ).to.be.true;
-			expect( items.get( 3 ).children.first.isEnabled ).to.be.true;
-			expect( items.get( 5 ).children.first.isEnabled ).to.be.true;
-			expect( items.get( 6 ).children.first.isEnabled ).to.be.true;
-			expect( dropdown.buttonView.isEnabled ).to.be.true;
+			const mergeCellUpButton = items.first;
+			const mergeCellRightButton = items.get( 1 );
+			const mergeCellDownButton = items.get( 2 );
+			const mergeCellLeftButton = items.get( 3 );
+			// separator
+			const splitVerticallyButton = items.get( 5 );
+			const splitHorizontallyButton = items.get( 6 );
+
+			expect( mergeCellUpButton.children.first.isEnabled ).to.be.true;
+			expect( mergeCellRightButton.children.first.isEnabled ).to.be.true;
+			expect( mergeCellDownButton.children.first.isEnabled ).to.be.true;
+			expect( mergeCellLeftButton.children.first.isEnabled ).to.be.true;
+			expect( splitVerticallyButton.children.first.isEnabled ).to.be.true;
+			expect( splitHorizontallyButton.children.first.isEnabled ).to.be.true;
 
 			mergeCellUpCommand.isEnabled = false;
-
-			expect( items.first.children.first.isEnabled ).to.be.false;
-			expect( dropdown.buttonView.isEnabled ).to.be.true;
+			expect( mergeCellUpButton.children.first.isEnabled ).to.be.false;
 
 			mergeCellRightCommand.isEnabled = false;
-
-			expect( items.get( 1 ).children.first.isEnabled ).to.be.false;
-			expect( dropdown.buttonView.isEnabled ).to.be.true;
+			expect( mergeCellRightButton.children.first.isEnabled ).to.be.false;
 
 			mergeCellDownCommand.isEnabled = false;
-			expect( items.get( 2 ).children.first.isEnabled ).to.be.false;
+			expect( mergeCellDownButton.children.first.isEnabled ).to.be.false;
 
 			mergeCellLeftCommand.isEnabled = false;
-			expect( items.get( 3 ).children.first.isEnabled ).to.be.false;
+			expect( mergeCellLeftButton.children.first.isEnabled ).to.be.false;
 
 			splitCellVerticallyCommand.isEnabled = false;
-			expect( items.get( 5 ).children.first.isEnabled ).to.be.false;
+			expect( splitVerticallyButton.children.first.isEnabled ).to.be.false;
 
 			splitCellHorizontallyCommand.isEnabled = false;
-			expect( items.get( 6 ).children.first.isEnabled ).to.be.false;
-
-			expect( dropdown.buttonView.isEnabled ).to.be.false;
+			expect( splitHorizontallyButton.children.first.isEnabled ).to.be.false;
 		} );
 
 		it( 'should bind items in panel to proper commands (RTL content)', () => {

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

@@ -747,4 +747,217 @@ describe( 'TableUtils', () => {
 			expect( tableUtils.getRows( root.getNodeByPath( [ 0 ] ) ) ).to.equal( 3 );
 		} );
 	} );
+
+	describe( 'removeRows()', () => {
+		describe( 'single row', () => {
+			it( 'should remove a given row from a table start', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10', '11' ],
+					[ '20', '21' ]
+				] ) );
+
+				tableUtils.removeRows( root.getChild( 0 ), { at: 0 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '10', '11' ],
+					[ '20', '21' ]
+				] ) );
+			} );
+
+			it( 'should remove last row', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10', '11' ]
+				] ) );
+
+				tableUtils.removeRows( root.getChild( 0 ), { at: 1 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '00', '01' ]
+				] ) );
+			} );
+
+			it( 'should change heading rows if removing a heading row', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10', '11' ],
+					[ '20', '21' ]
+				], { headingRows: 2 } ) );
+
+				tableUtils.removeRows( root.getChild( 0 ), { at: 1 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '00', '01' ],
+					[ '20', '21' ]
+				], { headingRows: 1 } ) );
+			} );
+
+			it( 'should decrease rowspan of table cells from previous rows', () => {
+				setData( model, modelTable( [
+					[ { rowspan: 4, contents: '00' }, { rowspan: 3, contents: '01' }, { rowspan: 2, contents: '02' }, '03', '04' ],
+					[ { rowspan: 2, contents: '13' }, '14' ],
+					[ '22', '23', '24' ],
+					[ '30', '31', '32', '33', '34' ]
+				] ) );
+
+				tableUtils.removeRows( root.getChild( 0 ), { at: 2 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ { rowspan: 3, contents: '00' }, { rowspan: 2, contents: '01' }, { rowspan: 2, contents: '02' }, '03', '04' ],
+					[ '13', '14' ],
+					[ '30', '31', '32', '33', '34' ]
+				] ) );
+			} );
+
+			it( 'should move rowspaned cells to row below removing it\'s row', () => {
+				setData( model, modelTable( [
+					[ { rowspan: 3, contents: '00' }, { rowspan: 2, contents: '01' }, '02' ],
+					[ '12' ],
+					[ '21', '22' ],
+					[ '30', '31', '32' ]
+				] ) );
+
+				tableUtils.removeRows( root.getChild( 0 ), { at: 0 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ { rowspan: 2, contents: '00' }, '01', '12' ],
+					[ '21', '22' ],
+					[ '30', '31', '32' ]
+				] ) );
+			} );
+		} );
+
+		describe( 'many rows', () => {
+			it( 'should properly remove middle rows', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10', '11' ],
+					[ '20', '21' ],
+					[ '30', '31' ]
+				] ) );
+
+				tableUtils.removeRows( root.getChild( 0 ), { at: 1, rows: 2 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '00', '01' ],
+					[ '30', '31' ]
+				] ) );
+			} );
+
+			it( 'should properly remove tailing rows', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10', '11' ],
+					[ '20', '21' ],
+					[ '30', '31' ]
+				] ) );
+
+				tableUtils.removeRows( root.getChild( 0 ), { at: 2, rows: 2 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '00', '01' ],
+					[ '10', '11' ]
+				] ) );
+			} );
+
+			it( 'should properly remove beginning rows', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10', '11' ],
+					[ '20', '21' ],
+					[ '30', '31' ]
+				] ) );
+
+				tableUtils.removeRows( root.getChild( 0 ), { at: 0, rows: 2 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '20', '21' ],
+					[ '30', '31' ]
+				] ) );
+			} );
+
+			it( 'should support removing multiple headings (removed rows in heading section)', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10', '11' ],
+					[ '20', '21' ],
+					[ '30', '31' ]
+				], { headingRows: 3 } ) );
+
+				tableUtils.removeRows( root.getChild( 0 ), { at: 0, rows: 2 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '20', '21' ],
+					[ '30', '31' ]
+				], { headingRows: 1 } ) );
+			} );
+
+			it( 'should support removing multiple headings (removed rows in heading and body section)', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10', '11' ],
+					[ '20', '21' ],
+					[ '30', '31' ],
+					[ '40', '41' ]
+				], { headingRows: 3 } ) );
+
+				tableUtils.removeRows( root.getChild( 0 ), { at: 1, rows: 3 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '00', '01' ],
+					[ '40', '41' ]
+				], { headingRows: 1 } ) );
+			} );
+
+			it( 'should support removing mixed heading and cell rows', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10', '11' ],
+					[ '20', '21' ]
+				], { headingRows: 1 } ) );
+
+				tableUtils.removeRows( root.getChild( 0 ), { at: 0, rows: 2 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '20', '21' ]
+				] ) );
+			} );
+
+			it( 'should properly calculate truncated rowspans', () => {
+				setData( model, modelTable( [
+					[ '00', { contents: '01', rowspan: 3 } ],
+					[ '10' ],
+					[ '20' ]
+				] ) );
+
+				tableUtils.removeRows( root.getChild( 0 ), { at: 0, rows: 2 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '20', '01' ]
+				] ) );
+			} );
+
+			it( 'should create one undo step (1 batch)', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10', '11' ],
+					[ '20', '21' ],
+					[ '30', '31' ]
+				], { headingRows: 3 } ) );
+
+				const createdBatches = new Set();
+
+				model.on( 'applyOperation', ( evt, args ) => {
+					const operation = args[ 0 ];
+
+					createdBatches.add( operation.batch );
+				} );
+
+				tableUtils.removeRows( root.getChild( 0 ), { at: 0, rows: 2 } );
+
+				expect( createdBatches.size ).to.equal( 1 );
+			} );
+		} );
+	} );
 } );

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

@@ -73,6 +73,7 @@ describe( 'ColorInputView', () => {
 			it( 'should be created', () => {
 				expect( view._dropdownView ).to.be.instanceOf( DropdownView );
 				expect( view._dropdownView.buttonView.element.classList.contains( 'ck-input-color__button' ) ).to.be.true;
+				expect( view._dropdownView.buttonView.tooltip ).to.equal( 'Color picker' );
 			} );
 
 			it( 'should bind #isEnabled to the view\'s #isReadOnly', () => {

+ 3 - 16
packages/ckeditor5-table/tests/ui/inserttableview.js

@@ -60,12 +60,12 @@ describe( 'InsertTableView', () => {
 		} );
 
 		describe( 'view#items bindings', () => {
-			it( 'updates view#height & view#width on "over" event', () => {
+			it( 'updates view#height & view#width on DOM "mouseover" event', () => {
 				const boxView = view.items.get( 0 );
 
 				expect( boxView.isOn ).to.be.false;
 
-				boxView.fire( 'over' );
+				boxView.element.dispatchEvent( new Event( 'mouseover', { bubbles: true } ) );
 
 				expect( boxView.isOn ).to.be.true;
 
@@ -74,7 +74,7 @@ describe( 'InsertTableView', () => {
 
 				const boxViewB = view.items.get( 22 );
 
-				boxViewB.fire( 'over' );
+				boxViewB.element.dispatchEvent( new Event( 'mouseover', { bubbles: true } ) );
 
 				expect( view.rows ).to.equal( 3 );
 				expect( view.columns ).to.equal( 3 );
@@ -108,19 +108,6 @@ describe( 'InsertTableView', () => {
 
 					sinon.assert.calledOnce( spy );
 				} );
-
-				describe( 'view#items mousemove event', () => {
-					it( 'fires "over" event', () => {
-						const boxView = view.items.get( 0 );
-						const spy = sinon.spy();
-
-						boxView.on( 'over', spy );
-
-						dispatchEvent( boxView.element, 'mouseover' );
-
-						sinon.assert.calledOnce( spy );
-					} );
-				} );
 			} );
 		} );
 	} );

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

@@ -518,6 +518,12 @@ describe( 'UI Utils', () => {
 			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;

+ 0 - 10
packages/ckeditor5-table/theme/formheader.css

@@ -1,10 +0,0 @@
-/*
- * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
- */
-
-/*
- * Note: This file should contain the wireframe styles only. But since there are no such styles,
- * it acts as a message to the builder telling that it should look for the corresponding styles
- * **in the theme** when compiling the editor.
- */

+ 5 - 1
packages/ckeditor5-table/theme/table.css

@@ -26,7 +26,11 @@
 		& th {
 			min-width: 2em;
 			padding: .4em;
-			border-color: hsl(0, 0%, 75%);
+
+			/* The border is inherited from .ck-editor__nested-editable styles, so theoretically it's not necessary here.
+			However, the border is a content style, so it should use .ck-content (so it works outside the editor).
+			Hence, the duplication. See https://github.com/ckeditor/ckeditor5/issues/6314 */
+			border: 1px solid hsl(0, 0%, 75%);
 		}
 
 		& th {