Kaynağa Gözat

Merge pull request #6786 from ckeditor/i/6120

Feature (table): Introduce pasting tables into a selected table fragment. Closes #6120.

MINOR BREAKING CHANGE: The `cropTable()` utility method was removed. Use `cropTableToDimensions()` instead.
Kuba Niegowski 5 yıl önce
ebeveyn
işleme
1b426397f9

+ 4 - 129
packages/ckeditor5-table/src/commands/mergecellscommand.js

@@ -8,9 +8,9 @@
  */
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
-import { findAncestor, updateNumericAttribute } from './utils';
 import TableUtils from '../tableutils';
-import { getColumnIndexes, getRowIndexes, getSelectedTableCells } from '../utils';
+import { findAncestor, updateNumericAttribute } from './utils';
+import { isSelectionRectangular, getSelectedTableCells } from '../utils';
 
 /**
  * The merge cells command.
@@ -28,7 +28,8 @@ export default class MergeCellsCommand extends Command {
 	 * @inheritDoc
 	 */
 	refresh() {
-		this.isEnabled = canMergeCells( this.editor.model.document.selection, this.editor.plugins.get( TableUtils ) );
+		const selectedTableCells = getSelectedTableCells( this.editor.model.document.selection );
+		this.isEnabled = isSelectionRectangular( selectedTableCells, this.editor.plugins.get( TableUtils ) );
 	}
 
 	/**
@@ -107,132 +108,6 @@ function isEmpty( tableCell ) {
 	return tableCell.childCount == 1 && tableCell.getChild( 0 ).is( 'paragraph' ) && tableCell.getChild( 0 ).isEmpty;
 }
 
-// Checks if the selection contains cells that can be merged.
-//
-// In a table below:
-//
-//   ┌───┬───┬───┬───┐
-//   │ a │ b │ c │ d │
-//   ├───┴───┼───┤   │
-//   │ e     │ f │   │
-//   ├       ├───┼───┤
-//   │       │ g │ h │
-//   └───────┴───┴───┘
-//
-// Valid selections are these which create a solid rectangle (without gaps), such as:
-//   - a, b (two horizontal cells)
-//   - c, f (two vertical cells)
-//   - a, b, e (cell "e" spans over four cells)
-//   - c, d, f (cell d spans over a cell in the row below)
-//
-// While an invalid selection would be:
-//   - a, c (the unselected cell "b" creates a gap)
-//   - f, g, h (cell "d" spans over a cell from the row of "f" cell - thus creates a gap)
-//
-// @param {module:engine/model/selection~Selection} selection
-// @param {module:table/tableUtils~TableUtils} tableUtils
-// @returns {boolean}
-function canMergeCells( selection, tableUtils ) {
-	const selectedTableCells = getSelectedTableCells( selection );
-
-	if ( selectedTableCells.length < 2 || !areCellInTheSameTableSection( selectedTableCells ) ) {
-		return false;
-	}
-
-	// A valid selection is a fully occupied rectangle composed of table cells.
-	// Below we will calculate the area of a selected table cells and the area of valid selection.
-	// The area of a valid selection is defined by top-left and bottom-right cells.
-	const rows = new Set();
-	const columns = new Set();
-
-	let areaOfSelectedCells = 0;
-
-	for ( const tableCell of selectedTableCells ) {
-		const { row, column } = tableUtils.getCellLocation( tableCell );
-		const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
-		const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
-
-		// Record row & column indexes of current cell.
-		rows.add( row );
-		columns.add( column );
-
-		// For cells that spans over multiple rows add also the last row that this cell spans over.
-		if ( rowspan > 1 ) {
-			rows.add( row + rowspan - 1 );
-		}
-
-		// For cells that spans over multiple columns add also the last column that this cell spans over.
-		if ( colspan > 1 ) {
-			columns.add( column + colspan - 1 );
-		}
-
-		areaOfSelectedCells += ( rowspan * colspan );
-	}
-
-	// We can only merge table cells that are in adjacent rows...
-	const areaOfValidSelection = getBiggestRectangleArea( rows, columns );
-
-	return areaOfValidSelection == areaOfSelectedCells;
-}
-
-// Calculates the area of a maximum rectangle that can span over the provided row & column indexes.
-//
-// @param {Array.<Number>} rows
-// @param {Array.<Number>} columns
-// @returns {Number}
-function getBiggestRectangleArea( rows, columns ) {
-	const rowsIndexes = Array.from( rows.values() );
-	const columnIndexes = Array.from( columns.values() );
-
-	const lastRow = Math.max( ...rowsIndexes );
-	const firstRow = Math.min( ...rowsIndexes );
-	const lastColumn = Math.max( ...columnIndexes );
-	const firstColumn = Math.min( ...columnIndexes );
-
-	return ( lastRow - firstRow + 1 ) * ( lastColumn - firstColumn + 1 );
-}
-
-// Checks if the selection does not mix a header (column or row) with other cells.
-//
-// For instance, in the table below valid selections consist of cells with the same letter only.
-// So, a-a (same heading row and column) or d-d (body cells) are valid while c-d or a-b are not.
-//
-//    header columns
-//     ↓   ↓
-//   ┌───┬───┬───┬───┐
-//   │ a │ a │ b │ b │  ← header row
-//   ├───┼───┼───┼───┤
-//   │ c │ c │ d │ d │
-//   ├───┼───┼───┼───┤
-//   │ c │ c │ d │ d │
-//   └───┴───┴───┴───┘
-//
-function areCellInTheSameTableSection( tableCells ) {
-	const table = findAncestor( 'table', tableCells[ 0 ] );
-
-	const rowIndexes = getRowIndexes( tableCells );
-	const headingRows = parseInt( table.getAttribute( 'headingRows' ) || 0 );
-
-	// Calculating row indexes is a bit cheaper so if this check fails we can't merge.
-	if ( !areIndexesInSameSection( rowIndexes, headingRows ) ) {
-		return false;
-	}
-
-	const headingColumns = parseInt( table.getAttribute( 'headingColumns' ) || 0 );
-	const columnIndexes = getColumnIndexes( tableCells );
-
-	// Similarly cells must be in same column section.
-	return areIndexesInSameSection( columnIndexes, headingColumns );
-}
-
-// Unified check if table rows/columns indexes are in the same heading/body section.
-function areIndexesInSameSection( { first, last }, headingSectionSize ) {
-	const firstCellIsInHeading = first < headingSectionSize;
-	const lastCellIsInHeading = last < headingSectionSize;
-
-	return firstCellIsInHeading === lastCellIsInHeading;
-}
-
 function getMergeDimensions( firstTableCell, selectedTableCells, tableUtils ) {
 	let maxWidthOffset = 0;
 	let maxHeightOffset = 0;

+ 226 - 2
packages/ckeditor5-table/src/tableclipboard.js

@@ -8,7 +8,13 @@
  */
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+
 import TableSelection from './tableselection';
+import TableWalker from './tablewalker';
+import { getColumnIndexes, getRowIndexes, isSelectionRectangular } from './utils';
+import { findAncestor } from './commands/utils';
+import { cropTableToDimensions } from './tableselection/croptable';
+import TableUtils from './tableutils';
 
 /**
  * This plugin adds support for copying/cutting/pasting fragments of tables.
@@ -28,7 +34,7 @@ export default class TableClipboard extends Plugin {
 	 * @inheritDoc
 	 */
 	static get requires() {
-		return [ TableSelection ];
+		return [ TableSelection, TableUtils ];
 	}
 
 	/**
@@ -40,6 +46,7 @@ export default class TableClipboard extends Plugin {
 
 		this.listenTo( viewDocument, 'copy', ( evt, data ) => this._onCopyCut( evt, data ) );
 		this.listenTo( viewDocument, 'cut', ( evt, data ) => this._onCopyCut( evt, data ) );
+		this.listenTo( editor.model, 'insertContent', ( evt, args ) => this._onInsertContent( evt, ...args ), { priority: 'high' } );
 	}
 
 	/**
@@ -50,7 +57,7 @@ export default class TableClipboard extends Plugin {
 	 * @param {Object} data Clipboard event data.
 	 */
 	_onCopyCut( evt, data ) {
-		const tableSelection = this.editor.plugins.get( 'TableSelection' );
+		const tableSelection = this.editor.plugins.get( TableSelection );
 
 		if ( !tableSelection.getSelectedTableCells() ) {
 			return;
@@ -74,4 +81,221 @@ export default class TableClipboard extends Plugin {
 			method: evt.name
 		} );
 	}
+
+	/**
+	 * Overrides default {@link module:engine/model/model~Model#insertContent `model.insertContent()`} method to handle pasting table inside
+	 * selected table fragment.
+	 *
+	 * Depending on selected table fragment:
+	 * - If a selected table fragment is smaller than paste table it will crop pasted table to match dimensions.
+	 * - If dimensions are equal it will replace selected table fragment with a pasted table contents.
+	 *
+	 * @private
+	 * @param evt
+	 * @param {module:engine/model/documentfragment~DocumentFragment|module:engine/model/item~Item} content The content to insert.
+	 * @param {module:engine/model/selection~Selectable} [selectable=model.document.selection]
+	 * The selection into which the content should be inserted. If not provided the current model document selection will be used.
+	 */
+	_onInsertContent( evt, content, selectable ) {
+		if ( selectable && !selectable.is( 'documentSelection' ) ) {
+			return;
+		}
+
+		const tableSelection = this.editor.plugins.get( TableSelection );
+		const selectedTableCells = tableSelection.getSelectedTableCells();
+
+		if ( !selectedTableCells ) {
+			return;
+		}
+
+		// We might need to crop table before inserting so reference might change.
+		let pastedTable = getTableIfOnlyTableInContent( content );
+
+		if ( !pastedTable ) {
+			return;
+		}
+
+		// Override default model.insertContent() handling at this point.
+		evt.stop();
+
+		// Currently not handled. See: https://github.com/ckeditor/ckeditor5/issues/6121.
+		if ( selectedTableCells.length === 1 ) {
+			// @if CK_DEBUG // console.log( 'NOT IMPLEMENTED YET: Single table cell is selected.' );
+
+			return;
+		}
+
+		const tableUtils = this.editor.plugins.get( TableUtils );
+
+		// Currently not handled. The selected table content should be trimmed to a rectangular selection.
+		// See: https://github.com/ckeditor/ckeditor5/issues/6122.
+		if ( !isSelectionRectangular( selectedTableCells, tableUtils ) ) {
+			// @if CK_DEBUG // console.log( 'NOT IMPLEMENTED YET: Selection is not rectangular (non-mergeable).' );
+
+			return;
+		}
+
+		const { last: lastColumnOfSelection, first: firstColumnOfSelection } = getColumnIndexes( selectedTableCells );
+		const { first: firstRowOfSelection, last: lastRowOfSelection } = getRowIndexes( selectedTableCells );
+
+		const selectionHeight = lastRowOfSelection - firstRowOfSelection + 1;
+		const selectionWidth = lastColumnOfSelection - firstColumnOfSelection + 1;
+
+		const pasteHeight = tableUtils.getRows( pastedTable );
+		const pasteWidth = tableUtils.getColumns( pastedTable );
+
+		// The if below is temporal and will be removed when handling this case.
+		// See: https://github.com/ckeditor/ckeditor5/issues/6769.
+		if ( selectionHeight > pasteHeight || selectionWidth > pasteWidth ) {
+			// @if CK_DEBUG // console.log( 'NOT IMPLEMENTED YET: Pasted table is smaller than selection area.' );
+
+			return;
+		}
+
+		const model = this.editor.model;
+
+		model.change( writer => {
+			// Crop pasted table if it extends selection area.
+			if ( selectionHeight < pasteHeight || selectionWidth < pasteWidth ) {
+				const cropDimensions = {
+					startRow: 0,
+					startColumn: 0,
+					endRow: selectionHeight - 1,
+					endColumn: selectionWidth - 1
+				};
+
+				pastedTable = cropTableToDimensions( pastedTable, cropDimensions, writer, tableUtils );
+			}
+
+			// Holds two-dimensional array that is addressed by [ row ][ column ] that stores cells anchored at given location.
+			const pastedTableLocationMap = createLocationMap( pastedTable, selectionWidth, selectionHeight );
+
+			// Content table to which we insert a pasted table.
+			const selectedTable = findAncestor( 'table', selectedTableCells[ 0 ] );
+
+			const selectedTableMap = [ ...new TableWalker( selectedTable, {
+				startRow: firstRowOfSelection,
+				endRow: lastRowOfSelection,
+				includeSpanned: true
+			} ) ];
+
+			// Selection must be set to pasted cells (some might be removed or new created).
+			const cellsToSelect = [];
+
+			// Store previous cell in order to insert a new table cells after it (if required).
+			let previousCellInRow;
+
+			// Content table replace cells algorithm iterates over a selected table fragment and:
+			//
+			// - Removes existing table cells at current slot (location).
+			// - Inserts cell from a pasted table for a matched slots.
+			//
+			// This ensures proper table geometry after the paste
+			for ( const { row, column, cell, isSpanned } of selectedTableMap ) {
+				if ( column === 0 ) {
+					previousCellInRow = null;
+				}
+
+				// Could use startColumn, endColumn. See: https://github.com/ckeditor/ckeditor5/issues/6785.
+				if ( column < firstColumnOfSelection || column > lastColumnOfSelection ) {
+					// Only update the previousCellInRow for non-spanned slots.
+					if ( !isSpanned ) {
+						previousCellInRow = cell;
+					}
+
+					continue;
+				}
+
+				// If the slot is occupied by a cell in a selected table - remove it.
+				// The slot of this cell will be either:
+				// - Replaced by a pasted table cell.
+				// - Spanned by a previously pasted table cell.
+				if ( !isSpanned ) {
+					writer.remove( cell );
+				}
+
+				// Map current table slot location to an pasted table slot location.
+				const pastedCell = pastedTableLocationMap[ row - firstRowOfSelection ][ column - firstColumnOfSelection ];
+
+				// There is no cell to insert (might be spanned by other cell in a pasted table) - advance to the next content table slot.
+				if ( !pastedCell ) {
+					continue;
+				}
+
+				// Clone cell to insert (to duplicate its attributes and children).
+				// Cloning is required to support repeating pasted table content when inserting to a bigger selection.
+				const cellToInsert = pastedCell._clone( true );
+
+				let insertPosition;
+
+				if ( !previousCellInRow ) {
+					insertPosition = writer.createPositionAt( selectedTable.getChild( row ), 0 );
+				} else {
+					insertPosition = writer.createPositionAfter( previousCellInRow );
+				}
+
+				writer.insert( cellToInsert, insertPosition );
+				cellsToSelect.push( cellToInsert );
+				previousCellInRow = cellToInsert;
+			}
+
+			writer.setSelection( cellsToSelect.map( cell => writer.createRangeOn( cell ) ) );
+		} );
+	}
+}
+
+function getTableIfOnlyTableInContent( content ) {
+	// Table passed directly.
+	if ( content.is( 'table' ) ) {
+		return content;
+	}
+
+	// We do not support mixed content when pasting table into table.
+	// See: https://github.com/ckeditor/ckeditor5/issues/6817.
+	if ( content.childCount != 1 || !content.getChild( 0 ).is( 'table' ) ) {
+		return null;
+	}
+
+	return content.getChild( 0 );
+}
+
+// Returns two-dimensional array that is addressed by [ row ][ column ] that stores cells anchored at given location.
+//
+// At given row & column location it might be one of:
+//
+// * cell - cell from pasted table anchored at this location.
+// * null - if no cell is anchored at this location.
+//
+// For instance, from a table below:
+//
+//		+----+----+----+----+
+//		| 00 | 01 | 02 | 03 |
+//		+    +----+----+----+
+//		|    | 11      | 13 |
+//		+----+         +----+
+//		| 20 |         | 23 |
+//		+----+----+----+----+
+//
+// The method will return an array (numbers represents cell element):
+//
+//	const map = [
+//		[ '00', '01', '02', '03' ],
+//		[ null, '11', null, '13' ],
+//		[ '20', null, null, '23' ]
+//	]
+//
+// This allows for a quick access to table at give row & column. For instance to access table cell "13" from pasted table call:
+//
+//		const cell = map[ 1 ][ 3 ]
+//
+function createLocationMap( table, width, height ) {
+	// Create height x width (row x column) two-dimensional table to store cells.
+	const map = new Array( height ).fill( null )
+		.map( () => new Array( width ).fill( null ) );
+
+	for ( const { column, row, cell } of new TableWalker( table ) ) {
+		map[ row ][ column ] = cell;
+	}
+
+	return map;
 }

+ 16 - 6
packages/ckeditor5-table/src/tableselection.js

@@ -13,12 +13,9 @@ import first from '@ckeditor/ckeditor5-utils/src/first';
 import TableWalker from './tablewalker';
 import TableUtils from './tableutils';
 import MouseEventsObserver from './tableselection/mouseeventsobserver';
-import {
-	getSelectedTableCells,
-	getTableCellsContainingSelection
-} from './utils';
+import { getColumnIndexes, getRowIndexes, getSelectedTableCells, getTableCellsContainingSelection } from './utils';
 import { findAncestor } from './commands/utils';
-import cropTable from './tableselection/croptable';
+import { cropTableToDimensions } from './tableselection/croptable';
 
 import '../theme/tableselection.css';
 
@@ -99,7 +96,20 @@ export default class TableSelection extends Plugin {
 
 		return this.editor.model.change( writer => {
 			const documentFragment = writer.createDocumentFragment();
-			const table = cropTable( selectedCells, this.editor.plugins.get( 'TableUtils' ), writer );
+
+			const { first: startColumn, last: endColumn } = getColumnIndexes( selectedCells );
+			const { first: startRow, last: endRow } = getRowIndexes( selectedCells );
+
+			const sourceTable = findAncestor( 'table', selectedCells[ 0 ] );
+
+			const cropDimensions = {
+				startRow,
+				startColumn,
+				endRow,
+				endColumn
+			};
+
+			const table = cropTableToDimensions( sourceTable, cropDimensions, writer, this.editor.plugins.get( 'TableUtils' ) );
 
 			writer.insert( table, documentFragment, 0 );
 

+ 91 - 99
packages/ckeditor5-table/src/tableselection/croptable.js

@@ -7,146 +7,138 @@
  * @module table/tableselection/croptable
  */
 
-import { findAncestor } from '../commands/utils';
+import { createEmptyTableCell, updateNumericAttribute } from '../commands/utils';
+import TableWalker from '../tablewalker';
 
 /**
- * Returns a cropped table from the selected table cells.
- *
- * This function is to be used with the table selection.
+ * Returns a cropped table according to given dimensions.
+
+ * To return a cropped table that starts at first row and first column and end in third row and column:
  *
- *		tableSelection.startSelectingFrom( startCell )
- *		tableSelection.setSelectingFrom( endCell )
+ *		const croppedTable = cropTableToDimensions( table, {
+ *			startRow: 1,
+ *			endRow: 1,
+ *			startColumn: 3,
+ *			endColumn: 3
+ *		}, tableUtils, writer );
  *
- *		const croppedTable = cropTable( tableSelection.getSelectedTableCells() );
+ * Calling the code above for the table below:
  *
- * **Note**: This function is also used by {@link module:table/tableselection~TableSelection#getSelectionAsFragment}.
+ *		      0   1   2   3   4                      0   1   2
+ *		    ┌───┬───┬───┬───┬───┐
+ *		 0  │ a │ b │ c │ d │ e │
+ *		    ├───┴───┤   ├───┴───┤                  ┌───┬───┬───┐
+ *		 1  │ f     │   │ g     │                  │   │   │ g │  0
+ *		    ├───┬───┴───┼───┬───┤   will return:   ├───┴───┼───┤
+ *		 2  │ h │ i     │ j │ k │                  │ i     │ j │  1
+ *		    ├───┤       ├───┤   │                  │       ├───┤
+ *		 3  │ l │       │ m │   │                  │       │ m │  2
+ *		    ├───┼───┬───┤   ├───┤                  └───────┴───┘
+ *		 4  │ n │ o │ p │   │ q │
+ *		    └───┴───┴───┴───┴───┘
  *
- * @param {Iterable.<module:engine/model/element~Element>} selectedTableCellsIterator
- * @param {module:table/tableutils~TableUtils} tableUtils
+ * @param {module:engine/model/element~Element} sourceTable
+ * @param {Object} cropDimensions
+ * @param {Number} cropDimensions.startRow
+ * @param {Number} cropDimensions.startColumn
+ * @param {Number} cropDimensions.endRow
+ * @param {Number} cropDimensions.endColumn
  * @param {module:engine/model/writer~Writer} writer
+ * @param {module:table/tableutils~TableUtils} tableUtils
  * @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;
+export function cropTableToDimensions( sourceTable, cropDimensions, writer, tableUtils ) {
+	const { startRow, startColumn, endRow, endColumn } = cropDimensions;
 
-	trimTable( tableCopy, selectionWidth, selectionHeight, writer, tableUtils );
+	// Create empty table with empty rows equal to crop height.
+	const croppedTable = writer.createElement( 'table' );
+	const cropHeight = endRow - startRow + 1;
 
-	const sourceTable = findAncestor( 'table', startElement );
-	addHeadingsToTableCopy( tableCopy, sourceTable, startRow, startColumn, writer );
+	for ( let i = 0; i < cropHeight; i++ ) {
+		writer.insertElement( 'tableRow', croppedTable, 'end' );
+	}
 
-	return tableCopy;
-}
+	const tableMap = [ ...new TableWalker( sourceTable, { startRow, endRow, includeSpanned: true } ) ];
 
-// 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' );
+	// Iterate over source table slots (including empty - spanned - ones).
+	for ( const { row: sourceRow, column: sourceColumn, cell: tableCell, isSpanned } of tableMap ) {
+		// Skip slots outside the cropped area.
+		// Could use startColumn, endColumn. See: https://github.com/ckeditor/ckeditor5/issues/6785.
+		if ( sourceColumn < startColumn || sourceColumn > endColumn ) {
+			continue;
+		}
 
-	const rowToCopyMap = new Map();
-	const copyToOriginalColumnMap = new Map();
+		// Row index in cropped table.
+		const rowInCroppedTable = sourceRow - startRow;
+		const row = croppedTable.getChild( rowInCroppedTable );
 
-	for ( const tableCell of selectedTableCells ) {
-		const row = findAncestor( 'tableRow', tableCell );
+		// For empty slots: fill the gap with empty table cell.
+		if ( isSpanned ) {
+			// TODO: Remove table utils usage. See: https://github.com/ckeditor/ckeditor5/issues/6785.
+			const { row: anchorRow, column: anchorColumn } = tableUtils.getCellLocation( tableCell );
 
-		if ( !rowToCopyMap.has( row ) ) {
-			const rowCopy = row._clone();
-			writer.append( rowCopy, tableCopy );
-			rowToCopyMap.set( row, rowCopy );
+			// But fill the gap only if the spanning cell is anchored outside cropped area.
+			// In the table from method jsdoc those cells are: "c" & "f".
+			if ( anchorRow < startRow || anchorColumn < startColumn ) {
+				createEmptyTableCell( writer, writer.createPositionAt( row, 'end' ) );
+			}
 		}
+		// Otherwise clone the cell with all children and trim if it exceeds cropped area.
+		else {
+			const tableCellCopy = tableCell._clone( true );
 
-		const tableCellCopy = tableCell._clone( true );
-		const { column } = tableUtils.getCellLocation( tableCell );
-
-		copyToOriginalColumnMap.set( tableCellCopy, column );
+			writer.append( tableCellCopy, row );
 
-		writer.append( tableCellCopy, rowToCopyMap.get( row ) );
+			// Trim table if it exceeds cropped area.
+			// In the table from method jsdoc those cells are: "g" & "m".
+			trimTableCellIfNeeded( tableCellCopy, sourceRow, sourceColumn, endRow, endColumn, writer );
+		}
 	}
 
-	addMissingTableCells( tableCopy, startColumn, copyToOriginalColumnMap, writer, tableUtils );
+	// Adjust heading rows & columns in cropped table if crop selection includes headings parts.
+	addHeadingsToCroppedTable( croppedTable, sourceTable, startRow, startColumn, writer );
 
-	return tableCopy;
+	return croppedTable;
 }
 
-// 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;
+// Adjusts table cell dimensions to not exceed limit row and column.
+//
+// If table cell span to a column (or row) that is after a limit column (or row) trim colspan (or rowspan)
+// so the table cell will fit in a cropped area.
+function trimTableCellIfNeeded( tableCell, cellRow, cellColumn, limitRow, limitColumn, writer ) {
+	const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
+	const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
 
-			if ( column !== shiftedColumn ) {
-				for ( let i = 0; i < shiftedColumn - column; i++ ) {
-					const prepCell = writer.createElement( 'tableCell' );
-					writer.insert( prepCell, writer.createPositionBefore( tableCell ) );
+	const endColumn = cellColumn + colspan - 1;
 
-					const paragraph = writer.createElement( 'paragraph' );
+	if ( endColumn > limitColumn ) {
+		const trimmedSpan = limitColumn - cellColumn + 1;
 
-					writer.insert( paragraph, prepCell, 0 );
-					writer.insertText( '', paragraph, 0 );
-				}
-			}
-		}
+		updateNumericAttribute( 'colspan', trimmedSpan, tableCell, writer, 1 );
 	}
-}
 
-// 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 endRow = cellRow + rowspan - 1;
 
-			const { row, column } = tableUtils.getCellLocation( tableCell );
+	if ( endRow > limitRow ) {
+		const trimmedSpan = limitRow - cellRow + 1;
 
-			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 );
-				}
-			}
-		}
+		updateNumericAttribute( 'rowspan', trimmedSpan, tableCell, writer, 1 );
 	}
 }
 
-// Sets proper heading attributes to copied table.
-function addHeadingsToTableCopy( tableCopy, sourceTable, startRow, startColumn, writer ) {
+// Sets proper heading attributes to a cropped table.
+function addHeadingsToCroppedTable( croppedTable, sourceTable, startRow, startColumn, writer ) {
 	const headingRows = parseInt( sourceTable.getAttribute( 'headingRows' ) || 0 );
 
 	if ( headingRows > 0 ) {
-		const copiedRows = headingRows - startRow;
-		writer.setAttribute( 'headingRows', copiedRows, tableCopy );
+		const headingRowsInCrop = headingRows - startRow;
+		updateNumericAttribute( 'headingRows', headingRowsInCrop, croppedTable, writer, 0 );
 	}
 
 	const headingColumns = parseInt( sourceTable.getAttribute( 'headingColumns' ) || 0 );
 
 	if ( headingColumns > 0 ) {
-		const copiedColumns = headingColumns - startColumn;
-		writer.setAttribute( 'headingColumns', copiedColumns, tableCopy );
+		const headingColumnsInCrop = headingColumns - startColumn;
+		updateNumericAttribute( 'headingColumns', headingColumnsInCrop, croppedTable, writer, 0 );
 	}
 }

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

@@ -179,6 +179,74 @@ export function getColumnIndexes( tableCells ) {
 	return getFirstLastIndexesObject( indexes );
 }
 
+/**
+ * Checks if the selection contains cells that do not exceed rectangular selection.
+ *
+ * In a table below:
+ *
+ *   ┌───┬───┬───┬───┐
+ *   │ a │ b │ c │ d │
+ *   ├───┴───┼───┤   │
+ *   │ e     │ f │   │
+ *   ├       ├───┼───┤
+ *   │       │ g │ h │
+ *   └───────┴───┴───┘
+ *
+ * Valid selections are these which create a solid rectangle (without gaps), such as:
+ *   - a, b (two horizontal cells)
+ *   - c, f (two vertical cells)
+ *   - a, b, e (cell "e" spans over four cells)
+ *   - c, d, f (cell d spans over a cell in the row below)
+ *
+ * While an invalid selection would be:
+ *   - a, c (the unselected cell "b" creates a gap)
+ *   - f, g, h (cell "d" spans over a cell from the row of "f" cell - thus creates a gap)
+ *
+ * @param {Array.<module:engine/model/element~Element>} selectedTableCells
+ * @param {module:table/tableutils~TableUtils} tableUtils
+ * @returns {Boolean}
+ */
+export function isSelectionRectangular( selectedTableCells, tableUtils ) {
+	if ( selectedTableCells.length < 2 || !areCellInTheSameTableSection( selectedTableCells ) ) {
+		return false;
+	}
+
+	// A valid selection is a fully occupied rectangle composed of table cells.
+	// Below we will calculate the area of a selected table cells and the area of valid selection.
+	// The area of a valid selection is defined by top-left and bottom-right cells.
+	const rows = new Set();
+	const columns = new Set();
+
+	let areaOfSelectedCells = 0;
+
+	for ( const tableCell of selectedTableCells ) {
+		const { row, column } = tableUtils.getCellLocation( tableCell );
+		const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
+		const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
+
+		// Record row & column indexes of current cell.
+		rows.add( row );
+		columns.add( column );
+
+		// For cells that spans over multiple rows add also the last row that this cell spans over.
+		if ( rowspan > 1 ) {
+			rows.add( row + rowspan - 1 );
+		}
+
+		// For cells that spans over multiple columns add also the last column that this cell spans over.
+		if ( colspan > 1 ) {
+			columns.add( column + colspan - 1 );
+		}
+
+		areaOfSelectedCells += ( rowspan * colspan );
+	}
+
+	// We can only merge table cells that are in adjacent rows...
+	const areaOfValidSelection = getBiggestRectangleArea( rows, columns );
+
+	return areaOfValidSelection == areaOfSelectedCells;
+}
+
 // Helper method to get an object with `first` and `last` indexes from an unsorted array of indexes.
 function getFirstLastIndexesObject( indexes ) {
 	const allIndexesSorted = indexes.sort( ( indexA, indexB ) => indexA - indexB );
@@ -203,3 +271,61 @@ function compareRangeOrder( rangeA, rangeB ) {
 	// b. Collapsed range on the same position (allowed by model but should not happen).
 	return posA.isBefore( posB ) ? -1 : 1;
 }
+
+// Calculates the area of a maximum rectangle that can span over the provided row & column indexes.
+//
+// @param {Array.<Number>} rows
+// @param {Array.<Number>} columns
+// @returns {Number}
+function getBiggestRectangleArea( rows, columns ) {
+	const rowsIndexes = Array.from( rows.values() );
+	const columnIndexes = Array.from( columns.values() );
+
+	const lastRow = Math.max( ...rowsIndexes );
+	const firstRow = Math.min( ...rowsIndexes );
+	const lastColumn = Math.max( ...columnIndexes );
+	const firstColumn = Math.min( ...columnIndexes );
+
+	return ( lastRow - firstRow + 1 ) * ( lastColumn - firstColumn + 1 );
+}
+
+// Checks if the selection does not mix a header (column or row) with other cells.
+//
+// For instance, in the table below valid selections consist of cells with the same letter only.
+// So, a-a (same heading row and column) or d-d (body cells) are valid while c-d or a-b are not.
+//
+//    header columns
+//     ↓   ↓
+//   ┌───┬───┬───┬───┐
+//   │ a │ a │ b │ b │  ← header row
+//   ├───┼───┼───┼───┤
+//   │ c │ c │ d │ d │
+//   ├───┼───┼───┼───┤
+//   │ c │ c │ d │ d │
+//   └───┴───┴───┴───┘
+//
+function areCellInTheSameTableSection( tableCells ) {
+	const table = findAncestor( 'table', tableCells[ 0 ] );
+
+	const rowIndexes = getRowIndexes( tableCells );
+	const headingRows = parseInt( table.getAttribute( 'headingRows' ) || 0 );
+
+	// Calculating row indexes is a bit cheaper so if this check fails we can't merge.
+	if ( !areIndexesInSameSection( rowIndexes, headingRows ) ) {
+		return false;
+	}
+
+	const headingColumns = parseInt( table.getAttribute( 'headingColumns' ) || 0 );
+	const columnIndexes = getColumnIndexes( tableCells );
+
+	// Similarly cells must be in same column section.
+	return areIndexesInSameSection( columnIndexes, headingColumns );
+}
+
+// Unified check if table rows/columns indexes are in the same heading/body section.
+function areIndexesInSameSection( { first, last }, headingSectionSize ) {
+	const firstCellIsInHeading = first < headingSectionSize;
+	const lastCellIsInHeading = last < headingSectionSize;
+
+	return firstCellIsInHeading === lastCellIsInHeading;
+}

+ 1 - 0
packages/ckeditor5-table/tests/manual/tablemocking.html

@@ -43,6 +43,7 @@
 	<button type="button" id="set-model-data">↓ Set model data ↓</button>
 	<button type="button" id="get-model-data">↑ Get model data ↑</button>
 	<button type="button" id="renumber-cells">Renumber cells</button>
+	<label><input type="checkbox" id="use-letters" checked="false">Use letters</label>
 
 	<span id="input-status"></span>
 </div>

+ 11 - 1
packages/ckeditor5-table/tests/manual/tablemocking.js

@@ -58,10 +58,13 @@ ClassicEditor
 				return;
 			}
 
+			const useLetters = document.getElementById( 'use-letters' ).checked;
+
 			editor.model.change( writer => {
 				for ( const { row, column, cell } of new TableWalker( table ) ) {
 					const selection = editor.model.createSelection( cell, 'in' );
-					editor.model.insertContent( writer.createText( `${ row }${ column }` ), selection );
+
+					editor.model.insertContent( writer.createText( createCellText( row, column, useLetters ) ), selection );
 				}
 			} );
 
@@ -127,6 +130,13 @@ ClassicEditor
 		function updateInputStatus( message = '' ) {
 			document.getElementById( 'input-status' ).innerText = message;
 		}
+
+		function createCellText( row, column, useLetters ) {
+			const rowLabel = useLetters ? String.fromCharCode( row + 'a'.charCodeAt( 0 ) ) : row;
+			const columnLabel = useLetters ? String.fromCharCode( column + 'a'.charCodeAt( 0 ) ) : column;
+
+			return `${ rowLabel }${ columnLabel }`;
+		}
 	} )
 	.catch( err => {
 		console.error( err.stack );

+ 422 - 0
packages/ckeditor5-table/tests/tableclipboard-copy-cut.js

@@ -0,0 +1,422 @@
+/**
+ * @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 ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
+import { modelTable, viewTable } from './_utils/utils';
+
+import TableEditing from '../src/tableediting';
+import TableClipboard from '../src/tableclipboard';
+
+describe( 'table clipboard', () => {
+	let editor, model, modelRoot, tableSelection, viewDocument, element;
+
+	beforeEach( async () => {
+		element = document.createElement( 'div' );
+		document.body.appendChild( element );
+
+		editor = await ClassicTestEditor.create( element, {
+			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();
+
+		element.remove();
+	} );
+
+	describe( 'Clipboard integration - copy', () => {
+		it( 'should do 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 a standalone table', () => {
+			const preventDefaultSpy = sinon.spy();
+
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 2 ] )
+			);
+
+			const data = {
+				dataTransfer: createDataTransfer(),
+				preventDefault: preventDefaultSpy
+			};
+			viewDocument.fire( 'copy', data );
+
+			sinon.assert.calledOnce( preventDefaultSpy );
+			expect( data.dataTransfer.getData( 'text/html' ) ).to.equal( viewTable( [
+				[ '01', '02' ],
+				[ '11', '12' ]
+			] ) );
+		} );
+
+		it( 'should trim selected table to a selection rectangle (inner cell with colspan, no colspan after trim)', () => {
+			setModelData( model, modelTable( [
+				[ '00[]', '01', '02' ],
+				[ '10', { contents: '11', colspan: 2 } ],
+				[ '20', '21', '22' ]
+			] ) );
+
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 2, 1 ] )
+			);
+
+			assertClipboardContentOnMethod( 'copy', viewTable( [
+				[ '00', '01' ],
+				[ '10', '11' ],
+				[ '20', '21' ]
+			] ) );
+		} );
+
+		it( 'should trim selected table to a selection rectangle (inner cell with colspan, has colspan after trim)', () => {
+			setModelData( model, modelTable( [
+				[ '00[]', '01', '02' ],
+				[ { contents: '10', colspan: 3 } ],
+				[ '20', '21', '22' ]
+			] ) );
+
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 2, 1 ] )
+			);
+
+			assertClipboardContentOnMethod( 'copy', viewTable( [
+				[ '00', '01' ],
+				[ { contents: '10', colspan: 2 } ],
+				[ '20', '21' ]
+			] ) );
+		} );
+
+		it( 'should trim selected table to a selection rectangle (inner cell with rowspan, no colspan after trim)', () => {
+			setModelData( model, modelTable( [
+				[ '00[]', '01', '02' ],
+				[ '10', { contents: '11', rowspan: 2 }, '12' ],
+				[ '20', '21', '22' ]
+			] ) );
+
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 2 ] )
+			);
+
+			assertClipboardContentOnMethod( 'copy', viewTable( [
+				[ '00', '01', '02' ],
+				[ '10', '11', '12' ]
+			] ) );
+		} );
+
+		it( 'should trim selected table to a selection rectangle (inner cell with rowspan, has rowspan after trim)', () => {
+			setModelData( model, modelTable( [
+				[ '00[]', { contents: '01', rowspan: 3 }, '02' ],
+				[ '10', '12' ],
+				[ '20', '22' ]
+			] ) );
+
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+			);
+
+			assertClipboardContentOnMethod( 'copy', viewTable( [
+				[ '00', { contents: '01', rowspan: 2 }, '02' ],
+				[ '10', '12' ]
+			] ) );
+		} );
+
+		it( 'should prepend spanned columns with empty cells (outside cell with colspan)', () => {
+			setModelData( model, modelTable( [
+				[ '00[]', '01', '02' ],
+				[ { contents: '10', colspan: 2 }, '12' ],
+				[ '20', '21', '22' ]
+			] ) );
+
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
+				modelRoot.getNodeByPath( [ 0, 2, 2 ] )
+			);
+
+			assertClipboardContentOnMethod( 'copy', viewTable( [
+				[ '01', '02' ],
+				[ '&nbsp;', '12' ],
+				[ '21', '22' ]
+			] ) );
+		} );
+
+		it( 'should prepend spanned columns with empty cells (outside cell with rowspan)', () => {
+			setModelData( model, modelTable( [
+				[ '00', { contents: '01', rowspan: 2 }, '02' ],
+				[ '10', '12' ],
+				[ '20', '21', '22' ]
+			] ) );
+
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 2, 2 ] )
+			);
+
+			assertClipboardContentOnMethod( 'copy', viewTable( [
+				[ '10', '&nbsp;', '12' ],
+				[ '20', '21', '22' ]
+			] ) );
+		} );
+
+		it( 'should fix selected table to a selection rectangle (hardcore case)', () => {
+			// This test check how previous simple rules run together (mixed prepending and trimming).
+			// In the example below a selection is set from cell "21" to "77"
+			//
+			//                    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           |         |    | 67 | 68 |             | 61           |    |    |    | 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 }, '06', '07', '08' ],
+				[ '10', '11', '13', '16', '17', '18' ],
+				[ '20', '21', '23', { contents: '26', colspan: 3 } ],
+				[ '30', '31', '33', '36', { contents: '37', colspan: 2 } ],
+				[ { contents: '40', colspan: 4 }, '46', '47', '48' ],
+				[ '50', '51', '52', '53', { contents: '56', 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.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 2, 1 ] ),
+				modelRoot.getNodeByPath( [ 0, 7, 6 ] )
+			);
+
+			assertClipboardContentOnMethod( 'copy', viewTable( [
+				[ '21', '&nbsp;', '23', '&nbsp;', '&nbsp;', { contents: '26', colspan: 2 } ],
+				[ '31', '&nbsp;', '33', '&nbsp;', '&nbsp;', '36', '37' ],
+				[ '&nbsp;', '&nbsp;', '&nbsp;', '&nbsp;', '&nbsp;', '46', '47' ],
+				[ '51', '52', '53', '&nbsp;', '&nbsp;', { contents: '56', rowspan: 3 }, '57' ],
+				[ { contents: '61', colspan: 3 }, '&nbsp;', '&nbsp;', '67' ],
+				[ '71', '72', '73', '74', '75', '77' ]
+			] ) );
+		} );
+
+		it( 'should update table heading attributes (selection with headings)', () => {
+			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.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 1, 1 ] ),
+				modelRoot.getNodeByPath( [ 0, 3, 3 ] )
+			);
+
+			assertClipboardContentOnMethod( 'copy', viewTable( [
+				[ '11', '12', '13' ],
+				[ '21', '22', '23' ],
+				[ { contents: '31', isHeading: true }, '32', '33' ] // TODO: bug in viewTable
+			], { headingRows: 2, headingColumns: 1 } ) );
+		} );
+
+		it( 'should update table heading attributes (selection without headings)', () => {
+			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.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 3, 2 ] ),
+				modelRoot.getNodeByPath( [ 0, 4, 4 ] )
+			);
+
+			assertClipboardContentOnMethod( 'copy', viewTable( [
+				[ '32', '33', '34' ],
+				[ '42', '43', '44' ]
+			] ) );
+		} );
+	} );
+
+	describe( 'Clipboard integration - cut', () => {
+		it( 'should not block clipboardOutput if no multi-cell selection', () => {
+			setModelData( model, modelTable( [
+				[ '[00]', '01', '02' ],
+				[ '10', '11', '12' ],
+				[ '20', '21', '22' ]
+			] ) );
+
+			const dataTransferMock = createDataTransfer();
+
+			viewDocument.fire( 'cut', {
+				dataTransfer: dataTransferMock,
+				preventDefault: sinon.spy()
+			} );
+
+			expect( dataTransferMock.getData( 'text/html' ) ).to.equal( '00' );
+		} );
+
+		it( 'should be preventable', () => {
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+			);
+
+			viewDocument.on( 'clipboardOutput', evt => evt.stop(), { priority: 'high' } );
+
+			viewDocument.fire( 'cut', {
+				dataTransfer: createDataTransfer(),
+				preventDefault: sinon.spy()
+			} );
+
+			assertEqualMarkup( getModelData( model ), modelTable( [
+				[ { contents: '00', isSelected: true }, { contents: '01', isSelected: true }, '02' ],
+				[ { contents: '10', isSelected: true }, { contents: '11', isSelected: true }, '12' ],
+				[ '20', '21', '22' ]
+			] ) );
+		} );
+
+		it( 'is clears selected table cells', () => {
+			const spy = sinon.spy();
+
+			viewDocument.on( 'clipboardOutput', spy );
+
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 1 ] )
+			);
+
+			viewDocument.fire( 'cut', {
+				dataTransfer: createDataTransfer(),
+				preventDefault: sinon.spy()
+			} );
+
+			assertEqualMarkup( getModelData( model ), modelTable( [
+				[ '', '', '02' ],
+				[ '', '[]', '12' ],
+				[ '20', '21', '22' ]
+			] ) );
+		} );
+
+		it( 'should copy selected table cells as a standalone table', () => {
+			const preventDefaultSpy = sinon.spy();
+
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 2 ] )
+			);
+
+			const data = {
+				dataTransfer: createDataTransfer(),
+				preventDefault: preventDefaultSpy
+			};
+			viewDocument.fire( 'cut', data );
+
+			sinon.assert.calledOnce( preventDefaultSpy );
+			expect( data.dataTransfer.getData( 'text/html' ) ).to.equal( viewTable( [
+				[ '01', '02' ],
+				[ '11', '12' ]
+			] ) );
+		} );
+
+		it( 'should be disabled in a readonly mode', () => {
+			const preventDefaultStub = sinon.stub();
+
+			editor.isReadOnly = true;
+
+			tableSelection.setCellSelection(
+				modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
+				modelRoot.getNodeByPath( [ 0, 1, 2 ] )
+			);
+
+			const data = {
+				dataTransfer: createDataTransfer(),
+				preventDefault: preventDefaultStub
+			};
+			viewDocument.fire( 'cut', data );
+
+			editor.isReadOnly = false;
+
+			expect( data.dataTransfer.getData( 'text/html' ) ).to.be.undefined;
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
+				[ '00', '01', '02' ],
+				[ '10', '11', '12' ],
+				[ '20', '21', '22' ]
+			] ) );
+
+			sinon.assert.calledOnce( preventDefaultStub );
+		} );
+	} );
+
+	function assertClipboardContentOnMethod( method, expectedViewTable ) {
+		const data = {
+			dataTransfer: createDataTransfer(),
+			preventDefault: sinon.spy()
+		};
+		viewDocument.fire( method, data );
+
+		expect( data.dataTransfer.getData( 'text/html' ) ).to.equal( expectedViewTable );
+	}
+
+	function createDataTransfer() {
+		const store = new Map();
+
+		return {
+			setData( type, data ) {
+				store.set( type, data );
+			},
+
+			getData( type ) {
+				return store.get( type );
+			}
+		};
+	}
+} );

Dosya farkı çok büyük olduğundan ihmal edildi
+ 1715 - 0
packages/ckeditor5-table/tests/tableclipboard-paste.js


+ 13 - 394
packages/ckeditor5-table/tests/tableclipboard.js

@@ -3,415 +3,34 @@
  * 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 Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+import TableSelection from '../src/tableselection';
+import TableUtils from '../src/tableutils';
 
-import TableEditing from '../src/tableediting';
-import { modelTable, viewTable } from './_utils/utils';
-import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
 import TableClipboard from '../src/tableclipboard';
-import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
 describe( 'table clipboard', () => {
-	let editor, model, modelRoot, tableSelection, viewDocument;
+	let editor;
 
 	beforeEach( async () => {
 		editor = await VirtualTestEditor.create( {
-			plugins: [ TableEditing, TableClipboard, Paragraph, Clipboard ]
+			plugins: [ TableClipboard, Paragraph ]
 		} );
-
-		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();
+	afterEach( () => {
+		return editor.destroy();
 	} );
 
-	describe( 'Clipboard integration', () => {
-		describe( 'copy', () => {
-			it( 'should do 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 a standalone table', () => {
-				const preventDefaultSpy = sinon.spy();
-
-				tableSelection.setCellSelection(
-					modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
-					modelRoot.getNodeByPath( [ 0, 1, 2 ] )
-				);
-
-				const data = {
-					dataTransfer: createDataTransfer(),
-					preventDefault: preventDefaultSpy
-				};
-				viewDocument.fire( 'copy', data );
-
-				sinon.assert.calledOnce( preventDefaultSpy );
-				expect( data.dataTransfer.getData( 'text/html' ) ).to.equal( viewTable( [
-					[ '01', '02' ],
-					[ '11', '12' ]
-				] ) );
-			} );
-
-			it( 'should trim selected table to a selection rectangle (inner cell with colspan, no colspan after trim)', () => {
-				setModelData( model, modelTable( [
-					[ '00[]', '01', '02' ],
-					[ '10', { contents: '11', colspan: 2 } ],
-					[ '20', '21', '22' ]
-				] ) );
-
-				tableSelection.setCellSelection(
-					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
-					modelRoot.getNodeByPath( [ 0, 2, 1 ] )
-				);
-
-				assertClipboardContentOnMethod( 'copy', viewTable( [
-					[ '00', '01' ],
-					[ '10', '11' ],
-					[ '20', '21' ]
-				] ) );
-			} );
-
-			it( 'should trim selected table to a selection rectangle (inner cell with colspan, has colspan after trim)', () => {
-				setModelData( model, modelTable( [
-					[ '00[]', '01', '02' ],
-					[ { contents: '10', colspan: 3 } ],
-					[ '20', '21', '22' ]
-				] ) );
-
-				tableSelection.setCellSelection(
-					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
-					modelRoot.getNodeByPath( [ 0, 2, 1 ] )
-				);
-
-				assertClipboardContentOnMethod( 'copy', viewTable( [
-					[ '00', '01' ],
-					[ { contents: '10', colspan: 2 } ],
-					[ '20', '21' ]
-				] ) );
-			} );
-
-			it( 'should trim selected table to a selection rectangle (inner cell with rowspan, no colspan after trim)', () => {
-				setModelData( model, modelTable( [
-					[ '00[]', '01', '02' ],
-					[ '10', { contents: '11', rowspan: 2 }, '12' ],
-					[ '20', '21', '22' ]
-				] ) );
-
-				tableSelection.setCellSelection(
-					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
-					modelRoot.getNodeByPath( [ 0, 1, 2 ] )
-				);
-
-				assertClipboardContentOnMethod( 'copy', viewTable( [
-					[ '00', '01', '02' ],
-					[ '10', '11', '12' ]
-				] ) );
-			} );
-
-			it( 'should trim selected table to a selection rectangle (inner cell with rowspan, has rowspan after trim)', () => {
-				setModelData( model, modelTable( [
-					[ '00[]', { contents: '01', rowspan: 3 }, '02' ],
-					[ '10', '12' ],
-					[ '20', '22' ]
-				] ) );
-
-				tableSelection.setCellSelection(
-					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
-					modelRoot.getNodeByPath( [ 0, 1, 1 ] )
-				);
-
-				assertClipboardContentOnMethod( 'copy', viewTable( [
-					[ '00', { contents: '01', rowspan: 2 }, '02' ],
-					[ '10', '12' ]
-				] ) );
-			} );
-
-			it( 'should prepend spanned columns with empty cells (outside cell with colspan)', () => {
-				setModelData( model, modelTable( [
-					[ '00[]', '01', '02' ],
-					[ { contents: '10', colspan: 2 }, '12' ],
-					[ '20', '21', '22' ]
-				] ) );
-
-				tableSelection.setCellSelection(
-					modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
-					modelRoot.getNodeByPath( [ 0, 2, 2 ] )
-				);
-
-				assertClipboardContentOnMethod( 'copy', viewTable( [
-					[ '01', '02' ],
-					[ '&nbsp;', '12' ],
-					[ '21', '22' ]
-				] ) );
-			} );
-
-			it( 'should prepend spanned columns with empty cells (outside cell with rowspan)', () => {
-				setModelData( model, modelTable( [
-					[ '00[]', { contents: '01', rowspan: 2 }, '02' ],
-					[ '10', '12' ],
-					[ '20', '21', '22' ]
-				] ) );
-
-				tableSelection.setCellSelection(
-					modelRoot.getNodeByPath( [ 0, 1, 0 ] ),
-					modelRoot.getNodeByPath( [ 0, 2, 2 ] )
-				);
-
-				assertClipboardContentOnMethod( 'copy', viewTable( [
-					[ '10', '&nbsp;', '12' ],
-					[ '20', '21', '22' ]
-				] ) );
-			} );
-
-			it( 'should fix selected table to a selection rectangle (hardcore case)', () => {
-				// 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.setCellSelection(
-					modelRoot.getNodeByPath( [ 0, 2, 1 ] ),
-					modelRoot.getNodeByPath( [ 0, 7, 6 ] )
-				);
-
-				assertClipboardContentOnMethod( 'copy', viewTable( [
-					[ '21', '&nbsp;', '23', '&nbsp;', '&nbsp;', { contents: '27', colspan: 2 } ],
-					[ '31', '&nbsp;', '33', '&nbsp;', '&nbsp;', '37', '37' ],
-					[ '&nbsp;', '&nbsp;', '&nbsp;', '&nbsp;', '&nbsp;', '47', '47' ],
-					[ '51', '52', '53', '&nbsp;', '&nbsp;', { contents: '57', rowspan: 3 }, '57' ],
-					[ { contents: '61', colspan: 3 }, '&nbsp;', '&nbsp;', '&nbsp;', '67' ],
-					[ '71', '72', '73', '74', '75', '77' ]
-				] ) );
-			} );
-
-			it( 'should update table heading attributes (selection with headings)', () => {
-				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.setCellSelection(
-					modelRoot.getNodeByPath( [ 0, 1, 1 ] ),
-					modelRoot.getNodeByPath( [ 0, 3, 3 ] )
-				);
-
-				assertClipboardContentOnMethod( 'copy', viewTable( [
-					[ '11', '12', '13' ],
-					[ '21', '22', '23' ],
-					[ { contents: '31', isHeading: true }, '32', '33' ] // TODO: bug in viewTable
-				], { headingRows: 2, headingColumns: 1 } ) );
-			} );
-
-			it( 'should update table heading attributes (selection without headings)', () => {
-				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.setCellSelection(
-					modelRoot.getNodeByPath( [ 0, 3, 2 ] ),
-					modelRoot.getNodeByPath( [ 0, 4, 4 ] )
-				);
-
-				assertClipboardContentOnMethod( 'copy', viewTable( [
-					[ '32', '33', '34' ],
-					[ '42', '43', '44' ]
-				] ) );
-			} );
+	describe( 'TableClipboard', () => {
+		it( 'should have pluginName', () => {
+			expect( TableClipboard.pluginName ).to.equal( 'TableClipboard' );
 		} );
 
-		describe( 'cut', () => {
-			it( 'should not block clipboardOutput if no multi-cell selection', () => {
-				setModelData( model, modelTable( [
-					[ '[00]', '01', '02' ],
-					[ '10', '11', '12' ],
-					[ '20', '21', '22' ]
-				] ) );
-
-				const dataTransferMock = createDataTransfer();
-
-				viewDocument.fire( 'cut', {
-					dataTransfer: dataTransferMock,
-					preventDefault: sinon.spy()
-				} );
-
-				expect( dataTransferMock.getData( 'text/html' ) ).to.equal( '00' );
-			} );
-
-			it( 'should be preventable', () => {
-				tableSelection.setCellSelection(
-					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
-					modelRoot.getNodeByPath( [ 0, 1, 1 ] )
-				);
-
-				viewDocument.on( 'clipboardOutput', evt => evt.stop(), { priority: 'high' } );
-
-				viewDocument.fire( 'cut', {
-					dataTransfer: createDataTransfer(),
-					preventDefault: sinon.spy()
-				} );
-
-				assertEqualMarkup( getModelData( model ), modelTable( [
-					[ { contents: '00', isSelected: true }, { contents: '01', isSelected: true }, '02' ],
-					[ { contents: '10', isSelected: true }, { contents: '11', isSelected: true }, '12' ],
-					[ '20', '21', '22' ]
-				] ) );
-			} );
-
-			it( 'is clears selected table cells', () => {
-				const spy = sinon.spy();
-
-				viewDocument.on( 'clipboardOutput', spy );
-
-				tableSelection.setCellSelection(
-					modelRoot.getNodeByPath( [ 0, 0, 0 ] ),
-					modelRoot.getNodeByPath( [ 0, 1, 1 ] )
-				);
-
-				viewDocument.fire( 'cut', {
-					dataTransfer: createDataTransfer(),
-					preventDefault: sinon.spy()
-				} );
-
-				assertEqualMarkup( getModelData( model ), modelTable( [
-					[ '', '', '02' ],
-					[ '', '[]', '12' ],
-					[ '20', '21', '22' ]
-				] ) );
-			} );
-
-			it( 'should copy selected table cells as a standalone table', () => {
-				const preventDefaultSpy = sinon.spy();
-
-				tableSelection.setCellSelection(
-					modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
-					modelRoot.getNodeByPath( [ 0, 1, 2 ] )
-				);
-
-				const data = {
-					dataTransfer: createDataTransfer(),
-					preventDefault: preventDefaultSpy
-				};
-				viewDocument.fire( 'cut', data );
-
-				sinon.assert.calledOnce( preventDefaultSpy );
-				expect( data.dataTransfer.getData( 'text/html' ) ).to.equal( viewTable( [
-					[ '01', '02' ],
-					[ '11', '12' ]
-				] ) );
-			} );
-
-			it( 'should be disabled in a readonly mode', () => {
-				const preventDefaultStub = sinon.stub();
-
-				editor.isReadOnly = true;
-
-				tableSelection.setCellSelection(
-					modelRoot.getNodeByPath( [ 0, 0, 1 ] ),
-					modelRoot.getNodeByPath( [ 0, 1, 2 ] )
-				);
-
-				const data = {
-					dataTransfer: createDataTransfer(),
-					preventDefault: preventDefaultStub
-				};
-				viewDocument.fire( 'cut', data );
-
-				editor.isReadOnly = false;
-
-				expect( data.dataTransfer.getData( 'text/html' ) ).to.be.undefined;
-				assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
-					[ '00', '01', '02' ],
-					[ '10', '11', '12' ],
-					[ '20', '21', '22' ]
-				] ) );
-
-				sinon.assert.calledOnce( preventDefaultStub );
-			} );
+		it( 'requires TableSelection and TableUtils ', () => {
+			expect( TableClipboard.requires ).to.deep.equal( [ TableSelection, TableUtils ] );
 		} );
 	} );
-
-	function assertClipboardContentOnMethod( method, expectedViewTable ) {
-		const data = {
-			dataTransfer: createDataTransfer(),
-			preventDefault: sinon.spy()
-		};
-		viewDocument.fire( method, data );
-
-		expect( data.dataTransfer.getData( 'text/html' ) ).to.equal( expectedViewTable );
-	}
-
-	function createDataTransfer() {
-		const store = new Map();
-
-		return {
-			setData( type, data ) {
-				store.set( type, data );
-			},
-
-			getData( type ) {
-				return store.get( type );
-			}
-		};
-	}
 } );