Przeglądaj źródła

Merge pull request #10 from ckeditor/t/9

Feature: Initial table support. Closes #4. Closes #7. Closes #9.
Piotrek Koszuliński 7 lat temu
rodzic
commit
089f055d74
38 zmienionych plików z 8462 dodań i 0 usunięć
  1. 11 0
      packages/ckeditor5-table/package.json
  2. 67 0
      packages/ckeditor5-table/src/commands/insertcolumncommand.js
  3. 67 0
      packages/ckeditor5-table/src/commands/insertrowcommand.js
  4. 60 0
      packages/ckeditor5-table/src/commands/inserttablecommand.js
  5. 170 0
      packages/ckeditor5-table/src/commands/mergecellcommand.js
  6. 75 0
      packages/ckeditor5-table/src/commands/removecolumncommand.js
  7. 92 0
      packages/ckeditor5-table/src/commands/removerowcommand.js
  8. 143 0
      packages/ckeditor5-table/src/commands/settableheaderscommand.js
  9. 71 0
      packages/ckeditor5-table/src/commands/splitcellcommand.js
  10. 43 0
      packages/ckeditor5-table/src/commands/utils.js
  11. 494 0
      packages/ckeditor5-table/src/converters/downcast.js
  12. 182 0
      packages/ckeditor5-table/src/converters/upcasttable.js
  13. 35 0
      packages/ckeditor5-table/src/table.js
  14. 235 0
      packages/ckeditor5-table/src/tableediting.js
  15. 89 0
      packages/ckeditor5-table/src/tableui.js
  16. 593 0
      packages/ckeditor5-table/src/tableutils.js
  17. 397 0
      packages/ckeditor5-table/src/tablewalker.js
  18. 121 0
      packages/ckeditor5-table/tests/_utils/utils.js
  19. 305 0
      packages/ckeditor5-table/tests/commands/insertcolumncommand.js
  20. 302 0
      packages/ckeditor5-table/tests/commands/insertrowcommand.js
  21. 139 0
      packages/ckeditor5-table/tests/commands/inserttablecommand.js
  22. 518 0
      packages/ckeditor5-table/tests/commands/mergecellcommand.js
  23. 196 0
      packages/ckeditor5-table/tests/commands/removecolumncommand.js
  24. 186 0
      packages/ckeditor5-table/tests/commands/removerowcommand.js
  25. 240 0
      packages/ckeditor5-table/tests/commands/settableheaderscommand.js
  26. 227 0
      packages/ckeditor5-table/tests/commands/splitcellcommand.js
  27. 80 0
      packages/ckeditor5-table/tests/commands/utils.js
  28. 1226 0
      packages/ckeditor5-table/tests/converters/downcast.js
  29. 309 0
      packages/ckeditor5-table/tests/converters/upcasttable.js
  30. 217 0
      packages/ckeditor5-table/tests/manual/table.html
  31. 25 0
      packages/ckeditor5-table/tests/manual/table.js
  32. 3 0
      packages/ckeditor5-table/tests/manual/table.md
  33. 19 0
      packages/ckeditor5-table/tests/table.js
  34. 342 0
      packages/ckeditor5-table/tests/tableediting.js
  35. 153 0
      packages/ckeditor5-table/tests/tableui.js
  36. 750 0
      packages/ckeditor5-table/tests/tableutils.js
  37. 254 0
      packages/ckeditor5-table/tests/tablewalker.js
  38. 26 0
      packages/ckeditor5-table/theme/table.css

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

@@ -7,8 +7,19 @@
     "ckeditor5-feature"
     "ckeditor5-feature"
   ],
   ],
   "dependencies": {
   "dependencies": {
+    "@ckeditor/ckeditor5-core": "^10.0.0",
+    "@ckeditor/ckeditor5-engine": "^10.0.0",
+    "@ckeditor/ckeditor5-ui": "^10.0.0",
+    "@ckeditor/ckeditor5-widget": "^10.0.0"
   },
   },
   "devDependencies": {
   "devDependencies": {
+    "@ckeditor/ckeditor5-editor-classic": "^10.0.0",
+    "@ckeditor/ckeditor5-paragraph": "^10.0.0",
+    "@ckeditor/ckeditor5-utils": "^10.0.0",
+    "eslint": "^4.15.0",
+    "eslint-config-ckeditor5": "^1.0.7",
+    "husky": "^0.14.3",
+    "lint-staged": "^7.0.0"
   },
   },
   "engines": {
   "engines": {
     "node": ">=6.0.0",
     "node": ">=6.0.0",

+ 67 - 0
packages/ckeditor5-table/src/commands/insertcolumncommand.js

@@ -0,0 +1,67 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module table/commands/insertcolumncommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+import { getParentTable } from './utils';
+import TableUtils from '../tableutils';
+
+/**
+ * The insert column command.
+ *
+ * @extends module:core/command~Command
+ */
+export default class InsertColumnCommand extends Command {
+	/**
+	 * Creates a new `InsertRowCommand` instance.
+	 *
+	 * @param {module:core/editor/editor~Editor} editor Editor on which this command will be used.
+	 * @param {Object} options
+	 * @param {String} [options.order="after"] The order of insertion relative to a column in which caret is located.
+	 * Possible values: "after" and "before".
+	 */
+	constructor( editor, options = {} ) {
+		super( editor );
+
+		/**
+		 * The order of insertion relative to a column in which caret is located.
+		 *
+		 * @readonly
+		 * @member {String} module:table/commands/insertcolumncommand~InsertColumnCommand#order
+		 */
+		this.order = options.order || 'after';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	refresh() {
+		const selection = this.editor.model.document.selection;
+
+		const tableParent = getParentTable( selection.getFirstPosition() );
+
+		this.isEnabled = !!tableParent;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	execute() {
+		const editor = this.editor;
+		const selection = editor.model.document.selection;
+		const tableUtils = editor.plugins.get( TableUtils );
+
+		const table = getParentTable( selection.getFirstPosition() );
+		const tableCell = selection.getFirstPosition().parent;
+
+		const { column } = tableUtils.getCellLocation( tableCell );
+		const insertAt = this.order === 'after' ? column + 1 : column;
+
+		tableUtils.insertColumns( table, { columns: 1, at: insertAt } );
+	}
+}

+ 67 - 0
packages/ckeditor5-table/src/commands/insertrowcommand.js

@@ -0,0 +1,67 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module table/commands/insertrowcommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+import { getParentTable } from './utils';
+import TableUtils from '../tableutils';
+
+/**
+ * The insert row command.
+ *
+ * @extends module:core/command~Command
+ */
+export default class InsertRowCommand extends Command {
+	/**
+	 * Creates a new `InsertRowCommand` instance.
+	 *
+	 * @param {module:core/editor/editor~Editor} editor Editor on which this command will be used.
+	 * @param {Object} options
+	 * @param {String} [options.order="below"] The order of insertion relative to a row in which caret is located.
+	 * Possible values: "above" and "below".
+	 */
+	constructor( editor, options = {} ) {
+		super( editor );
+
+		/**
+		 * The order of insertion relative to a row in which caret is located.
+		 *
+		 * @readonly
+		 * @member {String} module:table/commands/insertrowcommand~InsertRowCommand#order
+		 */
+		this.order = options.order || 'below';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	refresh() {
+		const selection = this.editor.model.document.selection;
+
+		const tableParent = getParentTable( selection.getFirstPosition() );
+
+		this.isEnabled = !!tableParent;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	execute() {
+		const editor = this.editor;
+		const selection = editor.model.document.selection;
+		const tableUtils = editor.plugins.get( TableUtils );
+
+		const tableCell = selection.getFirstPosition().parent;
+		const table = getParentTable( selection.getFirstPosition() );
+
+		const row = table.getChildIndex( tableCell.parent );
+		const insertAt = this.order === 'below' ? row + 1 : row;
+
+		tableUtils.insertRows( table, { rows: 1, at: insertAt } );
+	}
+}

+ 60 - 0
packages/ckeditor5-table/src/commands/inserttablecommand.js

@@ -0,0 +1,60 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module table/commands/inserttablecommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+import Position from '@ckeditor/ckeditor5-engine/src/model/position';
+import TableUtils from '../tableutils';
+
+/**
+ * The insert table command.
+ *
+ * @extends module:core/command~Command
+ */
+export default class InsertTableCommand extends Command {
+	/**
+	 * @inheritDoc
+	 */
+	refresh() {
+		const model = this.editor.model;
+		const selection = model.document.selection;
+		const schema = model.schema;
+
+		const validParent = getInsertTableParent( selection.getFirstPosition() );
+
+		this.isEnabled = schema.checkChild( validParent, 'table' );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	execute( options = {} ) {
+		const model = this.editor.model;
+		const selection = model.document.selection;
+		const tableUtils = this.editor.plugins.get( TableUtils );
+
+		const rows = parseInt( options.rows ) || 2;
+		const columns = parseInt( options.columns ) || 2;
+
+		const firstPosition = selection.getFirstPosition();
+
+		const isRoot = firstPosition.parent === firstPosition.root;
+		const insertPosition = isRoot ? Position.createAt( firstPosition ) : Position.createAfter( firstPosition.parent );
+
+		tableUtils.createTable( insertPosition, rows, columns );
+	}
+}
+
+// Returns valid parent to insert table
+//
+// @param {module:engine/model/position} position
+function getInsertTableParent( position ) {
+	const parent = position.parent;
+
+	return parent === parent.root ? parent : parent.parent;
+}

+ 170 - 0
packages/ckeditor5-table/src/commands/mergecellcommand.js

@@ -0,0 +1,170 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module table/commands/mergecellcommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+import Position from '@ckeditor/ckeditor5-engine/src/model/position';
+import Range from '@ckeditor/ckeditor5-engine/src/model/range';
+import TableWalker from '../tablewalker';
+
+/**
+ * The merge cell command.
+ *
+ * @extends module:core/command~Command
+ */
+export default class MergeCellCommand extends Command {
+	/**
+	 * Creates a new `MergeCellCommand` instance.
+	 *
+	 * @param {module:core/editor/editor~Editor} editor Editor on which this command will be used.
+	 * @param {Object} options
+	 * @param {String} options.direction Indicates which cell merge to currently selected one.
+	 * Possible values are: "left", "right", "up" and "down".
+	 */
+	constructor( editor, options ) {
+		super( editor );
+
+		/**
+		 * The direction indicates which cell will be merged to currently selected one.
+		 *
+		 * @readonly
+		 * @member {String} #direction
+		 */
+		this.direction = options.direction;
+
+		/**
+		 * Whether the merge is horizontal (left/right) or vertical (up/down).
+		 *
+		 * @readonly
+		 * @member {Boolean} #isHorizontal
+		 */
+		this.isHorizontal = this.direction == 'right' || this.direction == 'left';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	refresh() {
+		const cellToMerge = this._getMergeableCell();
+
+		this.isEnabled = !!cellToMerge;
+		// In order to check if currently selected cell can be merged with one defined by #direction some computation are done beforehand.
+		// As such we can cache it as a command's value.
+		this.value = cellToMerge;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	execute() {
+		const model = this.editor.model;
+		const doc = model.document;
+		const tableCell = doc.selection.getFirstPosition().parent;
+		const cellToMerge = this.value;
+		const direction = this.direction;
+
+		model.change( writer => {
+			const isMergeNext = direction == 'right' || direction == 'down';
+
+			// The merge mechanism is always the same so sort cells to be merged.
+			const cellToExpand = isMergeNext ? tableCell : cellToMerge;
+			const cellToRemove = isMergeNext ? cellToMerge : tableCell;
+
+			writer.move( Range.createIn( cellToRemove ), Position.createAt( cellToExpand, 'end' ) );
+			writer.remove( cellToRemove );
+
+			const spanAttribute = this.isHorizontal ? 'colspan' : 'rowspan';
+			const cellSpan = parseInt( tableCell.getAttribute( spanAttribute ) || 1 );
+			const cellToMergeSpan = parseInt( cellToMerge.getAttribute( spanAttribute ) || 1 );
+
+			writer.setAttribute( spanAttribute, cellSpan + cellToMergeSpan, cellToExpand );
+
+			writer.setSelection( Range.createIn( cellToExpand ) );
+		} );
+	}
+
+	/**
+	 * Returns a cell that is mergeable with current cell depending on command's direction.
+	 *
+	 * @returns {module:engine/model/element|undefined}
+	 * @private
+	 */
+	_getMergeableCell() {
+		const model = this.editor.model;
+		const doc = model.document;
+		const element = doc.selection.getFirstPosition().parent;
+
+		if ( !element.is( 'tableCell' ) ) {
+			return;
+		}
+
+		// First get the cell on proper direction.
+		const cellToMerge = this.isHorizontal ? getHorizontalCell( element, this.direction ) : getVerticalCell( element, this.direction );
+
+		if ( !cellToMerge ) {
+			return;
+		}
+
+		// If found check if the span perpendicular to merge direction is equal on both cells.
+		const spanAttribute = this.isHorizontal ? 'rowspan' : 'colspan';
+		const span = parseInt( element.getAttribute( spanAttribute ) || 1 );
+
+		const cellToMergeSpan = parseInt( cellToMerge.getAttribute( spanAttribute ) || 1 );
+
+		if ( cellToMergeSpan === span ) {
+			return cellToMerge;
+		}
+	}
+}
+
+// Returns horizontally mergeable cell.
+//
+// @param {module:engine/model/element~Element} tableCell
+// @param {String} direction
+// @returns {module:engine/model/node~Node|null}
+function getHorizontalCell( tableCell, direction ) {
+	return direction == 'right' ? tableCell.nextSibling : tableCell.previousSibling;
+}
+
+// Returns vertically mergeable cell.
+//
+// @param {module:engine/model/element~Element} tableCell
+// @param {String} direction
+// @returns {module:engine/model/node~Node|null}
+function getVerticalCell( tableCell, direction ) {
+	const tableRow = tableCell.parent;
+	const table = tableRow.parent;
+
+	const rowIndex = table.getChildIndex( tableRow );
+
+	// Don't search for mergeable cell if direction points out of the table.
+	if ( ( direction == 'down' && rowIndex === table.childCount - 1 ) || ( direction == 'up' && rowIndex === 0 ) ) {
+		return;
+	}
+
+	const headingRows = table.getAttribute( 'headingRows' ) || 0;
+
+	// Don't search for mergeable cell if direction points out of the current table section.
+	if ( headingRows && ( ( direction == 'down' && rowIndex === headingRows - 1 ) || ( direction == 'up' && rowIndex === headingRows ) ) ) {
+		return;
+	}
+
+	const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
+	const mergeRow = direction == 'down' ? rowIndex + rowspan : rowIndex;
+
+	const tableMap = [ ...new TableWalker( table, { endRow: mergeRow } ) ];
+
+	const currentCellData = tableMap.find( value => value.cell === tableCell );
+	const mergeColumn = currentCellData.column;
+
+	const cellToMergeData = tableMap.find( ( { row, column } ) => {
+		return column === mergeColumn && ( direction == 'down' ? mergeRow === row : mergeRow === rowspan + row );
+	} );
+
+	return cellToMergeData && cellToMergeData.cell;
+}

+ 75 - 0
packages/ckeditor5-table/src/commands/removecolumncommand.js

@@ -0,0 +1,75 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module table/commands/removecolumncommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+
+import TableWalker from '../tablewalker';
+import TableUtils from '../tableutils';
+import { updateNumericAttribute } from './utils';
+
+/**
+ * The split cell command.
+ *
+ * @extends module:core/command~Command
+ */
+export default class RemoveColumnCommand extends Command {
+	/**
+	 * @inheritDoc
+	 */
+	refresh() {
+		const editor = this.editor;
+		const selection = editor.model.document.selection;
+		const tableUtils = editor.plugins.get( TableUtils );
+
+		const selectedElement = selection.getFirstPosition().parent;
+
+		this.isEnabled = selectedElement.is( 'tableCell' ) && tableUtils.getColumns( selectedElement.parent.parent ) > 1;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	execute() {
+		const model = this.editor.model;
+		const selection = model.document.selection;
+
+		const firstPosition = selection.getFirstPosition();
+
+		const tableCell = firstPosition.parent;
+		const tableRow = tableCell.parent;
+		const table = tableRow.parent;
+
+		const headingColumns = table.getAttribute( 'headingColumns' ) || 0;
+		const row = table.getChildIndex( tableRow );
+
+		// Cache the table before removing or updating colspans.
+		const tableMap = [ ...new TableWalker( table ) ];
+
+		// Get column index of removed column.
+		const cellData = tableMap.find( value => value.cell === tableCell );
+		const removedColumn = cellData.column;
+
+		model.change( writer => {
+			// Update heading columns attribute if removing a row from head section.
+			if ( headingColumns && row <= headingColumns ) {
+				writer.setAttribute( 'headingColumns', headingColumns - 1, table );
+			}
+
+			for ( const { cell, column, colspan } of tableMap ) {
+				// If colspaned cell overlaps removed column decrease it's span.
+				if ( column <= removedColumn && colspan > 1 && column + colspan > removedColumn ) {
+					updateNumericAttribute( 'colspan', colspan - 1, cell, writer );
+				} else if ( column === removedColumn ) {
+					// The cell in removed column has colspan of 1.
+					writer.remove( cell );
+				}
+			}
+		} );
+	}
+}

+ 92 - 0
packages/ckeditor5-table/src/commands/removerowcommand.js

@@ -0,0 +1,92 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module table/commands/removerowcommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+import Position from '@ckeditor/ckeditor5-engine/src/model/position';
+import Range from '@ckeditor/ckeditor5-engine/src/model/range';
+
+import TableWalker from '../tablewalker';
+import { updateNumericAttribute } from './utils';
+
+/**
+ * The remove row command.
+ *
+ * @extends module:core/command~Command
+ */
+export default class RemoveRowCommand extends Command {
+	/**
+	 * @inheritDoc
+	 */
+	refresh() {
+		const model = this.editor.model;
+		const doc = model.document;
+
+		const element = doc.selection.getFirstPosition().parent;
+
+		this.isEnabled = element.is( 'tableCell' ) && element.parent.parent.childCount > 1;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	execute() {
+		const model = this.editor.model;
+		const selection = model.document.selection;
+
+		const firstPosition = selection.getFirstPosition();
+		const tableCell = firstPosition.parent;
+		const tableRow = tableCell.parent;
+		const table = tableRow.parent;
+
+		const currentRow = table.getChildIndex( tableRow );
+		const headingRows = table.getAttribute( 'headingRows' ) || 0;
+
+		model.change( writer => {
+			if ( headingRows && currentRow <= headingRows ) {
+				updateNumericAttribute( 'headingRows', headingRows - 1, table, writer, 0 );
+			}
+
+			const tableMap = [ ...new TableWalker( table, { endRow: currentRow } ) ];
+
+			const cellsToMove = new Map();
+
+			// Get cells from removed row that are spanned over multiple rows.
+			tableMap
+				.filter( ( { row, rowspan } ) => row === currentRow && 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 <= currentRow - 1 && row + rowspan > currentRow )
+				.forEach( ( { cell, rowspan } ) => updateNumericAttribute( 'rowspan', rowspan - 1, cell, writer ) );
+
+			// Move cells to another row.
+			const targetRow = currentRow + 1;
+			const tableWalker = new TableWalker( table, { includeSpanned: true, startRow: targetRow, endRow: targetRow } );
+
+			let previousCell;
+
+			for ( const { row, column, cell } of [ ...tableWalker ] ) {
+				if ( cellsToMove.has( column ) ) {
+					const { cell: cellToMove, rowspanToSet } = cellsToMove.get( column );
+					const targetPosition = previousCell ? Position.createAfter( previousCell ) : Position.createAt( table.getChild( row ) );
+
+					writer.move( Range.createOn( cellToMove ), targetPosition );
+					updateNumericAttribute( 'rowspan', rowspanToSet, cellToMove, writer );
+
+					previousCell = cellToMove;
+				} else {
+					previousCell = cell;
+				}
+			}
+
+			writer.remove( tableRow );
+		} );
+	}
+}

+ 143 - 0
packages/ckeditor5-table/src/commands/settableheaderscommand.js

@@ -0,0 +1,143 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module table/commands/settableheaderscommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+import Position from '@ckeditor/ckeditor5-engine/src/model/position';
+
+import { getParentTable, updateNumericAttribute } from './utils';
+import TableWalker from '../tablewalker';
+
+/**
+ * The set table headers command.
+ *
+ * @extends module:core/command~Command
+ */
+export default class SetTableHeadersCommand extends Command {
+	/**
+	 * @inheritDoc
+	 */
+	refresh() {
+		const model = this.editor.model;
+		const doc = model.document;
+		const selection = doc.selection;
+
+		const tableParent = getParentTable( selection.getFirstPosition() );
+
+		this.isEnabled = !!tableParent;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	execute( options = {} ) {
+		const model = this.editor.model;
+		const doc = model.document;
+		const selection = doc.selection;
+
+		const rowsToSet = parseInt( options.rows ) || 0;
+
+		const table = getParentTable( selection.getFirstPosition() );
+
+		model.change( writer => {
+			const currentHeadingRows = table.getAttribute( 'headingRows' ) || 0;
+
+			if ( currentHeadingRows !== rowsToSet && rowsToSet > 0 ) {
+				// Changing heading rows requires to check if any of a heading cell is overlaping 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, rowsToSet, currentHeadingRows );
+
+				for ( const cell of cellsToSplit ) {
+					splitHorizontally( cell, rowsToSet, writer );
+				}
+			}
+
+			const columnsToSet = parseInt( options.columns ) || 0;
+			updateTableAttribute( table, 'headingColumns', columnsToSet, writer );
+			updateTableAttribute( table, 'headingRows', rowsToSet, writer );
+		} );
+	}
+}
+
+// Returns cells that span beyond new heading section.
+//
+// @param {module:engine/model/element~Element} table 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;
+
+	const tableWalker = new TableWalker( table, { startRow: startAnalysisRow, endRow: headingRowsToSet } );
+
+	for ( const { row, rowspan, cell } of tableWalker ) {
+		if ( rowspan > 1 && row + rowspan > headingRowsToSet ) {
+			cellsToSplit.push( cell );
+		}
+	}
+
+	return cellsToSplit;
+}
+
+// @private
+function updateTableAttribute( table, attributeName, newValue, writer ) {
+	const currentValue = table.getAttribute( attributeName ) || 0;
+
+	if ( newValue !== currentValue ) {
+		updateNumericAttribute( attributeName, newValue, table, writer, 0 );
+	}
+}
+
+// Splits 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 startRow = table.getChildIndex( tableRow );
+	const endRow = startRow + newRowspan;
+	const tableMap = [ ...new TableWalker( table, { startRow, endRow, includeSpanned: true } ) ];
+
+	let columnIndex;
+
+	for ( const { row, column, cell, colspan, cellIndex } of tableMap ) {
+		if ( cell === tableCell ) {
+			columnIndex = column;
+
+			if ( colspan > 1 ) {
+				attributes.colspan = colspan;
+			}
+		}
+
+		if ( columnIndex !== undefined && columnIndex === column && row === endRow ) {
+			const tableRow = table.getChild( row );
+
+			writer.insertElement( 'tableCell', attributes, Position.createFromParentAndOffset( tableRow, cellIndex ) );
+		}
+	}
+
+	// Update the rowspan attribute after updating table.
+	updateNumericAttribute( 'rowspan', newRowspan, tableCell, writer );
+}

+ 71 - 0
packages/ckeditor5-table/src/commands/splitcellcommand.js

@@ -0,0 +1,71 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module table/commands/splitcellcommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+import TableUtils from '../tableutils';
+
+/**
+ * The split cell command.
+ *
+ * @extends module:core/command~Command
+ */
+export default class SplitCellCommand extends Command {
+	/**
+	 * Creates a new `SplitCellCommand` instance.
+	 *
+	 * @param {module:core/editor/editor~Editor} editor Editor on which this command will be used.
+	 * @param {Object} options
+	 * @param {String} options.direction Indicates whether the command should split cells `'horizontally'` or `'vertically'`.
+	 */
+	constructor( editor, options = {} ) {
+		super( editor );
+
+		/**
+		 * The direction indicates which cell will be split.
+		 *
+		 * @readonly
+		 * @member {String} #direction
+		 */
+		this.direction = options.direction || 'horizontally';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	refresh() {
+		const model = this.editor.model;
+		const doc = model.document;
+
+		const element = doc.selection.getFirstPosition().parent;
+
+		this.isEnabled = element.is( 'tableCell' );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	execute() {
+		const model = this.editor.model;
+		const document = model.document;
+		const selection = document.selection;
+
+		const firstPosition = selection.getFirstPosition();
+		const tableCell = firstPosition.parent;
+
+		const isHorizontally = this.direction === 'horizontally';
+
+		const tableUtils = this.editor.plugins.get( TableUtils );
+
+		if ( isHorizontally ) {
+			tableUtils.splitCellHorizontally( tableCell, 2 );
+		} else {
+			tableUtils.splitCellVertically( tableCell, 2 );
+		}
+	}
+}

+ 43 - 0
packages/ckeditor5-table/src/commands/utils.js

@@ -0,0 +1,43 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module table/commands/utils
+ */
+
+/**
+ * Returns parent table.
+ *
+ * @param {module:engine/model/position~Position} position
+ * @returns {module:engine/model/element~Element|module:engine/model/documentfragment~DocumentFragment}
+ */
+export function getParentTable( position ) {
+	let parent = position.parent;
+
+	while ( parent ) {
+		if ( parent.name === 'table' ) {
+			return parent;
+		}
+
+		parent = parent.parent;
+	}
+}
+
+/**
+ * Common method to update numeric value. If value is default one it will be unset.
+ *
+ * @param {String} key Attribute key.
+ * @param {*} value Attribute new value.
+ * @param {module:engine/model/item~Item} item Model item on which the attribute will be set.
+ * @param {module:engine/model/writer~Writer} writer
+ * @param {*} defaultValue Default attribute value if value is lower or equal then it will be unset.
+ */
+export function updateNumericAttribute( key, value, item, writer, defaultValue = 1 ) {
+	if ( value > defaultValue ) {
+		writer.setAttribute( key, value, item );
+	} else {
+		writer.removeAttribute( key, item );
+	}
+}

+ 494 - 0
packages/ckeditor5-table/src/converters/downcast.js

@@ -0,0 +1,494 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module table/converters/downcast
+ */
+
+import ViewPosition from '@ckeditor/ckeditor5-engine/src/view/position';
+import ViewRange from '@ckeditor/ckeditor5-engine/src/view/range';
+import TableWalker from './../tablewalker';
+import { toWidget, toWidgetEditable } from '@ckeditor/ckeditor5-widget/src/utils';
+
+/**
+ * Model table element to view table element conversion helper.
+ *
+ * This conversion helper creates whole table element with child elements.
+ *
+ * @param {Object} options
+ * @param {Boolean} options.asWidget If set to true the downcast conversion will produce widget.
+ * @returns {Function} Conversion helper.
+ */
+export function downcastInsertTable( options = {} ) {
+	return dispatcher => dispatcher.on( 'insert:table', ( evt, data, conversionApi ) => {
+		const table = data.item;
+
+		if ( !conversionApi.consumable.consume( table, 'insert' ) ) {
+			return;
+		}
+
+		// Consume attributes if present to not fire attribute change downcast
+		conversionApi.consumable.consume( table, 'attribute:headingRows:table' );
+		conversionApi.consumable.consume( table, 'attribute:headingColumns:table' );
+
+		const asWidget = options && options.asWidget;
+
+		const tableElement = conversionApi.writer.createContainerElement( 'table' );
+
+		let tableWidget;
+
+		if ( asWidget ) {
+			tableWidget = toWidget( tableElement, conversionApi.writer );
+		}
+
+		const tableWalker = new TableWalker( table );
+
+		const tableAttributes = {
+			headingRows: table.getAttribute( 'headingRows' ) || 0,
+			headingColumns: table.getAttribute( 'headingColumns' ) || 0
+		};
+
+		for ( const tableWalkerValue of tableWalker ) {
+			const { row, cell } = tableWalkerValue;
+
+			const tableSection = getOrCreateTableSection( getSectionName( row, tableAttributes ), tableElement, conversionApi );
+			const tableRow = table.getChild( row );
+
+			// Check if row was converted
+			const trElement = getOrCreateTr( tableRow, row, tableSection, conversionApi );
+
+			// Consume table cell - it will be always consumed as we convert whole table at once.
+			conversionApi.consumable.consume( cell, 'insert' );
+
+			const insertPosition = ViewPosition.createAt( trElement, 'end' );
+
+			createViewTableCellElement( tableWalkerValue, tableAttributes, insertPosition, conversionApi, options );
+		}
+
+		const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
+
+		conversionApi.mapper.bindElements( table, asWidget ? tableWidget : tableElement );
+		conversionApi.writer.insert( viewPosition, asWidget ? tableWidget : tableElement );
+	}, { priority: 'normal' } );
+}
+
+/**
+ * Model row element to view <tr> element conversion helper.
+ *
+ * This conversion helper creates whole <tr> element with child elements.
+ *
+ * @returns {Function} Conversion helper.
+ */
+export function downcastInsertRow( options = {} ) {
+	return dispatcher => dispatcher.on( 'insert:tableRow', ( evt, data, conversionApi ) => {
+		const tableRow = data.item;
+
+		if ( !conversionApi.consumable.consume( tableRow, 'insert' ) ) {
+			return;
+		}
+
+		const table = tableRow.parent;
+
+		const tableElement = conversionApi.mapper.toViewElement( table );
+
+		const row = table.getChildIndex( tableRow );
+
+		const tableWalker = new TableWalker( table, { startRow: row, endRow: row } );
+
+		const tableAttributes = {
+			headingRows: table.getAttribute( 'headingRows' ) || 0,
+			headingColumns: table.getAttribute( 'headingColumns' ) || 0
+		};
+
+		for ( const tableWalkerValue of tableWalker ) {
+			const tableSection = getOrCreateTableSection( getSectionName( row, tableAttributes ), tableElement, conversionApi );
+			const trElement = getOrCreateTr( tableRow, row, tableSection, conversionApi );
+
+			// Consume table cell - it will be always consumed as we convert whole row at once.
+			conversionApi.consumable.consume( tableWalkerValue.cell, 'insert' );
+
+			const insertPosition = ViewPosition.createAt( trElement, 'end' );
+
+			createViewTableCellElement( tableWalkerValue, tableAttributes, insertPosition, conversionApi, options );
+		}
+	}, { priority: 'normal' } );
+}
+
+/**
+ * Model tableCEll element to view <td> or <th> element conversion helper.
+ *
+ * This conversion helper will create proper <th> elements for tableCells that are in heading section (heading row or column)
+ * and <td> otherwise.
+ *
+ * @returns {Function} Conversion helper.
+ */
+export function downcastInsertCell( options = {} ) {
+	return dispatcher => dispatcher.on( 'insert:tableCell', ( evt, data, conversionApi ) => {
+		const tableCell = data.item;
+
+		if ( !conversionApi.consumable.consume( tableCell, 'insert' ) ) {
+			return;
+		}
+
+		const tableRow = tableCell.parent;
+		const table = tableRow.parent;
+		const rowIndex = table.getChildIndex( tableRow );
+
+		const tableWalker = new TableWalker( table, { startRow: rowIndex, endRow: rowIndex } );
+
+		const tableAttributes = {
+			headingRows: table.getAttribute( 'headingRows' ) || 0,
+			headingColumns: table.getAttribute( 'headingColumns' ) || 0
+		};
+
+		// We need to iterate over a table in order to get proper row & column values from a walker
+		for ( const tableWalkerValue of tableWalker ) {
+			if ( tableWalkerValue.cell === tableCell ) {
+				const trElement = conversionApi.mapper.toViewElement( tableRow );
+				const insertPosition = ViewPosition.createAt( trElement, tableRow.getChildIndex( tableCell ) );
+
+				createViewTableCellElement( tableWalkerValue, tableAttributes, insertPosition, conversionApi, options );
+
+				// No need to iterate further.
+				return;
+			}
+		}
+	}, { priority: 'normal' } );
+}
+
+/**
+ * Conversion helper that acts on headingRows table attribute change.
+ *
+ * This converter will:
+ *
+ * * rename <td> to <th> elements or vice versa depending on headings,
+ * * create <thead> or <tbody> elements if needed,
+ * * remove empty <thead> or <tbody> if needed.
+ *
+ * @returns {Function} Conversion helper.
+ */
+export function downcastTableHeadingRowsChange( options = {} ) {
+	const asWidget = !!options.asWidget;
+
+	return dispatcher => dispatcher.on( 'attribute:headingRows:table', ( evt, data, conversionApi ) => {
+		const table = data.item;
+
+		if ( !conversionApi.consumable.consume( data.item, evt.name ) ) {
+			return;
+		}
+
+		const viewTable = conversionApi.mapper.toViewElement( table );
+
+		const oldRows = data.attributeOldValue;
+		const newRows = data.attributeNewValue;
+
+		// The head section has grown so move rows from <tbody> to <thead>.
+		if ( newRows > oldRows ) {
+			// Filter out only those rows that are in wrong section.
+			const rowsToMove = Array.from( table.getChildren() ).filter( ( { index } ) => isBetween( index, oldRows - 1, newRows ) );
+
+			const viewTableHead = getOrCreateTableSection( 'thead', viewTable, conversionApi );
+			moveViewRowsToTableSection( rowsToMove, viewTableHead, conversionApi, 'end' );
+
+			// Rename all table cells from moved rows to 'th' as they lands in <thead>.
+			for ( const tableRow of rowsToMove ) {
+				for ( const tableCell of tableRow.getChildren() ) {
+					renameViewTableCell( tableCell, 'th', conversionApi, asWidget );
+				}
+			}
+
+			// Cleanup: this will remove any empty section from the view which may happen when moving all rows from a table section.
+			removeTableSectionIfEmpty( 'tbody', viewTable, conversionApi );
+		}
+		// The head section has shrunk so move rows from <thead> to <tbody>.
+		else {
+			// Filter out only those rows that are in wrong section.
+			const rowsToMove = Array.from( table.getChildren() )
+				.filter( ( { index } ) => isBetween( index, newRows - 1, oldRows ) )
+				.reverse(); // The rows will be moved from <thead> to <tbody> in reverse order at the beginning of a <tbody>.
+
+			const viewTableBody = getOrCreateTableSection( 'tbody', viewTable, conversionApi );
+			moveViewRowsToTableSection( rowsToMove, viewTableBody, conversionApi );
+
+			// Check if cells moved from <thead> to <tbody> requires renaming to <td> as this depends on current heading columns attribute.
+			const tableWalker = new TableWalker( table, { startRow: newRows ? newRows - 1 : newRows, endRow: oldRows - 1 } );
+
+			const tableAttributes = {
+				headingRows: table.getAttribute( 'headingRows' ) || 0,
+				headingColumns: table.getAttribute( 'headingColumns' ) || 0
+			};
+
+			for ( const tableWalkerValue of tableWalker ) {
+				renameViewTableCellIfRequired( tableWalkerValue, tableAttributes, conversionApi, asWidget );
+			}
+
+			// Cleanup: this will remove any empty section from the view which may happen when moving all rows from a table section.
+			removeTableSectionIfEmpty( 'thead', viewTable, conversionApi );
+		}
+
+		function isBetween( index, lower, upper ) {
+			return index > lower && index < upper;
+		}
+	}, { priority: 'normal' } );
+}
+
+/**
+ * Conversion helper that acts on headingColumns table attribute change.
+ *
+ * Depending on changed attributes this converter will rename <td> to <th> elements or vice versa depending of cell column index.
+ *
+ * @returns {Function} Conversion helper.
+ */
+export function downcastTableHeadingColumnsChange( options = {} ) {
+	const asWidget = !!options.asWidget;
+
+	return dispatcher => dispatcher.on( 'attribute:headingColumns:table', ( evt, data, conversionApi ) => {
+		const table = data.item;
+
+		if ( !conversionApi.consumable.consume( data.item, evt.name ) ) {
+			return;
+		}
+
+		const tableAttributes = {
+			headingRows: table.getAttribute( 'headingRows' ) || 0,
+			headingColumns: table.getAttribute( 'headingColumns' ) || 0
+		};
+
+		const oldColumns = data.attributeOldValue;
+		const newColumns = data.attributeNewValue;
+
+		const lastColumnToCheck = ( oldColumns > newColumns ? oldColumns : newColumns ) - 1;
+
+		for ( const tableWalkerValue of new TableWalker( table ) ) {
+			// Skip cells that were not in heading section before and after the change.
+			if ( tableWalkerValue.column > lastColumnToCheck ) {
+				continue;
+			}
+
+			renameViewTableCellIfRequired( tableWalkerValue, tableAttributes, conversionApi, asWidget );
+		}
+	}, { priority: 'normal' } );
+}
+
+/**
+ * Conversion helper that acts on removed row.
+ *
+ * @returns {Function} Conversion helper.
+ */
+export function downcastRemoveRow() {
+	return dispatcher => dispatcher.on( 'remove:tableRow', ( evt, data, conversionApi ) => {
+		// Prevent default remove converter.
+		evt.stop();
+
+		const viewStart = conversionApi.mapper.toViewPosition( data.position ).getLastMatchingPosition( value => !value.item.is( 'tr' ) );
+		const viewItem = viewStart.nodeAfter;
+		const tableSection = viewItem.parent;
+
+		// Remove associated <tr> from the view.
+		const removeRange = ViewRange.createOn( viewItem );
+		const removed = conversionApi.writer.remove( removeRange );
+
+		for ( const child of ViewRange.createIn( removed ).getItems() ) {
+			conversionApi.mapper.unbindViewElement( child );
+		}
+
+		// Check if table section has any children left - if not remove it from the view.
+		if ( !tableSection.childCount ) {
+			// No need to unbind anything as table section is not represented in the model.
+			conversionApi.writer.remove( ViewRange.createOn( tableSection ) );
+		}
+	}, { priority: 'higher' } );
+}
+
+// Renames table cell in the view to given element name.
+//
+// @param {module:engine/model/element~Element} tableCell
+// @param {String} desiredCellElementName
+// @param {Object} conversionApi
+// @param {Boolean} asWidget
+function renameViewTableCell( tableCell, desiredCellElementName, conversionApi, asWidget ) {
+	const viewCell = conversionApi.mapper.toViewElement( tableCell );
+
+	let renamedCell;
+
+	if ( asWidget ) {
+		const editable = conversionApi.writer.createEditableElement( desiredCellElementName, viewCell.getAttributes() );
+		renamedCell = toWidgetEditable( editable, conversionApi.writer );
+
+		conversionApi.writer.insert( ViewPosition.createAfter( viewCell ), renamedCell );
+		conversionApi.writer.move( ViewRange.createIn( viewCell ), ViewPosition.createAt( renamedCell ) );
+		conversionApi.writer.remove( ViewRange.createOn( viewCell ) );
+	} else {
+		renamedCell = conversionApi.writer.rename( viewCell, desiredCellElementName );
+	}
+
+	conversionApi.mapper.bindElements( tableCell, renamedCell );
+}
+
+// Renames a table cell element in a view according to it's location in table.
+//
+// @param {module:table/tablewalker~TableWalkerValue} tableWalkerValue
+// @param {{headingColumns, headingRows}} tableAttributes
+// @param {Object} conversionApi
+// @param {Boolean} asWidget
+function renameViewTableCellIfRequired( tableWalkerValue, tableAttributes, conversionApi, asWidget ) {
+	const { cell } = tableWalkerValue;
+
+	// Check whether current columnIndex is overlapped by table cells from previous rows.
+	const desiredCellElementName = getCellElementName( tableWalkerValue, tableAttributes );
+
+	const viewCell = conversionApi.mapper.toViewElement( cell );
+
+	// If in single change we're converting attribute changes and inserting cell the table cell might not be inserted into view
+	// because of child conversion is done after parent.
+	if ( viewCell && viewCell.name !== desiredCellElementName ) {
+		renameViewTableCell( cell, desiredCellElementName, conversionApi, asWidget );
+	}
+}
+
+// Creates a table cell element in a view.
+//
+// @param {module:table/tablewalker~TableWalkerValue} tableWalkerValue
+// @param {module:engine/view/position~Position} insertPosition
+// @param {Object} conversionApi
+function createViewTableCellElement( tableWalkerValue, tableAttributes, insertPosition, conversionApi, options ) {
+	const asWidget = options && options.asWidget;
+	const cellElementName = getCellElementName( tableWalkerValue, tableAttributes );
+
+	const cellElement = asWidget ?
+		toWidgetEditable( conversionApi.writer.createEditableElement( cellElementName ), conversionApi.writer ) :
+		conversionApi.writer.createContainerElement( cellElementName );
+
+	const tableCell = tableWalkerValue.cell;
+
+	conversionApi.mapper.bindElements( tableCell, cellElement );
+	conversionApi.writer.insert( insertPosition, cellElement );
+}
+
+// Creates or returns an existing tr element from a view.
+//
+// @param {module:engine/view/element~Element} tableRow
+// @param {Number} rowIndex
+// @param {module:engine/view/element~Element} tableSection
+// @param {Object} conversionApi
+// @returns {module:engine/view/element~Element}
+function getOrCreateTr( tableRow, rowIndex, tableSection, conversionApi ) {
+	let trElement = conversionApi.mapper.toViewElement( tableRow );
+
+	if ( !trElement ) {
+		// Will always consume since we're converting <tableRow> element from a parent <table>.
+		conversionApi.consumable.consume( tableRow, 'insert' );
+
+		trElement = conversionApi.writer.createContainerElement( 'tr' );
+		conversionApi.mapper.bindElements( tableRow, trElement );
+
+		const headingRows = tableRow.parent.getAttribute( 'headingRows' ) || 0;
+		const offset = headingRows > 0 && rowIndex >= headingRows ? rowIndex - headingRows : rowIndex;
+
+		const position = ViewPosition.createAt( tableSection, offset );
+		conversionApi.writer.insert( position, trElement );
+	}
+
+	return trElement;
+}
+
+// Returns `th` for heading cells and `td` for other cells for current table walker value.
+//
+// @param {module:table/tablewalker~TableWalkerValue} tableWalkerValue
+// @param {{headingColumns, headingRows}} tableAttributes
+// @returns {String}
+function getCellElementName( tableWalkerValue, tableAttributes ) {
+	const { row, column } = tableWalkerValue;
+	const { headingColumns, headingRows } = tableAttributes;
+
+	// Column heading are all tableCells in the first `columnHeading` rows.
+	const isColumnHeading = headingRows && headingRows > row;
+
+	// So a whole row gets <th> element.
+	if ( isColumnHeading ) {
+		return 'th';
+	}
+
+	// Row heading are tableCells which columnIndex is lower then headingColumns.
+	const isRowHeading = headingColumns && headingColumns > column;
+
+	return isRowHeading ? 'th' : 'td';
+}
+
+// Returns table section name for current table walker value.
+//
+// @param {Number} row
+// @param {{headingColumns, headingRows}} tableAttributes
+// @returns {String}
+function getSectionName( row, tableAttributes ) {
+	return row < tableAttributes.headingRows ? 'thead' : 'tbody';
+}
+
+// Creates or returns an existing <tbody> or <thead> element witch caching.
+//
+// @param {String} sectionName
+// @param {module:engine/view/element~Element} viewTable
+// @param {Object} conversionApi
+// @param {Object} cachedTableSection An object on which store cached elements.
+// @returns {module:engine/view/containerelement~ContainerElement}
+function getOrCreateTableSection( sectionName, viewTable, conversionApi ) {
+	const viewTableSection = getExistingTableSectionElement( sectionName, viewTable );
+
+	return viewTableSection ? viewTableSection : createTableSection( sectionName, viewTable, conversionApi );
+}
+
+// Finds an existing <tbody> or <thead> element or returns undefined.
+//
+// @param {String} sectionName
+// @param {module:engine/view/element~Element} tableElement
+// @param {Object} conversionApi
+function getExistingTableSectionElement( sectionName, tableElement ) {
+	for ( const tableSection of tableElement.getChildren() ) {
+		if ( tableSection.name == sectionName ) {
+			return tableSection;
+		}
+	}
+}
+
+// Creates table section at the end of a table.
+//
+// @param {String} sectionName
+// @param {module:engine/view/element~Element} tableElement
+// @param {Object} conversionApi
+// @returns {module:engine/view/containerelement~ContainerElement}
+function createTableSection( sectionName, tableElement, conversionApi ) {
+	const tableChildElement = conversionApi.writer.createContainerElement( sectionName );
+
+	conversionApi.writer.insert( ViewPosition.createAt( tableElement, sectionName == 'tbody' ? 'end' : 'start' ), tableChildElement );
+
+	return tableChildElement;
+}
+
+// Removes an existing <tbody> or <thead> element if it is empty.
+//
+// @param {String} sectionName
+// @param {module:engine/view/element~Element} tableElement
+// @param {Object} conversionApi
+function removeTableSectionIfEmpty( sectionName, tableElement, conversionApi ) {
+	const tableSection = getExistingTableSectionElement( sectionName, tableElement );
+
+	if ( tableSection && tableSection.childCount === 0 ) {
+		conversionApi.writer.remove( ViewRange.createOn( tableSection ) );
+	}
+}
+
+// Moves view table rows associated with passed model rows to provided table section element.
+//
+// @param {Array.<module:engine/model/element~Element>} rowsToMove
+// @param {module:engine/view/element~Element} viewTableSection
+// @param {Object} conversionApi
+// @param {Number|'end'|'before'|'after'} [offset=0] Offset or one of the flags.
+function moveViewRowsToTableSection( rowsToMove, viewTableSection, conversionApi, offset ) {
+	for ( const tableRow of rowsToMove ) {
+		const viewTableRow = conversionApi.mapper.toViewElement( tableRow );
+
+		conversionApi.writer.move( ViewRange.createOn( viewTableRow ), ViewPosition.createAt( viewTableSection, offset ) );
+	}
+}

+ 182 - 0
packages/ckeditor5-table/src/converters/upcasttable.js

@@ -0,0 +1,182 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module table/converters/upcasttable
+ */
+
+import ModelRange from '@ckeditor/ckeditor5-engine/src/model/range';
+import ModelPosition from '@ckeditor/ckeditor5-engine/src/model/position';
+
+/**
+ * View table element to model table element conversion helper.
+ *
+ * This conversion helper convert table element as well as tableRows.
+ *
+ * @returns {Function} Conversion helper.
+ */
+export default function upcastTable() {
+	return dispatcher => {
+		dispatcher.on( 'element:table', ( evt, data, conversionApi ) => {
+			const viewTable = data.viewItem;
+
+			// When element was already consumed then skip it.
+			if ( !conversionApi.consumable.test( viewTable, { name: true } ) ) {
+				return;
+			}
+
+			const { rows, headingRows, headingColumns } = scanTable( viewTable );
+
+			// Only set attributes if values is greater then 0.
+			const attributes = {};
+
+			if ( headingColumns ) {
+				attributes.headingColumns = headingColumns;
+			}
+
+			if ( headingRows ) {
+				attributes.headingRows = headingRows;
+			}
+
+			const table = conversionApi.writer.createElement( 'table', attributes );
+
+			// Insert element on allowed position.
+			const splitResult = conversionApi.splitToAllowedParent( table, data.modelCursor );
+			conversionApi.writer.insert( table, splitResult.position );
+			conversionApi.consumable.consume( viewTable, { name: true } );
+
+			if ( rows.length ) {
+				// Upcast table rows in proper order (heading rows first).
+				rows.forEach( row => conversionApi.convertItem( row, ModelPosition.createAt( table, 'end' ) ) );
+			} else {
+				// Create one row and one table cell for empty table.
+				const row = conversionApi.writer.createElement( 'tableRow' );
+
+				conversionApi.writer.insert( row, ModelPosition.createAt( table, 'end' ) );
+				conversionApi.writer.insertElement( 'tableCell', ModelPosition.createAt( row, 'end' ) );
+			}
+
+			// Set conversion result range.
+			data.modelRange = new ModelRange(
+				// Range should start before inserted element
+				ModelPosition.createBefore( table ),
+				// Should end after but we need to take into consideration that children could split our
+				// element, so we need to move range after parent of the last converted child.
+				// before: <allowed>[]</allowed>
+				// after: <allowed>[<converted><child></child></converted><child></child><converted>]</converted></allowed>
+				ModelPosition.createAfter( table )
+			);
+
+			// Now we need to check where the modelCursor should be.
+			// If we had to split parent to insert our element then we want to continue conversion inside split parent.
+			//
+			// before: <allowed><notAllowed>[]</notAllowed></allowed>
+			// after:  <allowed><notAllowed></notAllowed><converted></converted><notAllowed>[]</notAllowed></allowed>
+			if ( splitResult.cursorParent ) {
+				data.modelCursor = ModelPosition.createAt( splitResult.cursorParent );
+
+				// Otherwise just continue after inserted element.
+			} else {
+				data.modelCursor = data.modelRange.end;
+			}
+		}, { priority: 'normal' } );
+	};
+}
+
+// Scans table rows & extracts required metadata from table:
+//
+// headingRows    - number of rows that goes as table header.
+// headingColumns - max number of row headings.
+// rows           - sorted trs as they should go into the model - ie if <thead> is inserted after <tbody> in the view.
+//
+// @param {module:engine/view/element~Element} viewTable
+// @returns {{headingRows, headingColumns, rows}}
+function scanTable( viewTable ) {
+	const tableMeta = {
+		headingRows: 0,
+		headingColumns: 0
+	};
+
+	// The <tbody> and <thead> sections in the DOM doesn't have to be in order <thead> -> <tbody> and there might be more then one of them.
+	// As the model doesn't have those sections rows from different sections must be sorted.
+	// Ie below is a valid HTML table:
+	//
+	//		<table>
+	//			<tbody><tr><td>2</td></tr></tbody>
+	//			<thead><tr><td>1</td></tr></thead>
+	//			<tbody><tr><td>3</td></tr></tbody>
+	//		</table>
+	//
+	// But browsers will render rows in order as : 1 as heading and 2 & 3 as (body).
+	const headRows = [];
+	const bodyRows = [];
+
+	// Currently the editor does not support more then one <thead> section.
+	// Only the first <thead> from the view will be used as heading rows and others will be converted to body rows.
+	let firstTheadElement;
+
+	for ( const tableChild of Array.from( viewTable.getChildren() ) ) {
+		// Only <thead>, <tbody> & <tfoot> from allowed table children can have <tr>s.
+		// The else is for future purposes (mainly <caption>).
+		if ( tableChild.name === 'tbody' || tableChild.name === 'thead' || tableChild.name === 'tfoot' ) {
+			// Save the first <thead> in the table as table header - all other ones will be converted to table body rows.
+			if ( tableChild.name === 'thead' && !firstTheadElement ) {
+				firstTheadElement = tableChild;
+			}
+
+			for ( const tr of Array.from( tableChild.getChildren() ) ) {
+				// This <tr> is a child of a first <thead> element.
+				if ( tr.parent.name === 'thead' && tr.parent === firstTheadElement ) {
+					tableMeta.headingRows++;
+					headRows.push( tr );
+				} else {
+					bodyRows.push( tr );
+					// For other rows check how many column headings this row has.
+
+					const headingCols = scanRowForHeadingColumns( tr, tableMeta, firstTheadElement );
+
+					if ( headingCols > tableMeta.headingColumns ) {
+						tableMeta.headingColumns = headingCols;
+					}
+				}
+			}
+		}
+	}
+
+	tableMeta.rows = [ ...headRows, ...bodyRows ];
+
+	return tableMeta;
+}
+
+// Scans <tr> and it's children for metadata:
+// - For heading row:
+//     - either add this row to heading or body rows.
+//     - updates number of heading rows.
+// - For body rows:
+//     - calculates number of column headings.
+//
+// @param {module:engine/view/element~Element} tr
+// @returns {Number}
+function scanRowForHeadingColumns( tr ) {
+	let headingColumns = 0;
+	let index = 0;
+
+	// Filter out empty text nodes from tr children.
+	const children = Array.from( tr.getChildren() )
+		.filter( child => child.name === 'th' || child.name === 'td' );
+
+	// Count starting adjacent <th> elements of a <tr>.
+	while ( index < children.length && children[ index ].name === 'th' ) {
+		const th = children[ index ];
+
+		// Adjust columns calculation by the number of spanned columns.
+		const colspan = parseInt( th.getAttribute( 'colspan' ) || 1 );
+
+		headingColumns = headingColumns + colspan;
+		index++;
+	}
+
+	return headingColumns;
+}

+ 35 - 0
packages/ckeditor5-table/src/table.js

@@ -0,0 +1,35 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module table/table
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+
+import TableEditing from './tableediting';
+import TableUI from './tableui';
+import Widget from '@ckeditor/ckeditor5-widget/src/widget';
+
+/**
+ * The table plugin.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class Table extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ TableEditing, TableUI, Widget ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'Table';
+	}
+}

+ 235 - 0
packages/ckeditor5-table/src/tableediting.js

@@ -0,0 +1,235 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module table/tableediting
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters';
+import Range from '@ckeditor/ckeditor5-engine/src/model/range';
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
+
+import upcastTable from './converters/upcasttable';
+import {
+	downcastInsertCell,
+	downcastInsertRow,
+	downcastInsertTable,
+	downcastRemoveRow,
+	downcastTableHeadingColumnsChange,
+	downcastTableHeadingRowsChange
+} from './converters/downcast';
+import InsertTableCommand from './commands/inserttablecommand';
+import InsertRowCommand from './commands/insertrowcommand';
+import InsertColumnCommand from './commands/insertcolumncommand';
+import SplitCellCommand from './commands/splitcellcommand';
+import MergeCellCommand from './commands/mergecellcommand';
+import RemoveRowCommand from './commands/removerowcommand';
+import RemoveColumnCommand from './commands/removecolumncommand';
+import SetTableHeadersCommand from './commands/settableheaderscommand';
+import { getParentTable } from './commands/utils';
+
+import './../theme/table.css';
+import TableUtils from './tableutils';
+
+/**
+ * The table editing feature.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class TableEditing extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const schema = editor.model.schema;
+		const conversion = editor.conversion;
+
+		schema.register( 'table', {
+			allowWhere: '$block',
+			allowAttributes: [ 'headingRows', 'headingColumns' ],
+			isObject: true
+		} );
+
+		schema.register( 'tableRow', { allowIn: 'table' } );
+
+		schema.register( 'tableCell', {
+			allowIn: 'tableRow',
+			allowContentOf: '$block',
+			allowAttributes: [ 'colspan', 'rowspan' ],
+			isLimit: true
+		} );
+
+		// Table conversion.
+		conversion.for( 'upcast' ).add( upcastTable() );
+
+		conversion.for( 'editingDowncast' ).add( downcastInsertTable( { asWidget: true } ) );
+		conversion.for( 'dataDowncast' ).add( downcastInsertTable() );
+
+		// Table row conversion.
+		conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableRow', view: 'tr' } ) );
+
+		conversion.for( 'editingDowncast' ).add( downcastInsertRow( { asWidget: true } ) );
+		conversion.for( 'dataDowncast' ).add( downcastInsertRow() );
+		conversion.for( 'downcast' ).add( downcastRemoveRow() );
+
+		// Table cell conversion.
+		conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'td' } ) );
+		conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'th' } ) );
+
+		conversion.for( 'editingDowncast' ).add( downcastInsertCell( { asWidget: true } ) );
+		conversion.for( 'dataDowncast' ).add( downcastInsertCell() );
+
+		// Table attributes conversion.
+		conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } );
+		conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } );
+
+		// Table heading rows and cols conversion.
+		conversion.for( 'editingDowncast' ).add( downcastTableHeadingColumnsChange( { asWidget: true } ) );
+		conversion.for( 'dataDowncast' ).add( downcastTableHeadingColumnsChange() );
+		conversion.for( 'editingDowncast' ).add( downcastTableHeadingRowsChange( { asWidget: true } ) );
+		conversion.for( 'dataDowncast' ).add( downcastTableHeadingRowsChange() );
+
+		// Define all the commands.
+		editor.commands.add( 'insertTable', new InsertTableCommand( editor ) );
+		editor.commands.add( 'insertRowAbove', new InsertRowCommand( editor, { order: 'above' } ) );
+		editor.commands.add( 'insertRowBelow', new InsertRowCommand( editor, { order: 'below' } ) );
+		editor.commands.add( 'insertColumnBefore', new InsertColumnCommand( editor, { order: 'before' } ) );
+		editor.commands.add( 'insertColumnAfter', new InsertColumnCommand( editor, { order: 'after' } ) );
+
+		editor.commands.add( 'removeRow', new RemoveRowCommand( editor ) );
+		editor.commands.add( 'removeColumn', new RemoveColumnCommand( editor ) );
+
+		editor.commands.add( 'splitCellVertically', new SplitCellCommand( editor, { direction: 'vertically' } ) );
+		editor.commands.add( 'splitCellHorizontally', new SplitCellCommand( editor, { direction: 'horizontally' } ) );
+
+		editor.commands.add( 'mergeCellRight', new MergeCellCommand( editor, { direction: 'right' } ) );
+		editor.commands.add( 'mergeCellLeft', new MergeCellCommand( editor, { direction: 'left' } ) );
+		editor.commands.add( 'mergeCellDown', new MergeCellCommand( editor, { direction: 'down' } ) );
+		editor.commands.add( 'mergeCellUp', new MergeCellCommand( editor, { direction: 'up' } ) );
+
+		editor.commands.add( 'setTableHeaders', new SetTableHeadersCommand( editor ) );
+
+		// 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 ) );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ TableUtils ];
+	}
+
+	/**
+	 * Handles {@link module:engine/view/document~Document#event:keydown keydown} events for 'Tab' key executed
+	 * when table widget is selected.
+	 *
+	 * @private
+	 * @param {module:utils/eventinfo~EventInfo} eventInfo
+	 * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData
+	 */
+	_handleTabOnSelectedTable( eventInfo, domEventData ) {
+		const tabPressed = domEventData.keyCode == keyCodes.tab;
+
+		// Act only on TAB & SHIFT-TAB - Do not override native CTRL+TAB handler.
+		if ( !tabPressed || domEventData.ctrlKey ) {
+			return;
+		}
+
+		const editor = this.editor;
+		const selection = editor.model.document.selection;
+
+		if ( !selection.isCollapsed && selection.rangeCount === 1 && selection.getFirstRange().isFlat ) {
+			const selectedElement = selection.getSelectedElement();
+
+			if ( !selectedElement || selectedElement.name != 'table' ) {
+				return;
+			}
+
+			eventInfo.stop();
+			domEventData.preventDefault();
+			domEventData.stopPropagation();
+
+			editor.model.change( writer => {
+				writer.setSelection( Range.createIn( selectedElement.getChild( 0 ).getChild( 0 ) ) );
+			} );
+		}
+	}
+
+	/**
+	 * Handles {@link module:engine/view/document~Document#event:keydown keydown} events for 'Tab' key executed inside table cell.
+	 *
+	 * @private
+	 * @param {module:utils/eventinfo~EventInfo} eventInfo
+	 * @param {module:engine/view/observer/domeventdata~DomEventData} domEventData
+	 */
+	_handleTabInsideTable( eventInfo, domEventData ) {
+		const tabPressed = domEventData.keyCode == keyCodes.tab;
+
+		// Act only on TAB & SHIFT-TAB - Do not override native CTRL+TAB handler.
+		if ( !tabPressed || domEventData.ctrlKey ) {
+			return;
+		}
+
+		const editor = this.editor;
+		const selection = editor.model.document.selection;
+
+		const table = getParentTable( selection.getFirstPosition() );
+
+		if ( !table ) {
+			return;
+		}
+
+		domEventData.preventDefault();
+		domEventData.stopPropagation();
+
+		const tableCell = selection.focus.parent;
+		const tableRow = tableCell.parent;
+
+		const currentRowIndex = table.getChildIndex( tableRow );
+		const currentCellIndex = tableRow.getChildIndex( tableCell );
+
+		const isForward = !domEventData.shiftKey;
+		const isFirstCellInRow = currentCellIndex === 0;
+
+		if ( !isForward && isFirstCellInRow && currentRowIndex === 0 ) {
+			// It's the first cell of a table - don't do anything (stay in current position).
+			return;
+		}
+
+		const isLastCellInRow = currentCellIndex === tableRow.childCount - 1;
+		const isLastRow = currentRowIndex === table.childCount - 1;
+
+		if ( isForward && isLastRow && isLastCellInRow ) {
+			editor.plugins.get( TableUtils ).insertRows( table, { at: table.childCount } );
+		}
+
+		let cellToFocus;
+
+		// Move to first cell in next row.
+		if ( isForward && isLastCellInRow ) {
+			const nextRow = table.getChild( currentRowIndex + 1 );
+
+			cellToFocus = nextRow.getChild( 0 );
+		}
+		// Move to last cell in a previous row.
+		else if ( !isForward && isFirstCellInRow ) {
+			const previousRow = table.getChild( currentRowIndex - 1 );
+
+			cellToFocus = previousRow.getChild( previousRow.childCount - 1 );
+		}
+		// Move to next/previous cell.
+		else {
+			cellToFocus = tableRow.getChild( currentCellIndex + ( isForward ? 1 : -1 ) );
+		}
+
+		editor.model.change( writer => {
+			writer.setSelection( Range.createIn( cellToFocus ) );
+		} );
+	}
+}

+ 89 - 0
packages/ckeditor5-table/src/tableui.js

@@ -0,0 +1,89 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module table/tableui
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+
+import icon from '@ckeditor/ckeditor5-core/theme/icons/object-center.svg';
+import insertRowIcon from '@ckeditor/ckeditor5-core/theme/icons/object-left.svg';
+import insertColumnIcon from '@ckeditor/ckeditor5-core/theme/icons/object-right.svg';
+
+/**
+ * The table UI plugin.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class TableUI extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+
+		editor.ui.componentFactory.add( 'insertTable', locale => {
+			const command = editor.commands.get( 'insertTable' );
+			const buttonView = new ButtonView( locale );
+
+			buttonView.bind( 'isEnabled' ).to( command );
+
+			buttonView.set( {
+				icon,
+				label: 'Insert table',
+				tooltip: true
+			} );
+
+			buttonView.on( 'execute', () => {
+				editor.execute( 'insertTable' );
+				editor.editing.view.focus();
+			} );
+
+			return buttonView;
+		} );
+
+		editor.ui.componentFactory.add( 'insertRowBelow', locale => {
+			const command = editor.commands.get( 'insertRowBelow' );
+			const buttonView = new ButtonView( locale );
+
+			buttonView.bind( 'isEnabled' ).to( command );
+
+			buttonView.set( {
+				icon: insertRowIcon,
+				label: 'Insert row',
+				tooltip: true
+			} );
+
+			buttonView.on( 'execute', () => {
+				editor.execute( 'insertRowBelow' );
+				editor.editing.view.focus();
+			} );
+
+			return buttonView;
+		} );
+
+		editor.ui.componentFactory.add( 'insertColumnAfter', locale => {
+			const command = editor.commands.get( 'insertColumnAfter' );
+			const buttonView = new ButtonView( locale );
+
+			buttonView.bind( 'isEnabled' ).to( command );
+
+			buttonView.set( {
+				icon: insertColumnIcon,
+				label: 'Insert column',
+				tooltip: true
+			} );
+
+			buttonView.on( 'execute', () => {
+				editor.execute( 'insertColumnAfter' );
+				editor.editing.view.focus();
+			} );
+
+			return buttonView;
+		} );
+	}
+}

+ 593 - 0
packages/ckeditor5-table/src/tableutils.js

@@ -0,0 +1,593 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module table/tableutils
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import Position from '@ckeditor/ckeditor5-engine/src/model/position';
+
+import TableWalker from './tablewalker';
+import { getParentTable, updateNumericAttribute } from './commands/utils';
+
+/**
+ * The table utils plugin.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class TableUtils extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'TableUtils';
+	}
+
+	/**
+	 * Returns table cell location as an object with table row and table column indexes.
+	 *
+	 * For instance in a table below:
+	 *
+	 *		    0   1   2   3
+	 *		  +---+---+---+---+
+	 *		0 | a     | b | c |
+	 *		  +       +   +---+
+	 *		1 |       |   | d |
+	 *		  +---+---+   +---+
+	 *		2 | e     |   | f |
+	 *		  +---+---+---+---+
+	 *
+	 * the method will return:
+	 *
+	 *		const cellA = table.getNodeByPath( [ 0, 0 ] );
+	 *		editor.plugins.get( 'TableUtils' ).getCellLocation( cellA );
+	 *		// will return { row: 0, column: 0 }
+	 *
+	 *		const cellD = table.getNodeByPath( [ 1, 0 ] );
+	 *		editor.plugins.get( 'TableUtils' ).getCellLocation( cellD );
+	 *		// will return { row: 1, column: 3 }
+	 *
+	 * @param {module:engine/model/element~Element} tableCell
+	 * @returns {Object} Returns a `{row, column}` object.
+	 */
+	getCellLocation( tableCell ) {
+		const tableRow = tableCell.parent;
+		const table = tableRow.parent;
+
+		const rowIndex = table.getChildIndex( tableRow );
+
+		const tableWalker = new TableWalker( table, { startRow: rowIndex, endRow: rowIndex } );
+
+		for ( const { cell, row, column } of tableWalker ) {
+			if ( cell === tableCell ) {
+				return { row, column };
+			}
+		}
+	}
+
+	/**
+	 * Creates an empty table at given position.
+	 *
+	 * @param {module:engine/model/position~Position} position Position at which insert a table.
+	 * @param {Number} rows Number of rows to create.
+	 * @param {Number} columns Number of columns to create.
+	 */
+	createTable( position, rows, columns ) {
+		const model = this.editor.model;
+
+		model.change( writer => {
+			const table = writer.createElement( 'table' );
+
+			writer.insert( table, position );
+
+			createEmptyRows( writer, table, 0, rows, columns );
+		} );
+	}
+
+	/**
+	 * Insert rows into a table.
+	 *
+	 *		editor.plugins.get( 'TableUtils' ).insertRows( table, { at: 1, rows: 2 } );
+	 *
+	 * Assuming the table on the left, the above code will transform it to the table on the right:
+	 *
+	 *		row index
+	 *		  0 +---+---+---+       `at` = 1,      +---+---+---+ 0
+	 *		    | a | b | c |       `rows` = 2,    | a | b | c |
+	 *		  1 +   +---+---+   <-- insert here    +   +---+---+ 1
+	 *		    |   | d | e |                      |   |   |   |
+	 *		  2 +   +---+---+       will give:     +   +---+---+ 2
+	 *		    |   | f | g |                      |   |   |   |
+	 *		  3 +---+---+---+                      +   +---+---+ 3
+	 *		                                       |   | d | e |
+	 *		                                       +---+---+---+ 4
+	 *		                                       +   + f | g |
+	 *		                                       +---+---+---+ 5
+	 *
+	 * @param {module:engine/model/element~Element} table Table model element to which insert rows.
+	 * @param {Object} options
+	 * @param {Number} [options.at=0] Row index at which insert rows.
+	 * @param {Number} [options.rows=1] Number of rows to insert.
+	 */
+	insertRows( table, options = {} ) {
+		const model = this.editor.model;
+
+		const insertAt = options.at || 0;
+		const rowsToInsert = options.rows || 1;
+
+		model.change( writer => {
+			const headingRows = table.getAttribute( 'headingRows' ) || 0;
+
+			// Inserting rows inside heading section requires to update `headingRows` attribute as the heading section will grow.
+			if ( headingRows > insertAt ) {
+				writer.setAttribute( 'headingRows', headingRows + rowsToInsert, table );
+			}
+
+			// Inserting at the end and at the beginning of a table doesn't require to calculate anything special.
+			if ( insertAt === 0 || insertAt === table.childCount ) {
+				createEmptyRows( writer, table, insertAt, rowsToInsert, this.getColumns( table ) );
+
+				return;
+			}
+
+			// Iterate over all rows above inserted rows in order to check for rowspanned cells.
+			const tableIterator = new TableWalker( table, { endRow: insertAt } );
+
+			// Will hold number of cells needed to insert in created rows.
+			// The number might be different then table cell width when there are rowspanned cells.
+			let cellsToInsert = 0;
+
+			for ( const { row, rowspan, colspan, cell } of tableIterator ) {
+				const isBeforeInsertedRow = row < insertAt;
+				const overlapsInsertedRow = row + rowspan > insertAt;
+
+				if ( isBeforeInsertedRow && overlapsInsertedRow ) {
+					// This cell overlaps inserted rows so we need to expand it further.
+					writer.setAttribute( 'rowspan', rowspan + rowsToInsert, cell );
+				}
+
+				// Calculate how many cells to insert based on the width of cells in a row at insert position.
+				// It might be lower then table width as some cells might overlaps inserted row.
+				// In the table above the cell 'a' overlaps inserted row so only two empty cells are need to be created.
+				if ( row === insertAt ) {
+					cellsToInsert += colspan;
+				}
+			}
+
+			createEmptyRows( writer, table, insertAt, rowsToInsert, cellsToInsert );
+		} );
+	}
+
+	/**
+	 * Inserts columns into a table.
+	 *
+	 *		editor.plugins.get( 'TableUtils' ).insertColumns( table, { at: 1, columns: 2 } );
+	 *
+	 * Assuming the table on the left, the above code will transform it to the table on the right:
+	 *
+	 *		0   1   2   3                   0   1   2   3   4   5
+	 *		+---+---+---+                   +---+---+---+---+---+
+	 *		| a     | b |                   | a             | b |
+	 *		+       +---+                   +               +---+
+	 *		|       | c |                   |               | c |
+	 *		+---+---+---+     will give:    +---+---+---+---+---+
+	 *		| d | e | f |                   | d |   |   | e | f |
+	 *		+---+   +---+                   +---+---+---+  +---+
+	 *		| g |   | h |                   | g |   |   |   | h |
+	 *		+---+---+---+                   +---+---+---+---+---+
+	 *		| i         |                   | i                 |
+	 *		+---+---+---+                   +---+---+---+---+---+
+	 *		    ^---- insert here, `at` = 1, `columns` = 2
+	 *
+	 * @param {module:engine/model/element~Element} table Table model element to which insert columns.
+	 * @param {Object} options
+	 * @param {Number} [options.at=0] Column index at which insert columns.
+	 * @param {Number} [options.columns=1] Number of columns to insert.
+	 */
+	insertColumns( table, options = {} ) {
+		const model = this.editor.model;
+
+		const insertAt = options.at || 0;
+		const columnsToInsert = options.columns || 1;
+
+		model.change( writer => {
+			const headingColumns = table.getAttribute( 'headingColumns' );
+
+			// Inserting columns inside heading section requires to update `headingColumns` attribute as the heading section will grow.
+			if ( insertAt < headingColumns ) {
+				writer.setAttribute( 'headingColumns', headingColumns + columnsToInsert, table );
+			}
+
+			const tableColumns = this.getColumns( table );
+
+			// Inserting at the end and at the beginning of a table doesn't require to calculate anything special.
+			if ( insertAt === 0 || tableColumns === insertAt ) {
+				for ( const tableRow of table.getChildren() ) {
+					createCells( columnsToInsert, writer, Position.createAt( tableRow, insertAt ? 'end' : 0 ) );
+				}
+
+				return;
+			}
+
+			const tableWalker = new TableWalker( table, { column: insertAt, includeSpanned: true } );
+
+			for ( const { row, column, cell, colspan, rowspan, cellIndex } of tableWalker ) {
+				// When iterating over column the table walker outputs either:
+				// - cells at given column index (cell "e" from method docs),
+				// - spanned columns (spanned cell from row between cells "g" and "h" - spanned by "e", only if `includeSpanned: true`),
+				// - or a cell from the same row which spans over this column (cell "a").
+
+				if ( column !== insertAt ) {
+					// If column is different than `insertAt`, it is a cell that spans over an inserted column (cell "a" & "i").
+					// For such cells expand them by a number of columns inserted.
+					writer.setAttribute( 'colspan', colspan + columnsToInsert, cell );
+
+					// The `includeSpanned` option will output the "empty"/spanned column so skip this row already.
+					tableWalker.skipRow( row );
+
+					// This cell will overlap cells in rows below so skip them also (because of `includeSpanned` option) - (cell "a")
+					if ( rowspan > 1 ) {
+						for ( let i = row + 1; i < row + rowspan; i++ ) {
+							tableWalker.skipRow( i );
+						}
+					}
+				} else {
+					// It's either cell at this column index or spanned cell by a rowspanned cell from row above.
+					// In table above it's cell "e" and a spanned position from row below (empty cell between cells "g" and "h")
+					const insertPosition = Position.createFromParentAndOffset( table.getChild( row ), cellIndex );
+
+					createCells( columnsToInsert, writer, insertPosition );
+				}
+			}
+		} );
+	}
+
+	/**
+	 * Divides table cell vertically into several ones.
+	 *
+	 * The cell will visually split to more cells by updating colspans of other cells in a column
+	 * and inserting cells (columns) after that cell.
+	 *
+	 * In the table below, if cell "a" is split to 3 cells:
+	 *
+	 *		+---+---+---+
+	 *		| a | b | c |
+	 *		+---+---+---+
+	 *		| d | e | f |
+	 *		+---+---+---+
+	 *
+	 * it will result in the table below:
+	 *
+	 *		+---+---+---+---+---+
+	 *		| a |   |   | b | c |
+	 *		+---+---+---+---+---+
+	 *		| d         | e | f |
+	 *		+---+---+---+---+---+
+	 *
+	 * So cell d will get updated `colspan` to 3 and 2 cells will be added (2 columns created).
+	 *
+	 * Splitting cell that already has a colspan attribute set will distribute cell's colspan evenly and a reminder
+	 * will be left to original cell:
+	 *
+	 *		+---+---+---+
+	 *		| a         |
+	 *		+---+---+---+
+	 *		| b | c | d |
+	 *		+---+---+---+
+	 *
+	 * Splitting cell a with colspan=3 to a 2 cells will create 1 cell with colspan=2 and cell a will have colspan=1:
+	 *
+	 *		+---+---+---+
+	 *		| a     |   |
+	 *		+---+---+---+
+	 *		| b | c | d |
+	 *		+---+---+---+
+	 *
+	 * @param {module:engine/model/element~Element} tableCell
+	 * @param {Number} numberOfCells
+	 */
+	splitCellVertically( tableCell, numberOfCells = 2 ) {
+		const model = this.editor.model;
+		const table = getParentTable( tableCell );
+
+		const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
+		const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
+
+		model.change( writer => {
+			// First check - the cell spans over multiple rows so before doing anything else just split this cell.
+			if ( colspan > 1 ) {
+				// Get spans of new (inserted) cells and span to update of split cell.
+				const { newCellsSpan, updatedSpan } = breakSpanEvenly( colspan, numberOfCells );
+
+				updateNumericAttribute( 'colspan', updatedSpan, tableCell, writer );
+
+				// Each inserted cell will have the same attributes:
+				const newCellsAttributes = {};
+
+				// Do not store default value in the model.
+				if ( newCellsSpan > 1 ) {
+					newCellsAttributes.colspan = newCellsSpan;
+				}
+
+				// Copy rowspan of split cell.
+				if ( rowspan > 1 ) {
+					newCellsAttributes.rowspan = rowspan;
+				}
+
+				const cellsToInsert = colspan > numberOfCells ? numberOfCells - 1 : colspan - 1;
+				createCells( cellsToInsert, writer, Position.createAfter( tableCell ), newCellsAttributes );
+			}
+
+			// Second check - the cell has colspan of 1 or we need to create more cells then the currently one spans over.
+			if ( colspan < numberOfCells ) {
+				const cellsToInsert = numberOfCells - colspan;
+
+				// First step: expand cells on the same column as split cell.
+				const tableMap = [ ...new TableWalker( table ) ];
+
+				// Get the column index of split cell.
+				const { column: splitCellColumn } = tableMap.find( ( { cell } ) => cell === tableCell );
+
+				// Find cells which needs to be expanded vertically - those on the same column or those that spans over split cell's column.
+				const cellsToUpdate = tableMap.filter( ( { cell, colspan, column } ) => {
+					const isOnSameColumn = cell !== tableCell && column === splitCellColumn;
+					const spansOverColumn = ( column < splitCellColumn && column + colspan > splitCellColumn );
+
+					return isOnSameColumn || spansOverColumn;
+				} );
+
+				// Expand cells vertically.
+				for ( const { cell, colspan } of cellsToUpdate ) {
+					writer.setAttribute( 'colspan', colspan + cellsToInsert, cell );
+				}
+
+				// Second step: create columns after split cell.
+
+				// Each inserted cell will have the same attributes:
+				const newCellsAttributes = {};
+
+				// Do not store default value in the model.
+
+				// Copy rowspan of split cell.
+				if ( rowspan > 1 ) {
+					newCellsAttributes.rowspan = rowspan;
+				}
+
+				createCells( cellsToInsert, writer, Position.createAfter( tableCell ), newCellsAttributes );
+
+				const headingColumns = table.getAttribute( 'headingColumns' ) || 0;
+
+				// Update heading section if split cell is in heading section.
+				if ( headingColumns > splitCellColumn ) {
+					updateNumericAttribute( 'headingColumns', headingColumns + cellsToInsert, table, writer );
+				}
+			}
+		} );
+	}
+
+	/**
+	 * Divides table cell horizontally into several ones.
+	 *
+	 * The cell will visually split to more cells by updating rowspans of other cells in a row and inserting rows with single cell below.
+	 *
+	 * If in a table below cell b will be split to a 3 cells:
+	 *
+	 *		+---+---+---+
+	 *		| a | b | c |
+	 *		+---+---+---+
+	 *		| d | e | f |
+	 *		+---+---+---+
+	 *
+	 * will result in a table below:
+	 *
+	 *		+---+---+---+
+	 *		| a | b | c |
+	 *		+   +---+   +
+	 *		|   |   |   |
+	 *		+   +---+   +
+	 *		|   |   |   |
+	 *		+---+---+---+
+	 *		| d | e | f |
+	 *		+---+---+---+
+	 *
+	 * So cells a & b will get updated `rowspan` to 3 and 2 rows with single cell will be added.
+	 *
+	 * Splitting cell that has already a rowspan attribute set will distribute cell's rowspan evenly and a reminder
+	 * will be left to original cell:
+	 *
+	 *		+---+---+---+
+	 *		| a | b | c |
+	 *		+   +---+---+
+	 *		|   | d | e |
+	 *		+   +---+---+
+	 *		|   | f | g |
+	 *		+   +---+---+
+	 *		|   | h | i |
+	 *		+---+---+---+
+	 *
+	 * Splitting cell a with rowspan=4 to a 3 cells will create 2 cells with rowspan=1 and cell a will have rowspan=2:
+	 *
+	 *		+---+---+---+
+	 *		| a | b | c |
+	 *		+   +---+---+
+	 *		|   | d | e |
+	 *		+---+---+---+
+	 *		|   | f | g |
+	 *		+---+---+---+
+	 *		|   | h | i |
+	 *		+---+---+---+
+	 *
+	 * @param {module:engine/model/element~Element} tableCell
+	 * @param {Number} numberOfCells
+	 */
+	splitCellHorizontally( tableCell, numberOfCells = 2 ) {
+		const model = this.editor.model;
+
+		const table = getParentTable( tableCell );
+		const splitCellRow = table.getChildIndex( tableCell.parent );
+
+		const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
+		const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
+
+		model.change( writer => {
+			// First check - the cell spans over multiple rows so before doing anything else just split this cell.
+			if ( rowspan > 1 ) {
+				// Cache table map before updating table.
+				const tableMap = [ ...new TableWalker( table, {
+					startRow: splitCellRow,
+					endRow: splitCellRow + rowspan - 1,
+					includeSpanned: true
+				} ) ];
+
+				// Get spans of new (inserted) cells and span to update of split cell.
+				const { newCellsSpan, updatedSpan } = breakSpanEvenly( rowspan, numberOfCells );
+
+				updateNumericAttribute( 'rowspan', updatedSpan, tableCell, writer );
+
+				const { column: cellColumn } = tableMap.find( ( { cell } ) => cell === tableCell );
+
+				// Each inserted cell will have the same attributes:
+				const newCellsAttributes = {};
+
+				// Do not store default value in the model.
+				if ( newCellsSpan > 1 ) {
+					newCellsAttributes.rowspan = newCellsSpan;
+				}
+
+				// Copy colspan of split cell.
+				if ( colspan > 1 ) {
+					newCellsAttributes.colspan = colspan;
+				}
+
+				for ( const { column, row, cellIndex } of tableMap ) {
+					// As both newly created cells and the split cell might have rowspan,
+					// the insertion of new cells must go to appropriate rows:
+					//
+					// 1. It's a row after split cell + it's height.
+					const isAfterSplitCell = row >= splitCellRow + updatedSpan;
+					// 2. Is on the same column.
+					const isOnSameColumn = column === cellColumn;
+					// 3. And it's row index is after previous cell height.
+					const isInEvenlySplitRow = ( row + splitCellRow + updatedSpan ) % newCellsSpan === 0;
+
+					if ( isAfterSplitCell && isOnSameColumn && isInEvenlySplitRow ) {
+						const position = Position.createFromParentAndOffset( table.getChild( row ), cellIndex );
+
+						writer.insertElement( 'tableCell', newCellsAttributes, position );
+					}
+				}
+			}
+
+			// Second check - the cell has rowspan of 1 or we need to create more cells than the current cell spans over.
+			if ( rowspan < numberOfCells ) {
+				// We already split the cell in check one so here we split to the remaining number of cells only.
+				const cellsToInsert = numberOfCells - rowspan;
+
+				// This check is needed since we need to check if there are any cells from previous rows than spans over this cell's row.
+				const tableMap = [ ...new TableWalker( table, { startRow: 0, endRow: splitCellRow } ) ];
+
+				// First step: expand cells.
+				for ( const { cell, rowspan, row } of tableMap ) {
+					// Expand rowspan of cells that are either:
+					// - on the same row as current cell,
+					// - or are below split cell row and overlaps that row.
+					if ( cell !== tableCell && row + rowspan > splitCellRow ) {
+						const rowspanToSet = rowspan + cellsToInsert;
+
+						writer.setAttribute( 'rowspan', rowspanToSet, cell );
+					}
+				}
+
+				// Second step: create rows with single cell below split cell.
+				const newCellsAttributes = {};
+
+				// Copy colspan of split cell.
+				if ( colspan > 1 ) {
+					newCellsAttributes.colspan = colspan;
+				}
+
+				createEmptyRows( writer, table, splitCellRow + 1, cellsToInsert, 1, newCellsAttributes );
+
+				// Update heading section if split cell is in heading section.
+				const headingRows = table.getAttribute( 'headingRows' ) || 0;
+
+				if ( headingRows > splitCellRow ) {
+					updateNumericAttribute( 'headingRows', headingRows + cellsToInsert, table, writer );
+				}
+			}
+		} );
+	}
+
+	/**
+	 * Returns number of columns for given table.
+	 *
+	 *		editor.plugins.get( 'TableUtils' ).getColumns( table );
+	 *
+	 * @param {module:engine/model/element~Element} table Table to analyze.
+	 * @returns {Number}
+	 */
+	getColumns( table ) {
+		// Analyze first row only as all the rows should have the same width.
+		const row = table.getChild( 0 );
+
+		return [ ...row.getChildren() ].reduce( ( columns, row ) => {
+			const columnWidth = parseInt( row.getAttribute( 'colspan' ) || 1 );
+
+			return columns + columnWidth;
+		}, 0 );
+	}
+}
+
+// Creates empty rows at given index in an existing table.
+//
+// @param {module:engine/model/writer~Writer} writer
+// @param {module:engine/model/element~Element} table
+// @param {Number} insertAt Row index of row insertion.
+// @param {Number} rows Number of rows to create.
+// @param {Number} tableCellToInsert Number of cells to insert in each row.
+function createEmptyRows( writer, table, insertAt, rows, tableCellToInsert, attributes = {} ) {
+	for ( let i = 0; i < rows; i++ ) {
+		const tableRow = writer.createElement( 'tableRow' );
+
+		writer.insert( tableRow, table, insertAt );
+
+		createCells( tableCellToInsert, writer, Position.createAt( tableRow, 'end' ), attributes );
+	}
+}
+
+// Creates cells at given position.
+//
+// @param {Number} columns Number of columns to create
+// @param {module:engine/model/writer~Writer} writer
+// @param {module:engine/model/position~Position} insertPosition
+function createCells( cells, writer, insertPosition, attributes = {} ) {
+	for ( let i = 0; i < cells; i++ ) {
+		writer.insertElement( 'tableCell', attributes, insertPosition );
+	}
+}
+
+// Evenly distributes span of a cell to a number of provided cells.
+// The resulting spans will always be integer values.
+//
+// For instance breaking a span of 7 into 3 cells will return:
+//
+//		{ newCellsSpan: 2, updatedSpan: 3 }
+//
+// as two cells will have span of 2 and the reminder will go the first cell so it's span will change to 3.
+//
+// @param {Number} span Span value do break.
+// @param {Number} numberOfCells Number of resulting spans.
+// @returns {{newCellsSpan: Number, updatedSpan: Number}}
+function breakSpanEvenly( span, numberOfCells ) {
+	if ( span < numberOfCells ) {
+		return { newCellsSpan: 1, updatedSpan: 1 };
+	}
+
+	const newCellsSpan = Math.floor( span / numberOfCells );
+	const updatedSpan = ( span - newCellsSpan * numberOfCells ) + newCellsSpan;
+
+	return { newCellsSpan, updatedSpan };
+}

+ 397 - 0
packages/ckeditor5-table/src/tablewalker.js

@@ -0,0 +1,397 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module table/tablewalker
+ */
+
+/**
+ * Table iterator class. It allows to iterate over a table cells. For each cell the iterator yields
+ * {@link module:table/tablewalker~TableWalkerValue} with proper table cell attributes.
+ */
+export default class TableWalker {
+	/**
+	 * Creates an instance of table walker.
+	 *
+	 *
+	 * The TableWalker iterates internally by traversing table from row index = 0 and column index = 0.
+	 * It walks row by row and column by column in order to output values defined in constructor.
+	 * By default it will output only those locations that are occupied by a cell to include also a spanned rows & columns
+	 * pass `includeSpanned` option to a constructor.
+	 *
+	 * The most important values of iterator values are column & row indexes of a cell.
+	 *
+	 * To iterate over given row:
+	 *
+	 *		const tableWalker = new TableWalker( table, { startRow: 1, endRow: 2 } );
+	 *
+	 *		for ( const cellInfo of tableWalker ) {
+	 *			console.log( 'A cell at row ' + cellInfo.row + ' and column ' + cellInfo.column );
+	 *		}
+	 *
+	 * For instance the above code for a table:
+	 *
+	 *		+----+----+----+----+----+----+
+	 *		| 00      | 02 | 03      | 05 |
+	 *		|         +----+----+----+----+
+	 *		|         | 12      | 14 | 15 |
+	 *		|         +----+----+----+----+
+	 *		|         | 22                |
+	 *		|----+----+                   +
+	 *		| 31 | 32 |                   |
+	 *		+----+----+----+----+----+----+
+	 *
+	 * will log in the console:
+	 *
+	 *		'A cell at row 1 and column 2'
+	 *		'A cell at row 1 and column 4'
+	 *		'A cell at row 1 and column 5'
+	 *		'A cell at row 2 and column 2'
+	 *
+	 * To iterate over spanned cells also:
+	 *
+	 *		const tableWalker = new TableWalker( table, { startRow: 1, endRow: 1, includeSpanned: true } );
+	 *
+	 *		for ( const cellInfo of tableWalker ) {
+	 *			console.log( 'Cell at ' + cellInfo.row + ' x ' + cellInfo.column + ' : ' + ( cellInfo.cell ? 'has data' : 'is spanned' ) );
+	 *		}
+	 *
+	 * will log in the console for the table from previous example:
+	 *
+	 *		'Cell at 1 x 0 : is spanned'
+	 *		'Cell at 1 x 1 : is spanned'
+	 *		'Cell at 1 x 2 : has data'
+	 *		'Cell at 1 x 3 : is spanned'
+	 *		'Cell at 1 x 4 : has data'
+	 *		'Cell at 1 x 5 : has data'
+	 *
+	 * @constructor
+	 * @param {module:engine/model/element~Element} table A table over which iterate.
+	 * @param {Object} [options={}] Object with configuration.
+	 * @param {Number} [options.column] A column index for which this iterator will output cells.
+	 * @param {Number} [options.startRow=0] A row index for which this iterator should start.
+	 * @param {Number} [options.endRow] A row index for which this iterator should end.
+	 * @param {Boolean} [options.includeSpanned] Also return values for spanned cells.
+	 */
+	constructor( table, options = {} ) {
+		/**
+		 * The walker's table element.
+		 *
+		 * @readonly
+		 * @member {module:engine/model/element~Element}
+		 */
+		this.table = table;
+
+		/**
+		 * A row index on which this iterator will start.
+		 *
+		 * @readonly
+		 * @member {Number}
+		 */
+		this.startRow = options.startRow || 0;
+
+		/**
+		 * A row index on which this iterator will end.
+		 *
+		 * @readonly
+		 * @member {Number}
+		 */
+		this.endRow = typeof options.endRow == 'number' ? options.endRow : undefined;
+
+		/**
+		 * Enables output of spanned cells that are normally not yielded.
+		 *
+		 * @readonly
+		 * @member {Boolean}
+		 */
+		this.includeSpanned = !!options.includeSpanned;
+
+		/**
+		 * If set table walker will only output cells of given column or cells that overlaps it.
+		 *
+		 * @readonly
+		 * @member {Number}
+		 */
+		this.column = typeof options.column == 'number' ? options.column : undefined;
+
+		/**
+		 * Row indexes to skip from iteration.
+		 *
+		 * @readonly
+		 * @member {Set<Number>}
+		 * @private
+		 */
+		this._skipRows = new Set();
+
+		/**
+		 * A current row index.
+		 *
+		 * @readonly
+		 * @member {Number}
+		 * @private
+		 */
+		this._row = 0;
+
+		/**
+		 * A current column index.
+		 *
+		 * @readonly
+		 * @member {Number}
+		 * @private
+		 */
+		this._column = 0;
+
+		/**
+		 * A cell index in a parent row. For spanned cells when {@link #includeSpanned} is set to true
+		 * this represents the index of next table cell.
+		 *
+		 * @readonly
+		 * @member {Number}
+		 * @private
+		 */
+		this._cell = 0;
+
+		/**
+		 * Holds map of spanned cells in a table.
+		 *
+		 * @readonly
+		 * @member {Map<Number, Map.<Number, Number>>}
+		 * @private
+		 */
+		this._spannedCells = new Map();
+	}
+
+	/**
+	 * Iterable interface.
+	 *
+	 * @returns {Iterable.<module:table/tablewalker~TableWalkerValue>}
+	 */
+	[ Symbol.iterator ]() {
+		return this;
+	}
+
+	/**
+	 * Gets the next table walker's value.
+	 *
+	 * @returns {module:table/tablewalker~TableWalkerValue} Next table walker's value.
+	 */
+	next() {
+		const row = this.table.getChild( this._row );
+
+		// Iterator is done when no row (table end) or the row is after #endRow.
+		if ( !row || this._isOverEndRow() ) {
+			return { done: true };
+		}
+
+		// Spanned cell location handling.
+		if ( this._isSpanned( this._row, this._column ) ) {
+			// Current column must be kept as it will be updated before returning current value.
+			const currentColumn = this._column;
+			const outValue = this._formatOutValue( undefined, currentColumn );
+
+			// Advance to next column - always.
+			this._column++;
+
+			const skipCurrentValue = !this.includeSpanned || this._shouldSkipRow() || this._shouldSkipColumn( currentColumn, 1 );
+
+			// The current value will be returned only if #includedSpanned=true and also current row and column are not skipped.
+			return skipCurrentValue ? this.next() : outValue;
+		}
+
+		// The cell location is not spanned by other cells.
+		const cell = row.getChild( this._cell );
+
+		if ( !cell ) {
+			// If there are no more cells left in row advance to next row.
+			this._row++;
+			// And reset column & cell indexes.
+			this._column = 0;
+			this._cell = 0;
+
+			// Return next value.
+			return this.next();
+		}
+
+		// Read table cell attributes.
+		const colspan = parseInt( cell.getAttribute( 'colspan' ) || 1 );
+		const rowspan = parseInt( cell.getAttribute( 'rowspan' ) || 1 );
+
+		// Record this cell spans if it's not 1x1 cell.
+		if ( colspan > 1 || rowspan > 1 ) {
+			this._recordSpans( this._row, this._column, rowspan, colspan );
+		}
+
+		// Current column must be kept as it will be updated before returning current value.
+		const currentColumn = this._column;
+		const outValue = this._formatOutValue( cell, currentColumn, rowspan, colspan );
+
+		// Advance to next column before returning value.
+		this._column++;
+
+		// Advance to next cell in a parent row before returning value.
+		this._cell++;
+
+		const skipCurrentValue = this._shouldSkipRow() || this._shouldSkipColumn( currentColumn, colspan );
+
+		// The current value will be returned only if current row & column are not skipped.
+		return skipCurrentValue ? this.next() : outValue;
+	}
+
+	/**
+	 * Mark a row to skip on next iteration - will skip also cells from current row if any.
+	 *
+	 * @param {Number} row Row index to skip.
+	 */
+	skipRow( row ) {
+		this._skipRows.add( row );
+	}
+
+	/**
+	 * Check if current row is over {@link #endRow}.
+	 *
+	 * @returns {Boolean}
+	 * @private
+	 */
+	_isOverEndRow() {
+		// If {@link #endRow) is defined skipp all rows above it.
+		return this.endRow !== undefined && this._row > this.endRow;
+	}
+
+	/**
+	 * Common method for formatting iterator's out value.
+	 *
+	 * @param {module:engine/model/element~Element|undefined} cell Table cell to output. Might be undefined for spanned cell locations.
+	 * @param {Number} column Column index (use cached value)
+	 * @param {Number} rowspan Rowspan of current cell.
+	 * @param {Number} colspan Colspan of current cell.
+	 * @returns {{done: boolean, value: {cell: *, row: Number, column: *, rowspan: *, colspan: *, cellIndex: Number}}}
+	 * @private
+	 */
+	_formatOutValue( cell, column, rowspan = 1, colspan = 1 ) {
+		return {
+			done: false,
+			value: {
+				cell,
+				row: this._row,
+				column,
+				rowspan,
+				colspan,
+				cellIndex: this._cell
+			}
+		};
+	}
+
+	/**
+	 * Checks if current row should be skipped.
+	 *
+	 * @returns {Boolean}
+	 * @private
+	 */
+	_shouldSkipRow() {
+		const rowIsBelowStartRow = this._row < this.startRow;
+		const rowIsMarkedAsSkipped = this._skipRows.has( this._row );
+
+		return rowIsBelowStartRow || rowIsMarkedAsSkipped;
+	}
+
+	/**
+	 * Checks if current column should be skipped.
+	 *
+	 * @param {Number} column
+	 * @param {Number} colspan
+	 * @returns {Boolean}
+	 * @private
+	 */
+	_shouldSkipColumn( column, colspan ) {
+		if ( this.column === undefined ) {
+			// The {@link #column} is not defined so output all columns.
+			return false;
+		}
+
+		// When outputting cells from given column we skip:
+		// - Cells that are not on that column.
+		const isCurrentColumn = column === this.column;
+		// - CSells that are before given column and they overlaps given column.
+		const isPreviousThatOverlapsColumn = column < this.column && column + colspan > this.column;
+
+		return !isCurrentColumn && !isPreviousThatOverlapsColumn;
+	}
+
+	/**
+	 * Checks if current cell location - row x column - is spanned by other cell.
+	 *
+	 * @param {Number} row Row index of a cell location to check.
+	 * @param {Number} column Column index of a cell location to check.
+	 * @returns {Boolean}
+	 * @private
+	 */
+	_isSpanned( row, column ) {
+		if ( !this._spannedCells.has( row ) ) {
+			// No spans for given row.
+			return false;
+		}
+
+		const rowSpans = this._spannedCells.get( row );
+
+		// If spans for given rows has entry for column it means that this location if spanned by other cell.
+		return rowSpans.has( column );
+	}
+
+	/**
+	 * Updates spanned cells map relative to current cell location and it's span dimensions.
+	 *
+	 * @param {Number} row Row index of a cell.
+	 * @param {Number} column Column index of a cell.
+	 * @param {Number} rowspan Cell's height.
+	 * @param {Number} colspan Cell's width.
+	 * @private
+	 */
+	_recordSpans( row, column, rowspan, colspan ) {
+		// This will update all cell locations after current column - ie a cell has colspan set.
+		for ( let columnToUpdate = column + 1; columnToUpdate <= column + colspan - 1; columnToUpdate++ ) {
+			this._markSpannedCell( row, columnToUpdate );
+		}
+
+		// This will update all rows below current up to row's height.
+		for ( let rowToUpdate = row + 1; rowToUpdate < row + rowspan; rowToUpdate++ ) {
+			for ( let columnToUpdate = column; columnToUpdate <= column + colspan - 1; columnToUpdate++ ) {
+				this._markSpannedCell( rowToUpdate, columnToUpdate );
+			}
+		}
+	}
+
+	/**
+	 * Marks cell location as spanned by other cell.
+	 *
+	 * @param {Number} row Row index of cell location.
+	 * @param {Number} column Column index of cell location.
+	 * @private
+	 */
+	_markSpannedCell( row, column ) {
+		if ( !this._spannedCells.has( row ) ) {
+			this._spannedCells.set( row, new Map() );
+		}
+
+		const rowSpans = this._spannedCells.get( row );
+
+		rowSpans.set( column, true );
+	}
+}
+
+/**
+ * Object returned by {@link module:table/tablewalker~TableWalker} when traversing table cells.
+ *
+ * @typedef {Object} module:table/tablewalker~TableWalkerValue
+ * @property {module:engine/model/element~Element} [cell] Current table cell. Might be empty if
+ * {@link module:table/tablewalker~TableWalker#includeSpanned} is set to true.
+ * @property {Number} row The row index of a cell.
+ * @property {Number} column The column index of a cell. Column index is adjusted to widths & heights of previous cells.
+ * @property {Number} [colspan] The colspan attribute of a cell - always defined even if model attribute is not present. Not set if
+ * {@link module:table/tablewalker~TableWalker#includeSpanned} is set to true.
+ * @property {Number} [rowspan] The rowspan attribute of a cell - always defined even if model attribute is not present. Not set if
+ * {@link module:table/tablewalker~TableWalker#includeSpanned} is set to true.
+ * @property {Number} cellIndex The index of a current cell in a parent row. When using `includeSpanned` option it will indicate next child
+ * index if #cell is empty (which indicates that cell is spanned by other cell).
+ */

+ 121 - 0
packages/ckeditor5-table/tests/_utils/utils.js

@@ -0,0 +1,121 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+function formatAttributes( attributes ) {
+	let attributesString = '';
+
+	if ( attributes ) {
+		const entries = Object.entries( attributes );
+
+		if ( entries.length ) {
+			attributesString = ' ' + entries.map( entry => `${ entry[ 0 ] }="${ entry[ 1 ] }"` ).join( ' ' );
+		}
+	}
+	return attributesString;
+}
+
+function makeRows( tableData, cellElement, rowElement, headingElement = 'th' ) {
+	const tableRows = tableData
+		.reduce( ( previousRowsString, tableRow ) => {
+			const tableRowString = tableRow.reduce( ( tableRowString, tableCellData ) => {
+				let tableCell = tableCellData;
+
+				const isObject = typeof tableCellData === 'object';
+
+				let resultingCellElement = cellElement;
+
+				if ( isObject ) {
+					tableCell = tableCellData.contents;
+
+					if ( tableCellData.isHeading ) {
+						resultingCellElement = headingElement;
+					}
+
+					delete tableCellData.contents;
+					delete tableCellData.isHeading;
+				}
+
+				const formattedAttributes = formatAttributes( isObject ? tableCellData : '' );
+				tableRowString += `<${ resultingCellElement }${ formattedAttributes }>${ tableCell }</${ resultingCellElement }>`;
+
+				return tableRowString;
+			}, '' );
+
+			return `${ previousRowsString }<${ rowElement }>${ tableRowString }</${ rowElement }>`;
+		}, '' );
+	return tableRows;
+}
+
+/**
+ * @param {Number} columns
+ * @param {Array.<String>} tableData
+ * @param {Object} [attributes]
+ *
+ * @returns {String}
+ */
+export function modelTable( tableData, attributes ) {
+	const tableRows = makeRows( tableData, 'tableCell', 'tableRow', 'tableCell' );
+
+	return `<table${ formatAttributes( attributes ) }>${ tableRows }</table>`;
+}
+
+/**
+ * @param {Number} columns
+ * @param {Array.<String>} tableData
+ * @param {Object} [attributes]
+ *
+ * @returns {String}
+ */
+export function viewTable( tableData, attributes = {} ) {
+	const headingRows = attributes.headingRows || 0;
+
+	const thead = headingRows > 0 ? `<thead>${ makeRows( tableData.slice( 0, headingRows ), 'th', 'tr' ) }</thead>` : '';
+	const tbody = tableData.length > headingRows ? `<tbody>${ makeRows( tableData.slice( headingRows ), 'td', 'tr' ) }</tbody>` : '';
+
+	return `<table>${ thead }${ tbody }</table>`;
+}
+
+/**
+ * Formats model or view table - useful for chai assertions debuging.
+ *
+ * @param {String} tableString
+ * @returns {String}
+ */
+export function formatTable( tableString ) {
+	return tableString
+		.replace( /<tableRow>/g, '\n<tableRow>\n    ' )
+		.replace( /<thead>/g, '\n<thead>\n    ' )
+		.replace( /<tbody>/g, '\n<tbody>\n    ' )
+		.replace( /<tr>/g, '\n<tr>\n    ' )
+		.replace( /<\/tableRow>/g, '\n</tableRow>' )
+		.replace( /<\/thead>/g, '\n</thead>' )
+		.replace( /<\/tbody>/g, '\n</tbody>' )
+		.replace( /<\/tr>/g, '\n</tr>' )
+		.replace( /<\/table>/g, '\n</table>' );
+}
+
+/**
+ * Returns formatted model table string.
+ *
+ * @param {Array.<String>} tableData
+ * @param {Object} [attributes]
+ * @returns {String}
+ */
+export function formattedModelTable( tableData, attributes ) {
+	const tableString = modelTable( tableData, attributes );
+
+	return formatTable( tableString );
+}
+
+/**
+ * Returns formatted view table string.
+ *
+ * @param {Array.<String>} tableData
+ * @param {Object} [attributes]
+ * @returns {String}
+ */
+export function formattedViewTable( tableData, attributes ) {
+	return formatTable( viewTable( tableData, attributes ) );
+}

+ 305 - 0
packages/ckeditor5-table/tests/commands/insertcolumncommand.js

@@ -0,0 +1,305 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters';
+
+import InsertColumnCommand from '../../src/commands/insertcolumncommand';
+import {
+	downcastInsertCell,
+	downcastInsertRow,
+	downcastInsertTable,
+	downcastRemoveRow,
+	downcastTableHeadingColumnsChange,
+	downcastTableHeadingRowsChange
+} from '../../src/converters/downcast';
+import upcastTable from '../../src/converters/upcasttable';
+import { formatTable, formattedModelTable, modelTable } from '../_utils/utils';
+import TableUtils from '../../src/tableutils';
+
+describe( 'InsertColumnCommand', () => {
+	let editor, model, command;
+
+	beforeEach( () => {
+		return ModelTestEditor.create( {
+			plugins: [ TableUtils ]
+		} ).then( newEditor => {
+			editor = newEditor;
+			model = editor.model;
+
+			const conversion = editor.conversion;
+			const schema = model.schema;
+
+			schema.register( 'table', {
+				allowWhere: '$block',
+				allowAttributes: [ 'headingRows' ],
+				isObject: true
+			} );
+
+			schema.register( 'tableRow', { allowIn: 'table' } );
+
+			schema.register( 'tableCell', {
+				allowIn: 'tableRow',
+				allowContentOf: '$block',
+				allowAttributes: [ 'colspan', 'rowspan' ],
+				isLimit: true
+			} );
+
+			model.schema.register( 'p', { inheritAllFrom: '$block' } );
+
+			// Table conversion.
+			conversion.for( 'upcast' ).add( upcastTable() );
+			conversion.for( 'downcast' ).add( downcastInsertTable() );
+
+			// Insert row conversion.
+			conversion.for( 'downcast' ).add( downcastInsertRow() );
+
+			// Remove row conversion.
+			conversion.for( 'downcast' ).add( downcastRemoveRow() );
+
+			// Table cell conversion.
+			conversion.for( 'downcast' ).add( downcastInsertCell() );
+
+			conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'td' } ) );
+			conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'th' } ) );
+
+			// Table attributes conversion.
+			conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } );
+			conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } );
+
+			conversion.for( 'downcast' ).add( downcastTableHeadingColumnsChange() );
+			conversion.for( 'downcast' ).add( downcastTableHeadingRowsChange() );
+		} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	describe( 'order=after', () => {
+		beforeEach( () => {
+			command = new InsertColumnCommand( editor );
+		} );
+
+		describe( 'isEnabled', () => {
+			it( 'should be false if wrong node', () => {
+				setData( model, '<p>foo[]</p>' );
+				expect( command.isEnabled ).to.be.false;
+			} );
+
+			it( 'should be true if in table', () => {
+				setData( model, modelTable( [ [ '[]' ] ] ) );
+				expect( command.isEnabled ).to.be.true;
+			} );
+		} );
+
+		describe( 'execute()', () => {
+			it( 'should insert column in given table at given index', () => {
+				setData( model, modelTable( [
+					[ '11[]', '12' ],
+					[ '21', '22' ]
+				] ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '11[]', '', '12' ],
+					[ '21', '', '22' ]
+				] ) );
+			} );
+
+			it( 'should insert columns at table end', () => {
+				setData( model, modelTable( [
+					[ '11', '12' ],
+					[ '21', '22[]' ]
+				] ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '11', '12', '' ],
+					[ '21', '22[]', '' ]
+				] ) );
+			} );
+
+			it( 'should update table heading columns attribute when inserting column in headings section', () => {
+				setData( model, modelTable( [
+					[ '11[]', '12' ],
+					[ '21', '22' ],
+					[ '31', '32' ]
+				], { headingColumns: 2 } ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '11[]', '', '12' ],
+					[ '21', '', '22' ],
+					[ '31', '', '32' ]
+				], { headingColumns: 3 } ) );
+			} );
+
+			it( 'should not update table heading columns attribute when inserting column after headings section', () => {
+				setData( model, modelTable( [
+					[ '11', '12[]', '13' ],
+					[ '21', '22', '23' ],
+					[ '31', '32', '33' ]
+				], { headingColumns: 2 } ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '11', '12[]', '', '13' ],
+					[ '21', '22', '', '23' ],
+					[ '31', '32', '', '33' ]
+				], { headingColumns: 2 } ) );
+			} );
+
+			it( 'should skip spanned columns', () => {
+				setData( model, modelTable( [
+					[ '11[]', '12' ],
+					[ { colspan: 2, contents: '21' } ],
+					[ '31', '32' ]
+				], { headingColumns: 2 } ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '11[]', '', '12' ],
+					[ { colspan: 3, contents: '21' } ],
+					[ '31', '', '32' ]
+				], { headingColumns: 3 } ) );
+			} );
+
+			it( 'should skip wide spanned columns', () => {
+				setData( model, modelTable( [
+					[ '11', '12[]', '13', '14', '15' ],
+					[ '21', '22', { colspan: 2, contents: '23' }, '25' ],
+					[ { colspan: 4, contents: '31' }, { colspan: 2, contents: '34' } ]
+				], { headingColumns: 4 } ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '11', '12[]', '', '13', '14', '15' ],
+					[ '21', '22', '', { colspan: 2, contents: '23' }, '25' ],
+					[ { colspan: 5, contents: '31' }, { colspan: 2, contents: '34' } ]
+				], { headingColumns: 5 } ) );
+			} );
+		} );
+	} );
+
+	describe( 'order=before', () => {
+		beforeEach( () => {
+			command = new InsertColumnCommand( editor, { order: 'before' } );
+		} );
+
+		describe( 'isEnabled', () => {
+			it( 'should be false if wrong node', () => {
+				setData( model, '<p>foo[]</p>' );
+				expect( command.isEnabled ).to.be.false;
+			} );
+
+			it( 'should be true if in table', () => {
+				setData( model, modelTable( [ [ '[]' ] ] ) );
+				expect( command.isEnabled ).to.be.true;
+			} );
+		} );
+
+		describe( 'execute()', () => {
+			it( 'should insert column in given table at given index', () => {
+				setData( model, modelTable( [
+					[ '11', '12[]' ],
+					[ '21', '22' ]
+				] ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '11', '', '12[]' ],
+					[ '21', '', '22' ]
+				] ) );
+			} );
+
+			it( 'should insert columns at the table start', () => {
+				setData( model, modelTable( [
+					[ '11', '12' ],
+					[ '[]21', '22' ]
+				] ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '', '11', '12' ],
+					[ '', '[]21', '22' ]
+				] ) );
+			} );
+
+			it( 'should update table heading columns attribute when inserting column in headings section', () => {
+				setData( model, modelTable( [
+					[ '11', '12[]' ],
+					[ '21', '22' ],
+					[ '31', '32' ]
+				], { headingColumns: 2 } ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '11', '', '12[]' ],
+					[ '21', '', '22' ],
+					[ '31', '', '32' ]
+				], { headingColumns: 3 } ) );
+			} );
+
+			it( 'should not update table heading columns attribute when inserting column after headings section', () => {
+				setData( model, modelTable( [
+					[ '11', '12', '13[]' ],
+					[ '21', '22', '23' ],
+					[ '31', '32', '33' ]
+				], { headingColumns: 2 } ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '11', '12', '', '13[]' ],
+					[ '21', '22', '', '23' ],
+					[ '31', '32', '', '33' ]
+				], { headingColumns: 2 } ) );
+			} );
+
+			it( 'should skip spanned columns', () => {
+				setData( model, modelTable( [
+					[ '11', '12[]' ],
+					[ { colspan: 2, contents: '21' } ],
+					[ '31', '32' ]
+				], { headingColumns: 2 } ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '11', '', '12[]' ],
+					[ { colspan: 3, contents: '21' } ],
+					[ '31', '', '32' ]
+				], { headingColumns: 3 } ) );
+			} );
+
+			it( 'should skip wide spanned columns', () => {
+				setData( model, modelTable( [
+					[ '11', '12', '13[]', '14', '15' ],
+					[ '21', '22', { colspan: 2, contents: '23' }, '25' ],
+					[ { colspan: 4, contents: '31' }, { colspan: 2, contents: '34' } ]
+				], { headingColumns: 4 } ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '11', '12', '', '13[]', '14', '15' ],
+					[ '21', '22', '', { colspan: 2, contents: '23' }, '25' ],
+					[ { colspan: 5, contents: '31' }, { colspan: 2, contents: '34' } ]
+				], { headingColumns: 5 } ) );
+			} );
+		} );
+	} );
+} );

+ 302 - 0
packages/ckeditor5-table/tests/commands/insertrowcommand.js

@@ -0,0 +1,302 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters';
+
+import InsertRowCommand from '../../src/commands/insertrowcommand';
+import {
+	downcastInsertCell,
+	downcastInsertRow,
+	downcastInsertTable,
+	downcastRemoveRow,
+	downcastTableHeadingColumnsChange,
+	downcastTableHeadingRowsChange
+} from '../../src/converters/downcast';
+import upcastTable from '../../src/converters/upcasttable';
+import { formatTable, formattedModelTable, modelTable } from '../_utils/utils';
+import TableUtils from '../../src/tableutils';
+
+describe( 'InsertRowCommand', () => {
+	let editor, model, command;
+
+	beforeEach( () => {
+		return ModelTestEditor.create( {
+			plugins: [ TableUtils ]
+		} ).then( newEditor => {
+			editor = newEditor;
+			model = editor.model;
+
+			const conversion = editor.conversion;
+			const schema = model.schema;
+
+			schema.register( 'table', {
+				allowWhere: '$block',
+				allowAttributes: [ 'headingRows' ],
+				isObject: true
+			} );
+
+			schema.register( 'tableRow', { allowIn: 'table' } );
+
+			schema.register( 'tableCell', {
+				allowIn: 'tableRow',
+				allowContentOf: '$block',
+				allowAttributes: [ 'colspan', 'rowspan' ],
+				isLimit: true
+			} );
+
+			model.schema.register( 'p', { inheritAllFrom: '$block' } );
+
+			// Table conversion.
+			conversion.for( 'upcast' ).add( upcastTable() );
+			conversion.for( 'downcast' ).add( downcastInsertTable() );
+
+			// Insert row conversion.
+			conversion.for( 'downcast' ).add( downcastInsertRow() );
+
+			// Remove row conversion.
+			conversion.for( 'downcast' ).add( downcastRemoveRow() );
+
+			// Table cell conversion.
+			conversion.for( 'downcast' ).add( downcastInsertCell() );
+
+			conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'td' } ) );
+			conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'th' } ) );
+
+			// Table attributes conversion.
+			conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } );
+			conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } );
+
+			conversion.for( 'downcast' ).add( downcastTableHeadingColumnsChange() );
+			conversion.for( 'downcast' ).add( downcastTableHeadingRowsChange() );
+		} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	describe( 'order=below', () => {
+		beforeEach( () => {
+			command = new InsertRowCommand( editor );
+		} );
+
+		describe( 'isEnabled', () => {
+			it( 'should be false if wrong node', () => {
+				setData( model, '<p>foo[]</p>' );
+				expect( command.isEnabled ).to.be.false;
+			} );
+
+			it( 'should be true if in table', () => {
+				setData( model, modelTable( [ [ '[]' ] ] ) );
+				expect( command.isEnabled ).to.be.true;
+			} );
+		} );
+
+		describe( 'execute()', () => {
+			it( 'should insert row after current position', () => {
+				setData( model, modelTable( [
+					[ '00[]', '01' ],
+					[ '10', '11' ]
+				] ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '00[]', '01' ],
+					[ '', '' ],
+					[ '10', '11' ]
+				] ) );
+			} );
+
+			it( 'should update table heading rows attribute when inserting row in headings section', () => {
+				setData( model, modelTable( [
+					[ '00[]', '01' ],
+					[ '10', '11' ],
+					[ '20', '21' ]
+				], { headingRows: 2 } ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '00[]', '01' ],
+					[ '', '' ],
+					[ '10', '11' ],
+					[ '20', '21' ]
+				], { headingRows: 3 } ) );
+			} );
+
+			it( 'should not update table heading rows attribute when inserting row after headings section', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10[]', '11' ],
+					[ '20', '21' ]
+				], { headingRows: 2 } ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '00', '01' ],
+					[ '10[]', '11' ],
+					[ '', '' ],
+					[ '20', '21' ]
+				], { headingRows: 2 } ) );
+			} );
+
+			it( 'should expand rowspan of a cell that overlaps inserted rows', () => {
+				setData( model, modelTable( [
+					[ { colspan: 2, contents: '00' }, '02', '03' ],
+					[ { colspan: 2, rowspan: 4, contents: '10[]' }, '12', '13' ],
+					[ '22', '23' ]
+				], { headingColumns: 3, headingRows: 1 } ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ { colspan: 2, contents: '00' }, '02', '03' ],
+					[ { colspan: 2, rowspan: 5, contents: '10[]' }, '12', '13' ],
+					[ '', '' ],
+					[ '22', '23' ]
+				], { headingColumns: 3, headingRows: 1 } ) );
+			} );
+
+			it( 'should not expand rowspan of a cell that does not overlaps inserted rows', () => {
+				setData( model, modelTable( [
+					[ { rowspan: 2, contents: '00' }, '01', '02' ],
+					[ '11[]', '12' ],
+					[ '20', '21', '22' ]
+				], { headingColumns: 3, headingRows: 1 } ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ { rowspan: 2, contents: '00' }, '01', '02' ],
+					[ '11[]', '12' ],
+					[ '', '', '' ],
+					[ '20', '21', '22' ]
+				], { headingColumns: 3, headingRows: 1 } ) );
+			} );
+
+			it( 'should properly calculate columns if next row has colspans', () => {
+				setData( model, modelTable( [
+					[ { rowspan: 2, contents: '00' }, '01', '02' ],
+					[ '11[]', '12' ],
+					[ { colspan: 3, contents: '20' } ]
+				], { headingColumns: 3, headingRows: 1 } ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ { rowspan: 2, contents: '00' }, '01', '02' ],
+					[ '11[]', '12' ],
+					[ '', '', '' ],
+					[ { colspan: 3, contents: '20' } ]
+				], { headingColumns: 3, headingRows: 1 } ) );
+			} );
+
+			it( 'should insert rows at the end of a table', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10[]', '11' ]
+				] ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '00', '01' ],
+					[ '10[]', '11' ],
+					[ '', '' ]
+				] ) );
+			} );
+		} );
+	} );
+
+	describe( 'order=above', () => {
+		beforeEach( () => {
+			command = new InsertRowCommand( editor, { order: 'above' } );
+		} );
+
+		describe( 'isEnabled', () => {
+			it( 'should be false if wrong node', () => {
+				setData( model, '<p>foo[]</p>' );
+				expect( command.isEnabled ).to.be.false;
+			} );
+
+			it( 'should be true if in table', () => {
+				setData( model, modelTable( [ [ '[]' ] ] ) );
+				expect( command.isEnabled ).to.be.true;
+			} );
+		} );
+
+		describe( 'execute()', () => {
+			it( 'should insert row at the beginning of a table', () => {
+				setData( model, modelTable( [
+					[ '00[]', '01' ],
+					[ '10', '11' ]
+				] ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '', '' ],
+					[ '00[]', '01' ],
+					[ '10', '11' ]
+				] ) );
+			} );
+
+			it( 'should insert row at the end of a table', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10', '11' ],
+					[ '20[]', '21' ]
+				] ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '00', '01' ],
+					[ '10', '11' ],
+					[ '', '' ],
+					[ '20[]', '21' ]
+				] ) );
+			} );
+
+			it( 'should update table heading rows attribute when inserting row in headings section', () => {
+				setData( model, modelTable( [
+					[ '00[]', '01' ],
+					[ '10', '11' ],
+					[ '20', '21' ]
+				], { headingRows: 2 } ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '', '' ],
+					[ '00[]', '01' ],
+					[ '10', '11' ],
+					[ '20', '21' ]
+				], { headingRows: 3 } ) );
+			} );
+
+			it( 'should not update table heading rows attribute when inserting row after headings section', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10', '11' ],
+					[ '20[]', '21' ]
+				], { headingRows: 2 } ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '00', '01' ],
+					[ '10', '11' ],
+					[ '', '' ],
+					[ '20[]', '21' ]
+				], { headingRows: 2 } ) );
+			} );
+		} );
+	} );
+} );

+ 139 - 0
packages/ckeditor5-table/tests/commands/inserttablecommand.js

@@ -0,0 +1,139 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters';
+
+import InsertTableCommand from '../../src/commands/inserttablecommand';
+import {
+	downcastInsertCell,
+	downcastInsertRow,
+	downcastInsertTable,
+	downcastRemoveRow,
+	downcastTableHeadingColumnsChange,
+	downcastTableHeadingRowsChange
+} from '../../src/converters/downcast';
+import upcastTable from '../../src/converters/upcasttable';
+import TableUtils from '../../src/tableutils';
+
+describe( 'InsertTableCommand', () => {
+	let editor, model, command;
+
+	beforeEach( () => {
+		return ModelTestEditor.create( {
+			plugins: [ TableUtils ]
+		} ).then( newEditor => {
+			editor = newEditor;
+			model = editor.model;
+			command = new InsertTableCommand( editor );
+
+			const conversion = editor.conversion;
+			const schema = model.schema;
+
+			schema.register( 'table', {
+				allowWhere: '$block',
+				allowAttributes: [ 'headingRows' ],
+				isObject: true
+			} );
+
+			schema.register( 'tableRow', { allowIn: 'table' } );
+
+			schema.register( 'tableCell', {
+				allowIn: 'tableRow',
+				allowContentOf: '$block',
+				allowAttributes: [ 'colspan', 'rowspan' ],
+				isLimit: true
+			} );
+
+			model.schema.register( 'p', { inheritAllFrom: '$block' } );
+
+			// Table conversion.
+			conversion.for( 'upcast' ).add( upcastTable() );
+			conversion.for( 'downcast' ).add( downcastInsertTable() );
+
+			// Insert row conversion.
+			conversion.for( 'downcast' ).add( downcastInsertRow() );
+
+			// Remove row conversion.
+			conversion.for( 'downcast' ).add( downcastRemoveRow() );
+
+			// Table cell conversion.
+			conversion.for( 'downcast' ).add( downcastInsertCell() );
+
+			conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'td' } ) );
+			conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'th' } ) );
+
+			// Table attributes conversion.
+			conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } );
+			conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } );
+
+			conversion.for( 'downcast' ).add( downcastTableHeadingColumnsChange() );
+			conversion.for( 'downcast' ).add( downcastTableHeadingRowsChange() );
+		} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	describe( 'isEnabled', () => {
+		describe( 'when selection is collapsed', () => {
+			it( 'should be true if in paragraph', () => {
+				setData( model, '<p>foo[]</p>' );
+				expect( command.isEnabled ).to.be.true;
+			} );
+
+			it( 'should be false if in table', () => {
+				setData( model, '<table><tableRow><tableCell>foo[]</tableCell></tableRow></table>' );
+				expect( command.isEnabled ).to.be.false;
+			} );
+		} );
+	} );
+
+	describe( 'execute()', () => {
+		describe( 'collapsed selection', () => {
+			it( 'should insert table in empty root', () => {
+				setData( model, '[]' );
+
+				command.execute();
+
+				expect( getData( model ) ).to.equal(
+					'<table>' +
+					'<tableRow><tableCell></tableCell><tableCell></tableCell></tableRow>' +
+					'<tableRow><tableCell></tableCell><tableCell></tableCell></tableRow>' +
+					'</table>[]'
+				);
+			} );
+
+			it( 'should insert table with two rows and two columns after non-empty paragraph', () => {
+				setData( model, '<p>foo[]</p>' );
+
+				command.execute();
+
+				expect( getData( model ) ).to.equal( '<p>foo[]</p>' +
+					'<table>' +
+					'<tableRow><tableCell></tableCell><tableCell></tableCell></tableRow>' +
+					'<tableRow><tableCell></tableCell><tableCell></tableCell></tableRow>' +
+					'</table>'
+				);
+			} );
+
+			it( 'should insert table with given rows and columns after non-empty paragraph', () => {
+				setData( model, '<p>foo[]</p>' );
+
+				command.execute( { rows: 3, columns: 4 } );
+
+				expect( getData( model ) ).to.equal( '<p>foo[]</p>' +
+					'<table>' +
+					'<tableRow><tableCell></tableCell><tableCell></tableCell><tableCell></tableCell><tableCell></tableCell></tableRow>' +
+					'<tableRow><tableCell></tableCell><tableCell></tableCell><tableCell></tableCell><tableCell></tableCell></tableRow>' +
+					'<tableRow><tableCell></tableCell><tableCell></tableCell><tableCell></tableCell><tableCell></tableCell></tableRow>' +
+					'</table>'
+				);
+			} );
+		} );
+	} );
+} );

+ 518 - 0
packages/ckeditor5-table/tests/commands/mergecellcommand.js

@@ -0,0 +1,518 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters';
+
+import MergeCellCommand from '../../src/commands/mergecellcommand';
+import {
+	downcastInsertCell,
+	downcastInsertRow,
+	downcastInsertTable,
+	downcastRemoveRow,
+	downcastTableHeadingColumnsChange,
+	downcastTableHeadingRowsChange
+} from '../../src/converters/downcast';
+import upcastTable from '../../src/converters/upcasttable';
+import { formatTable, formattedModelTable, modelTable } from '../_utils/utils';
+
+describe( 'MergeCellCommand', () => {
+	let editor, model, command, root;
+
+	beforeEach( () => {
+		return ModelTestEditor.create()
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				root = model.document.getRoot( 'main' );
+
+				const conversion = editor.conversion;
+				const schema = model.schema;
+
+				schema.register( 'table', {
+					allowWhere: '$block',
+					allowAttributes: [ 'headingRows' ],
+					isObject: true
+				} );
+
+				schema.register( 'tableRow', { allowIn: 'table' } );
+
+				schema.register( 'tableCell', {
+					allowIn: 'tableRow',
+					allowContentOf: '$block',
+					allowAttributes: [ 'colspan', 'rowspan' ],
+					isLimit: true
+				} );
+
+				model.schema.register( 'p', { inheritAllFrom: '$block' } );
+
+				// Table conversion.
+				conversion.for( 'upcast' ).add( upcastTable() );
+				conversion.for( 'downcast' ).add( downcastInsertTable() );
+
+				// Insert row conversion.
+				conversion.for( 'downcast' ).add( downcastInsertRow() );
+
+				// Remove row conversion.
+				conversion.for( 'downcast' ).add( downcastRemoveRow() );
+
+				// Table cell conversion.
+				conversion.for( 'downcast' ).add( downcastInsertCell() );
+
+				conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'td' } ) );
+				conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'th' } ) );
+
+				// Table attributes conversion.
+				conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } );
+				conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } );
+
+				conversion.for( 'downcast' ).add( downcastTableHeadingColumnsChange() );
+				conversion.for( 'downcast' ).add( downcastTableHeadingRowsChange() );
+			} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	describe( 'direction=right', () => {
+		beforeEach( () => {
+			command = new MergeCellCommand( editor, { direction: 'right' } );
+		} );
+
+		describe( 'isEnabled', () => {
+			it( 'should be true if in cell that has sibling on the right', () => {
+				setData( model, modelTable( [
+					[ '00[]', '01' ]
+				] ) );
+
+				expect( command.isEnabled ).to.be.true;
+			} );
+
+			it( 'should be false if last cell of a row', () => {
+				setData( model, modelTable( [
+					[ '00', '01[]' ]
+				] ) );
+
+				expect( command.isEnabled ).to.be.false;
+			} );
+
+			it( 'should be true if in a cell that has sibling on the right with the same rowspan', () => {
+				setData( model, modelTable( [
+					[ { rowspan: 2, contents: '00[]' }, { rowspan: 2, contents: '01' } ]
+				] ) );
+
+				expect( command.isEnabled ).to.be.true;
+			} );
+
+			it( 'should be false if in a cell that has sibling but with different rowspan', () => {
+				setData( model, modelTable( [
+					[ { rowspan: 2, contents: '00[]' }, { rowspan: 3, contents: '01' } ]
+				] ) );
+
+				expect( command.isEnabled ).to.be.false;
+			} );
+
+			it( 'should be false if not in a cell', () => {
+				setData( model, '<p>11[]</p>' );
+
+				expect( command.isEnabled ).to.be.false;
+			} );
+		} );
+
+		describe( 'value', () => {
+			it( 'should be set to mergeable sibling if in cell that has sibling on the right', () => {
+				setData( model, modelTable( [
+					[ '00[]', '01' ]
+				] ) );
+
+				expect( command.value ).to.equal( root.getNodeByPath( [ 0, 0, 1 ] ) );
+			} );
+
+			it( 'should be undefined if last cell of a row', () => {
+				setData( model, modelTable( [
+					[ '00', '01[]' ]
+				] ) );
+
+				expect( command.value ).to.be.undefined;
+			} );
+
+			it( 'should be set to mergeable sibling if in a cell that has sibling on the right with the same rowspan', () => {
+				setData( model, modelTable( [
+					[ { rowspan: 2, contents: '00[]' }, { rowspan: 2, contents: '01' } ]
+				] ) );
+
+				expect( command.value ).to.equal( root.getNodeByPath( [ 0, 0, 1 ] ) );
+			} );
+
+			it( 'should be undefined if in a cell that has sibling but with different rowspan', () => {
+				setData( model, modelTable( [
+					[ { rowspan: 2, contents: '00[]' }, { rowspan: 3, contents: '01' } ]
+				] ) );
+
+				expect( command.value ).to.be.undefined;
+			} );
+
+			it( 'should be undefined if not in a cell', () => {
+				setData( model, '<p>11[]</p>' );
+
+				expect( command.value ).to.be.undefined;
+			} );
+		} );
+
+		describe( 'execute()', () => {
+			it( 'should merge table cells ', () => {
+				setData( model, modelTable( [
+					[ '[]00', '01' ]
+				] ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ { colspan: 2, contents: '[0001]' } ]
+				] ) );
+			} );
+		} );
+	} );
+
+	describe( 'direction=left', () => {
+		beforeEach( () => {
+			command = new MergeCellCommand( editor, { direction: 'left' } );
+		} );
+
+		describe( 'isEnabled', () => {
+			it( 'should be true if in cell that has sibling on the left', () => {
+				setData( model, modelTable( [
+					[ '00', '01[]' ]
+				] ) );
+
+				expect( command.isEnabled ).to.be.true;
+			} );
+
+			it( 'should be false if first cell of a row', () => {
+				setData( model, modelTable( [
+					[ '00[]', '01' ]
+				] ) );
+
+				expect( command.isEnabled ).to.be.false;
+			} );
+
+			it( 'should be true if in a cell that has sibling on the left with the same rowspan', () => {
+				setData( model, modelTable( [
+					[ { rowspan: 2, contents: '00' }, { rowspan: 2, contents: '01[]' } ]
+				] ) );
+
+				expect( command.isEnabled ).to.be.true;
+			} );
+
+			it( 'should be false if in a cell that has sibling but with different rowspan', () => {
+				setData( model, modelTable( [
+					[ { rowspan: 2, contents: '00' }, { rowspan: 3, contents: '01[]' } ]
+				] ) );
+
+				expect( command.isEnabled ).to.be.false;
+			} );
+
+			it( 'should be false if not in a cell', () => {
+				setData( model, '<p>11[]</p>' );
+
+				expect( command.isEnabled ).to.be.false;
+			} );
+		} );
+
+		describe( 'value', () => {
+			it( 'should be set to mergeable sibling if in cell that has sibling on the left', () => {
+				setData( model, modelTable( [
+					[ '00', '01[]' ]
+				] ) );
+
+				expect( command.value ).to.equal( root.getNodeByPath( [ 0, 0, 0 ] ) );
+			} );
+
+			it( 'should be undefined if first cell of a row', () => {
+				setData( model, modelTable( [
+					[ '00[]', '01' ]
+				] ) );
+
+				expect( command.value ).to.be.undefined;
+			} );
+
+			it( 'should be set to mergeable sibling if in a cell that has sibling on the left with the same rowspan', () => {
+				setData( model, modelTable( [
+					[ { rowspan: 2, contents: '00' }, { rowspan: 2, contents: '01[]' } ]
+				] ) );
+
+				expect( command.value ).to.equal( root.getNodeByPath( [ 0, 0, 0 ] ) );
+			} );
+
+			it( 'should be undefined if in a cell that has sibling but with different rowspan', () => {
+				setData( model, modelTable( [
+					[ { rowspan: 2, contents: '00' }, { rowspan: 3, contents: '01[]' } ]
+				] ) );
+
+				expect( command.value ).to.be.undefined;
+			} );
+
+			it( 'should be undefined if not in a cell', () => {
+				setData( model, '<p>11[]</p>' );
+
+				expect( command.value ).to.be.undefined;
+			} );
+		} );
+
+		describe( 'execute()', () => {
+			it( 'should merge table cells ', () => {
+				setData( model, modelTable( [
+					[ '00', '[]01' ]
+				] ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ { colspan: 2, contents: '[0001]' } ]
+				] ) );
+			} );
+		} );
+	} );
+
+	describe( 'direction=down', () => {
+		beforeEach( () => {
+			command = new MergeCellCommand( editor, { direction: 'down' } );
+		} );
+
+		describe( 'isEnabled', () => {
+			it( 'should be true if in cell that has mergeable cell in next row', () => {
+				setData( model, modelTable( [
+					[ '00', '01[]' ],
+					[ '10', '11' ]
+				] ) );
+
+				expect( command.isEnabled ).to.be.true;
+			} );
+
+			it( 'should be false if in last row', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10[]', '11' ]
+				] ) );
+
+				expect( command.isEnabled ).to.be.false;
+			} );
+
+			it( 'should be true if in a cell that has mergeable cell with the same colspan', () => {
+				setData( model, modelTable( [
+					[ { colspan: 2, contents: '00[]' }, '02' ],
+					[ { colspan: 2, contents: '01' }, '12' ]
+				] ) );
+
+				expect( command.isEnabled ).to.be.true;
+			} );
+
+			it( 'should be false if in a cell that potential mergeable cell has different colspan', () => {
+				setData( model, modelTable( [
+					[ { colspan: 2, contents: '00[]' }, '02' ],
+					[ { colspan: 3, contents: '01' } ]
+				] ) );
+
+				expect( command.isEnabled ).to.be.false;
+			} );
+
+			it( 'should be false if not in a cell', () => {
+				setData( model, '<p>11[]</p>' );
+
+				expect( command.isEnabled ).to.be.false;
+			} );
+
+			it( 'should be false if mergeable cell is in other table section then current cell', () => {
+				setData( model, modelTable( [
+					[ '00[]', '01' ],
+					[ '10', '11' ]
+				], { headingRows: 1 } ) );
+
+				expect( command.isEnabled ).to.be.false;
+			} );
+		} );
+
+		describe( 'value', () => {
+			it( 'should be set to mergeable cell', () => {
+				setData( model, modelTable( [
+					[ '00', '01[]' ],
+					[ '10', '11' ]
+				] ) );
+
+				expect( command.value ).to.equal( root.getNodeByPath( [ 0, 1, 1 ] ) );
+			} );
+
+			it( 'should be undefined if in last row', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10[]', '11' ]
+				] ) );
+
+				expect( command.value ).to.be.undefined;
+			} );
+
+			it( 'should be set to mergeable cell with the same rowspan', () => {
+				setData( model, modelTable( [
+					[ { colspan: 2, contents: '00[]' }, '02' ],
+					[ { colspan: 2, contents: '01' }, '12' ]
+				] ) );
+
+				expect( command.value ).to.equal( root.getNodeByPath( [ 0, 1, 0 ] ) );
+			} );
+
+			it( 'should be undefined if in a cell that potential mergeable cell has different rowspan', () => {
+				setData( model, modelTable( [
+					[ { colspan: 2, contents: '00[]' }, '02' ],
+					[ { colspan: 3, contents: '01' } ]
+				] ) );
+
+				expect( command.value ).to.be.undefined;
+			} );
+
+			it( 'should be undefined if not in a cell', () => {
+				setData( model, '<p>11[]</p>' );
+
+				expect( command.value ).to.be.undefined;
+			} );
+		} );
+
+		describe( 'execute()', () => {
+			it( 'should merge table cells ', () => {
+				setData( model, modelTable( [
+					[ '00', '01[]' ],
+					[ '10', '11' ]
+				] ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '00', { rowspan: 2, contents: '[0111]' } ],
+					[ '10' ]
+				] ) );
+			} );
+		} );
+	} );
+
+	describe( 'direction=up', () => {
+		beforeEach( () => {
+			command = new MergeCellCommand( editor, { direction: 'up' } );
+		} );
+
+		describe( 'isEnabled', () => {
+			it( 'should be true if in cell that has mergeable cell in previous row', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10', '11[]' ]
+				] ) );
+
+				expect( command.isEnabled ).to.be.true;
+			} );
+
+			it( 'should be false if in first row', () => {
+				setData( model, modelTable( [
+					[ '00[]', '01' ],
+					[ '10', '11' ]
+				] ) );
+
+				expect( command.isEnabled ).to.be.false;
+			} );
+
+			it( 'should be true if in a cell that has mergeable cell with the same colspan', () => {
+				setData( model, modelTable( [
+					[ { colspan: 2, contents: '00' }, '02' ],
+					[ { colspan: 2, contents: '01[]' }, '12' ]
+				] ) );
+
+				expect( command.isEnabled ).to.be.true;
+			} );
+
+			it( 'should be false if in a cell that potential mergeable cell has different colspan', () => {
+				setData( model, modelTable( [
+					[ { colspan: 2, contents: '00' }, '02' ],
+					[ { colspan: 3, contents: '01[]' } ]
+				] ) );
+
+				expect( command.isEnabled ).to.be.false;
+			} );
+
+			it( 'should be false if not in a cell', () => {
+				setData( model, '<p>11[]</p>' );
+
+				expect( command.isEnabled ).to.be.false;
+			} );
+
+			it( 'should be false if mergeable cell is in other table section then current cell', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10[]', '11' ]
+				], { headingRows: 1 } ) );
+
+				expect( command.isEnabled ).to.be.false;
+			} );
+		} );
+
+		describe( 'value', () => {
+			it( 'should be set to mergeable cell', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10', '11[]' ]
+				] ) );
+
+				expect( command.value ).to.equal( root.getNodeByPath( [ 0, 0, 1 ] ) );
+			} );
+
+			it( 'should be undefined if in first row', () => {
+				setData( model, modelTable( [
+					[ '00[]', '01' ],
+					[ '10', '11' ]
+				] ) );
+
+				expect( command.value ).to.be.undefined;
+			} );
+
+			it( 'should be set to mergeable cell with the same rowspan', () => {
+				setData( model, modelTable( [
+					[ { colspan: 2, contents: '00' }, '02' ],
+					[ { colspan: 2, contents: '01[]' }, '12' ]
+				] ) );
+
+				expect( command.value ).to.equal( root.getNodeByPath( [ 0, 0, 0 ] ) );
+			} );
+
+			it( 'should be undefined if in a cell that potential mergeable cell has different rowspan', () => {
+				setData( model, modelTable( [
+					[ { colspan: 2, contents: '00' }, '02' ],
+					[ { colspan: 3, contents: '01[]' } ]
+				] ) );
+
+				expect( command.value ).to.be.undefined;
+			} );
+
+			it( 'should be undefined if not in a cell', () => {
+				setData( model, '<p>11[]</p>' );
+
+				expect( command.value ).to.be.undefined;
+			} );
+		} );
+
+		describe( 'execute()', () => {
+			it( 'should merge table cells ', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10', '11[]' ]
+				] ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '00', { rowspan: 2, contents: '[0111]' } ],
+					[ '10' ]
+				] ) );
+			} );
+		} );
+	} );
+} );

+ 196 - 0
packages/ckeditor5-table/tests/commands/removecolumncommand.js

@@ -0,0 +1,196 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters';
+
+import RemoveColumnCommand from '../../src/commands/removecolumncommand';
+import {
+	downcastInsertCell,
+	downcastInsertRow,
+	downcastInsertTable,
+	downcastRemoveRow,
+	downcastTableHeadingColumnsChange,
+	downcastTableHeadingRowsChange
+} from '../../src/converters/downcast';
+import upcastTable from '../../src/converters/upcasttable';
+import { formatTable, formattedModelTable, modelTable } from '../_utils/utils';
+import TableUtils from '../../src/tableutils';
+
+describe( 'RemoveColumnCommand', () => {
+	let editor, model, command;
+
+	beforeEach( () => {
+		return ModelTestEditor.create( {
+			plugins: [ TableUtils ]
+		} ).then( newEditor => {
+			editor = newEditor;
+			model = editor.model;
+			command = new RemoveColumnCommand( editor );
+
+			const conversion = editor.conversion;
+			const schema = model.schema;
+
+			schema.register( 'table', {
+				allowWhere: '$block',
+				allowAttributes: [ 'headingRows', 'headingColumns' ],
+				isObject: true
+			} );
+
+			schema.register( 'tableRow', { allowIn: 'table' } );
+
+			schema.register( 'tableCell', {
+				allowIn: 'tableRow',
+				allowContentOf: '$block',
+				allowAttributes: [ 'colspan', 'rowspan' ],
+				isLimit: true
+			} );
+
+			model.schema.register( 'p', { inheritAllFrom: '$block' } );
+
+			// Table conversion.
+			conversion.for( 'upcast' ).add( upcastTable() );
+			conversion.for( 'downcast' ).add( downcastInsertTable() );
+
+			// Insert row conversion.
+			conversion.for( 'downcast' ).add( downcastInsertRow() );
+
+			// Remove row conversion.
+			conversion.for( 'downcast' ).add( downcastRemoveRow() );
+
+			// Table cell conversion.
+			conversion.for( 'downcast' ).add( downcastInsertCell() );
+
+			conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'td' } ) );
+			conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'th' } ) );
+
+			// Table attributes conversion.
+			conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } );
+			conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } );
+
+			conversion.for( 'downcast' ).add( downcastTableHeadingColumnsChange() );
+			conversion.for( 'downcast' ).add( downcastTableHeadingRowsChange() );
+		} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	describe( 'isEnabled', () => {
+		it( 'should be true if selection is inside table cell', () => {
+			setData( model, modelTable( [
+				[ '00[]', '01' ],
+				[ '10', '11' ]
+			] ) );
+
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be false if selection is inside table with one column only', () => {
+			setData( model, modelTable( [
+				[ '00' ],
+				[ '10[]' ],
+				[ '20[]' ]
+			] ) );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be false if selection is outside a table', () => {
+			setData( model, '<p>11[]</p>' );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+	} );
+
+	describe( 'execute()', () => {
+		it( 'should remove a given column', () => {
+			setData( model, modelTable( [
+				[ '00', '01', '02' ],
+				[ '10', '[]11', '12' ],
+				[ '20', '21', '22' ]
+			] ) );
+
+			command.execute();
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '00', '02' ],
+				[ '10[]', '12' ],
+				[ '20', '22' ]
+			] ) );
+		} );
+
+		it( 'should remove a given column from a table start', () => {
+			setData( model, modelTable( [
+				[ '[]00', '01' ],
+				[ '10', '11' ],
+				[ '20', '21' ]
+			] ) );
+
+			command.execute();
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '[]01' ],
+				[ '11' ],
+				[ '21' ]
+			] ) );
+		} );
+
+		it( 'should change heading columns if removing a heading column', () => {
+			setData( model, modelTable( [
+				[ '00', '01' ],
+				[ '[]10', '11' ],
+				[ '20', '21' ]
+			], { headingColumns: 2 } ) );
+
+			command.execute();
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '01' ],
+				[ '[]11' ],
+				[ '21' ]
+			], { headingColumns: 1 } ) );
+		} );
+
+		it( 'should decrease colspan of table cells from previous column', () => {
+			setData( model, modelTable( [
+				[ { colspan: 4, contents: '00' }, '03' ],
+				[ { colspan: 3, contents: '10' }, '13' ],
+				[ { colspan: 2, contents: '20' }, '22[]', '23' ],
+				[ '30', { colspan: 2, contents: '31' }, '33' ],
+				[ '40', '41', '42', '43' ]
+			] ) );
+
+			command.execute();
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ { colspan: 3, contents: '00' }, '03' ],
+				[ { colspan: 2, contents: '10' }, '13' ],
+				[ { colspan: 2, contents: '20[]' }, '23' ],
+				[ '30', '31', '33' ],
+				[ '40', '41', '43' ]
+
+			] ) );
+		} );
+
+		it( 'should decrease colspan of cells that are on removed column', () => {
+			setData( model, modelTable( [
+				[ { colspan: 3, contents: '[]00' }, '03' ],
+				[ { colspan: 2, contents: '10' }, '13' ],
+				[ '20', '21', '22', '23' ]
+			] ) );
+
+			command.execute();
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ { colspan: 2, contents: '[]00' }, '03' ],
+				[ '10', '13' ],
+				[ '21', '22', '23' ]
+			] ) );
+		} );
+	} );
+} );

+ 186 - 0
packages/ckeditor5-table/tests/commands/removerowcommand.js

@@ -0,0 +1,186 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters';
+
+import RemoveRowCommand from '../../src/commands/removerowcommand';
+import {
+	downcastInsertCell,
+	downcastInsertRow,
+	downcastInsertTable,
+	downcastRemoveRow,
+	downcastTableHeadingColumnsChange,
+	downcastTableHeadingRowsChange
+} from '../../src/converters/downcast';
+import upcastTable from '../../src/converters/upcasttable';
+import { formatTable, formattedModelTable, modelTable } from '../_utils/utils';
+
+describe( 'RemoveRowCommand', () => {
+	let editor, model, command;
+
+	beforeEach( () => {
+		return ModelTestEditor.create()
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				command = new RemoveRowCommand( editor );
+
+				const conversion = editor.conversion;
+				const schema = model.schema;
+
+				schema.register( 'table', {
+					allowWhere: '$block',
+					allowAttributes: [ 'headingRows' ],
+					isObject: true
+				} );
+
+				schema.register( 'tableRow', { allowIn: 'table' } );
+
+				schema.register( 'tableCell', {
+					allowIn: 'tableRow',
+					allowContentOf: '$block',
+					allowAttributes: [ 'colspan', 'rowspan' ],
+					isLimit: true
+				} );
+
+				model.schema.register( 'p', { inheritAllFrom: '$block' } );
+
+				// Table conversion.
+				conversion.for( 'upcast' ).add( upcastTable() );
+				conversion.for( 'downcast' ).add( downcastInsertTable() );
+
+				// Insert row conversion.
+				conversion.for( 'downcast' ).add( downcastInsertRow() );
+
+				// Remove row conversion.
+				conversion.for( 'downcast' ).add( downcastRemoveRow() );
+
+				// Table cell conversion.
+				conversion.for( 'downcast' ).add( downcastInsertCell() );
+
+				conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'td' } ) );
+				conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'th' } ) );
+
+				// Table attributes conversion.
+				conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } );
+				conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } );
+
+				conversion.for( 'downcast' ).add( downcastTableHeadingColumnsChange() );
+				conversion.for( 'downcast' ).add( downcastTableHeadingRowsChange() );
+			} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	describe( 'isEnabled', () => {
+		it( 'should be true if selection is inside table cell', () => {
+			setData( model, modelTable( [
+				[ '00[]', '01' ],
+				[ '10', '11' ]
+			] ) );
+
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be false if selection is inside table with one row only', () => {
+			setData( model, modelTable( [
+				[ '00[]', '01' ]
+			] ) );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be false if selection is outside a table', () => {
+			setData( model, '<p>11[]</p>' );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+	} );
+
+	describe( 'execute()', () => {
+		it( 'should remove a given row', () => {
+			setData( model, modelTable( [
+				[ '00', '01' ],
+				[ '[]10', '11' ],
+				[ '20', '21' ]
+			] ) );
+
+			command.execute();
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '00', '01[]' ],
+				[ '20', '21' ]
+			] ) );
+		} );
+
+		it( 'should remove a given row from a table start', () => {
+			setData( model, modelTable( [
+				[ '[]00', '01' ],
+				[ '10', '11' ],
+				[ '20', '21' ]
+			] ) );
+
+			command.execute();
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '[]10', '11' ],
+				[ '20', '21' ]
+			] ) );
+		} );
+
+		it( 'should change heading rows if removing a heading row', () => {
+			setData( model, modelTable( [
+				[ '00', '01' ],
+				[ '[]10', '11' ],
+				[ '20', '21' ]
+			], { headingRows: 2 } ) );
+
+			command.execute();
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '00', '01[]' ],
+				[ '20', '21' ]
+			], { headingRows: 1 } ) );
+		} );
+
+		it( 'should decrease rowspan of table cells from previous 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' ]
+			] ) );
+
+			command.execute();
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ { rowspan: 3, contents: '00' }, { rowspan: 2, contents: '01' }, { rowspan: 2, contents: '02' }, '03', '04' ],
+				[ '13', '14[]' ],
+				[ '30', '31', '32', '33', '34' ]
+			] ) );
+		} );
+
+		it( 'should move rowspaned cells to row below removing it\'s row', () => {
+			setData( model, modelTable( [
+				[ { rowspan: 3, contents: '[]00' }, { rowspan: 2, contents: '01' }, '02' ],
+				[ '12' ],
+				[ '22' ],
+				[ '30', '31', '32' ]
+			] ) );
+
+			command.execute();
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ { rowspan: 2, contents: '[]00' }, '01', '12' ],
+				[ '22' ],
+				[ '30', '31', '32' ]
+			] ) );
+		} );
+	} );
+} );

+ 240 - 0
packages/ckeditor5-table/tests/commands/settableheaderscommand.js

@@ -0,0 +1,240 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters';
+
+import SetTableHeadersCommand from '../../src/commands/settableheaderscommand';
+import {
+	downcastInsertCell,
+	downcastInsertRow,
+	downcastInsertTable,
+	downcastRemoveRow,
+	downcastTableHeadingColumnsChange,
+	downcastTableHeadingRowsChange
+} from '../../src/converters/downcast';
+import upcastTable from '../../src/converters/upcasttable';
+import { formatTable, formattedModelTable, modelTable } from '../_utils/utils';
+
+describe( 'SetTableHeadersCommand', () => {
+	let editor, model, command;
+
+	beforeEach( () => {
+		return ModelTestEditor.create()
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				command = new SetTableHeadersCommand( editor );
+
+				const conversion = editor.conversion;
+				const schema = model.schema;
+
+				schema.register( 'table', {
+					allowWhere: '$block',
+					allowAttributes: [ 'headingRows' ],
+					isObject: true
+				} );
+
+				schema.register( 'tableRow', { allowIn: 'table' } );
+
+				schema.register( 'tableCell', {
+					allowIn: 'tableRow',
+					allowContentOf: '$block',
+					allowAttributes: [ 'colspan', 'rowspan' ],
+					isLimit: true
+				} );
+
+				model.schema.register( 'p', { inheritAllFrom: '$block' } );
+
+				// Table conversion.
+				conversion.for( 'upcast' ).add( upcastTable() );
+				conversion.for( 'downcast' ).add( downcastInsertTable() );
+
+				// Insert row conversion.
+				conversion.for( 'downcast' ).add( downcastInsertRow() );
+
+				// Remove row conversion.
+				conversion.for( 'downcast' ).add( downcastRemoveRow() );
+
+				// Table cell conversion.
+				conversion.for( 'downcast' ).add( downcastInsertCell() );
+
+				conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'td' } ) );
+				conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'th' } ) );
+
+				// Table attributes conversion.
+				conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } );
+				conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } );
+
+				conversion.for( 'downcast' ).add( downcastTableHeadingColumnsChange() );
+				conversion.for( 'downcast' ).add( downcastTableHeadingRowsChange() );
+			} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	describe( 'isEnabled', () => {
+		it( 'should be false if not in a table', () => {
+			setData( model, '<p>foo[]</p>' );
+			expect( command.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be true if in table', () => {
+			setData( model, '<table><tableRow><tableCell>foo[]</tableCell></tableRow></table>' );
+			expect( command.isEnabled ).to.be.true;
+		} );
+	} );
+
+	describe( 'execute()', () => {
+		it( 'should set heading rows attribute', () => {
+			setData( model, modelTable( [
+				[ '00', '01' ],
+				[ '[]10', '11' ],
+				[ '20', '21' ]
+			] ) );
+
+			command.execute( { rows: 2 } );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '00', '01' ],
+				[ '[]10', '11' ],
+				[ '20', '21' ]
+			], { headingRows: 2 } ) );
+		} );
+
+		it( 'should remove heading rows attribute', () => {
+			setData( model, modelTable( [
+				[ '00', '01' ],
+				[ '[]10', '11' ],
+				[ '20', '21' ]
+			], { headingRows: 2 } ) );
+
+			command.execute( { rows: 0 } );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '00', '01' ],
+				[ '[]10', '11' ],
+				[ '20', '21' ]
+			] ) );
+		} );
+
+		it( 'should set heading columns attribute', () => {
+			setData( model, modelTable( [
+				[ '00', '01' ],
+				[ '[]10', '11' ],
+				[ '20', '21' ]
+			] ) );
+
+			command.execute( { columns: 2 } );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '00', '01' ],
+				[ '[]10', '11' ],
+				[ '20', '21' ]
+			], { headingColumns: 2 } ) );
+		} );
+
+		it( 'should remove heading columns attribute', () => {
+			setData( model, modelTable( [
+				[ '00', '01' ],
+				[ '[]10', '11' ],
+				[ '20', '21' ]
+			], { headingColumns: 2 } ) );
+
+			command.execute( { columns: 0 } );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '00', '01' ],
+				[ '[]10', '11' ],
+				[ '20', '21' ]
+			] ) );
+		} );
+
+		it( 'should remove heading columns & heading rows attributes', () => {
+			setData( model, modelTable( [
+				[ '00', '01' ],
+				[ '[]10', '11' ],
+				[ '20', '21' ]
+			], { headingColumns: 2, headingRows: 2 } ) );
+
+			command.execute();
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '00', '01' ],
+				[ '[]10', '11' ],
+				[ '20', '21' ]
+			] ) );
+		} );
+
+		it( 'should fix rowspaned cells on the edge of an table head section', () => {
+			setData( model, modelTable( [
+				[ '00', '01', '02' ],
+				[ { colspan: 2, rowspan: 2, contents: '10[]' }, '12' ],
+				[ '22' ]
+			], { headingColumns: 2, headingRows: 1 } ) );
+
+			command.execute( { rows: 2, columns: 2 } );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '00', '01', '02' ],
+				[ { colspan: 2, contents: '10[]' }, '12' ],
+				[ { colspan: 2, contents: '' }, '22' ]
+			], { headingColumns: 2, headingRows: 2 } ) );
+		} );
+
+		it( 'should split to at most 2 table cells when fixing rowspaned cells on the edge of an table head section', () => {
+			setData( model, modelTable( [
+				[ '00', '01', '02' ],
+				[ { colspan: 2, rowspan: 5, contents: '10[]' }, '12' ],
+				[ '22' ],
+				[ '32' ],
+				[ '42' ],
+				[ '52' ]
+			], { headingColumns: 2, headingRows: 1 } ) );
+
+			command.execute( { rows: 3, columns: 2 } );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '00', '01', '02' ],
+				[ { colspan: 2, rowspan: 2, contents: '10[]' }, '12' ],
+				[ '22' ],
+				[ { colspan: 2, rowspan: 3, contents: '' }, '32' ],
+				[ '42' ],
+				[ '52' ]
+			], { headingColumns: 2, headingRows: 3 } ) );
+		} );
+
+		it( 'should fix rowspaned cells on the edge of an table head section when creating section', () => {
+			setData( model, modelTable( [
+				[ { rowspan: 2, contents: '[]00' }, '01' ],
+				[ '11' ]
+			], { headingRows: 2 } ) );
+
+			command.execute( { rows: 1 } );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '[]00', '01' ],
+				[ '', '11' ],
+			], { headingRows: 1 } ) );
+		} );
+
+		it( 'should fix rowspaned cells inside a row', () => {
+			setData( model, modelTable( [
+				[ '00', { rowspan: 2, contents: '[]01' } ],
+				[ '10' ]
+			], { headingRows: 2 } ) );
+
+			command.execute( { rows: 1 } );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '00', '[]01' ],
+				[ '10', '' ]
+			], { headingRows: 1 } ) );
+		} );
+	} );
+} );

+ 227 - 0
packages/ckeditor5-table/tests/commands/splitcellcommand.js

@@ -0,0 +1,227 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters';
+
+import SplitCellCommand from '../../src/commands/splitcellcommand';
+import {
+	downcastInsertCell,
+	downcastInsertRow,
+	downcastInsertTable,
+	downcastRemoveRow,
+	downcastTableHeadingColumnsChange,
+	downcastTableHeadingRowsChange
+} from '../../src/converters/downcast';
+import upcastTable from '../../src/converters/upcasttable';
+import { formatTable, formattedModelTable, modelTable } from '../_utils/utils';
+import TableUtils from '../../src/tableutils';
+
+describe( 'SplitCellCommand', () => {
+	let editor, model, command;
+
+	beforeEach( () => {
+		return ModelTestEditor.create( {
+			plugins: [ TableUtils ]
+		} ).then( newEditor => {
+			editor = newEditor;
+			model = editor.model;
+			command = new SplitCellCommand( editor );
+
+			const conversion = editor.conversion;
+			const schema = model.schema;
+
+			schema.register( 'table', {
+				allowWhere: '$block',
+				allowAttributes: [ 'headingRows' ],
+				isObject: true
+			} );
+
+			schema.register( 'tableRow', { allowIn: 'table' } );
+
+			schema.register( 'tableCell', {
+				allowIn: 'tableRow',
+				allowContentOf: '$block',
+				allowAttributes: [ 'colspan', 'rowspan' ],
+				isLimit: true
+			} );
+
+			model.schema.register( 'p', { inheritAllFrom: '$block' } );
+
+			// Table conversion.
+			conversion.for( 'upcast' ).add( upcastTable() );
+			conversion.for( 'downcast' ).add( downcastInsertTable() );
+
+			// Insert row conversion.
+			conversion.for( 'downcast' ).add( downcastInsertRow() );
+
+			// Remove row conversion.
+			conversion.for( 'downcast' ).add( downcastRemoveRow() );
+
+			// Table cell conversion.
+			conversion.for( 'downcast' ).add( downcastInsertCell() );
+
+			conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'td' } ) );
+			conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'th' } ) );
+
+			// Table attributes conversion.
+			conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } );
+			conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } );
+
+			conversion.for( 'downcast' ).add( downcastTableHeadingColumnsChange() );
+			conversion.for( 'downcast' ).add( downcastTableHeadingRowsChange() );
+		} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	describe( 'direction=vertically', () => {
+		beforeEach( () => {
+			command = new SplitCellCommand( editor, { direction: 'vertically' } );
+		} );
+
+		describe( 'isEnabled', () => {
+			it( 'should be true if in a table cell', () => {
+				setData( model, modelTable( [
+					[ '00[]' ]
+				] ) );
+
+				expect( command.isEnabled ).to.be.true;
+			} );
+
+			it( 'should be false if not in cell', () => {
+				setData( model, '<p>11[]</p>' );
+
+				expect( command.isEnabled ).to.be.false;
+			} );
+		} );
+
+		describe( 'execute()', () => {
+			it( 'should split table cell for two table cells', () => {
+				setData( model, modelTable( [
+					[ '00', '01', '02' ],
+					[ '10', '[]11', '12' ],
+					[ '20', { colspan: 2, contents: '21' } ],
+					[ { colspan: 2, contents: '30' }, '32' ]
+				] ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '00', { colspan: 2, contents: '01' }, '02' ],
+					[ '10', '[]11', '', '12' ],
+					[ '20', { colspan: 3, contents: '21' } ],
+					[ { colspan: 3, contents: '30' }, '32' ]
+				] ) );
+			} );
+
+			it( 'should unsplit table cell if split is equal to colspan', () => {
+				setData( model, modelTable( [
+					[ '00', '01', '02' ],
+					[ '10', '11', '12' ],
+					[ '20', { colspan: 2, contents: '21[]' } ],
+					[ { colspan: 2, contents: '30' }, '32' ]
+				] ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '00', '01', '02' ],
+					[ '10', '11', '12' ],
+					[ '20', '21[]', '' ],
+					[ { colspan: 2, contents: '30' }, '32' ]
+				] ) );
+			} );
+
+			it( 'should properly unsplit table cell if split is uneven', () => {
+				setData( model, modelTable( [
+					[ '00', '01', '02' ],
+					[ { colspan: 3, contents: '10[]' } ]
+				] ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '00', '01', '02' ],
+					[ { colspan: 2, contents: '10[]' }, '' ]
+				] ) );
+			} );
+
+			it( 'should properly set colspan of inserted cells', () => {
+				setData( model, modelTable( [
+					[ '00', '01', '02', '03' ],
+					[ { colspan: 4, contents: '10[]' } ]
+				] ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '00', '01', '02', '03' ],
+					[ { colspan: 2, contents: '10[]' }, { colspan: 2, contents: '' } ]
+				] ) );
+			} );
+
+			it( 'should keep rowspan attribute for newly inserted cells', () => {
+				setData( model, modelTable( [
+					[ '00', '01', '02', '03', '04', '05' ],
+					[ { colspan: 5, rowspan: 2, contents: '10[]' }, '15' ],
+					[ '25' ]
+				] ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '00', '01', '02', '03', '04', '05' ],
+					[ { colspan: 3, rowspan: 2, contents: '10[]' }, { colspan: 2, rowspan: 2, contents: '' }, '15' ],
+					[ '25' ]
+				] ) );
+			} );
+		} );
+	} );
+
+	describe( 'direction=horizontally', () => {
+		beforeEach( () => {
+			command = new SplitCellCommand( editor, { direction: 'horizontally' } );
+		} );
+
+		describe( 'isEnabled', () => {
+			it( 'should be true if in a table cell', () => {
+				setData( model, modelTable( [
+					[ '00[]' ]
+				] ) );
+
+				expect( command.isEnabled ).to.be.true;
+			} );
+
+			it( 'should be false if not in cell', () => {
+				setData( model, '<p>11[]</p>' );
+
+				expect( command.isEnabled ).to.be.false;
+			} );
+		} );
+
+		describe( 'execute()', () => {
+			it( 'should split table cell for two table cells', () => {
+				setData( model, modelTable( [
+					[ '00', '01', '02' ],
+					[ '10', '[]11', '12' ],
+					[ '20', '21', '22' ]
+				] ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '00', '01', '02' ],
+					[ { rowspan: 2, contents: '10' }, '[]11', { rowspan: 2, contents: '12' } ],
+					[ '' ],
+					[ '20', '21', '22' ]
+				] ) );
+			} );
+		} );
+	} );
+} );

+ 80 - 0
packages/ckeditor5-table/tests/commands/utils.js

@@ -0,0 +1,80 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters';
+
+import { downcastInsertTable } from '../../src/converters/downcast';
+import upcastTable from '../../src/converters/upcasttable';
+import { modelTable } from '../_utils/utils';
+import { getParentTable } from '../../src/commands/utils';
+
+describe( 'commands utils', () => {
+	let editor, model;
+
+	beforeEach( () => {
+		return ModelTestEditor.create()
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+
+				const conversion = editor.conversion;
+				const schema = model.schema;
+
+				schema.register( 'table', {
+					allowWhere: '$block',
+					allowAttributes: [ 'headingRows' ],
+					isObject: true
+				} );
+
+				schema.register( 'tableRow', { allowIn: 'table' } );
+
+				schema.register( 'tableCell', {
+					allowIn: 'tableRow',
+					allowContentOf: '$block',
+					allowAttributes: [ 'colspan', 'rowspan' ],
+					isLimit: true
+				} );
+
+				model.schema.register( 'p', { inheritAllFrom: '$block' } );
+
+				// Table conversion.
+				conversion.for( 'upcast' ).add( upcastTable() );
+				conversion.for( 'downcast' ).add( downcastInsertTable() );
+
+				// Table row upcast only since downcast conversion is done in `downcastTable()`.
+				conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableRow', view: 'tr' } ) );
+
+				// Table cell conversion.
+				conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'td' } ) );
+				conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'th' } ) );
+
+				conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } );
+				conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } );
+			} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	describe( 'getParentTable()', () => {
+		it( 'should return undefined if not in table', () => {
+			setData( model, '<p>foo[]</p>' );
+
+			expect( getParentTable( model.document.selection.focus ) ).to.be.undefined;
+		} );
+
+		it( 'should return table if position is in tableCell', () => {
+			setData( model, modelTable( [ [ '[]' ] ] ) );
+
+			const parentTable = getParentTable( model.document.selection.focus );
+
+			expect( parentTable ).to.not.be.undefined;
+			expect( parentTable.is( 'table' ) ).to.be.true;
+		} );
+	} );
+} );

+ 1226 - 0
packages/ckeditor5-table/tests/converters/downcast.js

@@ -0,0 +1,1226 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import {
+	downcastInsertCell,
+	downcastInsertRow,
+	downcastInsertTable,
+	downcastRemoveRow,
+	downcastTableHeadingColumnsChange,
+	downcastTableHeadingRowsChange
+} from '../../src/converters/downcast';
+import { formatTable, formattedViewTable, modelTable, viewTable } from '../_utils/utils';
+
+describe( 'downcast converters', () => {
+	let editor, model, doc, root, viewDocument;
+
+	beforeEach( () => {
+		return VirtualTestEditor.create()
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				doc = model.document;
+				root = doc.getRoot( 'main' );
+				viewDocument = editor.editing.view;
+
+				const conversion = editor.conversion;
+				const schema = model.schema;
+
+				schema.register( 'table', {
+					allowWhere: '$block',
+					allowAttributes: [ 'headingRows', 'headingColumns' ],
+					isObject: true
+				} );
+
+				schema.register( 'tableRow', { allowIn: 'table' } );
+
+				schema.register( 'tableCell', {
+					allowIn: 'tableRow',
+					allowContentOf: '$block',
+					allowAttributes: [ 'colspan', 'rowspan' ],
+					isLimit: true
+				} );
+
+				conversion.for( 'downcast' ).add( downcastInsertTable() );
+				conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } );
+				conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } );
+
+				// Insert conversion
+				conversion.for( 'downcast' ).add( downcastInsertRow() );
+				conversion.for( 'downcast' ).add( downcastInsertCell() );
+
+				conversion.for( 'downcast' ).add( downcastRemoveRow() );
+
+				conversion.for( 'downcast' ).add( downcastTableHeadingRowsChange() );
+				conversion.for( 'downcast' ).add( downcastTableHeadingColumnsChange() );
+			} );
+	} );
+
+	describe( 'downcastInsertTable()', () => {
+		it( 'should create table with tbody', () => {
+			setModelData( model,
+				'<table>' +
+				'<tableRow><tableCell></tableCell></tableRow>' +
+				'</table>'
+			);
+
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
+				'<table>' +
+				'<tbody>' +
+				'<tr><td></td></tr>' +
+				'</tbody>' +
+				'</table>'
+			);
+		} );
+
+		it( 'should create table with tbody and thead', () => {
+			setModelData( model,
+				'<table headingRows="1">' +
+				'<tableRow><tableCell>1</tableCell></tableRow>' +
+				'<tableRow><tableCell>2</tableCell></tableRow>' +
+				'</table>'
+			);
+
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
+				'<table>' +
+				'<thead>' +
+				'<tr><th>1</th></tr>' +
+				'</thead>' +
+				'<tbody>' +
+				'<tr><td>2</td></tr>' +
+				'</tbody>' +
+				'</table>'
+			);
+		} );
+
+		it( 'should create table with thead', () => {
+			setModelData( model,
+				'<table headingRows="2">' +
+				'<tableRow><tableCell>1</tableCell></tableRow>' +
+				'<tableRow><tableCell>2</tableCell></tableRow>' +
+				'</table>'
+			);
+
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
+				'<table>' +
+				'<thead>' +
+				'<tr><th>1</th></tr>' +
+				'<tr><th>2</th></tr>' +
+				'</thead>' +
+				'</table>'
+			);
+		} );
+
+		it( 'should create table with heading columns and rows', () => {
+			setModelData( model,
+				'<table headingColumns="3" headingRows="1">' +
+				'<tableRow>' +
+				'<tableCell>11</tableCell><tableCell>12</tableCell><tableCell>13</tableCell><tableCell>14</tableCell>' +
+				'</tableRow>' +
+				'<tableRow>' +
+				'<tableCell>21</tableCell><tableCell>22</tableCell><tableCell>23</tableCell><tableCell>24</tableCell>' +
+				'</tableRow>' +
+				'</table>'
+			);
+
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
+				'<table>' +
+				'<thead>' +
+				'<tr><th>11</th><th>12</th><th>13</th><th>14</th></tr>' +
+				'</thead>' +
+				'<tbody>' +
+				'<tr><th>21</th><th>22</th><th>23</th><td>24</td></tr>' +
+				'</tbody>' +
+				'</table>'
+			);
+		} );
+
+		it( 'should be possible to overwrite', () => {
+			editor.conversion.elementToElement( { model: 'tableRow', view: 'tr', converterPriority: 'high' } );
+			editor.conversion.elementToElement( { model: 'tableCell', view: 'td', converterPriority: 'high' } );
+			editor.conversion.for( 'downcast' ).add( dispatcher => {
+				dispatcher.on( 'insert:table', ( evt, data, conversionApi ) => {
+					conversionApi.consumable.consume( data.item, 'insert' );
+
+					const tableElement = conversionApi.writer.createContainerElement( 'table', { foo: 'bar' } );
+					const viewPosition = conversionApi.mapper.toViewPosition( data.range.start );
+
+					conversionApi.mapper.bindElements( data.item, tableElement );
+					conversionApi.writer.insert( viewPosition, tableElement );
+				}, { priority: 'high' } );
+			} );
+
+			setModelData( model,
+				'<table>' +
+				'<tableRow><tableCell></tableCell></tableRow>' +
+				'</table>'
+			);
+
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
+				'<table foo="bar">' +
+				'<tr><td></td></tr>' +
+				'</table>'
+			);
+		} );
+
+		describe( 'headingColumns attribute', () => {
+			it( 'should mark heading columns table cells', () => {
+				setModelData( model,
+					'<table headingColumns="2">' +
+					'<tableRow><tableCell>11</tableCell><tableCell>12</tableCell><tableCell>13</tableCell></tableRow>' +
+					'<tableRow><tableCell>21</tableCell><tableCell>22</tableCell><tableCell>23</tableCell></tableRow>' +
+					'</table>'
+				);
+
+				expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
+					'<table>' +
+					'<tbody>' +
+					'<tr><th>11</th><th>12</th><td>13</td></tr>' +
+					'<tr><th>21</th><th>22</th><td>23</td></tr>' +
+					'</tbody>' +
+					'</table>'
+				);
+			} );
+
+			it( 'should mark heading columns table cells when one has colspan attribute', () => {
+				setModelData( model,
+					'<table headingColumns="3">' +
+					'<tableRow>' +
+					'<tableCell>11</tableCell><tableCell>12</tableCell><tableCell>13</tableCell><tableCell>14</tableCell>' +
+					'</tableRow>' +
+					'<tableRow><tableCell colspan="2">21</tableCell><tableCell>23</tableCell><tableCell>24</tableCell></tableRow>' +
+					'</table>'
+				);
+
+				expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
+					'<table>' +
+					'<tbody>' +
+					'<tr><th>11</th><th>12</th><th>13</th><td>14</td></tr>' +
+					'<tr><th colspan="2">21</th><th>23</th><td>24</td></tr>' +
+					'</tbody>' +
+					'</table>'
+				);
+			} );
+
+			it( 'should work with colspan and rowspan attributes on table cells', () => {
+				// The table in this test looks like a table below:
+				//
+				//   Row headings | Normal cells
+				//                |
+				// +----+----+----+----+
+				// | 11 | 12 | 13 | 14 |
+				// |    +----+    +----+
+				// |    | 22 |    | 24 |
+				// |----+----+    +----+
+				// | 31      |    | 34 |
+				// |         +----+----+
+				// |         | 43 | 44 |
+				// +----+----+----+----+
+
+				setModelData( model,
+					'<table headingColumns="3">' +
+					'<tableRow>' +
+					'<tableCell rowspan="2">11</tableCell>' +
+					'<tableCell>12</tableCell>' +
+					'<tableCell rowspan="3">13</tableCell>' +
+					'<tableCell>14</tableCell>' +
+					'</tableRow>' +
+					'<tableRow><tableCell>22</tableCell><tableCell>24</tableCell></tableRow>' +
+					'<tableRow><tableCell colspan="2" rowspan="2">31</tableCell><tableCell>34</tableCell></tableRow>' +
+					'<tableRow><tableCell>43</tableCell><tableCell>44</tableCell></tableRow>' +
+					'</table>'
+				);
+
+				expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
+					'<table>' +
+					'<tbody>' +
+					'<tr><th rowspan="2">11</th><th>12</th><th rowspan="3">13</th><td>14</td></tr>' +
+					'<tr><th>22</th><td>24</td></tr>' +
+					'<tr><th colspan="2" rowspan="2">31</th><td>34</td></tr>' +
+					'<tr><th>43</th><td>44</td></tr>' +
+					'</tbody>' +
+					'</table>'
+				);
+			} );
+		} );
+
+		describe( 'asWidget', () => {
+			beforeEach( () => {
+				return VirtualTestEditor.create()
+					.then( newEditor => {
+						editor = newEditor;
+						model = editor.model;
+						doc = model.document;
+						root = doc.getRoot( 'main' );
+						viewDocument = editor.editing.view;
+
+						const conversion = editor.conversion;
+						const schema = model.schema;
+
+						schema.register( 'table', {
+							allowWhere: '$block',
+							allowAttributes: [ 'headingRows', 'headingColumns' ],
+							isObject: true
+						} );
+
+						schema.register( 'tableRow', { allowIn: 'table' } );
+
+						schema.register( 'tableCell', {
+							allowIn: 'tableRow',
+							allowContentOf: '$block',
+							allowAttributes: [ 'colspan', 'rowspan' ],
+							isLimit: true
+						} );
+
+						conversion.for( 'downcast' ).add( downcastInsertTable( { asWidget: true } ) );
+						conversion.for( 'downcast' ).add( downcastInsertRow( { asWidget: true } ) );
+						conversion.for( 'downcast' ).add( downcastInsertCell( { asWidget: true } ) );
+
+						conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } );
+						conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } );
+					} );
+			} );
+
+			it( 'should create table as a widget', () => {
+				setModelData( model,
+					'<table>' +
+					'<tableRow><tableCell></tableCell></tableRow>' +
+					'</table>'
+				);
+
+				expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
+					'<table class="ck-widget" contenteditable="false">' +
+					'<tbody>' +
+					'<tr><td class="ck-editor__editable ck-editor__nested-editable" contenteditable="true"></td></tr>' +
+					'</tbody>' +
+					'</table>'
+				);
+			} );
+		} );
+	} );
+
+	describe( 'downcastInsertRow()', () => {
+		it( 'should react to changed rows', () => {
+			setModelData( model, modelTable( [
+				[ '11', '12' ]
+			] ) );
+
+			const table = root.getChild( 0 );
+
+			model.change( writer => {
+				const row = writer.createElement( 'tableRow' );
+
+				writer.insert( row, table, 1 );
+
+				writer.insertElement( 'tableCell', row, 'end' );
+				writer.insertElement( 'tableCell', row, 'end' );
+			} );
+
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [
+				[ '11', '12' ],
+				[ '', '' ]
+			] ) );
+		} );
+
+		it( 'should properly consume already added rows', () => {
+			setModelData( model, modelTable( [
+				[ '11', '12' ]
+			] ) );
+
+			const table = root.getChild( 0 );
+
+			model.change( writer => {
+				const row = writer.createElement( 'tableRow' );
+
+				writer.insert( row, table, 1 );
+
+				writer.insertElement( 'tableCell', row, 'end' );
+				writer.insertElement( 'tableCell', row, 'end' );
+			} );
+
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [
+				[ '11', '12' ],
+				[ '', '' ]
+			] ) );
+
+			model.change( writer => {
+				const row = writer.createElement( 'tableRow' );
+
+				writer.insert( row, table, 2 );
+
+				writer.insertElement( 'tableCell', row, 'end' );
+				writer.insertElement( 'tableCell', row, 'end' );
+			} );
+
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [
+				[ '11', '12' ],
+				[ '', '' ],
+				[ '', '' ]
+			] ) );
+		} );
+
+		it( 'should insert row on proper index', () => {
+			setModelData( model, modelTable( [
+				[ '11', '12' ],
+				[ '21', '22' ],
+				[ '31', '32' ]
+			] ) );
+
+			const table = root.getChild( 0 );
+
+			model.change( writer => {
+				const row = writer.createElement( 'tableRow' );
+
+				writer.insert( row, table, 1 );
+
+				writer.insertElement( 'tableCell', row, 'end' );
+				writer.insertElement( 'tableCell', row, 'end' );
+			} );
+
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [
+				[ '11', '12' ],
+				[ '', '' ],
+				[ '21', '22' ],
+				[ '31', '32' ]
+			] ) );
+		} );
+
+		it( 'should insert row on proper index when table has heading rows defined - insert in body', () => {
+			setModelData( model, modelTable( [
+				[ '11', '12' ],
+				[ '21', '22' ],
+				[ '31', '32' ]
+			], { headingRows: 1 } ) );
+
+			const table = root.getChild( 0 );
+
+			model.change( writer => {
+				const row = writer.createElement( 'tableRow' );
+
+				writer.insert( row, table, 1 );
+
+				writer.insertElement( 'tableCell', row, 'end' );
+				writer.insertElement( 'tableCell', row, 'end' );
+			} );
+
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [
+				[ '11', '12' ],
+				[ '', '' ],
+				[ '21', '22' ],
+				[ '31', '32' ]
+			], { headingRows: 1 } ) );
+		} );
+
+		it( 'should insert row on proper index when table has heading rows defined - insert in heading', () => {
+			setModelData( model, modelTable( [
+				[ '11', '12' ],
+				[ '21', '22' ],
+				[ '31', '32' ]
+			], { headingRows: 2 } ) );
+
+			const table = root.getChild( 0 );
+
+			model.change( writer => {
+				const row = writer.createElement( 'tableRow' );
+
+				writer.insert( row, table, 1 );
+
+				writer.insertElement( 'tableCell', row, 'end' );
+				writer.insertElement( 'tableCell', row, 'end' );
+			} );
+
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [
+				[ '11', '12' ],
+				[ '', '' ],
+				[ '21', '22' ],
+				[ '31', '32' ]
+			], { headingRows: 3 } ) );
+		} );
+
+		it( 'should react to changed rows when previous rows\' cells has rowspans', () => {
+			setModelData( model, modelTable( [
+				[ { rowspan: 3, contents: '11' }, '12' ],
+				[ '22' ]
+			] ) );
+
+			const table = root.getChild( 0 );
+
+			model.change( writer => {
+				const row = writer.createElement( 'tableRow' );
+
+				writer.insert( row, table, 2 );
+				writer.insertElement( 'tableCell', row, 'end' );
+			} );
+
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [
+				[ { rowspan: 3, contents: '11' }, '12' ],
+				[ '22' ],
+				[ '' ]
+			] ) );
+		} );
+
+		it( 'should properly create row headings', () => {
+			setModelData( model, modelTable( [
+				[ { rowspan: 3, contents: '11' }, '12' ],
+				[ '22' ]
+			], { headingColumns: 1 } ) );
+
+			const table = root.getChild( 0 );
+
+			model.change( writer => {
+				const firstRow = writer.createElement( 'tableRow' );
+
+				writer.insert( firstRow, table, 2 );
+				writer.insert( writer.createElement( 'tableCell' ), firstRow, 'end' );
+
+				const secondRow = writer.createElement( 'tableRow' );
+
+				writer.insert( secondRow, table, 3 );
+				writer.insert( writer.createElement( 'tableCell' ), secondRow, 'end' );
+				writer.insert( writer.createElement( 'tableCell' ), secondRow, 'end' );
+			} );
+
+			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+				[ { rowspan: 3, contents: '11', isHeading: true }, '12' ],
+				[ '22' ],
+				[ '' ],
+				[ { contents: '', isHeading: true }, '' ]
+			] ) );
+		} );
+
+		describe( 'asWidget', () => {
+			beforeEach( () => {
+				return VirtualTestEditor.create()
+					.then( newEditor => {
+						editor = newEditor;
+						model = editor.model;
+						doc = model.document;
+						root = doc.getRoot( 'main' );
+						viewDocument = editor.editing.view;
+
+						const conversion = editor.conversion;
+						const schema = model.schema;
+
+						schema.register( 'table', {
+							allowWhere: '$block',
+							allowAttributes: [ 'headingRows', 'headingColumns' ],
+							isObject: true
+						} );
+
+						schema.register( 'tableRow', { allowIn: 'table' } );
+
+						schema.register( 'tableCell', {
+							allowIn: 'tableRow',
+							allowContentOf: '$block',
+							allowAttributes: [ 'colspan', 'rowspan' ],
+							isLimit: true
+						} );
+
+						conversion.for( 'downcast' ).add( downcastInsertTable( { asWidget: true } ) );
+						conversion.for( 'downcast' ).add( downcastInsertRow( { asWidget: true } ) );
+						conversion.for( 'downcast' ).add( downcastInsertCell( { asWidget: true } ) );
+
+						conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } );
+						conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } );
+					} );
+			} );
+
+			it( 'should create table cell inside inserted row as a widget', () => {
+				setModelData( model,
+					'<table>' +
+					'<tableRow><tableCell>foo</tableCell></tableRow>' +
+					'</table>'
+				);
+
+				const table = root.getChild( 0 );
+
+				model.change( writer => {
+					const firstRow = writer.createElement( 'tableRow' );
+
+					writer.insert( firstRow, table, 1 );
+					writer.insert( writer.createElement( 'tableCell' ), firstRow, 'end' );
+				} );
+
+				expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
+					'<table class="ck-widget" contenteditable="false">' +
+					'<tbody>' +
+					'<tr><td class="ck-editor__editable ck-editor__nested-editable" contenteditable="true">foo</td></tr>' +
+					'<tr><td class="ck-editor__editable ck-editor__nested-editable" contenteditable="true"></td></tr>' +
+					'</tbody>' +
+					'</table>'
+				);
+			} );
+		} );
+	} );
+
+	describe( 'downcastInsertCell()', () => {
+		it( 'should add tableCell on proper index in tr', () => {
+			setModelData( model, modelTable( [
+				[ '11', '12' ]
+			] ) );
+
+			const table = root.getChild( 0 );
+
+			model.change( writer => {
+				const row = table.getChild( 0 );
+
+				writer.insertElement( 'tableCell', row, 1 );
+			} );
+
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [
+				[ '11', '', '12' ]
+			] ) );
+		} );
+
+		it( 'should add tableCell on proper index in tr when previous have colspans', () => {
+			setModelData( model, modelTable( [
+				[ { colspan: 2, contents: '11' }, '13' ]
+			] ) );
+
+			const table = root.getChild( 0 );
+
+			model.change( writer => {
+				const row = table.getChild( 0 );
+
+				writer.insertElement( 'tableCell', row, 1 );
+			} );
+
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [
+				[ { colspan: 2, contents: '11' }, '', '13' ]
+			] ) );
+		} );
+
+		it( 'should add tableCell on proper index in tr when previous row have rowspans', () => {
+			setModelData( model, modelTable( [
+				[ { rowspan: 2, contents: '11' }, '13' ],
+				[ '22', '23' ]
+			] ) );
+
+			const table = root.getChild( 0 );
+
+			model.change( writer => {
+				writer.insertElement( 'tableCell', table.getChild( 0 ), 1 );
+				writer.insertElement( 'tableCell', table.getChild( 1 ), 0 );
+			} );
+
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [
+				[ { rowspan: 2, contents: '11' }, '', '13' ],
+				[ '', '22', '23' ]
+			] ) );
+		} );
+
+		it( 'split cell simulation - simple', () => {
+			setModelData( model, modelTable( [
+				[ '11', '12' ],
+				[ '21', '22' ]
+			] ) );
+
+			const table = root.getChild( 0 );
+
+			model.change( writer => {
+				const firstRow = table.getChild( 0 );
+				const secondRow = table.getChild( 1 );
+
+				writer.insertElement( 'tableCell', firstRow, 1 );
+				writer.setAttribute( 'colspan', 2, secondRow.getChild( 0 ) );
+			} );
+
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [
+				[ '11', '', '12' ],
+				[ { colspan: 2, contents: '21' }, '22' ]
+			] ) );
+		} );
+
+		it( 'merge simulation - simple', () => {
+			setModelData( model, modelTable( [
+				[ '11', '12' ],
+				[ '21', '22' ]
+			] ) );
+
+			const table = root.getChild( 0 );
+
+			model.change( writer => {
+				const firstRow = table.getChild( 0 );
+
+				writer.setAttribute( 'colspan', 2, firstRow.getChild( 0 ) );
+				writer.remove( firstRow.getChild( 1 ) );
+			} );
+
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [
+				[ { colspan: 2, contents: '11' } ],
+				[ '21', '22' ]
+			] ) );
+		} );
+
+		describe( 'asWidget', () => {
+			beforeEach( () => {
+				return VirtualTestEditor.create()
+					.then( newEditor => {
+						editor = newEditor;
+						model = editor.model;
+						doc = model.document;
+						root = doc.getRoot( 'main' );
+						viewDocument = editor.editing.view;
+
+						const conversion = editor.conversion;
+						const schema = model.schema;
+
+						schema.register( 'table', {
+							allowWhere: '$block',
+							allowAttributes: [ 'headingRows', 'headingColumns' ],
+							isObject: true
+						} );
+
+						schema.register( 'tableRow', { allowIn: 'table' } );
+
+						schema.register( 'tableCell', {
+							allowIn: 'tableRow',
+							allowContentOf: '$block',
+							allowAttributes: [ 'colspan', 'rowspan' ],
+							isLimit: true
+						} );
+
+						conversion.for( 'downcast' ).add( downcastInsertTable( { asWidget: true } ) );
+						conversion.for( 'downcast' ).add( downcastInsertRow( { asWidget: true } ) );
+						conversion.for( 'downcast' ).add( downcastInsertCell( { asWidget: true } ) );
+
+						conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } );
+						conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } );
+					} );
+			} );
+
+			it( 'should create inserted table cell as a widget', () => {
+				setModelData( model,
+					'<table>' +
+					'<tableRow><tableCell>foo</tableCell></tableRow>' +
+					'</table>'
+				);
+
+				const table = root.getChild( 0 );
+
+				model.change( writer => {
+					const row = table.getChild( 0 );
+
+					writer.insert( writer.createElement( 'tableCell' ), row, 'end' );
+				} );
+
+				expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
+					'<table class="ck-widget" contenteditable="false">' +
+					'<tbody>' +
+					'<tr>' +
+					'<td class="ck-editor__editable ck-editor__nested-editable" contenteditable="true">foo</td>' +
+					'<td class="ck-editor__editable ck-editor__nested-editable" contenteditable="true"></td>' +
+					'</tr>' +
+					'</tbody>' +
+					'</table>'
+				);
+			} );
+		} );
+	} );
+
+	describe( 'downcastTableHeadingColumnsChange()', () => {
+		it( 'should work for adding heading columns', () => {
+			setModelData( model, modelTable( [
+				[ '11', '12' ],
+				[ '21', '22' ]
+			] ) );
+
+			const table = root.getChild( 0 );
+
+			model.change( writer => {
+				writer.setAttribute( 'headingColumns', 1, table );
+			} );
+
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [
+				[ { isHeading: true, contents: '11' }, '12' ],
+				[ { isHeading: true, contents: '21' }, '22' ]
+			] ) );
+		} );
+
+		it( 'should work for changing heading columns to a bigger number', () => {
+			setModelData( model, modelTable( [
+				[ '11', '12', '13', '14' ],
+				[ '21', '22', '23', '24' ]
+			], { headingColumns: 1 } ) );
+
+			const table = root.getChild( 0 );
+
+			model.change( writer => {
+				writer.setAttribute( 'headingColumns', 3, table );
+			} );
+
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [
+				[ { isHeading: true, contents: '11' }, { isHeading: true, contents: '12' }, { isHeading: true, contents: '13' }, '14' ],
+				[ { isHeading: true, contents: '21' }, { isHeading: true, contents: '22' }, { isHeading: true, contents: '23' }, '24' ]
+			] ) );
+		} );
+
+		it( 'should work for changing heading columns to a smaller number', () => {
+			setModelData( model, modelTable( [
+				[ { isHeading: true, contents: '11' }, { isHeading: true, contents: '12' }, { isHeading: true, contents: '13' }, '14' ],
+				[ { isHeading: true, contents: '21' }, { isHeading: true, contents: '22' }, { isHeading: true, contents: '23' }, '24' ]
+			], { headingColumns: 3 } ) );
+
+			const table = root.getChild( 0 );
+
+			model.change( writer => {
+				writer.setAttribute( 'headingColumns', 1, table );
+			} );
+
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [
+				[ { isHeading: true, contents: '11' }, '12', '13', '14' ],
+				[ { isHeading: true, contents: '21' }, '22', '23', '24' ]
+			], { headingColumns: 3 } ) );
+		} );
+
+		it( 'should work for removing heading columns', () => {
+			setModelData( model, modelTable( [
+				[ '11', '12' ],
+				[ '21', '22' ]
+			], { headingColumns: 1 } ) );
+			const table = root.getChild( 0 );
+
+			model.change( writer => {
+				writer.removeAttribute( 'headingColumns', table );
+			} );
+
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [
+				[ '11', '12' ],
+				[ '21', '22' ]
+			] ) );
+		} );
+
+		it( 'should be possible to overwrite', () => {
+			editor.conversion.attributeToAttribute( { model: 'headingColumns', view: 'headingColumns', converterPriority: 'high' } );
+			setModelData( model, modelTable( [ [ '11' ] ] ) );
+
+			const table = root.getChild( 0 );
+
+			model.change( writer => {
+				writer.setAttribute( 'headingColumns', 1, table );
+			} );
+
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
+				'<table headingColumns="1"><tbody><tr><td>11</td></tr></tbody></table>'
+			);
+		} );
+
+		it( 'should work with adding table cells', () => {
+			setModelData( model, modelTable( [
+				[ { rowspan: 2, contents: '11' }, '12', '13', '14' ],
+				[ '22', '23', '24' ],
+				[ { colspan: 2, contents: '31' }, '33', '34' ]
+			], { headingColumns: 2 } ) );
+
+			const table = root.getChild( 0 );
+
+			model.change( writer => {
+				// Inserting column in heading columns so update table's attribute also
+				writer.setAttribute( 'headingColumns', 3, table );
+
+				writer.insertElement( 'tableCell', table.getChild( 0 ), 2 );
+				writer.insertElement( 'tableCell', table.getChild( 1 ), 1 );
+				writer.insertElement( 'tableCell', table.getChild( 2 ), 1 );
+			} );
+
+			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+				[
+					{ isHeading: true, rowspan: 2, contents: '11' },
+					{ isHeading: true, contents: '12' },
+					{ isHeading: true, contents: '' },
+					'13',
+					'14'
+				],
+				[
+					{ isHeading: true, contents: '22' },
+					{ isHeading: true, contents: '' },
+					'23',
+					'24'
+				],
+				[
+					{ isHeading: true, colspan: 2, contents: '31' },
+					{ isHeading: true, contents: '' },
+					'33',
+					'34'
+				]
+			] ) );
+		} );
+
+		describe( 'asWidget', () => {
+			beforeEach( () => {
+				return VirtualTestEditor.create()
+					.then( newEditor => {
+						editor = newEditor;
+						model = editor.model;
+						doc = model.document;
+						root = doc.getRoot( 'main' );
+						viewDocument = editor.editing.view;
+
+						const conversion = editor.conversion;
+						const schema = model.schema;
+
+						schema.register( 'table', {
+							allowWhere: '$block',
+							allowAttributes: [ 'headingRows', 'headingColumns' ],
+							isObject: true
+						} );
+
+						schema.register( 'tableRow', { allowIn: 'table' } );
+
+						schema.register( 'tableCell', {
+							allowIn: 'tableRow',
+							allowContentOf: '$block',
+							allowAttributes: [ 'colspan', 'rowspan' ],
+							isLimit: true
+						} );
+
+						conversion.for( 'downcast' ).add( downcastInsertTable( { asWidget: true } ) );
+						conversion.for( 'downcast' ).add( downcastInsertRow( { asWidget: true } ) );
+						conversion.for( 'downcast' ).add( downcastInsertCell( { asWidget: true } ) );
+
+						conversion.for( 'downcast' ).add( downcastTableHeadingColumnsChange( { asWidget: true } ) );
+						conversion.for( 'downcast' ).add( downcastTableHeadingRowsChange( { asWidget: true } ) );
+
+						conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } );
+						conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } );
+					} );
+			} );
+
+			it( 'should create renamed cell as a widget', () => {
+				setModelData( model,
+					'<table>' +
+					'<tableRow><tableCell>foo</tableCell></tableRow>' +
+					'</table>'
+				);
+
+				const table = root.getChild( 0 );
+
+				model.change( writer => {
+					writer.setAttribute( 'headingRows', 1, table );
+				} );
+
+				expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
+					'<table class="ck-widget" contenteditable="false">' +
+					'<thead>' +
+					'<tr><th class="ck-editor__editable ck-editor__nested-editable" contenteditable="true">foo</th></tr>' +
+					'</thead>' +
+					'</table>'
+				);
+			} );
+		} );
+	} );
+
+	describe( 'downcastTableHeadingRowsChange()', () => {
+		it( 'should work for adding heading rows', () => {
+			setModelData( model, modelTable( [
+				[ '11', '12' ],
+				[ '21', '22' ],
+				[ '31', '32' ]
+			] ) );
+
+			const table = root.getChild( 0 );
+
+			model.change( writer => {
+				writer.setAttribute( 'headingRows', 2, table );
+			} );
+
+			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+				[ '11', '12' ],
+				[ '21', '22' ],
+				[ '31', '32' ]
+			], { headingRows: 2 } ) );
+		} );
+
+		it( 'should work for changing number of heading rows to a bigger number', () => {
+			setModelData( model, modelTable( [
+				[ '11', '12' ],
+				[ '21', '22' ],
+				[ '31', '32' ]
+			], { headingRows: 1 } ) );
+
+			const table = root.getChild( 0 );
+
+			model.change( writer => {
+				writer.setAttribute( 'headingRows', 2, table );
+			} );
+
+			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+				[ '11', '12' ],
+				[ '21', '22' ],
+				[ '31', '32' ]
+			], { headingRows: 2 } ) );
+		} );
+
+		it( 'should work for changing number of heading rows to a smaller number', () => {
+			setModelData( model, modelTable( [
+				[ '11', '12' ],
+				[ '21', '22' ],
+				[ '31', '32' ],
+				[ '41', '42' ]
+			], { headingRows: 3 } ) );
+
+			const table = root.getChild( 0 );
+
+			model.change( writer => {
+				writer.setAttribute( 'headingRows', 2, table );
+			} );
+
+			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+				[ '11', '12' ],
+				[ '21', '22' ],
+				[ '31', '32' ],
+				[ '41', '42' ]
+			], { headingRows: 2 } ) );
+		} );
+
+		it( 'should work for removing heading rows', () => {
+			setModelData( model, modelTable( [
+				[ '11', '12' ],
+				[ '21', '22' ]
+			], { headingRows: 2 } ) );
+
+			const table = root.getChild( 0 );
+
+			model.change( writer => {
+				writer.removeAttribute( 'headingRows', table );
+			} );
+
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [
+				[ '11', '12' ],
+				[ '21', '22' ]
+			] ) );
+		} );
+
+		it( 'should work for making heading rows without tbody', () => {
+			setModelData( model, modelTable( [
+				[ '11', '12' ],
+				[ '21', '22' ]
+			] ) );
+
+			const table = root.getChild( 0 );
+
+			model.change( writer => {
+				writer.setAttribute( 'headingRows', 2, table );
+			} );
+
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( viewTable( [
+				[ '11', '12' ],
+				[ '21', '22' ]
+			], { headingRows: 2 } ) );
+		} );
+
+		it( 'should be possible to overwrite', () => {
+			editor.conversion.attributeToAttribute( { model: 'headingRows', view: 'headingRows', converterPriority: 'high' } );
+			setModelData( model, modelTable( [ [ '11' ] ] ) );
+
+			const table = root.getChild( 0 );
+
+			model.change( writer => {
+				writer.setAttribute( 'headingRows', 1, table );
+			} );
+
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
+				'<table headingRows="1"><tbody><tr><td>11</td></tr></tbody></table>'
+			);
+		} );
+
+		it( 'should work with adding table rows at the beginning of a table', () => {
+			setModelData( model, modelTable( [
+				[ '00', '01' ],
+				[ '10', '11' ]
+			], { headingRows: 1 } ) );
+
+			const table = root.getChild( 0 );
+
+			model.change( writer => {
+				writer.setAttribute( 'headingRows', 2, table );
+
+				const tableRow = writer.createElement( 'tableRow' );
+
+				writer.insert( tableRow, table, 0 );
+				writer.insertElement( 'tableCell', tableRow, 'end' );
+				writer.insertElement( 'tableCell', tableRow, 'end' );
+			} );
+
+			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+				[ '', '' ],
+				[ '00', '01' ],
+				[ '10', '11' ]
+			], { headingRows: 2 } ) );
+		} );
+
+		describe( 'asWidget', () => {
+			beforeEach( () => {
+				return VirtualTestEditor.create()
+					.then( newEditor => {
+						editor = newEditor;
+						model = editor.model;
+						doc = model.document;
+						root = doc.getRoot( 'main' );
+						viewDocument = editor.editing.view;
+
+						const conversion = editor.conversion;
+						const schema = model.schema;
+
+						schema.register( 'table', {
+							allowWhere: '$block',
+							allowAttributes: [ 'headingRows', 'headingColumns' ],
+							isObject: true
+						} );
+
+						schema.register( 'tableRow', { allowIn: 'table' } );
+
+						schema.register( 'tableCell', {
+							allowIn: 'tableRow',
+							allowContentOf: '$block',
+							allowAttributes: [ 'colspan', 'rowspan' ],
+							isLimit: true
+						} );
+
+						conversion.for( 'downcast' ).add( downcastInsertTable( { asWidget: true } ) );
+						conversion.for( 'downcast' ).add( downcastInsertRow( { asWidget: true } ) );
+						conversion.for( 'downcast' ).add( downcastInsertCell( { asWidget: true } ) );
+
+						conversion.for( 'downcast' ).add( downcastTableHeadingColumnsChange( { asWidget: true } ) );
+						conversion.for( 'downcast' ).add( downcastTableHeadingRowsChange( { asWidget: true } ) );
+
+						conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } );
+						conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } );
+					} );
+			} );
+
+			it( 'should create renamed cell as a widget', () => {
+				setModelData( model,
+					'<table>' +
+					'<tableRow><tableCell>foo</tableCell></tableRow>' +
+					'</table>'
+				);
+
+				const table = root.getChild( 0 );
+
+				model.change( writer => {
+					writer.setAttribute( 'headingColumns', 1, table );
+				} );
+
+				expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
+					'<table class="ck-widget" contenteditable="false">' +
+					'<tbody>' +
+					'<tr><th class="ck-editor__editable ck-editor__nested-editable" contenteditable="true">foo</th></tr>' +
+					'</tbody>' +
+					'</table>'
+				);
+			} );
+		} );
+	} );
+
+	describe( 'downcastRemoveRow()', () => {
+		it( 'should react to removed row from the beginning of a tbody', () => {
+			setModelData( model, modelTable( [
+				[ '00[]', '01' ],
+				[ '10', '11' ]
+			] ) );
+
+			const table = root.getChild( 0 );
+
+			model.change( writer => {
+				writer.remove( table.getChild( 1 ) );
+			} );
+
+			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+				[ '00', '01' ]
+			] ) );
+		} );
+
+		it( 'should react to removed row form the end of a tbody', () => {
+			setModelData( model, modelTable( [
+				[ '00[]', '01' ],
+				[ '10', '11' ]
+			] ) );
+
+			const table = root.getChild( 0 );
+
+			model.change( writer => {
+				writer.remove( table.getChild( 0 ) );
+			} );
+
+			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+				[ '10', '11' ]
+			] ) );
+		} );
+
+		it( 'should react to removed row from the beginning of a thead', () => {
+			setModelData( model, modelTable( [
+				[ '00[]', '01' ],
+				[ '10', '11' ]
+			], { headingRows: 2 } ) );
+
+			const table = root.getChild( 0 );
+
+			model.change( writer => {
+				writer.remove( table.getChild( 1 ) );
+			} );
+
+			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+				[ '00', '01' ]
+			], { headingRows: 2 } ) );
+		} );
+
+		it( 'should react to removed row form the end of a thead', () => {
+			setModelData( model, modelTable( [
+				[ '00[]', '01' ],
+				[ '10', '11' ]
+			], { headingRows: 2 } ) );
+
+			const table = root.getChild( 0 );
+
+			model.change( writer => {
+				writer.remove( table.getChild( 0 ) );
+			} );
+
+			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+				[ '10', '11' ]
+			], { headingRows: 2 } ) );
+		} );
+
+		it( 'should remove empty thead section if a last row was removed from thead', () => {
+			setModelData( model, modelTable( [
+				[ '00[]', '01' ],
+				[ '10', '11' ]
+			], { headingRows: 1 } ) );
+
+			const table = root.getChild( 0 );
+
+			model.change( writer => {
+				writer.setAttribute( 'headingRows', 0, table );
+				writer.remove( table.getChild( 0 ) );
+			} );
+
+			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+				[ '10', '11' ]
+			] ) );
+		} );
+
+		it( 'should remove empty tbody section if a last row was removed from tbody', () => {
+			setModelData( model, modelTable( [
+				[ '00[]', '01' ],
+				[ '10', '11' ]
+			], { headingRows: 1 } ) );
+
+			const table = root.getChild( 0 );
+
+			model.change( writer => {
+				writer.remove( table.getChild( 1 ) );
+			} );
+
+			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+				[ '00', '01' ]
+			], { headingRows: 1 } ) );
+		} );
+	} );
+} );

+ 309 - 0
packages/ckeditor5-table/tests/converters/upcasttable.js

@@ -0,0 +1,309 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters';
+import { getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import upcastTable from '../../src/converters/upcasttable';
+
+describe( 'upcastTable()', () => {
+	let editor, model;
+
+	beforeEach( () => {
+		return VirtualTestEditor.create()
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+
+				const conversion = editor.conversion;
+				const schema = model.schema;
+
+				schema.register( 'table', {
+					allowWhere: '$block',
+					allowAttributes: [ 'headingRows', 'headingColumns' ],
+					isObject: true
+				} );
+
+				schema.register( 'tableRow', { allowIn: 'table' } );
+
+				schema.register( 'tableCell', {
+					allowIn: 'tableRow',
+					allowContentOf: '$block',
+					allowAttributes: [ 'colspan', 'rowspan' ],
+					isLimit: true
+				} );
+
+				conversion.for( 'upcast' ).add( upcastTable() );
+
+				// Table row upcast only since downcast conversion is done in `downcastTable()`.
+				conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableRow', view: 'tr' } ) );
+
+				// Table cell conversion.
+				conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'td' } ) );
+				conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'th' } ) );
+
+				conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } );
+				conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } );
+
+				// Since this part of test tests only view->model conversion editing pipeline is not necessary
+				// so defining model->view converters won't be necessary.
+				editor.editing.destroy();
+			} );
+	} );
+
+	function expectModel( data ) {
+		expect( getModelData( model, { withoutSelection: true } ) ).to.equal( data );
+	}
+
+	it( 'should create table model from table without thead', () => {
+		editor.setData(
+			'<table>' +
+			'<tr><td>1</td></tr>' +
+			'</table>'
+		);
+
+		expectModel(
+			'<table>' +
+			'<tableRow><tableCell>1</tableCell></tableRow>' +
+			'</table>'
+		);
+	} );
+
+	it( 'should create table model from table with one thead with one row', () => {
+		editor.setData(
+			'<table>' +
+			'<thead><tr><td>1</td></tr></thead>' +
+			'</table>'
+		);
+
+		expectModel(
+			'<table headingRows="1">' +
+			'<tableRow><tableCell>1</tableCell></tableRow>' +
+			'</table>'
+		);
+	} );
+
+	it( 'should create table model from table with one thead with more then on row', () => {
+		editor.setData(
+			'<table>' +
+			'<thead>' +
+			'<tr><td>1</td></tr>' +
+			'<tr><td>2</td></tr>' +
+			'<tr><td>3</td></tr>' +
+			'</thead>' +
+			'</table>'
+		);
+
+		expectModel(
+			'<table headingRows="3">' +
+			'<tableRow><tableCell>1</tableCell></tableRow>' +
+			'<tableRow><tableCell>2</tableCell></tableRow>' +
+			'<tableRow><tableCell>3</tableCell></tableRow>' +
+			'</table>'
+		);
+	} );
+
+	it( 'should create table model from table with two theads with one row', () => {
+		editor.setData(
+			'<table>' +
+			'<thead><tr><td>1</td></tr></thead>' +
+			'<tbody><tr><td>2</td></tr></tbody>' +
+			'<thead><tr><td>3</td></tr></thead>' +
+			'</table>'
+		);
+
+		expectModel(
+			'<table headingRows="1">' +
+			'<tableRow><tableCell>1</tableCell></tableRow>' +
+			'<tableRow><tableCell>2</tableCell></tableRow>' +
+			'<tableRow><tableCell>3</tableCell></tableRow>' +
+			'</table>'
+		);
+	} );
+
+	it( 'should create table model from table with thead after the tbody', () => {
+		editor.setData(
+			'<table>' +
+			'<tbody><tr><td>2</td></tr></tbody>' +
+			'<thead><tr><td>1</td></tr></thead>' +
+			'</table>'
+		);
+
+		expectModel(
+			'<table headingRows="1">' +
+			'<tableRow><tableCell>1</tableCell></tableRow>' +
+			'<tableRow><tableCell>2</tableCell></tableRow>' +
+			'</table>'
+		);
+	} );
+
+	it( 'should create table model from table with one tfoot with one row', () => {
+		editor.setData(
+			'<table>' +
+			'<tfoot><tr><td>1</td></tr></tfoot>' +
+			'</table>'
+		);
+
+		expectModel(
+			'<table>' +
+			'<tableRow><tableCell>1</tableCell></tableRow>' +
+			'</table>'
+		);
+	} );
+
+	it( 'should create valid table model from empty table', () => {
+		editor.setData(
+			'<table>' +
+			'</table>'
+		);
+
+		expectModel(
+			'<table><tableRow><tableCell></tableCell></tableRow></table>'
+		);
+	} );
+
+	it( 'should skip unknown table children', () => {
+		editor.setData(
+			'<table>' +
+			'<caption>foo</caption>' +
+			'<tr><td>bar</td></tr>' +
+			'</table>'
+		);
+
+		expectModel(
+			'<table><tableRow><tableCell>bar</tableCell></tableRow></table>'
+		);
+	} );
+
+	it( 'should create table model from some broken table', () => {
+		editor.setData(
+			'<table><td><p>foo</p></td></table>'
+		);
+
+		expectModel(
+			'<table><tableRow><tableCell>foo</tableCell></tableRow></table>'
+		);
+	} );
+
+	it( 'should fix if inside other blocks', () => {
+		// Using <div> instead of <p> as it breaks on Edge.
+		editor.model.schema.register( 'div', {
+			inheritAllFrom: '$block'
+		} );
+		editor.conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'div', view: 'div' } ) );
+
+		editor.setData(
+			'<div>foo' +
+			'<table>' +
+			'<tbody><tr><td>2</td></tr></tbody>' +
+			'<thead><tr><td>1</td></tr></thead>' +
+			'</table>' +
+			'bar</div>'
+		);
+
+		expectModel(
+			'<div>foo</div>' +
+			'<table headingRows="1">' +
+			'<tableRow><tableCell>1</tableCell></tableRow>' +
+			'<tableRow><tableCell>2</tableCell></tableRow>' +
+			'</table>' +
+			'<div>bar</div>'
+		);
+	} );
+
+	it( 'should be possible to overwrite table conversion', () => {
+		editor.model.schema.register( 'fooTable', {
+			allowWhere: '$block',
+			allowAttributes: [ 'headingRows' ],
+			isObject: true
+		} );
+
+		editor.conversion.elementToElement( { model: 'fooTable', view: 'table', converterPriority: 'high' } );
+
+		editor.setData(
+			'<table>' +
+			'<thead><tr><td>foo</td></tr></thead>' +
+			'</table>'
+		);
+
+		expectModel(
+			'<fooTable></fooTable>'
+		);
+	} );
+
+	describe( 'headingColumns', () => {
+		it( 'should properly calculate heading columns', () => {
+			editor.setData(
+				'<table>' +
+				'<tbody>' +
+				// This row starts with 1 th (3 total).
+				'<tr><th>21</th><td>22</td><th>23</th><th>24</th></tr>' +
+				// This row starts with 2 th (2 total). This one has max number of heading columns: 2.
+				'<tr><th>31</th><th>32</th><td>33</td><td>34</td></tr>' +
+				// This row starts with 1 th (1 total).
+				'<tr><th>41</th><td>42</td><td>43</td><td>44</td></tr>' +
+				// This row starts with 0 th (3 total).
+				'<tr><td>51</td><th>52</th><th>53</th><th>54</th></tr>' +
+				'</tbody>' +
+				'<thead>' +
+				// This row has 4 ths but it is a thead.
+				'<tr><th>11</th><th>12</th><th>13</th><th>14</th></tr>' +
+				'</thead>' +
+				'</table>'
+			);
+
+			expectModel(
+				'<table headingColumns="2" headingRows="1">' +
+				'<tableRow>' +
+				'<tableCell>11</tableCell><tableCell>12</tableCell><tableCell>13</tableCell><tableCell>14</tableCell>' +
+				'</tableRow>' +
+				'<tableRow>' +
+				'<tableCell>21</tableCell><tableCell>22</tableCell><tableCell>23</tableCell><tableCell>24</tableCell>' +
+				'</tableRow>' +
+				'<tableRow>' +
+				'<tableCell>31</tableCell><tableCell>32</tableCell><tableCell>33</tableCell><tableCell>34</tableCell>' +
+				'</tableRow>' +
+				'<tableRow>' +
+				'<tableCell>41</tableCell><tableCell>42</tableCell><tableCell>43</tableCell><tableCell>44</tableCell>' +
+				'</tableRow>' +
+				'<tableRow>' +
+				'<tableCell>51</tableCell><tableCell>52</tableCell><tableCell>53</tableCell><tableCell>54</tableCell>' +
+				'</tableRow>' +
+				'</table>'
+			);
+		} );
+
+		it( 'should calculate heading columns of cells with colspan', () => {
+			editor.setData(
+				'<table>' +
+				'<tbody>' +
+				// This row has colspan of 3 so it should be the whole table should have 3 heading columns.
+				'<tr><th>21</th><th>22</th><td>23</td><td>24</td></tr>' +
+				'<tr><th colspan="2">31</th><th>33</th><td>34</td></tr>' +
+				'</tbody>' +
+				'<thead>' +
+				// This row has 4 ths but it is a thead.
+				'<tr><th>11</th><th>12</th><th>13</th><th>14</th></tr>' +
+				'</thead>' +
+				'</table>'
+			);
+
+			expectModel(
+				'<table headingColumns="3" headingRows="1">' +
+				'<tableRow>' +
+				'<tableCell>11</tableCell><tableCell>12</tableCell><tableCell>13</tableCell><tableCell>14</tableCell>' +
+				'</tableRow>' +
+				'<tableRow>' +
+				'<tableCell>21</tableCell><tableCell>22</tableCell><tableCell>23</tableCell><tableCell>24</tableCell>' +
+				'</tableRow>' +
+				'<tableRow>' +
+				'<tableCell colspan="2">31</tableCell><tableCell>33</tableCell><tableCell>34</tableCell>' +
+				'</tableRow>' +
+				'</table>'
+			);
+		} );
+	} );
+} );

+ 217 - 0
packages/ckeditor5-table/tests/manual/table.html

@@ -0,0 +1,217 @@
+<style>
+
+	table {
+		border-collapse: collapse;
+		border-spacing: 0;
+		border-color: #000000;
+	}
+
+	table, th, td {
+		padding: 5px;
+		border: 1px inset #000000;
+	}
+
+	table th,
+	table td {
+		min-width: 1em;
+	}
+
+	table th {
+		background: #dadada;
+		font-weight: normal;
+	}
+
+	table thead th {
+		color: #ffffff;
+		background: #666666;
+	}
+</style>
+
+<div id="editor">
+	<p>Table with everything:</p>
+	<table>
+		<caption>Data about the planets of our solar system (Planetary facts taken from <a href="http://nssdc.gsfc.nasa.gov/planetary/factsheet/" class="external external-icon" rel="noopener">Nasa's Planetary Fact Sheet - Metric</a>.</caption>
+		<thead>
+		<tr>
+			<td colspan="2">&nbsp;</td>
+			<th scope="col">Name</th>
+			<th scope="col">Mass (10<sup>24</sup>kg)</th>
+			<th scope="col">Diameter (km)</th>
+			<th scope="col">Density (kg/m<sup>3</sup>)</th>
+			<th scope="col">Gravity (m/s<sup>2</sup>)</th>
+			<th scope="col">Length of day (hours)</th>
+			<th scope="col">Distance from Sun (10<sup>6</sup>km)</th>
+			<th scope="col">Mean temperature (°C)</th>
+			<th scope="col">Number of moons</th>
+			<th scope="col">Notes</th>
+		</tr>
+		</thead>
+		<tbody>
+		<tr>
+			<th colspan="2" rowspan="4" scope="rowgroup">Terrestial planets</th>
+			<th scope="row">Mercury</th>
+			<td>0.330</td>
+			<td>4,879</td>
+			<td>5427</td>
+			<td>3.7</td>
+			<td>4222.6</td>
+			<td>57.9</td>
+			<td>167</td>
+			<td>0</td>
+			<td>Closest to the Sun</td>
+		</tr>
+		<tr>
+			<th scope="row">Venus</th>
+			<td>4.87</td>
+			<td>12,104</td>
+			<td>5243</td>
+			<td>8.9</td>
+			<td>2802.0</td>
+			<td>108.2</td>
+			<td>464</td>
+			<td>0</td>
+			<td>&nbsp;</td>
+		</tr>
+		<tr>
+			<th scope="row">Earth</th>
+			<td>5.97</td>
+			<td>12,756</td>
+			<td>5514</td>
+			<td>9.8</td>
+			<td>24.0</td>
+			<td>149.6</td>
+			<td>15</td>
+			<td>1</td>
+			<td>Our world</td>
+		</tr>
+		<tr>
+			<th scope="row">Mars</th>
+			<td>0.642</td>
+			<td>6,792</td>
+			<td>3933</td>
+			<td>3.7</td>
+			<td>24.7</td>
+			<td>227.9</td>
+			<td>-65</td>
+			<td>2</td>
+			<td>The red planet</td>
+		</tr>
+		<tr>
+			<th scope="rowgroup" rowspan="4">Jovian planets</th>
+			<th scope="rowgroup" rowspan="2">Gas giants</th>
+			<th scope="row">Jupiter</th>
+			<td>1898</td>
+			<td>142,984</td>
+			<td>1326</td>
+			<td>23.1</td>
+			<td>9.9</td>
+			<td>778.6</td>
+			<td>-110</td>
+			<td>67</td>
+			<td>The largest planet</td>
+		</tr>
+		<tr>
+			<th scope="row">Saturn</th>
+			<td>568</td>
+			<td>120,536</td>
+			<td>687</td>
+			<td>9.0</td>
+			<td>10.7</td>
+			<td>1433.5</td>
+			<td>-140</td>
+			<td>62</td>
+			<td>&nbsp;</td>
+		</tr>
+		<tr>
+			<th scope="rowgroup" rowspan="2">Ice giants</th>
+			<th scope="row">Uranus</th>
+			<td>86.8</td>
+			<td>51,118</td>
+			<td>1271</td>
+			<td>8.7</td>
+			<td>17.2</td>
+			<td>2872.5</td>
+			<td>-195</td>
+			<td>27</td>
+			<td>&nbsp;</td>
+		</tr>
+		<tr>
+			<th scope="row">Neptune</th>
+			<td>102</td>
+			<td>49,528</td>
+			<td>1638</td>
+			<td>11.0</td>
+			<td>16.1</td>
+			<td>4495.1</td>
+			<td>-200</td>
+			<td>14</td>
+			<td>&nbsp;</td>
+		</tr>
+		<tr>
+			<th colspan="2" scope="rowgroup">Dwarf planets</th>
+			<th scope="row">Pluto</th>
+			<td>0.0146</td>
+			<td>2,370</td>
+			<td>2095</td>
+			<td>0.7</td>
+			<td>153.3</td>
+			<td>5906.4</td>
+			<td>-225</td>
+			<td>5</td>
+			<td>Declassified as a planet in 2006, but this <a href="http://www.usatoday.com/story/tech/2014/10/02/pluto-planet-solar-system/16578959/" class="external external-icon" rel="noopener">remains controversial</a>.</td>
+		</tr>
+		</tbody>
+	</table>
+
+
+	<p>Table with 2 tbody:</p>
+	<table>
+		<tbody>
+		<tr>
+			<td>a</td>
+			<td>b</td>
+			<td>c</td>
+		</tr>
+		</tbody>
+		<tbody>
+		<tr>
+			<td>a</td>
+			<td>b</td>
+			<td>c</td>
+		</tr>
+		</tbody>
+	</table>
+
+	<p>Table with no tbody:</p>
+	<table>
+		<tr>
+			<td>a</td>
+			<td>b</td>
+			<td>c</td>
+		</tr>
+		<tr>
+			<td>a</td>
+			<td>b</td>
+			<td>c</td>
+		</tr>
+	</table>
+
+	<p>Table with thead section between two tbody sections</p>
+	<table>
+		<tbody>
+		<tr>
+			<td>2</td>
+		</tr>
+		</tbody>
+		<thead>
+		<tr>
+			<td>1</td>
+		</tr>
+		</thead>
+		<tbody>
+		<tr>
+			<td>3</td>
+		</tr>
+		</tbody>
+	</table>
+</div>

+ 25 - 0
packages/ckeditor5-table/tests/manual/table.js

@@ -0,0 +1,25 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals console, window, document */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import Table from '../../src/table';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ ArticlePluginSet, Table ],
+		toolbar: [
+			'heading', '|', 'insertTable', 'insertRowBelow', 'insertColumnAfter',
+			'|', 'bold', 'italic', 'bulletedList', 'numberedList', 'blockQuote', 'undo', 'redo'
+		]
+	} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 3 - 0
packages/ckeditor5-table/tests/manual/table.md

@@ -0,0 +1,3 @@
+### Loading
+
+### Testing

+ 19 - 0
packages/ckeditor5-table/tests/table.js

@@ -0,0 +1,19 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Table from '../src/table';
+import TableEditing from '../src/tableediting';
+import TableUI from '../src/tableui';
+import Widget from '@ckeditor/ckeditor5-widget/src/widget';
+
+describe( 'Table', () => {
+	it( 'requires TableEditing, TableUI and Widget', () => {
+		expect( Table.requires ).to.deep.equal( [ TableEditing, TableUI, Widget ] );
+	} );
+
+	it( 'has proper name', () => {
+		expect( Table.pluginName ).to.equal( 'Table' );
+	} );
+} );

+ 342 - 0
packages/ckeditor5-table/tests/tableediting.js

@@ -0,0 +1,342 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+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 { getCode } from '@ckeditor/ckeditor5-utils/src/keyboard';
+
+import TableEditing from '../src/tableediting';
+import { formatTable, formattedModelTable, modelTable } from './_utils/utils';
+import InsertRowCommand from '../src/commands/insertrowcommand';
+import InsertTableCommand from '../src/commands/inserttablecommand';
+import InsertColumnCommand from '../src/commands/insertcolumncommand';
+import RemoveRowCommand from '../src/commands/removerowcommand';
+import RemoveColumnCommand from '../src/commands/removecolumncommand';
+import SplitCellCommand from '../src/commands/splitcellcommand';
+import MergeCellCommand from '../src/commands/mergecellcommand';
+import SetTableHeadersCommand from '../src/commands/settableheaderscommand';
+
+describe( 'TableEditing', () => {
+	let editor, model;
+
+	beforeEach( () => {
+		return VirtualTestEditor
+			.create( {
+				plugins: [ TableEditing, Paragraph ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+
+				model = editor.model;
+			} );
+	} );
+
+	afterEach( () => {
+		editor.destroy();
+	} );
+
+	it( 'should set proper schema rules', () => {
+	} );
+
+	it( 'adds insertTable command', () => {
+		expect( editor.commands.get( 'insertTable' ) ).to.be.instanceOf( InsertTableCommand );
+	} );
+
+	it( 'adds insertRowAbove command', () => {
+		expect( editor.commands.get( 'insertRowAbove' ) ).to.be.instanceOf( InsertRowCommand );
+	} );
+
+	it( 'adds insertRowBelow command', () => {
+		expect( editor.commands.get( 'insertRowBelow' ) ).to.be.instanceOf( InsertRowCommand );
+	} );
+
+	it( 'adds insertColumnBefore command', () => {
+		expect( editor.commands.get( 'insertColumnBefore' ) ).to.be.instanceOf( InsertColumnCommand );
+	} );
+
+	it( 'adds insertColumnAfter command', () => {
+		expect( editor.commands.get( 'insertColumnAfter' ) ).to.be.instanceOf( InsertColumnCommand );
+	} );
+
+	it( 'adds removeRow command', () => {
+		expect( editor.commands.get( 'removeRow' ) ).to.be.instanceOf( RemoveRowCommand );
+	} );
+
+	it( 'adds removeColumn command', () => {
+		expect( editor.commands.get( 'removeColumn' ) ).to.be.instanceOf( RemoveColumnCommand );
+	} );
+
+	it( 'adds splitCellVertically command', () => {
+		expect( editor.commands.get( 'splitCellVertically' ) ).to.be.instanceOf( SplitCellCommand );
+	} );
+
+	it( 'adds splitCellHorizontally command', () => {
+		expect( editor.commands.get( 'splitCellHorizontally' ) ).to.be.instanceOf( SplitCellCommand );
+	} );
+
+	it( 'adds mergeCellRight command', () => {
+		expect( editor.commands.get( 'mergeCellRight' ) ).to.be.instanceOf( MergeCellCommand );
+	} );
+
+	it( 'adds mergeCellLeft command', () => {
+		expect( editor.commands.get( 'mergeCellLeft' ) ).to.be.instanceOf( MergeCellCommand );
+	} );
+
+	it( 'adds mergeCellDown command', () => {
+		expect( editor.commands.get( 'mergeCellDown' ) ).to.be.instanceOf( MergeCellCommand );
+	} );
+
+	it( 'adds mergeCellUp command', () => {
+		expect( editor.commands.get( 'mergeCellUp' ) ).to.be.instanceOf( MergeCellCommand );
+	} );
+
+	it( 'adds setTableHeaders command', () => {
+		expect( editor.commands.get( 'setTableHeaders' ) ).to.be.instanceOf( SetTableHeadersCommand );
+	} );
+
+	describe( 'conversion in data pipeline', () => {
+		describe( 'model to view', () => {
+			it( 'should create tbody section', () => {
+				setModelData( model, '<table><tableRow><tableCell>foo[]</tableCell></tableRow></table>' );
+
+				expect( editor.getData() ).to.equal(
+					'<table>' +
+					'<tbody>' +
+					'<tr><td>foo</td></tr>' +
+					'</tbody>' +
+					'</table>'
+				);
+			} );
+
+			it( 'should create thead section', () => {
+				setModelData( model, '<table headingRows="1"><tableRow><tableCell>foo[]</tableCell></tableRow></table>' );
+
+				expect( editor.getData() ).to.equal(
+					'<table>' +
+					'<thead>' +
+					'<tr><th>foo</th></tr>' +
+					'</thead>' +
+					'</table>'
+				);
+			} );
+		} );
+
+		describe( 'view to model', () => {
+			it( 'should convert table', () => {
+				editor.setData( '<table><tbody><tr><td>foo</td></tr></tbody></table>' );
+
+				expect( getModelData( model, { withoutSelection: true } ) )
+					.to.equal( '<table><tableRow><tableCell>foo</tableCell></tableRow></table>' );
+			} );
+		} );
+	} );
+
+	describe( 'caret movement', () => {
+		let domEvtDataStub;
+
+		beforeEach( () => {
+			domEvtDataStub = {
+				keyCode: getCode( 'Tab' ),
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			};
+		} );
+
+		it( 'should do nothing if not tab pressed', () => {
+			setModelData( model, modelTable( [
+				[ '11', '12[]' ]
+			] ) );
+
+			domEvtDataStub.keyCode = getCode( 'a' );
+
+			editor.editing.view.document.fire( 'keydown', domEvtDataStub );
+
+			sinon.assert.notCalled( domEvtDataStub.preventDefault );
+			sinon.assert.notCalled( domEvtDataStub.stopPropagation );
+			expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [
+				[ '11', '12[]' ]
+			] ) );
+		} );
+
+		it( 'should do nothing if Ctrl+Tab is pressed', () => {
+			setModelData( model, modelTable( [
+				[ '11', '12[]' ]
+			] ) );
+
+			domEvtDataStub.ctrlKey = true;
+
+			editor.editing.view.document.fire( 'keydown', domEvtDataStub );
+
+			sinon.assert.notCalled( domEvtDataStub.preventDefault );
+			sinon.assert.notCalled( domEvtDataStub.stopPropagation );
+			expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [
+				[ '11', '12[]' ]
+			] ) );
+		} );
+
+		describe( 'on TAB', () => {
+			it( 'should do nothing if selection is not in a table', () => {
+				setModelData( model, '[]' + modelTable( [
+					[ '11', '12' ]
+				] ) );
+
+				editor.editing.view.document.fire( 'keydown', domEvtDataStub );
+
+				sinon.assert.notCalled( domEvtDataStub.preventDefault );
+				sinon.assert.notCalled( domEvtDataStub.stopPropagation );
+				expect( formatTable( getModelData( model ) ) ).to.equal( '[]' + formattedModelTable( [
+					[ '11', '12' ]
+				] ) );
+			} );
+
+			it( 'should move to next cell', () => {
+				setModelData( model, modelTable( [
+					[ '11[]', '12' ]
+				] ) );
+
+				editor.editing.view.document.fire( 'keydown', domEvtDataStub );
+
+				sinon.assert.calledOnce( domEvtDataStub.preventDefault );
+				sinon.assert.calledOnce( domEvtDataStub.stopPropagation );
+				expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [
+					[ '11', '[12]' ]
+				] ) );
+			} );
+
+			it( 'should create another row and move to first cell in new row', () => {
+				setModelData( model, modelTable( [
+					[ '11', '[12]' ]
+				] ) );
+
+				editor.editing.view.document.fire( 'keydown', domEvtDataStub );
+
+				expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [
+					[ '11', '12' ],
+					[ '[]', '' ]
+				] ) );
+			} );
+
+			it( 'should move to the first cell of next row if on end of a row', () => {
+				setModelData( model, modelTable( [
+					[ '11', '12[]' ],
+					[ '21', '22' ]
+				] ) );
+
+				editor.editing.view.document.fire( 'keydown', domEvtDataStub );
+
+				expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [
+					[ '11', '12' ],
+					[ '[21]', '22' ]
+				] ) );
+			} );
+
+			describe( 'on table widget selected', () => {
+				it( 'should move caret to the first table cell on TAB', () => {
+					const spy = sinon.spy();
+
+					editor.editing.view.document.on( 'keydown', spy );
+
+					setModelData( model, '[' + modelTable( [
+						[ '11', '12' ]
+					] ) + ']' );
+
+					editor.editing.view.document.fire( 'keydown', domEvtDataStub );
+
+					sinon.assert.calledOnce( domEvtDataStub.preventDefault );
+					sinon.assert.calledOnce( domEvtDataStub.stopPropagation );
+
+					expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [
+						[ '[11]', '12' ]
+					] ) );
+
+					// Should cancel event - so no other tab handler is called.
+					sinon.assert.notCalled( spy );
+				} );
+
+				it( 'shouldn\' do anything on other blocks', () => {
+					const spy = sinon.spy();
+
+					editor.editing.view.document.on( 'keydown', spy );
+
+					setModelData( model, '[<paragraph>foo</paragraph>]' );
+
+					editor.editing.view.document.fire( 'keydown', domEvtDataStub );
+
+					sinon.assert.notCalled( domEvtDataStub.preventDefault );
+					sinon.assert.notCalled( domEvtDataStub.stopPropagation );
+
+					expect( formatTable( getModelData( model ) ) ).to.equal( '[<paragraph>foo</paragraph>]' );
+
+					// Should not cancel event.
+					sinon.assert.calledOnce( spy );
+				} );
+			} );
+		} );
+
+		describe( 'on SHIFT+TAB', () => {
+			beforeEach( () => {
+				domEvtDataStub.shiftKey = true;
+			} );
+
+			it( 'should do nothing if selection is not in a table', () => {
+				setModelData( model, '[]' + modelTable( [
+					[ '11', '12' ]
+				] ) );
+
+				domEvtDataStub.keyCode = getCode( 'Tab' );
+				domEvtDataStub.shiftKey = true;
+
+				editor.editing.view.document.fire( 'keydown', domEvtDataStub );
+
+				sinon.assert.notCalled( domEvtDataStub.preventDefault );
+				sinon.assert.notCalled( domEvtDataStub.stopPropagation );
+				expect( formatTable( getModelData( model ) ) ).to.equal( '[]' + formattedModelTable( [
+					[ '11', '12' ]
+				] ) );
+			} );
+
+			it( 'should move to previous cell', () => {
+				setModelData( model, modelTable( [
+					[ '11', '12[]' ]
+				] ) );
+
+				editor.editing.view.document.fire( 'keydown', domEvtDataStub );
+
+				sinon.assert.calledOnce( domEvtDataStub.preventDefault );
+				sinon.assert.calledOnce( domEvtDataStub.stopPropagation );
+				expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [
+					[ '[11]', '12' ]
+				] ) );
+			} );
+
+			it( 'should not move if caret is in first table cell', () => {
+				setModelData( model, '<paragraph>foo</paragraph>' + modelTable( [
+					[ '[]11', '12' ]
+				] ) );
+
+				editor.editing.view.document.fire( 'keydown', domEvtDataStub );
+
+				expect( formatTable( getModelData( model ) ) ).to.equal(
+					'<paragraph>foo</paragraph>' + formattedModelTable( [ [ '[]11', '12' ] ] )
+				);
+			} );
+
+			it( 'should move to the last cell of previous row if on beginning of a row', () => {
+				setModelData( model, modelTable( [
+					[ '11', '12' ],
+					[ '[]21', '22' ]
+				] ) );
+
+				editor.editing.view.document.fire( 'keydown', domEvtDataStub );
+
+				expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [
+					[ '11', '[12]' ],
+					[ '21', '22' ]
+				] ) );
+			} );
+		} );
+	} );
+} );

+ 153 - 0
packages/ckeditor5-table/tests/tableui.js

@@ -0,0 +1,153 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global document */
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import { _clear as clearTranslations, add as addTranslations } from '@ckeditor/ckeditor5-utils/src/translation-service';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+
+import TableEditing from '../src/tableediting';
+import TableUI from '../src/tableui';
+
+testUtils.createSinonSandbox();
+
+describe( 'TableUI', () => {
+	let editor, element;
+
+	before( () => {
+		addTranslations( 'en', {} );
+		addTranslations( 'pl', {} );
+	} );
+
+	after( () => {
+		clearTranslations();
+	} );
+
+	beforeEach( () => {
+		element = document.createElement( 'div' );
+		document.body.appendChild( element );
+
+		return ClassicTestEditor
+			.create( element, {
+				plugins: [ TableEditing, TableUI ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+			} );
+	} );
+
+	afterEach( () => {
+		element.remove();
+
+		return editor.destroy();
+	} );
+
+	describe( 'insertTable button', () => {
+		let insertTable;
+
+		beforeEach( () => {
+			insertTable = editor.ui.componentFactory.create( 'insertTable' );
+		} );
+
+		it( 'should register insertTable buton', () => {
+			expect( insertTable ).to.be.instanceOf( ButtonView );
+			expect( insertTable.isOn ).to.be.false;
+			expect( insertTable.label ).to.equal( 'Insert table' );
+			expect( insertTable.icon ).to.match( /<svg / );
+		} );
+
+		it( 'should bind to insertTable command', () => {
+			const command = editor.commands.get( 'insertTable' );
+
+			command.isEnabled = true;
+			expect( insertTable.isOn ).to.be.false;
+			expect( insertTable.isEnabled ).to.be.true;
+
+			command.isEnabled = false;
+			expect( insertTable.isEnabled ).to.be.false;
+		} );
+
+		it( 'should execute insertTable command on button execute event', () => {
+			const executeSpy = testUtils.sinon.spy( editor, 'execute' );
+
+			insertTable.fire( 'execute' );
+
+			sinon.assert.calledOnce( executeSpy );
+			sinon.assert.calledWithExactly( executeSpy, 'insertTable' );
+		} );
+	} );
+
+	describe( 'insertRowBelow button', () => {
+		let insertRow;
+
+		beforeEach( () => {
+			insertRow = editor.ui.componentFactory.create( 'insertRowBelow' );
+		} );
+
+		it( 'should register insertRowBelow button', () => {
+			expect( insertRow ).to.be.instanceOf( ButtonView );
+			expect( insertRow.isOn ).to.be.false;
+			expect( insertRow.label ).to.equal( 'Insert row' );
+			expect( insertRow.icon ).to.match( /<svg / );
+		} );
+
+		it( 'should bind to insertRow command', () => {
+			const command = editor.commands.get( 'insertRowBelow' );
+
+			command.isEnabled = true;
+			expect( insertRow.isOn ).to.be.false;
+			expect( insertRow.isEnabled ).to.be.true;
+
+			command.isEnabled = false;
+			expect( insertRow.isEnabled ).to.be.false;
+		} );
+
+		it( 'should execute insertRow command on button execute event', () => {
+			const executeSpy = testUtils.sinon.spy( editor, 'execute' );
+
+			insertRow.fire( 'execute' );
+
+			sinon.assert.calledOnce( executeSpy );
+			sinon.assert.calledWithExactly( executeSpy, 'insertRowBelow' );
+		} );
+	} );
+
+	describe( 'insertColumnAfter button', () => {
+		let insertColumn;
+
+		beforeEach( () => {
+			insertColumn = editor.ui.componentFactory.create( 'insertColumnAfter' );
+		} );
+
+		it( 'should register insertColumn buton', () => {
+			expect( insertColumn ).to.be.instanceOf( ButtonView );
+			expect( insertColumn.isOn ).to.be.false;
+			expect( insertColumn.label ).to.equal( 'Insert column' );
+			expect( insertColumn.icon ).to.match( /<svg / );
+		} );
+
+		it( 'should bind to insertColumn command', () => {
+			const command = editor.commands.get( 'insertColumnAfter' );
+
+			command.isEnabled = true;
+			expect( insertColumn.isOn ).to.be.false;
+			expect( insertColumn.isEnabled ).to.be.true;
+
+			command.isEnabled = false;
+			expect( insertColumn.isEnabled ).to.be.false;
+		} );
+
+		it( 'should execute insertColumn command on button execute event', () => {
+			const executeSpy = testUtils.sinon.spy( editor, 'execute' );
+
+			insertColumn.fire( 'execute' );
+
+			sinon.assert.calledOnce( executeSpy );
+			sinon.assert.calledWithExactly( executeSpy, 'insertColumnAfter' );
+		} );
+	} );
+} );

+ 750 - 0
packages/ckeditor5-table/tests/tableutils.js

@@ -0,0 +1,750 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters';
+
+import {
+	downcastInsertCell,
+	downcastInsertRow,
+	downcastInsertTable,
+	downcastRemoveRow,
+	downcastTableHeadingColumnsChange,
+	downcastTableHeadingRowsChange
+} from '../src/converters/downcast';
+import upcastTable from '../src/converters/upcasttable';
+import { formatTable, formattedModelTable, modelTable } from './_utils/utils';
+import TableUtils from '../src/tableutils';
+
+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 );
+
+			const conversion = editor.conversion;
+			const schema = model.schema;
+
+			schema.register( 'table', {
+				allowWhere: '$block',
+				allowAttributes: [ 'headingRows' ],
+				isObject: true
+			} );
+
+			schema.register( 'tableRow', { allowIn: 'table' } );
+
+			schema.register( 'tableCell', {
+				allowIn: 'tableRow',
+				allowContentOf: '$block',
+				allowAttributes: [ 'colspan', 'rowspan' ],
+				isLimit: true
+			} );
+
+			model.schema.register( 'p', { inheritAllFrom: '$block' } );
+
+			// Table conversion.
+			conversion.for( 'upcast' ).add( upcastTable() );
+			conversion.for( 'downcast' ).add( downcastInsertTable() );
+
+			// Insert row conversion.
+			conversion.for( 'downcast' ).add( downcastInsertRow() );
+
+			// Remove row conversion.
+			conversion.for( 'downcast' ).add( downcastRemoveRow() );
+
+			// Table cell conversion.
+			conversion.for( 'downcast' ).add( downcastInsertCell() );
+
+			conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'td' } ) );
+			conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'th' } ) );
+
+			// Table attributes conversion.
+			conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } );
+			conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } );
+
+			conversion.for( 'downcast' ).add( downcastTableHeadingColumnsChange() );
+			conversion.for( 'downcast' ).add( downcastTableHeadingRowsChange() );
+		} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	describe( '#pluginName', () => {
+		it( 'should provide plugin name', () => {
+			expect( TableUtils.pluginName ).to.equal( 'TableUtils' );
+		} );
+	} );
+
+	describe( 'getCellLocation()', () => {
+		it( 'should return proper table cell location', () => {
+			setData( model, modelTable( [
+				[ { rowspan: 2, colspan: 2, contents: '00[]' }, '02' ],
+				[ '12' ]
+			] ) );
+
+			expect( tableUtils.getCellLocation( root.getNodeByPath( [ 0, 0, 0 ] ) ) ).to.deep.equal( { row: 0, column: 0 } );
+			expect( tableUtils.getCellLocation( root.getNodeByPath( [ 0, 0, 1 ] ) ) ).to.deep.equal( { row: 0, column: 2 } );
+			expect( tableUtils.getCellLocation( root.getNodeByPath( [ 0, 1, 0 ] ) ) ).to.deep.equal( { row: 1, column: 2 } );
+		} );
+	} );
+
+	describe( 'insertRows()', () => {
+		it( 'should insert row in given table at given index', () => {
+			setData( model, modelTable( [
+				[ '11[]', '12' ],
+				[ '21', '22' ]
+			] ) );
+
+			tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 1 } );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '11[]', '12' ],
+				[ '', '' ],
+				[ '21', '22' ]
+			] ) );
+		} );
+
+		it( 'should insert row in given table at default index', () => {
+			setData( model, modelTable( [
+				[ '11[]', '12' ],
+				[ '21', '22' ]
+			] ) );
+
+			tableUtils.insertRows( root.getNodeByPath( [ 0 ] ) );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '', '' ],
+				[ '11[]', '12' ],
+				[ '21', '22' ]
+			] ) );
+		} );
+
+		it( 'should update table heading rows attribute when inserting row in headings section', () => {
+			setData( model, modelTable( [
+				[ '11[]', '12' ],
+				[ '21', '22' ],
+				[ '31', '32' ]
+			], { headingRows: 2 } ) );
+
+			tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 1 } );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '11[]', '12' ],
+				[ '', '' ],
+				[ '21', '22' ],
+				[ '31', '32' ]
+			], { headingRows: 3 } ) );
+		} );
+
+		it( 'should not update table heading rows attribute when inserting row after headings section', () => {
+			setData( model, modelTable( [
+				[ '11[]', '12' ],
+				[ '21', '22' ],
+				[ '31', '32' ]
+			], { headingRows: 2 } ) );
+
+			tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 2 } );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '11[]', '12' ],
+				[ '21', '22' ],
+				[ '', '' ],
+				[ '31', '32' ]
+			], { headingRows: 2 } ) );
+		} );
+
+		it( 'should expand rowspan of a cell that overlaps inserted rows', () => {
+			setData( model, modelTable( [
+				[ { colspan: 2, contents: '11[]' }, '13', '14' ],
+				[ { colspan: 2, rowspan: 4, contents: '21' }, '23', '24' ],
+				[ '33', '34' ]
+			], { headingColumns: 3, headingRows: 1 } ) );
+
+			tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 2, rows: 3 } );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ { colspan: 2, contents: '11[]' }, '13', '14' ],
+				[ { colspan: 2, rowspan: 7, contents: '21' }, '23', '24' ],
+				[ '', '' ],
+				[ '', '' ],
+				[ '', '' ],
+				[ '33', '34' ]
+			], { headingColumns: 3, headingRows: 1 } ) );
+		} );
+
+		it( 'should not expand rowspan of a cell that does not overlaps inserted rows', () => {
+			setData( model, modelTable( [
+				[ { rowspan: 2, contents: '11[]' }, '12', '13' ],
+				[ '22', '23' ],
+				[ '31', '32', '33' ]
+			], { headingColumns: 3, headingRows: 1 } ) );
+
+			tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 2, rows: 3 } );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ { rowspan: 2, contents: '11[]' }, '12', '13' ],
+				[ '22', '23' ],
+				[ '', '', '' ],
+				[ '', '', '' ],
+				[ '', '', '' ],
+				[ '31', '32', '33' ]
+			], { headingColumns: 3, headingRows: 1 } ) );
+		} );
+
+		it( 'should properly calculate columns if next row has colspans', () => {
+			setData( model, modelTable( [
+				[ { rowspan: 2, contents: '11[]' }, '12', '13' ],
+				[ '22', '23' ],
+				[ { colspan: 3, contents: '31' } ]
+			], { headingColumns: 3, headingRows: 1 } ) );
+
+			tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 2, rows: 3 } );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ { rowspan: 2, contents: '11[]' }, '12', '13' ],
+				[ '22', '23' ],
+				[ '', '', '' ],
+				[ '', '', '' ],
+				[ '', '', '' ],
+				[ { colspan: 3, contents: '31' } ]
+			], { headingColumns: 3, headingRows: 1 } ) );
+		} );
+
+		it( 'should insert rows at the end of a table', () => {
+			setData( model, modelTable( [
+				[ '11[]', '12' ],
+				[ '21', '22' ]
+			] ) );
+
+			tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 2, rows: 3 } );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '11[]', '12' ],
+				[ '21', '22' ],
+				[ '', '' ],
+				[ '', '' ],
+				[ '', '' ]
+			] ) );
+		} );
+	} );
+
+	describe( 'insertColumns()', () => {
+		it( 'should insert column in given table at given index', () => {
+			setData( model, modelTable( [
+				[ '11[]', '12' ],
+				[ '21', '22' ]
+			] ) );
+
+			tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ), { at: 1 } );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '11[]', '', '12' ],
+				[ '21', '', '22' ]
+			] ) );
+		} );
+
+		it( 'should insert column in given table with default values', () => {
+			setData( model, modelTable( [
+				[ '11[]', '12' ],
+				[ '21', '22' ]
+			] ) );
+
+			tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ) );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '', '11[]', '12' ],
+				[ '', '21', '22' ]
+			] ) );
+		} );
+
+		it( 'should insert column in given table at default index', () => {
+			setData( model, modelTable( [
+				[ '11[]', '12' ],
+				[ '21', '22' ]
+			] ) );
+
+			tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ) );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '', '11[]', '12' ],
+				[ '', '21', '22' ]
+			] ) );
+		} );
+
+		it( 'should insert columns at the end of a row', () => {
+			setData( model, modelTable( [
+				[ '00[]', '01' ],
+				[ { colspan: 2, contents: '10' } ],
+				[ '20', { rowspan: 2, contents: '21' } ],
+				[ '30' ]
+			] ) );
+
+			tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ), { at: 2, columns: 2 } );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '00[]', '01', '', '' ],
+				[ { colspan: 2, contents: '10' }, '', '' ],
+				[ '20', { rowspan: 2, contents: '21' }, '', '' ],
+				[ '30', '', '' ]
+			] ) );
+		} );
+
+		it( 'should insert columns at the beginning of a row', () => {
+			setData( model, modelTable( [
+				[ '00[]', '01' ],
+				[ { colspan: 2, contents: '10' } ],
+				[ '20', { rowspan: 2, contents: '21' } ],
+				[ { rowspan: 2, contents: '30' } ],
+				[ '41' ]
+			] ) );
+
+			tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ), { at: 0, columns: 2 } );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '', '', '00[]', '01' ],
+				[ '', '', { colspan: 2, contents: '10' } ],
+				[ '', '', '20', { rowspan: 2, contents: '21' } ],
+				[ '', '', { rowspan: 2, contents: '30' } ],
+				[ '', '', '41' ]
+			] ) );
+		} );
+
+		it( 'should update table heading columns attribute when inserting column in headings section', () => {
+			setData( model, modelTable( [
+				[ '11[]', '12' ],
+				[ '21', '22' ],
+				[ '31', '32' ]
+			], { headingColumns: 2 } ) );
+
+			tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ), { at: 1 } );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '11[]', '', '12' ],
+				[ '21', '', '22' ],
+				[ '31', '', '32' ]
+			], { headingColumns: 3 } ) );
+		} );
+
+		it( 'should not update table heading columns attribute when inserting column after headings section', () => {
+			setData( model, modelTable( [
+				[ '11[]', '12', '13' ],
+				[ '21', '22', '23' ],
+				[ '31', '32', '33' ]
+			], { headingColumns: 2 } ) );
+
+			tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ), { at: 2 } );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '11[]', '12', '', '13' ],
+				[ '21', '22', '', '23' ],
+				[ '31', '32', '', '33' ]
+			], { headingColumns: 2 } ) );
+		} );
+
+		it( 'should expand spanned columns', () => {
+			setData( model, modelTable( [
+				[ '00[]', '01' ],
+				[ { colspan: 2, contents: '10' } ],
+				[ '20', '21' ]
+			], { headingColumns: 2 } ) );
+
+			tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ), { at: 1 } );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '00[]', '', '01' ],
+				[ { colspan: 3, contents: '10' } ],
+				[ '20', '', '21' ]
+			], { headingColumns: 3 } ) );
+		} );
+
+		it( 'should skip wide spanned columns', () => {
+			setData( model, modelTable( [
+				[ '11[]', '12', '13', '14', '15' ],
+				[ '21', '22', { colspan: 2, contents: '23' }, '25' ],
+				[ { colspan: 4, contents: '31' }, { colspan: 2, contents: '34' } ]
+			], { headingColumns: 4 } ) );
+
+			tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ), { at: 2, columns: 2 } );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '11[]', '12', '', '', '13', '14', '15' ],
+				[ '21', '22', '', '', { colspan: 2, contents: '23' }, '25' ],
+				[ { colspan: 6, contents: '31' }, { colspan: 2, contents: '34' } ]
+			], { headingColumns: 6 } ) );
+		} );
+
+		it( 'should skip row & column spanned cells', () => {
+			setData( model, modelTable( [
+				[ { colspan: 2, rowspan: 2, contents: '00[]' }, '02' ],
+				[ '12' ],
+				[ '20', '21', '22' ]
+			], { headingColumns: 2 } ) );
+
+			tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ), { at: 1, columns: 2 } );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ { colspan: 4, rowspan: 2, contents: '00[]' }, '02' ],
+				[ '12' ],
+				[ '20', '', '', '21', '22' ]
+			], { headingColumns: 4 } ) );
+		} );
+
+		it( 'should properly insert column while table has rowspanned cells', () => {
+			setData( model, modelTable( [
+				[ { rowspan: 4, contents: '00[]' }, { rowspan: 2, contents: '01' }, '02' ],
+				[ '12' ],
+				[ { rowspan: 2, contents: '21' }, '22' ],
+				[ '32' ]
+			], { headingColumns: 2 } ) );
+
+			tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ), { at: 1, columns: 1 } );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ { rowspan: 4, contents: '00[]' }, '', { rowspan: 2, contents: '01' }, '02' ],
+				[ '', '12' ],
+				[ '', { rowspan: 2, contents: '21' }, '22' ],
+				[ '', '32' ]
+			], { headingColumns: 3 } ) );
+		} );
+	} );
+
+	describe( 'splitCellVertically()', () => {
+		it( 'should split table cell to given table cells number', () => {
+			setData( model, modelTable( [
+				[ '00', '01', '02' ],
+				[ '10', '[]11', '12' ],
+				[ '20', { colspan: 2, contents: '21' } ],
+				[ { colspan: 2, contents: '30' }, '32' ]
+			] ) );
+
+			tableUtils.splitCellVertically( root.getNodeByPath( [ 0, 1, 1 ] ), 3 );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '00', { colspan: 3, contents: '01' }, '02' ],
+				[ '10', '[]11', '', '', '12' ],
+				[ '20', { colspan: 4, contents: '21' } ],
+				[ { colspan: 4, contents: '30' }, '32' ]
+			] ) );
+		} );
+
+		it( 'should split table cell for two table cells as a default', () => {
+			setData( model, modelTable( [
+				[ '00', '01', '02' ],
+				[ '10', '[]11', '12' ],
+				[ '20', { colspan: 2, contents: '21' } ],
+				[ { colspan: 2, contents: '30' }, '32' ]
+			] ) );
+
+			tableUtils.splitCellVertically( root.getNodeByPath( [ 0, 1, 1 ] ) );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '00', { colspan: 2, contents: '01' }, '02' ],
+				[ '10', '[]11', '', '12' ],
+				[ '20', { colspan: 3, contents: '21' } ],
+				[ { colspan: 3, contents: '30' }, '32' ]
+			] ) );
+		} );
+
+		it( 'should split table cell if split is equal to colspan', () => {
+			setData( model, modelTable( [
+				[ '00', '01', '02' ],
+				[ '10', '11', '12' ],
+				[ '20', { colspan: 2, contents: '21[]' } ],
+				[ { colspan: 2, contents: '30' }, '32' ]
+			] ) );
+
+			tableUtils.splitCellVertically( root.getNodeByPath( [ 0, 2, 1 ] ), 2 );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '00', '01', '02' ],
+				[ '10', '11', '12' ],
+				[ '20', '21[]', '' ],
+				[ { colspan: 2, contents: '30' }, '32' ]
+			] ) );
+		} );
+
+		it( 'should properly split table cell if split is uneven', () => {
+			setData( model, modelTable( [
+				[ '00', '01', '02' ],
+				[ { colspan: 3, contents: '10[]' } ]
+			] ) );
+
+			tableUtils.splitCellVertically( root.getNodeByPath( [ 0, 1, 0 ] ), 2 );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '00', '01', '02' ],
+				[ { colspan: 2, contents: '10[]' }, '' ]
+			] ) );
+		} );
+
+		it( 'should properly set colspan of inserted cells', () => {
+			setData( model, modelTable( [
+				[ '00', '01', '02', '03' ],
+				[ { colspan: 4, contents: '10[]' } ]
+			] ) );
+
+			tableUtils.splitCellVertically( root.getNodeByPath( [ 0, 1, 0 ] ), 2 );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '00', '01', '02', '03' ],
+				[ { colspan: 2, contents: '10[]' }, { colspan: 2, contents: '' } ]
+			] ) );
+		} );
+
+		it( 'should keep rowspan attribute for newly inserted cells', () => {
+			setData( model, modelTable( [
+				[ '00', '01', '02', '03', '04', '05' ],
+				[ { colspan: 5, rowspan: 2, contents: '10[]' }, '15' ],
+				[ '25' ]
+			] ) );
+
+			tableUtils.splitCellVertically( root.getNodeByPath( [ 0, 1, 0 ] ), 2 );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '00', '01', '02', '03', '04', '05' ],
+				[ { colspan: 3, rowspan: 2, contents: '10[]' }, { colspan: 2, rowspan: 2, contents: '' }, '15' ],
+				[ '25' ]
+			] ) );
+		} );
+
+		it( 'should keep rowspan attribute of for newly inserted cells if number of cells is bigger then curren colspan', () => {
+			setData( model, modelTable( [
+				[ '00', '01', '02' ],
+				[ { colspan: 2, rowspan: 2, contents: '10[]' }, '12' ],
+				[ '22' ]
+			] ) );
+
+			tableUtils.splitCellVertically( root.getNodeByPath( [ 0, 1, 0 ] ), 3 );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ { colspan: 2, contents: '00' }, '01', '02' ],
+				[ { rowspan: 2, contents: '10[]' }, { rowspan: 2, contents: '' }, { rowspan: 2, contents: '' }, '12' ],
+				[ '22' ]
+			] ) );
+		} );
+
+		it( 'should properly break a cell if it has colspan and number of created cells is bigger then colspan', () => {
+			setData( model, modelTable( [
+				[ '00', '01', '02', '03' ],
+				[ { colspan: 4, contents: '10[]' } ]
+			] ) );
+
+			tableUtils.splitCellVertically( root.getNodeByPath( [ 0, 1, 0 ] ), 6 );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ { colspan: 3, contents: '00' }, '01', '02', '03' ],
+				[ '10[]', '', '', '', '', '' ]
+			] ) );
+		} );
+
+		it( 'should update heading columns is split cell is in heading section', () => {
+			setData( model, modelTable( [
+				[ '00', '01' ],
+				[ '10[]', '11' ]
+			], { headingColumns: 1 } ) );
+
+			tableUtils.splitCellVertically( root.getNodeByPath( [ 0, 1, 0 ] ), 3 );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ { colspan: 3, contents: '00' }, '01' ],
+				[ '10[]', '', '', '11' ]
+			], { headingColumns: 3 } ) );
+		} );
+	} );
+
+	describe( 'splitCellHorizontally()', () => {
+		it( 'should split table cell to default table cells number', () => {
+			setData( model, modelTable( [
+				[ '00', '01', '02' ],
+				[ '10', '[]11', '12' ],
+				[ '20', '21', '22' ]
+			] ) );
+
+			tableUtils.splitCellHorizontally( root.getNodeByPath( [ 0, 1, 1 ] ) );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '00', '01', '02' ],
+				[ { rowspan: 2, contents: '10' }, '[]11', { rowspan: 2, contents: '12' } ],
+				[ '' ],
+				[ '20', '21', '22' ]
+			] ) );
+		} );
+
+		it( 'should split table cell to given table cells number', () => {
+			setData( model, modelTable( [
+				[ '00', '01', '02' ],
+				[ '10', '[]11', '12' ],
+				[ '20', '21', '22' ]
+			] ) );
+
+			tableUtils.splitCellHorizontally( root.getNodeByPath( [ 0, 1, 1 ] ), 4 );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '00', '01', '02' ],
+				[ { rowspan: 4, contents: '10' }, '[]11', { rowspan: 4, contents: '12' } ],
+				[ '' ],
+				[ '' ],
+				[ '' ],
+				[ '20', '21', '22' ]
+			] ) );
+		} );
+
+		it( 'should properly update rowspanned cells overlapping selected cell', () => {
+			setData( model, modelTable( [
+				[ { rowspan: 2, contents: '00' }, '01', { rowspan: 3, contents: '02' } ],
+				[ '[]11' ],
+				[ '20', '21' ]
+			] ) );
+
+			tableUtils.splitCellHorizontally( root.getNodeByPath( [ 0, 1, 0 ] ), 3 );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ { rowspan: 4, contents: '00' }, '01', { rowspan: 5, contents: '02' } ],
+				[ '[]11' ],
+				[ '' ],
+				[ '' ],
+				[ '20', '21' ]
+			] ) );
+		} );
+
+		it( 'should split rowspanned cell', () => {
+			setData( model, modelTable( [
+				[ '00', { rowspan: 2, contents: '01[]' } ],
+				[ '10' ],
+				[ '20', '21' ]
+			] ) );
+
+			const tableCell = root.getNodeByPath( [ 0, 0, 1 ] );
+
+			tableUtils.splitCellHorizontally( tableCell, 2 );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '00', '01[]' ],
+				[ '10', '' ],
+				[ '20', '21' ]
+			] ) );
+		} );
+
+		it( 'should copy colspan while splitting rowspanned cell', () => {
+			setData( model, modelTable( [
+				[ '00', { rowspan: 2, colspan: 2, contents: '01[]' } ],
+				[ '10' ],
+				[ '20', '21', '22' ]
+			] ) );
+
+			const tableCell = root.getNodeByPath( [ 0, 0, 1 ] );
+
+			tableUtils.splitCellHorizontally( tableCell, 2 );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '00', { colspan: 2, contents: '01[]' } ],
+				[ '10', { colspan: 2, contents: '' } ],
+				[ '20', '21', '22' ]
+			] ) );
+		} );
+
+		it( 'should evenly distribute rowspan attribute', () => {
+			setData( model, modelTable( [
+				[ '00', { rowspan: 7, contents: '01[]' } ],
+				[ '10' ],
+				[ '20' ],
+				[ '30' ],
+				[ '40' ],
+				[ '50' ],
+				[ '60' ],
+				[ '70', '71' ]
+			] ) );
+
+			const tableCell = root.getNodeByPath( [ 0, 0, 1 ] );
+
+			tableUtils.splitCellHorizontally( tableCell, 3 );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '00', { rowspan: 3, contents: '01[]' } ],
+				[ '10' ],
+				[ '20' ],
+				[ '30', { rowspan: 2, contents: '' } ],
+				[ '40' ],
+				[ '50', { rowspan: 2, contents: '' } ],
+				[ '60' ],
+				[ '70', '71' ]
+			] ) );
+		} );
+
+		it( 'should split rowspanned cell and updated other cells rowspan when splitting to bigger number of cells', () => {
+			setData( model, modelTable( [
+				[ '00', { rowspan: 2, contents: '01[]' } ],
+				[ '10' ],
+				[ '20', '21' ]
+			] ) );
+
+			const tableCell = root.getNodeByPath( [ 0, 0, 1 ] );
+
+			tableUtils.splitCellHorizontally( tableCell, 3 );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ { rowspan: 2, contents: '00' }, '01[]' ],
+				[ '' ],
+				[ '10', '' ],
+				[ '20', '21' ]
+			] ) );
+		} );
+
+		it( 'should split rowspanned & colspaned cell', () => {
+			setData( model, modelTable( [
+				[ '00', { colspan: 2, contents: '01[]' } ],
+				[ '10', '11' ]
+			] ) );
+
+			const tableCell = root.getNodeByPath( [ 0, 0, 1 ] );
+
+			tableUtils.splitCellHorizontally( tableCell, 3 );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ { rowspan: 3, contents: '00' }, { colspan: 2, contents: '01[]' } ],
+				[ { colspan: 2, contents: '' } ],
+				[ { colspan: 2, contents: '' } ],
+				[ '10', '11' ]
+			] ) );
+		} );
+
+		it( 'should split table cell from a heading section', () => {
+			setData( model, modelTable( [
+				[ '00[]', '01', '02' ],
+				[ '10', '11', '12' ],
+				[ '20', '21', '22' ]
+			], { headingRows: 1 } ) );
+
+			tableUtils.splitCellHorizontally( root.getNodeByPath( [ 0, 0, 0 ] ), 3 );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '00[]', { rowspan: 3, contents: '01' }, { rowspan: 3, contents: '02' } ],
+				[ '' ],
+				[ '' ],
+				[ '10', '11', '12' ],
+				[ '20', '21', '22' ]
+			], { headingRows: 3 } ) );
+		} );
+	} );
+
+	describe( 'getColumns()', () => {
+		it( 'should return proper number of columns', () => {
+			setData( model, modelTable( [
+				[ '00', { colspan: 3, contents: '01' }, '04' ]
+			] ) );
+
+			expect( tableUtils.getColumns( root.getNodeByPath( [ 0 ] ) ) ).to.equal( 5 );
+		} );
+	} );
+} );

+ 254 - 0
packages/ckeditor5-table/tests/tablewalker.js

@@ -0,0 +1,254 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import { modelTable } from './_utils/utils';
+
+import TableWalker from '../src/tablewalker';
+
+describe( 'TableWalker', () => {
+	let editor, model, doc, root;
+
+	beforeEach( () => {
+		return ModelTestEditor.create()
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				doc = model.document;
+				root = doc.getRoot( 'main' );
+
+				const schema = model.schema;
+
+				schema.register( 'table', {
+					allowWhere: '$block',
+					allowAttributes: [ 'headingRows', 'headingColumns' ],
+					isObject: true
+				} );
+
+				schema.register( 'tableRow', { allowIn: 'table' } );
+
+				schema.register( 'tableCell', {
+					allowIn: 'tableRow',
+					allowContentOf: '$block',
+					allowAttributes: [ 'colspan', 'rowspan' ],
+					isLimit: true
+				} );
+			} );
+	} );
+
+	function testWalker( tableData, expected, options ) {
+		setData( model, modelTable( tableData ) );
+
+		const iterator = new TableWalker( root.getChild( 0 ), options );
+
+		const result = [];
+
+		for ( const tableInfo of iterator ) {
+			result.push( tableInfo );
+		}
+
+		const formattedResult = result.map( ( { row, column, cell, cellIndex } ) => ( {
+			row,
+			column,
+			data: cell && cell.getChild( 0 ).data,
+			index: cellIndex
+		} ) );
+
+		expect( formattedResult ).to.deep.equal( expected );
+	}
+
+	it( 'should iterate over a table', () => {
+		testWalker( [
+			[ '00', '01' ],
+			[ '10', '11' ]
+		], [
+			{ row: 0, column: 0, index: 0, data: '00' },
+			{ row: 0, column: 1, index: 1, data: '01' },
+			{ row: 1, column: 0, index: 0, data: '10' },
+			{ row: 1, column: 1, index: 1, data: '11' }
+		] );
+	} );
+
+	it( 'should properly output column indexes of a table that has colspans', () => {
+		testWalker( [
+			[ { colspan: 2, contents: '00' }, '13' ]
+		], [
+			{ row: 0, column: 0, index: 0, data: '00' },
+			{ row: 0, column: 2, index: 1, data: '13' }
+		] );
+	} );
+
+	it( 'should properly output column indexes of a table that has rowspans', () => {
+		testWalker( [
+			[ { colspan: 2, rowspan: 3, contents: '00' }, '02' ],
+			[ '12' ],
+			[ '22' ],
+			[ '30', '31', '32' ]
+		], [
+			{ row: 0, column: 0, index: 0, data: '00' },
+			{ row: 0, column: 2, index: 1, data: '02' },
+			{ row: 1, column: 2, index: 0, data: '12' },
+			{ row: 2, column: 2, index: 0, data: '22' },
+			{ row: 3, column: 0, index: 0, data: '30' },
+			{ row: 3, column: 1, index: 1, data: '31' },
+			{ row: 3, column: 2, index: 2, data: '32' }
+		] );
+	} );
+
+	it( 'should properly output column indexes of a table that has multiple rowspans', () => {
+		testWalker( [
+			[ { rowspan: 3, contents: '11' }, '12', '13' ],
+			[ { rowspan: 2, contents: '22' }, '23' ],
+			[ '33' ],
+			[ '41', '42', '43' ]
+		], [
+			{ row: 0, column: 0, index: 0, data: '11' },
+			{ row: 0, column: 1, index: 1, data: '12' },
+			{ row: 0, column: 2, index: 2, data: '13' },
+			{ row: 1, column: 1, index: 0, data: '22' },
+			{ row: 1, column: 2, index: 1, data: '23' },
+			{ row: 2, column: 2, index: 0, data: '33' },
+			{ row: 3, column: 0, index: 0, data: '41' },
+			{ row: 3, column: 1, index: 1, data: '42' },
+			{ row: 3, column: 2, index: 2, data: '43' }
+		] );
+	} );
+
+	describe( 'option.startRow', () => {
+		it( 'should start iterating from given row but with cell spans properly calculated', () => {
+			testWalker( [
+				[ { colspan: 2, rowspan: 3, contents: '11' }, '13' ],
+				[ '23' ],
+				[ '33' ],
+				[ '41', '42', '43' ]
+			], [
+				{ row: 2, column: 2, index: 0, data: '33' },
+				{ row: 3, column: 0, index: 0, data: '41' },
+				{ row: 3, column: 1, index: 1, data: '42' },
+				{ row: 3, column: 2, index: 2, data: '43' }
+			], { startRow: 2 } );
+		} );
+	} );
+
+	describe( 'option.endRow', () => {
+		it( 'should stopp iterating after given row but with cell spans properly calculated', () => {
+			testWalker( [
+				[ { colspan: 2, rowspan: 3, contents: '11' }, '13' ],
+				[ '23' ],
+				[ '33' ],
+				[ '41', '42', '43' ]
+			], [
+				{ row: 0, column: 0, index: 0, data: '11' },
+				{ row: 0, column: 2, index: 1, data: '13' },
+				{ row: 1, column: 2, index: 0, data: '23' },
+				{ row: 2, column: 2, index: 0, data: '33' }
+			], { endRow: 2 } );
+		} );
+
+		it( 'should iterate over given row 0 only', () => {
+			testWalker( [
+				[ { colspan: 2, rowspan: 3, contents: '11' }, '13' ],
+				[ '23' ],
+				[ '33' ],
+				[ '41', '42', '43' ]
+			], [
+				{ row: 0, column: 0, index: 0, data: '11' },
+				{ row: 0, column: 2, index: 1, data: '13' }
+			], { endRow: 0 } );
+		} );
+	} );
+
+	describe( 'option.includeSpanned', () => {
+		it( 'should output spanned cells at the end of a table', () => {
+			testWalker( [
+				[ '00', { rowspan: 2, contents: '01' } ],
+				[ '10' ]
+			], [
+				{ row: 0, column: 0, index: 0, data: '00' },
+				{ row: 0, column: 1, index: 1, data: '01' },
+				{ row: 1, column: 0, index: 0, data: '10' },
+				{ row: 1, column: 1, index: 1, data: undefined }
+			], { includeSpanned: true } );
+		} );
+
+		it( 'should output spanned cells as empty cell', () => {
+			testWalker( [
+				[ { colspan: 2, rowspan: 3, contents: '00' }, '02' ],
+				[ '12' ],
+				[ '22' ],
+				[ '30', { colspan: 2, contents: '31' } ]
+			], [
+				{ row: 0, column: 0, index: 0, data: '00' },
+				{ row: 0, column: 1, index: 1, data: undefined },
+				{ row: 0, column: 2, index: 1, data: '02' },
+				{ row: 1, column: 0, index: 0, data: undefined },
+				{ row: 1, column: 1, index: 0, data: undefined },
+				{ row: 1, column: 2, index: 0, data: '12' },
+				{ row: 2, column: 0, index: 0, data: undefined },
+				{ row: 2, column: 1, index: 0, data: undefined },
+				{ row: 2, column: 2, index: 0, data: '22' },
+				{ row: 3, column: 0, index: 0, data: '30' },
+				{ row: 3, column: 1, index: 1, data: '31' },
+				{ row: 3, column: 2, index: 2, data: undefined }
+			], { includeSpanned: true } );
+		} );
+
+		it( 'should output rowspanned cells at the end of a table row', () => {
+			testWalker( [
+				[ '00', { rowspan: 2, contents: '01' } ],
+				[ '10' ]
+			], [
+				{ row: 0, column: 0, index: 0, data: '00' },
+				{ row: 0, column: 1, index: 1, data: '01' },
+				{ row: 1, column: 0, index: 0, data: '10' },
+				{ row: 1, column: 1, index: 1, data: undefined }
+			], { includeSpanned: true } );
+		} );
+
+		it( 'should work with startRow & endRow options', () => {
+			testWalker( [
+				[ { colspan: 2, rowspan: 3, contents: '00' }, '02' ],
+				[ '12' ],
+				[ '22' ],
+				[ '30', '31', '32' ]
+			], [
+				{ row: 1, column: 0, index: 0, data: undefined },
+				{ row: 1, column: 1, index: 0, data: undefined },
+				{ row: 1, column: 2, index: 0, data: '12' },
+				{ row: 2, column: 0, index: 0, data: undefined },
+				{ row: 2, column: 1, index: 0, data: undefined },
+				{ row: 2, column: 2, index: 0, data: '22' }
+			], { includeSpanned: true, startRow: 1, endRow: 2 } );
+		} );
+
+		it( 'should output rowspanned cells at the end of a table row', () => {
+			testWalker( [
+				[ '00', { rowspan: 2, contents: '01' } ],
+				[ '10' ],
+				[ '20', '21' ]
+			], [
+				{ row: 0, column: 0, index: 0, data: '00' },
+				{ row: 0, column: 1, index: 1, data: '01' },
+				{ row: 1, column: 0, index: 0, data: '10' },
+				{ row: 1, column: 1, index: 1, data: undefined }
+			], { startRow: 0, endRow: 1, includeSpanned: true } );
+		} );
+	} );
+
+	describe( 'options.startColumn', () => {
+		it( 'should output only cells on given column', () => {
+			testWalker( [
+				[ { colspan: 2, rowspan: 3, contents: '00' }, '02' ],
+				[ '12' ],
+				[ '22' ],
+				[ '30', '31', '32' ]
+			], [
+				{ row: 0, column: 0, index: 0, data: '00' },
+				{ row: 3, column: 1, index: 1, data: '31' }
+			], { column: 1 } );
+		} );
+	} );
+} );

+ 26 - 0
packages/ckeditor5-table/theme/table.css

@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+.ck table.ck-widget th.ck-editor__nested-editable {
+	border-color: inherit;
+	border-style: inset;
+}
+
+.ck table.ck-widget td.ck-editor__nested-editable {
+	border-color: inherit;
+	border-style: inset;
+}
+
+.ck table.ck-widget td.ck-editor__nested-editable.ck-editor__nested-editable_focused,
+.ck table.ck-widget td.ck-editor__nested-editable:focus {
+	background-color: inherit;
+	color: inherit;
+}
+
+.ck table.ck-widget th.ck-editor__nested-editable.ck-editor__nested-editable_focused,
+.ck table.ck-widget th.ck-editor__nested-editable:focus {
+	background-color: inherit;
+	color: inherit;
+}