Skip to content
This repository has been archived by the owner on Jun 26, 2020. It is now read-only.

Commit

Permalink
Merge pull request #290 from ckeditor/i/6123
Browse files Browse the repository at this point in the history
Feature: Introduced the `MergeCellsCommand`. Closes ckeditor/ckeditor5#6123.
  • Loading branch information
oleq authored Apr 1, 2020
2 parents 9fffd07 + e5cb4b7 commit a5a7d3e
Show file tree
Hide file tree
Showing 7 changed files with 784 additions and 24 deletions.
241 changes: 241 additions & 0 deletions src/commands/mergecellscommand.js
Original file line number Diff line number Diff line change
@@ -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 );
}
12 changes: 1 addition & 11 deletions src/commands/removerowcommand.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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).
Expand Down
3 changes: 3 additions & 0 deletions src/tableediting.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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' } ) );
Expand Down
8 changes: 8 additions & 0 deletions src/tableui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
21 changes: 21 additions & 0 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}
Expand Down
Loading

0 comments on commit a5a7d3e

Please sign in to comment.