UNPKG

@ckeditor/ckeditor5-table

Version:

Table feature for CKEditor 5.

368 lines (367 loc) • 13.1 kB
/** * @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 TableWalker from './../tablewalker.js'; import { createEmptyTableCell, updateNumericAttribute } from '../utils/common.js'; /** * Injects a table layout post-fixer into the model. * * The role of the table layout post-fixer is to ensure that the table rows have the correct structure * after a {@link module:engine/model/model~Model#change `change()`} block was executed. * * The correct structure means that: * * * All table rows have the same size. * * None of the table cells extend vertically beyond their section (either header or body). * * A table cell has always at least one element as a child. * * If the table structure is not correct, the post-fixer will automatically correct it in two steps: * * 1. It will clip table cells that extend beyond their section. * 2. It will add empty table cells to the rows that are narrower than the widest table row. * * ## Clipping overlapping table cells * * Such situation may occur when pasting a table (or a part of a table) to the editor from external sources. * * For example, see the following table which has a cell (FOO) with the rowspan attribute (2): * * ```xml * <table headingRows="1"> * <tableRow> * <tableCell rowspan="2"><paragraph>FOO</paragraph></tableCell> * <tableCell colspan="2"><paragraph>BAR</paragraph></tableCell> * </tableRow> * <tableRow> * <tableCell><paragraph>BAZ</paragraph></tableCell> * <tableCell><paragraph>XYZ</paragraph></tableCell> * </tableRow> * </table> * ``` * * It will be rendered in the view as: * * ```xml * <table> * <thead> * <tr> * <td rowspan="2">FOO</td> * <td colspan="2">BAR</td> * </tr> * </thead> * <tbody> * <tr> * <td>BAZ</td> * <td>XYZ</td> * </tr> * </tbody> * </table> * ``` * * In the above example the table will be rendered as a table with two rows: one in the header and second one in the body. * The table cell (FOO) cannot span over multiple rows as it would extend from the header to the body section. * The `rowspan` attribute must be changed to (1). The value (1) is the default value of the `rowspan` attribute * so the `rowspan` attribute will be removed from the model. * * The table cell with BAZ in the content will be in the first column of the table. * * ## Adding missing table cells * * The table post-fixer will insert empty table cells to equalize table row sizes (the number of columns). * The size of a table row is calculated by counting column spans of table cells, both horizontal (from the same row) and * vertical (from the rows above). * * In the above example, the table row in the body section of the table is narrower then the row from the header: it has two cells * with the default colspan (1). The header row has one cell with colspan (1) and the second with colspan (2). * The table cell (FOO) does not extend beyond the head section (and as such will be fixed in the first step of this post-fixer). * The post-fixer will add a missing table cell to the row in the body section of the table. * * The table from the above example will be fixed and rendered to the view as below: * * ```xml * <table> * <thead> * <tr> * <td rowspan="2">FOO</td> * <td colspan="2">BAR</td> * </tr> * </thead> * <tbody> * <tr> * <td>BAZ</td> * <td>XYZ</td> * </tr> * </tbody> * </table> * ``` * * ## Collaboration and undo - Expectations vs post-fixer results * * The table post-fixer only ensures proper structure without a deeper analysis of the nature of the change. As such, it might lead * to a structure which was not intended by the user. In particular, it will also fix undo steps (in conjunction with collaboration) * in which the editor content might not return to the original state. * * This will usually happen when one or more users change the size of the table. * * As an example see the table below: * * ```xml * <table> * <tbody> * <tr> * <td>11</td> * <td>12</td> * </tr> * <tr> * <td>21</td> * <td>22</td> * </tr> * </tbody> * </table> * ``` * * and the user actions: * * 1. Both users have a table with two rows and two columns. * 2. User A adds a column at the end of the table. This will insert empty table cells to two rows. * 3. User B adds a row at the end of the table. This will insert a row with two empty table cells. * 4. Both users will have a table as below: * * ```xml * <table> * <tbody> * <tr> * <td>11</td> * <td>12</td> * <td>(empty, inserted by A)</td> * </tr> * <tr> * <td>21</td> * <td>22</td> * <td>(empty, inserted by A)</td> * </tr> * <tr> * <td>(empty, inserted by B)</td> * <td>(empty, inserted by B)</td> * </tr> * </tbody> * </table> * ``` * * The last row is shorter then others so the table post-fixer will add an empty row to the last row: * * ```xml * <table> * <tbody> * <tr> * <td>11</td> * <td>12</td> * <td>(empty, inserted by A)</td> * </tr> * <tr> * <td>21</td> * <td>22</td> * <td>(empty, inserted by A)</td> * </tr> * <tr> * <td>(empty, inserted by B)</td> * <td>(empty, inserted by B)</td> * <td>(empty, inserted by the post-fixer)</td> * </tr> * </tbody> * </table> * ``` * * Unfortunately undo does not know the nature of the changes and depending on which user applies the post-fixer changes, undoing them * might lead to a broken table. If User B undoes inserting the column to the table, the undo engine will undo only the operations of * inserting empty cells to rows from the initial table state (row 1 and 2) but the cell in the post-fixed row will remain: * * ```xml * <table> * <tbody> * <tr> * <td>11</td> * <td>12</td> * </tr> * <tr> * <td>21</td> * <td>22</td> * </tr> * <tr> * <td>(empty, inserted by B)</td> * <td>(empty, inserted by B)</td> * <td>(empty, inserted by a post-fixer)</td> * </tr> * </tbody> * </table> * ``` * * After undo, the table post-fixer will detect that two rows are shorter than others and will fix the table to: * * ```xml * <table> * <tbody> * <tr> * <td>11</td> * <td>12</td> * <td>(empty, inserted by a post-fixer after undo)</td> * </tr> * <tr> * <td>21</td> * <td>22</td> * <td>(empty, inserted by a post-fixer after undo)</td> * </tr> * <tr> * <td>(empty, inserted by B)</td> * <td>(empty, inserted by B)</td> * <td>(empty, inserted by a post-fixer)</td> * </tr> * </tbody> * </table> * ``` */ export default function injectTableLayoutPostFixer(model) { model.document.registerPostFixer(writer => tableLayoutPostFixer(writer, model)); } /** * The table layout post-fixer. */ function tableLayoutPostFixer(writer, model) { const changes = model.document.differ.getChanges(); let wasFixed = false; // Do not analyze the same table more then once - may happen for multiple changes in the same table. const analyzedTables = new Set(); for (const entry of changes) { let table = null; if (entry.type == 'insert' && entry.name == 'table') { table = entry.position.nodeAfter; } // Fix table on adding/removing table cells and rows. if ((entry.type == 'insert' || entry.type == 'remove') && (entry.name == 'tableRow' || entry.name == 'tableCell')) { table = entry.position.findAncestor('table'); } // Fix table on any table's attribute change - including attributes of table cells. if (isTableAttributeEntry(entry)) { table = entry.range.start.findAncestor('table'); } if (table && !analyzedTables.has(table)) { // Step 1: correct rowspans of table cells if necessary. // The wasFixed flag should be true if any of tables in batch was fixed - might be more then one. wasFixed = fixTableCellsRowspan(table, writer) || wasFixed; // Step 2: fix table rows sizes. wasFixed = fixTableRowsSizes(table, writer) || wasFixed; analyzedTables.add(table); } } return wasFixed; } /** * Fixes the invalid value of the `rowspan` attribute because a table cell cannot vertically extend beyond the table section it belongs to. * * @returns Returns `true` if the table was fixed. */ function fixTableCellsRowspan(table, writer) { let wasFixed = false; const cellsToTrim = findCellsToTrim(table); if (cellsToTrim.length) { // @if CK_DEBUG_TABLE // console.log( `Post-fixing table: trimming cells row-spans (${ cellsToTrim.length }).` ); wasFixed = true; for (const data of cellsToTrim) { updateNumericAttribute('rowspan', data.rowspan, data.cell, writer, 1); } } return wasFixed; } /** * Makes all table rows in a table the same size. * * @returns Returns `true` if the table was fixed. */ function fixTableRowsSizes(table, writer) { let wasFixed = false; const childrenLengths = getChildrenLengths(table); const rowsToRemove = []; // Find empty rows. for (const [rowIndex, size] of childrenLengths.entries()) { // Ignore all non-row models. if (!size && table.getChild(rowIndex).is('element', 'tableRow')) { rowsToRemove.push(rowIndex); } } // Remove empty rows. if (rowsToRemove.length) { // @if CK_DEBUG_TABLE // console.log( `Post-fixing table: remove empty rows (${ rowsToRemove.length }).` ); wasFixed = true; for (const rowIndex of rowsToRemove.reverse()) { writer.remove(table.getChild(rowIndex)); childrenLengths.splice(rowIndex, 1); } } // Filter out everything that's not a table row. const rowsLengths = childrenLengths.filter((row, rowIndex) => table.getChild(rowIndex).is('element', 'tableRow')); // Verify if all the rows have the same number of columns. const tableSize = rowsLengths[0]; const isValid = rowsLengths.every(length => length === tableSize); if (!isValid) { // @if CK_DEBUG_TABLE // console.log( 'Post-fixing table: adding missing cells.' ); // Find the maximum number of columns. const maxColumns = rowsLengths.reduce((prev, current) => current > prev ? current : prev, 0); for (const [rowIndex, size] of rowsLengths.entries()) { const columnsToInsert = maxColumns - size; if (columnsToInsert) { for (let i = 0; i < columnsToInsert; i++) { createEmptyTableCell(writer, writer.createPositionAt(table.getChild(rowIndex), 'end')); } wasFixed = true; } } } return wasFixed; } /** * Searches for table cells that extend beyond the table section to which they belong to. It will return an array of objects * that stores table cells to be trimmed and the correct value of the `rowspan` attribute to set. */ function findCellsToTrim(table) { const headingRows = parseInt(table.getAttribute('headingRows') || '0'); const maxRows = Array.from(table.getChildren()) .reduce((count, row) => row.is('element', 'tableRow') ? count + 1 : count, 0); const cellsToTrim = []; for (const { row, cell, cellHeight } of new TableWalker(table)) { // Skip cells that do not expand over its row. if (cellHeight < 2) { continue; } const isInHeader = row < headingRows; // Row limit is either end of header section or whole table as table body is after the header. const rowLimit = isInHeader ? headingRows : maxRows; // If table cell expands over its limit reduce it height to proper value. if (row + cellHeight > rowLimit) { const newRowspan = rowLimit - row; cellsToTrim.push({ cell, rowspan: newRowspan }); } } return cellsToTrim; } /** * Returns an array with lengths of rows assigned to the corresponding row index. */ function getChildrenLengths(table) { // TableWalker will not provide items for the empty rows, we need to pre-fill this array. const lengths = new Array(table.childCount).fill(0); for (const { rowIndex } of new TableWalker(table, { includeAllSlots: true })) { lengths[rowIndex]++; } return lengths; } /** * Checks if the differ entry for an attribute change is one of the table's attributes. */ function isTableAttributeEntry(entry) { if (entry.type !== 'attribute') { return false; } const key = entry.attributeKey; return key === 'headingRows' || key === 'colspan' || key === 'rowspan'; }