Ver código fonte

Merge pull request #290 from ckeditor/i/6123

Feature: Introduced the `MergeCellsCommand`. Closes ckeditor/ckeditor5#6123.
Aleksander Nowodzinski 5 anos atrás
pai
commit
64797d495d

+ 241 - 0
packages/ckeditor5-table/src/commands/mergecellscommand.js

@@ -0,0 +1,241 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/commands/mergecellscommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+import TableWalker from '../tablewalker';
+import { findAncestor, updateNumericAttribute } from './utils';
+import TableUtils from '../tableutils';
+import { getRowIndexes, getSelectedTableCells } from '../utils';
+
+/**
+ * The merge cells command.
+ *
+ * The command is registered by the {@link module:table/tableediting~TableEditing} as `'mergeTableCells'` editor command.
+ *
+ * For example, to merge selected table cells:
+ *
+ *		editor.execute( 'mergeTableCells' );
+ *
+ * @extends module:core/command~Command
+ */
+export default class MergeCellsCommand extends Command {
+	/**
+	 * @inheritDoc
+	 */
+	refresh() {
+		this.isEnabled = canMergeCells( this.editor.model.document.selection, this.editor.plugins.get( TableUtils ) );
+	}
+
+	/**
+	 * Executes the command.
+	 *
+	 * @fires execute
+	 */
+	execute() {
+		const model = this.editor.model;
+		const tableUtils = this.editor.plugins.get( TableUtils );
+
+		model.change( writer => {
+			const selectedTableCells = getSelectedTableCells( model.document.selection );
+
+			// All cells will be merge into the first one.
+			const firstTableCell = selectedTableCells.shift();
+
+			// This prevents the "model-selection-range-intersects" error, caused by removing row selected cells.
+			writer.setSelection( firstTableCell, 'on' );
+
+			// Update target cell dimensions.
+			const { mergeWidth, mergeHeight } = getMergeDimensions( firstTableCell, selectedTableCells, tableUtils );
+			updateNumericAttribute( 'colspan', mergeWidth, firstTableCell, writer );
+			updateNumericAttribute( 'rowspan', mergeHeight, firstTableCell, writer );
+
+			for ( const tableCell of selectedTableCells ) {
+				const tableRow = tableCell.parent;
+				mergeTableCells( tableCell, firstTableCell, writer );
+				removeRowIfEmpty( tableRow, writer );
+			}
+
+			writer.setSelection( firstTableCell, 'in' );
+		} );
+	}
+}
+
+// Properly removes the empty row from a table. Updates the `rowspan` attribute of cells that overlap the removed row.
+//
+// @param {module:engine/model/element~Element} row
+// @param {module:engine/model/writer~Writer} writer
+function removeRowIfEmpty( row, writer ) {
+	if ( row.childCount ) {
+		return;
+	}
+
+	const table = row.parent;
+	const removedRowIndex = table.getChildIndex( row );
+
+	for ( const { cell, row, rowspan } of new TableWalker( table, { endRow: removedRowIndex } ) ) {
+		const overlapsRemovedRow = row + rowspan - 1 >= removedRowIndex;
+
+		if ( overlapsRemovedRow ) {
+			updateNumericAttribute( 'rowspan', rowspan - 1, cell, writer );
+		}
+	}
+
+	writer.remove( row );
+}
+
+// Merges two table cells - will ensure that after merging cells with empty paragraphs the result table cell will only have one paragraph.
+// If one of the merged table cells is empty, the merged table cell will have contents of the non-empty table cell.
+// If both are empty, the merged table cell will have only one empty paragraph.
+//
+// @param {module:engine/model/element~Element} cellBeingMerged
+// @param {module:engine/model/element~Element} targetCell
+// @param {module:engine/model/writer~Writer} writer
+function mergeTableCells( cellBeingMerged, targetCell, writer ) {
+	if ( !isEmpty( cellBeingMerged ) ) {
+		if ( isEmpty( targetCell ) ) {
+			writer.remove( writer.createRangeIn( targetCell ) );
+		}
+
+		writer.move( writer.createRangeIn( cellBeingMerged ), writer.createPositionAt( targetCell, 'end' ) );
+	}
+
+	// Remove merged table cell.
+	writer.remove( cellBeingMerged );
+}
+
+// Checks if the passed table cell contains an empty paragraph.
+//
+// @param {module:engine/model/element~Element} tableCell
+// @returns {Boolean}
+function isEmpty( tableCell ) {
+	return tableCell.childCount == 1 && tableCell.getChild( 0 ).is( 'paragraph' ) && tableCell.getChild( 0 ).isEmpty;
+}
+
+// Checks if the selection contains mergeable cells.
+//
+// In a table below:
+//
+//   ┌───┬───┬───┬───┐
+//   │ a │ b │ c │ d │
+//   ├───┴───┼───┤   │
+//   │ e     │ f │   │
+//   ├       ├───┼───┤
+//   │       │ g │ h │
+//   └───────┴───┴───┘
+//
+// Valid selections are these which create a solid rectangle (without gaps), such as:
+//   - a, b (two horizontal cells)
+//   - c, f (two vertical cells)
+//   - a, b, e (cell "e" spans over four cells)
+//   - c, d, f (cell d spans over a cell in the row below)
+//
+// While an invalid selection would be:
+//   - a, c (cell "b" not selected creates a gap)
+//   - f, g, h (cell "d" spans over a cell from row of "f" cell - thus creates a gap)
+//
+// @param {module:engine/model/selection~Selection} selection
+// @param {module:table/tableUtils~TableUtils} tableUtils
+// @returns {boolean}
+function canMergeCells( selection, tableUtils ) {
+	const selectedTableCells = getSelectedTableCells( selection );
+
+	if ( selectedTableCells.length < 2 || !areCellInTheSameTableSection( selectedTableCells ) ) {
+		return false;
+	}
+
+	// A valid selection is a fully occupied rectangle composed of table cells.
+	// Below we will calculate the area of a selected table cells and the area of valid selection.
+	// The area of a valid selection is defined by top-left and bottom-right cells.
+	const rows = new Set();
+	const columns = new Set();
+
+	let areaOfSelectedCells = 0;
+
+	for ( const tableCell of selectedTableCells ) {
+		const { row, column } = tableUtils.getCellLocation( tableCell );
+		const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
+		const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
+
+		// Record row & column indexes of current cell.
+		rows.add( row );
+		columns.add( column );
+
+		// For cells that spans over multiple rows add also the last row that this cell spans over.
+		if ( rowspan > 1 ) {
+			rows.add( row + rowspan - 1 );
+		}
+
+		// For cells that spans over multiple columns add also the last column that this cell spans over.
+		if ( colspan > 1 ) {
+			columns.add( column + colspan - 1 );
+		}
+
+		areaOfSelectedCells += ( rowspan * colspan );
+	}
+
+	// We can only merge table cells that are in adjacent rows...
+	const areaOfValidSelection = getBiggestRectangleArea( rows, columns );
+
+	return areaOfValidSelection == areaOfSelectedCells;
+}
+
+// Calculates the area of a maximum rectangle that can span over provided row & column indexes.
+//
+// @param {Array.<Number>} rows
+// @param {Array.<Number>} columns
+// @returns {Number}
+function getBiggestRectangleArea( rows, columns ) {
+	const rowsIndexes = Array.from( rows.values() );
+	const columnIndexes = Array.from( columns.values() );
+
+	const lastRow = Math.max( ...rowsIndexes );
+	const firstRow = Math.min( ...rowsIndexes );
+	const lastColumn = Math.max( ...columnIndexes );
+	const firstColumn = Math.min( ...columnIndexes );
+
+	return ( lastRow - firstRow + 1 ) * ( lastColumn - firstColumn + 1 );
+}
+
+function areCellInTheSameTableSection( tableCells ) {
+	const table = findAncestor( 'table', tableCells[ 0 ] );
+
+	const rowIndexes = getRowIndexes( tableCells );
+	const headingRows = parseInt( table.getAttribute( 'headingRows' ) || 0 );
+
+	const firstCellIsInBody = rowIndexes.first > headingRows - 1;
+	const lastCellIsInBody = rowIndexes.last > headingRows - 1;
+
+	return firstCellIsInBody === lastCellIsInBody;
+}
+
+function getMergeDimensions( firstTableCell, selectedTableCells, tableUtils ) {
+	let maxWidthOffset = 0;
+	let maxHeightOffset = 0;
+
+	for ( const tableCell of selectedTableCells ) {
+		const { row, column } = tableUtils.getCellLocation( tableCell );
+
+		maxWidthOffset = getMaxOffset( tableCell, column, maxWidthOffset, 'colspan' );
+		maxHeightOffset = getMaxOffset( tableCell, row, maxHeightOffset, 'rowspan' );
+	}
+
+	// Update table cell span attribute and merge set selection on a merged contents.
+	const { row: firstCellRow, column: firstCellColumn } = tableUtils.getCellLocation( firstTableCell );
+
+	const mergeWidth = maxWidthOffset - firstCellColumn;
+	const mergeHeight = maxHeightOffset - firstCellRow;
+
+	return { mergeWidth, mergeHeight };
+}
+
+function getMaxOffset( tableCell, start, currentMaxOffset, which ) {
+	const dimensionValue = parseInt( tableCell.getAttribute( which ) || 1 );
+
+	return Math.max( currentMaxOffset, start + dimensionValue );
+}

+ 1 - 11
packages/ckeditor5-table/src/commands/removerowcommand.js

@@ -11,7 +11,7 @@ import Command from '@ckeditor/ckeditor5-core/src/command';
 
 import TableWalker from '../tablewalker';
 import { findAncestor, updateNumericAttribute } from './utils';
-import { getSelectionAffectedTableCells } from '../utils';
+import { getRowIndexes, getSelectionAffectedTableCells } from '../utils';
 
 /**
  * The remove row command.
@@ -138,16 +138,6 @@ export default class RemoveRowCommand extends Command {
 	}
 }
 
-// Returns a helper object with first and last row index contained in given `referenceCells`.
-function getRowIndexes( referenceCells ) {
-	const allIndexesSorted = referenceCells.map( cell => cell.parent.index ).sort();
-
-	return {
-		first: allIndexesSorted[ 0 ],
-		last: allIndexesSorted[ allIndexesSorted.length - 1 ]
-	};
-}
-
 // Returns a cell that should be focused before removing the row, belonging to the same column as the currently focused cell.
 // * If the row was not the last one, the cell to focus will be in the row that followed it (before removal).
 // * If the row was the last one, the cell to focus will be in the row that preceded it (before removal).

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

@@ -28,6 +28,7 @@ import RemoveRowCommand from './commands/removerowcommand';
 import RemoveColumnCommand from './commands/removecolumncommand';
 import SetHeaderRowCommand from './commands/setheaderrowcommand';
 import SetHeaderColumnCommand from './commands/setheadercolumncommand';
+import MergeCellsCommand from './commands/mergecellscommand';
 import { getTableCellsContainingSelection } from './utils';
 import TableUtils from '../src/tableutils';
 
@@ -131,6 +132,8 @@ export default class TableEditing extends Plugin {
 		editor.commands.add( 'splitTableCellVertically', new SplitCellCommand( editor, { direction: 'vertically' } ) );
 		editor.commands.add( 'splitTableCellHorizontally', new SplitCellCommand( editor, { direction: 'horizontally' } ) );
 
+		editor.commands.add( 'mergeTableCells', new MergeCellsCommand( editor ) );
+
 		editor.commands.add( 'mergeTableCellRight', new MergeCellCommand( editor, { direction: 'right' } ) );
 		editor.commands.add( 'mergeTableCellLeft', new MergeCellCommand( editor, { direction: 'left' } ) );
 		editor.commands.add( 'mergeTableCellDown', new MergeCellCommand( editor, { direction: 'down' } ) );

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

@@ -187,6 +187,14 @@ export default class TableUI extends Plugin {
 					}
 				},
 				{ type: 'separator' },
+				{
+					type: 'button',
+					model: {
+						commandName: 'mergeTableCells',
+						label: t( 'Merge cells' )
+					}
+				},
+				{ type: 'separator' },
 				{
 					type: 'button',
 					model: {

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

@@ -137,6 +137,27 @@ export function getSelectionAffectedTableCells( selection ) {
 	return getTableCellsContainingSelection( selection );
 }
 
+/**
+ * Returns a helper object with `first` and `last` row index contained in given `tableCells`.
+ *
+ *		const selectedTableCells = getSelectedTableCells( editor.model.document.selection );
+ *
+ *		const { first, last } = getRowIndexes( selectedTableCells );
+ *
+ *		console.log( `Selected rows ${ first } to ${ last }` );
+ *
+ * @package {Array.<module:engine/model/element~Element>}
+ * @returns {Object} Returns an object with `first` and `last` table row indexes.
+ */
+export function getRowIndexes( tableCells ) {
+	const allIndexesSorted = tableCells.map( cell => cell.parent.index ).sort();
+
+	return {
+		first: allIndexesSorted[ 0 ],
+		last: allIndexesSorted[ allIndexesSorted.length - 1 ]
+	};
+}
+
 function sortRanges( rangesIterator ) {
 	return Array.from( rangesIterator ).sort( compareRangeOrder );
 }

+ 480 - 0
packages/ckeditor5-table/tests/commands/mergecellscommand.js

@@ -0,0 +1,480 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import MergeCellsCommand from '../../src/commands/mergecellscommand';
+import { modelTable } from '../_utils/utils';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
+import TableSelection from '../../src/tableselection';
+import TableEditing from '../../src/tableediting';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+describe( 'MergeCellsCommand', () => {
+	let editor, model, command, root, tableSelection;
+
+	beforeEach( async () => {
+		editor = await ModelTestEditor.create( {
+			plugins: [ Paragraph, TableEditing, TableSelection ]
+		} );
+
+		model = editor.model;
+		root = model.document.getRoot( 'main' );
+		tableSelection = editor.plugins.get( TableSelection );
+
+		command = new MergeCellsCommand( editor );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	describe( 'isEnabled', () => {
+		it( 'should be false if collapsed selection in table cell', () => {
+			setData( model, modelTable( [
+				[ '00[]', '01' ]
+			] ) );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be false if only one table cell is selected', () => {
+			setData( model, modelTable( [
+				[ '00', '01' ]
+			] ) );
+
+			selectNodes( [ [ 0, 0, 0 ] ] );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be true if at least two adjacent table cells are selected', () => {
+			setData( model, modelTable( [
+				[ '00', '01' ]
+			] ) );
+
+			selectNodes( [ [ 0, 0, 0 ], [ 0, 0, 1 ] ] );
+
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be true if many table cells are selected', () => {
+			setData( model, modelTable( [
+				[ '00', '01', '02', '03' ],
+				[ '10', '11', '12', '13' ],
+				[ '20', '21', '22', '23' ],
+				[ '30', '31', '32', '33' ]
+			] ) );
+
+			selectNodes( [
+				[ 0, 0, 1 ], [ 0, 0, 2 ],
+				[ 0, 1, 1 ], [ 0, 1, 2 ],
+				[ 0, 2, 1 ], [ 0, 2, 2 ],
+				[ 0, 3, 1 ], [ 0, 3, 2 ]
+			] );
+
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be false if at least one table cell is not selected from an area', () => {
+			setData( model, modelTable( [
+				[ '00', '01', '02', '03' ],
+				[ '10', '11', '12', '13' ],
+				[ '20', '21', '22', '23' ],
+				[ '30', '31', '32', '33' ]
+			] ) );
+
+			selectNodes( [
+				[ 0, 0, 1 ], [ 0, 0, 2 ],
+				[ 0, 1, 2 ], // one table cell not selected from this row
+				[ 0, 2, 1 ], [ 0, 2, 2 ],
+				[ 0, 3, 1 ], [ 0, 3, 2 ]
+			] );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be false if table cells are not in adjacent rows', () => {
+			setData( model, modelTable( [
+				[ '00', '01' ],
+				[ '10', '11' ]
+			] ) );
+
+			selectNodes( [
+				[ 0, 1, 0 ],
+				[ 0, 0, 1 ]
+			] );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be false if table cells are not in adjacent columns', () => {
+			setData( model, modelTable( [
+				[ '00', '01', '02' ]
+			] ) );
+
+			selectNodes( [ [ 0, 0, 0 ], [ 0, 0, 2 ] ] );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be false if any table cell with colspan attribute extends over selection area', () => {
+			setData( model, modelTable( [
+				[ '00', { colspan: 2, contents: '01' } ],
+				[ '10', '11', '12' ]
+			] ) );
+
+			selectNodes( [
+				[ 0, 0, 0 ], [ 0, 0, 1 ],
+				[ 0, 1, 0 ], [ 0, 1, 1 ]
+			] );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be true if none table cell with colspan attribute extends over selection area', () => {
+			setData( model, modelTable( [
+				[ '00', { colspan: 2, contents: '01' } ],
+				[ '10', '11', '12' ]
+			] ) );
+
+			selectNodes( [
+				[ 0, 0, 0 ], [ 0, 0, 1 ],
+				[ 0, 1, 0 ], [ 0, 1, 1 ],
+				[ 0, 1, 2 ]
+			] );
+
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be true if first table cell is inside selection area', () => {
+			setData( model, modelTable( [
+				[ { colspan: 2, rowspan: 2, contents: '00' }, '02', '03' ],
+				[ '12', '13' ]
+			] ) );
+
+			selectNodes( [
+				[ 0, 0, 0 ], [ 0, 0, 1 ],
+				[ 0, 1, 0 ]
+			] );
+
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be false if any table cell with rowspan attribute extends over selection area', () => {
+			setData( model, modelTable( [
+				[ '00', { rowspan: 2, contents: '01' } ],
+				[ '10' ]
+			] ) );
+
+			selectNodes( [ [ 0, 0, 0 ], [ 0, 0, 1 ] ] );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be true if none table cell with rowspan attribute extends over selection area', () => {
+			setData( model, modelTable( [
+				[ '00', { rowspan: 2, contents: '01' } ],
+				[ '10' ]
+			] ) );
+
+			selectNodes( [
+				[ 0, 0, 0 ], [ 0, 0, 1 ],
+				[ 0, 1, 0 ]
+			] );
+
+			expect( command.isEnabled ).to.be.true;
+		} );
+
+		it( 'should be false if not in a cell', () => {
+			setData( model, '<paragraph>11[]</paragraph>' );
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+
+		it( 'should be false if selection has cells from header and body sections', () => {
+			setData( model, modelTable( [
+				[ '00[]', '01' ],
+				[ '10', '11' ]
+			], { headingRows: 1 } ) );
+
+			tableSelection._setCellSelection(
+				root.getNodeByPath( [ 0, 0, 0 ] ),
+				root.getNodeByPath( [ 0, 1, 0 ] )
+			);
+
+			expect( command.isEnabled ).to.be.false;
+		} );
+	} );
+
+	describe( 'execute()', () => {
+		it( 'should merge simple table cell selection', () => {
+			setData( model, modelTable( [
+				[ '[]00', '01' ]
+			] ) );
+
+			tableSelection._setCellSelection(
+				root.getNodeByPath( [ 0, 0, 0 ] ),
+				root.getNodeByPath( [ 0, 0, 1 ] )
+			);
+
+			command.execute();
+
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ { colspan: 2, contents: '<paragraph>[00</paragraph><paragraph>01]</paragraph>' } ]
+			] ) );
+		} );
+
+		it( 'should merge selection with a cell with rowspan in the selection', () => {
+			setData( model, modelTable( [
+				[ '[]00', '01', '02' ],
+				[ '10', { contents: '11', rowspan: 2 }, '12' ],
+				[ '20', '22' ]
+			] ) );
+
+			tableSelection._setCellSelection(
+				root.getNodeByPath( [ 0, 1, 0 ] ),
+				root.getNodeByPath( [ 0, 2, 1 ] )
+			);
+
+			command.execute();
+
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ '00', '01', '02' ],
+				[ {
+					colspan: 3,
+					contents: '<paragraph>[10</paragraph><paragraph>11</paragraph><paragraph>12</paragraph>' +
+						'<paragraph>20</paragraph><paragraph>22]</paragraph>'
+				} ]
+			] ) );
+		} );
+
+		it( 'should merge selection with a cell with rowspan in the selection (reverse selection)', () => {
+			setData( model, modelTable( [
+				[ '[]00', '01', '02' ],
+				[ '10', { contents: '11', rowspan: 2 }, '12' ],
+				[ '20', '22' ]
+			] ) );
+
+			tableSelection._setCellSelection(
+				root.getNodeByPath( [ 0, 2, 1 ] ),
+				root.getNodeByPath( [ 0, 1, 0 ] )
+			);
+
+			command.execute();
+
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ '00', '01', '02' ],
+				[ {
+					colspan: 3,
+					contents: '<paragraph>[10</paragraph><paragraph>11</paragraph><paragraph>12</paragraph>' +
+						'<paragraph>20</paragraph><paragraph>22]</paragraph>'
+				} ]
+			] ) );
+		} );
+
+		it( 'should merge selection inside a table (properly calculate target rowspan/colspan)', () => {
+			setData( model, modelTable( [
+				[ '[]00', '01', '02', '03' ],
+				[ '10', '11', { contents: '12', rowspan: 2 }, '13' ],
+				[ '20', '21', '23' ],
+				[ '30', '31', '32', '33' ]
+			] ) );
+
+			tableSelection._setCellSelection(
+				root.getNodeByPath( [ 0, 2, 1 ] ),
+				root.getNodeByPath( [ 0, 1, 2 ] )
+			);
+
+			command.execute();
+
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ '00', '01', '02', '03' ],
+				[ '10', {
+					colspan: 2,
+					rowspan: 2,
+					contents: '<paragraph>[11</paragraph><paragraph>12</paragraph><paragraph>21]</paragraph>'
+				}, '13' ],
+				[ '20', '23' ],
+				[ '30', '31', '32', '33' ]
+			] ) );
+		} );
+
+		it( 'should merge table cells - extend colspan attribute', () => {
+			setData( model, modelTable( [
+				[ { colspan: 2, contents: '00' }, '02', '03' ],
+				[ '10', '11', '12', '13' ]
+			] ) );
+
+			selectNodes( [
+				[ 0, 0, 0 ], [ 0, 0, 1 ],
+				[ 0, 1, 0 ], [ 0, 1, 1 ], [ 0, 1, 2 ]
+			] );
+
+			command.execute();
+
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ {
+					colspan: 3,
+					rowspan: 2,
+					contents: '<paragraph>[00</paragraph>' +
+						'<paragraph>02</paragraph>' +
+						'<paragraph>10</paragraph>' +
+						'<paragraph>11</paragraph>' +
+						'<paragraph>12]</paragraph>'
+				}, '03' ],
+				[ '13' ]
+			] ) );
+		} );
+
+		it( 'should merge to a single paragraph - every cell is empty', () => {
+			setData( model, modelTable( [
+				[ '[]', '' ]
+			] ) );
+
+			selectNodes( [ [ 0, 0, 0 ], [ 0, 0, 1 ] ] );
+
+			command.execute();
+
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ { colspan: 2, contents: '<paragraph>[]</paragraph>' } ]
+			] ) );
+		} );
+
+		it( 'should merge to a single paragraph - merged cell is empty', () => {
+			setData( model, modelTable( [
+				[ 'foo', '' ]
+			] ) );
+
+			selectNodes( [ [ 0, 0, 0 ], [ 0, 0, 1 ] ] );
+
+			command.execute();
+
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ { colspan: 2, contents: '<paragraph>[foo]</paragraph>' } ]
+			] ) );
+		} );
+
+		it( 'should merge to a single paragraph - cell to which others are merged is empty', () => {
+			setData( model, modelTable( [
+				[ '', 'foo' ]
+			] ) );
+
+			selectNodes( [ [ 0, 0, 0 ], [ 0, 0, 1 ] ] );
+
+			command.execute();
+
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ { colspan: 2, contents: '<paragraph>[foo]</paragraph>' } ]
+			] ) );
+		} );
+
+		it( 'should not merge empty blocks other then <paragraph> to a single block', () => {
+			model.schema.register( 'block', {
+				allowWhere: '$block',
+				allowContentOf: '$block',
+				isBlock: true
+			} );
+
+			setData( model, modelTable( [
+				[ '<block>[]</block>', '<block></block>' ]
+			] ) );
+
+			selectNodes( [ [ 0, 0, 0 ], [ 0, 0, 1 ] ] );
+
+			command.execute();
+
+			assertEqualMarkup( getData( model ), modelTable( [
+				[ { colspan: 2, contents: '<block>[</block><block>]</block>' } ]
+			] ) );
+		} );
+
+		describe( 'removing empty row', () => {
+			it( 'should remove empty row if merging all table cells from that row', () => {
+				setData( model, modelTable( [
+					[ '00' ],
+					[ '10' ],
+					[ '20' ]
+				] ) );
+
+				selectNodes( [
+					[ 0, 0, 0 ],
+					[ 0, 1, 0 ],
+					[ 0, 2, 0 ]
+				] );
+
+				command.execute();
+
+				assertEqualMarkup( getData( model ), modelTable( [
+					[
+						'<paragraph>[00</paragraph><paragraph>10</paragraph><paragraph>20]</paragraph>'
+					]
+				] ) );
+			} );
+
+			it( 'should decrease rowspan if cell overlaps removed row', () => {
+				setData( model, modelTable( [
+					[ '00', { rowspan: 2, contents: '01' }, { rowspan: 3, contents: '02' } ],
+					[ '10' ],
+					[ '20', '21' ]
+				] ) );
+
+				selectNodes( [
+					[ 0, 0, 0 ],
+					[ 0, 1, 0 ],
+					[ 0, 2, 0 ]
+				] );
+
+				command.execute();
+
+				assertEqualMarkup( getData( model ), modelTable( [
+					[
+						{ rowspan: 2, contents: '<paragraph>[00</paragraph><paragraph>10</paragraph><paragraph>20]</paragraph>' },
+						'01',
+						{ rowspan: 2, contents: '02' }
+					],
+					[ '21' ]
+				] ) );
+			} );
+
+			it( 'should not decrease rowspan if cell from previous row does not overlaps removed row', () => {
+				setData( model, modelTable( [
+					[ '00', { rowspan: 2, contents: '01' } ],
+					[ '10' ],
+					[ '20', '21' ],
+					[ '30', '31' ]
+				] ) );
+
+				selectNodes( [
+					[ 0, 2, 0 ], [ 0, 2, 1 ],
+					[ 0, 3, 0 ], [ 0, 3, 1 ]
+				] );
+
+				command.execute();
+
+				assertEqualMarkup( getData( model ), modelTable( [
+					[ '00', { rowspan: 2, contents: '01' } ],
+					[ '10' ],
+					[
+						{
+							colspan: 2,
+							contents: '<paragraph>[20</paragraph><paragraph>21</paragraph>' +
+								'<paragraph>30</paragraph><paragraph>31]</paragraph>'
+						}
+					]
+				] ) );
+			} );
+		} );
+	} );
+
+	function selectNodes( paths ) {
+		model.change( writer => {
+			const ranges = paths.map( path => writer.createRangeOn( root.getNodeByPath( path ) ) );
+
+			writer.setSelection( ranges );
+		} );
+	}
+} );

+ 30 - 13
packages/ckeditor5-table/tests/tableui.js

@@ -372,6 +372,8 @@ describe( 'TableUI', () => {
 				'Merge cell down',
 				'Merge cell left',
 				'|',
+				'Merge cells',
+				'|',
 				'Split cell vertically',
 				'Split cell horizontally'
 			] );
@@ -384,6 +386,7 @@ describe( 'TableUI', () => {
 			const mergeCellRightCommand = editor.commands.get( 'mergeTableCellRight' );
 			const mergeCellDownCommand = editor.commands.get( 'mergeTableCellDown' );
 			const mergeCellLeftCommand = editor.commands.get( 'mergeTableCellLeft' );
+			const mergeCellsCommand = editor.commands.get( 'mergeTableCells' );
 			const splitCellVerticallyCommand = editor.commands.get( 'splitTableCellVertically' );
 			const splitCellHorizontallyCommand = editor.commands.get( 'splitTableCellHorizontally' );
 
@@ -391,38 +394,52 @@ describe( 'TableUI', () => {
 			mergeCellRightCommand.isEnabled = true;
 			mergeCellDownCommand.isEnabled = true;
 			mergeCellLeftCommand.isEnabled = true;
+			mergeCellsCommand.isEnabled = true;
 			splitCellVerticallyCommand.isEnabled = true;
 			splitCellHorizontallyCommand.isEnabled = true;
 
-			expect( items.first.children.first.isEnabled ).to.be.true;
-			expect( items.get( 1 ).children.first.isEnabled ).to.be.true;
-			expect( items.get( 2 ).children.first.isEnabled ).to.be.true;
-			expect( items.get( 3 ).children.first.isEnabled ).to.be.true;
-			expect( items.get( 5 ).children.first.isEnabled ).to.be.true;
-			expect( items.get( 6 ).children.first.isEnabled ).to.be.true;
+			const mergeCellUpButton = items.first;
+			const mergeCellRightButton = items.get( 1 );
+			const mergeCellDownButton = items.get( 2 );
+			const mergeCellLeftButton = items.get( 3 );
+			// separator
+			const mergeCellsButton = items.get( 5 );
+			// separator
+			const splitVerticallyButton = items.get( 7 );
+			const splitHorizontallyButton = items.get( 8 );
+
+			expect( mergeCellUpButton.children.first.isEnabled ).to.be.true;
+			expect( mergeCellRightButton.children.first.isEnabled ).to.be.true;
+			expect( mergeCellDownButton.children.first.isEnabled ).to.be.true;
+			expect( mergeCellLeftButton.children.first.isEnabled ).to.be.true;
+			expect( mergeCellsButton.children.first.isEnabled ).to.be.true;
+			expect( splitVerticallyButton.children.first.isEnabled ).to.be.true;
+			expect( splitHorizontallyButton.children.first.isEnabled ).to.be.true;
 			expect( dropdown.buttonView.isEnabled ).to.be.true;
 
 			mergeCellUpCommand.isEnabled = false;
-
-			expect( items.first.children.first.isEnabled ).to.be.false;
+			expect( mergeCellUpButton.children.first.isEnabled ).to.be.false;
 			expect( dropdown.buttonView.isEnabled ).to.be.true;
 
 			mergeCellRightCommand.isEnabled = false;
 
-			expect( items.get( 1 ).children.first.isEnabled ).to.be.false;
+			expect( mergeCellRightButton.children.first.isEnabled ).to.be.false;
 			expect( dropdown.buttonView.isEnabled ).to.be.true;
 
 			mergeCellDownCommand.isEnabled = false;
-			expect( items.get( 2 ).children.first.isEnabled ).to.be.false;
+			expect( mergeCellDownButton.children.first.isEnabled ).to.be.false;
 
 			mergeCellLeftCommand.isEnabled = false;
-			expect( items.get( 3 ).children.first.isEnabled ).to.be.false;
+			expect( mergeCellLeftButton.children.first.isEnabled ).to.be.false;
+
+			mergeCellsCommand.isEnabled = false;
+			expect( mergeCellsButton.children.first.isEnabled ).to.be.false;
 
 			splitCellVerticallyCommand.isEnabled = false;
-			expect( items.get( 5 ).children.first.isEnabled ).to.be.false;
+			expect( splitVerticallyButton.children.first.isEnabled ).to.be.false;
 
 			splitCellHorizontallyCommand.isEnabled = false;
-			expect( items.get( 6 ).children.first.isEnabled ).to.be.false;
+			expect( splitHorizontallyButton.children.first.isEnabled ).to.be.false;
 
 			expect( dropdown.buttonView.isEnabled ).to.be.false;
 		} );