@ckeditor/ckeditor5-table
Version:
Table feature for CKEditor 5.
1,022 lines • 61.8 kB
JavaScript
/**
* @license Copyright (c) 2003-2026, 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, isEntireCellsLineHeader, isTableCellTypeEnabled } from './utils/common.js';
import { removeEmptyColumns, removeEmptyRows } from './utils/structure.js';
import { getTableColumnElements } from './tablecolumnresize/utils.js';
/**
* The table utilities plugin.
*/
export 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) {
this.setHeadingRowsCount(writer, table, Math.min(options.headingRows, rows));
}
if (options.headingColumns) {
this.setHeadingColumnsCount(writer, table, Math.min(options.headingColumns, columns));
}
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 cellTypeEnabled = isTableCellTypeEnabled(this.editor);
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 => {
let headingRows = table.getAttribute('headingRows') || 0;
const headingColumns = table.getAttribute('headingColumns') || 0;
// Inserting rows inside heading section requires to update `headingRows` attribute as the heading section will grow.
if (headingRows > insertAt) {
headingRows += rowsToInsert;
this.setHeadingRowsCount(writer, table, headingRows, {
shallow: true
});
}
// Inserting at the end or at the beginning of a table doesn't require to calculate anything special.
if (!isCopyStructure && (insertAt === 0 || insertAt === rows)) {
const rows = createEmptyRows(writer, table, insertAt, rowsToInsert, columns);
if (cellTypeEnabled) {
for (let rowOffset = 0; rowOffset < rows.length; rowOffset++) {
const row = rows[rowOffset];
for (let columnIndex = 0; columnIndex < columns; columnIndex++) {
const cell = row[columnIndex];
if (insertAt + rowOffset < headingRows || columnIndex < headingColumns) {
writer.setAttribute('tableCellType', 'header', cell);
}
}
}
}
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) {
const insertedCells = createEmptyTableCell(writer, insertPosition, colspan > 1 ? { colspan } : undefined);
// If we insert row in heading section, set proper cell type.
if (cellTypeEnabled && (insertAt + rowIndex < headingRows || cellIndex < headingColumns)) {
writer.setAttribute('tableCellType', 'header', insertedCells);
}
}
// 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;
const cellTypeEnabled = isTableCellTypeEnabled(this.editor);
model.change(writer => {
const headingRows = table.getAttribute('headingRows') || 0;
let headingColumns = table.getAttribute('headingColumns');
// Inserting columns inside heading section requires to update `headingColumns` attribute as the heading section will grow.
if (insertAt < headingColumns) {
headingColumns += columnsToInsert;
this.setHeadingColumnsCount(writer, table, headingColumns, {
shallow: true
});
}
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) {
let rowIndex = 0;
for (const tableRow of table.getChildren()) {
// Ignore non-row elements inside the table (e.g. caption).
if (!tableRow.is('element', 'tableRow')) {
continue;
}
const insertedCells = createCells(columnsToInsert, writer, writer.createPositionAt(tableRow, insertAt ? 'end' : 0));
if (cellTypeEnabled) {
// If we insert column in heading section, set proper cell type.
for (let columnOffset = 0; columnOffset < insertedCells.length; columnOffset++) {
if (insertAt + columnOffset < headingColumns || rowIndex < headingRows) {
writer.setAttribute('tableCellType', 'header', insertedCells[columnOffset]);
}
}
}
rowIndex++;
}
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")
const insertedCells = createCells(columnsToInsert, writer, tableSlot.getPositionBefore());
// If we insert column in heading section, set proper cell type.
if (cellTypeEnabled) {
for (let columnOffset = 0; columnOffset < insertedCells.length; columnOffset++) {
if (insertAt + columnOffset < headingColumns || row < headingRows) {
writer.setAttribute('tableCellType', 'header', insertedCells[columnOffset]);
}
}
}
}
}
});
}
/**
* 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);
}
// 3. If next rows are entirely header, adjust heading rows count.
if (isTableCellTypeEnabled(this.editor)) {
let headingRows = table.getAttribute('headingRows') || 0;
const totalRows = this.getRows(table);
while (headingRows < totalRows && isEntireCellsLineHeader({ table, row: headingRows })) {
headingRows++;
}
this.setHeadingRowsCount(writer, table, headingRows, { shallow: true });
}
});
}
/**
* 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);
}
// If next columns are entirely header, adjust heading columns count.
if (isTableCellTypeEnabled(this.editor)) {
let headingColumns = table.getAttribute('headingColumns') || 0;
const totalColumns = this.getColumns(table);
while (headingColumns < totalColumns && isEntireCellsLineHeader({ table, column: headingColumns })) {
headingColumns++;
}
this.setHeadingColumnsCount(writer, table, headingColumns, { shallow: true });
}
});
}
/**
* 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;
}
/**
* Sets the number of heading rows for the given `table`.
*
* @param writer The model writer.
* @param table The table model element.
* @param headingRows The number of heading rows to set.
* @param options Additional options.
* @param options.shallow If set to `true` it will only update the `headingRows` attribute
* without updating the cell types in the table. Default is `false`.
* @param options.resetFormerHeadingCells If set to `true`, it will check if the rows that are no longer in the heading section
* should be updated to body cells. Default is `true`.
* @param options.autoExpand If set to `true`, it will check if the following rows look like a header and expand the heading section.
* Default is `true`.
*/
setHeadingRowsCount(writer, table, headingRows, options = {}) {
const { shallow, resetFormerHeadingCells = true, autoExpand = true } = options;
const oldHeadingRows = table.getAttribute('headingRows') || 0;
if (headingRows === oldHeadingRows) {
return;
}
updateNumericAttribute('headingRows', headingRows, table, writer, 0);
if (shallow || !isTableCellTypeEnabled(this.editor)) {
return;
}
// Set header type to all cells in new heading rows.
for (const { cell, row, column } of new TableWalker(table, { endRow: headingRows - 1 })) {
updateTableCellType({
table,
writer,
cell,
row,
column
});
}
// If heading rows were reduced, set body type to all cells in rows that are no longer in heading section.
if (resetFormerHeadingCells && headingRows < oldHeadingRows) {
for (let row = headingRows; row < oldHeadingRows; row++) {
// Handle edge case when some cells were already changed to body type manually,
// before changing heading rows count.
if (!isEntireCellsLineHeader({ table, row })) {
break;
}
for (const { cell, row: cellRow, column } of new TableWalker(table, { row })) {
updateTableCellType({
table,
writer,
cell,
row: cellRow,
column
});
}
}
}
// If following rows looks like header, expand heading rows to cover them.
if (autoExpand && headingRows > oldHeadingRows) {
const totalRows = this.getRows(table);
while (headingRows < totalRows && isEntireCellsLineHeader({ table, row: headingRows })) {
headingRows++;
}
updateNumericAttribute('headingRows', headingRows, table, writer, 0);
}
}
/**
* Sets the number of heading columns for the given `table`.
*
* @param writer The model writer to use.
* @param table The table model element.
* @param headingColumns The number of heading columns to set.
* @param options Additional options.
* @param options.shallow If set to `true` it will only update the `headingColumns` attribute
* without updating the cell types in the table. Default is `false`.
* @param options.resetFormerHeadingCells If set to `true`, it will check if the columns that are no longer in the heading section
* should be updated to body cells. Default is `true`.
* @param options.autoExpand If set to `true`, it will check if the following columns look like a header and expand the heading section.
* Default is `true`.
*/
setHeadingColumnsCount(writer, table, headingColumns, options = {}) {
const { shallow, resetFormerHeadingCells = true, autoExpand = true } = options;
const oldHeadingColumns = table.getAttribute('headingColumns') || 0;
if (headingColumns === oldHeadingColumns) {
return;
}
updateNumericAttribute('headingColumns', headingColumns, table, writer, 0);
if (shallow || !isTableCellTypeEnabled(this.editor)) {
return;
}
// Set header type to all cells in new heading columns.
for (const { cell, row, column } of new TableWalker(table, { endColumn: headingColumns - 1 })) {
updateTableCellType({
table,
writer,
cell,
row,
column
});
}
// If heading columns were reduced, set body type to all cells in columns that are no longer in heading section.
if (resetFormerHeadingCells && headingColumns < oldHeadingColumns) {
for (let column = headingColumns; column < oldHeadingColumns; column++) {
// Handle edge case when some cells were already changed to body type manually,
// before changing heading columns count.
if (!isEntireCellsLineHeader({ table, column })) {
break;
}
for (const { cell, row, column: cellColumn } of new TableWalker(table, { column })) {
updateTableCellType({
table,
writer,
cell,
row,
column: cellColumn
});
}
}
}
// If following columns looks like header, expand heading columns to cover them.
if (autoExpand && headingColumns > oldHeadingColumns) {
const totalColumns = this.getColumns(table);
while (headingColumns < totalColumns && isEntireCellsLineHeader({ table, column: headingColumns })) {
headingColumns++;
}
updateNumericAttribute('headingColumns', headingColumns, table, writer, 0);
}
}
/**
* Returns all model table cells that the provided model selection's ranges
* {@link module:engine/model/range~ModelRange#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~ModelRange#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(