UNPKG

@ckeditor/ckeditor5-table

Version:

Table feature for CKEditor 5.

359 lines (358 loc) • 14.6 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 { global } from 'ckeditor5/src/utils.js'; import { COLUMN_WIDTH_PRECISION, COLUMN_MIN_WIDTH_AS_PERCENTAGE, COLUMN_MIN_WIDTH_IN_PIXELS } from './constants.js'; /** * Returns all the inserted or changed table model elements in a given change set. Only the tables * with 'columnsWidth' attribute are taken into account. The returned set may be empty. * * Most notably if an entire table is removed it will not be included in returned set. * * @param model The model to collect the affected elements from. * @returns A set of table model elements. */ export function getChangedResizedTables(model) { const affectedTables = new Set(); for (const change of model.document.differ.getChanges()) { let referencePosition = null; // Checks if the particular change from the differ is: // - an insertion or removal of a table, a row or a cell, // - an attribute change on a table, a row or a cell. switch (change.type) { case 'insert': referencePosition = ['table', 'tableRow', 'tableCell'].includes(change.name) ? change.position : null; break; case 'remove': // If the whole table is removed, there's no need to update its column widths (#12201). referencePosition = ['tableRow', 'tableCell'].includes(change.name) ? change.position : null; break; case 'attribute': if (change.range.start.nodeAfter) { referencePosition = ['table', 'tableRow', 'tableCell'].includes(change.range.start.nodeAfter.name) ? change.range.start : null; } break; } if (!referencePosition) { continue; } const tableNode = (referencePosition.nodeAfter && referencePosition.nodeAfter.is('element', 'table')) ? referencePosition.nodeAfter : referencePosition.findAncestor('table'); // We iterate over the whole table looking for the nested tables that are also affected. for (const node of model.createRangeOn(tableNode).getItems()) { if (!node.is('element', 'table')) { continue; } if (!getColumnGroupElement(node)) { continue; } affectedTables.add(node); } } return affectedTables; } /** * Calculates the percentage of the minimum column width given in pixels for a given table. * * @param modelTable A table model element. * @param editor The editor instance. * @returns The minimal column width in percentage. */ export function getColumnMinWidthAsPercentage(modelTable, editor) { return COLUMN_MIN_WIDTH_IN_PIXELS * 100 / getTableWidthInPixels(modelTable, editor); } /** * Calculates the table width in pixels. * * @param modelTable A table model element. * @param editor The editor instance. * @returns The width of the table in pixels. */ export function getTableWidthInPixels(modelTable, editor) { // It is possible for a table to not have a <tbody> element - see #11878. const referenceElement = getChildrenViewElement(modelTable, 'tbody', editor) || getChildrenViewElement(modelTable, 'thead', editor); const domReferenceElement = editor.editing.view.domConverter.mapViewToDom(referenceElement); return getElementWidthInPixels(domReferenceElement); } /** * Returns the a view element with a given name that is nested directly in a `<table>` element * related to a given `modelTable`. * * @param elementName Name of a view to be looked for, e.g. `'colgroup`', `'thead`'. * @returns Matched view or `undefined` otherwise. */ function getChildrenViewElement(modelTable, elementName, editor) { const viewFigure = editor.editing.mapper.toViewElement(modelTable); const viewTable = [...viewFigure.getChildren()] .find((node) => node.is('element', 'table')); return [...viewTable.getChildren()] .find((node) => node.is('element', elementName)); } /** * Returns the computed width (in pixels) of the DOM element without padding and borders. * * @param domElement A DOM element. * @returns The width of the DOM element in pixels. */ export function getElementWidthInPixels(domElement) { const styles = global.window.getComputedStyle(domElement); // In the 'border-box' box sizing algorithm, the element's width // already includes the padding and border width (#12335). if (styles.boxSizing === 'border-box') { return parseFloat(styles.width) - parseFloat(styles.paddingLeft) - parseFloat(styles.paddingRight) - parseFloat(styles.borderLeftWidth) - parseFloat(styles.borderRightWidth); } else { return parseFloat(styles.width); } } /** * Returns the column indexes on the left and right edges of a cell. They differ if the cell spans * across multiple columns. * * @param cell A table cell model element. * @param tableUtils The Table Utils plugin instance. * @returns An object containing the indexes of the left and right edges of the cell. */ export function getColumnEdgesIndexes(cell, tableUtils) { const cellColumnIndex = tableUtils.getCellLocation(cell).column; const cellWidth = cell.getAttribute('colspan') || 1; return { leftEdge: cellColumnIndex, rightEdge: cellColumnIndex + cellWidth - 1 }; } /** * Rounds the provided value to a fixed-point number with defined number of digits after the decimal point. * * @param value A number to be rounded. * @returns The rounded number. */ export function toPrecision(value) { const multiplier = Math.pow(10, COLUMN_WIDTH_PRECISION); const number = typeof value === 'number' ? value : parseFloat(value); return Math.round(number * multiplier) / multiplier; } /** * Clamps the number within the inclusive lower (min) and upper (max) bounds. Returned number is rounded using the * {@link ~toPrecision `toPrecision()`} function. * * @param number A number to be clamped. * @param min A lower bound. * @param max An upper bound. * @returns The clamped number. */ export function clamp(number, min, max) { if (number <= min) { return toPrecision(min); } if (number >= max) { return toPrecision(max); } return toPrecision(number); } /** * Creates an array with defined length and fills all elements with defined value. * * @param length The length of the array. * @param value The value to fill the array with. * @returns An array with defined length and filled with defined value. */ export function createFilledArray(length, value) { return Array(length).fill(value); } /** * Sums all array values that can be parsed to a float. * * @param array An array of numbers. * @returns The sum of all array values. */ export function sumArray(array) { return array .map(value => typeof value === 'number' ? value : parseFloat(value)) .filter(value => !Number.isNaN(value)) .reduce((result, item) => result + item, 0); } /** * Makes sure that the sum of the widths from all columns is 100%. If the sum of all the widths is not equal 100%, all the widths are * changed proportionally so that they all sum back to 100%. If there are columns without specified width, the amount remaining * after assigning the known widths will be distributed equally between them. * * @param columnWidths An array of column widths. * @returns An array of column widths guaranteed to sum up to 100%. */ export function normalizeColumnWidths(columnWidths) { const widths = columnWidths.map(width => { if (width === 'auto') { return width; } return parseFloat(width.replace('%', '')); }); let normalizedWidths = calculateMissingColumnWidths(widths); const totalWidth = sumArray(normalizedWidths); if (totalWidth !== 100) { normalizedWidths = normalizedWidths // Adjust all the columns proportionally. .map(width => toPrecision(width * 100 / totalWidth)) // Due to rounding of numbers it may happen that the sum of the widths of all columns will not be exactly 100%. // Therefore, the width of the last column is explicitly adjusted (narrowed or expanded), since all the columns // have been proportionally changed already. .map((columnWidth, columnIndex, width) => { const isLastColumn = columnIndex === width.length - 1; if (!isLastColumn) { return columnWidth; } const totalWidth = sumArray(width); return toPrecision(columnWidth + 100 - totalWidth); }); } return normalizedWidths.map(width => width + '%'); } /** * Initializes the column widths by parsing the attribute value and calculating the uninitialized column widths. The special value 'auto' * indicates that width for the column must be calculated. The width of such uninitialized column is calculated as follows: * - If there is enough free space in the table for all uninitialized columns to have at least the minimum allowed width for all of them, * then set this width equally for all uninitialized columns. * - Otherwise, just set the minimum allowed width for all uninitialized columns. The sum of all column widths will be greater than 100%, * but then it will be adjusted proportionally to 100% in {@link #normalizeColumnWidths `normalizeColumnWidths()`}. * * @param columnWidths An array of column widths. * @returns An array with 'auto' values replaced with calculated widths. */ function calculateMissingColumnWidths(columnWidths) { const numberOfUninitializedColumns = columnWidths.filter(columnWidth => columnWidth === 'auto').length; if (numberOfUninitializedColumns === 0) { return columnWidths.map(columnWidth => toPrecision(columnWidth)); } const totalWidthOfInitializedColumns = sumArray(columnWidths); const widthForUninitializedColumn = Math.max((100 - totalWidthOfInitializedColumns) / numberOfUninitializedColumns, COLUMN_MIN_WIDTH_AS_PERCENTAGE); return columnWidths .map(columnWidth => columnWidth === 'auto' ? widthForUninitializedColumn : columnWidth) .map(columnWidth => toPrecision(columnWidth)); } /** * Calculates the total horizontal space taken by the cell. That includes: * * width, * * left and red padding, * * border width. * * @param domCell A DOM cell element. * @returns Width in pixels without `px` at the end. */ export function getDomCellOuterWidth(domCell) { const styles = global.window.getComputedStyle(domCell); // In the 'border-box' box sizing algorithm, the element's width // already includes the padding and border width (#12335). if (styles.boxSizing === 'border-box') { return parseInt(styles.width); } else { return parseFloat(styles.width) + parseFloat(styles.paddingLeft) + parseFloat(styles.paddingRight) + parseFloat(styles.borderWidth); } } /** * Updates column elements to match columns widths. * * @param columns * @param tableColumnGroup * @param normalizedWidths * @param writer */ export function updateColumnElements(columns, tableColumnGroup, normalizedWidths, writer) { for (let i = 0; i < Math.max(normalizedWidths.length, columns.length); i++) { const column = columns[i]; const columnWidth = normalizedWidths[i]; if (!columnWidth) { // Number of `<tableColumn>` elements exceeds actual number of columns. writer.remove(column); } else if (!column) { // There is fewer `<tableColumn>` elements than actual columns. writer.appendElement('tableColumn', { columnWidth }, tableColumnGroup); } else { // Update column width. writer.setAttribute('columnWidth', columnWidth, column); } } } /** * Returns a 'tableColumnGroup' element from the 'table'. * * @internal * @param element A 'table' or 'tableColumnGroup' element. * @returns A 'tableColumnGroup' element. */ export function getColumnGroupElement(element) { if (element.is('element', 'tableColumnGroup')) { return element; } const children = element.getChildren(); return Array .from(children) .find(element => element.is('element', 'tableColumnGroup')); } /** * Returns an array of 'tableColumn' elements. It may be empty if there's no `tableColumnGroup` element. * * @internal * @param element A 'table' or 'tableColumnGroup' element. * @returns An array of 'tableColumn' elements. */ export function getTableColumnElements(element) { const columnGroupElement = getColumnGroupElement(element); if (!columnGroupElement) { return []; } return Array.from(columnGroupElement.getChildren()); } /** * Returns an array of table column widths. * * @internal * @param element A 'table' or 'tableColumnGroup' element. * @returns An array of table column widths. */ export function getTableColumnsWidths(element) { return getTableColumnElements(element).map(column => column.getAttribute('columnWidth')); } /** * Translates the `colSpan` model attribute into additional column widths and returns the resulting array. * * @internal * @param element A 'table' or 'tableColumnGroup' element. * @param writer A writer instance. * @returns An array of table column widths. */ export function translateColSpanAttribute(element, writer) { const tableColumnElements = getTableColumnElements(element); return tableColumnElements.reduce((acc, element) => { const columnWidth = element.getAttribute('columnWidth'); const colSpan = element.getAttribute('colSpan'); if (!colSpan) { acc.push(columnWidth); return acc; } // Translate the `colSpan` model attribute on to the proper number of column widths // and remove it from the element. // See https://github.com/ckeditor/ckeditor5/issues/14521#issuecomment-1662102889 for more details. for (let i = 0; i < colSpan; i++) { acc.push(columnWidth); } writer.removeAttribute('colSpan', element); return acc; }, []); }