@ckeditor/ckeditor5-table
Version:
Table feature for CKEditor 5.
590 lines (589 loc) • 28.8 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
*/
/**
* @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' });
});
}
}