Browse Source

Merge pull request #7226 from ckeditor/i/6122-table-paste-spans

Internal (table): Handle spanned table cells in pasting scenarios. Closes #6122.

Fix (table): Setting column as header will now split col-spanned cells. Closes #6658.
Kuba Niegowski 5 years ago
parent
commit
9531af4362

+ 12 - 3
packages/ckeditor5-table/src/commands/setheadercolumncommand.js

@@ -10,7 +10,7 @@
 import Command from '@ckeditor/ckeditor5-core/src/command';
 
 import { findAncestor, isHeadingColumnCell, updateNumericAttribute } from './utils';
-import { getColumnIndexes, getSelectionAffectedTableCells } from '../utils';
+import { getColumnIndexes, getSelectionAffectedTableCells, getHorizontallyOverlappingCells, splitVertically } from '../utils';
 
 /**
  * The header column command.
@@ -69,12 +69,21 @@ export default class SetHeaderColumnCommand extends Command {
 
 		const model = this.editor.model;
 		const selectedCells = getSelectionAffectedTableCells( model.document.selection );
-		const { first, last } = getColumnIndexes( selectedCells );
+		const table = findAncestor( 'table', selectedCells[ 0 ] );
 
+		const { first, last } = getColumnIndexes( selectedCells );
 		const headingColumnsToSet = this.value ? first : last + 1;
 
 		model.change( writer => {
-			const table = findAncestor( 'table', selectedCells[ 0 ] );
+			if ( headingColumnsToSet ) {
+				// Changing heading columns requires to check if any of a heading cell is overlapping horizontally the table head.
+				// Any table cell that has a colspan attribute > 1 will not exceed the table head so we need to fix it in columns before.
+				const overlappingCells = getHorizontallyOverlappingCells( table, headingColumnsToSet );
+
+				for ( const { cell, column } of overlappingCells ) {
+					splitVertically( cell, column, headingColumnsToSet, writer );
+				}
+			}
 
 			updateNumericAttribute( 'headingColumns', headingColumnsToSet, table, writer, 0 );
 		} );

+ 5 - 79
packages/ckeditor5-table/src/commands/setheaderrowcommand.js

@@ -9,9 +9,8 @@
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
 
-import { createEmptyTableCell, findAncestor, updateNumericAttribute } from './utils';
-import { getRowIndexes, getSelectionAffectedTableCells } from '../utils';
-import TableWalker from '../tablewalker';
+import { findAncestor, updateNumericAttribute } from './utils';
+import { getVerticallyOverlappingCells, getRowIndexes, getSelectionAffectedTableCells, splitHorizontally } from '../utils';
 
 /**
  * The header row command.
@@ -77,9 +76,10 @@ export default class SetHeaderRowCommand extends Command {
 			if ( headingRowsToSet ) {
 				// Changing heading rows requires to check if any of a heading cell is overlapping vertically the table head.
 				// Any table cell that has a rowspan attribute > 1 will not exceed the table head so we need to fix it in rows below.
-				const cellsToSplit = getOverlappingCells( table, headingRowsToSet, currentHeadingRows );
+				const startRow = headingRowsToSet > currentHeadingRows ? currentHeadingRows : 0;
+				const overlappingCells = getVerticallyOverlappingCells( table, headingRowsToSet, startRow );
 
-				for ( const cell of cellsToSplit ) {
+				for ( const { cell } of overlappingCells ) {
 					splitHorizontally( cell, headingRowsToSet, writer );
 				}
 			}
@@ -102,77 +102,3 @@ export default class SetHeaderRowCommand extends Command {
 		return !!headingRows && tableCell.parent.index < headingRows;
 	}
 }
-
-// Returns cells that span beyond the new heading section.
-//
-// @param {module:engine/model/element~Element} table The table to check.
-// @param {Number} headingRowsToSet New heading rows attribute.
-// @param {Number} currentHeadingRows Current heading rows attribute.
-// @returns {Array.<module:engine/model/element~Element>}
-function getOverlappingCells( table, headingRowsToSet, currentHeadingRows ) {
-	const cellsToSplit = [];
-
-	const startAnalysisRow = headingRowsToSet > currentHeadingRows ? currentHeadingRows : 0;
-	// We're analyzing only when headingRowsToSet > 0.
-	const endAnalysisRow = headingRowsToSet - 1;
-
-	const tableWalker = new TableWalker( table, { startRow: startAnalysisRow, endRow: endAnalysisRow } );
-
-	for ( const { row, rowspan, cell } of tableWalker ) {
-		if ( rowspan > 1 && row + rowspan > headingRowsToSet ) {
-			cellsToSplit.push( cell );
-		}
-	}
-
-	return cellsToSplit;
-}
-
-// Splits the table cell horizontally.
-//
-// @param {module:engine/model/element~Element} tableCell
-// @param {Number} headingRows
-// @param {module:engine/model/writer~Writer} writer
-function splitHorizontally( tableCell, headingRows, writer ) {
-	const tableRow = tableCell.parent;
-	const table = tableRow.parent;
-	const rowIndex = tableRow.index;
-
-	const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) );
-	const newRowspan = headingRows - rowIndex;
-
-	const attributes = {};
-
-	const spanToSet = rowspan - newRowspan;
-
-	if ( spanToSet > 1 ) {
-		attributes.rowspan = spanToSet;
-	}
-
-	const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
-
-	if ( colspan > 1 ) {
-		attributes.colspan = colspan;
-	}
-
-	const startRow = table.getChildIndex( tableRow );
-	const endRow = startRow + newRowspan;
-	const tableMap = [ ...new TableWalker( table, { startRow, endRow, includeSpanned: true } ) ];
-
-	let columnIndex;
-
-	for ( const { row, column, cell, cellIndex } of tableMap ) {
-		if ( cell === tableCell && columnIndex === undefined ) {
-			columnIndex = column;
-		}
-
-		if ( columnIndex !== undefined && columnIndex === column && row === endRow ) {
-			const tableRow = table.getChild( row );
-			const tableCellPosition = writer.createPositionAt( tableRow, cellIndex );
-
-			createEmptyTableCell( writer, tableCellPosition, attributes );
-		}
-	}
-
-	// Update the rowspan attribute after updating table.
-	updateNumericAttribute( 'rowspan', newRowspan, tableCell, writer );
-}

+ 252 - 33
packages/ckeditor5-table/src/tableclipboard.js

@@ -11,7 +11,16 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 
 import TableSelection from './tableselection';
 import TableWalker from './tablewalker';
-import { getColumnIndexes, getRowIndexes, getSelectionAffectedTableCells, isSelectionRectangular } from './utils';
+import {
+	getColumnIndexes,
+	getVerticallyOverlappingCells,
+	getRowIndexes,
+	getSelectionAffectedTableCells,
+	getHorizontallyOverlappingCells,
+	isSelectionRectangular,
+	splitHorizontally,
+	splitVertically
+} from './utils';
 import { findAncestor } from './commands/utils';
 import { cropTableToDimensions } from './tableselection/croptable';
 import TableUtils from './tableutils';
@@ -110,9 +119,6 @@ export default class TableClipboard extends Plugin {
 			return;
 		}
 
-		// Content table to which we insert a pasted table.
-		const selectedTable = findAncestor( 'table', selectedTableCells[ 0 ] );
-
 		// We might need to crop table before inserting so reference might change.
 		let pastedTable = getTableIfOnlyTableInContent( content );
 
@@ -123,32 +129,70 @@ export default class TableClipboard extends Plugin {
 		// Override default model.insertContent() handling at this point.
 		evt.stop();
 
-		const pasteWidth = tableUtils.getColumns( pastedTable );
-		const pasteHeight = tableUtils.getRows( pastedTable );
-
 		model.change( writer => {
-			let { first: firstColumnOfSelection, last: lastColumnOfSelection } = getColumnIndexes( selectedTableCells );
-			let { first: firstRowOfSelection, last: lastRowOfSelection } = getRowIndexes( selectedTableCells );
+			const columnIndexes = getColumnIndexes( selectedTableCells );
+			const rowIndexes = getRowIndexes( selectedTableCells );
+
+			let { first: firstColumnOfSelection, last: lastColumnOfSelection } = columnIndexes;
+			let { first: firstRowOfSelection, last: lastRowOfSelection } = rowIndexes;
+
+			const pasteHeight = tableUtils.getRows( pastedTable );
+			const pasteWidth = tableUtils.getColumns( pastedTable );
 
-			if ( selectedTableCells.length == 1 ) {
+			// Content table to which we insert a pasted table.
+			const selectedTable = findAncestor( 'table', selectedTableCells[ 0 ] );
+
+			// Single cell selected - expand selection to pasted table dimensions.
+			const shouldExpandSelection = selectedTableCells.length === 1;
+
+			if ( shouldExpandSelection ) {
 				lastRowOfSelection += pasteHeight - 1;
 				lastColumnOfSelection += pasteWidth - 1;
 
 				expandTableSize( selectedTable, lastRowOfSelection + 1, lastColumnOfSelection + 1, writer, tableUtils );
 			}
 
-			// Currently not handled. The selected table content should be trimmed to a rectangular selection.
-			// See: https://github.com/ckeditor/ckeditor5/issues/6122.
-			else if ( !isSelectionRectangular( selectedTableCells, tableUtils ) ) {
-				// @if CK_DEBUG // console.log( 'NOT IMPLEMENTED YET: Selection is not rectangular (non-mergeable).' );
+			// In case of expanding selection we do not reset the selection so in this case we will always try to fix selection
+			// like in the case of a non-rectangular area. This might be fixed by re-setting selected cells array but this shortcut is safe.
+			if ( shouldExpandSelection || !isSelectionRectangular( selectedTableCells, tableUtils ) ) {
+				const splitDimensions = {
+					firstRow: firstRowOfSelection,
+					lastRow: lastRowOfSelection,
+					firstColumn: firstColumnOfSelection,
+					lastColumn: lastColumnOfSelection
+				};
 
-				return;
+				// For a non-rectangular selection (ie in which some cells sticks out from a virtual selection rectangle) we need to create
+				// a table layout that has a rectangular selection. This will split cells so the selection become rectangular.
+				// Beyond this point we will operate on fixed content table.
+				splitCellsToRectangularSelection( selectedTable, splitDimensions, writer );
+			}
+			// However a selected table fragment might be invalid if examined alone. Ie such table fragment:
+			//
+			//    +---+---+---+---+
+			//  0 | a | b | c | d |
+			//    +   +   +---+---+
+			//  1 |   | e | f | g |
+			//    +   +---+   +---+
+			//  2 |   | h |   | i | <- last row, each cell has rowspan = 2,
+			//    +   +   +   +   +    so we need to return 3, not 2
+			//  3 |   |   |   |   |
+			//    +---+---+---+---+
+			//
+			// is invalid as the cells "h" and "i" have rowspans.
+			// This case needs only adjusting the selection dimension as the rest of the algorithm operates on empty slots also.
+			else {
+				lastRowOfSelection = adjustLastRowIndex( selectedTable, rowIndexes, columnIndexes );
+				lastColumnOfSelection = adjustLastColumnIndex( selectedTable, rowIndexes, columnIndexes );
 			}
 
+			// Beyond this point we operate on a fixed content table with rectangular selection and proper last row/column values.
+
 			const selectionHeight = lastRowOfSelection - firstRowOfSelection + 1;
 			const selectionWidth = lastColumnOfSelection - firstColumnOfSelection + 1;
 
 			// The if below is temporal and will be removed when handling this case.
+			// This if to be removed as handling of replicating cells should be done in replaceSelectedCellsWithPasted().
 			// 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.' );
@@ -156,17 +200,21 @@ export default class TableClipboard extends Plugin {
 				return;
 			}
 
-			// 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
-				};
+			// Crop pasted table if:
+			// - Pasted table dimensions exceeds selection area.
+			// - Pasted table has broken layout (ie some cells sticks out by the table dimensions established by the first and last row).
+			//
+			// Note: The table dimensions are established by the width of the first row and the total number of rows.
+			// It is possible to programmatically create a table that has rows which would have cells anchored beyond first row width but
+			// such table will not be created by other editing solutions.
+			const cropDimensions = {
+				startRow: 0,
+				startColumn: 0,
+				endRow: Math.min( selectionHeight - 1, pasteHeight - 1 ),
+				endColumn: Math.min( selectionWidth - 1, pasteWidth - 1 )
+			};
 
-				pastedTable = cropTableToDimensions( pastedTable, cropDimensions, writer, tableUtils );
-			}
+			pastedTable = cropTableToDimensions( pastedTable, cropDimensions, writer, tableUtils );
 
 			const selectionDimensions = {
 				firstColumnOfSelection,
@@ -196,8 +244,9 @@ export default class TableClipboard extends Plugin {
 // @param {module:engine/model/writer~Writer} writer
 function replaceSelectedCellsWithPasted( pastedTable, selectedTable, selectionDimensions, writer ) {
 	const {
-		firstColumnOfSelection, lastColumnOfSelection, selectionWidth,
-		firstRowOfSelection, lastRowOfSelection, selectionHeight
+		firstColumnOfSelection, lastColumnOfSelection,
+		firstRowOfSelection, lastRowOfSelection,
+		selectionWidth, selectionHeight
 	} = selectionDimensions;
 
 	// Holds two-dimensional array that is addressed by [ row ][ column ] that stores cells anchored at given location.
@@ -272,24 +321,24 @@ function replaceSelectedCellsWithPasted( pastedTable, selectedTable, selectionDi
 	writer.setSelection( cellsToSelect.map( cell => writer.createRangeOn( cell ) ) );
 }
 
-// Expand table (in place) to expected size (rows and columns).
-function expandTableSize( table, rows, columns, writer, tableUtils ) {
+// Expand table (in place) to expected size.
+function expandTableSize( table, expectedHeight, expectedWidth, writer, tableUtils ) {
 	const tableWidth = tableUtils.getColumns( table );
 	const tableHeight = tableUtils.getRows( table );
 
-	if ( columns > tableWidth ) {
+	if ( expectedWidth > tableWidth ) {
 		tableUtils.insertColumns( table, {
 			batch: writer.batch,
 			at: tableWidth,
-			columns: columns - tableWidth
+			columns: expectedWidth - tableWidth
 		} );
 	}
 
-	if ( rows > tableHeight ) {
+	if ( expectedHeight > tableHeight ) {
 		tableUtils.insertRows( table, {
 			batch: writer.batch,
 			at: tableHeight,
-			rows: rows - tableHeight
+			rows: expectedHeight - tableHeight
 		} );
 	}
 }
@@ -349,3 +398,173 @@ function createLocationMap( table, width, height ) {
 
 	return map;
 }
+
+// Make selected cells rectangular by splitting the cells that stand out from a rectangular selection.
+//
+// In the table below a selection is shown with "::" and slots with anchor cells are named.
+//
+// +----+----+----+----+----+                    +----+----+----+----+----+
+// | 00 | 01 | 02 | 03      |                    | 00 | 01 | 02 | 03      |
+// +    +----+    +----+----+                    |    ::::::::::::::::----+
+// |    | 11 |    | 13 | 14 |                    |    ::11 |    | 13:: 14 |    <- first row
+// +----+----+    +    +----+                    +----::---|    |   ::----+
+// | 20 | 21 |    |    | 24 |   select cells:    | 20 ::21 |    |   :: 24 |
+// +----+----+    +----+----+     11 -> 33       +----::---|    |---::----+
+// | 30      |    | 33 | 34 |                    | 30 ::   |    | 33:: 34 |    <- last row
+// +         +    +----+    +                    |    ::::::::::::::::    +
+// |         |    | 43 |    |                    |         |    | 43 |    |
+// +----+----+----+----+----+                    +----+----+----+----+----+
+//                                                      ^          ^
+//                                                     first & last columns
+//
+// Will update table to:
+//
+//                       +----+----+----+----+----+
+//                       | 00 | 01 | 02 | 03      |
+//                       +    +----+----+----+----+
+//                       |    | 11 |    | 13 | 14 |
+//                       +----+----+    +    +----+
+//                       | 20 | 21 |    |    | 24 |
+//                       +----+----+    +----+----+
+//                       | 30 |    |    | 33 | 34 |
+//                       +    +----+----+----+    +
+//                       |    |    |    | 43 |    |
+//                       +----+----+----+----+----+
+//
+// In th example above:
+// - Cell "02" which have `rowspan = 4` must be trimmed at first and at after last row.
+// - Cell "03" which have `rowspan = 2` and `colspan = 2` must be trimmed at first column and after last row.
+// - Cells "00", "03" & "30" which cannot be cut by this algorithm as they are outside the trimmed area.
+// - Cell "13" cannot be cut as it is inside the trimmed area.
+function splitCellsToRectangularSelection( table, dimensions, writer ) {
+	const { firstRow, lastRow, firstColumn, lastColumn } = dimensions;
+
+	const rowIndexes = { first: firstRow, last: lastRow };
+	const columnIndexes = { first: firstColumn, last: lastColumn };
+
+	// 1. Split cells vertically in two steps as first step might create cells that needs to split again.
+	doVerticalSplit( table, firstColumn, rowIndexes, writer );
+	doVerticalSplit( table, lastColumn + 1, rowIndexes, writer );
+
+	// 2. Split cells horizontally in two steps as first step might create cells that needs to split again.
+	doHorizontalSplit( table, firstRow, columnIndexes, writer );
+	doHorizontalSplit( table, lastRow + 1, columnIndexes, writer, firstRow );
+}
+
+function doHorizontalSplit( table, splitRow, limitColumns, writer, startRow = 0 ) {
+	// If selection starts at first row then no split is needed.
+	if ( splitRow < 1 ) {
+		return;
+	}
+
+	const overlappingCells = getVerticallyOverlappingCells( table, splitRow, startRow );
+
+	// Filter out cells that are not touching insides of the rectangular selection.
+	const cellsToSplit = overlappingCells.filter( ( { column, colspan } ) => isAffectedBySelection( column, colspan, limitColumns ) );
+
+	for ( const { cell } of cellsToSplit ) {
+		splitHorizontally( cell, splitRow, writer );
+	}
+}
+
+function doVerticalSplit( table, splitColumn, limitRows, writer ) {
+	// If selection starts at first column then no split is needed.
+	if ( splitColumn < 1 ) {
+		return;
+	}
+
+	const overlappingCells = getHorizontallyOverlappingCells( table, splitColumn );
+
+	// Filter out cells that are not touching insides of the rectangular selection.
+	const cellsToSplit = overlappingCells.filter( ( { row, rowspan } ) => isAffectedBySelection( row, rowspan, limitRows ) );
+
+	for ( const { cell, column } of cellsToSplit ) {
+		splitVertically( cell, column, splitColumn, writer );
+	}
+}
+
+// Checks if cell at given row (column) is affected by a rectangular selection defined by first/last column (row).
+//
+// The same check is used for row as for column.
+function isAffectedBySelection( index, span, limit ) {
+	const endIndex = index + span - 1;
+	const { first, last } = limit;
+
+	const isInsideSelection = index >= first && index <= last;
+	const overlapsSelectionFromOutside = index < first && endIndex >= first;
+
+	return isInsideSelection || overlapsSelectionFromOutside;
+}
+
+// Returns adjusted last row index if selection covers part of a row with empty slots (spanned by other cells).
+// The rowIndexes.last is equal to last row index but selection might be bigger.
+//
+// This happens *only* on rectangular selection so we analyze a case like this:
+//
+//    +---+---+---+---+
+//  0 | a | b | c | d |
+//    +   +   +---+---+
+//  1 |   | e | f | g |
+//    +   +---+   +---+
+//  2 |   | h |   | i | <- last row, each cell has rowspan = 2,
+//    +   +   +   +   +    so we need to return 3, not 2
+//  3 |   |   |   |   |
+//    +---+---+---+---+
+function adjustLastRowIndex( table, rowIndexes, columnIndexes ) {
+	const tableIterator = new TableWalker( table, {
+		startRow: rowIndexes.last,
+		endRow: rowIndexes.last
+	} );
+
+	const lastRowMap = Array.from( tableIterator ).filter( ( { column } ) => {
+		// Could use startColumn, endColumn. See: https://github.com/ckeditor/ckeditor5/issues/6785.
+		return columnIndexes.first <= column && column <= columnIndexes.last;
+	} );
+
+	const everyCellHasSingleRowspan = lastRowMap.every( ( { rowspan } ) => rowspan === 1 );
+
+	// It is a "flat" row, so the last row index is OK.
+	if ( everyCellHasSingleRowspan ) {
+		return rowIndexes.last;
+	}
+
+	// Otherwise get any cell's rowspan and adjust the last row index.
+	const rowspanAdjustment = lastRowMap[ 0 ].rowspan - 1;
+	return rowIndexes.last + rowspanAdjustment;
+}
+
+// Returns adjusted last column index if selection covers part of a column with empty slots (spanned by other cells).
+// The columnIndexes.last is equal to last column index but selection might be bigger.
+//
+// This happens *only* on rectangular selection so we analyze a case like this:
+//
+//   0   1   2   3
+// +---+---+---+---+
+// | a             |
+// +---+---+---+---+
+// | b | c | d     |
+// +---+---+---+---+
+// | e     | f     |
+// +---+---+---+---+
+// | g | h         |
+// +---+---+---+---+
+//           ^
+//          last column, each cell has colspan = 2, so we need to return 3, not 2
+function adjustLastColumnIndex( table, rowIndexes, columnIndexes ) {
+	const lastColumnMap = Array.from( new TableWalker( table, {
+		startRow: rowIndexes.first,
+		endRow: rowIndexes.last,
+		column: columnIndexes.last
+	} ) );
+
+	const everyCellHasSingleColspan = lastColumnMap.every( ( { colspan } ) => colspan === 1 );
+
+	// It is a "flat" column, so the last column index is OK.
+	if ( everyCellHasSingleColspan ) {
+		return columnIndexes.last;
+	}
+
+	// Otherwise get any cell's colspan and adjust the last column index.
+	const colspanAdjustment = lastColumnMap[ 0 ].colspan - 1;
+	return columnIndexes.last + colspanAdjustment;
+}

+ 167 - 2
packages/ckeditor5-table/src/utils.js

@@ -8,7 +8,7 @@
  */
 
 import { isWidget, toWidget } from '@ckeditor/ckeditor5-widget/src/utils';
-import { findAncestor } from './commands/utils';
+import { createEmptyTableCell, findAncestor, updateNumericAttribute } from './commands/utils';
 import TableWalker from './tablewalker';
 
 /**
@@ -188,7 +188,7 @@ export function getColumnIndexes( tableCells ) {
  *   │ a │ b │ c │ d │
  *   ├───┴───┼───┤   │
  *   │ e     │ f │   │
- *          ├───┼───┤
+ *          ├───┼───┤
  *   │       │ g │ h │
  *   └───────┴───┴───┘
  *
@@ -247,6 +247,171 @@ export function isSelectionRectangular( selectedTableCells, tableUtils ) {
 	return areaOfValidSelection == areaOfSelectedCells;
 }
 
+/**
+ * Returns slot info of cells that starts above and overlaps a given row.
+ *
+ * In a table below, passing `overlapRow = 3`
+ *
+ *       ┌───┬───┬───┬───┬───┐
+ *    0  │ a │ b │ c │ d │ e │
+ *       │   ├───┼───┼───┼───┤
+ *    1  │   │ f │ g │ h │ i │
+ *       ├───┤   ├───┼───┤   │
+ *    2  │ j │   │ k │ l │   │
+ *       │   │   │   ├───┼───┤
+ *    3  │   │   │   │ m │ n │  <- overlap row to check
+ *       ├───┼───┤   │   ├───│
+ *    4  │ o │ p │   │   │ q │
+ *       └───┴───┴───┴───┴───┘
+ *
+ * will return slot info for cells: "j", "f", "k".
+ *
+ * @param {module:engine/model/element~Element} table The table to check.
+ * @param {Number} overlapRow The index of the row to check.
+ * @param {Number} [startRow=0] A row to start analysis. Use it when it is known that the cells above that row will not overlap.
+ * @returns {Array.<module:table/tablewalker~TableWalkerValue>}
+ */
+export function getVerticallyOverlappingCells( table, overlapRow, startRow = 0 ) {
+	const cells = [];
+
+	const tableWalker = new TableWalker( table, { startRow, endRow: overlapRow - 1 } );
+
+	for ( const slotInfo of tableWalker ) {
+		const { row, rowspan } = slotInfo;
+		const cellEndRow = row + rowspan - 1;
+
+		if ( row < overlapRow && overlapRow <= cellEndRow ) {
+			cells.push( slotInfo );
+		}
+	}
+
+	return cells;
+}
+
+/**
+ * Splits the table cell horizontally.
+ *
+ * @param {module:engine/model/element~Element} tableCell
+ * @param {Number} splitRow
+ * @param {module:engine/model/writer~Writer} writer
+ */
+export function splitHorizontally( tableCell, splitRow, writer ) {
+	const tableRow = tableCell.parent;
+	const table = tableRow.parent;
+	const rowIndex = tableRow.index;
+
+	const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) );
+	const newRowspan = splitRow - rowIndex;
+
+	const newCellAttributes = {};
+	const newCellRowSpan = rowspan - newRowspan;
+
+	if ( newCellRowSpan > 1 ) {
+		newCellAttributes.rowspan = newCellRowSpan;
+	}
+
+	const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
+
+	if ( colspan > 1 ) {
+		newCellAttributes.colspan = colspan;
+	}
+
+	const startRow = rowIndex;
+	const endRow = startRow + newRowspan;
+	const tableMap = [ ...new TableWalker( table, { startRow, endRow, includeSpanned: true } ) ];
+
+	let columnIndex;
+
+	for ( const { row, column, cell, cellIndex } of tableMap ) {
+		if ( cell === tableCell && columnIndex === undefined ) {
+			columnIndex = column;
+		}
+
+		if ( columnIndex !== undefined && columnIndex === column && row === endRow ) {
+			const tableRow = table.getChild( row );
+			const tableCellPosition = writer.createPositionAt( tableRow, cellIndex );
+
+			createEmptyTableCell( writer, tableCellPosition, newCellAttributes );
+		}
+	}
+
+	// Update the rowspan attribute after updating table.
+	updateNumericAttribute( 'rowspan', newRowspan, tableCell, writer );
+}
+
+/**
+ * Returns slot info of cells that starts before and overlaps a given column.
+ *
+ * In a table below, passing `overlapColumn = 3`
+ *
+ *      0   1   2   3   4
+ *    ┌───────┬───────┬───┐
+ *    │ a     │ b     │ c │
+ *    │───┬───┴───────┼───┤
+ *    │ d │ e         │ f │
+ *    ├───┼───┬───────┴───┤
+ *    │ g │ h │ i         │
+ *    ├───┼───┼───┬───────┤
+ *    │ j │ k │ l │ m     │
+ *    ├───┼───┴───┼───┬───┤
+ *    │ n │ o     │ p │ q │
+ *    └───┴───────┴───┴───┘
+ *                  ^
+ *                  Overlap column to check
+ *
+ * will return slot info for cells: "b", "e", "i".
+ *
+ * @param {module:engine/model/element~Element} table The table to check.
+ * @param {Number} overlapColumn The index of the column to check.
+ * @returns {Array.<module:table/tablewalker~TableWalkerValue>}
+ */
+export function getHorizontallyOverlappingCells( table, overlapColumn ) {
+	const cellsToSplit = [];
+
+	const tableWalker = new TableWalker( table );
+
+	for ( const slotInfo of tableWalker ) {
+		const { column, colspan } = slotInfo;
+		const cellEndColumn = column + colspan - 1;
+
+		if ( column < overlapColumn && overlapColumn <= cellEndColumn ) {
+			cellsToSplit.push( slotInfo );
+		}
+	}
+
+	return cellsToSplit;
+}
+
+/**
+ * Splits the table cell vertically.
+ *
+ * @param {module:engine/model/element~Element} tableCell
+ * @param {Number} columnIndex The table cell column index.
+ * @param {Number} splitColumn The index of column to split cell on.
+ * @param {module:engine/model/writer~Writer} writer
+ */
+export function splitVertically( tableCell, columnIndex, splitColumn, writer ) {
+	const colspan = parseInt( tableCell.getAttribute( 'colspan' ) );
+	const newColspan = splitColumn - columnIndex;
+
+	const newCellAttributes = {};
+	const newCellColSpan = colspan - newColspan;
+
+	if ( newCellColSpan > 1 ) {
+		newCellAttributes.colspan = newCellColSpan;
+	}
+
+	const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
+
+	if ( rowspan > 1 ) {
+		newCellAttributes.rowspan = rowspan;
+	}
+
+	createEmptyTableCell( writer, writer.createPositionAfter( tableCell ), newCellAttributes );
+	// Update the colspan attribute after updating table.
+	updateNumericAttribute( 'colspan', newColspan, tableCell, writer );
+}
+
 // 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 );

+ 104 - 2
packages/ckeditor5-table/tests/commands/setheadercolumncommand.js

@@ -198,6 +198,18 @@ describe( 'SetHeaderColumnCommand', () => {
 			], { headingColumns: 1 } ) );
 		} );
 
+		it( 'should remove "headingColumns" attribute from table if no value was given', () => {
+			setData( model, modelTable( [
+				[ '[]00', '01', '02', '03' ]
+			], { headingColumns: 3 } ) );
+
+			command.execute();
+
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ '[]00', '01', '02', '03' ]
+			] ) );
+		} );
+
 		describe( 'multi-cell selection', () => {
 			describe( 'setting header', () => {
 				it( 'should set it correctly in a middle of single-row, multiple cell selection', () => {
@@ -404,7 +416,7 @@ describe( 'SetHeaderColumnCommand', () => {
 			], { headingColumns: 2 } ) );
 		} );
 
-		it( 'should respect forceValue parameter #1', () => {
+		it( 'should respect forceValue parameter (forceValue=true)', () => {
 			setData( model, modelTable( [
 				[ '00', '01[]', '02', '03' ]
 			], { headingColumns: 3 } ) );
@@ -416,7 +428,7 @@ describe( 'SetHeaderColumnCommand', () => {
 			], { headingColumns: 3 } ) );
 		} );
 
-		it( 'should respect forceValue parameter #2', () => {
+		it( 'should respect forceValue parameter (forceValue=false)', () => {
 			setData( model, modelTable( [
 				[ '00', '01[]', '02', '03' ]
 			], { headingColumns: 1 } ) );
@@ -427,5 +439,95 @@ describe( 'SetHeaderColumnCommand', () => {
 				[ '00', '01[]', '02', '03' ]
 			], { headingColumns: 1 } ) );
 		} );
+
+		it( 'should fix col-spanned cells on the edge of an table heading columns section', () => {
+			// +----+----+----+
+			// | 00 | 01      |
+			// +----+         +
+			// | 10 |         |
+			// +----+----+----+
+			// | 20 | 21 | 22 |
+			// +----+----+----+
+			//      ^-- heading columns
+			setData( model, modelTable( [
+				[ '00', { contents: '[]01', colspan: 2, rowspan: 2 } ],
+				[ '10' ],
+				[ '20', '21', '22' ]
+			], { headingColumns: 1 } ) );
+
+			command.execute();
+
+			// +----+----+----+
+			// | 00 | 01 |    |
+			// +----+    +    +
+			// | 10 |    |    |
+			// +----+----+----+
+			// | 20 | 21 | 22 |
+			// +----+----+----+
+			//           ^-- heading columns
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ '00', { contents: '[]01', rowspan: 2 }, { contents: '', rowspan: 2 } ],
+				[ '10' ],
+				[ '20', '21', '22' ]
+			], { headingColumns: 2 } ) );
+		} );
+
+		it( 'should split to at most 2 table cells when fixing col-spanned cells on the edge of an table heading columns section', () => {
+			// +----+----+----+----+----+----+
+			// | 00 | 01                     |
+			// +----+                        +
+			// | 10 |                        |
+			// +----+----+----+----+----+----+
+			// | 20 | 21 | 22 | 23 | 24 | 25 |
+			// +----+----+----+----+----+----+
+			//      ^-- heading columns
+			setData( model, modelTable( [
+				[ '00', { contents: '01', colspan: 5, rowspan: 2 } ],
+				[ '10' ],
+				[ '20', '21', '22[]', '23', '24', '25' ]
+			], { headingColumns: 1 } ) );
+
+			command.execute();
+
+			// +----+----+----+----+----+----+
+			// | 00 | 01      |              |
+			// +----+         +              +
+			// | 10 |         |              |
+			// +----+----+----+----+----+----+
+			// | 20 | 21 | 22 | 23 | 24 | 25 |
+			// +----+----+----+----+----+----+
+			//                ^-- heading columns
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ '00', { contents: '01', colspan: 2, rowspan: 2 }, { contents: '', colspan: 3, rowspan: 2 } ],
+				[ '10' ],
+				[ '20', '21', '22[]', '23', '24', '25' ]
+			], { headingColumns: 3 } ) );
+		} );
+
+		it( 'should fix col-spanned cells on the edge of an table heading columns section when creating section', () => {
+			// +----+----+
+			// | 00      |
+			// +----+----+
+			// | 10 | 11 |
+			// +----+----+
+			//           ^-- heading columns
+			setData( model, modelTable( [
+				[ { contents: '00', colspan: 2 } ],
+				[ '10', '[]11' ]
+			], { headingColumns: 2 } ) );
+
+			command.execute();
+
+			// +----+----+
+			// | 00 |    |
+			// +----+----+
+			// | 10 | 11 |
+			// +----+----+
+			//      ^-- heading columns
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ '00', '' ],
+				[ '10', '[]11' ]
+			], { headingColumns: 1 } ) );
+		} );
 	} );
 } );

+ 3 - 3
packages/ckeditor5-table/tests/commands/setheaderrowcommand.js

@@ -225,7 +225,7 @@ describe( 'SetHeaderRowCommand', () => {
 			], { headingRows: 1 } ) );
 		} );
 
-		it( 'should unsetset heading rows attribute', () => {
+		it( 'should remove "headingRows" attribute from table if no value was given', () => {
 			setData( model, modelTable( [
 				[ '[]00' ],
 				[ '10' ],
@@ -551,7 +551,7 @@ describe( 'SetHeaderRowCommand', () => {
 			} );
 		} );
 
-		it( 'should respect forceValue parameter #1', () => {
+		it( 'should respect forceValue parameter (forceValue=true)', () => {
 			setData( model, modelTable( [
 				[ '00' ],
 				[ '[]10' ],
@@ -569,7 +569,7 @@ describe( 'SetHeaderRowCommand', () => {
 			], { headingRows: 3 } ) );
 		} );
 
-		it( 'should respect forceValue parameter #2', () => {
+		it( 'should respect forceValue parameter (forceValue=false)', () => {
 			setData( model, modelTable( [
 				[ '00' ],
 				[ '[]10' ],

File diff suppressed because it is too large
+ 987 - 87
packages/ckeditor5-table/tests/tableclipboard-paste.js


+ 89 - 1
packages/ckeditor5-table/tests/utils.js

@@ -13,7 +13,7 @@ import { modelTable } from './_utils/utils';
 import {
 	getSelectedTableCells,
 	getTableCellsContainingSelection,
-	getSelectionAffectedTableCells
+	getSelectionAffectedTableCells, getVerticallyOverlappingCells, getHorizontallyOverlappingCells
 } from '../src/utils';
 
 describe( 'table utils', () => {
@@ -359,4 +359,92 @@ describe( 'table utils', () => {
 			expect( getSelectionAffectedTableCells( selection ) ).to.be.empty;
 		} );
 	} );
+
+	describe( 'getVerticallyOverlappingCells()', () => {
+		let table;
+
+		beforeEach( () => {
+			// +----+----+----+----+----+
+			// | 00 | 01 | 02 | 03 | 04 |
+			// +    +    +----+    +----+
+			// |    |    | 12 |    | 14 |
+			// +    +    +    +----+----+
+			// |    |    |    | 23 | 24 |
+			// +    +----+    +    +----+
+			// |    | 31 |    |    | 34 |
+			// +    +    +----+----+----+
+			// |    |    | 42 | 43 | 44 |
+			// +----+----+----+----+----+
+			setModelData( model, modelTable( [
+				[ { contents: '00', rowspan: 5 }, { contents: '01', rowspan: 3 }, '02', { contents: '03', rowspan: 2 }, '04' ],
+				[ { contents: '12', rowspan: 3 }, '14' ],
+				[ { contents: '23', rowspan: 2 }, '24' ],
+				[ { contents: '31', rowspan: 2 }, '34' ],
+				[ '42', '43', '44' ]
+			] ) );
+
+			table = modelRoot.getChild( 0 );
+		} );
+
+		it( 'should return empty array for no overlapping cells', () => {
+			const cellsInfo = getVerticallyOverlappingCells( table, 0 );
+
+			expect( cellsInfo ).to.be.empty;
+		} );
+
+		it( 'should return overlapping cells info for given overlapRow', () => {
+			const cellsInfo = getVerticallyOverlappingCells( table, 2 );
+
+			expect( cellsInfo[ 0 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) ); // Cell 00
+			expect( cellsInfo[ 1 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 0, 1 ] ) ); // Cell 01
+			expect( cellsInfo[ 2 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 1, 0 ] ) ); // Cell 12
+		} );
+
+		it( 'should ignore rows below startRow', () => {
+			const cellsInfo = getVerticallyOverlappingCells( table, 2, 1 );
+
+			expect( cellsInfo[ 0 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 1, 0 ] ) ); // Cell 12
+		} );
+	} );
+
+	describe( 'getHorizontallyOverlappingCells()', () => {
+		let table;
+
+		beforeEach( () => {
+			// +----+----+----+----+----+
+			// | 00                     |
+			// +----+----+----+----+----+
+			// | 10           | 13      |
+			// +----+----+----+----+----+
+			// | 20 | 21           | 24 |
+			// +----+----+----+----+----+
+			// | 30      | 32      | 34 |
+			// +----+----+----+----+----+
+			// | 40 | 41 | 42 | 43 | 44 |
+			// +----+----+----+----+----+
+			setModelData( model, modelTable( [
+				[ { contents: '00', colspan: 5 } ],
+				[ { contents: '10', colspan: 3 }, { contents: '13', colspan: 2 } ],
+				[ '20', { contents: '21', colspan: 3 }, '24' ],
+				[ { contents: '30', colspan: 2 }, { contents: '32', colspan: 2 }, '34' ],
+				[ '40', '41', '42', '43', '44' ]
+			] ) );
+
+			table = modelRoot.getChild( 0 );
+		} );
+
+		it( 'should return empty array for no overlapping cells', () => {
+			const cellsInfo = getHorizontallyOverlappingCells( table, 0 );
+
+			expect( cellsInfo ).to.be.empty;
+		} );
+
+		it( 'should return overlapping cells info for given overlapColumn', () => {
+			const cellsInfo = getHorizontallyOverlappingCells( table, 2 );
+
+			expect( cellsInfo[ 0 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 0, 0 ] ) ); // Cell 00
+			expect( cellsInfo[ 1 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 1, 0 ] ) ); // Cell 10
+			expect( cellsInfo[ 2 ].cell ).to.equal( modelRoot.getNodeByPath( [ 0, 2, 1 ] ) ); // Cell 21
+		} );
+	} );
 } );