瀏覽代碼

Merge branch 'master' into i/3267

# Conflicts:
#	src/tableediting.js
Kuba Niegowski 5 年之前
父節點
當前提交
9021da969d

+ 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
@@ -395,6 +395,14 @@ The {@link module:table/tabletoolbar~TableToolbar} plugin introduces two balloon
 			<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>
 		</tr>

+ 2 - 0
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.",

+ 5 - 49
packages/ckeditor5-table/src/commands/removecolumncommand.js

@@ -10,7 +10,6 @@
 import Command from '@ckeditor/ckeditor5-core/src/command';
 
 import TableWalker from '../tablewalker';
-import { updateNumericAttribute } from './utils';
 import { getSelectionAffectedTableCells } from '../utils';
 
 /**
@@ -69,59 +68,16 @@ export default class RemoveColumnCommand extends Command {
 			// A temporary workaround to avoid the "model-selection-range-intersects" error.
 			writer.setSelection( writer.createRangeOn( table ) );
 
-			adjustHeadingColumns( table, removedColumnIndexes, writer );
+			const columnsToRemove = removedColumnIndexes.last - removedColumnIndexes.first + 1;
 
-			for (
-				let removedColumnIndex = removedColumnIndexes.last;
-				removedColumnIndex >= removedColumnIndexes.first;
-				removedColumnIndex--
-			) {
-				this._removeColumn( removedColumnIndex, table, writer );
-			}
+			this.editor.plugins.get( 'TableUtils' ).removeColumns( table, {
+				at: removedColumnIndexes.first,
+				columns: columnsToRemove
+			} );
 
 			writer.setSelection( writer.createPositionAt( cellToFocus, 0 ) );
 		} );
 	}
-
-	/**
-	 * Removes a column from the given `table`.
-	 *
-	 * @private
-	 * @param {Number} removedColumnIndex Index of the column that should be removed.
-	 * @param {module:engine/model/element~Element} table
-	 * @param {module:engine/model/writer~Writer} writer
-	 */
-	_removeColumn( removedColumnIndex, table, writer ) {
-		for ( const { cell, column, colspan } of new TableWalker( table ) ) {
-			// If colspaned cell overlaps removed column decrease its span.
-			if ( column <= removedColumnIndex && colspan > 1 && column + colspan > removedColumnIndex ) {
-				updateNumericAttribute( 'colspan', colspan - 1, cell, writer );
-			} else if ( column === removedColumnIndex ) {
-				const cellRow = cell.parent;
-
-				// The cell in removed column has colspan of 1.
-				writer.remove( cell );
-
-				// If the cell was the last one in the row, get rid of the entire row.
-				// https://github.com/ckeditor/ckeditor5/issues/6429
-				if ( !cellRow.childCount ) {
-					writer.remove( cellRow );
-				}
-			}
-		}
-	}
-}
-
-// Updates heading columns attribute if removing a row from head section.
-function adjustHeadingColumns( table, removedColumnIndexes, writer ) {
-	const headingColumns = table.getAttribute( 'headingColumns' ) || 0;
-
-	if ( headingColumns && removedColumnIndexes.first <= headingColumns ) {
-		const headingsRemoved = Math.min( headingColumns - 1 /* Other numbers are 0-based */, removedColumnIndexes.last ) -
-			removedColumnIndexes.first + 1;
-
-		writer.setAttribute( 'headingColumns', headingColumns - headingsRemoved, table );
-	}
 }
 
 // Returns a proper table cell to focus after removing a column.

+ 7 - 76
packages/ckeditor5-table/src/commands/removerowcommand.js

@@ -9,8 +9,7 @@
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
 
-import TableWalker from '../tablewalker';
-import { findAncestor, updateNumericAttribute } from './utils';
+import { findAncestor } from './utils';
 import { getRowIndexes, getSelectionAffectedTableCells } from '../utils';
 
 /**
@@ -65,77 +64,18 @@ export default class RemoveRowCommand extends Command {
 			// This prevents the "model-selection-range-intersects" error, caused by removing row selected cells.
 			writer.setSelection( writer.createSelection( table, 'on' ) );
 
-			let cellToFocus;
+			const rowsToRemove = removedRowIndexes.last - removedRowIndexes.first + 1;
 
-			for ( let i = removedRowIndexes.last; i >= removedRowIndexes.first; i-- ) {
-				const removedRowIndex = i;
-				this._removeRow( removedRowIndex, table, writer );
+			this.editor.plugins.get( 'TableUtils' ).removeRows( table, {
+				at: removedRowIndexes.first,
+				rows: rowsToRemove
+			} );
 
-				cellToFocus = getCellToFocus( table, removedRowIndex, columnIndexToFocus );
-			}
-
-			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 cell that should be focused before removing the row, belonging to the same column as the currently focused cell.
@@ -159,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 );
+		} );
+	}
+}

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

@@ -29,6 +29,8 @@ 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 { getSelectedTableCells, getTableCellsContainingSelection } from './utils';
 import TableUtils from '../src/tableutils';
 import { findAncestor } from './commands/utils';
@@ -145,6 +147,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 - 21
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' )
+					}
 				}
 			];
 
@@ -190,14 +205,6 @@ export default class TableUI extends Plugin {
 				{
 					type: 'button',
 					model: {
-						commandName: 'mergeTableCells',
-						label: t( 'Merge cells' )
-					}
-				},
-				{ type: 'separator' },
-				{
-					type: 'button',
-					model: {
 						commandName: 'splitTableCellVertically',
 						label: t( 'Split cell vertically' )
 					}
@@ -211,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 );
 		} );
 	}
 
@@ -227,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( {
@@ -259,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.

+ 173 - 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
 	 *
@@ -254,6 +254,120 @@ 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 );
+				} );
+			}
+		} );
+	}
+
+	/**
+	 * Removes columns from the given `table`.
+	 *
+	 * This method re-calculates the table geometry including the `colspan` attribute of table cells overlapping removed columns
+	 * and table headings values.
+	 *
+	 *		editor.plugins.get( 'TableUtils' ).removeColumns( table, { at: 1, columns: 2 } );
+	 *
+	 * Executing the above code in the context of the table on the left will transform its structure as presented on the right:
+	 *
+	 *		  0   1   2   3   4                       0   1   2
+	 *		┌───────────────┬───┐                   ┌───────┬───┐
+	 *		│ a             │ b │                   │ a     │ b │
+	 *		│               ├───┤                   │       ├───┤
+	 *		│               │ c │                   │       │ c │
+	 *		├───┬───┬───┬───┼───┤     will give:    ├───┬───┼───┤
+	 *		│ d │ e │ f │ g │ h │                   │ d │ g │ h │
+	 *		├───┼───┼───┤   ├───┤                   ├───┤   ├───┤
+	 *		│ i │ j │ k │   │ l │                   │ i │   │ l │
+	 *		├───┴───┴───┴───┴───┤                   ├───┴───┴───┤
+	 *		│ m                 │                   │ m         │
+	 *		└───────────────────┘                   └───────────┘
+	 *		      ^---- remove from here, `at` = 1, `columns` = 2
+	 *
+	 * @param {module:engine/model/element~Element} table
+	 * @param {Object} options
+	 * @param {Number} options.at The row index at which the removing columns will start.
+	 * @param {Number} [options.columns=1] The number of columns to remove.
+	 */
+	removeColumns( table, options ) {
+		const model = this.editor.model;
+		const first = options.at;
+		const columnsToRemove = options.columns || 1;
+		const last = options.at + columnsToRemove - 1;
+
+		model.change( writer => {
+			adjustHeadingColumns( table, { first, last }, writer );
+
+			for ( let removedColumnIndex = last; removedColumnIndex >= first; removedColumnIndex-- ) {
+				for ( const { cell, column, colspan } of [ ...new TableWalker( table ) ] ) {
+					// If colspaned cell overlaps removed column decrease its span.
+					if ( column <= removedColumnIndex && colspan > 1 && column + colspan > removedColumnIndex ) {
+						updateNumericAttribute( 'colspan', colspan - 1, cell, writer );
+					} else if ( column === removedColumnIndex ) {
+						const cellRow = cell.parent;
+
+						// The cell in removed column has colspan of 1.
+						writer.remove( cell );
+
+						// If the cell was the last one in the row, get rid of the entire row.
+						// https://github.com/ckeditor/ckeditor5/issues/6429
+						if ( !cellRow.childCount ) {
+							this.removeRows( table, { at: cellRow.index } );
+						}
+					}
+				}
+			}
+		} );
+	}
+
+	/**
 	 * Divides a table cell vertically into several ones.
 	 *
 	 * The cell will be visually split into more cells by updating colspans of other cells in a column
@@ -615,3 +729,61 @@ 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;
+}
+
+// Updates heading columns attribute if removing a row from head section.
+function adjustHeadingColumns( table, removedColumnIndexes, writer ) {
+	const headingColumns = table.getAttribute( 'headingColumns' ) || 0;
+
+	if ( headingColumns && removedColumnIndexes.first < headingColumns ) {
+		const headingsRemoved = Math.min( headingColumns - 1 /* Other numbers are 0-based */, removedColumnIndexes.last ) -
+			removedColumnIndexes.first + 1;
+
+		writer.setAttribute( 'headingColumns', headingColumns - headingsRemoved, table );
+	}
+}

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

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

+ 41 - 26
packages/ckeditor5-table/tests/commands/removecolumncommand.js

@@ -367,21 +367,21 @@ describe( 'RemoveColumnCommand', () => {
 
 		it( 'should decrease colspan of table cells from previous column', () => {
 			setData( model, modelTable( [
-				[ { colspan: 4, contents: '00' }, '03' ],
-				[ { colspan: 3, contents: '10' }, '13' ],
-				[ { colspan: 2, contents: '20' }, '22[]', '23' ],
-				[ '30', { colspan: 2, contents: '31' }, '33' ],
-				[ '40', '41', '42', '43' ]
+				[ { colspan: 4, contents: '00' }, '04' ],
+				[ { colspan: 3, contents: '10' }, '13', '14' ],
+				[ { colspan: 2, contents: '20' }, '22[]', '23', '24' ],
+				[ '30', { colspan: 2, contents: '31' }, '33', '34' ],
+				[ '40', '41', '42', '43', '44' ]
 			] ) );
 
 			command.execute();
 
 			assertEqualMarkup( getData( model ), modelTable( [
-				[ { colspan: 3, contents: '00' }, '03' ],
-				[ { colspan: 2, contents: '10' }, '13' ],
-				[ { colspan: 2, contents: '20' }, '[]23' ],
-				[ '30', '31', '33' ],
-				[ '40', '41', '43' ]
+				[ { colspan: 3, contents: '00' }, '04' ],
+				[ { colspan: 2, contents: '10' }, '13', '14' ],
+				[ { colspan: 2, contents: '20' }, '[]23', '24' ],
+				[ '30', '31', '33', '34' ],
+				[ '40', '41', '43', '44' ]
 
 			] ) );
 		} );
@@ -389,7 +389,7 @@ describe( 'RemoveColumnCommand', () => {
 		it( 'should decrease colspan of cells that are on removed column', () => {
 			setData( model, modelTable( [
 				[ { colspan: 3, contents: '[]00' }, '03' ],
-				[ { colspan: 2, contents: '10' }, '13' ],
+				[ { colspan: 2, contents: '10' }, '12', '13' ],
 				[ '20', '21', '22', '23' ]
 			] ) );
 
@@ -397,7 +397,7 @@ describe( 'RemoveColumnCommand', () => {
 
 			assertEqualMarkup( getData( model ), modelTable( [
 				[ { colspan: 2, contents: '[]00' }, '03' ],
-				[ '10', '13' ],
+				[ '10', '12', '13' ],
 				[ '21', '22', '23' ]
 			] ) );
 		} );
@@ -418,47 +418,47 @@ describe( 'RemoveColumnCommand', () => {
 			] ) );
 		} );
 
-		it( 'should work property if the rowspan is in the first column (#1)', () => {
+		it( 'should work property if the rowspan is in the first column (the other cell in row is selected)', () => {
 			setData( model, modelTable( [
 				[ { rowspan: 2, contents: '00' }, '[]01' ],
-				[ '10' ]
+				[ '11' ]
 			] ) );
 
 			command.execute();
 
 			assertEqualMarkup( getData( model ), modelTable( [
-				[ { rowspan: 2, contents: '[]00' } ]
+				[ '[]00' ]
 			] ) );
 		} );
 
-		it( 'should work property if the rowspan is in the first column (#2)', () => {
+		it( 'should work property if the rowspan is in the first column (the cell in row below is selected)', () => {
 			setData( model, modelTable( [
 				[ { rowspan: 2, contents: '00' }, '01' ],
-				[ '[]10' ]
+				[ '[]11' ]
 			] ) );
 
 			command.execute();
 
 			assertEqualMarkup( getData( model ), modelTable( [
-				[ { rowspan: 2, contents: '[]00' } ]
+				[ '[]00' ]
 			] ) );
 		} );
 
-		it( 'should work property if the rowspan is in the first column (#3)', () => {
+		it( 'should work property if the rowspan is in the first column (the cell with rowspan is selected)', () => {
 			setData( model, modelTable( [
 				[ { rowspan: 2, contents: '00[]' }, '01' ],
-				[ '10' ]
+				[ '11' ]
 			] ) );
 
 			command.execute();
 
 			assertEqualMarkup( getData( model ), modelTable( [
 				[ '[]01' ],
-				[ '10' ]
+				[ '11' ]
 			] ) );
 		} );
 
-		it( 'should work property if the rowspan is in the last column (#1)', () => {
+		it( 'should work property if the rowspan is in the last column (the other cell in row is selected)', () => {
 			setData( model, modelTable( [
 				[ '[]00', { rowspan: 2, contents: '01' } ],
 				[ '10' ]
@@ -467,11 +467,11 @@ describe( 'RemoveColumnCommand', () => {
 			command.execute();
 
 			assertEqualMarkup( getData( model ), modelTable( [
-				[ { rowspan: 2, contents: '[]01' } ]
+				[ '[]01' ]
 			] ) );
 		} );
 
-		it( 'should work property if the rowspan is in the last column (#2)', () => {
+		it( 'should work property if the rowspan is in the last column (the cell in row below is selected)', () => {
 			setData( model, modelTable( [
 				[ '00', { rowspan: 2, contents: '01' } ],
 				[ '[]10' ]
@@ -480,11 +480,11 @@ describe( 'RemoveColumnCommand', () => {
 			command.execute();
 
 			assertEqualMarkup( getData( model ), modelTable( [
-				[ { rowspan: 2, contents: '[]01' } ]
+				[ '[]01' ]
 			] ) );
 		} );
 
-		it( 'should work property if the rowspan is in the last column (#3)', () => {
+		it( 'should work property if the rowspan is in the last column (the cell with rowspan is selected)', () => {
 			setData( model, modelTable( [
 				[ '00', { rowspan: 2, contents: '[]01' } ],
 				[ '10' ]
@@ -497,5 +497,20 @@ describe( 'RemoveColumnCommand', () => {
 				[ '10' ]
 			] ) );
 		} );
+
+		it( 'should remove column if removing row with one column - other columns are spanned', () => {
+			setData( model, modelTable( [
+				[ '[]00', { rowspan: 2, contents: '01' }, { rowspan: 2, contents: '02' } ],
+				[ '10' ],
+				[ '20', '21', '22' ]
+			] ) );
+
+			command.execute();
+
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ '[]01', '02' ],
+				[ '21', '22' ]
+			] ) );
+		} );
 	} );
 } );

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

+ 56 - 22
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;
 
@@ -372,8 +422,6 @@ describe( 'TableUI', () => {
 				'Merge cell down',
 				'Merge cell left',
 				'|',
-				'Merge cells',
-				'|',
 				'Split cell vertically',
 				'Split cell horizontally'
 			] );
@@ -386,7 +434,6 @@ describe( 'TableUI', () => {
 			const mergeCellRightCommand = editor.commands.get( 'mergeTableCellRight' );
 			const mergeCellDownCommand = editor.commands.get( 'mergeTableCellDown' );
 			const mergeCellLeftCommand = editor.commands.get( 'mergeTableCellLeft' );
-			const mergeCellsCommand = editor.commands.get( 'mergeTableCells' );
 			const splitCellVerticallyCommand = editor.commands.get( 'splitTableCellVertically' );
 			const splitCellHorizontallyCommand = editor.commands.get( 'splitTableCellHorizontally' );
 
@@ -394,7 +441,6 @@ describe( 'TableUI', () => {
 			mergeCellRightCommand.isEnabled = true;
 			mergeCellDownCommand.isEnabled = true;
 			mergeCellLeftCommand.isEnabled = true;
-			mergeCellsCommand.isEnabled = true;
 			splitCellVerticallyCommand.isEnabled = true;
 			splitCellHorizontallyCommand.isEnabled = true;
 
@@ -403,28 +449,21 @@ describe( 'TableUI', () => {
 			const mergeCellDownButton = items.get( 2 );
 			const mergeCellLeftButton = items.get( 3 );
 			// separator
-			const mergeCellsButton = items.get( 5 );
-			// separator
-			const splitVerticallyButton = items.get( 7 );
-			const splitHorizontallyButton = items.get( 8 );
+			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( mergeCellsButton.children.first.isEnabled ).to.be.true;
 			expect( splitVerticallyButton.children.first.isEnabled ).to.be.true;
 			expect( splitHorizontallyButton.children.first.isEnabled ).to.be.true;
-			expect( dropdown.buttonView.isEnabled ).to.be.true;
 
 			mergeCellUpCommand.isEnabled = false;
 			expect( mergeCellUpButton.children.first.isEnabled ).to.be.false;
-			expect( dropdown.buttonView.isEnabled ).to.be.true;
 
 			mergeCellRightCommand.isEnabled = false;
-
 			expect( mergeCellRightButton.children.first.isEnabled ).to.be.false;
-			expect( dropdown.buttonView.isEnabled ).to.be.true;
 
 			mergeCellDownCommand.isEnabled = false;
 			expect( mergeCellDownButton.children.first.isEnabled ).to.be.false;
@@ -432,16 +471,11 @@ describe( 'TableUI', () => {
 			mergeCellLeftCommand.isEnabled = false;
 			expect( mergeCellLeftButton.children.first.isEnabled ).to.be.false;
 
-			mergeCellsCommand.isEnabled = false;
-			expect( mergeCellsButton.children.first.isEnabled ).to.be.false;
-
 			splitCellVerticallyCommand.isEnabled = false;
 			expect( splitVerticallyButton.children.first.isEnabled ).to.be.false;
 
 			splitCellHorizontallyCommand.isEnabled = false;
 			expect( splitHorizontallyButton.children.first.isEnabled ).to.be.false;
-
-			expect( dropdown.buttonView.isEnabled ).to.be.false;
 		} );
 
 		it( 'should bind items in panel to proper commands (RTL content)', () => {

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

@@ -747,4 +747,461 @@ 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 );
+			} );
+		} );
+	} );
+
+	describe( 'removeColumns()', () => {
+		describe( 'single row', () => {
+			it( 'should remove a given column', () => {
+				setData( model, modelTable( [
+					[ '00', '01', '02' ],
+					[ '10', '11', '12' ],
+					[ '20', '21', '22' ]
+				] ) );
+
+				tableUtils.removeColumns( root.getNodeByPath( [ 0 ] ), { at: 1 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '00', '02' ],
+					[ '10', '12' ],
+					[ '20', '22' ]
+				] ) );
+			} );
+
+			it( 'should remove a given column from a table start', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10', '11' ],
+					[ '20', '21' ]
+				] ) );
+
+				tableUtils.removeColumns( root.getNodeByPath( [ 0 ] ), { at: 0 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '01' ],
+					[ '11' ],
+					[ '21' ]
+				] ) );
+			} );
+
+			it( 'should change heading columns if removing a heading column', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10', '11' ],
+					[ '20', '21' ]
+				], { headingColumns: 2 } ) );
+
+				tableUtils.removeColumns( root.getNodeByPath( [ 0 ] ), { at: 0 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '01' ],
+					[ '11' ],
+					[ '21' ]
+				], { headingColumns: 1 } ) );
+			} );
+
+			it( 'should decrease colspan of table cells from previous column', () => {
+				setData( model, modelTable( [
+					[ { colspan: 4, contents: '00' }, '04' ],
+					[ { colspan: 3, contents: '10' }, '13', '14' ],
+					[ { colspan: 2, contents: '20' }, '22', '23', '24' ],
+					[ '30', { colspan: 2, contents: '31' }, '33', '34' ],
+					[ '40', '41', '42', '43', '44' ]
+				] ) );
+
+				tableUtils.removeColumns( root.getNodeByPath( [ 0 ] ), { at: 2 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ { colspan: 3, contents: '00' }, '04' ],
+					[ { colspan: 2, contents: '10' }, '13', '14' ],
+					[ { colspan: 2, contents: '20' }, '23', '24' ],
+					[ '30', '31', '33', '34' ],
+					[ '40', '41', '43', '44' ]
+
+				] ) );
+			} );
+
+			it( 'should decrease colspan of cells that are on removed column', () => {
+				setData( model, modelTable( [
+					[ { colspan: 3, contents: '00' }, '03' ],
+					[ { colspan: 2, contents: '10' }, '12', '13' ],
+					[ '20', '21', '22', '23' ]
+				] ) );
+
+				tableUtils.removeColumns( root.getNodeByPath( [ 0 ] ), { at: 0 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ { colspan: 2, contents: '00' }, '03' ],
+					[ '10', '12', '13' ],
+					[ '21', '22', '23' ]
+				] ) );
+			} );
+
+			it( 'should remove column with rowspan (first column)', () => {
+				setData( model, modelTable( [
+					[ { rowspan: 2, contents: '00' }, '01' ],
+					[ '11' ]
+				] ) );
+
+				tableUtils.removeColumns( root.getNodeByPath( [ 0 ] ), { at: 0 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '01' ],
+					[ '11' ]
+				] ) );
+			} );
+
+			it( 'should remove column with rowspan (last column)', () => {
+				setData( model, modelTable( [
+					[ '00', { rowspan: 2, contents: '01' } ],
+					[ '10' ]
+				] ) );
+
+				tableUtils.removeColumns( root.getNodeByPath( [ 0 ] ), { at: 1 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '00' ],
+					[ '10' ]
+				] ) );
+			} );
+
+			it( 'should remove column if other column is rowspanned (last column)', () => {
+				setData( model, modelTable( [
+					[ '00', { rowspan: 2, contents: '01' } ],
+					[ '10' ]
+				] ) );
+
+				tableUtils.removeColumns( root.getNodeByPath( [ 0 ] ), { at: 0 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '01' ]
+				] ) );
+			} );
+
+			it( 'should remove column if other column is rowspanned (first column)', () => {
+				setData( model, modelTable( [
+					[ { rowspan: 2, contents: '00' }, '01' ],
+					[ '11' ]
+				] ) );
+
+				tableUtils.removeColumns( root.getNodeByPath( [ 0 ] ), { at: 1 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '00' ]
+				] ) );
+			} );
+
+			it( 'should remove column if removing row with one column - other columns are spanned', () => {
+				setData( model, modelTable( [
+					[ '00', { rowspan: 2, contents: '01' }, { rowspan: 2, contents: '02' } ],
+					[ '10' ],
+					[ '20', '21', '22' ]
+				] ) );
+
+				tableUtils.removeColumns( root.getNodeByPath( [ 0 ] ), { at: 0 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '01', '02' ],
+					[ '21', '22' ]
+				] ) );
+			} );
+		} );
+
+		describe( 'multiple columns', () => {
+			it( 'should properly remove two first columns', () => {
+				setData( model, modelTable( [
+					[ '00', '01', '02' ],
+					[ '10', '11', '12' ],
+					[ '20', '21', '22' ],
+					[ '30', '31', '32' ]
+				] ) );
+
+				tableUtils.removeColumns( root.getNodeByPath( [ 0 ] ), { at: 0, columns: 2 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '02' ],
+					[ '12' ],
+					[ '22' ],
+					[ '32' ]
+				] ) );
+			} );
+
+			it( 'should properly remove two middle columns', () => {
+				setData( model, modelTable( [
+					[ '00', '01', '02', '03' ],
+					[ '10', '11', '12', '13' ],
+					[ '20', '21', '22', '23' ],
+					[ '30', '31', '32', '33' ]
+				] ) );
+
+				tableUtils.removeColumns( root.getNodeByPath( [ 0 ] ), { at: 1, columns: 2 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '00', '03' ],
+					[ '10', '13' ],
+					[ '20', '23' ],
+					[ '30', '33' ]
+				] ) );
+			} );
+
+			it( 'should properly remove two last columns', () => {
+				setData( model, modelTable( [
+					[ '00', '01', '02' ],
+					[ '10', '11', '12' ],
+					[ '20', '21', '22' ],
+					[ '30', '31', '32' ]
+				] ) );
+
+				tableUtils.removeColumns( root.getNodeByPath( [ 0 ] ), { at: 1, columns: 2 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '00' ],
+					[ '10' ],
+					[ '20' ],
+					[ '30' ]
+				] ) );
+			} );
+
+			it( 'should properly remove multiple heading columns', () => {
+				setData( model, modelTable( [
+					[ '00', '01', '02', '03', '04' ],
+					[ '10', '11', '12', '13', '14' ]
+				], { headingColumns: 3 } ) );
+
+				tableUtils.removeColumns( root.getNodeByPath( [ 0 ] ), { at: 1, columns: 3 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '00', '04' ],
+					[ '10', '14' ]
+				], { headingColumns: 1 } ) );
+			} );
+
+			it( 'should properly calculate truncated colspans', () => {
+				setData( model, modelTable( [
+					[ { contents: '00', colspan: 3 } ],
+					[ '10', '11', '12' ],
+					[ '20', '21', '22' ]
+				] ) );
+
+				tableUtils.removeColumns( root.getNodeByPath( [ 0 ] ), { at: 0, columns: 2 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '00' ],
+					[ '12' ],
+					[ '22' ]
+				] ) );
+			} );
+		} );
+	} );
 } );

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

+ 4 - 4
packages/ckeditor5-table/theme/tableform.css

@@ -8,7 +8,7 @@
 		&.ck-table-form__border-row {
 			flex-wrap: wrap;
 
-			& .ck-labeled-view {
+			& .ck-labeled-field-view {
 				display: flex;
 				flex-direction: column-reverse;
 
@@ -30,7 +30,7 @@
 			flex-wrap: wrap;
 			align-items: center;
 
-			& .ck-labeled-view {
+			& .ck-labeled-field-view {
 				display: flex;
 				flex-direction: column-reverse;
 				align-items: center;
@@ -46,11 +46,11 @@
 		}
 	}
 
-	& .ck.ck-labeled-view {
+	& .ck.ck-labeled-field-view {
 		/* Allow absolute positioning of the status (error) balloons. */
 		position: relative;
 
-		& .ck.ck-labeled-view__status {
+		& .ck.ck-labeled-field-view__status {
 			position: absolute;
 			left: 50%;
 			bottom: calc( -1 * var(--ck-table-properties-error-arrow-size) );