Sfoglia il codice sorgente

Merge pull request #89 from ckeditor/t/13

Feature: Implemented the table post–fixer which bulletproofs the feature in various complex use–cases (e.g. pasting and collaboration). Closes #13.
Aleksander Nowodzinski 7 anni fa
parent
commit
3fc7f5fcac

+ 1 - 0
packages/ckeditor5-table/package.json

@@ -17,6 +17,7 @@
   "devDependencies": {
     "@ckeditor/ckeditor5-editor-classic": "^11.0.0",
     "@ckeditor/ckeditor5-paragraph": "^10.0.2",
+    "@ckeditor/ckeditor5-undo": "^10.0.1",
     "@ckeditor/ckeditor5-utils": "^10.2.0",
     "eslint": "^4.15.0",
     "eslint-config-ckeditor5": "^1.0.7",

+ 378 - 0
packages/ckeditor5-table/src/converters/table-post-fixer.js

@@ -0,0 +1,378 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module table/converters/table-post-fixer
+ */
+
+import Position from '@ckeditor/ckeditor5-engine/src/model/position';
+import { getParentTable, updateNumericAttribute } from './../commands/utils';
+import TableWalker from './../tablewalker';
+
+/**
+ * Injects a table post-fixer into the model.
+ *
+ * The role of the table post-fixer is to ensure that the table rows have the correct structure
+ * after a {@link module:engine/model/model~Model#change `change()`} block was executed.
+ *
+ * The correct structure means that:
+ *
+ * * All table rows have the same size.
+ * * None of a table cells that extend vertically beyond their section (either header or body).
+ *
+ * If the table structure is not correct, the post-fixer will automatically correct it in two steps:
+ *
+ * 1. It will clip table cells that extends beyond it section.
+ * 2. It will add empty table cells to those rows which are narrower then the widest table row.
+ *
+ * ## Clipping overlapping table cells
+ *
+ * Such situation may occur when pasting a table (or part of a table) to the editor from external sources.
+ *
+ * For example, see the following table which has the cell (FOO) with the rowspan attribute (2):
+ *
+ *		<table headingRows="1">
+ *			<tableRow>
+ *				<tableCell rowspan="2">FOO</tableCell>
+ *				<tableCell colspan="2">BAR</tableCell>
+ *			</tableRow>
+ *			<tableRow>
+ *				<tableCell>BAZ</tableCell>
+ *				<tableCell>XYZ</tableCell>
+ *			</tableRow>
+ *		</table>
+ *
+ * will be rendered in the view as:
+ *
+ *		<table>
+ *			<thead>
+ *				<tr>
+ *					<td rowspan="2">FOO</td>
+ *					<td colspan="2">BAR</td>
+ *				</tr>
+ *			</thead>
+ *			<tbody>
+ *				<tr>
+ *					<td>BAZ<td>
+ *					<td>XYZ<td>
+ *				</tr>
+ *			</tbody>
+ *		</table>
+ *
+ * In the above example the table will be rendered as a table with two rows - one in the header and second one in the body.
+ * The table cell (FOO) cannot span over multiple rows as it would expand from the header to the body section.
+ * The `rowspan` attribute must be changed to (1). The value (1) is a default value of the `rowspan` attribute
+ * so the `rowspan` attribute will be removed from the model.
+ *
+ * The table cell with BAZ contents will be in the first column of the table.
+ *
+ * ## Adding missing table cells
+ *
+ * The table post-fixer will insert empty table cells to equalize table rows sizes (number of columns).
+ * The size of a table row is calculated by counting column spans of table cells - both horizontal (from the same row) and
+ * vertical (from rows above).
+ *
+ * In the above example, the table row in the body section of the table is narrower then the row from the header - it has two cells
+ * with the default colspan (1). The header row has one cell with colspan (1) and second with colspan (2).
+ * The table cell (FOO) does not expand beyond the head section (and as such will be fixed in the first step of this post-fixer).
+ * The post-fixer will add a missing table cell to the row in the body section of the table.
+ *
+ * The table from the above example will be fixed and rendered to the view as below:
+ *
+ *		<table>
+ *			<thead>
+ *				<tr>
+ *					<td rowspan="2">FOO</td>
+ *					<td colspan="2">BAR</td>
+ *				</tr>
+ *			</thead>
+ *			<tbody>
+ *				<tr>
+ *					<td>BAZ<td>
+ *					<td>XYZ<td>
+ *				</tr>
+ *			</tbody>
+ *		</table>
+ *
+ * ## Collaboration & Undo - Expectations vs post-fixer results
+ *
+ * The table post-fixer only ensures proper structure without deeper analysis of the nature of a change. As such, it might lead
+ * to a structure which was not intended by the user changes. In particular, it will also fix undo steps (in conjunction with collaboration)
+ * in which editor content might not return to the original state.
+ *
+ * This will usually happen when one or more users changes size of the table.
+ *
+ * As en example see a table below:
+ *
+ *		<table>
+ *			<tbody>
+ *				<tr>
+ *					<td>11</td>
+ *					<td>12</td>
+ *				</tr>
+ *				<tr>
+ *					<td>21<td>
+ *					<td>22<td>
+ *				</tr>
+ *			</tbody>
+ *		</table>
+ *
+ * and user actions:
+ *
+ * 1. Both user have table with two rows and two columns.
+ * 2. User A adds a column at the end of the table - this will insert empty table cells to two rows.
+ * 3. User B adds a row at the end of the table- this will insert a row with two empty table cells.
+ * 4. Both users will have a table as below:
+ *
+ *
+ *		<table>
+ *			<tbody>
+ *				<tr>
+ *					<td>11</td>
+ *					<td>12</td>
+ *					<td>(empty, inserted by A)</td>
+ *				</tr>
+ *				<tr>
+ *					<td>21</td>
+ *					<td>22</td>
+ *					<td>(empty, inserted by A)</td>
+ *				</tr>
+ *				<tr>
+ *					<td>(empty, inserted by B)</td>
+ *					<td>(empty, inserted by B)</td>
+ *				</tr>
+ *			</tbody>
+ *		</table>
+ *
+ * The last row is shorter then others so table post-fixer will add empty row to tha last row:
+ *
+ *		<table>
+ *			<tbody>
+ *				<tr>
+ *					<td>11</td>
+ *					<td>12</td>
+ *					<td>(empty, inserted by A)</td>
+ *				</tr>
+ *				<tr>
+ *					<td>21<td>
+ *					<td>22<td>
+ *					<td>(empty, inserted by A)</td>
+ *				</tr>
+ *				<tr>
+ *					<td>(empty, inserted by B)</td>
+ *					<td>(empty, inserted by B)</td>
+ *					<td>(empty, inserted by a post-fixer)</td>
+ *				</tr>
+ *			</tbody>
+ *		</table>
+ *
+ * Unfortunately undo doesn't know the nature of changes and depending which user will apply post-fixer changes undoing them might lead to
+ * broken table. If User B will undo inserting column to a table the undo engine will undo only operations of
+ * inserting empty cells to rows from initial table state (row 1 & 2) but the cell in post-fixed row will remain:
+ *
+ *		<table>
+ *			<tbody>
+ *				<tr>
+ *					<td>11</td>
+ *					<td>12</td>
+ *				</tr>
+ *				<tr>
+ *					<td>21</td>
+ *					<td>22</td>
+ *				</tr>
+ *				<tr>
+ *					<td>(empty, inserted by B)</td>
+ *					<td>(empty, inserted by B)</td>
+ *					<td>(empty, inserted by a post-fixer)</td>
+ *				</tr>
+ *			</tbody>
+ *		</table>
+ *
+ * After undo the table post-fixer will detect that two rows are shorter then other and will fix table to:
+ *
+ *		<table>
+ *			<tbody>
+ *				<tr>
+ *					<td>11</td>
+ *					<td>12</td>
+ *					<td>(empty, inserted by a post-fixer after undo)<td>
+ *				</tr>
+ *				<tr>
+ *					<td>21<td>
+ *					<td>22<td>
+ *					<td>(empty, inserted by a post-fixer after undo)<td>
+ *				</tr>
+ *				<tr>
+ *					<td>(empty, inserted by B)<td>
+ *					<td>(empty, inserted by B)<td>
+ *					<td>(empty, inserted by a post-fixer)<td>
+ *				</tr>
+ *			</tbody>
+ *		</table>
+ *
+ * @param {module:engine/model/model~Model} model
+ */
+export default function injectTablePostFixer( model ) {
+	model.document.registerPostFixer( writer => tablePostFixer( writer, model ) );
+}
+
+// The table post-fixer.
+//
+// @param {module:engine/model/writer~Writer} writer
+// @param {module:engine/model/model~Model} model
+function tablePostFixer( writer, model ) {
+	const changes = model.document.differ.getChanges();
+
+	let wasFixed = false;
+
+	// Do not analyze the same table more then once - may happen for multiple changes in the same table.
+	const analyzedTables = new Set();
+
+	for ( const entry of changes ) {
+		let table;
+
+		// Fix table on table insert.
+		if ( entry.name == 'table' && entry.type == 'insert' ) {
+			table = entry.position.nodeAfter;
+		}
+
+		// Fix table on adding/removing table cells and rows.
+		if ( entry.name == 'tableRow' || entry.name == 'tableCell' ) {
+			table = getParentTable( entry.position );
+		}
+
+		// Fix table on any table's attribute change - including attributes of table cells.
+		if ( isTableAttributeEntry( entry ) ) {
+			table = getParentTable( entry.range.start );
+		}
+
+		if ( table && !analyzedTables.has( table ) ) {
+			// Step 1: correct rowspans of table cells if necessary.
+			// The wasFixed flag should be true if any of tables in batch was fixed - might be more then one.
+			wasFixed = fixTableCellsRowspan( table, writer ) || wasFixed;
+			// Step 2: fix table rows sizes.
+			wasFixed = fixTableRowsSizes( table, writer ) || wasFixed;
+
+			analyzedTables.add( table );
+		}
+	}
+
+	return wasFixed;
+}
+
+// Fixes the invalid value of the rowspan attribute because a table cell cannot vertically extend beyond the table section it belongs to.
+//
+// @param {module:engine/model/element~Element} table
+// @param {module:engine/model/writer~Writer} writer
+// @returns {Boolean} Returns true if table was fixed.
+function fixTableCellsRowspan( table, writer ) {
+	let wasFixed = false;
+
+	const cellsToTrim = findCellsToTrim( table );
+
+	if ( cellsToTrim.length ) {
+		wasFixed = true;
+
+		for ( const data of cellsToTrim ) {
+			updateNumericAttribute( 'rowspan', data.rowspan, data.cell, writer, 1 );
+		}
+	}
+
+	return wasFixed;
+}
+
+// Makes all table rows in a table the same size.
+//
+// @param {module:engine/model/element~Element} table
+// @param {module:engine/model/writer~Writer} writer
+// @returns {Boolean} Returns true if table was fixed.
+function fixTableRowsSizes( table, writer ) {
+	let wasFixed = false;
+
+	const rowsLengths = getRowsLengths( table );
+	const tableSize = rowsLengths[ 0 ];
+
+	const isValid = Object.values( rowsLengths ).every( length => length === tableSize );
+
+	if ( !isValid ) {
+		const maxColumns = Object.values( rowsLengths ).reduce( ( prev, current ) => current > prev ? current : prev, 0 );
+
+		for ( const [ rowIndex, size ] of Object.entries( rowsLengths ) ) {
+			const columnsToInsert = maxColumns - size;
+
+			if ( columnsToInsert ) {
+				for ( let i = 0; i < columnsToInsert; i++ ) {
+					writer.insertElement( 'tableCell', Position.createAt( table.getChild( rowIndex ), 'end' ) );
+				}
+
+				wasFixed = true;
+			}
+		}
+	}
+
+	return wasFixed;
+}
+
+// Searches for the table cells that extends beyond the table section to which they belong to. It will return an array of objects
+// that holds table cells to be trimmed and correct value of a rowspan attribute to set.
+//
+// @param {module:engine/model/element~Element} table
+// @returns {Array.<{{cell, rowspan}}>}
+function findCellsToTrim( table ) {
+	const headingRows = parseInt( table.getAttribute( 'headingRows' ) || 0 );
+	const maxRows = table.childCount;
+
+	const cellsToTrim = [];
+
+	for ( const { row, rowspan, cell } of new TableWalker( table ) ) {
+		// Skip cells that do not expand over its row.
+		if ( rowspan < 2 ) {
+			continue;
+		}
+
+		const isInHeader = row < headingRows;
+
+		// Row limit is either end of header section or whole table as table body is after the header.
+		const rowLimit = isInHeader ? headingRows : maxRows;
+
+		// If table cell expands over its limit reduce it height to proper value.
+		if ( row + rowspan > rowLimit ) {
+			const newRowspan = rowLimit - row;
+
+			cellsToTrim.push( { cell, rowspan: newRowspan } );
+		}
+	}
+
+	return cellsToTrim;
+}
+
+// Returns an object with lengths of rows assigned to the corresponding row index.
+//
+// @param {module:engine/model/element~Element} table
+// @returns {Object}
+function getRowsLengths( table ) {
+	const lengths = {};
+
+	for ( const { row } of new TableWalker( table, { includeSpanned: true } ) ) {
+		if ( !lengths[ row ] ) {
+			lengths[ row ] = 0;
+		}
+
+		lengths[ row ] += 1;
+	}
+
+	return lengths;
+}
+
+// Checks if the differ entry for an attribute change is one of table's attributes.
+//
+// @param entry
+// @returns {Boolean}
+function isTableAttributeEntry( entry ) {
+	const isAttributeType = entry.type === 'attribute';
+	const key = entry.attributeKey;
+
+	return isAttributeType && ( key === 'headingRows' || key === 'colspan' || key === 'rowspan' );
+}

+ 7 - 2
packages/ckeditor5-table/src/tableediting.js

@@ -21,6 +21,7 @@ import {
 	downcastTableHeadingColumnsChange,
 	downcastTableHeadingRowsChange
 } from './converters/downcast';
+
 import InsertTableCommand from './commands/inserttablecommand';
 import InsertRowCommand from './commands/insertrowcommand';
 import InsertColumnCommand from './commands/insertcolumncommand';
@@ -31,7 +32,8 @@ import RemoveColumnCommand from './commands/removecolumncommand';
 import SetHeaderRowCommand from './commands/setheaderrowcommand';
 import SetHeaderColumnCommand from './commands/setheadercolumncommand';
 import { getParentTable } from './commands/utils';
-import TableUtils from './tableutils';
+import TableUtils from '../src/tableutils';
+import injectTablePostFixer from './converters/table-post-fixer';
 
 import '../theme/tableediting.css';
 
@@ -46,7 +48,8 @@ export default class TableEditing extends Plugin {
 	 */
 	init() {
 		const editor = this.editor;
-		const schema = editor.model.schema;
+		const model = editor.model;
+		const schema = model.schema;
 		const conversion = editor.conversion;
 
 		schema.register( 'table', {
@@ -119,6 +122,8 @@ export default class TableEditing extends Plugin {
 		editor.commands.add( 'setTableColumnHeader', new SetHeaderColumnCommand( editor ) );
 		editor.commands.add( 'setTableRowHeader', new SetHeaderRowCommand( editor ) );
 
+		injectTablePostFixer( model );
+
 		// Handle tab key navigation.
 		this.listenTo( editor.editing.view.document, 'keydown', ( ...args ) => this._handleTabOnSelectedTable( ...args ) );
 		this.listenTo( editor.editing.view.document, 'keydown', ( ...args ) => this._handleTabInsideTable( ...args ) );

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

@@ -25,7 +25,7 @@
  *			contents: 'foo' // text contents of a cell
  *		};
  *
- * @param {Array.<String>} tableData
+ * @param {Array.<Array.<String>|Object>} tableData
  * @param {Object} [attributes] Optional table attributes: `headingRows` and `headingColumns`.
  *
  * @returns {String}

+ 450 - 0
packages/ckeditor5-table/tests/converters/table-post-fixer.js

@@ -0,0 +1,450 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Range from '@ckeditor/ckeditor5-engine/src/model/range';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import { getData as getModelData, parse, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import TableEditing from '../../src/tableediting';
+import { formatTable, formattedModelTable, modelTable } from './../_utils/utils';
+import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting';
+
+describe( 'Table post-fixer', () => {
+	let editor, model, root;
+
+	beforeEach( () => {
+		return VirtualTestEditor
+			.create( {
+				plugins: [ TableEditing, Paragraph, UndoEditing ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				root = model.document.getRoot();
+			} );
+	} );
+
+	afterEach( () => {
+		editor.destroy();
+	} );
+
+	describe( 'on insert table', () => {
+		it( 'should add missing columns to tableRows that are shorter then the longest table row', () => {
+			const parsed = parse( modelTable( [
+				[ '00' ],
+				[ '10', '11', '12' ],
+				[ '20', '21' ]
+			] ), model.schema );
+
+			model.change( writer => {
+				writer.remove( Range.createIn( root ) );
+				writer.insert( parsed, root );
+			} );
+
+			expect( formatTable( getModelData( model, { withoutSelection: true } ) ) ).to.equal( formattedModelTable( [
+				[ '00', '', '' ],
+				[ '10', '11', '12' ],
+				[ '20', '21', '' ]
+			] ) );
+		} );
+
+		it( 'should add missing columns to tableRows that are shorter then the longest table row (complex 1)', () => {
+			const parsed = parse( modelTable( [
+				[ '00', { rowspan: 2, contents: '10' } ],
+				[ '10', { colspan: 2, contents: '12' } ],
+				[ '20', '21' ]
+			] ), model.schema );
+
+			model.change( writer => {
+				writer.remove( Range.createIn( root ) );
+				writer.insert( parsed, root );
+			} );
+
+			expect( formatTable( getModelData( model, { withoutSelection: true } ) ) ).to.equal( formattedModelTable( [
+				[ '00', { rowspan: 2, contents: '10' }, '', '' ],
+				[ '10', { colspan: 2, contents: '12' } ],
+				[ '20', '21', '', '' ]
+			] ) );
+		} );
+
+		it( 'should add missing columns to tableRows that are shorter then the longest table row (complex 2)', () => {
+			const parsed = parse( modelTable( [
+				[ { colspan: 6, contents: '00' } ],
+				[ { rowspan: 2, contents: '10' }, '11', { colspan: 3, contents: '12' } ],
+				[ '21', '22' ]
+			] ), model.schema );
+
+			model.change( writer => {
+				writer.remove( Range.createIn( root ) );
+				writer.insert( parsed, root );
+			} );
+
+			expect( formatTable( getModelData( model, { withoutSelection: true } ) ) ).to.equal( formattedModelTable( [
+				[ { colspan: 6, contents: '00' } ],
+				[ { rowspan: 2, contents: '10' }, '11', { colspan: 3, contents: '12' }, '' ],
+				[ '21', '22', '', '', '' ]
+			] ) );
+		} );
+
+		it( 'should fix the wrong rowspan attribute of a table cell inside the header', () => {
+			const parsed = parse( modelTable( [
+				[ { rowspan: 2, contents: '00' }, { rowspan: 3, contents: '01' }, '02' ],
+				[ { rowspan: 8, contents: '12' } ],
+				[ '20', '21', '22' ]
+			], { headingRows: 2 } ), model.schema );
+
+			model.change( writer => {
+				writer.remove( Range.createIn( root ) );
+				writer.insert( parsed, root );
+			} );
+
+			expect( formatTable( getModelData( model, { withoutSelection: true } ) ) ).to.equal( formattedModelTable( [
+				[ { rowspan: 2, contents: '00' }, { rowspan: 2, contents: '01' }, '02' ],
+				[ '12' ],
+				[ '20', '21', '22' ]
+			], { headingRows: 2 } ) );
+		} );
+
+		it( 'should fix the wrong rowspan attribute of a table cell inside the body', () => {
+			const parsed = parse( modelTable( [
+				[ '00', '01', '02' ],
+				[ { rowspan: 2, contents: '10' }, { rowspan: 3, contents: '11' }, '12' ],
+				[ { rowspan: 8, contents: '22' } ]
+			], { headingRows: 1 } ), model.schema );
+
+			model.change( writer => {
+				writer.remove( Range.createIn( root ) );
+				writer.insert( parsed, root );
+			} );
+
+			expect( formatTable( getModelData( model, { withoutSelection: true } ) ) ).to.equal( formattedModelTable( [
+				[ '00', '01', '02' ],
+				[ { rowspan: 2, contents: '10' }, { rowspan: 2, contents: '11' }, '12' ],
+				[ '22' ]
+			], { headingRows: 1 } ) );
+		} );
+
+		it( 'should fix multiple tables', () => {
+			const tableA = modelTable( [
+				[ '11' ],
+				[ '21', '22' ]
+			] );
+			const tableB = modelTable( [
+				[ 'aa', 'ab' ],
+				[ 'ba', 'bb' ]
+			] );
+			const tableC = modelTable( [
+				[ 'xx' ],
+				[ 'yy', 'yy' ]
+			] );
+
+			const parsed = parse( tableA + tableB + tableC, model.schema );
+
+			model.change( writer => {
+				writer.remove( Range.createIn( root ) );
+				writer.insert( parsed, root );
+			} );
+
+			const expectedTableA = formattedModelTable( [
+				[ '11', '' ],
+				[ '21', '22' ]
+			] );
+			const expectedTableB = formattedModelTable( [
+				[ 'aa', 'ab' ],
+				[ 'ba', 'bb' ]
+			] );
+			const expectedTableC = formattedModelTable( [
+				[ 'xx', '' ],
+				[ 'yy', 'yy' ]
+			] );
+
+			const expectedTables = expectedTableA + expectedTableB + expectedTableC;
+
+			expect( formatTable( getModelData( model, { withoutSelection: true } ) ) ).to.equal( expectedTables );
+		} );
+
+		it( 'should not crash on table remove', () => {
+			setModelData( model, modelTable( [
+				[ '11', '12' ]
+			] ) );
+
+			expect( () => {
+				model.change( writer => {
+					writer.remove( Range.createIn( root ) );
+				} );
+			} ).to.not.throw();
+
+			expect( getModelData( model, { withoutSelection: true } ) ).to.equal( '<paragraph></paragraph>' );
+		} );
+	} );
+
+	describe( 'on collaboration', () => {
+		it( 'should add missing cells to columns (remove column vs insert row)', () => {
+			_testExternal(
+				modelTable( [
+					[ '00[]', '01' ],
+					[ '10', '11' ]
+				] ),
+				writer => _removeColumn( writer, 1, [ 0, 1 ] ),
+				writer => _insertRow( writer, 1, [ 'a', 'b' ] ),
+				// Table should have added empty cells.
+				formattedModelTable( [
+					[ '00', '' ],
+					[ 'a', 'b' ],
+					[ '10', '' ]
+				] ),
+				// Table will have empty column after undo.
+				formattedModelTable( [
+					[ '00', '01', '' ],
+					[ 'a', 'b', '' ],
+					[ '10', '11', '' ]
+				] ) );
+		} );
+
+		it( 'should add missing cells to columns (insert row vs remove column)', () => {
+			_testExternal(
+				modelTable( [
+					[ '00[]', '01' ],
+					[ '10', '11' ]
+				] ),
+				writer => _insertRow( writer, 1, [ 'a', 'b' ] ),
+				writer => _removeColumn( writer, 1, [ 0, 2 ] ),
+				// There should be empty cells added.
+				formattedModelTable( [
+					[ '00', '' ],
+					[ 'a', 'b' ],
+					[ '10', '' ]
+				] ),
+				// Table will have empty column after undo.
+				formattedModelTable( [
+					[ '00', '' ],
+					[ '10', '' ]
+				] ) );
+		} );
+
+		it( 'should add empty cell to an added row (insert row vs insert column)', () => {
+			_testExternal(
+				modelTable( [
+					[ '00[]', '01' ],
+					[ '10', '11' ]
+				] ),
+				writer => _insertRow( writer, 1, [ 'a', 'b' ] ),
+				writer => _insertColumn( writer, 1, [ 0, 2 ] ),
+				// There should be empty cells added.
+				formattedModelTable( [
+					[ '00', '', '01' ],
+					[ 'a', 'b', '' ],
+					[ '10', '', '11' ]
+				] ),
+				// Table will have empty column after undo.
+				formattedModelTable( [
+					[ '00', '', '01' ],
+					[ '10', '', '11' ]
+				] ) );
+		} );
+
+		it( 'should add empty cell to an added row (insert column vs insert row)', () => {
+			_testExternal(
+				modelTable( [
+					[ '00[]', '01' ],
+					[ '10', '11' ]
+				] ),
+				writer => _insertColumn( writer, 1, [ 0, 1 ] ),
+				writer => _insertRow( writer, 1, [ 'a', 'b' ] ),
+				// There should be empty cells added.
+				formattedModelTable( [
+					[ '00', '', '01' ],
+					[ 'a', 'b', '' ],
+					[ '10', '', '11' ]
+				] ),
+				// Table will have empty column after undo.
+				formattedModelTable( [
+					[ '00', '01', '' ],
+					[ 'a', 'b', '' ],
+					[ '10', '11', '' ]
+				] ) );
+		} );
+
+		it( 'should add empty cell when inserting column over a colspanned cell (insert column vs insert column)', () => {
+			_testExternal(
+				modelTable( [
+					[ { colspan: 3, contents: '00' } ],
+					[ '10', '11', '12' ]
+				] ),
+				writer => {
+					_setAttribute( writer, 'colspan', 4, [ 0, 0, 0 ] );
+					_insertColumn( writer, 2, [ 1 ] );
+				},
+				writer => {
+					_setAttribute( writer, 'colspan', 4, [ 0, 0, 0 ] );
+					_insertColumn( writer, 1, [ 1 ] );
+				},
+				// There should be empty cells added.
+				formattedModelTable( [
+					[ { colspan: 4, contents: '00' }, '' ],
+					[ '10', '', '11', '', '12' ]
+				] ),
+				// Table will have empty column after undo.
+				formattedModelTable( [
+					[ { colspan: 3, contents: '00' }, '' ],
+					[ '10', '', '11', '12' ]
+				] ) );
+		} );
+
+		it( 'should add empty cell when inserting column over a colspanned cell (insert column vs insert column) - inverted', () => {
+			_testExternal(
+				modelTable( [
+					[ { colspan: 3, contents: '00' } ],
+					[ '10', '11', '12' ]
+				] ),
+				writer => {
+					_setAttribute( writer, 'colspan', 4, [ 0, 0, 0 ] );
+					_insertColumn( writer, 1, [ 1 ] );
+				},
+				writer => {
+					_setAttribute( writer, 'colspan', 4, [ 0, 0, 0 ] );
+					_insertColumn( writer, 3, [ 1 ] );
+				},
+				// There should be empty cells added.
+				formattedModelTable( [
+					[ { colspan: 4, contents: '00' }, '' ],
+					[ '10', '', '11', '', '12' ]
+				] ),
+				// Table will have empty column after undo.
+				formattedModelTable( [
+					[ { colspan: 3, contents: '00' }, '' ],
+					[ '10', '11', '', '12' ]
+				] ) );
+		} );
+
+		it( 'should insert table cell on undo (change table headers on row with rowspanned cell vs remove row)', () => {
+			_testExternal(
+				modelTable( [
+					[ '11', { rowspan: 2, contents: '12' }, '13' ],
+					[ '21', '23' ],
+					[ '31', '32', '33' ]
+				] ),
+				writer => {
+					_setAttribute( writer, 'headingRows', 1, [ 0 ] );
+					_removeAttribute( writer, 'rowspan', [ 0, 0, 1 ] );
+					_insertCell( writer, 1, 1 );
+				},
+				writer => {
+					_removeRow( writer, 1 );
+				},
+				formattedModelTable( [
+					[ '11', '12', '13' ],
+					[ '31', '32', '33' ]
+				], { headingRows: 1 } ),
+				formattedModelTable( [
+					[ '11', { rowspan: 2, contents: '12' }, '13', '' ],
+					[ '31', '32', '33' ]
+				] ) );
+		} );
+
+		it( 'should insert empty table cell (remove row vs change table headers on row with rowspanned cell)', () => {
+			_testExternal(
+				modelTable( [
+					[ '11', { rowspan: 2, contents: '12' }, '13' ],
+					[ '21', '23' ],
+					[ '31', '32', '33' ]
+				] ),
+				writer => {
+					_removeRow( writer, 1 );
+				},
+				writer => {
+					_setAttribute( writer, 'headingRows', 1, [ 0 ] );
+					_removeAttribute( writer, 'rowspan', [ 0, 0, 1 ] );
+				},
+				formattedModelTable( [
+					[ '11', '12', '13', '' ],
+					[ '31', '32', '33', '' ]
+				], { headingRows: 1 } ),
+				formattedModelTable( [
+					[ '11', '12', '13', '' ],
+					[ '21', '23', '', '' ],
+					[ '31', '32', '33', '' ]
+				], { headingRows: 1 } ) );
+		} );
+
+		function _testExternal( initialData, localCallback, externalCallback, modelAfter, modelAfterUndo ) {
+			setModelData( model, initialData );
+
+			model.change( localCallback );
+
+			model.enqueueChange( 'transparent', externalCallback );
+
+			expect( formatTable( getModelData( model, { withoutSelection: true } ) ), 'after operations' ).to.equal( modelAfter );
+
+			editor.execute( 'undo' );
+
+			expect( formatTable( getModelData( model, { withoutSelection: true } ) ), 'after undo' ).to.equal( modelAfterUndo );
+
+			editor.execute( 'redo' );
+
+			expect( formatTable( getModelData( model, { withoutSelection: true } ) ), 'after redo' ).to.equal( modelAfter );
+		}
+
+		function _removeColumn( writer, columnIndex, rows ) {
+			const table = root.getChild( 0 );
+
+			for ( const index of rows ) {
+				const tableRow = table.getChild( index );
+				const tableCell = tableRow.getChild( columnIndex );
+
+				writer.remove( tableCell );
+			}
+		}
+
+		function _removeRow( writer, rowIndex ) {
+			const table = root.getChild( 0 );
+			const tableRow = table.getChild( rowIndex );
+
+			writer.remove( tableRow );
+		}
+
+		function _insertRow( writer, rowIndex, rowData ) {
+			const table = root.getChild( 0 );
+
+			const parsedTable = parse(
+				modelTable( [ rowData ] ),
+				model.schema
+			);
+
+			writer.insert( parsedTable.getChild( 0 ), table, rowIndex );
+		}
+
+		function _insertCell( writer, rowIndex, index ) {
+			const table = root.getChild( 0 );
+			const tableRow = table.getChild( rowIndex );
+
+			writer.insertElement( 'tableCell', tableRow, index );
+		}
+
+		function _setAttribute( writer, attributeKey, attributeValue, path ) {
+			const node = root.getNodeByPath( path );
+
+			writer.setAttribute( attributeKey, attributeValue, node );
+		}
+
+		function _removeAttribute( writer, attributeKey, path ) {
+			const node = root.getNodeByPath( path );
+
+			writer.removeAttribute( attributeKey, node );
+		}
+
+		function _insertColumn( writer, columnIndex, rows ) {
+			const table = root.getChild( 0 );
+
+			for ( const index of rows ) {
+				const tableRow = table.getChild( index );
+				const tableCell = writer.createElement( 'tableCell' );
+
+				writer.insert( tableCell, tableRow, columnIndex );
+			}
+		}
+	} );
+} );