Преглед изворни кода

Merge branch 'master' into i/6091

Maciej Gołaszewski пре 5 година
родитељ
комит
2f7584b935
38 измењених фајлова са 2756 додато и 75 уклоњено
  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%)',
 		color: 'hsl(200, 18%, 100%)',
 		label: 'White'
 		label: 'White'
-	},
+	}
 ];
 ];
 
 
 ClassicEditor
 ClassicEditor
 	.create( document.querySelector( '#snippet-table-styling-colors' ), {
 	.create( document.querySelector( '#snippet-table-styling-colors' ), {
 		extraPlugins: [
 		extraPlugins: [
 			CKEditorPlugins.TableProperties,
 			CKEditorPlugins.TableProperties,
-			CKEditorPlugins.TableCellProperties,
+			CKEditorPlugins.TableCellProperties
 		],
 		],
 		cloudServices: CS_CONFIG,
 		cloudServices: CS_CONFIG,
 		toolbar: {
 		toolbar: {

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

@@ -6,12 +6,12 @@
 				<tr>
 				<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(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: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>
 				<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;">Q1</th>
 					<th style="background-color:hsl(180,52%,58%);width:75px;">Q2</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>
 					<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%)',
 		color: 'hsl(180, 52%, 58%)',
-		label: 'Light Aqua',
+		label: 'Light Aqua'
 	},
 	},
 	{
 	{
 		color: 'hsl(180, 97%, 31%)',
 		color: 'hsl(180, 97%, 31%)',
@@ -95,7 +95,7 @@ ClassicEditor
 	.create( document.querySelector( '#snippet-table-styling' ), {
 	.create( document.querySelector( '#snippet-table-styling' ), {
 		extraPlugins: [
 		extraPlugins: [
 			CKEditorPlugins.TableProperties,
 			CKEditorPlugins.TableProperties,
-			CKEditorPlugins.TableCellProperties,
+			CKEditorPlugins.TableCellProperties
 		],
 		],
 		cloudServices: CS_CONFIG,
 		cloudServices: CS_CONFIG,
 		toolbar: {
 		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 table = tableRow.parent;
 
 
 		const headingColumns = table.getAttribute( 'headingColumns' ) || 0;
 		const headingColumns = table.getAttribute( 'headingColumns' ) || 0;
-		const row = table.getChildIndex( tableRow );
 
 
 		// Cache the table before removing or updating colspans.
 		// Cache the table before removing or updating colspans.
 		const tableMap = [ ...new TableWalker( table ) ];
 		const tableMap = [ ...new TableWalker( table ) ];
@@ -59,10 +58,12 @@ export default class RemoveColumnCommand extends Command {
 		// Get column index of removed column.
 		// Get column index of removed column.
 		const cellData = tableMap.find( value => value.cell === tableCell );
 		const cellData = tableMap.find( value => value.cell === tableCell );
 		const removedColumn = cellData.column;
 		const removedColumn = cellData.column;
+		const selectionRow = cellData.row;
+		const cellToFocus = getCellToFocus( tableCell );
 
 
 		model.change( writer => {
 		model.change( writer => {
 			// Update heading columns attribute if removing a row from head section.
 			// 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 );
 				writer.setAttribute( 'headingColumns', headingColumns - 1, table );
 			}
 			}
 
 
@@ -75,6 +76,21 @@ export default class RemoveColumnCommand extends Command {
 					writer.remove( cell );
 					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 tableRow = tableCell.parent;
 		const table = tableRow.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 headingRows = table.getAttribute( 'headingRows' ) || 0;
 
 
+		const columnToFocus = cellData.column;
+
 		model.change( writer => {
 		model.change( writer => {
-			if ( headingRows && currentRow <= headingRows ) {
+			if ( headingRows && removedRow <= headingRows ) {
 				updateNumericAttribute( 'headingRows', headingRows - 1, table, writer, 0 );
 				updateNumericAttribute( 'headingRows', headingRows - 1, table, writer, 0 );
 			}
 			}
 
 
-			const tableMap = [ ...new TableWalker( table, { endRow: currentRow } ) ];
-
 			const cellsToMove = new Map();
 			const cellsToMove = new Map();
 
 
 			// Get cells from removed row that are spanned over multiple rows.
 			// Get cells from removed row that are spanned over multiple rows.
 			tableMap
 			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 } ) );
 				.forEach( ( { column, cell, rowspan } ) => cellsToMove.set( column, { cell, rowspanToSet: rowspan - 1 } ) );
 
 
 			// Reduce rowspan on cells that are above removed row and overlaps removed row.
 			// Reduce rowspan on cells that are above removed row and overlaps removed row.
 			tableMap
 			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 ) );
 				.forEach( ( { cell, rowspan } ) => updateNumericAttribute( 'rowspan', rowspan - 1, cell, writer ) );
 
 
 			// Move cells to another row.
 			// Move cells to another row.
-			const targetRow = currentRow + 1;
+			const targetRow = removedRow + 1;
 			const tableWalker = new TableWalker( table, { includeSpanned: true, startRow: targetRow, endRow: targetRow } );
 			const tableWalker = new TableWalker( table, { includeSpanned: true, startRow: targetRow, endRow: targetRow } );
 
 
 			let previousCell;
 			let previousCell;
@@ -93,6 +98,27 @@ export default class RemoveRowCommand extends Command {
 			}
 			}
 
 
 			writer.remove( tableRow );
 			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';
 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 {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}
  * @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 ) {
 	while ( parent ) {
 		if ( parent.name === parentName ) {
 		if ( parent.name === parentName ) {

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

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

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

@@ -22,7 +22,7 @@ import {
 	lengthFieldValidator,
 	lengthFieldValidator,
 	lineWidthFieldValidator,
 	lineWidthFieldValidator,
 	repositionContextualBalloon,
 	repositionContextualBalloon,
-	defaultColors,
+	defaultColors
 } from '../ui/utils';
 } from '../ui/utils';
 import {
 import {
 	getLocalizedColorOptions,
 	getLocalizedColorOptions,
@@ -255,7 +255,7 @@ export default class TablePropertiesUI extends Plugin {
 			backgroundColor: commands.get( 'tableBackgroundColor' ).value || '',
 			backgroundColor: commands.get( 'tableBackgroundColor' ).value || '',
 			width: commands.get( 'tableWidth' ).value || '',
 			width: commands.get( 'tableWidth' ).value || '',
 			height: commands.get( 'tableHeight' ).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, {
 		this.children.add( new FormRowView( locale, {
 			children: [
 			children: [
 				this.saveButtonView,
 				this.saveButtonView,
-				this.cancelButtonView,
+				this.cancelButtonView
 			],
 			],
 			class: 'ck-table-form__action-row'
 			class: 'ck-table-form__action-row'
 		} ) );
 		} ) );
@@ -366,7 +366,7 @@ export default class TablePropertiesView extends View {
 			this.heightInput,
 			this.heightInput,
 			this.alignmentToolbar,
 			this.alignmentToolbar,
 			this.saveButtonView,
 			this.saveButtonView,
-			this.cancelButtonView,
+			this.cancelButtonView
 		].forEach( view => {
 		].forEach( view => {
 			// Register the view as focusable.
 			// Register the view as focusable.
 			this._focusables.add( view );
 			this._focusables.add( view );
@@ -455,7 +455,7 @@ export default class TablePropertiesView extends View {
 
 
 		borderColorInput.set( {
 		borderColorInput.set( {
 			label: t( 'Color' ),
 			label: t( 'Color' ),
-			class: 'ck-table-form__border-color',
+			class: 'ck-table-form__border-color'
 		} );
 		} );
 
 
 		borderColorInput.view.bind( 'value' ).to( this, 'borderColor' );
 		borderColorInput.view.bind( 'value' ).to( this, 'borderColor' );
@@ -502,7 +502,7 @@ export default class TablePropertiesView extends View {
 
 
 		backgroundInput.set( {
 		backgroundInput.set( {
 			label: t( 'Background' ),
 			label: t( 'Background' ),
-			class: 'ck-table-properties-form__background',
+			class: 'ck-table-properties-form__background'
 		} );
 		} );
 
 
 		backgroundInput.view.bind( 'value' ).to( this, 'backgroundColor' );
 		backgroundInput.view.bind( 'value' ).to( this, 'backgroundColor' );
@@ -537,7 +537,7 @@ export default class TablePropertiesView extends View {
 
 
 		widthInput.set( {
 		widthInput.set( {
 			label: t( 'Width' ),
 			label: t( 'Width' ),
-			class: 'ck-table-form__dimensions-row__width',
+			class: 'ck-table-form__dimensions-row__width'
 		} );
 		} );
 
 
 		widthInput.view.bind( 'value' ).to( this, 'width' );
 		widthInput.view.bind( 'value' ).to( this, 'width' );
@@ -566,7 +566,7 @@ export default class TablePropertiesView extends View {
 
 
 		heightInput.set( {
 		heightInput.set( {
 			label: t( 'Height' ),
 			label: t( 'Height' ),
-			class: 'ck-table-form__dimensions-row__height',
+			class: 'ck-table-form__dimensions-row__height'
 		} );
 		} );
 
 
 		heightInput.view.bind( 'value' ).to( this, 'height' );
 		heightInput.view.bind( 'value' ).to( this, 'height' );
@@ -652,7 +652,7 @@ export default class TablePropertiesView extends View {
 			icon: checkIcon,
 			icon: checkIcon,
 			class: 'ck-button-save',
 			class: 'ck-button-save',
 			type: 'submit',
 			type: 'submit',
-			withText: true,
+			withText: true
 		} );
 		} );
 
 
 		saveButtonView.bind( 'isEnabled' ).toMany( fieldsThatShouldValidateToSave, 'errorText', ( ...errorTexts ) => {
 		saveButtonView.bind( 'isEnabled' ).toMany( fieldsThatShouldValidateToSave, 'errorText', ( ...errorTexts ) => {
@@ -664,7 +664,7 @@ export default class TablePropertiesView extends View {
 			icon: cancelIcon,
 			icon: cancelIcon,
 			class: 'ck-button-cancel',
 			class: 'ck-button-cancel',
 			type: 'cancel',
 			type: 'cancel',
-			withText: true,
+			withText: true
 		} );
 		} );
 
 
 		cancelButtonView.delegate( 'execute' ).to( this, 'cancel' );
 		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: [
 			children: [
 				this._inputView,
 				this._inputView,
 				this._dropdownView
 				this._dropdownView
-			],
+			]
 		} );
 		} );
 	}
 	}
 
 
@@ -170,7 +170,7 @@ export default class ColorInputView extends View {
 		dropdown.buttonView.extendTemplate( {
 		dropdown.buttonView.extendTemplate( {
 			attributes: {
 			attributes: {
 				class: 'ck-input-color__button'
 				class: 'ck-input-color__button'
-			},
+			}
 		} );
 		} );
 
 
 		dropdown.buttonView.children.add( colorPreview );
 		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: [
 				class: [
 					'ck',
 					'ck',
 					'ck-form__header__label'
 					'ck-form__header__label'
-				],
+				]
 			},
 			},
 			children: [
 			children: [
 				{ text: bind.to( 'label' ) }
 				{ text: bind.to( 'label' ) }

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

@@ -110,7 +110,7 @@ export function getBorderStyleLabels( t ) {
 		groove: t( 'Groove' ),
 		groove: t( 'Groove' ),
 		ridge: t( 'Ridge' ),
 		ridge: t( 'Ridge' ),
 		inset: t( 'Inset' ),
 		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', {
 	schema.register( 'tableCell', {
 		allowIn: 'tableRow',
 		allowIn: 'tableRow',
 		allowAttributes: [ 'colspan', 'rowspan' ],
 		allowAttributes: [ 'colspan', 'rowspan' ],
-		isLimit: true
+		isObject: true
 	} );
 	} );
 
 
 	// Allow all $block content inside table cell.
 	// Allow all $block content inside table cell.
@@ -322,11 +322,59 @@ export function assertTableStyle( editor, tableStyle, figureStyle ) {
 export function assertTableCellStyle( editor, tableCellStyle ) {
 export function assertTableCellStyle( editor, tableCellStyle ) {
 	assertEqualMarkup( editor.getData(),
 	assertEqualMarkup( editor.getData(),
 		'<figure class="table"><table><tbody><tr>' +
 		'<figure class="table"><table><tbody><tr>' +
-			`<td${ tableCellStyle ? ` style="${ tableCellStyle }"` : '' }>foo</td>` +
+		`<td${ tableCellStyle ? ` style="${ tableCellStyle }"` : '' }>foo</td>` +
 		'</tr></tbody></table></figure>'
 		'</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
 // Formats table cell attributes
 //
 //
 // @param {Object} attributes Attributes of a cell.
 // @param {Object} attributes Attributes of a cell.
@@ -356,20 +404,24 @@ function makeRows( tableData, options ) {
 				let contents = isObject ? tableCellData.contents : tableCellData;
 				let contents = isObject ? tableCellData.contents : tableCellData;
 
 
 				let resultingCellElement = cellElement;
 				let resultingCellElement = cellElement;
+				let isSelected = false;
 
 
 				if ( isObject ) {
 				if ( isObject ) {
 					if ( tableCellData.isHeading ) {
 					if ( tableCellData.isHeading ) {
 						resultingCellElement = headingElement;
 						resultingCellElement = headingElement;
 					}
 					}
 
 
+					isSelected = !!tableCellData.isSelected;
+
 					delete tableCellData.contents;
 					delete tableCellData.contents;
 					delete tableCellData.isHeading;
 					delete tableCellData.isHeading;
+					delete tableCellData.isSelected;
 				}
 				}
 
 
 				const attributes = isObject ? tableCellData : {};
 				const attributes = isObject ? tableCellData : {};
 
 
 				if ( asWidget ) {
 				if ( asWidget ) {
-					attributes.class = WIDGET_TABLE_CELL_CLASS + ( attributes.class ? ` ${ attributes.class }` : '' );
+					attributes.class = getClassToSet( attributes );
 					attributes.contenteditable = 'true';
 					attributes.contenteditable = 'true';
 				}
 				}
 
 
@@ -381,7 +433,9 @@ function makeRows( tableData, options ) {
 				}
 				}
 
 
 				const formattedAttributes = formatAttributes( attributes );
 				const formattedAttributes = formatAttributes( attributes );
-				tableRowString += `<${ resultingCellElement }${ formattedAttributes }>${ contents }</${ resultingCellElement }>`;
+				const tableCell = `<${ resultingCellElement }${ formattedAttributes }>${ contents }</${ resultingCellElement }>`;
+
+				tableRowString += isSelected ? `[${ tableCell }]` : tableCell;
 
 
 				return tableRowString;
 				return tableRowString;
 			}, '' );
 			}, '' );
@@ -389,3 +443,11 @@ function makeRows( tableData, options ) {
 			return `${ previousRowsString }<${ rowElement }>${ tableRowString }</${ rowElement }>`;
 			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', () => {
 			it( 'should be false if mergeable cell is in other table section then current cell', () => {
 				setData( model, modelTable( [
 				setData( model, modelTable( [
-					[ '00', '01[]' ],
+					[ '00', '01[]' ]
 				], { headingColumns: 1 } ) );
 				], { headingColumns: 1 } ) );
 
 
 				expect( command.isEnabled ).to.be.false;
 				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( [
 			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00', '02' ],
 				[ '00', '02' ],
-				[ '<paragraph>10[]</paragraph>', '12' ],
+				[ '10', '[]12' ],
 				[ '20', '22' ]
 				[ '20', '22' ]
 			] ) );
 			] ) );
 		} );
 		} );
@@ -87,7 +87,7 @@ describe( 'RemoveColumnCommand', () => {
 			command.execute();
 			command.execute();
 
 
 			assertEqualMarkup( getData( model ), modelTable( [
 			assertEqualMarkup( getData( model ), modelTable( [
-				[ '<paragraph>[]01</paragraph>' ],
+				[ '[]01' ],
 				[ '11' ],
 				[ '11' ],
 				[ '21' ]
 				[ '21' ]
 			] ) );
 			] ) );
@@ -104,7 +104,7 @@ describe( 'RemoveColumnCommand', () => {
 
 
 			assertEqualMarkup( getData( model ), modelTable( [
 			assertEqualMarkup( getData( model ), modelTable( [
 				[ '01' ],
 				[ '01' ],
-				[ '<paragraph>[]11</paragraph>' ],
+				[ '[]11' ],
 				[ '21' ]
 				[ '21' ]
 			], { headingColumns: 1 } ) );
 			], { headingColumns: 1 } ) );
 		} );
 		} );
@@ -123,7 +123,7 @@ describe( 'RemoveColumnCommand', () => {
 			assertEqualMarkup( getData( model ), modelTable( [
 			assertEqualMarkup( getData( model ), modelTable( [
 				[ { colspan: 3, contents: '00' }, '03' ],
 				[ { colspan: 3, contents: '00' }, '03' ],
 				[ { colspan: 2, contents: '10' }, '13' ],
 				[ { colspan: 2, contents: '10' }, '13' ],
-				[ { colspan: 2, contents: '<paragraph>20[]</paragraph>' }, '23' ],
+				[ { colspan: 2, contents: '20' }, '[]23' ],
 				[ '30', '31', '33' ],
 				[ '30', '31', '33' ],
 				[ '40', '41', '43' ]
 				[ '40', '41', '43' ]
 
 
@@ -145,5 +145,21 @@ describe( 'RemoveColumnCommand', () => {
 				[ '21', '22', '23' ]
 				[ '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();
 			command.execute();
 
 
 			assertEqualMarkup( getData( model ), modelTable( [
 			assertEqualMarkup( getData( model ), modelTable( [
-				[ '00', '<paragraph>01[]</paragraph>' ],
-				[ '20', '21' ]
+				[ '00', '01' ],
+				[ '[]20', '21' ]
 			] ) );
 			] ) );
 		} );
 		} );
 
 
@@ -80,7 +80,7 @@ describe( 'RemoveRowCommand', () => {
 			command.execute();
 			command.execute();
 
 
 			assertEqualMarkup( getData( model ), modelTable( [
 			assertEqualMarkup( getData( model ), modelTable( [
-				[ '<paragraph>[]10</paragraph>', '11' ],
+				[ '[]10', '11' ],
 				[ '20', '21' ]
 				[ '20', '21' ]
 			] ) );
 			] ) );
 		} );
 		} );
@@ -95,8 +95,8 @@ describe( 'RemoveRowCommand', () => {
 			command.execute();
 			command.execute();
 
 
 			assertEqualMarkup( getData( model ), modelTable( [
 			assertEqualMarkup( getData( model ), modelTable( [
-				[ '00', '<paragraph>01[]</paragraph>' ],
-				[ '20', '21' ]
+				[ '00', '01' ],
+				[ '[]20', '21' ]
 			], { headingRows: 1 } ) );
 			], { headingRows: 1 } ) );
 		} );
 		} );
 
 
@@ -112,8 +112,8 @@ describe( 'RemoveRowCommand', () => {
 
 
 			assertEqualMarkup( getData( model ), modelTable( [
 			assertEqualMarkup( getData( model ), modelTable( [
 				[ { rowspan: 3, contents: '00' }, { rowspan: 2, contents: '01' }, { rowspan: 2, contents: '02' }, '03', '04' ],
 				[ { 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)',
 			model: 'rgb(0,255,0)',
 			label: 'Green',
 			label: 'Green',
 			hasBorder: false
 			hasBorder: false
-		},
+		}
 	]
 	]
 };
 };
 
 
@@ -479,7 +479,7 @@ describe( 'table cell properties', () => {
 								'Align cell text to the left',
 								'Align cell text to the left',
 								'Align cell text to the center',
 								'Align cell text to the center',
 								'Align cell text to the right',
 								'Align cell text to the right',
-								'Justify cell text',
+								'Justify cell text'
 							] );
 							] );
 
 
 							expect( toolbar.items.map( ( { isOn } ) => isOn ) ).to.have.ordered.members( [
 							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( [
 							expect( toolbar.items.map( ( { label } ) => label ) ).to.have.ordered.members( [
 								'Align cell text to the top',
 								'Align cell text to the top',
 								'Align cell text to the middle',
 								'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( [
 							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', () => {
 		describe( 'on TAB', () => {
 			it( 'should do nothing if selection is not in a table', () => {
 			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 );
 				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)',
 			model: 'rgb(0,255,0)',
 			label: 'Green',
 			label: 'Green',
 			hasBorder: false
 			hasBorder: false
-		},
+		}
 	]
 	]
 };
 };
 
 
@@ -575,7 +575,7 @@ describe( 'table properties', () => {
 					view.heightInput,
 					view.heightInput,
 					view.alignmentToolbar,
 					view.alignmentToolbar,
 					view.saveButtonView,
 					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',
 				groove: 'Groove',
 				ridge: 'Ridge',
 				ridge: 'Ridge',
 				inset: 'Inset',
 				inset: 'Inset',
-				outset: 'Outset',
+				outset: 'Outset'
 			} );
 			} );
 		} );
 		} );
 	} );
 	} );
@@ -330,7 +330,7 @@ describe( 'UI Utils', () => {
 				'Groove',
 				'Groove',
 				'Ridge',
 				'Ridge',
 				'Inset',
 				'Inset',
-				'Outset',
+				'Outset'
 			] );
 			] );
 		} );
 		} );
 
 
@@ -350,7 +350,7 @@ describe( 'UI Utils', () => {
 				false,
 				false,
 				false,
 				false,
 				false,
 				false,
-				false,
+				false
 			] );
 			] );
 
 
 			view.borderStyle = 'inset';
 			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);
+	}
+}