瀏覽代碼

Merge branch 'master' into i/6437

# Conflicts:
#	src/tableutils.js
Maciej Gołaszewski 5 年之前
父節點
當前提交
4fa2f99c76
共有 2 個文件被更改,包括 415 次插入94 次删除
  1. 134 63
      packages/ckeditor5-table/src/tableutils.js
  2. 281 31
      packages/ckeditor5-table/tests/tableutils.js

+ 134 - 63
packages/ckeditor5-table/src/tableutils.js

@@ -267,13 +267,13 @@ export default class TableUtils extends Plugin {
 	 *		    ┌───┬───┬───┐        `at` = 1        ┌───┬───┬───┐
 	 *		  0 │ a │ b │ c │        `rows` = 2      │ a │ b │ c │ 0
 	 *		    │   ├───┼───┤                        │   ├───┼───┤
-	 *		  1 │   │ d │ e │  <-- remove from here  │   │ h │ i │ 1
-	 *		    │   ├───┼───┤        will give:      ├───┼───┼───┤
-	 *		  2 │   │ f │ g │                        │ j │ k │ l │ 2
-	 *		    │   ├───┼───┤                        └───┴───┴───┘
-	 *		  3 │   │ h │ i
+	 *		  1 │   │ d │ e │  <-- remove from here  │   │ d │ g │ 1
+	 *		    │   ├───┤        will give:      ├───┼───┼───┤
+	 *		  2 │   │   │ f │                        │ h │ i │ j │ 2
+	 *		    │   ├───┤                        └───┴───┴───┘
+	 *		  3 │   │   │ g
 	 *		    ├───┼───┼───┤
-	 *		  4 │ j │ k │ l
+	 *		  4 │ h │ i │ j
 	 *		    └───┴───┴───┘
 	 *
 	 * @param {module:engine/model/element~Element} table
@@ -283,28 +283,36 @@ export default class TableUtils extends Plugin {
 	 */
 	removeRows( table, options ) {
 		const model = this.editor.model;
-		const first = options.at;
-		const rowsToRemove = options.rows || 1;
 
+		const rowsToRemove = options.rows || 1;
+		const first = options.at;
 		const last = first + rowsToRemove - 1;
 		const batch = options.batch || 'default';
 
-		const headingRows = table.getAttribute( 'headingRows' ) || 0;
+		// Removing rows from table requires most calculations to be done prior to changing table structure.
 
-		if ( headingRows && first < headingRows ) {
-			const newRows = getNewHeadingRowsValue( first, last, headingRows );
-
-			// Must be done after the changes in table structure (removing rows).
-			// Otherwise the downcast converter for headingRows attribute will fail. ckeditor/ckeditor5#6391.
-			model.enqueueChange( batch, writer => {
-				updateNumericAttribute( 'headingRows', newRows, table, writer, 0 );
-			} );
-		}
+		// 1. Preparation - get row-spanned cells that have to be modified after removing rows.
+		const { cellsToMove, cellsToTrim } = getCellsToMoveAndTrimOnRemoveRow( table, first, last );
 
+		// 2. Execution
 		model.enqueueChange( batch, writer => {
+			// 2a. Move cells from removed rows that extends over a removed section - must be done before removing rows.
+			// This will fill any gaps in a rows below that previously were empty because of row-spanned cells.
+			const rowAfterRemovedSection = last + 1;
+			moveCellsToRow( table, rowAfterRemovedSection, cellsToMove, writer );
+
+			// 2b. Remove all required rows.
 			for ( let i = last; i >= first; i-- ) {
-				removeRow( table, i, writer );
+				writer.remove( table.getChild( i ) );
+			}
+
+			// 2c. Update cells from rows above that overlap removed section. Similar to step 2 but does not involve moving cells.
+			for ( const { rowspan, cell } of cellsToTrim ) {
+				updateNumericAttribute( 'rowspan', rowspan, cell, writer );
 			}
+
+			// 2d. Adjust heading rows if removed rows were in a heading section.
+			updateHeadingRows( table, first, last, model, batch );
 		} );
 	}
 
@@ -731,60 +739,123 @@ function breakSpanEvenly( span, numberOfCells ) {
 	return { newCellsSpan, updatedSpan };
 }
 
-function removeRow( table, rowIndex, writer ) {
-	const cellsToMove = new Map();
-	const tableRow = table.getChild( rowIndex );
-	const tableMap = [ ...new TableWalker( table, { endRow: rowIndex } ) ];
-
-	// Get cells from removed row that are spanned over multiple rows.
-	tableMap
-		.filter( ( { row, rowspan } ) => row === rowIndex && rowspan > 1 )
-		.forEach( ( { column, cell, rowspan } ) => cellsToMove.set( column, { cell, rowspanToSet: rowspan - 1 } ) );
-
-	// Reduce rowspan on cells that are above removed row and overlaps removed row.
-	tableMap
-		.filter( ( { row, rowspan } ) => row <= rowIndex - 1 && row + rowspan > rowIndex )
-		.forEach( ( { cell, rowspan } ) => updateNumericAttribute( 'rowspan', rowspan - 1, cell, writer ) );
-
-	// Move cells to another row.
-	const targetRow = rowIndex + 1;
-	const tableWalker = new TableWalker( table, { includeSpanned: true, startRow: targetRow, endRow: targetRow } );
-	let previousCell;
+// Updates heading columns attribute if removing a row from head section.
+function adjustHeadingColumns( table, removedColumnIndexes, writer ) {
+	const headingColumns = table.getAttribute( 'headingColumns' ) || 0;
 
-	for ( const { row, column, cell } of [ ...tableWalker ] ) {
-		if ( cellsToMove.has( column ) ) {
-			const { cell: cellToMove, rowspanToSet } = cellsToMove.get( column );
-			const targetPosition = previousCell ?
-				writer.createPositionAfter( previousCell ) :
-				writer.createPositionAt( table.getChild( row ), 0 );
-			writer.move( writer.createRangeOn( cellToMove ), targetPosition );
-			updateNumericAttribute( 'rowspan', rowspanToSet, cellToMove, writer );
-			previousCell = cellToMove;
-		} else {
-			previousCell = cell;
-		}
-	}
+	if ( headingColumns && removedColumnIndexes.first < headingColumns ) {
+		const headingsRemoved = Math.min( headingColumns - 1 /* Other numbers are 0-based */, removedColumnIndexes.last ) -
+			removedColumnIndexes.first + 1;
 
-	writer.remove( tableRow );
+		writer.setAttribute( 'headingColumns', headingColumns - headingsRemoved, table );
+	}
 }
 
 // Calculates a new heading rows value for removing rows from heading section.
-function getNewHeadingRowsValue( first, last, headingRows ) {
-	if ( last < headingRows ) {
-		return headingRows - ( last - first + 1 );
+function updateHeadingRows( table, first, last, model, batch ) {
+	const headingRows = table.getAttribute( 'headingRows' ) || 0;
+
+	if ( first < headingRows ) {
+		const newRows = last < headingRows ? headingRows - ( last - first + 1 ) : first;
+
+		// Must be done after the changes in table structure (removing rows).
+		// Otherwise the downcast converter for headingRows attribute will fail. ckeditor/ckeditor5#6391.
+		model.enqueueChange( batch, writer => {
+			updateNumericAttribute( 'headingRows', newRows, table, writer, 0 );
+		} );
 	}
+}
+
+// Finds cells that will be:
+// - trimmed - Cells that are "above" removed rows sections and overlap the removed section - their rowspan must be trimmed.
+// - moved - Cells from removed rows section might stick out of. These cells are moved to the next row after a removed section.
+//
+// Sample table with overlapping & sticking out cells:
+//
+//      +----+----+----+----+----+
+//      | 00 | 01 | 02 | 03 | 04 |
+//      +----+    +    +    +    +
+//      | 10 |    |    |    |    |
+//      +----+----+    +    +    +
+//      | 20 | 21 |    |    |    | <-- removed row
+//      +    +    +----+    +    +
+//      |    |    | 32 |    |    | <-- removed row
+//      +----+    +    +----+    +
+//      | 40 |    |    | 43 |    |
+//      +----+----+----+----+----+
+//
+// In a table above:
+// - cells to trim: '02', '03' & '04'.
+// - cells to move: '21' & '32'.
+function getCellsToMoveAndTrimOnRemoveRow( table, first, last ) {
+	const cellsToMove = new Map();
+	const cellsToTrim = [];
+
+	for ( const { row, column, rowspan, cell } of new TableWalker( table, { endRow: last } ) ) {
+		const lastRowOfCell = row + rowspan - 1;
+
+		const isCellStickingOutFromRemovedRows = row >= first && row <= last && lastRowOfCell > last;
+
+		if ( isCellStickingOutFromRemovedRows ) {
+			const rowspanInRemovedSection = last - row + 1;
+			const rowSpanToSet = rowspan - rowspanInRemovedSection;
+
+			cellsToMove.set( column, {
+				cell,
+				rowspan: rowSpanToSet
+			} );
+		}
 
-	return first;
+		const isCellOverlappingRemovedRows = row < first && lastRowOfCell >= first;
+
+		if ( isCellOverlappingRemovedRows ) {
+			let rowspanAdjustment;
+
+			// Cell fully covers removed section - trim it by removed rows count.
+			if ( lastRowOfCell >= last ) {
+				rowspanAdjustment = last - first + 1;
+			}
+			// Cell partially overlaps removed section - calculate cell's span that is in removed section.
+			else {
+				rowspanAdjustment = lastRowOfCell - first + 1;
+			}
+
+			cellsToTrim.push( {
+				cell,
+				rowspan: rowspan - rowspanAdjustment
+			} );
+		}
+	}
+	return { cellsToMove, cellsToTrim };
 }
 
-// Updates heading columns attribute if removing a row from head section.
-function adjustHeadingColumns( table, removedColumnIndexes, writer ) {
-	const headingColumns = table.getAttribute( 'headingColumns' ) || 0;
+function moveCellsToRow( table, targetRowIndex, cellsToMove, writer ) {
+	const tableWalker = new TableWalker( table, {
+		includeSpanned: true,
+		startRow: targetRowIndex,
+		endRow: targetRowIndex
+	} );
 
-	if ( headingColumns && removedColumnIndexes.first < headingColumns ) {
-		const headingsRemoved = Math.min( headingColumns - 1 /* Other numbers are 0-based */, removedColumnIndexes.last ) -
-			removedColumnIndexes.first + 1;
+	const tableRowMap = [ ...tableWalker ];
+	const row = table.getChild( targetRowIndex );
 
-		writer.setAttribute( 'headingColumns', headingColumns - headingsRemoved, table );
+	let previousCell;
+
+	for ( const { column, cell, isSpanned } of tableRowMap ) {
+		if ( cellsToMove.has( column ) ) {
+			const { cell: cellToMove, rowspan } = cellsToMove.get( column );
+
+			const targetPosition = previousCell ?
+				writer.createPositionAfter( previousCell ) :
+				writer.createPositionAt( row, 0 );
+
+			writer.move( writer.createRangeOn( cellToMove ), targetPosition );
+			updateNumericAttribute( 'rowspan', rowspan, cellToMove, writer );
+
+			previousCell = cellToMove;
+		} else if ( !isSpanned ) {
+			// If cell is spanned then `cell` holds reference to overlapping cell. See ckeditor/ckeditor5#6502.
+			previousCell = cell;
+		}
 	}
 }

+ 281 - 31
packages/ckeditor5-table/tests/tableutils.js

@@ -8,37 +8,53 @@ import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model
 
 import { defaultConversion, defaultSchema, modelTable } from './_utils/utils';
 
+import TableEditing from '../src/tableediting';
 import TableUtils from '../src/tableutils';
 import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 
 describe( 'TableUtils', () => {
 	let editor, model, root, tableUtils;
 
-	beforeEach( () => {
-		return ModelTestEditor.create( {
-			plugins: [ TableUtils ]
-		} ).then( newEditor => {
-			editor = newEditor;
-			model = editor.model;
-			root = model.document.getRoot( 'main' );
-			tableUtils = editor.plugins.get( TableUtils );
-
-			defaultSchema( model.schema );
-			defaultConversion( editor.conversion );
-		} );
-	} );
-
 	afterEach( () => {
 		return editor.destroy();
 	} );
 
 	describe( '#pluginName', () => {
+		beforeEach( () => {
+			return ModelTestEditor.create( {
+				plugins: [ TableUtils ]
+			} ).then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				root = model.document.getRoot( 'main' );
+				tableUtils = editor.plugins.get( TableUtils );
+
+				defaultSchema( model.schema );
+				defaultConversion( editor.conversion );
+			} );
+		} );
+
 		it( 'should provide plugin name', () => {
 			expect( TableUtils.pluginName ).to.equal( 'TableUtils' );
 		} );
 	} );
 
 	describe( 'getCellLocation()', () => {
+		beforeEach( () => {
+			return ModelTestEditor.create( {
+				plugins: [ TableUtils ]
+			} ).then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				root = model.document.getRoot( 'main' );
+				tableUtils = editor.plugins.get( TableUtils );
+
+				defaultSchema( model.schema );
+				defaultConversion( editor.conversion );
+			} );
+		} );
+
 		it( 'should return proper table cell location', () => {
 			setData( model, modelTable( [
 				[ { rowspan: 2, colspan: 2, contents: '00[]' }, '02' ],
@@ -52,6 +68,20 @@ describe( 'TableUtils', () => {
 	} );
 
 	describe( 'insertRows()', () => {
+		beforeEach( () => {
+			return ModelTestEditor.create( {
+				plugins: [ TableUtils ]
+			} ).then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				root = model.document.getRoot( 'main' );
+				tableUtils = editor.plugins.get( TableUtils );
+
+				defaultSchema( model.schema );
+				defaultConversion( editor.conversion );
+			} );
+		} );
+
 		it( 'should insert row in given table at given index', () => {
 			setData( model, modelTable( [
 				[ '11[]', '12' ],
@@ -192,6 +222,20 @@ describe( 'TableUtils', () => {
 	} );
 
 	describe( 'insertColumns()', () => {
+		beforeEach( () => {
+			return ModelTestEditor.create( {
+				plugins: [ TableUtils ]
+			} ).then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				root = model.document.getRoot( 'main' );
+				tableUtils = editor.plugins.get( TableUtils );
+
+				defaultSchema( model.schema );
+				defaultConversion( editor.conversion );
+			} );
+		} );
+
 		it( 'should insert column in given table at given index', () => {
 			setData( model, modelTable( [
 				[ '11[]', '12' ],
@@ -370,7 +414,7 @@ describe( 'TableUtils', () => {
 			], { headingColumns: 4 } ) );
 		} );
 
-		it( 'should properly insert column while table has rowspanned cells', () => {
+		it( 'should properly insert column while table has row-spanned cells', () => {
 			setData( model, modelTable( [
 				[ { rowspan: 4, contents: '00[]' }, { rowspan: 2, contents: '01' }, '02' ],
 				[ '12' ],
@@ -390,6 +434,20 @@ describe( 'TableUtils', () => {
 	} );
 
 	describe( 'splitCellVertically()', () => {
+		beforeEach( () => {
+			return ModelTestEditor.create( {
+				plugins: [ TableUtils ]
+			} ).then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				root = model.document.getRoot( 'main' );
+				tableUtils = editor.plugins.get( TableUtils );
+
+				defaultSchema( model.schema );
+				defaultConversion( editor.conversion );
+			} );
+		} );
+
 		it( 'should split table cell to given table cells number', () => {
 			setData( model, modelTable( [
 				[ '00', '01', '02' ],
@@ -534,6 +592,20 @@ describe( 'TableUtils', () => {
 	} );
 
 	describe( 'splitCellHorizontally()', () => {
+		beforeEach( () => {
+			return ModelTestEditor.create( {
+				plugins: [ TableUtils ]
+			} ).then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				root = model.document.getRoot( 'main' );
+				tableUtils = editor.plugins.get( TableUtils );
+
+				defaultSchema( model.schema );
+				defaultConversion( editor.conversion );
+			} );
+		} );
+
 		it( 'should split table cell to default table cells number', () => {
 			setData( model, modelTable( [
 				[ '00', '01', '02' ],
@@ -570,7 +642,7 @@ describe( 'TableUtils', () => {
 			] ) );
 		} );
 
-		it( 'should properly update rowspanned cells overlapping selected cell', () => {
+		it( 'should properly update row-spanned cells overlapping selected cell', () => {
 			setData( model, modelTable( [
 				[ { rowspan: 2, contents: '00' }, '01', { rowspan: 3, contents: '02' } ],
 				[ '[]11' ],
@@ -588,7 +660,7 @@ describe( 'TableUtils', () => {
 			] ) );
 		} );
 
-		it( 'should split rowspanned cell', () => {
+		it( 'should split row-spanned cell', () => {
 			setData( model, modelTable( [
 				[ '00', { rowspan: 2, contents: '01[]' } ],
 				[ '10' ],
@@ -606,7 +678,7 @@ describe( 'TableUtils', () => {
 			] ) );
 		} );
 
-		it( 'should copy colspan while splitting rowspanned cell', () => {
+		it( 'should copy colspan while splitting row-spanned cell', () => {
 			setData( model, modelTable( [
 				[ '00', { rowspan: 2, colspan: 2, contents: '01[]' } ],
 				[ '10' ],
@@ -652,7 +724,7 @@ describe( 'TableUtils', () => {
 			] ) );
 		} );
 
-		it( 'should split rowspanned cell and updated other cells rowspan when splitting to bigger number of cells', () => {
+		it( 'should split row-spanned cell and updated other cells rowspan when splitting to bigger number of cells', () => {
 			setData( model, modelTable( [
 				[ '00', { rowspan: 2, contents: '01[]' } ],
 				[ '10' ],
@@ -671,7 +743,7 @@ describe( 'TableUtils', () => {
 			] ) );
 		} );
 
-		it( 'should split rowspanned & colspaned cell', () => {
+		it( 'should split row-spanned & col-spanned cell', () => {
 			setData( model, modelTable( [
 				[ '00', { colspan: 2, contents: '01[]' } ],
 				[ '10', '11' ]
@@ -709,6 +781,20 @@ describe( 'TableUtils', () => {
 	} );
 
 	describe( 'getColumns()', () => {
+		beforeEach( () => {
+			return ModelTestEditor.create( {
+				plugins: [ TableUtils ]
+			} ).then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				root = model.document.getRoot( 'main' );
+				tableUtils = editor.plugins.get( TableUtils );
+
+				defaultSchema( model.schema );
+				defaultConversion( editor.conversion );
+			} );
+		} );
+
 		it( 'should return proper number of columns', () => {
 			setData( model, modelTable( [
 				[ '00', { colspan: 3, contents: '01' }, '04' ]
@@ -719,6 +805,20 @@ describe( 'TableUtils', () => {
 	} );
 
 	describe( 'getRows()', () => {
+		beforeEach( () => {
+			return ModelTestEditor.create( {
+				plugins: [ TableUtils ]
+			} ).then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				root = model.document.getRoot( 'main' );
+				tableUtils = editor.plugins.get( TableUtils );
+
+				defaultSchema( model.schema );
+				defaultConversion( editor.conversion );
+			} );
+		} );
+
 		it( 'should return proper number of columns for simple table', () => {
 			setData( model, modelTable( [
 				[ '00', '01' ],
@@ -749,6 +849,17 @@ describe( 'TableUtils', () => {
 	} );
 
 	describe( 'removeRows()', () => {
+		beforeEach( () => {
+			return ModelTestEditor.create( {
+				plugins: [ Paragraph, TableEditing, TableUtils ]
+			} ).then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				root = model.document.getRoot( 'main' );
+				tableUtils = editor.plugins.get( TableUtils );
+			} );
+		} );
+
 		describe( 'single row', () => {
 			it( 'should remove a given row from a table start', () => {
 				setData( model, modelTable( [
@@ -794,11 +905,57 @@ describe( 'TableUtils', () => {
 			} );
 
 			it( 'should decrease rowspan of table cells from previous rows', () => {
+				// +----+----+----+----+----+
+				// | 00 | 01 | 02 | 03 | 04 |
+				// +----+    +    +    +    +
+				// | 10 |    |    |    |    |
+				// +----+----+    +    +    +
+				// | 20 | 21 |    |    |    |
+				// +----+----+----+    +    +
+				// | 30 | 31 | 32 |    |    |
+				// +----+----+----+----+    +
+				// | 40 | 41 | 42 | 43 |    |
+				// +----+----+----+----+----+
+				// | 50 | 51 | 52 | 53 | 54 |
+				// +----+----+----+----+----+
+				setData( model, modelTable( [
+					[ '00', { contents: '01', rowspan: 2 }, { contents: '02', rowspan: 3 }, { contents: '03', rowspan: 4 },
+						{ contents: '04', rowspan: 5 } ],
+					[ '10' ],
+					[ '20', '21' ],
+					[ '30', '31', '32' ],
+					[ '40', '41', '42', '43' ],
+					[ '50', '51', '52', '53', '54' ]
+				] ) );
+
+				tableUtils.removeRows( root.getChild( 0 ), { at: 1, rows: 1 } );
+
+				// +----+----+----+----+----+
+				// | 00 | 01 | 02 | 03 | 04 |
+				// +----+----+    +    +    +
+				// | 20 | 21 |    |    |    |
+				// +----+----+----+    +    +
+				// | 30 | 31 | 32 |    |    |
+				// +----+----+----+----+    +
+				// | 40 | 41 | 42 | 43 |    |
+				// +----+----+----+----+----+
+				// | 50 | 51 | 52 | 53 | 54 |
+				// +----+----+----+----+----+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '00', '01', { contents: '02', rowspan: 2 }, { contents: '03', rowspan: 3 }, { contents: '04', rowspan: 4 } ],
+					[ '20', '21' ],
+					[ '30', '31', '32' ],
+					[ '40', '41', '42', '43' ],
+					[ '50', '51', '52', '53', '54' ]
+				] ) );
+			} );
+
+			it( 'should decrease rowspan of table cells from previous rows (row-spanned cells on different rows)', () => {
 				setData( model, modelTable( [
 					[ { rowspan: 4, contents: '00' }, { rowspan: 3, contents: '01' }, { rowspan: 2, contents: '02' }, '03', '04' ],
 					[ { rowspan: 2, contents: '13' }, '14' ],
-					[ '22', '23', '24' ],
-					[ '30', '31', '32', '33', '34' ]
+					[ '22', '24' ],
+					[ '31', '32', '33', '34' ]
 				] ) );
 
 				tableUtils.removeRows( root.getChild( 0 ), { at: 2 } );
@@ -806,11 +963,11 @@ describe( 'TableUtils', () => {
 				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
 					[ { rowspan: 3, contents: '00' }, { rowspan: 2, contents: '01' }, { rowspan: 2, contents: '02' }, '03', '04' ],
 					[ '13', '14' ],
-					[ '30', '31', '32', '33', '34' ]
+					[ '31', '32', '33', '34' ]
 				] ) );
 			} );
 
-			it( 'should move rowspaned cells to row below removing it\'s row', () => {
+			it( 'should move row-spanned cells to a row below removing it\'s row', () => {
 				setData( model, modelTable( [
 					[ { rowspan: 3, contents: '00' }, { rowspan: 2, contents: '01' }, '02' ],
 					[ '12' ],
@@ -826,6 +983,21 @@ describe( 'TableUtils', () => {
 					[ '30', '31', '32' ]
 				] ) );
 			} );
+
+			it( 'should move row-spanned cells to a row below removing it\'s row (other cell is overlapping removed row)', () => {
+				setData( model, modelTable( [
+					[ '00', { rowspan: 3, contents: '01' }, '02', '03', '04' ],
+					[ '10', { rowspan: 2, contents: '12' }, '13', '14' ],
+					[ '20', '23', '24' ]
+				] ) );
+
+				tableUtils.removeRows( root.getChild( 0 ), { at: 1 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '00', { rowspan: 2, contents: '01' }, '02', '03', '04' ],
+					[ '20', '12', '23', '24' ]
+				] ) );
+			} );
 		} );
 
 		describe( 'many rows', () => {
@@ -924,17 +1096,81 @@ describe( 'TableUtils', () => {
 				] ) );
 			} );
 
-			it( 'should properly calculate truncated rowspans', () => {
+			it( 'should move row-spanned cells to a row after removed rows section', () => {
+				setData( model, modelTable( [
+					[ '00', '01', '02', '03' ],
+					[ { rowspan: 4, contents: '10' }, { rowspan: 3, contents: '11' }, { rowspan: 2, contents: '12' }, '13' ],
+					[ { rowspan: 3, contents: '23' } ],
+					[ '32' ],
+					[ '41', '42' ]
+				] ) );
+
+				tableUtils.removeRows( root.getChild( 0 ), { at: 1, rows: 2 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ '00', '01', '02', '03' ],
+					[ { rowspan: 2, contents: '10' }, '11', '32', { rowspan: 2, contents: '23' } ],
+					[ '41', '42' ]
+				] ) );
+			} );
+
+			it( 'should decrease rowspan of table cells from rows before removed rows section', () => {
 				setData( model, modelTable( [
-					[ '00', { contents: '01', rowspan: 3 } ],
+					[ { rowspan: 4, contents: '00' }, { rowspan: 3, contents: '01' }, { rowspan: 2, contents: '02' }, '03' ],
+					[ '13' ],
+					[ '22', '23' ],
+					[ '31', '32', '33' ]
+				] ) );
+
+				tableUtils.removeRows( root.getChild( 0 ), { at: 1, rows: 2 } );
+
+				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
+					[ { rowspan: 2, contents: '00' }, '01', '02', '03' ],
+					[ '31', '32', '33' ]
+				] ) );
+			} );
+
+			it( 'should decrease rowspan of table cells from previous rows', () => {
+				// +----+----+----+----+----+
+				// | 00 | 01 | 02 | 03 | 04 |
+				// +----+    +    +    +    +
+				// | 10 |    |    |    |    |
+				// +----+----+    +    +    +
+				// | 20 | 21 |    |    |    |
+				// +----+----+----+    +    +
+				// | 30 | 31 | 32 |    |    |
+				// +----+----+----+----+    +
+				// | 40 | 41 | 42 | 43 |    |
+				// +----+----+----+----+----+
+				// | 50 | 51 | 52 | 53 | 54 |
+				// +----+----+----+----+----+
+				setData( model, modelTable( [
+					[ '00', { contents: '01', rowspan: 2 }, { contents: '02', rowspan: 3 }, { contents: '03', rowspan: 4 },
+						{ contents: '04', rowspan: 5 } ],
 					[ '10' ],
-					[ '20' ]
+					[ '20', '21' ],
+					[ '30', '31', '32' ],
+					[ '40', '41', '42', '43' ],
+					[ '50', '51', '52', '53', '54' ]
 				] ) );
 
-				tableUtils.removeRows( root.getChild( 0 ), { at: 0, rows: 2 } );
+				tableUtils.removeRows( root.getChild( 0 ), { at: 2, rows: 2 } );
 
+				// +----+----+----+----+----+
+				// | 00 | 01 | 02 | 03 | 04 |
+				// +----+    +    +    +    +
+				// | 10 |    |    |    |    |
+				// +----+----+----+----+    +
+				// | 40 | 41 | 42 | 43 |    |
+				// +----+----+----+----+----+
+				// | 50 | 51 | 52 | 53 | 54 |
+				// +----+----+----+----+----+
 				assertEqualMarkup( getData( model, { withoutSelection: true } ), modelTable( [
-					[ '20', '01' ]
+					[ '00', { contents: '01', rowspan: 2 }, { contents: '02', rowspan: 2 }, { contents: '03', rowspan: 2 },
+						{ contents: '04', rowspan: 3 } ],
+					[ '10' ],
+					[ '40', '41', '42', '43' ],
+					[ '50', '51', '52', '53', '54' ]
 				] ) );
 			} );
 
@@ -964,6 +1200,20 @@ describe( 'TableUtils', () => {
 	} );
 
 	describe( 'removeColumns()', () => {
+		beforeEach( () => {
+			return ModelTestEditor.create( {
+				plugins: [ TableUtils ]
+			} ).then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				root = model.document.getRoot( 'main' );
+				tableUtils = editor.plugins.get( TableUtils );
+
+				defaultSchema( model.schema );
+				defaultConversion( editor.conversion );
+			} );
+		} );
+
 		describe( 'single row', () => {
 			it( 'should remove a given column', () => {
 				setData( model, modelTable( [
@@ -1078,7 +1328,7 @@ describe( 'TableUtils', () => {
 				] ) );
 			} );
 
-			it( 'should remove column if other column is rowspanned (last column)', () => {
+			it( 'should remove column if other column is row-spanned (last column)', () => {
 				setData( model, modelTable( [
 					[ '00', { rowspan: 2, contents: '01' } ],
 					[ '10' ]
@@ -1091,7 +1341,7 @@ describe( 'TableUtils', () => {
 				] ) );
 			} );
 
-			it( 'should remove column if other column is rowspanned (first column)', () => {
+			it( 'should remove column if other column is row-spanned (first column)', () => {
 				setData( model, modelTable( [
 					[ { rowspan: 2, contents: '00' }, '01' ],
 					[ '11' ]