UNPKG

@ckeditor/ckeditor5-table

Version:

Table feature for CKEditor 5.

1,412 lines (1,405 loc) 469 kB
/** * @license Copyright (c) 2003-2026, 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"; import { Widget, WidgetToolbarRepository, isWidget, toWidget, toWidgetEditable } from "@ckeditor/ckeditor5-widget"; import { CKEditorError, Collection, DomEmitterMixin, FocusTracker, KeystrokeHandler, Rect, first, getLocalizedArrowKeyCodeDirection, global, priorities, toUnit, uid } from "@ckeditor/ckeditor5-utils"; import { debounce, isEqual, isObject, throttle } from "es-toolkit/compat"; import { IconAlignBottom, IconAlignCenter, IconAlignJustify, IconAlignLeft, IconAlignMiddle, IconAlignRight, IconAlignTop, IconCaption, IconObjectCenter, IconObjectInlineLeft, IconObjectInlineRight, IconObjectLeft, IconObjectRight, IconPreviousArrow, IconTable, IconTableCellProperties, IconTableColumn, IconTableLayout, IconTableMergeCell, IconTableProperties, IconTableRow } from "@ckeditor/ckeditor5-icons"; import { BalloonPanelView, ButtonView, ColorSelectorView, ContextualBalloon, DropdownButtonView, FocusCycler, FormHeaderView, FormRowView, InputTextView, LabelView, LabeledFieldView, MenuBarMenuView, SplitButtonView, SwitchButtonView, ToolbarView, UIModel, View, ViewCollection, addKeyboardHandlingForGrid, addListToDropdown, clickOutsideHandler, createDropdown, createLabeledDropdown, createLabeledInputText, getLocalizedColorOptions, normalizeColorOptions, submitHandler } from "@ckeditor/ckeditor5-ui"; import { ClipboardMarkersUtils, ClipboardPipeline } from "@ckeditor/ckeditor5-clipboard"; import { DomEventObserver, Matcher, ModelElement, addBackgroundStylesRules, addBorderStylesRules, addMarginStylesRules, addPaddingStylesRules, enableViewPlaceholder, isColorStyleValue, isLengthStyleValue, isPercentageStyleValue } from "@ckeditor/ckeditor5-engine"; /** * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options */ 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) => { 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; conversionApi.consumable.consume(viewElement, { styles: styleName }); } } }); if (attributeName) conversion.for("upcast").attributeToAttribute({ view: { name: viewElement, attributes: { [attributeName]: /.+/ } }, model: { key: modelAttribute, value: (viewElement, conversionApi, data) => { 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") { const parsedValue = parseFloat(value); if (isNaN(parsedValue)) value = localDefaultValue; else value = parsedValue + (value.includes("%") ? "%" : "px"); } if (localDefaultValue !== value) return value; 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) => { const { modelRange, viewItem } = data; if (!modelRange) return; 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) => viewItem.hasStyle(styleName)); const viewTable = viewItem.is("element", "table") ? viewItem : viewItem.findAncestor("table"); const hasTableBorderAttribute = viewTable.hasAttribute("border"); if (!stylesToConsume.length && !hasTableBorderAttribute) return; const matcherPattern = { styles: stylesToConsume }; if (!conversionApi.consumable.test(viewItem, matcherPattern)) return; const modelElement = first(modelRange.getItems({ shallow: true })); const tableModelElement = modelElement.findAncestor("table", { includeSelf: true }); let localDefaultBorder = defaultBorder; if (tableModelElement && tableModelElement.getAttribute("tableType") == "layout") localDefaultBorder = { style: "none", color: "", width: "" }; conversionApi.consumable.consume(viewItem, matcherPattern); const normalizedBorder = { style: viewItem.getNormalizedStyle("border-style"), color: viewItem.getNormalizedStyle("border-color"), width: viewItem.getNormalizedStyle("border-width") }; if (hasTableBorderAttribute && conversionApi.consumable.test(viewTable, { attributes: "border" })) { const borderValue = parseFloat(viewTable.getAttribute("border") || "1"); const borderPx = Number.isNaN(borderValue) || !Number.isFinite(borderValue) || borderValue < 0 || viewItem.name != "table" && borderValue > 1 ? "1px" : `${borderValue}px`; normalizedBorder.width = { top: borderPx, bottom: borderPx, right: borderPx, left: borderPx, ...normalizedBorder.width || {} }; if (viewItem.is("element", "table")) conversionApi.consumable.consume(viewTable, { attributes: "border" }); } 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); }); }); } /** * 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 = Array.from(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" ]; if (!sides.every((side) => style[side])) return style; const topSideStyle = style.top; if (!sides.every((side) => style[side] === topSideStyle)) return style; return topSideStyle; } /** * Conversion helper for upcasting the `cellpadding` table attribute. * * @param editor The editor instance. * @param viewElementName The view element name that should be converted. * @param defaultPadding The default padding value. * @internal */ function upcastTableCellPaddingAttribute(editor, viewElementName, defaultPadding) { const { conversion } = editor; conversion.for("upcast").add((dispatcher) => { dispatcher.on(`element:${viewElementName}`, (evt, data, conversionApi) => { const { modelRange, viewItem } = data; if (!modelRange) return; if (viewItem.is("element", "table")) { conversionApi.consumable.consume(viewItem, { attributes: "cellpadding" }); return; } const viewTable = viewItem.findAncestor("table"); if (!viewTable.hasAttribute("cellpadding") || !conversionApi.consumable.test(viewTable, { attributes: "cellpadding" })) return; const modelElement = modelRange?.start?.nodeAfter; const cellpaddingValue = parseFloat(viewTable.getAttribute("cellpadding") || "1"); const cellpaddingPx = Number.isNaN(cellpaddingValue) || !Number.isFinite(cellpaddingValue) || cellpaddingValue < 0 ? "0px" : `${cellpaddingValue}px`; const tableCellPaddings = modelElement.getAttribute("tableCellPadding"); if (!tableCellPaddings) { if (defaultPadding !== cellpaddingPx) conversionApi.writer.setAttribute("tableCellPadding", cellpaddingPx, modelElement); } else if (typeof tableCellPaddings === "object") { const normalizedPaddings = { ...defaultPadding !== cellpaddingPx && { top: cellpaddingPx }, ...defaultPadding !== cellpaddingPx && { right: cellpaddingPx }, ...defaultPadding !== cellpaddingPx && { bottom: cellpaddingPx }, ...defaultPadding !== cellpaddingPx && { left: cellpaddingPx }, ...tableCellPaddings }; conversionApi.writer.setAttribute("tableCellPadding", normalizedPaddings, modelElement); } }, { priority: "low" }); }); } /** * 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 = [ { 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 }; } }, { view: { name: /^(table|figure)$/, styles: { "margin-left": "auto", "margin-right": "auto" } }, getAlign: () => "center", getConsumables: () => { return { styles: ["margin-left", "margin-right"] }; } }, { view: { name: /^(table|figure)$/, key: "class", value: "table-style-align-left" }, getAlign: () => "left", getConsumables() { return { classes: DEFAULT_TABLE_ALIGNMENT_OPTIONS.left.className }; } }, { view: { name: /^(table|figure)$/, key: "class", value: DEFAULT_TABLE_ALIGNMENT_OPTIONS.right.className }, getAlign: () => "right", getConsumables() { return { classes: DEFAULT_TABLE_ALIGNMENT_OPTIONS.right.className }; } }, { view: { name: /^(table|figure)$/, key: "class", value: DEFAULT_TABLE_ALIGNMENT_OPTIONS.center.className }, getAlign: () => "center", getConsumables() { return { classes: DEFAULT_TABLE_ALIGNMENT_OPTIONS.center.className }; } }, { view: { name: /^(table|figure)$/, key: "class", value: DEFAULT_TABLE_ALIGNMENT_OPTIONS.blockLeft.className }, getAlign: () => "blockLeft", getConsumables() { return { classes: DEFAULT_TABLE_ALIGNMENT_OPTIONS.blockLeft.className }; } }, { view: { name: /^(table|figure)$/, key: "class", value: DEFAULT_TABLE_ALIGNMENT_OPTIONS.blockRight.className }, getAlign: () => "blockRight", getConsumables() { return { classes: DEFAULT_TABLE_ALIGNMENT_OPTIONS.blockRight.className }; } }, { view: { name: /^(table|figure)$/, styles: { "margin-left": "0", "margin-right": "auto" } }, getAlign: () => "blockLeft", getConsumables() { return { styles: ["margin-left", "margin-right"] }; } }, { view: { name: /^(table|figure)$/, styles: { "margin-left": "auto", "margin-right": "0" } }, getAlign: () => "blockRight", getConsumables() { return { styles: ["margin-left", "margin-right"] }; } }, { 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: void 0, style: "margin-left: 0; margin-right: auto;", className: DEFAULT_TABLE_ALIGNMENT_OPTIONS.blockLeft.className }, blockRight: { align: void 0, style: "margin-left: auto; margin-right: 0;", className: DEFAULT_TABLE_ALIGNMENT_OPTIONS.blockRight.className } }; /** * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options */ /** * 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. */ var TableWalker = class { /** * 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 !== void 0 ? options.row : options.startRow || 0; this._endRow = options.row !== void 0 ? options.row : options.endRow; this._startColumn = options.column !== void 0 ? options.column : options.startColumn || 0; this._endColumn = options.column !== void 0 ? options.column : options.endColumn; this._includeAllSlots = !!options.includeAllSlots; this._skipRows = /* @__PURE__ */ new Set(); this._row = 0; this._rowIndex = 0; this._column = 0; this._cellIndex = 0; this._spannedCells = /* @__PURE__ */ 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); if (!row || this._isOverEndRow()) return { done: true, value: void 0 }; 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) return this._advanceToNextRow(); const colspan = parseInt(cell.getAttribute("colspan") || "1"); const rowspan = parseInt(cell.getAttribute("rowspan") || "1"); if (colspan > 1 || rowspan > 1) this._recordSpans(cell, rowspan, colspan); if (!this._shouldSkipSlot()) outValue = this._formatOutValue(cell); this._nextCellAtColumn = this._column + colspan; } this._column++; if (this._column == this._nextCellAtColumn) this._cellIndex++; 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() { return this._endRow !== void 0 && this._row > this._endRow; } /** * Checks if the current cell is over {@link #_endColumn} */ _isOverEndColumn() { return this._endColumn !== void 0 && 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 !== void 0 && 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); if (!rowMap) return null; 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, /* @__PURE__ */ new Map()); this._spannedCells.get(row).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) { return [...this._table.getChild(rowIndex).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. */ var TableSlot = class { /** * 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; } /** * 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() { return this._table.root.document.model.createPositionAt(this._table.getChild(this.row), this._cellIndex); } }; /** * This `TableSlot`'s getter (property) was removed in CKEditor 5 v20.0.0. * * Check out the new `TableWalker`'s API in the documentation. * * @error tableslot-getter-removed * @param getterName */ /** * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options */ /** * Checks if the given cell type represents a header cell. * * @param cellType The type of the table cell. * @returns `true` if the cell type represents a header cell, `false` otherwise. */ function isTableHeaderCellType(cellType) { return cellType === "header" || cellType === "header-row" || cellType === "header-column"; } /** * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options */ /** * 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 !== void 0 && value !== null && defaultValue !== void 0 && 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(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(); if (selectedElement && selectedElement.is("element", "table")) return selectedElement; return selection.getFirstPosition().findAncestor("table"); } /** * Groups table cells by their parent table. * * @internal */ function groupCellsByTable(tableCells) { const tableMap = /* @__PURE__ */ new Map(); for (const tableCell of tableCells) { const table = tableCell.findAncestor("table"); if (!tableMap.has(table)) tableMap.set(table, []); tableMap.get(table).push(tableCell); } return tableMap; } /** * Checks if all cells in a given row or column are header cells. * * @internal */ function isEntireCellsLineHeader({ table, row, column }) { const tableWalker = new TableWalker(table, { row, column }); for (const { cell } of tableWalker) if (!isTableHeaderCellType(cell.getAttribute("tableCellType"))) return false; return true; } /** * Checks whether the `tableCellType` attribute is enabled in the editor schema and the experimental flag is set. * * @internal */ function isTableCellTypeEnabled(editor) { return editor.model.schema.checkAttribute("tableCell", "tableCellType"); } /** * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options */ /** * 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; const croppedTable = writer.createElement("table"); const sourceTableType = sourceTable.getAttribute("tableType"); if (sourceTableType) writer.setAttribute("tableType", sourceTableType, croppedTable); 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 })]; for (const { row: sourceRow, column: sourceColumn, cell: tableCell, isAnchor, cellAnchorRow, cellAnchorColumn } of tableMap) { const rowInCroppedTable = sourceRow - startRow; const row = croppedTable.getChild(rowInCroppedTable); if (!isAnchor) { if (cellAnchorRow < startRow || cellAnchorColumn < startColumn) createEmptyTableCell(writer, writer.createPositionAt(row, "end")); } else { const tableCellCopy = writer.cloneElement(tableCell); writer.append(tableCellCopy, row); trimTableCellIfNeeded(tableCellCopy, sourceRow, sourceColumn, endRow, endColumn, writer); } } addHeadingsToCroppedTable(croppedTable, sourceTable, startRow, startColumn, writer); addFootersToCroppedTable(croppedTable, sourceTable, startRow, endRow, 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 === void 0) columnIndex = column; if (columnIndex !== void 0 && columnIndex === column && row === endRow) newCell = createEmptyTableCell(writer, tableSlot.getPositionBefore(), newCellAttributes); } 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); 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"); if (cellColumn + colspan - 1 > limitColumn) updateNumericAttribute("colspan", limitColumn - cellColumn + 1, tableCell, writer, 1); if (cellRow + rowspan - 1 > limitRow) updateNumericAttribute("rowspan", limitRow - cellRow + 1, 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) updateNumericAttribute("headingRows", headingRows - startRow, croppedTable, writer, 0); const headingColumns = parseInt(sourceTable.getAttribute("headingColumns") || "0"); if (headingColumns > 0) updateNumericAttribute("headingColumns", headingColumns - startColumn, croppedTable, writer, 0); } /** * Sets footer row attributes to a cropped table. */ function addFootersToCroppedTable(croppedTable, sourceTable, startRow, endRow, writer) { const maxRows = Array.from(sourceTable.getChildren()).reduce((count, row) => row.is("element", "tableRow") ? count + 1 : count, 0); const footerRows = parseInt(sourceTable.getAttribute("footerRows") || "0"); const footerIndex = maxRows - footerRows; if (footerRows < 1) return; let footerRowsInCrop = 0; if (endRow >= footerIndex) footerRowsInCrop = endRow - Math.max(footerIndex, startRow) + 1; updateNumericAttribute("footerRows", footerRowsInCrop, 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 column from a table use {@link module:table/tableutils~TableUtils#removeColumns `TableUtils.removeColumns()`}. * * @internal * @returns True if removed some columns. */ function removeEmptyColumns(table, tableUtils) { const width = tableUtils.getColumns(table); const columnsMap = new Array(width).fill(0); for (const { column } of new TableWalker(table)) columnsMap[column]++; const emptyColumns = columnsMap.reduce((result, cellsCount, column) => { return cellsCount ? result : [...result, column]; }, []); if (emptyColumns.length > 0) { const emptyColumn = emptyColumns[emptyColumns.length - 1]; tableUtils.removeColumns(table, { at: emptyColumn }); return true; } return false; } /** * Removes rows that have no cells anchored. * * In table below: * * +----+----+----+ * | 00 | 01 | 02 | * +----+----+----+ * | 10 | 11 | 12 | * + + + + * | | | | <-- empty * +----+----+----+ * | 30 | 31 | 32 | * +----+----+----+ * | 40 | 42 | * + + + * | | | <-- empty * +----+----+----+ * | 60 | 61 | 62 | * +----+----+----+ * * Will remove rows 2 and 5. * * **Note:** This is a low-level helper method for clearing invalid model state when doing table modifications. * To remove a row from a table use {@link module:table/tableutils~TableUtils#removeRows `TableUtils.removeRows()`}. * * @internal * @returns True if removed some rows. */ function removeEmptyRows(table, tableUtils) { const emptyRows = []; const tableRowCount = tableUtils.getRows(table); for (let rowIndex = 0; rowIndex < tableRowCount; rowIndex++) if (table.getChild(rowIndex).isEmpty) emptyRows.push(rowIndex); if (emptyRows.length > 0) { const emptyRow = emptyRows[emptyRows.length - 1]; tableUtils.removeRows(table, { at: emptyRow }); return true; } return false; } /** * Removes rows and columns that have no cells anchored. * * In table below: * * +----+----+----+----+ * | 00 | 02 | * +----+----+ + * | 10 | | * +----+----+----+----+ * | 20 | 22 | 23 | * + + + + * | | | | <-- empty row * +----+----+----+----+ * ^--- empty column * * Will remove row 3 and column 1. * * **Note:** This is a low-level helper method for clearing invalid model state when doing table modifications. * To remove a rows from a table use {@link module:table/tableutils~TableUtils#removeRows `TableUtils.removeRows()`} and * {@link module:table/tableutils~TableUtils#removeColumns `TableUtils.removeColumns()`} to remove a column. * * @internal */ function removeEmptyRowsColumns(table, tableUtils) { if (!removeEmptyColumns(table, tableUtils)) removeEmptyRows(table, tableUtils); } /** * Returns adjusted last row index if selection covers part of a row with empty slots (spanned by other cells). * The `dimensions.lastRow` is equal to last row index but selection might be bigger. * * This happens *only* on rectangular selection so we analyze a case like this: * * +---+---+---+---+ * 0 | a | b | c | d | * + + +---+---+ * 1 | | e | f | g | * + +---+ +---+ * 2 | | h | | i | <- last row, each cell has rowspan = 2, * + + + + + so we need to return 3, not 2 * 3 | | | | | * +---+---+---+---+ * * @internal * @returns Adjusted last row index. */ function adjustLastRowIndex(table, dimensions) { const lastRowMap = Array.from(new TableWalker(table, { startColumn: dimensions.firstColumn, endColumn: dimensions.lastColumn, row: dimensions.lastRow })); if (lastRowMap.every(({ cellHeight }) => cellHeight === 1)) return dimensions.lastRow; const rowspanAdjustment = lastRowMap[0].cellHeight - 1; return dimensions.lastRow + rowspanAdjustment; } /** * Returns adjusted last column index if selection covers part of a column with empty slots (spanned by other cells). * The `dimensions.lastColumn` is equal to last column index but selection might be bigger. * * This happens *only* on rectangular selection so we analyze a case like this: * * 0 1 2 3 * +---+---+---+---+ * | a | * +---+---+---+---+ * | b | c | d | * +---+---+---+---+ * | e | f | * +---+---+---+---+ * | g | h | * +---+---+---+---+ * ^ * last column, each cell has colspan = 2, so we need to return 3, not 2 * * @internal * @returns Adjusted last column index. */ function adjustLastColumnIndex(table, dimensions) { const lastColumnMap = Array.from(new TableWalker(table, { startRow: dimensions.firstRow, endRow: dimensions.lastRow, column: dimensions.lastColumn })); if (lastColumnMap.every(({ cellWidth }) => cellWidth === 1)) return dimensions.lastColumn; const colspanAdjustment = lastColumnMap[0].cellWidth - 1; return dimensions.lastColumn + colspanAdjustment; } /** * Get view `<table>` element from the wrapper. */ function getViewTableFromWrapper(wrapperView) { for (const wrapperChild of wrapperView.getChildren()) if (wrapperChild.is("element", "table")) return wrapperChild; } /** * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options */ /** * Returns a function that converts the table view representation: * * ```xml * <figure class="table"><table>...</table></figure> * ``` * * to the model representation: * * ```xml * <table></table> * ``` * * @internal */ function upcastTableFigure() { return (dispatcher) => { dispatcher.on("element:figure", (evt, data, conversionApi) => { if (!conversionApi.consumable.test(data.viewItem, { name: true, classes: "table" })) return; const viewTable = getViewTableFromWrapper(data.viewItem); if (!viewTable || !conversionApi.consumable.test(viewTable, { name: true })) return; conversionApi.consumable.consume(data.viewItem, { name: true, classes: "table" }); const conversionResult = conversionApi.convertItem(viewTable, data.modelCursor); /* istanbul ignore if: defensive guard for the `ModelRange | null` return type -- @preserve */ if (!conversionResult.modelRange) { conversionApi.consumable.revert(data.viewItem, { name: true, classes: "table" }); return; } const modelTable = first(conversionRe