UNPKG

@ckeditor/ckeditor5-table

Version:

Table feature for CKEditor 5.

1,292 lines (1,287 loc) 621 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 { Command, Plugin } from '@ckeditor/ckeditor5-core/dist/index.js'; import { toWidgetEditable, toWidget, Widget, isWidget, WidgetToolbarRepository } from '@ckeditor/ckeditor5-widget/dist/index.js'; import { first, global, CKEditorError, KeystrokeHandler, FocusTracker, Collection, getLocalizedArrowKeyCodeDirection, Rect, DomEmitterMixin, toUnit } from '@ckeditor/ckeditor5-utils/dist/index.js'; import { isObject, debounce, isEqual, throttle } from 'es-toolkit/compat'; import { IconTable, IconTableColumn, IconTableRow, IconTableMergeCell, IconCheck, IconCancel, IconAlignBottom, IconAlignMiddle, IconAlignTop, IconAlignJustify, IconAlignRight, IconAlignCenter, IconAlignLeft, IconTableCellProperties, IconTableLayout, IconTableProperties, IconObjectInlineRight, IconObjectCenter, IconObjectInlineLeft, IconCaption, IconPreviousArrow, IconObjectRight, IconObjectLeft } from '@ckeditor/ckeditor5-icons/dist/index.js'; import { View, addKeyboardHandlingForGrid, ButtonView, createDropdown, MenuBarMenuView, SwitchButtonView, SplitButtonView, addListToDropdown, UIModel, ViewCollection, FocusCycler, InputTextView, ColorSelectorView, FormHeaderView, FormRowView, submitHandler, LabelView, LabeledFieldView, createLabeledDropdown, createLabeledInputText, ToolbarView, BalloonPanelView, ContextualBalloon, normalizeColorOptions, getLocalizedColorOptions, clickOutsideHandler, DropdownButtonView } from '@ckeditor/ckeditor5-ui/dist/index.js'; import { ClipboardMarkersUtils, ClipboardPipeline } from '@ckeditor/ckeditor5-clipboard/dist/index.js'; import { DomEventObserver, isColorStyleValue, isLengthStyleValue, isPercentageStyleValue, addBorderStylesRules, addPaddingStylesRules, addBackgroundStylesRules, addMarginStylesRules, enableViewPlaceholder, ModelElement } from '@ckeditor/ckeditor5-engine/dist/index.js'; const ALIGN_VALUES_REG_EXP$1 = /^(left|center|right)$/; const FLOAT_VALUES_REG_EXP = /^(left|none|right)$/; /** * Conversion helper for upcasting attributes using normalized styles. * * @param options.modelAttribute The attribute to set. * @param options.styleName The style name to convert. * @param options.attributeName The HTML attribute name to convert. * @param options.attributeType The HTML attribute type for value normalization. * @param options.viewElement The view element name that should be converted. * @param options.defaultValue The default value for the specified `modelAttribute`. * @param options.shouldUpcast The function which returns `true` if style should be upcasted from this element. * @internal */ function upcastStyleToAttribute(conversion, options) { const { modelAttribute, styleName, attributeName, attributeType, viewElement, defaultValue, shouldUpcast = ()=>true, reduceBoxSides = false } = options; conversion.for('upcast').attributeToAttribute({ view: { name: viewElement, styles: { [styleName]: /[\s\S]+/ } }, model: { key: modelAttribute, value: (viewElement, conversionApi, data)=>{ // Ignore table elements inside figures and figures without the table class. if (!shouldUpcast(viewElement)) { return; } const localDefaultValue = getDefaultValueAdjusted(defaultValue, '', data); const normalized = viewElement.getNormalizedStyle(styleName); const value = reduceBoxSides ? reduceBoxSidesValue(normalized) : normalized; if (localDefaultValue !== value) { return value; } // Consume the style even if not applied to the element so it won't be processed by other converters. conversionApi.consumable.consume(viewElement, { styles: styleName }); } } }); if (attributeName) { conversion.for('upcast').attributeToAttribute({ view: { name: viewElement, attributes: { [attributeName]: /.+/ } }, model: { key: modelAttribute, value: (viewElement, conversionApi, data)=>{ // Convert attributes of table and table cell elements, ignore figure. // Do not convert attribute if related style is set as it has a higher priority. // Do not convert attribute if the element is a table inside a figure with the related style set. if (viewElement.name == 'figure' || viewElement.hasStyle(styleName) || viewElement.name == 'table' && viewElement.parent.name == 'figure' && viewElement.parent.hasStyle(styleName)) { return; } const localDefaultValue = getDefaultValueAdjusted(defaultValue, '', data); let value = viewElement.getAttribute(attributeName); if (value && attributeType == 'length' && !value.endsWith('px')) { value += 'px'; } if (localDefaultValue !== value) { return value; } // Consume the attribute even if not applied to the element so it won't be processed by other converters. conversionApi.consumable.consume(viewElement, { attributes: attributeName }); } } }); } } /** * Conversion helper for upcasting border styles for view elements. * * @param editor The editor instance. * @param defaultBorder The default border values. * @param defaultBorder.color The default `borderColor` value. * @param defaultBorder.style The default `borderStyle` value. * @param defaultBorder.width The default `borderWidth` value. * @internal */ function upcastBorderStyles(editor, viewElementName, modelAttributes, defaultBorder) { const { conversion } = editor; conversion.for('upcast').add((dispatcher)=>dispatcher.on('element:' + viewElementName, (evt, data, conversionApi)=>{ // If the element was not converted by element-to-element converter, // we should not try to convert the style. See #8393. if (!data.modelRange) { return; } // Check the most detailed properties. These will be always set directly or // when using the "group" properties like: `border-(top|right|bottom|left)` or `border`. const stylesToConsume = [ 'border-top-width', 'border-top-color', 'border-top-style', 'border-bottom-width', 'border-bottom-color', 'border-bottom-style', 'border-right-width', 'border-right-color', 'border-right-style', 'border-left-width', 'border-left-color', 'border-left-style' ].filter((styleName)=>data.viewItem.hasStyle(styleName)); if (!stylesToConsume.length) { return; } const matcherPattern = { styles: stylesToConsume }; // Try to consume appropriate values from consumable values list. if (!conversionApi.consumable.test(data.viewItem, matcherPattern)) { return; } const modelElement = [ ...data.modelRange.getItems({ shallow: true }) ].pop(); const tableElement = modelElement.findAncestor('table', { includeSelf: true }); let localDefaultBorder = defaultBorder; if (tableElement && tableElement.getAttribute('tableType') == 'layout') { localDefaultBorder = { style: 'none', color: '', width: '' }; } conversionApi.consumable.consume(data.viewItem, matcherPattern); const normalizedBorder = { style: data.viewItem.getNormalizedStyle('border-style'), color: data.viewItem.getNormalizedStyle('border-color'), width: data.viewItem.getNormalizedStyle('border-width') }; const reducedBorder = { style: reduceBoxSidesValue(normalizedBorder.style), color: reduceBoxSidesValue(normalizedBorder.color), width: reduceBoxSidesValue(normalizedBorder.width) }; if (reducedBorder.style !== localDefaultBorder.style) { conversionApi.writer.setAttribute(modelAttributes.style, reducedBorder.style, modelElement); } if (reducedBorder.color !== localDefaultBorder.color) { conversionApi.writer.setAttribute(modelAttributes.color, reducedBorder.color, modelElement); } if (reducedBorder.width !== localDefaultBorder.width) { conversionApi.writer.setAttribute(modelAttributes.width, reducedBorder.width, modelElement); } })); if (editor.config.get('experimentalFlags.upcastTableBorderZeroAttributes')) { // If parent table has `border="0"` attribute then set border style to `none` // all table cells of that table and table itself. conversion.for('upcast').add((dispatcher)=>{ dispatcher.on(`element:${viewElementName}`, (evt, data, conversionApi)=>{ const { modelRange, viewItem } = data; const viewTable = viewItem.is('element', 'table') ? viewItem : viewItem.findAncestor('table'); // If something already consumed the border attribute on the nearest table element, skip the conversion. if (!conversionApi.consumable.test(viewTable, { attributes: 'border' })) { return; } // Ignore tables with border different than "0". if (viewTable.getAttribute('border') !== '0') { return; } const modelElement = modelRange?.start?.nodeAfter; // If model element has already border style attribute, skip the conversion. if (!modelElement || modelElement.hasAttribute(modelAttributes.style)) { return; } conversionApi.writer.setAttribute(modelAttributes.style, 'none', modelElement); if (viewItem.is('element', 'table')) { conversionApi.consumable.consume(viewItem, { attributes: 'border' }); } }); }); } } /** * Conversion helper for downcasting an attribute to a style. * * @internal */ function downcastAttributeToStyle(conversion, options) { const { modelElement, modelAttribute, styleName } = options; conversion.for('downcast').attributeToAttribute({ model: { name: modelElement, key: modelAttribute }, view: (modelAttributeValue)=>({ key: 'style', value: { [styleName]: modelAttributeValue } }) }); } /** * Conversion helper for downcasting attributes from the model table to a view table (not to `<figure>`). * * @internal */ function downcastTableAttribute(conversion, options) { const { modelAttribute, styleName } = options; conversion.for('downcast').add((dispatcher)=>dispatcher.on(`attribute:${modelAttribute}:table`, (evt, data, conversionApi)=>{ const { item, attributeNewValue } = data; const { mapper, writer } = conversionApi; if (!conversionApi.consumable.consume(data.item, evt.name)) { return; } const table = [ ...mapper.toViewElement(item).getChildren() ].find((child)=>child.is('element', 'table')); if (attributeNewValue) { writer.setStyle(styleName, attributeNewValue, table); } else { writer.removeStyle(styleName, table); } })); } /** * Returns the default value for table or table cell property adjusted for layout tables. * * @internal */ function getDefaultValueAdjusted(defaultValue, layoutTableDefault, data) { const modelElement = data.modelRange && first(data.modelRange.getItems({ shallow: true })); const tableElement = modelElement && modelElement.is('element') && modelElement.findAncestor('table', { includeSelf: true }); if (tableElement && tableElement.getAttribute('tableType') === 'layout') { return layoutTableDefault; } return defaultValue; } /** * Reduces the full top, right, bottom, left object to a single string if all sides are equal. * Returns original style otherwise. */ function reduceBoxSidesValue(style) { if (!style) { return; } const sides = [ 'top', 'right', 'bottom', 'left' ]; const allSidesDefined = sides.every((side)=>style[side]); if (!allSidesDefined) { return style; } const topSideStyle = style.top; const allSidesEqual = sides.every((side)=>style[side] === topSideStyle); if (!allSidesEqual) { return style; } return topSideStyle; } /** * Default table alignment options. */ const DEFAULT_TABLE_ALIGNMENT_OPTIONS = { left: { className: 'table-style-align-left' }, center: { className: 'table-style-align-center' }, right: { className: 'table-style-align-right' }, blockLeft: { className: 'table-style-block-align-left' }, blockRight: { className: 'table-style-block-align-right' } }; /** * Configuration for upcasting table alignment from view to model. */ const upcastTableAlignmentConfig = [ // Support for the `float:*;` CSS definition for the table alignment. { view: { name: /^(table|figure)$/, styles: { float: FLOAT_VALUES_REG_EXP } }, getAlign: (viewElement)=>{ let align = viewElement.getStyle('float'); if (align === 'none') { align = 'center'; } return align; }, getConsumables (viewElement) { const float = viewElement.getStyle('float'); const styles = [ 'float' ]; if (float === 'left' && viewElement.hasStyle('margin-right')) { styles.push('margin-right'); } else if (float === 'right' && viewElement.hasStyle('margin-left')) { styles.push('margin-left'); } return { styles }; } }, // Support for the `margin-left:auto; margin-right:auto;` CSS definition for the table alignment. { view: { name: /^(table|figure)$/, styles: { 'margin-left': 'auto', 'margin-right': 'auto' } }, getAlign: ()=>'center', getConsumables: ()=>{ return { styles: [ 'margin-left', 'margin-right' ] }; } }, // Support for the left alignment using CSS classes. { view: { name: /^(table|figure)$/, key: 'class', value: 'table-style-align-left' }, getAlign: ()=>'left', getConsumables () { return { classes: DEFAULT_TABLE_ALIGNMENT_OPTIONS.left.className }; } }, // Support for the right alignment using CSS classes. { view: { name: /^(table|figure)$/, key: 'class', value: DEFAULT_TABLE_ALIGNMENT_OPTIONS.right.className }, getAlign: ()=>'right', getConsumables () { return { classes: DEFAULT_TABLE_ALIGNMENT_OPTIONS.right.className }; } }, // Support for the center alignment using CSS classes. { view: { name: /^(table|figure)$/, key: 'class', value: DEFAULT_TABLE_ALIGNMENT_OPTIONS.center.className }, getAlign: ()=>'center', getConsumables () { return { classes: DEFAULT_TABLE_ALIGNMENT_OPTIONS.center.className }; } }, // Support for the block alignment left using CSS classes. { view: { name: /^(table|figure)$/, key: 'class', value: DEFAULT_TABLE_ALIGNMENT_OPTIONS.blockLeft.className }, getAlign: ()=>'blockLeft', getConsumables () { return { classes: DEFAULT_TABLE_ALIGNMENT_OPTIONS.blockLeft.className }; } }, // Support for the block alignment right using CSS classes. { view: { name: /^(table|figure)$/, key: 'class', value: DEFAULT_TABLE_ALIGNMENT_OPTIONS.blockRight.className }, getAlign: ()=>'blockRight', getConsumables () { return { classes: DEFAULT_TABLE_ALIGNMENT_OPTIONS.blockRight.className }; } }, // Support for the block alignment left using margin CSS styles. { view: { name: /^(table|figure)$/, styles: { 'margin-left': '0', 'margin-right': 'auto' } }, getAlign: ()=>'blockLeft', getConsumables () { return { styles: [ 'margin-left', 'margin-right' ] }; } }, // Support for the block alignment right using margin CSS styles. { view: { name: /^(table|figure)$/, styles: { 'margin-left': 'auto', 'margin-right': '0' } }, getAlign: ()=>'blockRight', getConsumables () { return { styles: [ 'margin-left', 'margin-right' ] }; } }, // Support for the `align` attribute as the backward compatibility while pasting from other sources. { view: { name: 'table', attributes: { align: ALIGN_VALUES_REG_EXP$1 } }, getAlign: (viewElement)=>viewElement.getAttribute('align'), getConsumables () { return { attributes: 'align' }; } } ]; const downcastTableAlignmentConfig = { center: { align: 'center', style: 'margin-left: auto; margin-right: auto;', className: 'table-style-align-center' }, left: { align: 'left', style: 'float: left;', className: 'table-style-align-left' }, right: { align: 'right', style: 'float: right;', className: 'table-style-align-right' }, blockLeft: { align: undefined, style: 'margin-left: 0; margin-right: auto;', className: DEFAULT_TABLE_ALIGNMENT_OPTIONS.blockLeft.className }, blockRight: { align: undefined, style: 'margin-left: auto; margin-right: 0;', className: DEFAULT_TABLE_ALIGNMENT_OPTIONS.blockRight.className } }; /** * A common method to update the numeric value. If a value is the default one, it will be unset. * * @internal * @param key An attribute key. * @param value The new attribute value. * @param item A model item on which the attribute will be set. * @param defaultValue The default attribute value. If a value is lower or equal, it will be unset. */ function updateNumericAttribute(key, value, item, writer, defaultValue = 1) { if (value !== undefined && value !== null && defaultValue !== undefined && defaultValue !== null && value > defaultValue) { writer.setAttribute(key, value, item); } else { writer.removeAttribute(key, item); } } /** * A common method to create an empty table cell. It creates a proper model structure as a table cell must have at least one block inside. * * @internal * @param writer The model writer. * @param insertPosition The position at which the table cell should be inserted. * @param attributes The element attributes. * @returns Created table cell. */ function createEmptyTableCell(writer, insertPosition, attributes = {}) { const tableCell = writer.createElement('tableCell', attributes); writer.insertElement('paragraph', tableCell); writer.insert(tableCell, insertPosition); return tableCell; } /** * Checks if a table cell belongs to the heading column section. * * @internal */ function isHeadingColumnCell(tableUtils, tableCell) { const table = tableCell.parent.parent; const headingColumns = parseInt(table.getAttribute('headingColumns') || '0'); const { column } = tableUtils.getCellLocation(tableCell); return !!headingColumns && column < headingColumns; } /** * Enables conversion for an attribute for simple view-model mappings. * * @internal * @param options.defaultValue The default value for the specified `modelAttribute`. */ function enableProperty$1(schema, conversion, options) { const { modelAttribute } = options; schema.extend('tableCell', { allowAttributes: [ modelAttribute ] }); schema.setAttributeProperties(modelAttribute, { isFormatting: true }); upcastStyleToAttribute(conversion, { viewElement: /^(td|th)$/, ...options }); downcastAttributeToStyle(conversion, { modelElement: 'tableCell', ...options }); } /** * Depending on the position of the selection we either return the table under cursor or look for the table higher in the hierarchy. * * @internal */ function getSelectionAffectedTable(selection) { const selectedElement = selection.getSelectedElement(); // Is the command triggered from the `tableToolbar`? if (selectedElement && selectedElement.is('element', 'table')) { return selectedElement; } return selection.getFirstPosition().findAncestor('table'); } /** * @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/tablewalker */ /** * The table iterator class. It allows to iterate over table cells. For each cell the iterator yields * {@link module:table/tablewalker~TableSlot} with proper table cell attributes. */ class TableWalker { /** * The walker's table element. * * @internal */ _table; /** * A row index from which this iterator will start. */ _startRow; /** * A row index at which this iterator will end. */ _endRow; /** * If set, the table walker will only output cells from a given column and following ones or cells that overlap them. */ _startColumn; /** * If set, the table walker will only output cells up to a given column. */ _endColumn; /** * Enables output of spanned cells that are normally not yielded. */ _includeAllSlots; /** * Row indexes to skip from the iteration. */ _skipRows; /** * The current row index. * * @internal */ _row; /** * The index of the current row element in the table. * * @internal */ _rowIndex; /** * The current column index. * * @internal */ _column; /** * The cell index in a parent row. For spanned cells when {@link #_includeAllSlots} is set to `true`, * this represents the index of the next table cell. * * @internal */ _cellIndex; /** * Holds a map of spanned cells in a table. */ _spannedCells; /** * Index of the next column where a cell is anchored. */ _nextCellAtColumn; /** * Indicates whether the iterator jumped to (or close to) the start row, ignoring rows that don't need to be traversed. */ _jumpedToStartRow = false; /** * Creates an instance of the table walker. * * The table walker iterates internally by traversing the table from row index = 0 and column index = 0. * It walks row by row and column by column in order to output values defined in the constructor. * By default it will output only the locations that are occupied by a cell. To include also spanned rows and columns, * pass the `includeAllSlots` option to the constructor. * * The most important values of the iterator are column and row indexes of a cell. * * See {@link module:table/tablewalker~TableSlot} what values are returned by the table walker. * * To iterate over a given row: * * ```ts * const tableWalker = new TableWalker( table, { startRow: 1, endRow: 2 } ); * * for ( const tableSlot of tableWalker ) { * console.log( 'A cell at row', tableSlot.row, 'and column', tableSlot.column ); * } * ``` * * For instance the code above for the following table: * * +----+----+----+----+----+----+ * | 00 | 02 | 03 | 04 | 05 | * | +----+----+----+----+ * | | 12 | 14 | 15 | * | +----+----+----+ + * | | 22 | | * |----+----+----+----+----+ + * | 30 | 31 | 32 | 33 | 34 | | * +----+----+----+----+----+----+ * * will log in the console: * * 'A cell at row 1 and column 2' * 'A cell at row 1 and column 4' * 'A cell at row 1 and column 5' * 'A cell at row 2 and column 2' * * To also iterate over spanned cells: * * ```ts * const tableWalker = new TableWalker( table, { row: 1, includeAllSlots: true } ); * * for ( const tableSlot of tableWalker ) { * console.log( 'Slot at', tableSlot.row, 'x', tableSlot.column, ':', tableSlot.isAnchor ? 'is anchored' : 'is spanned' ); * } * ``` * * will log in the console for the table from the previous example: * * 'Cell at 1 x 0 : is spanned' * 'Cell at 1 x 1 : is spanned' * 'Cell at 1 x 2 : is anchored' * 'Cell at 1 x 3 : is spanned' * 'Cell at 1 x 4 : is anchored' * 'Cell at 1 x 5 : is anchored' * * **Note**: Option `row` is a shortcut that sets both `startRow` and `endRow` to the same row. * (Use either `row` or `startRow` and `endRow` but never together). Similarly the `column` option sets both `startColumn` * and `endColumn` to the same column (Use either `column` or `startColumn` and `endColumn` but never together). * * @param table A table over which the walker iterates. * @param options An object with configuration. * @param options.row A row index for which this iterator will output cells. Can't be used together with `startRow` and `endRow`. * @param options.startRow A row index from which this iterator should start. Can't be used together with `row`. Default value is 0. * @param options.endRow A row index at which this iterator should end. Can't be used together with `row`. * @param options.column A column index for which this iterator will output cells. * Can't be used together with `startColumn` and `endColumn`. * @param options.startColumn A column index from which this iterator should start. * Can't be used together with `column`. Default value is 0. * @param options.endColumn A column index at which this iterator should end. Can't be used together with `column`. * @param options.includeAllSlots Also return values for spanned cells. Default value is "false". */ constructor(table, options = {}){ this._table = table; this._startRow = options.row !== undefined ? options.row : options.startRow || 0; this._endRow = options.row !== undefined ? options.row : options.endRow; this._startColumn = options.column !== undefined ? options.column : options.startColumn || 0; this._endColumn = options.column !== undefined ? options.column : options.endColumn; this._includeAllSlots = !!options.includeAllSlots; this._skipRows = new Set(); this._row = 0; this._rowIndex = 0; this._column = 0; this._cellIndex = 0; this._spannedCells = new Map(); this._nextCellAtColumn = -1; } /** * Iterable interface. */ [Symbol.iterator]() { return this; } /** * Gets the next table walker's value. * * @returns The next table walker's value. */ next() { if (this._canJumpToStartRow()) { this._jumpToNonSpannedRowClosestToStartRow(); } const row = this._table.getChild(this._rowIndex); // Iterator is done when there's no row (table ended) or the row is after `endRow` limit. if (!row || this._isOverEndRow()) { return { done: true, value: undefined }; } // We step over current element when it is not a tableRow instance. if (!row.is('element', 'tableRow')) { this._rowIndex++; return this.next(); } if (this._isOverEndColumn()) { return this._advanceToNextRow(); } let outValue = null; const spanData = this._getSpanned(); if (spanData) { if (this._includeAllSlots && !this._shouldSkipSlot()) { outValue = this._formatOutValue(spanData.cell, spanData.row, spanData.column); } } else { const cell = row.getChild(this._cellIndex); if (!cell) { // If there are no more cells left in row advance to the next row. return this._advanceToNextRow(); } const colspan = parseInt(cell.getAttribute('colspan') || '1'); const rowspan = parseInt(cell.getAttribute('rowspan') || '1'); // Record this cell spans if it's not 1x1 cell. if (colspan > 1 || rowspan > 1) { this._recordSpans(cell, rowspan, colspan); } if (!this._shouldSkipSlot()) { outValue = this._formatOutValue(cell); } this._nextCellAtColumn = this._column + colspan; } // Advance to the next column before returning value. this._column++; if (this._column == this._nextCellAtColumn) { this._cellIndex++; } // The current value will be returned only if current row and column are not skipped. return outValue || this.next(); } /** * Marks a row to skip in the next iteration. It will also skip cells from the current row if there are any cells from the current row * to output. * * @param row The row index to skip. */ skipRow(row) { this._skipRows.add(row); } /** * Advances internal cursor to the next row. */ _advanceToNextRow() { this._row++; this._rowIndex++; this._column = 0; this._cellIndex = 0; this._nextCellAtColumn = -1; return this.next(); } /** * Checks if the current row is over {@link #_endRow}. */ _isOverEndRow() { // If #_endRow is defined skip all rows after it. return this._endRow !== undefined && this._row > this._endRow; } /** * Checks if the current cell is over {@link #_endColumn} */ _isOverEndColumn() { // If #_endColumn is defined skip all cells after it. return this._endColumn !== undefined && this._column > this._endColumn; } /** * A common method for formatting the iterator's output value. * * @param cell The table cell to output. * @param anchorRow The row index of a cell anchor slot. * @param anchorColumn The column index of a cell anchor slot. */ _formatOutValue(cell, anchorRow = this._row, anchorColumn = this._column) { return { done: false, value: new TableSlot(this, cell, anchorRow, anchorColumn) }; } /** * Checks if the current slot should be skipped. */ _shouldSkipSlot() { const rowIsMarkedAsSkipped = this._skipRows.has(this._row); const rowIsBeforeStartRow = this._row < this._startRow; const columnIsBeforeStartColumn = this._column < this._startColumn; const columnIsAfterEndColumn = this._endColumn !== undefined && this._column > this._endColumn; return rowIsMarkedAsSkipped || rowIsBeforeStartRow || columnIsBeforeStartColumn || columnIsAfterEndColumn; } /** * Returns the cell element that is spanned over the current cell location. */ _getSpanned() { const rowMap = this._spannedCells.get(this._row); // No spans for given row. if (!rowMap) { return null; } // If spans for given rows has entry for column it means that this location if spanned by other cell. return rowMap.get(this._column) || null; } /** * Updates spanned cells map relative to the current cell location and its span dimensions. * * @param cell A cell that is spanned. * @param rowspan Cell height. * @param colspan Cell width. */ _recordSpans(cell, rowspan, colspan) { const data = { cell, row: this._row, column: this._column }; for(let rowToUpdate = this._row; rowToUpdate < this._row + rowspan; rowToUpdate++){ for(let columnToUpdate = this._column; columnToUpdate < this._column + colspan; columnToUpdate++){ if (rowToUpdate != this._row || columnToUpdate != this._column) { this._markSpannedCell(rowToUpdate, columnToUpdate, data); } } } } /** * Marks the cell location as spanned by another cell. * * @param row The row index of the cell location. * @param column The column index of the cell location. * @param data A spanned cell details (cell element, anchor row and column). */ _markSpannedCell(row, column, data) { if (!this._spannedCells.has(row)) { this._spannedCells.set(row, new Map()); } const rowSpans = this._spannedCells.get(row); rowSpans.set(column, data); } /** * Checks if part of the table can be skipped. */ _canJumpToStartRow() { return !!this._startRow && this._startRow > 0 && !this._jumpedToStartRow; } /** * Sets the current row to `this._startRow` or the first row before it that has the number of cells * equal to the number of columns in the table. * * Example: * +----+----+----+ * | 00 | 01 | 02 | * |----+----+----+ * | 10 | 12 | * | +----+ * | | 22 | * | +----+ * | | 32 | <--- Start row * +----+----+----+ * | 40 | 41 | 42 | * +----+----+----+ * * If the 4th row is a `this._startRow`, this method will: * 1.) Count the number of columns this table has based on the first row (3 columns in this case). * 2.) Check if the 4th row contains 3 cells. It doesn't, so go to the row before it. * 3.) Check if the 3rd row contains 3 cells. It doesn't, so go to the row before it. * 4.) Check if the 2nd row contains 3 cells. It does, so set the current row to that row. * * Setting the current row this way is necessary to let the `next()` method loop over the cells * spanning multiple rows or columns and update the `this._spannedCells` property. */ _jumpToNonSpannedRowClosestToStartRow() { const firstRowLength = this._getRowLength(0); for(let i = this._startRow; !this._jumpedToStartRow; i--){ if (firstRowLength === this._getRowLength(i)) { this._row = i; this._rowIndex = i; this._jumpedToStartRow = true; } } } /** * Returns a number of columns in a row taking `colspan` into consideration. */ _getRowLength(rowIndex) { const row = this._table.getChild(rowIndex); return [ ...row.getChildren() ].reduce((cols, row)=>{ return cols + parseInt(row.getAttribute('colspan') || '1'); }, 0); } } /** * An object returned by {@link module:table/tablewalker~TableWalker} when traversing table cells. */ class TableSlot { /** * The current table cell. */ cell; /** * The row index of a table slot. */ row; /** * The column index of a table slot. */ column; /** * The row index of a cell anchor slot. */ cellAnchorRow; /** * The column index of a cell anchor slot. */ cellAnchorColumn; /** * The index of the current cell in the parent row. */ _cellIndex; /** * The index of the current row element in the table. */ _rowIndex; /** * The table element. */ _table; /** * Creates an instance of the table walker value. * * @param tableWalker The table walker instance. * @param cell The current table cell. * @param anchorRow The row index of a cell anchor slot. * @param anchorColumn The column index of a cell anchor slot. */ constructor(tableWalker, cell, anchorRow, anchorColumn){ this.cell = cell; this.row = tableWalker._row; this.column = tableWalker._column; this.cellAnchorRow = anchorRow; this.cellAnchorColumn = anchorColumn; this._cellIndex = tableWalker._cellIndex; this._rowIndex = tableWalker._rowIndex; this._table = tableWalker._table; } // @if CK_DEBUG // public get isSpanned(): unknown { return throwMissingGetterError( 'isSpanned' ); } // @if CK_DEBUG // public get colspan(): unknown { return throwMissingGetterError( 'colspan' ); } // @if CK_DEBUG // public get rowspan(): unknown { return throwMissingGetterError( 'rowspan' ); } // @if CK_DEBUG // public get cellIndex(): unknown { return throwMissingGetterError( 'cellIndex' ); } /** * Whether the cell is anchored in the current slot. */ get isAnchor() { return this.row === this.cellAnchorRow && this.column === this.cellAnchorColumn; } /** * The width of a cell defined by a `colspan` attribute. If the model attribute is not present, it is set to `1`. */ get cellWidth() { return parseInt(this.cell.getAttribute('colspan') || '1'); } /** * The height of a cell defined by a `rowspan` attribute. If the model attribute is not present, it is set to `1`. */ get cellHeight() { return parseInt(this.cell.getAttribute('rowspan') || '1'); } /** * The index of the current row element in the table. */ get rowIndex() { return this._rowIndex; } /** * Returns the {@link module:engine/model/position~ModelPosition} before the table slot. */ getPositionBefore() { const model = this._table.root.document.model; return model.createPositionAt(this._table.getChild(this.row), this._cellIndex); } } // @if CK_DEBUG // throw new CKEditorError( 'tableslot-getter-removed', null, { // @if CK_DEBUG // getterName // @if CK_DEBUG // } ); // @if CK_DEBUG // } /** * 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 │ * └───┴───┴───┴───┴───┘ * * @internal */ function cropTableToDimensions(sourceTable, cropDimensions, writer) { const { startRow, startColumn, endRow, endColumn } = cropDimensions; // Initialize the cropped table element. const croppedTable = writer.createElement('table'); // Copy table type attribute if present. const sourceTableType = sourceTable.getAttribute('tableType'); if (sourceTableType) { writer.setAttribute('tableType', sourceTableType, croppedTable); } // Create empty table with empty rows equal to crop height. 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')); } } 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". * * @internal * @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. */ 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. * * @internal * @returns Created table cell, if any were created. */ 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". * * @internal * @param table The table to check. * @param overlapColumn The index of the column to check. */ 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. * * @internal * @param columnIndex The table cell column index. * @param splitColumn The index of column to split cell on. * @returns Created table cell. */ 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. * * @internal */ 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 c