@ckeditor/ckeditor5-table
Version:
Table feature for CKEditor 5.
427 lines (425 loc) • 17.5 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
*/
import { default as TableWalker } from '../tablewalker.js';
import { createEmptyTableCell, updateNumericAttribute } from './common.js';
/**
* Returns a cropped table according to given dimensions.
* To return a cropped table that starts at first row and first column and end in third row and column:
*
* ```ts
* const croppedTable = cropTableToDimensions( table, {
* startRow: 1,
* endRow: 3,
* startColumn: 1,
* endColumn: 3
* }, writer );
* ```
*
* Calling the code above for the table below:
*
* 0 1 2 3 4 0 1 2
* ┌───┬───┬───┬───┬───┐
* 0 │ a │ b │ c │ d │ e │
* ├───┴───┤ ├───┴───┤ ┌───┬───┬───┐
* 1 │ f │ │ g │ │ │ │ g │ 0
* ├───┬───┴───┼───┬───┤ will return: ├───┴───┼───┤
* 2 │ h │ i │ j │ k │ │ i │ j │ 1
* ├───┤ ├───┤ │ │ ├───┤
* 3 │ l │ │ m │ │ │ │ m │ 2
* ├───┼───┬───┤ ├───┤ └───────┴───┘
* 4 │ n │ o │ p │ │ q │
* └───┴───┴───┴───┴───┘
*/
export function cropTableToDimensions(sourceTable, cropDimensions, writer) {
const { startRow, startColumn, endRow, endColumn } = cropDimensions;
// Create empty table with empty rows equal to crop height.
const croppedTable = writer.createElement('table');
const cropHeight = endRow - startRow + 1;
for (let i = 0; i < cropHeight; i++) {
writer.insertElement('tableRow', croppedTable, 'end');
}
const tableMap = [...new TableWalker(sourceTable, { startRow, endRow, startColumn, endColumn, includeAllSlots: true })];
// Iterate over source table slots (including empty - spanned - ones).
for (const { row: sourceRow, column: sourceColumn, cell: tableCell, isAnchor, cellAnchorRow, cellAnchorColumn } of tableMap) {
// Row index in cropped table.
const rowInCroppedTable = sourceRow - startRow;
const row = croppedTable.getChild(rowInCroppedTable);
// For empty slots: fill the gap with empty table cell.
if (!isAnchor) {
// But fill the gap only if the spanning cell is anchored outside cropped area.
// In the table from method jsdoc those cells are: "c" & "f".
if (cellAnchorRow < startRow || cellAnchorColumn < startColumn) {
createEmptyTableCell(writer, writer.createPositionAt(row, 'end'));
}
}
// Otherwise clone the cell with all children and trim if it exceeds cropped area.
else {
const tableCellCopy = writer.cloneElement(tableCell);
writer.append(tableCellCopy, row);
// Trim table if it exceeds cropped area.
// In the table from method jsdoc those cells are: "g" & "m".
trimTableCellIfNeeded(tableCellCopy, sourceRow, sourceColumn, endRow, endColumn, writer);
}
}
// Adjust heading rows & columns in cropped table if crop selection includes headings parts.
addHeadingsToCroppedTable(croppedTable, sourceTable, startRow, startColumn, writer);
return croppedTable;
}
/**
* Returns slot info of cells that starts above and overlaps a given row.
*
* In a table below, passing `overlapRow = 3`
*
* ┌───┬───┬───┬───┬───┐
* 0 │ a │ b │ c │ d │ e │
* │ ├───┼───┼───┼───┤
* 1 │ │ f │ g │ h │ i │
* ├───┤ ├───┼───┤ │
* 2 │ j │ │ k │ l │ │
* │ │ │ ├───┼───┤
* 3 │ │ │ │ m │ n │ <- overlap row to check
* ├───┼───┤ │ ├───│
* 4 │ o │ p │ │ │ q │
* └───┴───┴───┴───┴───┘
*
* will return slot info for cells: "j", "f", "k".
*
* @param table The table to check.
* @param overlapRow The index of the row to check.
* @param startRow row to start analysis. Use it when it is known that the cells above that row will not overlap. Default value is 0.
*/
export function getVerticallyOverlappingCells(table, overlapRow, startRow = 0) {
const cells = [];
const tableWalker = new TableWalker(table, { startRow, endRow: overlapRow - 1 });
for (const slotInfo of tableWalker) {
const { row, cellHeight } = slotInfo;
const cellEndRow = row + cellHeight - 1;
if (row < overlapRow && overlapRow <= cellEndRow) {
cells.push(slotInfo);
}
}
return cells;
}
/**
* Splits the table cell horizontally.
*
* @returns Created table cell, if any were created.
*/
export function splitHorizontally(tableCell, splitRow, writer) {
const tableRow = tableCell.parent;
const table = tableRow.parent;
const rowIndex = tableRow.index;
const rowspan = parseInt(tableCell.getAttribute('rowspan'));
const newRowspan = splitRow - rowIndex;
const newCellAttributes = {};
const newCellRowSpan = rowspan - newRowspan;
if (newCellRowSpan > 1) {
newCellAttributes.rowspan = newCellRowSpan;
}
const colspan = parseInt(tableCell.getAttribute('colspan') || '1');
if (colspan > 1) {
newCellAttributes.colspan = colspan;
}
const startRow = rowIndex;
const endRow = startRow + newRowspan;
const tableMap = [...new TableWalker(table, { startRow, endRow, includeAllSlots: true })];
let newCell = null;
let columnIndex;
for (const tableSlot of tableMap) {
const { row, column, cell } = tableSlot;
if (cell === tableCell && columnIndex === undefined) {
columnIndex = column;
}
if (columnIndex !== undefined && columnIndex === column && row === endRow) {
newCell = createEmptyTableCell(writer, tableSlot.getPositionBefore(), newCellAttributes);
}
}
// Update the rowspan attribute after updating table.
updateNumericAttribute('rowspan', newRowspan, tableCell, writer);
return newCell;
}
/**
* Returns slot info of cells that starts before and overlaps a given column.
*
* In a table below, passing `overlapColumn = 3`
*
* 0 1 2 3 4
* ┌───────┬───────┬───┐
* │ a │ b │ c │
* │───┬───┴───────┼───┤
* │ d │ e │ f │
* ├───┼───┬───────┴───┤
* │ g │ h │ i │
* ├───┼───┼───┬───────┤
* │ j │ k │ l │ m │
* ├───┼───┴───┼───┬───┤
* │ n │ o │ p │ q │
* └───┴───────┴───┴───┘
* ^
* Overlap column to check
*
* will return slot info for cells: "b", "e", "i".
*
* @param table The table to check.
* @param overlapColumn The index of the column to check.
*/
export function getHorizontallyOverlappingCells(table, overlapColumn) {
const cellsToSplit = [];
const tableWalker = new TableWalker(table);
for (const slotInfo of tableWalker) {
const { column, cellWidth } = slotInfo;
const cellEndColumn = column + cellWidth - 1;
if (column < overlapColumn && overlapColumn <= cellEndColumn) {
cellsToSplit.push(slotInfo);
}
}
return cellsToSplit;
}
/**
* Splits the table cell vertically.
*
* @param columnIndex The table cell column index.
* @param splitColumn The index of column to split cell on.
* @returns Created table cell.
*/
export function splitVertically(tableCell, columnIndex, splitColumn, writer) {
const colspan = parseInt(tableCell.getAttribute('colspan'));
const newColspan = splitColumn - columnIndex;
const newCellAttributes = {};
const newCellColSpan = colspan - newColspan;
if (newCellColSpan > 1) {
newCellAttributes.colspan = newCellColSpan;
}
const rowspan = parseInt(tableCell.getAttribute('rowspan') || '1');
if (rowspan > 1) {
newCellAttributes.rowspan = rowspan;
}
const newCell = createEmptyTableCell(writer, writer.createPositionAfter(tableCell), newCellAttributes);
// Update the colspan attribute after updating table.
updateNumericAttribute('colspan', newColspan, tableCell, writer);
return newCell;
}
/**
* Adjusts table cell dimensions to not exceed limit row and column.
*
* If table cell width (or height) covers a column (or row) that is after a limit column (or row)
* this method will trim "colspan" (or "rowspan") attribute so the table cell will fit in a defined limits.
*/
export function trimTableCellIfNeeded(tableCell, cellRow, cellColumn, limitRow, limitColumn, writer) {
const colspan = parseInt(tableCell.getAttribute('colspan') || '1');
const rowspan = parseInt(tableCell.getAttribute('rowspan') || '1');
const endColumn = cellColumn + colspan - 1;
if (endColumn > limitColumn) {
const trimmedSpan = limitColumn - cellColumn + 1;
updateNumericAttribute('colspan', trimmedSpan, tableCell, writer, 1);
}
const endRow = cellRow + rowspan - 1;
if (endRow > limitRow) {
const trimmedSpan = limitRow - cellRow + 1;
updateNumericAttribute('rowspan', trimmedSpan, tableCell, writer, 1);
}
}
/**
* Sets proper heading attributes to a cropped table.
*/
function addHeadingsToCroppedTable(croppedTable, sourceTable, startRow, startColumn, writer) {
const headingRows = parseInt(sourceTable.getAttribute('headingRows') || '0');
if (headingRows > 0) {
const headingRowsInCrop = headingRows - startRow;
updateNumericAttribute('headingRows', headingRowsInCrop, croppedTable, writer, 0);
}
const headingColumns = parseInt(sourceTable.getAttribute('headingColumns') || '0');
if (headingColumns > 0) {
const headingColumnsInCrop = headingColumns - startColumn;
updateNumericAttribute('headingColumns', headingColumnsInCrop, croppedTable, writer, 0);
}
}
/**
* Removes columns that have no cells anchored.
*
* In table below:
*
* +----+----+----+----+----+----+----+
* | 00 | 01 | 03 | 04 | 06 |
* +----+----+----+----+ +----+
* | 10 | 11 | 13 | | 16 |
* +----+----+----+----+----+----+----+
* | 20 | 21 | 23 | 24 | 26 |
* +----+----+----+----+----+----+----+
* ^--- empty ---^
*
* Will remove columns 2 and 5.
*
* **Note:** This is a low-level helper method for clearing invalid model state when doing table modifications.
* To remove a column from a table use {@link module:table/tableutils~TableUtils#removeColumns `TableUtils.removeColumns()`}.
*
* @internal
* @returns True if removed some columns.
*/
export function removeEmptyColumns(table, tableUtils) {
const width = tableUtils.getColumns(table);
const columnsMap = new Array(width).fill(0);
for (const { column } of new TableWalker(table)) {
columnsMap[column]++;
}
const emptyColumns = columnsMap.reduce((result, cellsCount, column) => {
return cellsCount ? result : [...result, column];
}, []);
if (emptyColumns.length > 0) {
// Remove only last empty column because it will recurrently trigger removing empty rows.
const emptyColumn = emptyColumns[emptyColumns.length - 1];
// @if CK_DEBUG_TABLE // console.log( `Removing empty column: ${ emptyColumn }.` );
tableUtils.removeColumns(table, { at: emptyColumn });
return true;
}
return false;
}
/**
* Removes rows that have no cells anchored.
*
* In table below:
*
* +----+----+----+
* | 00 | 01 | 02 |
* +----+----+----+
* | 10 | 11 | 12 |
* + + + +
* | | | | <-- empty
* +----+----+----+
* | 30 | 31 | 32 |
* +----+----+----+
* | 40 | 42 |
* + + +
* | | | <-- empty
* +----+----+----+
* | 60 | 61 | 62 |
* +----+----+----+
*
* Will remove rows 2 and 5.
*
* **Note:** This is a low-level helper method for clearing invalid model state when doing table modifications.
* To remove a row from a table use {@link module:table/tableutils~TableUtils#removeRows `TableUtils.removeRows()`}.
*
* @internal
* @returns True if removed some rows.
*/
export function removeEmptyRows(table, tableUtils) {
const emptyRows = [];
const tableRowCount = tableUtils.getRows(table);
for (let rowIndex = 0; rowIndex < tableRowCount; rowIndex++) {
const tableRow = table.getChild(rowIndex);
if (tableRow.isEmpty) {
emptyRows.push(rowIndex);
}
}
if (emptyRows.length > 0) {
// Remove only last empty row because it will recurrently trigger removing empty columns.
const emptyRow = emptyRows[emptyRows.length - 1];
// @if CK_DEBUG_TABLE // console.log( `Removing empty row: ${ emptyRow }.` );
tableUtils.removeRows(table, { at: emptyRow });
return true;
}
return false;
}
/**
* Removes rows and columns that have no cells anchored.
*
* In table below:
*
* +----+----+----+----+
* | 00 | 02 |
* +----+----+ +
* | 10 | |
* +----+----+----+----+
* | 20 | 22 | 23 |
* + + + +
* | | | | <-- empty row
* +----+----+----+----+
* ^--- empty column
*
* Will remove row 3 and column 1.
*
* **Note:** This is a low-level helper method for clearing invalid model state when doing table modifications.
* To remove a rows from a table use {@link module:table/tableutils~TableUtils#removeRows `TableUtils.removeRows()`} and
* {@link module:table/tableutils~TableUtils#removeColumns `TableUtils.removeColumns()`} to remove a column.
*
* @internal
*/
export function removeEmptyRowsColumns(table, tableUtils) {
const removedColumns = removeEmptyColumns(table, tableUtils);
// If there was some columns removed then cleaning empty rows was already triggered.
if (!removedColumns) {
removeEmptyRows(table, tableUtils);
}
}
/**
* Returns adjusted last row index if selection covers part of a row with empty slots (spanned by other cells).
* The `dimensions.lastRow` is equal to last row index but selection might be bigger.
*
* This happens *only* on rectangular selection so we analyze a case like this:
*
* +---+---+---+---+
* 0 | a | b | c | d |
* + + +---+---+
* 1 | | e | f | g |
* + +---+ +---+
* 2 | | h | | i | <- last row, each cell has rowspan = 2,
* + + + + + so we need to return 3, not 2
* 3 | | | | |
* +---+---+---+---+
*
* @returns Adjusted last row index.
*/
export function adjustLastRowIndex(table, dimensions) {
const lastRowMap = Array.from(new TableWalker(table, {
startColumn: dimensions.firstColumn,
endColumn: dimensions.lastColumn,
row: dimensions.lastRow
}));
const everyCellHasSingleRowspan = lastRowMap.every(({ cellHeight }) => cellHeight === 1);
// It is a "flat" row, so the last row index is OK.
if (everyCellHasSingleRowspan) {
return dimensions.lastRow;
}
// Otherwise get any cell's rowspan and adjust the last row index.
const rowspanAdjustment = lastRowMap[0].cellHeight - 1;
return dimensions.lastRow + rowspanAdjustment;
}
/**
* Returns adjusted last column index if selection covers part of a column with empty slots (spanned by other cells).
* The `dimensions.lastColumn` is equal to last column index but selection might be bigger.
*
* This happens *only* on rectangular selection so we analyze a case like this:
*
* 0 1 2 3
* +---+---+---+---+
* | a |
* +---+---+---+---+
* | b | c | d |
* +---+---+---+---+
* | e | f |
* +---+---+---+---+
* | g | h |
* +---+---+---+---+
* ^
* last column, each cell has colspan = 2, so we need to return 3, not 2
*
* @returns Adjusted last column index.
*/
export function adjustLastColumnIndex(table, dimensions) {
const lastColumnMap = Array.from(new TableWalker(table, {
startRow: dimensions.firstRow,
endRow: dimensions.lastRow,
column: dimensions.lastColumn
}));
const everyCellHasSingleColspan = lastColumnMap.every(({ cellWidth }) => cellWidth === 1);
// It is a "flat" column, so the last column index is OK.
if (everyCellHasSingleColspan) {
return dimensions.lastColumn;
}
// Otherwise get any cell's colspan and adjust the last column index.
const colspanAdjustment = lastColumnMap[0].cellWidth - 1;
return dimensions.lastColumn + colspanAdjustment;
}