Browse Source

Merge pull request #230 from ckeditor/i/6113

Feature: Introduced the `TableSelection` plugin. Closes ckeditor/ckeditor5#6113.
Aleksander Nowodzinski 5 years ago
parent
commit
ed709b3bcc

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

@@ -51,7 +51,6 @@ export default class RemoveColumnCommand extends Command {
 		const table = tableRow.parent;
 
 		const headingColumns = table.getAttribute( 'headingColumns' ) || 0;
-		const row = table.getChildIndex( tableRow );
 
 		// Cache the table before removing or updating colspans.
 		const tableMap = [ ...new TableWalker( table ) ];
@@ -59,10 +58,12 @@ export default class RemoveColumnCommand extends Command {
 		// Get column index of removed column.
 		const cellData = tableMap.find( value => value.cell === tableCell );
 		const removedColumn = cellData.column;
+		const selectionRow = cellData.row;
+		const cellToFocus = getCellToFocus( tableCell );
 
 		model.change( writer => {
 			// Update heading columns attribute if removing a row from head section.
-			if ( headingColumns && row <= headingColumns ) {
+			if ( headingColumns && selectionRow <= headingColumns ) {
 				writer.setAttribute( 'headingColumns', headingColumns - 1, table );
 			}
 
@@ -75,6 +76,21 @@ export default class RemoveColumnCommand extends Command {
 					writer.remove( cell );
 				}
 			}
+
+			writer.setSelection( writer.createPositionAt( cellToFocus, 0 ) );
 		} );
 	}
 }
+
+// Returns a proper table cell to focus after removing a column. It should be a next sibling to selection visually stay in place but:
+// - selection is on last table cell it will return previous cell.
+// - table cell is spanned over 2+ columns - it will be truncated so the selection should stay in that cell.
+function getCellToFocus( tableCell ) {
+	const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
+
+	if ( colspan > 1 ) {
+		return tableCell;
+	}
+
+	return tableCell.nextSibling ? tableCell.nextSibling : tableCell.previousSibling;
+}

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

@@ -48,30 +48,35 @@ export default class RemoveRowCommand extends Command {
 		const tableRow = tableCell.parent;
 		const table = tableRow.parent;
 
-		const currentRow = table.getChildIndex( tableRow );
+		const removedRow = table.getChildIndex( tableRow );
+
+		const tableMap = [ ...new TableWalker( table, { endRow: removedRow } ) ];
+
+		const cellData = tableMap.find( value => value.cell === tableCell );
+
 		const headingRows = table.getAttribute( 'headingRows' ) || 0;
 
+		const columnToFocus = cellData.column;
+
 		model.change( writer => {
-			if ( headingRows && currentRow <= headingRows ) {
+			if ( headingRows && removedRow <= headingRows ) {
 				updateNumericAttribute( 'headingRows', headingRows - 1, table, writer, 0 );
 			}
 
-			const tableMap = [ ...new TableWalker( table, { endRow: currentRow } ) ];
-
 			const cellsToMove = new Map();
 
 			// Get cells from removed row that are spanned over multiple rows.
 			tableMap
-				.filter( ( { row, rowspan } ) => row === currentRow && rowspan > 1 )
+				.filter( ( { row, rowspan } ) => row === removedRow && 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 <= currentRow - 1 && row + rowspan > currentRow )
+				.filter( ( { row, rowspan } ) => row <= removedRow - 1 && row + rowspan > removedRow )
 				.forEach( ( { cell, rowspan } ) => updateNumericAttribute( 'rowspan', rowspan - 1, cell, writer ) );
 
 			// Move cells to another row.
-			const targetRow = currentRow + 1;
+			const targetRow = removedRow + 1;
 			const tableWalker = new TableWalker( table, { includeSpanned: true, startRow: targetRow, endRow: targetRow } );
 
 			let previousCell;
@@ -93,6 +98,27 @@ export default class RemoveRowCommand extends Command {
 			}
 
 			writer.remove( tableRow );
+
+			const cellToFocus = getCellToFocus( table, removedRow, columnToFocus );
+			writer.setSelection( writer.createPositionAt( cellToFocus, 0 ) );
 		} );
 	}
 }
+
+// Returns a cell that should be focused before removing the row, belonging to the same column as the currently focused cell.
+function getCellToFocus( table, removedRow, columnToFocus ) {
+	const row = table.getChild( removedRow );
+
+	// Default to first table cell.
+	let cellToFocus = row.getChild( 0 );
+	let column = 0;
+
+	for ( const tableCell of row.getChildren() ) {
+		if ( column > columnToFocus ) {
+			return cellToFocus;
+		}
+
+		cellToFocus = tableCell;
+		column += parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
+	}
+}

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

@@ -10,14 +10,15 @@
 import { isObject } from 'lodash-es';
 
 /**
- * Returns the parent element of the given name. Returns undefined if the position is not inside the desired parent.
+ * Returns the parent element of the given name. Returns undefined if the position or the element is not inside the desired parent.
  *
  * @param {String} parentName The name of the parent element to find.
- * @param {module:engine/model/position~Position|module:engine/model/position~Position} position The position to start searching.
+ * @param {module:engine/model/position~Position|module:engine/model/position~Position} positionOrElement The position or
+ * the parentElement to start searching.
  * @returns {module:engine/model/element~Element|module:engine/model/documentfragment~DocumentFragment}
  */
-export function findAncestor( parentName, position ) {
-	let parent = position.parent;
+export function findAncestor( parentName, positionOrElement ) {
+	let parent = positionOrElement.parent;
 
 	while ( parent ) {
 		if ( parent.name === parentName ) {

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

@@ -75,7 +75,7 @@ export default class TableEditing extends Plugin {
 		schema.register( 'tableCell', {
 			allowIn: 'tableRow',
 			allowAttributes: [ 'colspan', 'rowspan' ],
-			isLimit: true
+			isObject: true
 		} );
 
 		// Allow all $block content inside table cell.

+ 260 - 0
packages/ckeditor5-table/src/tableselection.js

@@ -0,0 +1,260 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tableselection
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+
+import TableWalker from './tablewalker';
+import TableUtils from './tableutils';
+import { setupTableSelectionHighlighting } from './tableselection/converters';
+import MouseSelectionHandler from './tableselection/mouseselectionhandler';
+
+import '../theme/tableselection.css';
+
+/**
+ * The table selection plugin.
+ *
+ * It introduces the ability to select table cells. The table selection is described by two nodes: start and end.
+ * Both are the oposite corners of an rectangle that spans over them.
+ *
+ * Consider a table:
+ *
+ *		    0   1   2   3
+ *		  +---+---+---+---+
+ *		0 | a | b | c | d |
+ *		  +-------+   +---+
+ *		1 | e | f |   | g |
+ *		  +---+---+---+---+
+ *		2 | h | i     | j |
+ *		  +---+---+---+---+
+ *
+ * Setting the table selection start in table cell "b" and the end in table cell "g" will select table cells: "b", "c", "d", "f", and "g".
+ * The cells that span over multiple rows or columns can extend over the selection rectangle. For instance, setting a selection from
+ * the table cell "a" to the table cell "i" will create a selection in which the table cell "i" will be (partially) outside
+ * the rectangle of selected cells: "a", "b", "e", "f", "h", and "i".
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class TableSelection extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'TableSelection';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ TableUtils ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	constructor( editor ) {
+		super( editor );
+
+		/**
+		 * A mouse selection handler.
+		 *
+		 * @private
+		 * @readonly
+		 * @member {module:table/tableselection/mouseselectionhandler~MouseSelectionHandler}
+		 */
+		this._mouseHandler = new MouseSelectionHandler( this, this.editor.editing );
+
+		/**
+		 * A reference to the table utilities used across the class.
+		 *
+		 * @private
+		 * @readonly
+		 * @member {module:table/tableutils~TableUtils} #_tableUtils
+		 */
+	}
+
+	/**
+	 * A flag indicating that there are selected table cells and the selection includes more than one table cell.
+	 *
+	 * @type {Boolean}
+	 */
+	get hasMultiCellSelection() {
+		return !!this._startElement && !!this._endElement && this._startElement !== this._endElement;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const selection = editor.model.document.selection;
+
+		this._tableUtils = editor.plugins.get( 'TableUtils' );
+
+		setupTableSelectionHighlighting( editor, this );
+
+		selection.on( 'change:range', () => this._clearSelectionOnExternalChange( selection ) );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	destroy() {
+		super.destroy();
+		this._mouseHandler.stopListening();
+	}
+
+	/**
+	 * Marks the table cell as a start of a table selection.
+	 *
+	 *		editor.plugins.get( 'TableSelection' ).startSelectingFrom( tableCell );
+	 *
+	 * This method will clear the previous selection. The model selection will not be updated until
+	 * the {@link #setSelectingTo} method is used.
+	 *
+	 * @param {module:engine/model/element~Element} tableCell
+	 */
+	startSelectingFrom( tableCell ) {
+		this.clearSelection();
+
+		this._startElement = tableCell;
+		this._endElement = tableCell;
+	}
+
+	/**
+	 * Updates the current table selection end element. Table selection is defined by a start and an end element.
+	 * This method updates the end element. Must be preceded by {@link #startSelectingFrom}.
+	 *
+	 *		editor.plugins.get( 'TableSelection' ).startSelectingFrom( startTableCell );
+	 *
+	 *		editor.plugins.get( 'TableSelection' ).setSelectingTo( endTableCell );
+	 *
+	 * This method will update model selection if start and end cells are different and belongs to the same table.
+	 *
+	 * @param {module:engine/model/element~Element} tableCell
+	 */
+	setSelectingTo( tableCell ) {
+		if ( !this._startElement ) {
+			this._startElement = tableCell;
+		}
+
+		const table = this._startElement.parent.parent;
+
+		// Do not add tableCell to selection if it is from other table or is already set as end element.
+		if ( table !== tableCell.parent.parent || this._endElement === tableCell ) {
+			return;
+		}
+
+		this._endElement = tableCell;
+		this._updateModelSelection();
+	}
+
+	/**
+	 * Stops the selection process (but do not clear the current selection).
+	 * The selection process is finished but the selection in the model remains.
+	 *
+	 *		editor.plugins.get( 'TableSelection' ).startSelectingFrom( startTableCell );
+	 *		editor.plugins.get( 'TableSelection' ).setSelectingTo( endTableCell );
+	 *		editor.plugins.get( 'TableSelection' ).stopSelection();
+	 *
+	 * To clear the selection use {@link #clearSelection}.
+	 *
+	 * @param {module:engine/model/element~Element} [tableCell]
+	 */
+	stopSelection( tableCell ) {
+		if ( tableCell && tableCell.parent.parent === this._startElement.parent.parent ) {
+			this._endElement = tableCell;
+		}
+
+		this._updateModelSelection();
+	}
+
+	/**
+	 * Stops the current selection process and clears the table selection in the model.
+	 *
+	 *		editor.plugins.get( 'TableSelection' ).startSelectingFrom( startTableCell );
+	 *		editor.plugins.get( 'TableSelection' ).setSelectingTo( endTableCell );
+	 *		editor.plugins.get( 'TableSelection' ).stopSelection();
+	 *
+	 *		editor.plugins.get( 'TableSelection' ).clearSelection();
+	 */
+	clearSelection() {
+		this._startElement = undefined;
+		this._endElement = undefined;
+	}
+
+	/**
+	 * Returns an iterator for selected table cells.
+	 *
+	 *		tableSelection.startSelectingFrom( startTableCell );
+	 *		tableSelection.stopSelection( endTableCell );
+	 *
+	 *		const selectedTableCells = Array.from( tableSelection.getSelectedTableCells() );
+	 *		// The above array will represent a rectangular table selection.
+	 *
+	 * @returns {Iterable.<module:engine/model/element~Element>}
+	 */
+	* getSelectedTableCells() {
+		if ( !this.hasMultiCellSelection ) {
+			return;
+		}
+
+		const startLocation = this._tableUtils.getCellLocation( this._startElement );
+		const endLocation = this._tableUtils.getCellLocation( this._endElement );
+
+		const startRow = Math.min( startLocation.row, endLocation.row );
+		const endRow = Math.max( startLocation.row, endLocation.row );
+
+		const startColumn = Math.min( startLocation.column, endLocation.column );
+		const endColumn = Math.max( startLocation.column, endLocation.column );
+
+		for ( const cellInfo of new TableWalker( this._startElement.parent.parent, { startRow, endRow } ) ) {
+			if ( cellInfo.column >= startColumn && cellInfo.column <= endColumn ) {
+				yield cellInfo.cell;
+			}
+		}
+	}
+
+	/**
+	 * Synchronizes the model selection with currently selected table cells.
+	 *
+	 * @private
+	 */
+	_updateModelSelection() {
+		if ( !this.hasMultiCellSelection ) {
+			return;
+		}
+
+		const editor = this.editor;
+		const model = editor.model;
+
+		const modelRanges = [];
+
+		for ( const tableCell of this.getSelectedTableCells() ) {
+			modelRanges.push( model.createRangeOn( tableCell ) );
+		}
+
+		// Update model's selection
+		model.change( writer => {
+			writer.setSelection( modelRanges );
+		} );
+	}
+
+	/**
+	 * Checks if the selection has changed via an external change and if it is required to clear the internal state of the plugin.
+	 *
+	 * @param {module:engine/model/documentselection~DocumentSelection} selection
+	 * @private
+	 */
+	_clearSelectionOnExternalChange( selection ) {
+		if ( selection.rangeCount <= 1 && this.hasMultiCellSelection ) {
+			this.clearSelection();
+		}
+	}
+}

+ 49 - 0
packages/ckeditor5-table/src/tableselection/converters.js

@@ -0,0 +1,49 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tableselection/converters
+ */
+
+/**
+ * Adds a visual highlight style to selected table cells.
+ *
+ * @param {module:core/editor/editor~Editor} editor
+ * @param {module:table/tableselection~TableSelection} tableSelection
+ */
+export function setupTableSelectionHighlighting( editor, tableSelection ) {
+	const highlighted = new Set();
+
+	editor.conversion.for( 'editingDowncast' ).add( dispatcher => dispatcher.on( 'selection', ( evt, data, conversionApi ) => {
+		const view = editor.editing.view;
+		const viewWriter = conversionApi.writer;
+		const viewSelection = viewWriter.document.selection;
+
+		if ( tableSelection.hasMultiCellSelection ) {
+			clearHighlightedTableCells( highlighted, view );
+
+			for ( const tableCell of tableSelection.getSelectedTableCells() ) {
+				const viewElement = conversionApi.mapper.toViewElement( tableCell );
+
+				viewWriter.addClass( 'ck-editor__editable_selected', viewElement );
+				highlighted.add( viewElement );
+			}
+
+			viewWriter.setSelection( viewSelection.getRanges(), { fake: true, label: 'TABLE' } );
+		} else {
+			clearHighlightedTableCells( highlighted, view );
+		}
+	}, { priority: 'lowest' } ) );
+}
+
+function clearHighlightedTableCells( highlighted, view ) {
+	const previous = [ ...highlighted.values() ];
+
+	view.change( writer => {
+		for ( const previouslyHighlighted of previous ) {
+			writer.removeClass( 'ck-editor__editable_selected', previouslyHighlighted );
+		}
+	} );
+}

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

@@ -0,0 +1,86 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tableselection/mouseeventsobserver
+ */
+
+import DomEventObserver from '@ckeditor/ckeditor5-engine/src/view/observer/domeventobserver';
+
+/**
+ * The mouse selection events observer.
+ *
+ * It registers listeners for DOM events:
+ *
+ * - `'mousemove'`
+ * - `'mouseup'`
+ * - `'mouseleave'`
+ *
+ * Note that this observer is disabled by default. To enable this observer it needs to be added to
+ * {@link module:engine/view/view~View} using the {@link module:engine/view/view~View#addObserver} method.
+ *
+ * It is registered by {@link module:table/tableselection/mouseselectionhandler~MouseSelectionHandler}.
+ *
+ * @extends module:engine/view/observer/domeventobserver~DomEventObserver
+ */
+export default class MouseEventsObserver extends DomEventObserver {
+	/**
+	 * @inheritDoc
+	 */
+	constructor( view ) {
+		super( view );
+
+		this.domEventType = [ 'mousemove', 'mouseup', 'mouseleave' ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	onDomEvent( domEvent ) {
+		this.fire( domEvent.type, domEvent );
+	}
+}
+
+/**
+ * Fired when the mouse button is released over one of the editables.
+ *
+ * Introduced by {@link module:table/tableselection/mouseeventsobserver~MouseEventsObserver}.
+ *
+ * Note that this event is not available by default. To make it available
+ * {@link module:table/tableselection/mouseeventsobserver~MouseEventsObserver} needs to be added
+ * to {@link module:engine/view/view~View} using the {@link module:engine/view/view~View#addObserver} method.
+ *
+ * @see module:table/tableselection/mouseeventsobserver~MouseEventsObserver
+ * @event module:engine/view/document~Document#event:mouseup
+ * @param {module:engine/view/observer/domeventdata~DomEventData} data Event data.
+ */
+
+/**
+ * Fired when the mouse is moved over one of the editables.
+ *
+ * Introduced by {@link module:table/tableselection/mouseeventsobserver~MouseEventsObserver}.
+ *
+ * Note that this event is not available by default. To make it available
+ * {@link module:table/tableselection/mouseeventsobserver~MouseEventsObserver} needs to be added
+ * to {@link module:engine/view/view~View} using the {@link module:engine/view/view~View#addObserver} method.
+ *
+ * @see module:table/tableselection/mouseeventsobserver~MouseEventsObserver
+ * @event module:engine/view/document~Document#event:mousemove
+ * @param {module:engine/view/observer/domeventdata~DomEventData} data Event data.
+ */
+
+/**
+ * Fired when the mouse is moved out of one of the editables.
+ *
+ * Introduced by {@link module:table/tableselection/mouseeventsobserver~MouseEventsObserver}.
+ *
+ * Note that this event is not available by default. To make it available
+ * {@link module:table/tableselection/mouseeventsobserver~MouseEventsObserver} needs to be added
+ * to {@link module:engine/view/view~View} using the {@link module:engine/view/view~View#addObserver} method.
+ *
+ * @see module:table/tableselection/mouseeventsobserver~MouseEventsObserver
+ * @event module:engine/view/document~Document#event:mouseleave
+ * @param {module:engine/view/observer/domeventdata~DomEventData} data Event data.
+ */

+ 160 - 0
packages/ckeditor5-table/src/tableselection/mouseselectionhandler.js

@@ -0,0 +1,160 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tableselection/mouseselectionhandler
+ */
+
+import mix from '@ckeditor/ckeditor5-utils/src/mix';
+import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
+
+import { findAncestor } from '../commands/utils';
+import MouseEventsObserver from './mouseeventsobserver';
+
+/**
+ * A mouse selection handler class for the table selection.
+ *
+ * It registers the {@link module:table/tableselection/mouseeventsobserver~MouseEventsObserver} to observe view document mouse events
+ * and invoke proper {@link module:table/tableselection~TableSelection} actions.
+ */
+export default class MouseSelectionHandler {
+	/**
+	 * Creates an instance of the `MouseSelectionHandler`.
+	 *
+	 * @param {module:table/tableselection~TableSelection} tableSelection
+	 * @param {module:engine/controller/editingcontroller~EditingController} editing
+	 */
+	constructor( tableSelection, editing ) {
+		/**
+		 * The table selection plugin instance.
+		 *
+		 * @private
+		 * @readonly
+		 * @member {module:table/tableselection~TableSelection}
+		 */
+		this._tableSelection = tableSelection;
+
+		/**
+		 * A flag indicating that the mouse selection is "active". A selection is "active" if it was started and not yet finished.
+		 * A selection can be "active", for instance, if a user moves a mouse over a table while holding a mouse button down.
+		 *
+		 * @readonly
+		 * @member {Boolean}
+		 */
+		this.isSelecting = false;
+
+		/**
+		 * Editing mapper.
+		 *
+		 * @private
+		 * @readonly
+		 * @member {module:engine/conversion/mapper~Mapper}
+		 */
+		this._mapper = editing.mapper;
+
+		const view = editing.view;
+
+		// Currently the MouseObserver only handles `mouseup` events.
+		view.addObserver( MouseEventsObserver );
+
+		this.listenTo( view.document, 'mousedown', ( event, domEventData ) => this._handleMouseDown( domEventData ) );
+		this.listenTo( view.document, 'mousemove', ( event, domEventData ) => this._handleMouseMove( domEventData ) );
+		this.listenTo( view.document, 'mouseup', ( event, domEventData ) => this._handleMouseUp( domEventData ) );
+	}
+
+	/**
+	 * Handles starting a selection when "mousedown" event has table cell as a DOM target.
+	 *
+	 * If there is no table cell in the event target, it will clear the previous selection.
+	 *
+	 * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData
+	 * @private
+	 */
+	_handleMouseDown( domEventData ) {
+		const tableCell = this._getModelTableCellFromDomEvent( domEventData );
+
+		if ( !tableCell ) {
+			this._tableSelection.clearSelection();
+			this._tableSelection.stopSelection();
+
+			return;
+		}
+
+		this.isSelecting = true;
+		this._tableSelection.startSelectingFrom( tableCell );
+	}
+
+	/**
+	 * Handles updating the table selection when the "mousemove" event has a table cell as a DOM target.
+	 *
+	 * Does nothing if there is no table cell in event target or the selection has not been started yet.
+	 *
+	 * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData
+	 * @private
+	 */
+	_handleMouseMove( domEventData ) {
+		if ( !isButtonPressed( domEventData ) ) {
+			this._tableSelection.stopSelection();
+
+			return;
+		}
+
+		const tableCell = this._getModelTableCellFromDomEvent( domEventData );
+
+		if ( !tableCell ) {
+			return;
+		}
+
+		this._tableSelection.setSelectingTo( tableCell );
+	}
+
+	/**
+	 * Handles ending (not clearing) the table selection on the "mouseup" event.
+	 *
+	 * Does nothing if the selection has not been started yet.
+	 *
+	 * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData
+	 * @private
+	 */
+	_handleMouseUp( domEventData ) {
+		if ( !this.isSelecting ) {
+			return;
+		}
+
+		const tableCell = this._getModelTableCellFromDomEvent( domEventData );
+
+		// Selection can be stopped if table cell is undefined.
+		this._tableSelection.stopSelection( tableCell );
+	}
+
+	/**
+	 * Finds a model table cell for a given DOM event.
+	 *
+	 * @private
+	 * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData
+	 * @returns {module:engine/model/element~Element|undefined} Returns model table cell or undefined event target is not
+	 * a mapped table cell.
+	 */
+	_getModelTableCellFromDomEvent( domEventData ) {
+		const viewTargetElement = domEventData.target;
+		const modelElement = this._mapper.toModelElement( viewTargetElement );
+
+		if ( !modelElement ) {
+			return;
+		}
+
+		if ( modelElement.is( 'tableCell' ) ) {
+			return modelElement;
+		}
+
+		return findAncestor( 'tableCell', modelElement );
+	}
+}
+
+mix( MouseSelectionHandler, ObservableMixin );
+
+function isButtonPressed( domEventData ) {
+	return !!domEventData.domEvent.buttons;
+}

+ 66 - 4
packages/ckeditor5-table/tests/_utils/utils.js

@@ -196,7 +196,7 @@ export function defaultSchema( schema, registerParagraph = true ) {
 	schema.register( 'tableCell', {
 		allowIn: 'tableRow',
 		allowAttributes: [ 'colspan', 'rowspan' ],
-		isLimit: true
+		isObject: true
 	} );
 
 	// Allow all $block content inside table cell.
@@ -322,11 +322,59 @@ export function assertTableStyle( editor, tableStyle, figureStyle ) {
 export function assertTableCellStyle( editor, tableCellStyle ) {
 	assertEqualMarkup( editor.getData(),
 		'<figure class="table"><table><tbody><tr>' +
-			`<td${ tableCellStyle ? ` style="${ tableCellStyle }"` : '' }>foo</td>` +
+		`<td${ tableCellStyle ? ` style="${ tableCellStyle }"` : '' }>foo</td>` +
 		'</tr></tbody></table></figure>'
 	);
 }
 
+/**
+ * A helper method for asserting selected table cells.
+ *
+ * To check if a table has expected cells selected pass two dimensional array of truthy and falsy values:
+ *
+ *		assertSelectedCells( model, [
+ *			[ 0, 1 ],
+ *			[ 0, 1 ]
+ *		] );
+ *
+ * The above call will check if table has second column selected (assuming no spans).
+ *
+ * **Note**: This function operates on child indexes - not rows/columns.
+ */
+export function assertSelectedCells( model, tableMap ) {
+	const tableIndex = 0;
+
+	for ( let rowIndex = 0; rowIndex < tableMap.length; rowIndex++ ) {
+		const row = tableMap[ rowIndex ];
+
+		for ( let cellIndex = 0; cellIndex < row.length; cellIndex++ ) {
+			const expectSelected = row[ cellIndex ];
+
+			if ( expectSelected ) {
+				assertNodeIsSelected( model, [ tableIndex, rowIndex, cellIndex ] );
+			} else {
+				assertNodeIsNotSelected( model, [ tableIndex, rowIndex, cellIndex ] );
+			}
+		}
+	}
+}
+
+function assertNodeIsSelected( model, path ) {
+	const modelRoot = model.document.getRoot();
+	const node = modelRoot.getNodeByPath( path );
+	const selectionRanges = Array.from( model.document.selection.getRanges() );
+
+	expect( selectionRanges.some( range => range.containsItem( node ) ), `Expected node [${ path }] to be selected` ).to.be.true;
+}
+
+function assertNodeIsNotSelected( model, path ) {
+	const modelRoot = model.document.getRoot();
+	const node = modelRoot.getNodeByPath( path );
+	const selectionRanges = Array.from( model.document.selection.getRanges() );
+
+	expect( selectionRanges.every( range => !range.containsItem( node ) ), `Expected node [${ path }] to be not selected` ).to.be.true;
+}
+
 // Formats table cell attributes
 //
 // @param {Object} attributes Attributes of a cell.
@@ -356,20 +404,24 @@ function makeRows( tableData, options ) {
 				let contents = isObject ? tableCellData.contents : tableCellData;
 
 				let resultingCellElement = cellElement;
+				let isSelected = false;
 
 				if ( isObject ) {
 					if ( tableCellData.isHeading ) {
 						resultingCellElement = headingElement;
 					}
 
+					isSelected = !!tableCellData.isSelected;
+
 					delete tableCellData.contents;
 					delete tableCellData.isHeading;
+					delete tableCellData.isSelected;
 				}
 
 				const attributes = isObject ? tableCellData : {};
 
 				if ( asWidget ) {
-					attributes.class = WIDGET_TABLE_CELL_CLASS + ( attributes.class ? ` ${ attributes.class }` : '' );
+					attributes.class = getClassToSet( attributes );
 					attributes.contenteditable = 'true';
 				}
 
@@ -381,7 +433,9 @@ function makeRows( tableData, options ) {
 				}
 
 				const formattedAttributes = formatAttributes( attributes );
-				tableRowString += `<${ resultingCellElement }${ formattedAttributes }>${ contents }</${ resultingCellElement }>`;
+				const tableCell = `<${ resultingCellElement }${ formattedAttributes }>${ contents }</${ resultingCellElement }>`;
+
+				tableRowString += isSelected ? `[${ tableCell }]` : tableCell;
 
 				return tableRowString;
 			}, '' );
@@ -389,3 +443,11 @@ function makeRows( tableData, options ) {
 			return `${ previousRowsString }<${ rowElement }>${ tableRowString }</${ rowElement }>`;
 		}, '' );
 }
+
+// Properly handles passed CSS class - editor do sort them.
+function getClassToSet( attributes ) {
+	return ( WIDGET_TABLE_CELL_CLASS + ( attributes.class ? ` ${ attributes.class }` : '' ) )
+		.split( ' ' )
+		.sort()
+		.join( ' ' );
+}

+ 20 - 4
packages/ckeditor5-table/tests/commands/removecolumncommand.js

@@ -72,7 +72,7 @@ describe( 'RemoveColumnCommand', () => {
 
 			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00', '02' ],
-				[ '<paragraph>10[]</paragraph>', '12' ],
+				[ '10', '[]12' ],
 				[ '20', '22' ]
 			] ) );
 		} );
@@ -87,7 +87,7 @@ describe( 'RemoveColumnCommand', () => {
 			command.execute();
 
 			assertEqualMarkup( getData( model ), modelTable( [
-				[ '<paragraph>[]01</paragraph>' ],
+				[ '[]01' ],
 				[ '11' ],
 				[ '21' ]
 			] ) );
@@ -104,7 +104,7 @@ describe( 'RemoveColumnCommand', () => {
 
 			assertEqualMarkup( getData( model ), modelTable( [
 				[ '01' ],
-				[ '<paragraph>[]11</paragraph>' ],
+				[ '[]11' ],
 				[ '21' ]
 			], { headingColumns: 1 } ) );
 		} );
@@ -123,7 +123,7 @@ describe( 'RemoveColumnCommand', () => {
 			assertEqualMarkup( getData( model ), modelTable( [
 				[ { colspan: 3, contents: '00' }, '03' ],
 				[ { colspan: 2, contents: '10' }, '13' ],
-				[ { colspan: 2, contents: '<paragraph>20[]</paragraph>' }, '23' ],
+				[ { colspan: 2, contents: '20' }, '[]23' ],
 				[ '30', '31', '33' ],
 				[ '40', '41', '43' ]
 
@@ -145,5 +145,21 @@ describe( 'RemoveColumnCommand', () => {
 				[ '21', '22', '23' ]
 			] ) );
 		} );
+
+		it( 'should move focus to previous column of removed cell if in last column', () => {
+			setData( model, modelTable( [
+				[ '00', '01', '02' ],
+				[ '10', '11', '12[]' ],
+				[ '20', '21', '22' ]
+			] ) );
+
+			command.execute();
+
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ '00', '01' ],
+				[ '10', '[]11' ],
+				[ '20', '21' ]
+			] ) );
+		} );
 	} );
 } );

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

@@ -65,8 +65,8 @@ describe( 'RemoveRowCommand', () => {
 			command.execute();
 
 			assertEqualMarkup( getData( model ), modelTable( [
-				[ '00', '<paragraph>01[]</paragraph>' ],
-				[ '20', '21' ]
+				[ '00', '01' ],
+				[ '[]20', '21' ]
 			] ) );
 		} );
 
@@ -80,7 +80,7 @@ describe( 'RemoveRowCommand', () => {
 			command.execute();
 
 			assertEqualMarkup( getData( model ), modelTable( [
-				[ '<paragraph>[]10</paragraph>', '11' ],
+				[ '[]10', '11' ],
 				[ '20', '21' ]
 			] ) );
 		} );
@@ -95,8 +95,8 @@ describe( 'RemoveRowCommand', () => {
 			command.execute();
 
 			assertEqualMarkup( getData( model ), modelTable( [
-				[ '00', '<paragraph>01[]</paragraph>' ],
-				[ '20', '21' ]
+				[ '00', '01' ],
+				[ '[]20', '21' ]
 			], { headingRows: 1 } ) );
 		} );
 
@@ -112,8 +112,8 @@ describe( 'RemoveRowCommand', () => {
 
 			assertEqualMarkup( getData( model ), modelTable( [
 				[ { rowspan: 3, contents: '00' }, { rowspan: 2, contents: '01' }, { rowspan: 2, contents: '02' }, '03', '04' ],
-				[ '13', '<paragraph>14[]</paragraph>' ],
-				[ '30', '31', '32', '33', '34' ]
+				[ '13', '14' ],
+				[ '30', '31', '[]32', '33', '34' ]
 			] ) );
 		} );
 

+ 151 - 0
packages/ckeditor5-table/tests/manual/tableselection.html

@@ -0,0 +1,151 @@
+<style>
+	body {
+		font-family: Helvetica, Arial, sans-serif;
+		font-size: 14px;
+	}
+
+	.print-selected {
+		background: #b8e3ff;
+	}
+</style>
+
+<div id="editor">
+	<h3>A simple table to test selection:</h3>
+	<table>
+		<thead>
+			<tr>
+				<td>0</td>
+				<td>1</td>
+				<td>2</td>
+				<td>3</td>
+				<td>4</td>
+			</tr>
+		</thead>
+		<tbody>
+			<tr>
+				<td>a</td>
+				<td>b</td>
+				<td>c</td>
+				<td>d</td>
+				<td>e</td>
+			</tr>
+			<tr>
+				<td>f</td>
+				<td>g</td>
+				<td>h</td>
+				<td>i</td>
+				<td>j</td>
+			</tr>
+			<tr>
+				<td>k</td>
+				<td>l</td>
+				<td>m</td>
+				<td>n</td>
+				<td>o</td>
+			</tr>
+			<tr>
+				<td>p</td>
+				<td>q</td>
+				<td>r</td>
+				<td>s</td>
+				<td>t</td>
+			</tr>
+		</tbody>
+	</table>
+
+	<h3>A complex table</h3>
+
+	<figure class="table">
+		<table>
+			<thead>
+				<tr>
+					<td>a</td>
+					<td>b</td>
+					<td>c</td>
+					<td>d</td>
+					<td>e</td>
+					<td>f</td>
+					<td>g</td>
+					<td>h</td>
+					<td>i</td>
+				</tr>
+			</thead>
+			<tbody>
+				<tr>
+					<td>00</td>
+					<td>01</td>
+					<td rowspan="4">02</td>
+					<td>03</td>
+					<td rowspan="7" colspan="2">04</td>
+					<td>07</td>
+					<td>07</td>
+					<td>08</td>
+				</tr>
+				<tr>
+					<td>10</td>
+					<td>11</td>
+					<td>13</td>
+					<td>17</td>
+					<td>17</td>
+					<td>18</td>
+				</tr>
+				<tr>
+					<td>20</td>
+					<td>21</td>
+					<td>23</td>
+					<td colspan="3">27</td>
+				</tr>
+				<tr>
+					<td>30</td>
+					<td>31</td>
+					<td>33</td>
+					<td>37</td>
+					<td colspan="2">37</td>
+				</tr>
+				<tr>
+					<td colspan="4">40</td>
+					<td>47</td>
+					<td>47</td>
+					<td>48</td>
+				</tr>
+				<tr>
+					<td>50</td>
+					<td>51</td>
+					<td>52</td>
+					<td>53</td>
+					<td rowspan="4">57</td>
+					<td>57</td>
+					<td>58</td>
+				</tr>
+				<tr>
+					<td>60</td>
+					<td colspan="3">61</td>
+					<td>67</td>
+					<td>68</td>
+				</tr>
+				<tr>
+					<td>70</td>
+					<td rowspan="2">71</td>
+					<td>72</td>
+					<td>73</td>
+					<td>74</td>
+					<td>75</td>
+					<td>77</td>
+					<td>78</td>
+				</tr>
+				<tr>
+					<td>80</td>
+					<td>82</td>
+					<td>83</td>
+					<td>84</td>
+					<td>85</td>
+					<td>87</td>
+					<td>88</td>
+				</tr>
+			</tbody>
+		</table>
+	</figure>
+</div>
+
+<h2>Model contents:</h2>
+<pre><code id="model"></code></pre>

+ 64 - 0
packages/ckeditor5-table/tests/manual/tableselection.js

@@ -0,0 +1,64 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals console, window, document, global */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import Table from '../../src/table';
+import TableToolbar from '../../src/tabletoolbar';
+import { getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import TableSelection from '../../src/tableselection';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ ArticlePluginSet, Table, TableToolbar, TableSelection ],
+		toolbar: [
+			'heading', '|', 'insertTable', '|', 'bold', 'italic', 'bulletedList', 'numberedList', 'blockQuote', 'undo', 'redo'
+		],
+		table: {
+			contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells' ]
+		}
+	} )
+	.then( editor => {
+		window.editor = editor;
+		editor.model.document.on( 'change', () => {
+			printModelContents( editor );
+		} );
+
+		printModelContents( editor );
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );
+
+const modelDiv = global.document.querySelector( '#model' );
+
+function printModelContents( editor ) {
+	modelDiv.innerHTML = formatTable( getData( editor.model ) )
+		.replace( /</g, '&lt;' )
+		.replace( />/g, '&gt;' )
+		.replace( /\n/g, '<br>' )
+		.replace( /\[/g, '<span class="print-selected">[' )
+		.replace( /]/g, ']</span>' );
+}
+
+function formatTable( tableString ) {
+	return tableString
+		.replace( /<table>/g, '\n<table' )
+		.replace( /<table /g, '\n<table ' )
+		.replace( /<tableRow>/g, '\n<tableRow>\n    ' )
+		.replace( /<thead>/g, '\n<thead>\n    ' )
+		.replace( /<tbody>/g, '\n<tbody>\n    ' )
+		.replace( /<tr>/g, '\n<tr>\n    ' )
+		.replace( /<\/tableRow>/g, '\n</tableRow>' )
+		.replace( /<\/thead>/g, '\n</thead>' )
+		.replace( /<\/tbody>/g, '\n</tbody>' )
+		.replace( /<\/tr>/g, '\n</tr>' )
+		.replace( /<\/table>/g, '\n</table>' )
+		.replace( /tableCell/g, 'cell' )
+		.replace( /tableRow/g, 'row' )
+		.replace( /paragraph/g, 'p' );
+}

+ 6 - 0
packages/ckeditor5-table/tests/manual/tableselection.md

@@ -0,0 +1,6 @@
+### Testing
+
+Selecting table cells:
+
+1. It should be possible to select multiple table cells.
+2. Observe selection inn the below model representation - for a block selection the table cells should be selected.

+ 1 - 3
packages/ckeditor5-table/tests/tableediting.js

@@ -259,9 +259,7 @@ describe( 'TableEditing', () => {
 
 		describe( 'on TAB', () => {
 			it( 'should do nothing if selection is not in a table', () => {
-				setModelData( model, '<paragraph>[]</paragraph>' + modelTable( [
-					[ '11', '12' ]
-				] ) );
+				setModelData( model, '<paragraph>[]</paragraph>' + modelTable( [ [ '11', '12' ] ] ) );
 
 				editor.editing.view.document.fire( 'keydown', domEvtDataStub );
 

+ 325 - 0
packages/ckeditor5-table/tests/tableselection.js

@@ -0,0 +1,325 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import TableEditing from '../src/tableediting';
+import TableSelection from '../src/tableselection';
+import { assertSelectedCells, modelTable, viewTable } from './_utils/utils';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
+import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+
+describe( 'table selection', () => {
+	let editor, model, tableSelection, modelRoot;
+
+	beforeEach( async () => {
+		editor = await VirtualTestEditor.create( {
+			plugins: [ TableEditing, TableSelection, Paragraph ]
+		} );
+
+		model = editor.model;
+		modelRoot = model.document.getRoot();
+		tableSelection = editor.plugins.get( TableSelection );
+
+		setModelData( model, modelTable( [
+			[ '11[]', '12', '13' ],
+			[ '21', '22', '23' ],
+			[ '31', '32', '33' ]
+		] ) );
+	} );
+
+	afterEach( async () => {
+		await editor.destroy();
+	} );
+
+	describe( 'TableSelection', () => {
+		describe( 'startSelectingFrom()', () => {
+			it( 'should not change model selection', () => {
+				const spy = sinon.spy();
+
+				model.document.selection.on( 'change', spy );
+
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) );
+
+				sinon.assert.notCalled( spy );
+			} );
+		} );
+
+		describe( 'setSelectingTo()', () => {
+			it( 'should set model selection on passed cell if startSelectingFrom() was not used', () => {
+				const spy = sinon.spy();
+
+				model.document.selection.on( 'change', spy );
+
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) );
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) );
+
+				sinon.assert.calledOnce( spy );
+
+				assertSelectedCells( model, [
+					[ 1, 1, 0 ],
+					[ 0, 0, 0 ],
+					[ 0, 0, 0 ]
+				] );
+			} );
+
+			it( 'should change model selection if valid selection will be set', () => {
+				const spy = sinon.spy();
+
+				model.document.selection.on( 'change', spy );
+
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) );
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) );
+
+				sinon.assert.calledOnce( spy );
+			} );
+
+			it( 'should not change model selection if passed table cell is from other table then start cell', () => {
+				setModelData( model,
+					modelTable( [
+						[ '11[]', '12', '13' ],
+						[ '21', '22', '23' ],
+						[ '31', '32', '33' ]
+					] ) +
+					modelTable( [
+						[ 'a', 'b' ],
+						[ 'c', 'd' ]
+					] )
+				);
+
+				const spy = sinon.spy();
+
+				model.document.selection.on( 'change', spy );
+
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) );
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 1, 0, 1 ] ) );
+
+				sinon.assert.notCalled( spy );
+			} );
+
+			it( 'should select two table cells', () => {
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) );
+
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) );
+
+				assertSelectedCells( model, [
+					[ 1, 1, 0 ],
+					[ 0, 0, 0 ],
+					[ 0, 0, 0 ]
+				] );
+			} );
+
+			it( 'should select four table cells for diagonal selection', () => {
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) );
+
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 1, 1 ] ) );
+
+				assertSelectedCells( model, [
+					[ 1, 1, 0 ],
+					[ 1, 1, 0 ],
+					[ 0, 0, 0 ]
+				] );
+			} );
+
+			it( 'should select row table cells', () => {
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) );
+
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 0, 2 ] ) );
+
+				assertSelectedCells( model, [
+					[ 1, 1, 1 ],
+					[ 0, 0, 0 ],
+					[ 0, 0, 0 ]
+				] );
+			} );
+
+			it( 'should select column table cells', () => {
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) );
+
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 2, 1 ] ) );
+
+				assertSelectedCells( model, [
+					[ 0, 1, 0 ],
+					[ 0, 1, 0 ],
+					[ 0, 1, 0 ]
+				] );
+			} );
+
+			it( 'should create proper selection on consecutive changes', () => {
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 1, 1 ] ) );
+
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 2, 1 ] ) );
+
+				assertSelectedCells( model, [
+					[ 0, 0, 0 ],
+					[ 0, 1, 0 ],
+					[ 0, 1, 0 ]
+				] );
+
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) );
+
+				assertSelectedCells( model, [
+					[ 0, 1, 0 ],
+					[ 0, 1, 0 ],
+					[ 0, 0, 0 ]
+				] );
+
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 2, 2 ] ) );
+
+				assertSelectedCells( model, [
+					[ 0, 0, 0 ],
+					[ 0, 1, 1 ],
+					[ 0, 1, 1 ]
+				] );
+			} );
+		} );
+
+		describe( 'stopSelection()', () => {
+			it( 'should not clear currently selected cells if not cell was passed', () => {
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) );
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) );
+
+				tableSelection.stopSelection();
+
+				assertSelectedCells( model, [
+					[ 1, 1, 0 ],
+					[ 0, 0, 0 ],
+					[ 0, 0, 0 ]
+				] );
+			} );
+
+			it( 'should change model selection if cell was passed', () => {
+				const spy = sinon.spy();
+
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) );
+
+				model.document.selection.on( 'change', spy );
+				tableSelection.stopSelection( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) );
+
+				sinon.assert.calledOnce( spy );
+			} );
+
+			it( 'should extend selection to passed table cell', () => {
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) );
+
+				tableSelection.stopSelection( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) );
+
+				assertSelectedCells( model, [
+					[ 1, 1, 0 ],
+					[ 0, 0, 0 ],
+					[ 0, 0, 0 ]
+				] );
+			} );
+		} );
+
+		describe( 'clearSelection()', () => {
+			it( 'should not change model selection', () => {
+				const spy = sinon.spy();
+
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) );
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) );
+
+				model.document.selection.on( 'change', spy );
+
+				tableSelection.clearSelection();
+
+				sinon.assert.notCalled( spy );
+			} );
+
+			it( 'should not reset model selections', () => {
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) );
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) );
+
+				tableSelection.clearSelection();
+
+				assertSelectedCells( model, [
+					[ 1, 1, 0 ],
+					[ 0, 0, 0 ],
+					[ 0, 0, 0 ]
+				] );
+			} );
+		} );
+
+		describe( '* getSelectedTableCells()', () => {
+			it( 'should return nothing if selection is not started', () => {
+				expect( Array.from( tableSelection.getSelectedTableCells() ) ).to.deep.equal( [] );
+			} );
+
+			it( 'should return two table cells', () => {
+				const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
+				const lastCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] );
+
+				tableSelection.startSelectingFrom( firstCell );
+				tableSelection.setSelectingTo( lastCell );
+
+				expect( Array.from( tableSelection.getSelectedTableCells() ) ).to.deep.equal( [
+					firstCell, lastCell
+				] );
+			} );
+
+			it( 'should return four table cells for diagonal selection', () => {
+				const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
+				const lastCell = modelRoot.getNodeByPath( [ 0, 1, 1 ] );
+
+				tableSelection.startSelectingFrom( firstCell );
+				tableSelection.setSelectingTo( lastCell );
+
+				expect( Array.from( tableSelection.getSelectedTableCells() ) ).to.deep.equal( [
+					firstCell, modelRoot.getNodeByPath( [ 0, 0, 1 ] ), modelRoot.getNodeByPath( [ 0, 1, 0 ] ), lastCell
+				] );
+			} );
+
+			it( 'should return row table cells', () => {
+				const firstCell = modelRoot.getNodeByPath( [ 0, 0, 0 ] );
+				const lastCell = modelRoot.getNodeByPath( [ 0, 0, 2 ] );
+
+				tableSelection.startSelectingFrom( firstCell );
+				tableSelection.setSelectingTo( lastCell );
+
+				expect( Array.from( tableSelection.getSelectedTableCells() ) ).to.deep.equal( [
+					firstCell, modelRoot.getNodeByPath( [ 0, 0, 1 ] ), lastCell
+				] );
+			} );
+
+			it( 'should return column table cells', () => {
+				const firstCell = modelRoot.getNodeByPath( [ 0, 0, 1 ] );
+				const lastCell = modelRoot.getNodeByPath( [ 0, 2, 1 ] );
+
+				tableSelection.startSelectingFrom( firstCell );
+				tableSelection.setSelectingTo( lastCell );
+
+				expect( Array.from( tableSelection.getSelectedTableCells() ) ).to.deep.equal( [
+					firstCell, modelRoot.getNodeByPath( [ 0, 1, 1 ] ), lastCell
+				] );
+			} );
+		} );
+
+		describe( 'behavior', () => {
+			it( 'should clear selection on external changes', () => {
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) );
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) );
+
+				editor.model.change( writer => {
+					writer.setSelection( modelRoot.getNodeByPath( [ 0, 0, 0, 0 ] ), 0 );
+				} );
+
+				assertSelectedCells( model, [
+					[ 0, 0, 0 ],
+					[ 0, 0, 0 ],
+					[ 0, 0, 0 ]
+				] );
+
+				expect( editor.editing.view.document.selection.isFake ).to.be.false;
+				assertEqualMarkup( getViewData( editor.editing.view ), viewTable( [
+					[ '{}11', '12', '13' ],
+					[ '21', '22', '23' ],
+					[ '31', '32', '33' ]
+				], { asWidget: true } ) );
+			} );
+		} );
+	} );
+} );

+ 50 - 0
packages/ckeditor5-table/tests/tableselection/mouseeventsobserver.js

@@ -0,0 +1,50 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals document */
+
+import View from '@ckeditor/ckeditor5-engine/src/view/view';
+import MouseEventsObserver from '../../src/tableselection/mouseeventsobserver';
+
+describe( 'table selection', () => {
+	describe( 'MouseEventsObserver', () => {
+		let view, viewDocument, observer;
+
+		beforeEach( () => {
+			view = new View();
+			viewDocument = view.document;
+			observer = view.addObserver( MouseEventsObserver );
+		} );
+
+		afterEach( () => {
+			view.destroy();
+		} );
+
+		it( 'should define domEventTypes', () => {
+			expect( observer.domEventType ).to.deep.equal( [
+				'mousemove',
+				'mouseup',
+				'mouseleave'
+			] );
+		} );
+
+		describe( 'onDomEvent', () => {
+			for ( const eventName of [ 'mousemove', 'mouseup', 'mouseleave' ] ) {
+				it( `should fire ${ eventName } with the right event data`, () => {
+					const spy = sinon.spy();
+
+					viewDocument.on( eventName, spy );
+
+					observer.onDomEvent( { type: eventName, target: document.body } );
+
+					expect( spy.calledOnce ).to.be.true;
+
+					const data = spy.args[ 0 ][ 1 ];
+					expect( data.domTarget ).to.equal( document.body );
+				} );
+			}
+		} );
+	} );
+} );

+ 485 - 0
packages/ckeditor5-table/tests/tableselection/mouseselectionhandler.js

@@ -0,0 +1,485 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
+
+import TableEditing from '../../src/tableediting';
+import TableSelection from '../../src/tableselection';
+import { assertSelectedCells, modelTable, viewTable } from '../_utils/utils';
+
+describe( 'table selection', () => {
+	let editor, model, view, viewDoc;
+
+	beforeEach( async () => {
+		editor = await VirtualTestEditor.create( {
+			plugins: [ TableEditing, TableSelection, Paragraph ]
+		} );
+
+		model = editor.model;
+		view = editor.editing.view;
+		viewDoc = view.document;
+
+		setModelData( model, modelTable( [
+			[ '11[]', '12', '13' ],
+			[ '21', '22', '23' ],
+			[ '31', '32', '33' ]
+		] ) );
+	} );
+
+	afterEach( async () => {
+		await editor.destroy();
+	} );
+
+	describe( 'MouseSelectionHandler', () => {
+		it( 'should not start table selection when mouse move is inside one table cell', () => {
+			setModelData( model, modelTable( [
+				[ '[]00', '01' ],
+				[ '10', '11' ]
+			] ) );
+
+			pressMouseButtonOver( getTableCell( '00' ) );
+
+			assertEqualMarkup( getModelData( model ), modelTable( [
+				[ '[]00', '01' ],
+				[ '10', '11' ]
+			] ) );
+
+			movePressedMouseOver( getTableCell( '00' ) );
+
+			assertEqualMarkup( getModelData( model ), modelTable( [
+				[ '[]00', '01' ],
+				[ '10', '11' ]
+			] ) );
+		} );
+
+		it( 'should start table selection when mouse move expands over two cells', () => {
+			setModelData( model, modelTable( [
+				[ '[]00', '01' ],
+				[ '10', '11' ]
+			] ) );
+
+			pressMouseButtonOver( getTableCell( '00' ) );
+
+			assertEqualMarkup( getModelData( model ), modelTable( [
+				[ '[]00', '01' ],
+				[ '10', '11' ]
+			] ) );
+
+			movePressedMouseOver( getTableCell( '01' ) );
+
+			assertSelectedCells( model, [
+				[ 1, 1 ],
+				[ 0, 0 ]
+			] );
+
+			assertEqualMarkup( getViewData( view ), viewTable( [
+				[
+					{ contents: '00', class: 'ck-editor__editable_selected', isSelected: true },
+					{ contents: '01', class: 'ck-editor__editable_selected', isSelected: true }
+				],
+				[ '10', '11' ]
+			], { asWidget: true } ) );
+		} );
+
+		it( 'should select rectangular table cells when mouse moved to diagonal cell (up -> down)', () => {
+			setModelData( model, modelTable( [
+				[ '[]00', '01' ],
+				[ '10', '11' ]
+			] ) );
+
+			pressMouseButtonOver( getTableCell( '00' ) );
+
+			assertEqualMarkup( getModelData( model ), modelTable( [
+				[ '[]00', '01' ],
+				[ '10', '11' ]
+			] ) );
+
+			movePressedMouseOver( getTableCell( '11' ) );
+
+			assertSelectedCells( model, [
+				[ 1, 1 ],
+				[ 1, 1 ]
+			] );
+
+			assertEqualMarkup( getViewData( view ), viewTable( [
+				[
+					{ contents: '00', class: 'ck-editor__editable_selected', isSelected: true },
+					{ contents: '01', class: 'ck-editor__editable_selected', isSelected: true }
+				],
+				[
+					{ contents: '10', class: 'ck-editor__editable_selected', isSelected: true },
+					{ contents: '11', class: 'ck-editor__editable_selected', isSelected: true }
+				]
+			], { asWidget: true } ) );
+		} );
+
+		it( 'should select rectangular table cells when mouse moved to diagonal cell (down -> up)', () => {
+			setModelData( model, modelTable( [
+				[ '00', '01' ],
+				[ '10', '[]11' ]
+			] ) );
+
+			pressMouseButtonOver( getTableCell( '11' ) );
+
+			assertEqualMarkup( getModelData( model ), modelTable( [
+				[ '00', '01' ],
+				[ '10', '[]11' ]
+			] ) );
+
+			movePressedMouseOver( getTableCell( '00' ) );
+
+			assertSelectedCells( model, [
+				[ 1, 1 ],
+				[ 1, 1 ]
+			] );
+
+			assertEqualMarkup( getViewData( view ), viewTable( [
+				[
+					{ contents: '00', class: 'ck-editor__editable_selected', isSelected: true },
+					{ contents: '01', class: 'ck-editor__editable_selected', isSelected: true }
+				],
+				[
+					{ contents: '10', class: 'ck-editor__editable_selected', isSelected: true },
+					{ contents: '11', class: 'ck-editor__editable_selected', isSelected: true }
+				]
+			], { asWidget: true } ) );
+		} );
+
+		it( 'should update view selection after changing selection rect', () => {
+			setModelData( model, modelTable( [
+				[ '[]00', '01', '02' ],
+				[ '10', '11', '12' ],
+				[ '20', '21', '22' ]
+			] ) );
+
+			pressMouseButtonOver( getTableCell( '00' ) );
+			movePressedMouseOver( getTableCell( '22' ) );
+
+			assertSelectedCells( model, [
+				[ 1, 1, 1 ],
+				[ 1, 1, 1 ],
+				[ 1, 1, 1 ]
+			] );
+
+			assertEqualMarkup( getViewData( view ), viewTable( [
+				[
+					{ contents: '00', class: 'ck-editor__editable_selected', isSelected: true },
+					{ contents: '01', class: 'ck-editor__editable_selected', isSelected: true },
+					{ contents: '02', class: 'ck-editor__editable_selected', isSelected: true }
+				],
+				[
+					{ contents: '10', class: 'ck-editor__editable_selected', isSelected: true },
+					{ contents: '11', class: 'ck-editor__editable_selected', isSelected: true },
+					{ contents: '12', class: 'ck-editor__editable_selected', isSelected: true }
+				],
+				[
+					{ contents: '20', class: 'ck-editor__editable_selected', isSelected: true },
+					{ contents: '21', class: 'ck-editor__editable_selected', isSelected: true },
+					{ contents: '22', class: 'ck-editor__editable_selected', isSelected: true }
+				]
+			], { asWidget: true } ) );
+
+			movePressedMouseOver( getTableCell( '11' ) );
+
+			assertSelectedCells( model, [
+				[ 1, 1, 0 ],
+				[ 1, 1, 0 ],
+				[ 0, 0, 0 ]
+			] );
+
+			assertEqualMarkup( getViewData( view ), viewTable( [
+				[
+					{ contents: '00', class: 'ck-editor__editable_selected', isSelected: true },
+					{ contents: '01', class: 'ck-editor__editable_selected', isSelected: true },
+					'02'
+				],
+				[
+					{ contents: '10', class: 'ck-editor__editable_selected', isSelected: true },
+					{ contents: '11', class: 'ck-editor__editable_selected', isSelected: true },
+					'12'
+				],
+				[
+					'20',
+					'21',
+					'22'
+				]
+			], { asWidget: true } ) );
+		} );
+
+		it( 'should stop selecting after "mouseup" event', () => {
+			setModelData( model, modelTable( [
+				[ '[]00', '01' ],
+				[ '10', '11' ]
+			] ) );
+
+			pressMouseButtonOver( getTableCell( '00' ) );
+
+			assertEqualMarkup( getModelData( model ), modelTable( [
+				[ '[]00', '01' ],
+				[ '10', '11' ]
+			] ) );
+
+			movePressedMouseOver( getTableCell( '01' ) );
+			releaseMouseButtonOver( getTableCell( '01' ) );
+
+			assertSelectedCells( model, [
+				[ 1, 1 ],
+				[ 0, 0 ]
+			] );
+
+			assertEqualMarkup( getViewData( view ), viewTable( [
+				[
+					{ contents: '00', class: 'ck-editor__editable_selected', isSelected: true },
+					{ contents: '01', class: 'ck-editor__editable_selected', isSelected: true }
+				],
+				[ '10', '11' ]
+			], { asWidget: true } ) );
+
+			moveReleasedMouseOver( getTableCell( '11' ) );
+
+			assertSelectedCells( model, [
+				[ 1, 1 ],
+				[ 0, 0 ]
+			] );
+		} );
+
+		it( 'should do nothing on "mouseup" event', () => {
+			setModelData( model, modelTable( [
+				[ '[]00', '01' ],
+				[ '10', '11' ]
+			] ) );
+
+			releaseMouseButtonOver( getTableCell( '01' ) );
+
+			assertSelectedCells( model, [
+				[ 0, 0 ],
+				[ 0, 0 ]
+			] );
+		} );
+
+		it( 'should stop selection mode on "mouseleve" event if next "mousemove" has no button pressed', () => {
+			setModelData( model, modelTable( [
+				[ '[]00', '01' ],
+				[ '10', '11' ]
+			] ) );
+
+			pressMouseButtonOver( getTableCell( '00' ) );
+
+			assertEqualMarkup( getModelData( model ), modelTable( [
+				[ '[]00', '01' ],
+				[ '10', '11' ]
+			] ) );
+
+			movePressedMouseOver( getTableCell( '01' ) );
+			makeMouseLeave();
+
+			assertSelectedCells( model, [
+				[ 1, 1 ],
+				[ 0, 0 ]
+			] );
+
+			assertEqualMarkup( getViewData( view ), viewTable( [
+				[
+					{ contents: '00', class: 'ck-editor__editable_selected', isSelected: true },
+					{ contents: '01', class: 'ck-editor__editable_selected', isSelected: true }
+				],
+				[ '10', '11' ]
+			], { asWidget: true } ) );
+
+			moveReleasedMouseOver( getTableCell( '11' ) );
+
+			assertSelectedCells( model, [
+				[ 1, 1 ],
+				[ 0, 0 ]
+			] );
+		} );
+
+		it( 'should continue selection mode on "mouseleve" and "mousemove" if mouse button is pressed', () => {
+			setModelData( model, modelTable( [
+				[ '[]00', '01' ],
+				[ '10', '11' ]
+			] ) );
+
+			pressMouseButtonOver( getTableCell( '00' ) );
+
+			assertEqualMarkup( getModelData( model ), modelTable( [
+				[ '[]00', '01' ],
+				[ '10', '11' ]
+			] ) );
+
+			movePressedMouseOver( getTableCell( '01' ) );
+			makeMouseLeave();
+
+			assertSelectedCells( model, [
+				[ 1, 1 ],
+				[ 0, 0 ]
+			] );
+
+			assertEqualMarkup( getViewData( view ), viewTable( [
+				[
+					{ contents: '00', class: 'ck-editor__editable_selected', isSelected: true },
+					{ contents: '01', class: 'ck-editor__editable_selected', isSelected: true }
+				],
+				[ '10', '11' ]
+			], { asWidget: true } ) );
+
+			movePressedMouseOver( getTableCell( '11' ) );
+
+			assertSelectedCells( model, [
+				[ 1, 1 ],
+				[ 1, 1 ]
+			] );
+		} );
+
+		it( 'should do nothing on "mouseleve" event', () => {
+			setModelData( model, modelTable( [
+				[ '[]00', '01' ],
+				[ '10', '11' ]
+			] ) );
+
+			makeMouseLeave();
+
+			assertSelectedCells( model, [
+				[ 0, 0 ],
+				[ 0, 0 ]
+			] );
+		} );
+
+		it( 'should do nothing on "mousedown" event over ui element (click on selection handle)', () => {
+			setModelData( model, modelTable( [
+				[ '[]00', '01' ],
+				[ '10', '11' ]
+			] ) );
+
+			const uiElement = viewDoc.getRoot()
+				.getChild( 0 )
+				.getChild( 0 ); // selection handler;
+
+			fireEvent( view, 'mousedown', addTarget( uiElement ), mouseButtonPressed );
+
+			assertEqualMarkup( getModelData( model ), modelTable( [
+				[ '[]00', '01' ],
+				[ '10', '11' ]
+			] ) );
+		} );
+
+		it( 'should do nothing on "mousemove" event over ui element (click on selection handle)', () => {
+			setModelData( model, modelTable( [
+				[ '[]00', '01' ],
+				[ '10', '11' ]
+			] ) );
+
+			const uiElement = viewDoc.getRoot()
+				.getChild( 0 )
+				.getChild( 0 ); // selection handler;
+
+			fireEvent( view, 'mousemove', addTarget( uiElement ), mouseButtonPressed );
+
+			assertEqualMarkup( getModelData( model ), modelTable( [
+				[ '[]00', '01' ],
+				[ '10', '11' ]
+			] ) );
+		} );
+
+		it( 'should clear view table selection after mouse click outside table', () => {
+			setModelData( model, modelTable( [
+				[ '[]00', '01' ],
+				[ '10', '11' ]
+			] ) + '<paragraph>foo</paragraph>' );
+
+			pressMouseButtonOver( getTableCell( '00' ) );
+
+			assertEqualMarkup( getModelData( model ), modelTable( [
+				[ '[]00', '01' ],
+				[ '10', '11' ]
+			] ) + '<paragraph>foo</paragraph>' );
+
+			movePressedMouseOver( getTableCell( '01' ) );
+
+			const paragraph = viewDoc.getRoot().getChild( 1 );
+
+			fireEvent( view, 'mousemove', addTarget( paragraph ) );
+			fireEvent( view, 'mousedown', addTarget( paragraph ) );
+			fireEvent( view, 'mouseup', addTarget( paragraph ) );
+
+			// The click in the DOM would trigger selection change and it will set the selection:
+			model.change( writer => {
+				writer.setSelection( writer.createRange( writer.createPositionAt( model.document.getRoot().getChild( 1 ), 0 ) ) );
+			} );
+
+			assertEqualMarkup( getViewData( view ), viewTable( [
+				[ '00', '01' ],
+				[ '10', '11' ]
+			], { asWidget: true } ) + '<p>{}foo</p>' );
+		} );
+	} );
+
+	function getTableCell( data ) {
+		for ( const value of view.createRangeIn( viewDoc.getRoot() ) ) {
+			if ( value.type === 'text' && value.item.data === data ) {
+				return value.item.parent.parent;
+			}
+		}
+	}
+
+	function makeMouseLeave() {
+		fireEvent( view, 'mouseleave' );
+	}
+
+	function pressMouseButtonOver( target ) {
+		fireEvent( view, 'mousedown', addTarget( target ), mouseButtonPressed );
+	}
+
+	function movePressedMouseOver( target ) {
+		moveMouseOver( target, mouseButtonPressed );
+	}
+
+	function moveReleasedMouseOver( target ) {
+		moveMouseOver( target, mouseButtonReleased );
+	}
+
+	function moveMouseOver( target, ...decorators ) {
+		fireEvent( view, 'mousemove', addTarget( target ), ...decorators );
+	}
+
+	function releaseMouseButtonOver( target ) {
+		fireEvent( view, 'mouseup', addTarget( target ), mouseButtonReleased );
+	}
+
+	function addTarget( target ) {
+		return domEventData => {
+			domEventData.target = target;
+		};
+	}
+
+	function mouseButtonPressed( domEventData ) {
+		domEventData.domEvent.buttons = 1;
+	}
+
+	function mouseButtonReleased( domEventData ) {
+		domEventData.domEvent.buttons = 0;
+	}
+
+	function fireEvent( view, eventName, ...decorators ) {
+		const domEvtDataStub = {
+			domEvent: {
+				buttons: 0
+			},
+			target: undefined,
+			preventDefault: sinon.spy(),
+			stopPropagation: sinon.spy()
+		};
+
+		for ( const decorator of decorators ) {
+			decorator( domEvtDataStub );
+		}
+
+		viewDoc.fire( eventName, domEvtDataStub );
+	}
+} );

+ 17 - 0
packages/ckeditor5-table/theme/tableselection.css

@@ -0,0 +1,17 @@
+/*
+ * 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.
+ */
+
+.ck.ck-editor__editable .table table {
+	& td.ck-editor__editable_selected,
+	& th.ck-editor__editable_selected {
+		box-shadow: inset 0 0 0 1px var(--ck-color-focus-border);
+	}
+}