@ckeditor/ckeditor5-table
Version:
Table feature for CKEditor 5.
1,070 lines • 51.3 kB
JavaScript
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module table/tableutils
*/
import { CKEditorError } from 'ckeditor5/src/utils.js';
import { Plugin } from 'ckeditor5/src/core.js';
import TableWalker from './tablewalker.js';
import { createEmptyTableCell, updateNumericAttribute } from './utils/common.js';
import { removeEmptyColumns, removeEmptyRows } from './utils/structure.js';
import { getTableColumnElements } from './tablecolumnresize/utils.js';
/**
* The table utilities plugin.
*/
export default class TableUtils extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return 'TableUtils';
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
init() {
this.decorate('insertColumns');
this.decorate('insertRows');
}
/**
* Returns the table cell location as an object with table row and table column indexes.
*
* For instance, in the table below:
*
* 0 1 2 3
* +---+---+---+---+
* 0 | a | b | c |
* + + +---+
* 1 | | | d |
* +---+---+ +---+
* 2 | e | | f |
* +---+---+---+---+
*
* the method will return:
*
* ```ts
* 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 }
* ```
*
* @returns 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, { row: rowIndex });
for (const { cell, row, column } of tableWalker) {
if (cell === tableCell) {
return { row, column };
}
}
// Should be unreachable code.
/* istanbul ignore next -- @preserve */
return undefined;
}
/**
* Creates an empty table with a proper structure. The table needs to be inserted into the model,
* for example, by using the {@link module:engine/model/model~Model#insertContent} function.
*
* ```ts
* model.change( ( writer ) => {
* // Create a table of 2 rows and 7 columns:
* const table = tableUtils.createTable( writer, { rows: 2, columns: 7 } );
*
* // Insert a table to the model at the best position taking the current selection:
* model.insertContent( table );
* }
* ```
*
* @param writer The model writer.
* @param options.rows The number of rows to create. Default value is 2.
* @param options.columns The number of columns to create. Default value is 2.
* @param options.headingRows The number of heading rows. Default value is 0.
* @param options.headingColumns The number of heading columns. Default value is 0.
* @returns The created table element.
*/
createTable(writer, options) {
const table = writer.createElement('table');
const rows = options.rows || 2;
const columns = options.columns || 2;
createEmptyRows(writer, table, 0, rows, columns);
if (options.headingRows) {
updateNumericAttribute('headingRows', Math.min(options.headingRows, rows), table, writer, 0);
}
if (options.headingColumns) {
updateNumericAttribute('headingColumns', Math.min(options.headingColumns, columns), table, writer, 0);
}
return table;
}
/**
* Inserts rows into a table.
*
* ```ts
* 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 table The table model element where the rows will be inserted.
* @param options.at The row index at which the rows will be inserted. Default value is 0.
* @param options.rows The number of rows to insert. Default value is 1.
* @param options.copyStructureFromAbove The flag for copying row structure. Note that
* the row structure will not be copied if this option is not provided.
*/
insertRows(table, options = {}) {
const model = this.editor.model;
const insertAt = options.at || 0;
const rowsToInsert = options.rows || 1;
const isCopyStructure = options.copyStructureFromAbove !== undefined;
const copyStructureFrom = options.copyStructureFromAbove ? insertAt - 1 : insertAt;
const rows = this.getRows(table);
const columns = this.getColumns(table);
if (insertAt > rows) {
/**
* The `options.at` points at a row position that does not exist.
*
* @error tableutils-insertrows-insert-out-of-range
*/
throw new CKEditorError('tableutils-insertrows-insert-out-of-range', this, { options });
}
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) {
updateNumericAttribute('headingRows', headingRows + rowsToInsert, table, writer, 0);
}
// Inserting at the end or at the beginning of a table doesn't require to calculate anything special.
if (!isCopyStructure && (insertAt === 0 || insertAt === rows)) {
createEmptyRows(writer, table, insertAt, rowsToInsert, columns);
return;
}
// Iterate over all the rows above the inserted rows in order to check for the row-spanned cells.
const walkerEndRow = isCopyStructure ? Math.max(insertAt, copyStructureFrom) : insertAt;
const tableIterator = new TableWalker(table, { endRow: walkerEndRow });
// Store spans of the reference row to reproduce it's structure. This array is column number indexed.
const rowColSpansMap = new Array(columns).fill(1);
for (const { row, column, cellHeight, cellWidth, cell } of tableIterator) {
const lastCellRow = row + cellHeight - 1;
const isOverlappingInsertedRow = row < insertAt && insertAt <= lastCellRow;
const isReferenceRow = row <= copyStructureFrom && copyStructureFrom <= lastCellRow;
// If the cell is row-spanned and overlaps the inserted row, then reserve space for it in the row map.
if (isOverlappingInsertedRow) {
// This cell overlaps the inserted rows so we need to expand it further.
writer.setAttribute('rowspan', cellHeight + rowsToInsert, cell);
// Mark this cell with negative number to indicate how many cells should be skipped when adding the new cells.
rowColSpansMap[column] = -cellWidth;
}
// Store the colspan from reference row.
else if (isCopyStructure && isReferenceRow) {
rowColSpansMap[column] = cellWidth;
}
}
for (let rowIndex = 0; rowIndex < rowsToInsert; rowIndex++) {
const tableRow = writer.createElement('tableRow');
writer.insert(tableRow, table, insertAt);
for (let cellIndex = 0; cellIndex < rowColSpansMap.length; cellIndex++) {
const colspan = rowColSpansMap[cellIndex];
const insertPosition = writer.createPositionAt(tableRow, 'end');
// Insert the empty cell only if this slot is not row-spanned from any other cell.
if (colspan > 0) {
createEmptyTableCell(writer, insertPosition, colspan > 1 ? { colspan } : undefined);
}
// Skip the col-spanned slots, there won't be any cells.
cellIndex += Math.abs(colspan) - 1;
}
}
});
}
/**
* Inserts columns into a table.
*
* ```ts
* 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 table The table model element where the columns will be inserted.
* @param options.at The column index at which the columns will be inserted. Default value is 0.
* @param options.columns The number of columns to insert. Default value is 1.
*/
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()) {
// Ignore non-row elements inside the table (e.g. caption).
if (!tableRow.is('element', 'tableRow')) {
continue;
}
createCells(columnsToInsert, writer, writer.createPositionAt(tableRow, insertAt ? 'end' : 0));
}
return;
}
const tableWalker = new TableWalker(table, { column: insertAt, includeAllSlots: true });
for (const tableSlot of tableWalker) {
const { row, cell, cellAnchorColumn, cellAnchorRow, cellWidth, cellHeight } = tableSlot;
// 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 `includeAllSlots: true`),
// - or a cell from the same row which spans over this column (cell "a").
if (cellAnchorColumn < insertAt) {
// If cell is anchored in previous column, 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', cellWidth + columnsToInsert, cell);
// This cell will overlap cells in rows below so skip them (because of `includeAllSlots` option) - (cell "a")
const lastCellRow = cellAnchorRow + cellHeight - 1;
for (let i = row; i <= lastCellRow; i++) {
tableWalker.skipRow(i);
}
}
else {
// It's either cell at this column index or spanned cell by a row-spanned 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")
createCells(columnsToInsert, writer, tableSlot.getPositionBefore());
}
}
});
}
/**
* Removes rows from the given `table`.
*
* This method re-calculates the table geometry including `rowspan` attribute of table cells overlapping removed rows
* and table headings values.
*
* ```ts
* editor.plugins.get( 'TableUtils' ).removeRows( table, { at: 1, rows: 2 } );
* ```
*
* Executing the above code in the context of the table on the left will transform its structure as presented on the right:
*
* row index
* ┌───┬───┬───┐ `at` = 1 ┌───┬───┬───┐
* 0 │ a │ b │ c │ `rows` = 2 │ a │ b │ c │ 0
* │ ├───┼───┤ │ ├───┼───┤
* 1 │ │ d │ e │ <-- remove from here │ │ d │ g │ 1
* │ │ ├───┤ will give: ├───┼───┼───┤
* 2 │ │ │ f │ │ h │ i │ j │ 2
* │ │ ├───┤ └───┴───┴───┘
* 3 │ │ │ g │
* ├───┼───┼───┤
* 4 │ h │ i │ j │
* └───┴───┴───┘
*
* @param options.at The row index at which the removing rows will start.
* @param options.rows The number of rows to remove. Default value is 1.
*/
removeRows(table, options) {
const model = this.editor.model;
const rowsToRemove = options.rows || 1;
const rowCount = this.getRows(table);
const first = options.at;
const last = first + rowsToRemove - 1;
if (last > rowCount - 1) {
/**
* The `options.at` param must point at existing row and `options.rows` must not exceed the rows in the table.
*
* @error tableutils-removerows-row-index-out-of-range
*/
throw new CKEditorError('tableutils-removerows-row-index-out-of-range', this, { table, options });
}
model.change(writer => {
const indexesObject = { first, last };
// Removing rows from the table require that most calculations to be done prior to changing table structure.
// Preparations must be done in the same enqueueChange callback to use the current table structure.
// 1. Preparation - get row-spanned cells that have to be modified after removing rows.
const { cellsToMove, cellsToTrim } = getCellsToMoveAndTrimOnRemoveRow(table, indexesObject);
// 2. Execution
// 2a. Move cells from removed rows that extends over a removed section - must be done before removing rows.
// This will fill any gaps in a rows below that previously were empty because of row-spanned cells.
if (cellsToMove.size) {
const rowAfterRemovedSection = last + 1;
moveCellsToRow(table, rowAfterRemovedSection, cellsToMove, writer);
}
// 2b. Remove all required rows.
for (let i = last; i >= first; i--) {
writer.remove(table.getChild(i));
}
// 2c. Update cells from rows above that overlap removed section. Similar to step 2 but does not involve moving cells.
for (const { rowspan, cell } of cellsToTrim) {
updateNumericAttribute('rowspan', rowspan, cell, writer);
}
// 2d. Adjust heading rows if removed rows were in a heading section.
updateHeadingRows(table, indexesObject, writer);
// 2e. Remove empty columns (without anchored cells) if there are any.
if (!removeEmptyColumns(table, this)) {
// If there wasn't any empty columns then we still need to check if this wasn't called
// because of cleaning empty rows and we only removed one of them.
removeEmptyRows(table, this);
}
});
}
/**
* Removes columns from the given `table`.
*
* This method re-calculates the table geometry including the `colspan` attribute of table cells overlapping removed columns
* and table headings values.
*
* ```ts
* editor.plugins.get( 'TableUtils' ).removeColumns( table, { at: 1, columns: 2 } );
* ```
*
* Executing the above code in the context of the table on the left will transform its structure as presented on the right:
*
* 0 1 2 3 4 0 1 2
* ┌───────────────┬───┐ ┌───────┬───┐
* │ a │ b │ │ a │ b │
* │ ├───┤ │ ├───┤
* │ │ c │ │ │ c │
* ├───┬───┬───┬───┼───┤ will give: ├───┬───┼───┤
* │ d │ e │ f │ g │ h │ │ d │ g │ h │
* ├───┼───┼───┤ ├───┤ ├───┤ ├───┤
* │ i │ j │ k │ │ l │ │ i │ │ l │
* ├───┴───┴───┴───┴───┤ ├───┴───┴───┤
* │ m │ │ m │
* └───────────────────┘ └───────────┘
* ^---- remove from here, `at` = 1, `columns` = 2
*
* @param options.at The row index at which the removing columns will start.
* @param options.columns The number of columns to remove.
*/
removeColumns(table, options) {
const model = this.editor.model;
const first = options.at;
const columnsToRemove = options.columns || 1;
const last = options.at + columnsToRemove - 1;
model.change(writer => {
adjustHeadingColumns(table, { first, last }, writer);
const tableColumns = getTableColumnElements(table);
for (let removedColumnIndex = last; removedColumnIndex >= first; removedColumnIndex--) {
for (const { cell, column, cellWidth } of [...new TableWalker(table)]) {
// If colspaned cell overlaps removed column decrease its span.
if (column <= removedColumnIndex && cellWidth > 1 && column + cellWidth > removedColumnIndex) {
updateNumericAttribute('colspan', cellWidth - 1, cell, writer);
}
else if (column === removedColumnIndex) {
// The cell in removed column has colspan of 1.
writer.remove(cell);
}
}
// If table has `tableColumn` elements, we need to update it manually.
// See https://github.com/ckeditor/ckeditor5/issues/14521#issuecomment-1662102889 for details.
if (tableColumns[removedColumnIndex]) {
// If the removed column is the first one then we need to add its width to the next column.
// Otherwise we add it to the previous column.
const adjacentColumn = removedColumnIndex === 0 ? tableColumns[1] : tableColumns[removedColumnIndex - 1];
const removedColumnWidth = parseFloat(tableColumns[removedColumnIndex].getAttribute('columnWidth'));
const adjacentColumnWidth = parseFloat(adjacentColumn.getAttribute('columnWidth'));
writer.remove(tableColumns[removedColumnIndex]);
// Add the removed column width (in %) to the adjacent column.
writer.setAttribute('columnWidth', removedColumnWidth + adjacentColumnWidth + '%', adjacentColumn);
}
}
// Remove empty rows that could appear after removing columns.
if (!removeEmptyRows(table, this)) {
// If there wasn't any empty rows then we still need to check if this wasn't called
// because of cleaning empty columns and we only removed one of them.
removeEmptyColumns(table, this);
}
});
}
/**
* Divides a table cell vertically into several ones.
*
* The cell will be visually split into 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 into 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 its `colspan` updated to `3` and 2 cells will be added (2 columns will be created).
*
* Splitting a cell that already has a `colspan` attribute set will distribute the cell `colspan` evenly and the remainder
* will be left to the original cell:
*
* +---+---+---+
* | a |
* +---+---+---+
* | b | c | d |
* +---+---+---+
*
* Splitting cell "a" with `colspan=3` into 2 cells will create 1 cell with a `colspan=a` and cell "a" that will have `colspan=2`:
*
* +---+---+---+
* | a | |
* +---+---+---+
* | b | c | d |
* +---+---+---+
*/
splitCellVertically(tableCell, numberOfCells = 2) {
const model = this.editor.model;
const tableRow = tableCell.parent;
const table = tableRow.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 (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, writer.createPositionAfter(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, cellWidth, column }) => {
const isOnSameColumn = cell !== tableCell && column === splitCellColumn;
const spansOverColumn = (column < splitCellColumn && column + cellWidth > splitCellColumn);
return isOnSameColumn || spansOverColumn;
});
// Expand cells vertically.
for (const { cell, cellWidth } of cellsToUpdate) {
writer.setAttribute('colspan', cellWidth + 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, writer.createPositionAfter(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 a table cell horizontally into several ones.
*
* The cell will be visually split into more cells by updating rowspans of other cells in the row and inserting rows with a single cell
* below.
*
* If in the table below cell "b" is split into 3 cells:
*
* +---+---+---+
* | a | b | c |
* +---+---+---+
* | d | e | f |
* +---+---+---+
*
* It will result in the table below:
*
* +---+---+---+
* | a | b | c |
* + +---+ +
* | | | |
* + +---+ +
* | | | |
* +---+---+---+
* | d | e | f |
* +---+---+---+
*
* So cells "a" and "b" will get their `rowspan` updated to `3` and 2 rows with a single cell will be added.
*
* Splitting a cell that already has a `rowspan` attribute set will distribute the cell `rowspan` evenly and the remainder
* will be left to the original cell:
*
* +---+---+---+
* | a | b | c |
* + +---+---+
* | | d | e |
* + +---+---+
* | | f | g |
* + +---+---+
* | | h | i |
* +---+---+---+
*
* Splitting cell "a" with `rowspan=4` into 3 cells will create 2 cells with a `rowspan=1` and cell "a" will have `rowspan=2`:
*
* +---+---+---+
* | a | b | c |
* + +---+---+
* | | d | e |
* +---+---+---+
* | | f | g |
* +---+---+---+
* | | h | i |
* +---+---+---+
*/
splitCellHorizontally(tableCell, numberOfCells = 2) {
const model = this.editor.model;
const tableRow = tableCell.parent;
const table = tableRow.parent;
const splitCellRow = table.getChildIndex(tableRow);
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,
includeAllSlots: 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;
}
// Accumulator that stores distance from the last inserted cell span.
// It helps with evenly splitting larger cell spans (for example 10 cells collapsing into 3 cells).
// We split these cells into 3, 3, 4 cells and we have to call `createCells` only when distance between
// these cells is equal or greater than the new cells span size.
let distanceFromLastCellSpan = 0;
for (const tableSlot of tableMap) {
const { column, row } = tableSlot;
// 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;
// Reset distance from the last cell span if we are on the same column and we exceeded the new cells span size.
if (distanceFromLastCellSpan >= newCellsSpan && isOnSameColumn) {
distanceFromLastCellSpan = 0;
}
if (isAfterSplitCell && isOnSameColumn) {
// Create new cells only if the distance from the last cell span is equal or greater than the new cells span.
if (!distanceFromLastCellSpan) {
createCells(1, writer, tableSlot.getPositionBefore(), newCellsAttributes);
}
// Increase the distance from the last cell span.
distanceFromLastCellSpan++;
}
}
}
// 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, cellHeight, 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 + cellHeight > splitCellRow) {
const rowspanToSet = cellHeight + 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 the number of columns for a given table.
*
* ```ts
* editor.plugins.get( 'TableUtils' ).getColumns( table );
* ```
*
* @param table The table to analyze.
*/
getColumns(table) {
// Analyze first row only as all the rows should have the same width.
// Using the first row without checking if it's a tableRow because we expect
// that table will have only tableRow model elements at the beginning.
const row = table.getChild(0);
return [...row.getChildren()]
// $marker elements can also be children of a row too (when TrackChanges is on). Don't include them in the count.
.filter(node => node.is('element', 'tableCell'))
.reduce((columns, row) => {
const columnWidth = parseInt(row.getAttribute('colspan') || '1');
return columns + columnWidth;
}, 0);
}
/**
* Returns the number of rows for a given table. Any other element present in the table model is omitted.
*
* ```ts
* editor.plugins.get( 'TableUtils' ).getRows( table );
* ```
*
* @param table The table to analyze.
*/
getRows(table) {
// Rowspan not included due to #6427.
return Array.from(table.getChildren())
.reduce((rowCount, child) => child.is('element', 'tableRow') ? rowCount + 1 : rowCount, 0);
}
/**
* Creates an instance of the table walker.
*
* The table walker iterates internally by traversing the 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 the options.
* By default it will output only the locations that are occupied by a cell. To include also spanned rows and columns,
* pass the `includeAllSlots` option.
*
* @internal
* @param table A table over which the walker iterates.
* @param options An object with configuration.
*/
createTableWalker(table, options = {}) {
return new TableWalker(table, options);
}
/**
* Returns all model table cells that are fully selected (from the outside)
* within the provided model selection's ranges.
*
* To obtain the cells selected from the inside, use
* {@link #getTableCellsContainingSelection}.
*/
getSelectedTableCells(selection) {
const cells = [];
for (const range of this.sortRanges(selection.getRanges())) {
const element = range.getContainedElement();
if (element && element.is('element', 'tableCell')) {
cells.push(element);
}
}
return cells;
}
/**
* Returns all model table cells that the provided model selection's ranges
* {@link module:engine/model/range~Range#start} inside.
*
* To obtain the cells selected from the outside, use
* {@link #getSelectedTableCells}.
*/
getTableCellsContainingSelection(selection) {
const cells = [];
for (const range of selection.getRanges()) {
const cellWithSelection = range.start.findAncestor('tableCell');
if (cellWithSelection) {
cells.push(cellWithSelection);
}
}
return cells;
}
/**
* Returns all model table cells that are either completely selected
* by selection ranges or host selection range
* {@link module:engine/model/range~Range#start start positions} inside them.
*
* Combines {@link #getTableCellsContainingSelection} and
* {@link #getSelectedTableCells}.
*/
getSelectionAffectedTableCells(selection) {
const selectedCells = this.getSelectedTableCells(selection);
if (selectedCells.length) {
return selectedCells;
}
return this.getTableCellsContainingSelection(selection);
}
/**
* Returns an object with the `first` and `last` row index contained in the given `tableCells`.
*
* ```ts
* const selectedTableCells = getSelectedTableCells( editor.model.document.selection );
*
* const { first, last } = getRowIndexes( selectedTableCells );
*
* console.log( `Selected rows: ${ first } to ${ last }` );
* ```
*
* @returns Returns an object with the `first` and `last` table row indexes.
*/
getRowIndexes(tableCells) {
const indexes = tableCells.map(cell => cell.parent.index);
return this._getFirstLastIndexesObject(indexes);
}
/**
* Returns an object with the `first` and `last` column index contained in the given `tableCells`.
*
* ```ts
* const selectedTableCells = getSelectedTableCells( editor.model.document.selection );
*
* const { first, last } = getColumnIndexes( selectedTableCells );
*
* console.log( `Selected columns: ${ first } to ${ last }` );
* ```
*
* @returns Returns an object with the `first` and `last` table column indexes.
*/
getColumnIndexes(tableCells) {
const table = tableCells[0].findAncestor('table');
const tableMap = [...new TableWalker(table)];
const indexes = tableMap
.filter(entry => tableCells.includes(entry.cell))
.map(entry => entry.column);
return this._getFirstLastIndexesObject(indexes);
}
/**
* Checks if the selection contains cells that do not exceed rectangular selection.
*
* 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 (the unselected cell "b" creates a gap)
* - f, g, h (cell "d" spans over a cell from the row of "f" cell - thus creates a gap)
*/
isSelectionRectangular(selectedTableCells) {
if (selectedTableCells.length < 2 || !this._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 } = this.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;
}
/**
* Returns array of sorted ranges.
*/
sortRanges(ranges) {
return Array.from(ranges).sort(compareRangeOrder);
}
/**
* Helper method to get an object with `first` and `last` indexes from an unsorted array of indexes.
*/
_getFirstLastIndexesObject(indexes) {
const allIndexesSorted = indexes.sort((indexA, indexB) => indexA - indexB);
const first = allIndexesSorted[0];
const last = allIndexesSorted[allIndexesSorted.length - 1];
return { first, last };
}
/**
* Checks if the selection does not mix a header (column or row) with other cells.
*
* For instance, in the table below valid selections consist of cells with the same letter only.
* So, a-a (same heading row and column) or d-d (body cells) are valid while c-d or a-b are not.
*
* header columns
* ↓ ↓
* ┌───┬───┬───┬───┐
* │ a │ a │ b │ b │ ← header row
* ├───┼───┼───┼───┤
* │ c │ c │ d │ d │
* ├───┼───┼───┼───┤
* │ c │ c │ d │ d │
* └───┴───┴───┴───┘
*/
_areCellInTheSameTableSection(tableCells) {
const table = tableCells[0].findAncestor('table');
const rowIndexes = this.getRowIndexes(tableCells);
const headingRows = parseInt(table.getAttribute('headingRows')) || 0;
// Calculating row indexes is a bit cheaper so if this check fails we can't merge.
if (!this._areIndexesInSameSection(rowIndexes, headingRows)) {
return false;
}
const columnIndexes = this.getColumnIndexes(tableCells);
const headingColumns = parseInt(table.getAttribute('headingColumns')) || 0;
// Similarly cells must be in same column section.
return this._areIndexesInSameSection(columnIndexes, headingColumns);
}
/**
* Unified check if table rows/columns indexes are in the same heading/body section.
*/
_areIndexesInSameSection({ first, last }, headingSectionSize) {
const firstCellIsInHeading = first < headingSectionSize;
const lastCellIsInHeading = last < headingSectionSize;
return firstCellIsInHeading === lastCellIsInHeading;
}
}
/**
* Creates empty rows at the given index in an existing table.
*
* @param insertAt The row index of row insertion.
* @param rows The number of rows to create.
* @param tableCellToInsert The 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, writer.createPositionAt(tableRow, 'end'), attributes);
}
}
/**
* Creates cells at a given position.
*
* @param cells The number of cells to create
*/
function createCells(cells, writer, insertPosition, attributes = {}) {
for (let i = 0; i < cells; i++) {
createEmptyTableCell(writer, insertPosition, attributes);
}
}
/**
* Evenly distributes the 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:
*
* ```ts
* { newCellsSpan: 2, updatedSpan: 3 }
* ```
*
* as two cells will have a span of 2 and the remainder will go the first cell so its span will change to 3.
*
* @param span The span value do break.
* @param numberOfCells The number of resulting spans.
*/
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 };
}
/**
* Updates heading columns attribute if removing a row from head section.
*/
function adjustHeadingColumns(table, removedColumnIndexes, writer) {
const headingColumns = table.getAttribute('headingColumns') || 0;
if (headingColumns && removedColumnIndexes.first < headingColumns) {
const headingsRemoved = Math.min(headingColumns - 1 /* Other numbers are 0-based */, removedColumnIndexes.last) -
removedColumnIndexes.first + 1;
writer.setAttribute('headingColumns', headingColumns - headingsRemoved, table);
}
}
/**
* Calculates a new heading rows value for removing rows from heading section.
*/
function updateHeadingRows(table, { first, last }, writer) {
const headingRows = table.getAttribute('headingRows') || 0;
if (first < headingRows) {
const newRows = last < headingRows ? headingRows - (last - first + 1) : first;
updateNumericAttribute('headingRows', newRows, table, writer, 0);
}
}
/**
* Finds cells that will be:
* - trimmed - Cells that are "above" removed rows sections and overlap the removed section - their rowspan must be trimmed.
* - moved - Cells from removed rows section might stick out of. These cells are moved to the next row after a removed section.
*
* Sample table with overlapping & sticking out cells:
*
* +----+----+----+----+----+
* | 00 | 01 | 02 | 03 | 04 |
* +----+ + + + +
* | 10 | | | | |
* +----+----+ + + +
* | 20 | 21 | | | | <-- removed row
* + + +----+ + +
* | | | 32 | | | <-- removed row
* +----+ + +----+ +
* | 40 | | | 43 | |
* +----+----+----+----+----+
*
* In a table above:
* - cells to trim: '02', '03' & '04'.
* - cells to move: '21' & '32'.
*/
function getCellsToMoveAndTrimOnRemoveRow(table, { first, last }) {
const cellsToMove = new Map();
const cellsToTrim = [];
for (const { row, column, cellHeight, cell } of new TableWalker(table, { endRow: last })) {
const lastRowOfCell = row + cellHeight - 1;
const isCellStickingOutFromRemovedRows = row >= first && row <= last && lastRowOfCell > last;
if (isCellStickingOutFromRemovedRows) {
const rowspanInRemovedSection = last - row + 1;
const rowSpanToSet = cellHeight - rowspanInRemovedSection;
cellsToMove.set(column, {
cell,
rowspan: rowSpanToSet
});
}
const isCellOverlappingRemovedRows = row < first && lastRowOfCell >= first;
if (isCellOverlappingRemovedRows) {
let rowspanAdjustment;
// Cell fully covers removed section - trim it by removed rows count.
if (lastRowOfCell >= last) {
rowspanAdjustment = last - first + 1;
}
// Cell partially overlaps removed section - calculate cell's span that is in removed section.
else {
rowspanAdjustment = lastRowOfCell - first + 1;
}
cellsToTrim.push({
cell,
rowspan: cellHeight - rowspanAdjustment
});
}
}
return { cellsToMove, cellsToTrim };
}
function moveCellsToRow(table, targetRowIndex, cellsToMove, writer) {
const tableWalker = new TableWalker(table, {
includeAllSlots: true,
row: targetRowIndex
});
const tableRowMap = [...tableWalker];
const row = table.getChild(targetRowIndex);
let previousCell;
for (const { column, cell, isAnchor } of tableRowMap) {
if (cellsToMove.has(column)) {
const { cell: cellToMove, rowspan } = cellsToMove.get(column);
const targetPosition = previousCell ?
writer.createPositionAfter(previousCell) :
writer.createPositionAt(row, 0);
writer.move(writer.createRangeOn(cellToMove), targetPosition);
updateNumericAttribute('rowspan', rowspan, cellToMove, writer);
previousCell = cellToMove;
}
else if (isAnchor) {
// If cell is spanned then `cell` holds reference to overlapping cell. See ckeditor/ckeditor5#6502.
previousCell = cell;
}
}
}
function compareRangeOrder(rangeA, rangeB) {
// Since table cell ranges are disjoint, it's enough to check their start positions.
const posA = rangeA.start;
const posB = rangeB.start;
// Checking for equal position (returning 0) is not needed because this would be either:
// a. Intersecting range (not allowed by model)
// b. Collapsed range on the same position (allowed by model but should not happen).
return posA.isBefore(posB) ? -1 : 1;
}
/**
* Calculates the area of a maximum rectangle that can span over the provided row & column indexes.
*/
function getBiggestRectangleArea(rows, columns) {
const rowsIndexes = Array.from(rows.values());
const columnIn