UNPKG

@ckeditor/ckeditor5-table

Version:

Table feature for CKEditor 5.

590 lines (589 loc) • 28.8 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 */ /** * @module table/tablecolumnresize/tablecolumnresizeediting */ import { throttle, isEqual } from 'lodash-es'; import { global, DomEmitterMixin } from 'ckeditor5/src/utils.js'; import { Plugin } from 'ckeditor5/src/core.js'; import MouseEventsObserver from '../../src/tablemouse/mouseeventsobserver.js'; import TableEditing from '../tableediting.js'; import TableUtils from '../tableutils.js'; import TableWalker from '../tablewalker.js'; import TableWidthsCommand from './tablewidthscommand.js'; import { downcastTableResizedClass, upcastColgroupElement } from './converters.js'; import { clamp, createFilledArray, sumArray, getColumnEdgesIndexes, getChangedResizedTables, getColumnMinWidthAsPercentage, getElementWidthInPixels, getTableWidthInPixels, normalizeColumnWidths, toPrecision, getDomCellOuterWidth, updateColumnElements, getColumnGroupElement, getTableColumnElements, getTableColumnsWidths } from './utils.js'; import { COLUMN_MIN_WIDTH_IN_PIXELS } from './constants.js'; /** * The table column resize editing plugin. */ export default class TableColumnResizeEditing extends Plugin { /** * @inheritDoc */ static get requires() { return [TableEditing, TableUtils]; } /** * @inheritDoc */ static get pluginName() { return 'TableColumnResizeEditing'; } /** * @inheritDoc */ static get isOfficialPlugin() { return true; } /** * @inheritDoc */ constructor(editor) { super(editor); this._isResizingActive = false; this.set('_isResizingAllowed', true); this._resizingData = null; this._domEmitter = new (DomEmitterMixin())(); this._tableUtilsPlugin = editor.plugins.get('TableUtils'); this.on('change:_isResizingAllowed', (evt, name, value) => { // Toggling the `ck-column-resize_disabled` class shows and hides the resizers through CSS. const classAction = value ? 'removeClass' : 'addClass'; editor.editing.view.change(writer => { for (const root of editor.editing.view.document.roots) { writer[classAction]('ck-column-resize_disabled', editor.editing.view.document.getRoot(root.rootName)); } }); }); } /** * @inheritDoc */ init() { this._extendSchema(); this._registerPostFixer(); this._registerConverters(); this._registerResizingListeners(); this._registerResizerInserter(); const editor = this.editor; const columnResizePlugin = editor.plugins.get('TableColumnResize'); const tableEditing = editor.plugins.get('TableEditing'); tableEditing.registerAdditionalSlot({ filter: element => element.is('element', 'tableColumnGroup'), positionOffset: 0 }); const tableWidthsCommand = new TableWidthsCommand(editor); // For backwards compatibility we have two commands that perform exactly the same operation. editor.commands.add('resizeTableWidth', tableWidthsCommand); editor.commands.add('resizeColumnWidths', tableWidthsCommand); // Currently the states of column resize and table resize (which is actually the last column resize) features // are bound together. They can be separated in the future by adding distinct listeners and applying // different CSS classes (e.g. `ck-column-resize_disabled` and `ck-table-resize_disabled`) to the editor root. // See #12148 for the details. this.bind('_isResizingAllowed').to(editor, 'isReadOnly', columnResizePlugin, 'isEnabled', tableWidthsCommand, 'isEnabled', (isEditorReadOnly, isPluginEnabled, isTableWidthsCommandCommandEnabled) => !isEditorReadOnly && isPluginEnabled && isTableWidthsCommandCommandEnabled); } /** * @inheritDoc */ destroy() { this._domEmitter.stopListening(); super.destroy(); } /** * Returns a 'tableColumnGroup' element from the 'table'. * * @param element A 'table' or 'tableColumnGroup' element. * @returns A 'tableColumnGroup' element. */ getColumnGroupElement(element) { return getColumnGroupElement(element); } /** * Returns an array of 'tableColumn' elements. * * @param element A 'table' or 'tableColumnGroup' element. * @returns An array of 'tableColumn' elements. */ getTableColumnElements(element) { return getTableColumnElements(element); } /** * Returns an array of table column widths. * * @param element A 'table' or 'tableColumnGroup' element. * @returns An array of table column widths. */ getTableColumnsWidths(element) { return getTableColumnsWidths(element); } /** * Registers new attributes for a table model element. */ _extendSchema() { this.editor.model.schema.extend('table', { allowAttributes: ['tableWidth'] }); this.editor.model.schema.register('tableColumnGroup', { allowIn: 'table', isLimit: true }); this.editor.model.schema.register('tableColumn', { allowIn: 'tableColumnGroup', allowAttributes: ['columnWidth', 'colSpan'], isLimit: true }); } /** * Registers table column resize post-fixer. * * It checks if the change from the differ concerns a table-related element or attribute. For detected changes it: * * Adjusts the `columnWidths` attribute to guarantee that the sum of the widths from all columns is 100%. * * Checks if the `columnWidths` attribute gets updated accordingly after columns have been added or removed. */ _registerPostFixer() { const editor = this.editor; const model = editor.model; model.document.registerPostFixer(writer => { let changed = false; for (const table of getChangedResizedTables(model)) { const tableColumnGroup = this.getColumnGroupElement(table); const columns = this.getTableColumnElements(tableColumnGroup); const columnWidths = this.getTableColumnsWidths(tableColumnGroup); // Adjust the `columnWidths` attribute to guarantee that the sum of the widths from all columns is 100%. let normalizedWidths = normalizeColumnWidths(columnWidths); // If the number of columns has changed, then we need to adjust the widths of the affected columns. normalizedWidths = adjustColumnWidths(normalizedWidths, table, this); if (isEqual(columnWidths, normalizedWidths)) { continue; } updateColumnElements(columns, tableColumnGroup, normalizedWidths, writer); changed = true; } return changed; }); /** * Adjusts if necessary the `columnWidths` in case if the number of column has changed. * * @param columnWidths Note: this array **may be modified** by the function. * @param table Table to be checked. */ function adjustColumnWidths(columnWidths, table, plugin) { const newTableColumnsCount = plugin._tableUtilsPlugin.getColumns(table); const columnsCountDelta = newTableColumnsCount - columnWidths.length; if (columnsCountDelta === 0) { return columnWidths; } const widths = columnWidths.map(width => Number(width.replace('%', ''))); // Collect all cells that are affected by the change. const cellSet = getAffectedCells(plugin.editor.model.document.differ, table); for (const cell of cellSet) { const currentColumnsDelta = newTableColumnsCount - widths.length; if (currentColumnsDelta === 0) { continue; } // If the column count in the table changed, adjust the widths of the affected columns. const hasMoreColumns = currentColumnsDelta > 0; const currentColumnIndex = plugin._tableUtilsPlugin.getCellLocation(cell).column; if (hasMoreColumns) { const columnMinWidthAsPercentage = getColumnMinWidthAsPercentage(table, plugin.editor); const columnWidthsToInsert = createFilledArray(currentColumnsDelta, columnMinWidthAsPercentage); widths.splice(currentColumnIndex, 0, ...columnWidthsToInsert); } else { // Moves the widths of the removed columns to the preceding one. // Other editors either reduce the width of the whole table or adjust the widths // proportionally, so change of this behavior can be considered in the future. const removedColumnWidths = widths.splice(currentColumnIndex, Math.abs(currentColumnsDelta)); widths[currentColumnIndex] += sumArray(removedColumnWidths); } } return widths.map(width => width + '%'); } /** * Returns a set of cells that have been changed in a given table. */ function getAffectedCells(differ, table) { const cellSet = new Set(); for (const change of differ.getChanges()) { if (change.type == 'insert' && change.position.nodeAfter && change.position.nodeAfter.name == 'tableCell' && change.position.nodeAfter.getAncestors().includes(table)) { cellSet.add(change.position.nodeAfter); } else if (change.type == 'remove') { // If the first cell was removed, use the node after the change position instead. const referenceNode = (change.position.nodeBefore || change.position.nodeAfter); if (referenceNode.name == 'tableCell' && referenceNode.getAncestors().includes(table)) { cellSet.add(referenceNode); } } } return cellSet; } } /** * Registers table column resize converters. */ _registerConverters() { const editor = this.editor; const conversion = editor.conversion; // Table width style conversion.for('upcast').attributeToAttribute({ view: { name: 'figure', key: 'style', value: { width: /[\s\S]+/ } }, model: { name: 'table', key: 'tableWidth', value: (viewElement) => viewElement.getStyle('width') } }); conversion.for('downcast').attributeToAttribute({ model: { name: 'table', key: 'tableWidth' }, view: (width) => ({ name: 'figure', key: 'style', value: { width } }) }); conversion.elementToElement({ model: 'tableColumnGroup', view: 'colgroup' }); conversion.elementToElement({ model: 'tableColumn', view: 'col' }); conversion.for('downcast').add(downcastTableResizedClass()); conversion.for('upcast').add(upcastColgroupElement(this._tableUtilsPlugin)); conversion.for('upcast').attributeToAttribute({ view: { name: 'col', styles: { width: /.*/ } }, model: { key: 'columnWidth', value: (viewElement) => { const viewColWidth = viewElement.getStyle('width'); // 'pt' is the default unit for table column width pasted from MS Office. // See https://github.com/ckeditor/ckeditor5/issues/14521#issuecomment-1662102889 for more details. if (!viewColWidth || (!viewColWidth.endsWith('%') && !viewColWidth.endsWith('pt'))) { return 'auto'; } return viewColWidth; } } }); // The `col[span]` attribute is present in tables pasted from MS Excel. We use it to set the temporary `colSpan` model attribute, // which is consumed during the `colgroup` element upcast. // See https://github.com/ckeditor/ckeditor5/issues/14521#issuecomment-1662102889 for more details. conversion.for('upcast').attributeToAttribute({ view: { name: 'col', key: 'span' }, model: 'colSpan' }); conversion.for('downcast').attributeToAttribute({ model: { name: 'tableColumn', key: 'columnWidth' }, view: width => ({ key: 'style', value: { width } }) }); } /** * Registers listeners to handle resizing process. */ _registerResizingListeners() { const editingView = this.editor.editing.view; editingView.addObserver(MouseEventsObserver); editingView.document.on('mousedown', this._onMouseDownHandler.bind(this), { priority: 'high' }); this._domEmitter.listenTo(global.window.document, 'mousemove', throttle(this._onMouseMoveHandler.bind(this), 50)); this._domEmitter.listenTo(global.window.document, 'mouseup', this._onMouseUpHandler.bind(this)); } /** * Handles the `mousedown` event on column resizer element: * * calculates the initial column pixel widths, * * inserts the `<colgroup>` element if it is not present in the `<table>`, * * puts the necessary data in the temporary storage, * * applies the attributes to the `<table>` view element. * * @param eventInfo An object containing information about the fired event. * @param domEventData The data related to the DOM event. */ _onMouseDownHandler(eventInfo, domEventData) { const target = domEventData.target; if (!target.hasClass('ck-table-column-resizer')) { return; } if (!this._isResizingAllowed) { return; } const editor = this.editor; const modelTable = editor.editing.mapper.toModelElement(target.findAncestor('figure')); // Do not resize if table model is in non-editable place. if (!editor.model.canEditAt(modelTable)) { return; } domEventData.preventDefault(); eventInfo.stop(); // The column widths are calculated upon mousedown to allow lazy applying the `columnWidths` attribute on the table. const columnWidthsInPx = _calculateDomColumnWidths(modelTable, this._tableUtilsPlugin, editor); const viewTable = target.findAncestor('table'); const editingView = editor.editing.view; // Insert colgroup for the table that is resized for the first time. if (!Array.from(viewTable.getChildren()).find(viewCol => viewCol.is('element', 'colgroup'))) { editingView.change(viewWriter => { _insertColgroupElement(viewWriter, columnWidthsInPx, viewTable); }); } this._isResizingActive = true; this._resizingData = this._getResizingData(domEventData, columnWidthsInPx); // At this point we change only the editor view - we don't want other users to see our changes yet, // so we can't apply them in the model. editingView.change(writer => _applyResizingAttributesToTable(writer, viewTable, this._resizingData)); /** * Calculates the DOM columns' widths. It is done by taking the width of the widest cell * from each table column (we rely on the {@link module:table/tablewalker~TableWalker} * to determine which column the cell belongs to). * * @param modelTable A table which columns should be measured. * @param tableUtils The Table Utils plugin instance. * @param editor The editor instance. * @returns Columns' widths expressed in pixels (without unit). */ function _calculateDomColumnWidths(modelTable, tableUtilsPlugin, editor) { const columnWidthsInPx = Array(tableUtilsPlugin.getColumns(modelTable)); const tableWalker = new TableWalker(modelTable); for (const cellSlot of tableWalker) { const viewCell = editor.editing.mapper.toViewElement(cellSlot.cell); const domCell = editor.editing.view.domConverter.mapViewToDom(viewCell); const domCellWidth = getDomCellOuterWidth(domCell); if (!columnWidthsInPx[cellSlot.column] || domCellWidth < columnWidthsInPx[cellSlot.column]) { columnWidthsInPx[cellSlot.column] = toPrecision(domCellWidth); } } return columnWidthsInPx; } /** * Creates a `<colgroup>` element with `<col>`s and inserts it into a given view table. * * @param viewWriter A writer instance. * @param columnWidthsInPx Column widths. * @param viewTable A table view element. */ function _insertColgroupElement(viewWriter, columnWidthsInPx, viewTable) { const colgroup = viewWriter.createContainerElement('colgroup'); for (let i = 0; i < columnWidthsInPx.length; i++) { const viewColElement = viewWriter.createEmptyElement('col'); const columnWidthInPc = `${toPrecision(columnWidthsInPx[i] / sumArray(columnWidthsInPx) * 100)}%`; viewWriter.setStyle('width', columnWidthInPc, viewColElement); viewWriter.insert(viewWriter.createPositionAt(colgroup, 'end'), viewColElement); } viewWriter.insert(viewWriter.createPositionAt(viewTable, 0), colgroup); } /** * Applies the style and classes to the view table as the resizing begun. * * @param viewWriter A writer instance. * @param viewTable A table containing the clicked resizer. * @param resizingData Data related to the resizing. */ function _applyResizingAttributesToTable(viewWriter, viewTable, resizingData) { const figureInitialPcWidth = resizingData.widths.viewFigureWidth / resizingData.widths.viewFigureParentWidth; viewWriter.addClass('ck-table-resized', viewTable); viewWriter.addClass('ck-table-column-resizer__active', resizingData.elements.viewResizer); viewWriter.setStyle('width', `${toPrecision(figureInitialPcWidth * 100)}%`, viewTable.findAncestor('figure')); } } /** * Handles the `mousemove` event. * * If resizing process is not in progress, it does nothing. * * If resizing is active but not allowed, it stops the resizing process instantly calling the `mousedown` event handler. * * Otherwise it dynamically updates the widths of the resized columns. * * @param eventInfo An object containing information about the fired event. * @param mouseEventData The native DOM event. */ _onMouseMoveHandler(eventInfo, mouseEventData) { if (!this._isResizingActive) { return; } if (!this._isResizingAllowed) { this._onMouseUpHandler(); return; } const { columnPosition, flags: { isRightEdge, isTableCentered, isLtrContent }, elements: { viewFigure, viewLeftColumn, viewRightColumn }, widths: { viewFigureParentWidth, tableWidth, leftColumnWidth, rightColumnWidth } } = this._resizingData; const dxLowerBound = -leftColumnWidth + COLUMN_MIN_WIDTH_IN_PIXELS; const dxUpperBound = isRightEdge ? viewFigureParentWidth - tableWidth : rightColumnWidth - COLUMN_MIN_WIDTH_IN_PIXELS; // The multiplier is needed for calculating the proper movement offset: // - it should negate the sign if content language direction is right-to-left, // - it should double the offset if the table edge is resized and table is centered. const multiplier = (isLtrContent ? 1 : -1) * (isRightEdge && isTableCentered ? 2 : 1); const dx = clamp((mouseEventData.clientX - columnPosition) * multiplier, Math.min(dxLowerBound, 0), Math.max(dxUpperBound, 0)); if (dx === 0) { return; } this.editor.editing.view.change(writer => { const leftColumnWidthAsPercentage = toPrecision((leftColumnWidth + dx) * 100 / tableWidth); writer.setStyle('width', `${leftColumnWidthAsPercentage}%`, viewLeftColumn); if (isRightEdge) { const tableWidthAsPercentage = toPrecision((tableWidth + dx) * 100 / viewFigureParentWidth); writer.setStyle('width', `${tableWidthAsPercentage}%`, viewFigure); } else { const rightColumnWidthAsPercentage = toPrecision((rightColumnWidth - dx) * 100 / tableWidth); writer.setStyle('width', `${rightColumnWidthAsPercentage}%`, viewRightColumn); } }); } /** * Handles the `mouseup` event. * * If resizing process is not in progress, it does nothing. * * If resizing is active but not allowed, it cancels the resizing process restoring the original widths. * * Otherwise it propagates the changes from view to the model by executing the adequate commands. */ _onMouseUpHandler() { if (!this._isResizingActive) { return; } const { viewResizer, modelTable, viewFigure, viewColgroup } = this._resizingData.elements; const editor = this.editor; const editingView = editor.editing.view; const tableColumnGroup = this.getColumnGroupElement(modelTable); const viewColumns = Array .from(viewColgroup.getChildren()) .filter((column) => column.is('view:element')); const columnWidthsAttributeOld = tableColumnGroup ? this.getTableColumnsWidths(tableColumnGroup) : null; const columnWidthsAttributeNew = viewColumns.map(column => column.getStyle('width')); const isColumnWidthsAttributeChanged = !isEqual(columnWidthsAttributeOld, columnWidthsAttributeNew); const tableWidthAttributeOld = modelTable.getAttribute('tableWidth'); const tableWidthAttributeNew = viewFigure.getStyle('width'); const isTableWidthAttributeChanged = tableWidthAttributeOld !== tableWidthAttributeNew; if (isColumnWidthsAttributeChanged || isTableWidthAttributeChanged) { if (this._isResizingAllowed) { editor.execute('resizeTableWidth', { table: modelTable, tableWidth: `${toPrecision(tableWidthAttributeNew)}%`, columnWidths: columnWidthsAttributeNew }); } else { // In read-only mode revert all changes in the editing view. The model is not touched so it does not need to be restored. // This case can occur if the read-only mode kicks in during the resizing process. editingView.change(writer => { // If table had resized columns before, restore the previous column widths. // Otherwise clean up the view from the temporary column resizing markup. if (columnWidthsAttributeOld) { for (const viewCol of viewColumns) { writer.setStyle('width', columnWidthsAttributeOld.shift(), viewCol); } } else { writer.remove(viewColgroup); } if (isTableWidthAttributeChanged) { // If the whole table was already resized before, restore the previous table width. // Otherwise clean up the view from the temporary table resizing markup. if (tableWidthAttributeOld) { writer.setStyle('width', tableWidthAttributeOld, viewFigure); } else { writer.removeStyle('width', viewFigure); } } // If a table and its columns weren't resized before, // prune the remaining common resizing markup. if (!columnWidthsAttributeOld && !tableWidthAttributeOld) { writer.removeClass('ck-table-resized', [...viewFigure.getChildren()].find(element => element.name === 'table')); } }); } } editingView.change(writer => { writer.removeClass('ck-table-column-resizer__active', viewResizer); }); this._isResizingActive = false; this._resizingData = null; } /** * Retrieves and returns required data needed for the resizing process. * * @param domEventData The data of the `mousedown` event. * @param columnWidths The current widths of the columns. * @returns The data needed for the resizing process. */ _getResizingData(domEventData, columnWidths) { const editor = this.editor; const columnPosition = domEventData.domEvent.clientX; const viewResizer = domEventData.target; const viewLeftCell = viewResizer.findAncestor('td') || viewResizer.findAncestor('th'); const modelLeftCell = editor.editing.mapper.toModelElement(viewLeftCell); const modelTable = modelLeftCell.findAncestor('table'); const leftColumnIndex = getColumnEdgesIndexes(modelLeftCell, this._tableUtilsPlugin).rightEdge; const lastColumnIndex = this._tableUtilsPlugin.getColumns(modelTable) - 1; const isRightEdge = leftColumnIndex === lastColumnIndex; const isTableCentered = !modelTable.hasAttribute('tableAlignment'); const isLtrContent = editor.locale.contentLanguageDirection !== 'rtl'; const viewTable = viewLeftCell.findAncestor('table'); const viewFigure = viewTable.findAncestor('figure'); const viewColgroup = [...viewTable.getChildren()] .find(viewCol => viewCol.is('element', 'colgroup')); const viewLeftColumn = viewColgroup.getChild(leftColumnIndex); const viewRightColumn = isRightEdge ? undefined : viewColgroup.getChild(leftColumnIndex + 1); const viewFigureParentWidth = getElementWidthInPixels(editor.editing.view.domConverter.mapViewToDom(viewFigure.parent)); const viewFigureWidth = getElementWidthInPixels(editor.editing.view.domConverter.mapViewToDom(viewFigure)); const tableWidth = getTableWidthInPixels(modelTable, editor); const leftColumnWidth = columnWidths[leftColumnIndex]; const rightColumnWidth = isRightEdge ? undefined : columnWidths[leftColumnIndex + 1]; return { columnPosition, flags: { isRightEdge, isTableCentered, isLtrContent }, elements: { viewResizer, modelTable, viewFigure, viewColgroup, viewLeftColumn, viewRightColumn }, widths: { viewFigureParentWidth, viewFigureWidth, tableWidth, leftColumnWidth, rightColumnWidth } }; } /** * Registers a listener ensuring that each resizable cell have a resizer handle. */ _registerResizerInserter() { this.editor.conversion.for('editingDowncast').add(dispatcher => { dispatcher.on('insert:tableCell', (evt, data, conversionApi) => { const modelElement = data.item; const viewElement = conversionApi.mapper.toViewElement(modelElement); const viewWriter = conversionApi.writer; viewWriter.insert(viewWriter.createPositionAt(viewElement, 'end'), viewWriter.createUIElement('div', { class: 'ck-table-column-resizer' })); }, { priority: 'lowest' }); }); } }