@ckeditor/ckeditor5-table
Version:
Table feature for CKEditor 5.
1,412 lines (1,405 loc) • 469 kB
JavaScript
/**
* @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