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

Commit

Permalink
Merge 8318c30 into d905bef
Browse files Browse the repository at this point in the history
  • Loading branch information
jodator committed Jan 28, 2020
2 parents d905bef + 8318c30 commit 0e58b7b
Show file tree
Hide file tree
Showing 17 changed files with 1,545 additions and 109 deletions.
260 changes: 260 additions & 0 deletions src/commands/mergecellscommand.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
/**
* @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';

/**
* The merge cells command.
*
* The command is registered by {@link module:table/tableediting~TableEditing} as `'mergeTableCellRight'`, `'mergeTableCellLeft'`,
* `'mergeTableCellUp'` and `'mergeTableCellDown'` editor commands.
*
* To merge a table cell at the current selection with another cell, execute the command corresponding with the preferred direction.
*
* For example, to merge with a cell to the right:
*
* editor.execute( 'mergeTableCellRight' );
*
* **Note**: If a table cell has a different [`rowspan`](https://www.w3.org/TR/html50/tabular-data.html#attr-tdth-rowspan)
* (for `'mergeTableCellRight'` and `'mergeTableCellLeft'`) or [`colspan`](https://www.w3.org/TR/html50/tabular-data.html#attr-tdth-colspan)
* (for `'mergeTableCellUp'` and `'mergeTableCellDown'`), the command will be disabled.
*
* @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.
*
* Depending on the command's {@link #direction} value, it will merge the cell that is to the `'left'`, `'right'`, `'up'` or `'down'`.
*
* @fires execute
*/
execute() {
const model = this.editor.model;

const tableUtils = this.editor.plugins.get( TableUtils );

model.change( writer => {
const selectedTableCells = [ ... this.editor.model.document.selection.getRanges() ].map( range => range.start.nodeAfter );

const firstTableCell = selectedTableCells.shift();

// TODO: this shouldn't be necessary (right now the selection could overlap existing.
writer.setSelection( firstTableCell, 'in' );

const { row, column } = tableUtils.getCellLocation( firstTableCell );

const colspan = parseInt( firstTableCell.getAttribute( 'colspan' ) || 1 );
const rowspan = parseInt( firstTableCell.getAttribute( 'rowspan' ) || 1 );

let rightMax = column + colspan;
let bottomMax = row + rowspan;

const rowsToCheck = new Set();

for ( const tableCell of selectedTableCells ) {
const { row, column } = tableUtils.getCellLocation( tableCell );

const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );

if ( column + colspan > rightMax ) {
rightMax = column + colspan;
}

if ( row + rowspan > bottomMax ) {
bottomMax = row + rowspan;
}
}

for ( const tableCell of selectedTableCells ) {
rowsToCheck.add( tableCell.parent );
mergeTableCells( tableCell, firstTableCell, writer );
}

// Update table cell span attribute and merge set selection on merged contents.
updateNumericAttribute( 'colspan', rightMax - column, firstTableCell, writer );
updateNumericAttribute( 'rowspan', bottomMax - row, firstTableCell, writer );

writer.setSelection( firstTableCell, 'in' );

// Remove empty rows after merging table cells.
for ( const row of rowsToCheck ) {
if ( !row.childCount ) {
removeEmptyRow( row, writer );
}
}
} );
}
}

// Properly removes empty row from a table. Will update `rowspan` attribute of cells that overlaps removed row.
//
// @param {module:engine/model/element~Element} removedTableCellRow
// @param {module:engine/model/writer~Writer} writer
function removeEmptyRow( removedTableCellRow, writer ) {
const table = removedTableCellRow.parent;

const removedRowIndex = table.getChildIndex( removedTableCellRow );

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( removedTableCellRow );
}

// Merges two table cells - will ensure that after merging cells with empty paragraph the result table cell will only have one paragraph.
// If one of the merged table cell 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} cellToRemove
// @param {module:engine/model/element~Element} cellToExpand
// @param {module:engine/model/writer~Writer} writer
function mergeTableCells( cellToRemove, cellToExpand, writer ) {
if ( !isEmpty( cellToRemove ) ) {
if ( isEmpty( cellToExpand ) ) {
writer.remove( writer.createRangeIn( cellToExpand ) );
}

writer.move( writer.createRangeIn( cellToRemove ), writer.createPositionAt( cellToExpand, 'end' ) );
}

// Remove merged table cell.
writer.remove( cellToRemove );
}

// Checks if passed table cell contains 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;
}

// Check if selection contains mergeable cells.
//
// In a table below:
//
// +---+---+---+---+
// | a | b | c | d |
// +---+---+---+ +
// | e | f | |
// + +---+---+
// | | g | h |
// +---+---+---+---+
//
// Valid selections are those which creates 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 cell in row below)
//
// While 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 ) {
// Collapsed selection or selection only one range can't contain mergeable table cells.
if ( selection.isCollapsed || selection.rangeCount < 2 ) {
return false;
}

// All cells must be inside the same table.
let firstRangeTable;

const tableCells = [];

for ( const range of selection.getRanges() ) {
// Selection ranges must be set on whole <tableCell> element.
if ( range.isCollapsed || !range.isFlat || !range.start.nodeAfter.is( 'tableCell' ) ) {
return false;
}

const parentTable = findAncestor( 'table', range.start );

if ( !firstRangeTable ) {
firstRangeTable = parentTable;
} else if ( firstRangeTable !== parentTable ) {
return false;
}

tableCells.push( range.start.nodeAfter );
}

// At this point selection contains ranges over table cells in the same table.
// The valid selection is a fully occupied rectangle composed of table cells.
// Below we calculate area of selected cells and the area of valid selection.
// The area of 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 tableCells ) {
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 );
}
24 changes: 21 additions & 3 deletions src/commands/removecolumncommand.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,30 +51,48 @@ export default class RemoveColumnCommand extends Command {
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;
const removedRow = cellData.row;

let cellToFocus;

const tableUtils = this.editor.plugins.get( 'TableUtils' );
const columns = tableUtils.getColumns( tableCell.parent.parent );

const columnToFocus = removedColumn === columns - 1 ? removedColumn - 1 : removedColumn + 1;
const rowToFocus = removedRow;

model.change( writer => {
// Update heading columns attribute if removing a row from head section.
if ( headingColumns && row <= headingColumns ) {
if ( headingColumns && removedRow <= headingColumns ) {
writer.setAttribute( 'headingColumns', headingColumns - 1, table );
}

for ( const { cell, column, colspan } of tableMap ) {
for ( const { cell, row, column, rowspan, 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 );
}

if ( isCellToFocusAfterRemoving( row, rowToFocus, rowspan, column, columnToFocus, colspan ) ) {
cellToFocus = cell;
}
}

writer.setSelection( writer.createPositionAt( cellToFocus, 0 ) );
} );
}
}

function isCellToFocusAfterRemoving( row, rowToFocus, rowspan, column, columnToFocus, colspan ) {
return ( row <= rowToFocus && row + rowspan >= rowToFocus ) && ( column <= columnToFocus && column + colspan >= columnToFocus );
}
30 changes: 23 additions & 7 deletions src/commands/removerowcommand.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,30 +48,36 @@ export default class RemoveRowCommand extends Command {
const tableRow = tableCell.parent;
const table = tableRow.parent;

const currentRow = table.getChildIndex( tableRow );
const removedRow = table.getChildIndex( tableRow );

const tableMap = [ ...new TableWalker( table, { endRow: removedRow } ) ];

const cellData = tableMap.find( value => value.cell === tableCell );

const headingRows = table.getAttribute( 'headingRows' ) || 0;

const rowToFocus = removedRow;
const columnToFocus = cellData.column;

model.change( writer => {
if ( headingRows && currentRow <= headingRows ) {
if ( headingRows && removedRow <= 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 )
.filter( ( { row, rowspan } ) => row === removedRow && 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 )
.filter( ( { row, rowspan } ) => row <= removedRow - 1 && row + rowspan > removedRow )
.forEach( ( { cell, rowspan } ) => updateNumericAttribute( 'rowspan', rowspan - 1, cell, writer ) );

// Move cells to another row.
const targetRow = currentRow + 1;
const targetRow = removedRow + 1;
const tableWalker = new TableWalker( table, { includeSpanned: true, startRow: targetRow, endRow: targetRow } );

let previousCell;
Expand All @@ -93,6 +99,16 @@ export default class RemoveRowCommand extends Command {
}

writer.remove( tableRow );

const { cell: cellToFocus } = [ ...new TableWalker( table ) ].find( ( { row, column, rowspan, colspan } ) => {
return isCellToFocusAfterRemoving( row, rowToFocus, rowspan, column, columnToFocus, colspan );
} );

writer.setSelection( writer.createPositionAt( cellToFocus, 0 ) );
} );
}
}

function isCellToFocusAfterRemoving( row, rowToFocus, rowspan, column, columnToFocus, colspan ) {
return ( row <= rowToFocus && row + rowspan >= rowToFocus + 1 ) && ( column <= columnToFocus && column + colspan >= columnToFocus + 1 );
}
5 changes: 4 additions & 1 deletion 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 { findAncestor } from './commands/utils';
import TableUtils from '../src/tableutils';

Expand Down Expand Up @@ -75,7 +76,7 @@ export default class TableEditing extends Plugin {
schema.register( 'tableCell', {
allowIn: 'tableRow',
allowAttributes: [ 'colspan', 'rowspan' ],
isLimit: true
isObject: true
} );

// Allow all $block content inside table cell.
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

0 comments on commit 0e58b7b

Please sign in to comment.