diff --git a/src/commands/mergecellscommand.js b/src/commands/mergecellscommand.js new file mode 100644 index 00000000..c0fc30d0 --- /dev/null +++ b/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.} rows +// @param {Array.} 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 ); +} diff --git a/src/commands/removerowcommand.js b/src/commands/removerowcommand.js index c1162a10..7e716a5a 100644 --- a/src/commands/removerowcommand.js +++ b/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). diff --git a/src/tableediting.js b/src/tableediting.js index 353999f1..46f80cea 100644 --- a/src/tableediting.js +++ b/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' } ) ); diff --git a/src/tableui.js b/src/tableui.js index ae7c009b..0ab19ee6 100644 --- a/src/tableui.js +++ b/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: { diff --git a/src/utils.js b/src/utils.js index 408884bd..a4f066cf 100644 --- a/src/utils.js +++ b/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.} + * @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 ); } diff --git a/tests/commands/mergecellscommand.js b/tests/commands/mergecellscommand.js new file mode 100644 index 00000000..d0689ece --- /dev/null +++ b/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, '11[]' ); + + 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: '[0001]' } ] + ] ) ); + } ); + + 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: '[101112' + + '2022]' + } ] + ] ) ); + } ); + + 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: '[101112' + + '2022]' + } ] + ] ) ); + } ); + + 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: '[111221]' + }, '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: '[00' + + '02' + + '10' + + '11' + + '12]' + }, '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: '[]' } ] + ] ) ); + } ); + + 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: '[foo]' } ] + ] ) ); + } ); + + 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: '[foo]' } ] + ] ) ); + } ); + + it( 'should not merge empty blocks other then to a single block', () => { + model.schema.register( 'block', { + allowWhere: '$block', + allowContentOf: '$block', + isBlock: true + } ); + + setData( model, modelTable( [ + [ '[]', '' ] + ] ) ); + + selectNodes( [ [ 0, 0, 0 ], [ 0, 0, 1 ] ] ); + + command.execute(); + + assertEqualMarkup( getData( model ), modelTable( [ + [ { colspan: 2, contents: '[]' } ] + ] ) ); + } ); + + 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( [ + [ + '[001020]' + ] + ] ) ); + } ); + + 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: '[001020]' }, + '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: '[2021' + + '3031]' + } + ] + ] ) ); + } ); + } ); + } ); + + function selectNodes( paths ) { + model.change( writer => { + const ranges = paths.map( path => writer.createRangeOn( root.getNodeByPath( path ) ) ); + + writer.setSelection( ranges ); + } ); + } +} ); diff --git a/tests/tableui.js b/tests/tableui.js index b02a05b2..c554e83d 100644 --- a/tests/tableui.js +++ b/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; } );