Przeglądaj źródła

Merge branch 'master' into i/6091

Maciej Gołaszewski 5 lat temu
rodzic
commit
2f7584b935
38 zmienionych plików z 2756 dodań i 75 usunięć
  1. 2 2
      packages/ckeditor5-table/docs/_snippets/features/table-styling-colors.js
  2. 4 4
      packages/ckeditor5-table/docs/_snippets/features/table-styling.html
  3. 2 2
      packages/ckeditor5-table/docs/_snippets/features/table-styling.js
  4. 18 2
      packages/ckeditor5-table/src/commands/removecolumncommand.js
  5. 33 7
      packages/ckeditor5-table/src/commands/removerowcommand.js
  6. 5 4
      packages/ckeditor5-table/src/commands/utils.js
  7. 12 12
      packages/ckeditor5-table/src/tablecellproperties/ui/tablecellpropertiesview.js
  8. 97 0
      packages/ckeditor5-table/src/tableclipboard.js
  9. 1 1
      packages/ckeditor5-table/src/tableediting.js
  10. 2 2
      packages/ckeditor5-table/src/tableproperties/tablepropertiesui.js
  11. 8 8
      packages/ckeditor5-table/src/tableproperties/ui/tablepropertiesview.js
  12. 282 0
      packages/ckeditor5-table/src/tableselection.js
  13. 49 0
      packages/ckeditor5-table/src/tableselection/converters.js
  14. 152 0
      packages/ckeditor5-table/src/tableselection/croptable.js
  15. 86 0
      packages/ckeditor5-table/src/tableselection/mouseeventsobserver.js
  16. 160 0
      packages/ckeditor5-table/src/tableselection/mouseselectionhandler.js
  17. 2 2
      packages/ckeditor5-table/src/ui/colorinputview.js
  18. 1 1
      packages/ckeditor5-table/src/ui/formheaderview.js
  19. 1 1
      packages/ckeditor5-table/src/ui/utils.js
  20. 66 4
      packages/ckeditor5-table/tests/_utils/utils.js
  21. 1 1
      packages/ckeditor5-table/tests/commands/mergecellcommand.js
  22. 20 4
      packages/ckeditor5-table/tests/commands/removecolumncommand.js
  23. 7 7
      packages/ckeditor5-table/tests/commands/removerowcommand.js
  24. 174 0
      packages/ckeditor5-table/tests/manual/tableclipboard.html
  25. 44 0
      packages/ckeditor5-table/tests/manual/tableclipboard.js
  26. 21 0
      packages/ckeditor5-table/tests/manual/tableclipboard.md
  27. 184 0
      packages/ckeditor5-table/tests/manual/tableselection.html
  28. 78 0
      packages/ckeditor5-table/tests/manual/tableselection.js
  29. 9 0
      packages/ckeditor5-table/tests/manual/tableselection.md
  30. 3 3
      packages/ckeditor5-table/tests/tablecellproperties/ui/tablecellpropertiesview.js
  31. 335 0
      packages/ckeditor5-table/tests/tableclipboard.js
  32. 1 3
      packages/ckeditor5-table/tests/tableediting.js
  33. 2 2
      packages/ckeditor5-table/tests/tableproperties/ui/tablepropertiesview.js
  34. 339 0
      packages/ckeditor5-table/tests/tableselection.js
  35. 50 0
      packages/ckeditor5-table/tests/tableselection/mouseeventsobserver.js
  36. 485 0
      packages/ckeditor5-table/tests/tableselection/mouseselectionhandler.js
  37. 3 3
      packages/ckeditor5-table/tests/ui/utils.js
  38. 17 0
      packages/ckeditor5-table/theme/tableselection.css

+ 2 - 2
packages/ckeditor5-table/docs/_snippets/features/table-styling-colors.js

@@ -87,14 +87,14 @@ const customColorPalette = [
 	{
 		color: 'hsl(200, 18%, 100%)',
 		label: 'White'
-	},
+	}
 ];
 
 ClassicEditor
 	.create( document.querySelector( '#snippet-table-styling-colors' ), {
 		extraPlugins: [
 			CKEditorPlugins.TableProperties,
-			CKEditorPlugins.TableCellProperties,
+			CKEditorPlugins.TableCellProperties
 		],
 		cloudServices: CS_CONFIG,
 		toolbar: {

+ 4 - 4
packages/ckeditor5-table/docs/_snippets/features/table-styling.html

@@ -6,12 +6,12 @@
 				<tr>
 					<th style="background-color:hsl(0,0%,90%);width:317px;" colspan="1" rowspan="2"><p>&nbsp;</p><p>&nbsp;</p></th>
 					<th style="background-color:hsl(180,97%,31%);text-align:center;width:224px;" colspan="3">Full Year (unaudited)</th>
-					<th style="background-color:hsl(180,97%,31%);text-align:center;width:360px;" colspan="4">2013 (unaudited)</th>
+					<th style="background-color:hsl(180,97%,31%);text-align:center;width:360px;" colspan="4">2019 (unaudited)</th>
 				</tr>
 				<tr>
-					<th style="background-color:hsl(180,52%,58%);width:75px;">2011</th>
-					<th style="background-color:hsl(180,52%,58%);width:75px;">2012</th>
-					<th style="background-color:hsl(180,52%,58%);width:75px;">2013</th>
+					<th style="background-color:hsl(180,52%,58%);width:75px;">2017</th>
+					<th style="background-color:hsl(180,52%,58%);width:75px;">2018</th>
+					<th style="background-color:hsl(180,52%,58%);width:75px;">2019</th>
 					<th style="background-color:hsl(180,52%,58%);width:75px;">Q1</th>
 					<th style="background-color:hsl(180,52%,58%);width:75px;">Q2</th>
 					<th style="background-color:hsl(180,52%,58%);width:75px;">Q3</th>

+ 2 - 2
packages/ckeditor5-table/docs/_snippets/features/table-styling.js

@@ -71,7 +71,7 @@ const COLOR_PALETTE = [
 	},
 	{
 		color: 'hsl(180, 52%, 58%)',
-		label: 'Light Aqua',
+		label: 'Light Aqua'
 	},
 	{
 		color: 'hsl(180, 97%, 31%)',
@@ -95,7 +95,7 @@ ClassicEditor
 	.create( document.querySelector( '#snippet-table-styling' ), {
 		extraPlugins: [
 			CKEditorPlugins.TableProperties,
-			CKEditorPlugins.TableCellProperties,
+			CKEditorPlugins.TableCellProperties
 		],
 		cloudServices: CS_CONFIG,
 		toolbar: {

+ 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 ) {

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

@@ -25,7 +25,7 @@ import {
 	fillToolbar,
 	getBorderStyleDefinitions,
 	getBorderStyleLabels,
-	getLabeledColorInputCreator,
+	getLabeledColorInputCreator
 } from '../../ui/utils';
 import FormRowView from '../../ui/formrowview';
 import FormHeaderView from '../../ui/formheaderview';
@@ -366,7 +366,7 @@ export default class TableCellPropertiesView extends View {
 			children: [
 				alignmentLabel,
 				horizontalAlignmentToolbar,
-				verticalAlignmentToolbar,
+				verticalAlignmentToolbar
 			],
 			class: 'ck-table-cell-properties-form__alignment-row'
 		} ) );
@@ -375,7 +375,7 @@ export default class TableCellPropertiesView extends View {
 		this.children.add( new FormRowView( locale, {
 			children: [
 				this.saveButtonView,
-				this.cancelButtonView,
+				this.cancelButtonView
 			],
 			class: 'ck-table-form__action-row'
 		} ) );
@@ -508,7 +508,7 @@ export default class TableCellPropertiesView extends View {
 
 		borderColorInput.set( {
 			label: t( 'Color' ),
-			class: 'ck-table-form__border-color',
+			class: 'ck-table-form__border-color'
 		} );
 
 		borderColorInput.view.bind( 'value' ).to( this, 'borderColor' );
@@ -531,7 +531,7 @@ export default class TableCellPropertiesView extends View {
 			borderRowLabel,
 			borderStyleDropdown,
 			borderColorInput,
-			borderWidthInput,
+			borderWidthInput
 		};
 	}
 
@@ -555,7 +555,7 @@ export default class TableCellPropertiesView extends View {
 
 		backgroundInput.set( {
 			label: t( 'Background' ),
-			class: 'ck-table-cell-properties-form__background',
+			class: 'ck-table-cell-properties-form__background'
 		} );
 
 		backgroundInput.view.bind( 'value' ).to( this, 'backgroundColor' );
@@ -590,7 +590,7 @@ export default class TableCellPropertiesView extends View {
 
 		widthInput.set( {
 			label: t( 'Width' ),
-			class: 'ck-table-form__dimensions-row__width',
+			class: 'ck-table-form__dimensions-row__width'
 		} );
 
 		widthInput.view.bind( 'value' ).to( this, 'width' );
@@ -619,7 +619,7 @@ export default class TableCellPropertiesView extends View {
 
 		heightInput.set( {
 			label: t( 'Height' ),
-			class: 'ck-table-form__dimensions-row__height',
+			class: 'ck-table-form__dimensions-row__height'
 		} );
 
 		heightInput.view.bind( 'value' ).to( this, 'height' );
@@ -651,7 +651,7 @@ export default class TableCellPropertiesView extends View {
 
 		paddingInput.set( {
 			label: t( 'Padding' ),
-			class: 'ck-table-cell-properties-form__padding',
+			class: 'ck-table-cell-properties-form__padding'
 		} );
 
 		paddingInput.view.bind( 'value' ).to( this, 'padding' );
@@ -752,7 +752,7 @@ export default class TableCellPropertiesView extends View {
 			icon: checkIcon,
 			class: 'ck-button-save',
 			type: 'submit',
-			withText: true,
+			withText: true
 		} );
 
 		saveButtonView.bind( 'isEnabled' ).toMany( fieldsThatShouldValidateToSave, 'errorText', ( ...errorTexts ) => {
@@ -764,7 +764,7 @@ export default class TableCellPropertiesView extends View {
 			icon: cancelIcon,
 			class: 'ck-button-cancel',
 			type: 'cancel',
-			withText: true,
+			withText: true
 		} );
 
 		cancelButtonView.delegate( 'execute' ).to( this, 'cancel' );
@@ -787,7 +787,7 @@ export default class TableCellPropertiesView extends View {
 			left: t( 'Align cell text to the left' ),
 			center: t( 'Align cell text to the center' ),
 			right: t( 'Align cell text to the right' ),
-			justify: t( 'Justify cell text' ),
+			justify: t( 'Justify cell text' )
 		};
 	}
 

+ 97 - 0
packages/ckeditor5-table/src/tableclipboard.js

@@ -0,0 +1,97 @@
+/**
+ * @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/tableclipboard
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import TableSelection from './tableselection';
+
+/**
+ * The table clipboard integration plugin.
+ *
+ * It introduces the ability to copy selected table cells.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class TableClipboard extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'TableClipboard';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ TableSelection ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const viewDocument = editor.editing.view.document;
+
+		/**
+		 * A table selection plugin instance.
+		 *
+		 * @private
+		 * @readonly
+		 * @member {module:table/tableselection~TableSelection} module:tableclipboard~TableClipboard#_tableSelection
+		 */
+		this._tableSelection = editor.plugins.get( 'TableSelection' );
+
+		this.listenTo( viewDocument, 'copy', ( evt, data ) => this._onCopy( evt, data ), { priority: 'normal' } );
+		this.listenTo( viewDocument, 'cut', ( evt, data ) => this._onCut( evt, data ), { priority: 'high' } );
+	}
+
+	/**
+	 * A clipboard "copy" event handler.
+	 *
+	 * @param {module:utils/eventinfo~EventInfo} evt An object containing information about the handled event.
+	 * @param {Object} data Clipboard event data.
+	 * @private
+	 */
+	_onCopy( evt, data ) {
+		const tableSelection = this._tableSelection;
+
+		if ( !tableSelection.hasMultiCellSelection ) {
+			return;
+		}
+
+		data.preventDefault();
+		evt.stop();
+
+		const dataController = this.editor.data;
+		const viewDocument = this.editor.editing.view.document;
+
+		const content = dataController.toView( tableSelection.getSelectionAsFragment() );
+
+		viewDocument.fire( 'clipboardOutput', {
+			dataTransfer: data.dataTransfer,
+			content,
+			method: evt.name
+		} );
+	}
+
+	/**
+	 * A clipboard "cut" event handler.
+	 *
+	 * @param {module:utils/eventinfo~EventInfo} evt An object containing information about the handled event.
+	 * @param {Object} data Clipboard event data.
+	 * @private
+	 */
+	_onCut( evt, data ) {
+		if ( this._tableSelection.hasMultiCellSelection ) {
+			data.preventDefault();
+			evt.stop();
+		}
+	}
+}

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

+ 2 - 2
packages/ckeditor5-table/src/tableproperties/tablepropertiesui.js

@@ -22,7 +22,7 @@ import {
 	lengthFieldValidator,
 	lineWidthFieldValidator,
 	repositionContextualBalloon,
-	defaultColors,
+	defaultColors
 } from '../ui/utils';
 import {
 	getLocalizedColorOptions,
@@ -255,7 +255,7 @@ export default class TablePropertiesUI extends Plugin {
 			backgroundColor: commands.get( 'tableBackgroundColor' ).value || '',
 			width: commands.get( 'tableWidth' ).value || '',
 			height: commands.get( 'tableHeight' ).value || '',
-			alignment: commands.get( 'tableAlignment' ).value || '',
+			alignment: commands.get( 'tableAlignment' ).value || ''
 		} );
 	}
 

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

@@ -324,7 +324,7 @@ export default class TablePropertiesView extends View {
 		this.children.add( new FormRowView( locale, {
 			children: [
 				this.saveButtonView,
-				this.cancelButtonView,
+				this.cancelButtonView
 			],
 			class: 'ck-table-form__action-row'
 		} ) );
@@ -366,7 +366,7 @@ export default class TablePropertiesView extends View {
 			this.heightInput,
 			this.alignmentToolbar,
 			this.saveButtonView,
-			this.cancelButtonView,
+			this.cancelButtonView
 		].forEach( view => {
 			// Register the view as focusable.
 			this._focusables.add( view );
@@ -455,7 +455,7 @@ export default class TablePropertiesView extends View {
 
 		borderColorInput.set( {
 			label: t( 'Color' ),
-			class: 'ck-table-form__border-color',
+			class: 'ck-table-form__border-color'
 		} );
 
 		borderColorInput.view.bind( 'value' ).to( this, 'borderColor' );
@@ -502,7 +502,7 @@ export default class TablePropertiesView extends View {
 
 		backgroundInput.set( {
 			label: t( 'Background' ),
-			class: 'ck-table-properties-form__background',
+			class: 'ck-table-properties-form__background'
 		} );
 
 		backgroundInput.view.bind( 'value' ).to( this, 'backgroundColor' );
@@ -537,7 +537,7 @@ export default class TablePropertiesView extends View {
 
 		widthInput.set( {
 			label: t( 'Width' ),
-			class: 'ck-table-form__dimensions-row__width',
+			class: 'ck-table-form__dimensions-row__width'
 		} );
 
 		widthInput.view.bind( 'value' ).to( this, 'width' );
@@ -566,7 +566,7 @@ export default class TablePropertiesView extends View {
 
 		heightInput.set( {
 			label: t( 'Height' ),
-			class: 'ck-table-form__dimensions-row__height',
+			class: 'ck-table-form__dimensions-row__height'
 		} );
 
 		heightInput.view.bind( 'value' ).to( this, 'height' );
@@ -652,7 +652,7 @@ export default class TablePropertiesView extends View {
 			icon: checkIcon,
 			class: 'ck-button-save',
 			type: 'submit',
-			withText: true,
+			withText: true
 		} );
 
 		saveButtonView.bind( 'isEnabled' ).toMany( fieldsThatShouldValidateToSave, 'errorText', ( ...errorTexts ) => {
@@ -664,7 +664,7 @@ export default class TablePropertiesView extends View {
 			icon: cancelIcon,
 			class: 'ck-button-cancel',
 			type: 'cancel',
-			withText: true,
+			withText: true
 		} );
 
 		cancelButtonView.delegate( 'execute' ).to( this, 'cancel' );

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

@@ -0,0 +1,282 @@
+/**
+ * @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 { findAncestor } from './commands/utils';
+import cropTable from './tableselection/croptable';
+
+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( findAncestor( 'table', this._startElement ), { startRow, endRow } ) ) {
+			if ( cellInfo.column >= startColumn && cellInfo.column <= endColumn ) {
+				yield cellInfo.cell;
+			}
+		}
+	}
+
+	/**
+	 * Returns selected table fragment as a document fragment.
+	 *
+	 * @returns {module:engine/model/documentfragment~DocumentFragment|undefined}
+	 */
+	getSelectionAsFragment() {
+		if ( !this.hasMultiCellSelection ) {
+			return;
+		}
+
+		return this.editor.model.change( writer => {
+			const documentFragment = writer.createDocumentFragment();
+
+			const table = cropTable( this.getSelectedTableCells(), this._tableUtils, writer );
+			writer.insert( table, documentFragment, 0 );
+
+			return documentFragment;
+		} );
+	}
+
+	/**
+	 * 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 );
+		}
+	} );
+}

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

@@ -0,0 +1,152 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tableselection/croptable
+ */
+
+import { findAncestor } from '../commands/utils';
+
+/**
+ * Returns cropped table from selected table cells.
+ *
+ * This is to be used with table selection
+ *
+ *		tableSelection.startSelectingFrom( startCell )
+ *		tableSelection.setSelectingFrom( endCell )
+ *
+ *		const croppedTable = cropTable( tableSelection.getSelectedTableCells );
+ *
+ * **Note**: This function is used also by {@link module:table/tableselection~TableSelection#getSelectionAsFragment}
+ *
+ * @param {Iterable.<module:engine/model/element~Element>} selectedTableCellsIterator
+ * @param {module:table/tableutils~TableUtils} tableUtils
+ * @param {module:engine/model/writer~Writer} writer
+ * @returns {module:engine/model/element~Element}
+ */
+export default function cropTable( selectedTableCellsIterator, tableUtils, writer ) {
+	const selectedTableCells = Array.from( selectedTableCellsIterator );
+	const startElement = selectedTableCells[ 0 ];
+	const endElement = selectedTableCells[ selectedTableCells.length - 1 ];
+
+	const { row: startRow, column: startColumn } = tableUtils.getCellLocation( startElement );
+
+	const tableCopy = makeTableCopy( selectedTableCells, startColumn, writer, tableUtils );
+
+	const { row: endRow, column: endColumn } = tableUtils.getCellLocation( endElement );
+	const selectionWidth = endColumn - startColumn + 1;
+	const selectionHeight = endRow - startRow + 1;
+
+	trimTable( tableCopy, selectionWidth, selectionHeight, writer, tableUtils );
+
+	const sourceTable = findAncestor( 'table', startElement );
+	addHeadingsToTableCopy( tableCopy, sourceTable, startRow, startColumn, writer );
+
+	return tableCopy;
+}
+
+// Creates a table copy from a selected table cells.
+//
+// It fills "gaps" in copied table - ie when cell outside copied range was spanning over selection.
+function makeTableCopy( selectedTableCells, startColumn, writer, tableUtils ) {
+	const tableCopy = writer.createElement( 'table' );
+
+	const rowToCopyMap = new Map();
+	const copyToOriginalColumnMap = new Map();
+
+	for ( const tableCell of selectedTableCells ) {
+		const row = findAncestor( 'tableRow', tableCell );
+
+		if ( !rowToCopyMap.has( row ) ) {
+			const rowCopy = row._clone();
+			writer.append( rowCopy, tableCopy );
+			rowToCopyMap.set( row, rowCopy );
+		}
+
+		const tableCellCopy = tableCell._clone( true );
+		const { column } = tableUtils.getCellLocation( tableCell );
+
+		copyToOriginalColumnMap.set( tableCellCopy, column );
+
+		writer.append( tableCellCopy, rowToCopyMap.get( row ) );
+	}
+
+	addMissingTableCells( tableCopy, startColumn, copyToOriginalColumnMap, writer, tableUtils );
+
+	return tableCopy;
+}
+
+// Fills gaps for spanned cell from outside the selection range.
+function addMissingTableCells( tableCopy, startColumn, copyToOriginalColumnMap, writer, tableUtils ) {
+	for ( const row of tableCopy.getChildren() ) {
+		for ( const tableCell of Array.from( row.getChildren() ) ) {
+			const { column } = tableUtils.getCellLocation( tableCell );
+
+			const originalColumn = copyToOriginalColumnMap.get( tableCell );
+			const shiftedColumn = originalColumn - startColumn;
+
+			if ( column !== shiftedColumn ) {
+				for ( let i = 0; i < shiftedColumn - column; i++ ) {
+					const prepCell = writer.createElement( 'tableCell' );
+					writer.insert( prepCell, writer.createPositionBefore( tableCell ) );
+
+					const paragraph = writer.createElement( 'paragraph' );
+
+					writer.insert( paragraph, prepCell, 0 );
+					writer.insertText( '', paragraph, 0 );
+				}
+			}
+		}
+	}
+}
+
+// Trims table to a given dimensions.
+function trimTable( table, width, height, writer, tableUtils ) {
+	for ( const row of table.getChildren() ) {
+		for ( const tableCell of row.getChildren() ) {
+			const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
+			const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
+
+			const { row, column } = tableUtils.getCellLocation( tableCell );
+
+			if ( column + colspan > width ) {
+				const newSpan = width - column;
+
+				if ( newSpan > 1 ) {
+					writer.setAttribute( 'colspan', newSpan, tableCell );
+				} else {
+					writer.removeAttribute( 'colspan', tableCell );
+				}
+			}
+
+			if ( row + rowspan > height ) {
+				const newSpan = height - row;
+
+				if ( newSpan > 1 ) {
+					writer.setAttribute( 'rowspan', newSpan, tableCell );
+				} else {
+					writer.removeAttribute( 'rowspan', tableCell );
+				}
+			}
+		}
+	}
+}
+
+// Sets proper heading attributes to copied table.
+function addHeadingsToTableCopy( tableCopy, sourceTable, startRow, startColumn, writer ) {
+	const headingRows = parseInt( sourceTable.getAttribute( 'headingRows' ) || 0 );
+
+	if ( headingRows > 0 ) {
+		const copiedRows = headingRows - startRow;
+		writer.setAttribute( 'headingRows', copiedRows, tableCopy );
+	}
+
+	const headingColumns = parseInt( sourceTable.getAttribute( 'headingColumns' ) || 0 );
+
+	if ( headingColumns > 0 ) {
+		const copiedColumns = headingColumns - startColumn;
+		writer.setAttribute( 'headingColumns', copiedColumns, tableCopy );
+	}
+}

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

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

@@ -120,7 +120,7 @@ export default class ColorInputView extends View {
 			children: [
 				this._inputView,
 				this._dropdownView
-			],
+			]
 		} );
 	}
 
@@ -170,7 +170,7 @@ export default class ColorInputView extends View {
 		dropdown.buttonView.extendTemplate( {
 			attributes: {
 				class: 'ck-input-color__button'
-			},
+			}
 		} );
 
 		dropdown.buttonView.children.add( colorPreview );

+ 1 - 1
packages/ckeditor5-table/src/ui/formheaderview.js

@@ -66,7 +66,7 @@ export default class FormHeaderView extends View {
 				class: [
 					'ck',
 					'ck-form__header__label'
-				],
+				]
 			},
 			children: [
 				{ text: bind.to( 'label' ) }

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

@@ -110,7 +110,7 @@ export function getBorderStyleLabels( t ) {
 		groove: t( 'Groove' ),
 		ridge: t( 'Ridge' ),
 		inset: t( 'Inset' ),
-		outset: t( 'Outset' ),
+		outset: t( 'Outset' )
 	};
 }
 

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

+ 1 - 1
packages/ckeditor5-table/tests/commands/mergecellcommand.js

@@ -317,7 +317,7 @@ describe( 'MergeCellCommand', () => {
 
 			it( 'should be false if mergeable cell is in other table section then current cell', () => {
 				setData( model, modelTable( [
-					[ '00', '01[]' ],
+					[ '00', '01[]' ]
 				], { headingColumns: 1 } ) );
 
 				expect( command.isEnabled ).to.be.false;

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

+ 174 - 0
packages/ckeditor5-table/tests/manual/tableclipboard.html

@@ -0,0 +1,174 @@
+<style>
+	body {
+		font-family: Helvetica, Arial, sans-serif;
+		font-size: 14px;
+	}
+
+	.print-selected {
+		background: #b8e3ff;
+	}
+</style>
+
+<div style="display:flex">
+	<div style="flex:1;">
+
+		<h3>A "content" test editor</h3>
+
+		<div id="editor-content">
+			<p>Jelly-o topping chocolate danish. Powder donut dragée cupcake sesame snaps cotton candy. Cotton candy pudding apple pie liquorice
+				sugar plum cookie. Powder gummi bears macaroon. Gingerbread soufflé liquorice jelly-o marzipan pudding. Toffee sweet candy canes danish
+				macaroon cotton candy fruitcake. Sesame snaps cake cookie chocolate cupcake. Ice cream pie apple pie sweet. Wafer ice cream gingerbread
+				fruitcake donut jelly sweet. Sugar plum chocolate gummi bears. Jelly-o oat cake wafer brownie gingerbread pie dragée. Marshmallow
+				marzipan candy canes. Cotton candy liquorice cake soufflé pie candy.</p>
+
+			<figure class="table" style="width:60%;">
+				<table
+					style="background-color:hsl(0,0%,90%);border-bottom:2px solid hsl(30, 75%, 60%);border-left:2px solid hsl(30, 75%, 60%);border-right:2px solid hsl(30, 75%, 60%);border-top:2px solid hsl(30, 75%, 60%);">
+					<thead>
+						<tr>
+							<th>0</th>
+							<th>1</th>
+							<th>2</th>
+							<th>3</th>
+							<th>4</th>
+						</tr>
+					</thead>
+					<tbody>
+						<tr>
+							<td>a</td>
+							<td>b</td>
+							<td style="border-bottom:4px dashed hsl(0, 75%, 60%);border-left:4px dashed hsl(0, 75%, 60%);border-right:4px dashed hsl(0, 75%, 60%);border-top:4px dashed hsl(0, 75%, 60%);height:30px;text-align:right;vertical-align:top;width:30px;">
+								c
+							</td>
+							<td>d</td>
+							<td><i><strong>e</strong></i></td>
+						</tr>
+						<tr>
+							<td style="background-color:hsl(210,75%,60%);">f</td>
+							<td style="background-color:hsl(210,75%,60%);">g</td>
+							<td style="background-color:hsl(210,75%,60%);">h</td>
+							<td style="background-color:hsl(210,75%,60%);">i</td>
+							<td style="background-color:hsl(210,75%,60%);"><i><strong>j</strong></i></td>
+						</tr>
+						<tr>
+							<td>k</td>
+							<td>l</td>
+							<td>m</td>
+							<td>n</td>
+							<td><i><strong>o</strong></i></td>
+						</tr>
+						<tr>
+							<td>p</td>
+							<td>q</td>
+							<td>r</td>
+							<td>s</td>
+							<td>t</td>
+						</tr>
+					</tbody>
+				</table>
+			</figure>
+		</div>
+
+		<h3>A "geometry" test editor</h3>
+
+		<div id="editor-geometry">
+			<figure class="table">
+				<table>
+					<thead>
+						<tr>
+							<th>a</th>
+							<th>b</th>
+							<th>c</th>
+							<th>d</th>
+							<th>e</th>
+							<th>f</th>
+							<th>g</th>
+							<th>h</th>
+							<th>i</th>
+						</tr>
+					</thead>
+					<tbody>
+						<tr>
+							<td>00</td>
+							<td>01</td>
+							<td rowspan="4">02</td>
+							<td>03</td>
+							<td colspan="2" rowspan="7">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>
+	</div>
+
+	<div style="padding-left: 2em;flex: 1;">
+		<h3>Content editable to test paste</h3>
+		<div contenteditable="true" class="ck-content" style="padding: 1em;border: 1px dotted #333;">
+			paste here
+		</div>
+	</div>
+</div>

+ 44 - 0
packages/ckeditor5-table/tests/manual/tableclipboard.js

@@ -0,0 +1,44 @@
+/**
+ * @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, document, window, CKEditorInspector */
+
+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 TableSelection from '../../src/tableselection';
+import TableClipboard from '../../src/tableclipboard';
+import TableProperties from '../../src/tableproperties';
+import TableCellProperties from '../../src/tablecellproperties';
+
+window.editors = {};
+
+createEditor( '#editor-content', 'content' );
+createEditor( '#editor-geometry', 'geometry' );
+
+function createEditor( target, inspectorName ) {
+	ClassicEditor
+		.create( document.querySelector( target ), {
+			plugins: [ ArticlePluginSet, Table, TableToolbar, TableSelection, TableClipboard, TableProperties, TableCellProperties ],
+			toolbar: [
+				'heading', '|',
+				'insertTable', '|',
+				'bold', 'italic', 'link', '|',
+				'bulletedList', 'numberedList', 'blockQuote', '|',
+				'undo', 'redo'
+			],
+			table: {
+				contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells', 'tableProperties', 'tableCellProperties' ]
+			}
+		} )
+		.then( editor => {
+			window.editors[ inspectorName ] = editor;
+			CKEditorInspector.attach( inspectorName, editor );
+		} )
+		.catch( err => {
+			console.error( err.stack );
+		} );
+}

+ 21 - 0
packages/ckeditor5-table/tests/manual/tableclipboard.md

@@ -0,0 +1,21 @@
+### Testing
+
+Copying selected table cells:
+
+1. Select a fragment of table cell.
+2. Use copy shortcut <kbd>ctrl</kbd>+<kbd>C</kbd>.
+3. Paste selected content:
+    - somewhere in the document.
+    - in the editable field on the right.
+4. The pasted table should:
+    - be rectangular (no missing nor exceeding table cells)
+    - have proper headings
+5. The editors are exposed as:
+    - `window.editor.content` and "content" editor in CKEditor inspector
+    - `window.editor.geometry` and "geometry" editor in CKEditor inspector
+
+Note that table copy:
+
+- have cut disabled
+- paste in table is not possible
+- pasted table can be fixed by a post-fixer (use content editable to verify what's being copied)

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

@@ -0,0 +1,184 @@
+<style>
+	body {
+		font-family: Helvetica, Arial, sans-serif;
+		font-size: 14px;
+	}
+
+	.print-selected {
+		background: #b8e3ff;
+	}
+
+	/* This shouldn't be needed. See https://github.com/ckeditor/ckeditor5/issues/6314 */
+	.external-source td, .external-source th {
+		border: solid 1px hsl(0, 0%, 85%);
+	}
+</style>
+
+<h3>A "content" test editor</h3>
+
+<div id="editor-content">
+	<p>Jelly-o topping chocolate danish. Powder donut dragée cupcake sesame snaps cotton candy. Cotton candy pudding apple pie liquorice
+	sugar plum cookie. Powder gummi bears macaroon. Gingerbread soufflé liquorice jelly-o marzipan pudding. Toffee sweet candy canes danish
+	macaroon cotton candy fruitcake. Sesame snaps cake cookie chocolate cupcake. Ice cream pie apple pie sweet. Wafer ice cream gingerbread
+	fruitcake donut jelly sweet. Sugar plum chocolate gummi bears. Jelly-o oat cake wafer brownie gingerbread pie dragée. Marshmallow
+	marzipan candy canes. Cotton candy liquorice cake soufflé pie candy.</p>
+	<figure class="table">
+		<table>
+			<thead>
+				<tr>
+					<th>0</th>
+					<th>1</th>
+					<th>2</th>
+					<th>3</th>
+					<th>4</th>
+				</tr>
+			</thead>
+			<tbody>
+				<tr>
+					<td>a</td>
+					<td>b</td>
+					<td><strong>c</strong></td>
+					<td><a href="https://example.com">d</a></td>
+					<td>e</td>
+				</tr>
+				<tr>
+					<td><i>f</i></td>
+					<td><i>g</i></td>
+					<td><i><strong>h</strong></i></td>
+					<td><i>i</i></td>
+					<td><i>j</i></td>
+				</tr>
+				<tr>
+					<td>k</td>
+					<td>l</td>
+					<td><strong>m</strong></td>
+					<td>n</td>
+					<td>o</td>
+				</tr>
+				<tr>
+					<td>p</td>
+					<td>
+						<ul>
+							<li>q</li>
+							<li>q</li>
+						</ul>
+					</td>
+					<td>
+						<ol>
+							<li>r</li>
+							<li>r</li>
+						</ol>
+					</td>
+					<td>s</td>
+					<td>
+						<blockquote><p>t</p></blockquote>
+						<p>t</p></td>
+				</tr>
+			</tbody>
+		</table>
+	</figure>
+	<p>Halvah oat cake lemon drops. Cake tart caramels. Topping soufflé cheesecake. Chocolate bar sugar plum pastry sesame snaps bear claw
+		gummies cotton candy topping. Tootsie roll topping cake chocolate bar marshmallow lemon drops. Cheesecake cookie croissant chupa
+		chups. Biscuit jujubes lollipop fruitcake sesame snaps halvah. Marzipan croissant dessert chocolate cheesecake halvah jelly beans.
+		Caramels pastry bear claw oat cake sugar plum muffin sweet roll cake. Chocolate bar sweet roll sweet roll bonbon apple pie pastry
+		lemon drops icing. Apple pie wafer marzipan. Cake donut macaroon pudding. Gummi bears wafer toffee chocolate bar bear claw.
+		Fruitcake jelly chocolate bar.</p>
+</div>
+
+<h3>A "geometry" test editor</h3>
+
+<div id="editor-geometry">
+	<figure class="table">
+		<table>
+			<thead>
+				<tr>
+					<th>a</th>
+					<th>b</th>
+					<th>c</th>
+					<th>d</th>
+					<th>e</th>
+					<th>f</th>
+					<th>g</th>
+					<th>h</th>
+					<th>i</th>
+				</tr>
+			</thead>
+			<tbody>
+				<tr>
+					<td>00</td>
+					<td>01</td>
+					<td rowspan="4">02</td>
+					<td>03</td>
+					<td colspan="2" rowspan="7">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>

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

@@ -0,0 +1,78 @@
+/**
+ * @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, CKEditorInspector */
+
+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';
+import TableClipboard from '../../src/tableclipboard';
+import TableProperties from '../../src/tableproperties';
+import TableCellProperties from '../../src/tablecellproperties';
+
+window.editors = {};
+
+createEditor( '#editor-content', 'content' );
+createEditor( '#editor-geometry', 'geometry' );
+
+function createEditor( target, inspectorName ) {
+	ClassicEditor
+		.create( document.querySelector( target ), {
+			plugins: [ ArticlePluginSet, Table, TableToolbar, TableSelection, TableClipboard, TableProperties, TableCellProperties ],
+			toolbar: [
+				'heading', '|',
+				'insertTable', '|',
+				'bold', 'italic', 'link', '|',
+				'bulletedList', 'numberedList', 'blockQuote', '|',
+				'undo', 'redo'
+			],
+			table: {
+				contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells', 'tableProperties', 'tableCellProperties' ]
+			}
+		} )
+		.then( editor => {
+			window.editors[ inspectorName ] = editor;
+			CKEditorInspector.attach( inspectorName, editor );
+
+			editor.model.document.on( 'change', () => {
+				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' );
+}

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

@@ -0,0 +1,9 @@
+### 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.
+3. The editors are exposed as:
+    - `window.editor.content` and "content" editor in CKEditor inspector
+    - `window.editor.geometry` and "geometry" editor in CKEditor inspector

+ 3 - 3
packages/ckeditor5-table/tests/tablecellproperties/ui/tablecellpropertiesview.js

@@ -36,7 +36,7 @@ const VIEW_OPTIONS = {
 			model: 'rgb(0,255,0)',
 			label: 'Green',
 			hasBorder: false
-		},
+		}
 	]
 };
 
@@ -479,7 +479,7 @@ describe( 'table cell properties', () => {
 								'Align cell text to the left',
 								'Align cell text to the center',
 								'Align cell text to the right',
-								'Justify cell text',
+								'Justify cell text'
 							] );
 
 							expect( toolbar.items.map( ( { isOn } ) => isOn ) ).to.have.ordered.members( [
@@ -518,7 +518,7 @@ describe( 'table cell properties', () => {
 							expect( toolbar.items.map( ( { label } ) => label ) ).to.have.ordered.members( [
 								'Align cell text to the top',
 								'Align cell text to the middle',
-								'Align cell text to the bottom',
+								'Align cell text to the bottom'
 							] );
 
 							expect( toolbar.items.map( ( { isOn } ) => isOn ) ).to.have.ordered.members( [

+ 335 - 0
packages/ckeditor5-table/tests/tableclipboard.js

@@ -0,0 +1,335 @@
+/**
+ * @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 { modelTable, viewTable } from './_utils/utils';
+import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
+import ViewDocumentFragment from '@ckeditor/ckeditor5-engine/src/view/documentfragment';
+import { stringify as stringifyView } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+import TableClipboard from '../src/tableclipboard';
+
+describe( 'table clipboard', () => {
+	let editor, model, modelRoot, tableSelection, viewDocument;
+
+	beforeEach( async () => {
+		editor = await VirtualTestEditor.create( {
+			plugins: [ TableEditing, TableClipboard, Paragraph, Clipboard ]
+		} );
+
+		model = editor.model;
+		modelRoot = model.document.getRoot();
+		viewDocument = editor.editing.view.document;
+		tableSelection = editor.plugins.get( 'TableSelection' );
+
+		setModelData( model, modelTable( [
+			[ '00[]', '01', '02' ],
+			[ '10', '11', '12' ],
+			[ '20', '21', '22' ]
+		] ) );
+	} );
+
+	afterEach( async () => {
+		await editor.destroy();
+	} );
+
+	describe( 'Clipboard integration', () => {
+		describe( 'copy', () => {
+			it( 'should to nothing for normal selection in table', () => {
+				const dataTransferMock = createDataTransfer();
+				const spy = sinon.spy();
+
+				viewDocument.on( 'clipboardOutput', spy );
+
+				viewDocument.fire( 'copy', {
+					dataTransfer: dataTransferMock,
+					preventDefault: sinon.spy()
+				} );
+
+				sinon.assert.calledOnce( spy );
+			} );
+
+			it( 'should copy selected table cells as standalone table', done => {
+				const dataTransferMock = createDataTransfer();
+				const preventDefaultSpy = sinon.spy();
+
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) );
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 1, 2 ] ) );
+
+				viewDocument.on( 'clipboardOutput', ( evt, data ) => {
+					expect( preventDefaultSpy.calledOnce ).to.be.true;
+					expect( data.method ).to.equal( 'copy' );
+
+					expect( data.dataTransfer ).to.equal( dataTransferMock );
+
+					expect( data.content ).is.instanceOf( ViewDocumentFragment );
+					expect( stringifyView( data.content ) ).to.equal( viewTable( [
+						[ '01', '02' ],
+						[ '11', '12' ]
+					] ) );
+
+					done();
+				} );
+
+				viewDocument.fire( 'copy', {
+					dataTransfer: dataTransferMock,
+					preventDefault: preventDefaultSpy
+				} );
+			} );
+
+			it( 'should trim selected table to a selection rectangle (inner cell with colspan, no colspan after trim)', done => {
+				setModelData( model, modelTable( [
+					[ '00[]', '01', '02' ],
+					[ '10', { contents: '11', colspan: 2 } ],
+					[ '20', '21', '22' ]
+				] ) );
+
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) );
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 2, 1 ] ) );
+
+				assertClipboardCopy( viewTable( [
+					[ '00', '01' ],
+					[ '10', '11' ],
+					[ '20', '21' ]
+				] ), done );
+			} );
+
+			it( 'should trim selected table to a selection rectangle (inner cell with colspan, has colspan after trim)', done => {
+				setModelData( model, modelTable( [
+					[ '00[]', '01', '02' ],
+					[ { contents: '10', colspan: 3 } ],
+					[ '20', '21', '22' ]
+				] ) );
+
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) );
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 2, 1 ] ) );
+
+				assertClipboardCopy( viewTable( [
+					[ '00', '01' ],
+					[ { contents: '10', colspan: 2 } ],
+					[ '20', '21' ]
+				] ), done );
+			} );
+
+			it( 'should trim selected table to a selection rectangle (inner cell with rowspan, no colspan after trim)', done => {
+				setModelData( model, modelTable( [
+					[ '00[]', '01', '02' ],
+					[ '10', { contents: '11', rowspan: 2 }, '12' ],
+					[ '20', '21', '22' ]
+				] ) );
+
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) );
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 1, 2 ] ) );
+
+				assertClipboardCopy( viewTable( [
+					[ '00', '01', '02' ],
+					[ '10', '11', '12' ]
+				] ), done );
+			} );
+
+			it( 'should trim selected table to a selection rectangle (inner cell with rowspan, has rowspan after trim)', done => {
+				setModelData( model, modelTable( [
+					[ '00[]', { contents: '01', rowspan: 3 }, '02' ],
+					[ '10', '12' ],
+					[ '20', '22' ]
+				] ) );
+
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) );
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 1, 1 ] ) );
+
+				assertClipboardCopy( viewTable( [
+					[ '00', { contents: '01', rowspan: 2 }, '02' ],
+					[ '10', '12' ]
+				] ), done );
+			} );
+
+			it( 'should prepend spanned columns with empty cells (outside cell with colspan)', done => {
+				setModelData( model, modelTable( [
+					[ '00[]', '01', '02' ],
+					[ { contents: '10', colspan: 2 }, '12' ],
+					[ '20', '21', '22' ]
+				] ) );
+
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) );
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 2, 2 ] ) );
+
+				assertClipboardCopy( viewTable( [
+					[ '01', '02' ],
+					[ '', '12' ],
+					[ '21', '22' ]
+				] ), done );
+			} );
+
+			it( 'should prepend spanned columns with empty cells (outside cell with rowspan)', done => {
+				setModelData( model, modelTable( [
+					[ '00[]', { contents: '01', rowspan: 2 }, '02' ],
+					[ '10', '12' ],
+					[ '20', '21', '22' ]
+				] ) );
+
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 1, 0 ] ) );
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 2, 2 ] ) );
+
+				assertClipboardCopy( viewTable( [
+					[ '10', '', '12' ],
+					[ '20', '21', '22' ]
+				] ), done );
+			} );
+
+			it( 'should fix selected table to a selection rectangle (hardcore case)', done => {
+				// This test check how previous simple rules run together (mixed prepending and trimming).
+				// In the example below a selection is set from cell "32" to "88"
+				//
+				//                    Input table:                                         Copied table:
+				//
+				//   +----+----+----+----+----+----+----+----+----+
+				//   | 00 | 01 | 02 | 03 | 04      | 06 | 07 | 08 |
+				//   +----+----+    +----+         +----+----+----+
+				//   | 10 | 11 |    | 13 |         | 16 | 17 | 18 |
+				//   +----+----+    +----+         +----+----+----+             +----+----+----+---------+----+----+
+				//   | 20 | 21 |    | 23 |         | 26           |             | 21 |    | 23 |    |    | 26 |    |
+				//   +----+----+    +----+         +----+----+----+             +----+----+----+----+----+----+----+
+				//   | 30 | 31 |    | 33 |         | 36 | 37      |             | 31 |    | 33 |    |    | 36 | 37 |
+				//   +----+----+----+----+         +----+----+----+             +----+----+----+----+----+----+----+
+				//   | 40                |         | 46 | 47 | 48 |             |    |    |    |    |    | 46 | 47 |
+				//   +----+----+----+----+         +----+----+----+     ==>     +----+----+----+----+----+----+----+
+				//   | 50 | 51 | 52 | 53 |         | 56 | 57 | 58 |             | 51 | 52 | 53 |    |    | 56 | 57 |
+				//   +----+----+----+----+----+----+    +----+----+             +----+----+----+----+----+----+----+
+				//   | 60 | 61           | 64 | 65 |    | 67 | 68 |             | 61 |    |    | 64 | 65 |    | 67 |
+				//   +----+----+----+----+----+----+    +----+----+             +----+----+----+----+----+----+----+
+				//   | 70 | 71 | 72 | 73 | 74 | 75 |    | 77 | 78 |             | 71 | 72 | 73 | 74 | 75 |    | 77 |
+				//   +----+    +----+----+----+----+    +----+----+             +----+----+----+----+----+----+----+
+				//   | 80 |    | 82 | 83 | 84 | 85 |    | 87 | 88 |
+				//   +----+----+----+----+----+----+----+----+----+
+				//
+				setModelData( model, modelTable( [
+					[ '00', '01', { contents: '02', rowspan: 4 }, '03', { contents: '04', colspan: 2, rowspan: 7 }, '07', '07', '08' ],
+					[ '10', '11', '13', '17', '17', '18' ],
+					[ '20', '21', '23', { contents: '27', colspan: 3 } ],
+					[ '30', '31', '33', '37', { contents: '37', colspan: 2 } ],
+					[ { contents: '40', colspan: 4 }, '47', '47', '48' ],
+					[ '50', '51', '52', '53', { contents: '57', rowspan: 4 }, '57', '58' ],
+					[ '60', { contents: '61', colspan: 3 }, '67', '68' ],
+					[ '70', { contents: '71', rowspan: 2 }, '72', '73', '74', '75', '77', '78' ],
+					[ '80', '82', '83', '84', '85', '87', '88' ]
+				] ) );
+
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 2, 1 ] ) );
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 7, 6 ] ) );
+
+				assertClipboardCopy( viewTable( [
+					[ '21', '', '23', '', '', { contents: '27', colspan: 2 } ],
+					[ '31', '', '33', '', '', '37', '37' ],
+					[ '', '', '', '', '', '47', '47' ],
+					[ '51', '52', '53', '', '', { contents: '57', rowspan: 3 }, '57' ],
+					[ { contents: '61', colspan: 3 }, '', '', '', '67' ],
+					[ '71', '72', '73', '74', '75', '77' ]
+				] ), done );
+			} );
+
+			it( 'should update table heading attributes (selection with headings)', done => {
+				setModelData( model, modelTable( [
+					[ '00', '01', '02', '03', '04' ],
+					[ '10', '11', '12', '13', '14' ],
+					[ '20', '21', '22', '23', '24' ],
+					[ '30', '31', '32', '33', '34' ],
+					[ '40', '41', '42', '43', '44' ]
+				], { headingRows: 3, headingColumns: 2 } ) );
+
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 1, 1 ] ) );
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 3, 3 ] ) );
+
+				assertClipboardCopy( viewTable( [
+					[ '11', '12', '13' ],
+					[ '21', '22', '23' ],
+					[ { contents: '31', isHeading: true }, '32', '33' ] // TODO: bug in viewTable
+				], { headingRows: 2, headingColumns: 1 } ), done );
+			} );
+
+			it( 'should update table heading attributes (selection without headings)', done => {
+				setModelData( model, modelTable( [
+					[ '00', '01', '02', '03', '04' ],
+					[ '10', '11', '12', '13', '14' ],
+					[ '20', '21', '22', '23', '24' ],
+					[ '30', '31', '32', '33', '34' ],
+					[ '40', '41', '42', '43', '44' ]
+				], { headingRows: 3, headingColumns: 2 } ) );
+
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 3, 2 ] ) );
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 4, 4 ] ) );
+
+				assertClipboardCopy( viewTable( [
+					[ '32', '33', '34' ],
+					[ '42', '43', '44' ]
+				] ), done );
+			} );
+		} );
+
+		describe( 'cut', () => {
+			it( 'is disabled for multi-range selection over a table', () => {
+				const dataTransferMock = createDataTransfer();
+				const preventDefaultSpy = sinon.spy();
+				const spy = sinon.spy();
+
+				viewDocument.on( 'clipboardOutput', spy );
+
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) );
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 1, 2 ] ) );
+
+				viewDocument.fire( 'cut', {
+					dataTransfer: dataTransferMock,
+					preventDefault: preventDefaultSpy
+				} );
+
+				sinon.assert.notCalled( spy );
+				sinon.assert.calledOnce( preventDefaultSpy );
+			} );
+
+			it( 'is not disabled normal selection over a table', () => {
+				const dataTransferMock = createDataTransfer();
+				const spy = sinon.spy();
+
+				viewDocument.on( 'clipboardOutput', spy );
+
+				viewDocument.fire( 'cut', {
+					dataTransfer: dataTransferMock,
+					preventDefault: sinon.spy()
+				} );
+
+				sinon.assert.calledOnce( spy );
+			} );
+		} );
+	} );
+
+	function assertClipboardCopy( expectedViewTable, callback ) {
+		viewDocument.on( 'clipboardOutput', ( evt, data ) => {
+			expect( stringifyView( data.content ) ).to.equal( expectedViewTable );
+
+			callback();
+		} );
+
+		viewDocument.fire( 'copy', {
+			dataTransfer: createDataTransfer(),
+			preventDefault: sinon.spy()
+		} );
+	}
+
+	function createDataTransfer() {
+		const store = new Map();
+
+		return {
+			setData( type, data ) {
+				store.set( type, data );
+			},
+
+			getData( type ) {
+				return store.get( type );
+			}
+		};
+	}
+} );

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

+ 2 - 2
packages/ckeditor5-table/tests/tableproperties/ui/tablepropertiesview.js

@@ -36,7 +36,7 @@ const VIEW_OPTIONS = {
 			model: 'rgb(0,255,0)',
 			label: 'Green',
 			hasBorder: false
-		},
+		}
 	]
 };
 
@@ -575,7 +575,7 @@ describe( 'table properties', () => {
 					view.heightInput,
 					view.alignmentToolbar,
 					view.saveButtonView,
-					view.cancelButtonView,
+					view.cancelButtonView
 				] );
 			} );
 

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

@@ -0,0 +1,339 @@
+/**
+ * @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';
+import DocumentFragment from '@ckeditor/ckeditor5-engine/src/model/documentfragment';
+
+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( 'getSelectionAsFragment()', () => {
+			it( 'should return undefined if no table cells are selected', () => {
+				expect( tableSelection.getSelectionAsFragment() ).to.be.undefined;
+			} );
+
+			it( 'should return document fragment for selected table cells', () => {
+				tableSelection.startSelectingFrom( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) );
+				tableSelection.setSelectingTo( modelRoot.getNodeByPath( [ 0, 1, 1 ] ) );
+
+				expect( tableSelection.getSelectionAsFragment() ).to.be.instanceOf( DocumentFragment );
+			} );
+		} );
+
+		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 );
+	}
+} );

+ 3 - 3
packages/ckeditor5-table/tests/ui/utils.js

@@ -190,7 +190,7 @@ describe( 'UI Utils', () => {
 				groove: 'Groove',
 				ridge: 'Ridge',
 				inset: 'Inset',
-				outset: 'Outset',
+				outset: 'Outset'
 			} );
 		} );
 	} );
@@ -330,7 +330,7 @@ describe( 'UI Utils', () => {
 				'Groove',
 				'Ridge',
 				'Inset',
-				'Outset',
+				'Outset'
 			] );
 		} );
 
@@ -350,7 +350,7 @@ describe( 'UI Utils', () => {
 				false,
 				false,
 				false,
-				false,
+				false
 			] );
 
 			view.borderStyle = 'inset';

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