@ckeditor/ckeditor5-table
Version:
Table feature for CKEditor 5.
1,191 lines (1,183 loc) • 468 kB
JavaScript
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import { Command, Plugin, icons } from '@ckeditor/ckeditor5-core/dist/index.js';
import { toWidgetEditable, toWidget, Widget, isWidget, WidgetToolbarRepository } from '@ckeditor/ckeditor5-widget/dist/index.js';
import { first, global, CKEditorError, KeystrokeHandler, FocusTracker, Collection, getLocalizedArrowKeyCodeDirection, Rect, DomEmitterMixin } from '@ckeditor/ckeditor5-utils/dist/index.js';
import { View, addKeyboardHandlingForGrid, ButtonView, createDropdown, MenuBarMenuView, SwitchButtonView, SplitButtonView, addListToDropdown, ViewModel, ViewCollection, FocusCycler, InputTextView, ColorSelectorView, FormHeaderView, submitHandler, LabelView, LabeledFieldView, createLabeledDropdown, createLabeledInputText, ToolbarView, BalloonPanelView, ContextualBalloon, normalizeColorOptions, getLocalizedColorOptions, clickOutsideHandler } from '@ckeditor/ckeditor5-ui/dist/index.js';
import { ClipboardMarkersUtils, ClipboardPipeline } from '@ckeditor/ckeditor5-clipboard/dist/index.js';
import { DomEventObserver, isColor, isLength, isPercentage, addBorderRules, addPaddingRules, addBackgroundRules, enablePlaceholder, Element } from '@ckeditor/ckeditor5-engine/dist/index.js';
import { isObject, debounce, isEqual, throttle } from 'lodash-es';
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/ /**
* @module table/converters/tableproperties
*/ /**
* 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.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.
*/ function upcastStyleToAttribute(conversion, options) {
const { modelAttribute, styleName, viewElement, defaultValue, reduceBoxSides = false, shouldUpcast = ()=>true } = options;
conversion.for('upcast').attributeToAttribute({
view: {
name: viewElement,
styles: {
[styleName]: /[\s\S]+/
}
},
model: {
key: modelAttribute,
value: (viewElement)=>{
if (!shouldUpcast(viewElement)) {
return;
}
const normalized = viewElement.getNormalizedStyle(styleName);
const value = reduceBoxSides ? reduceBoxSidesValue(normalized) : normalized;
if (defaultValue !== value) {
return value;
}
}
}
});
}
/**
* Conversion helper for upcasting border styles for view elements.
*
* @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.
*/ function upcastBorderStyles(conversion, viewElementName, modelAttributes, defaultBorder) {
conversion.for('upcast').add((dispatcher)=>dispatcher.on('element:' + viewElementName, (evt, data, conversionApi)=>{
// If the element was not converted by element-to-element converter,
// we should not try to convert the style. See #8393.
if (!data.modelRange) {
return;
}
// Check the most detailed properties. These will be always set directly or
// when using the "group" properties like: `border-(top|right|bottom|left)` or `border`.
const stylesToConsume = [
'border-top-width',
'border-top-color',
'border-top-style',
'border-bottom-width',
'border-bottom-color',
'border-bottom-style',
'border-right-width',
'border-right-color',
'border-right-style',
'border-left-width',
'border-left-color',
'border-left-style'
].filter((styleName)=>data.viewItem.hasStyle(styleName));
if (!stylesToConsume.length) {
return;
}
const matcherPattern = {
styles: stylesToConsume
};
// Try to consume appropriate values from consumable values list.
if (!conversionApi.consumable.test(data.viewItem, matcherPattern)) {
return;
}
const modelElement = [
...data.modelRange.getItems({
shallow: true
})
].pop();
conversionApi.consumable.consume(data.viewItem, matcherPattern);
const normalizedBorder = {
style: data.viewItem.getNormalizedStyle('border-style'),
color: data.viewItem.getNormalizedStyle('border-color'),
width: data.viewItem.getNormalizedStyle('border-width')
};
const reducedBorder = {
style: reduceBoxSidesValue(normalizedBorder.style),
color: reduceBoxSidesValue(normalizedBorder.color),
width: reduceBoxSidesValue(normalizedBorder.width)
};
if (reducedBorder.style !== defaultBorder.style) {
conversionApi.writer.setAttribute(modelAttributes.style, reducedBorder.style, modelElement);
}
if (reducedBorder.color !== defaultBorder.color) {
conversionApi.writer.setAttribute(modelAttributes.color, reducedBorder.color, modelElement);
}
if (reducedBorder.width !== defaultBorder.width) {
conversionApi.writer.setAttribute(modelAttributes.width, reducedBorder.width, modelElement);
}
}));
}
/**
* Conversion helper for downcasting an attribute to a style.
*/ 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>`).
*/ function downcastTableAttribute(conversion, options) {
const { modelAttribute, styleName } = options;
conversion.for('downcast').add((dispatcher)=>dispatcher.on(`attribute:${modelAttribute}:table`, (evt, data, conversionApi)=>{
const { item, attributeNewValue } = data;
const { mapper, writer } = conversionApi;
if (!conversionApi.consumable.consume(data.item, evt.name)) {
return;
}
const table = [
...mapper.toViewElement(item).getChildren()
].find((child)=>child.is('element', 'table'));
if (attributeNewValue) {
writer.setStyle(styleName, attributeNewValue, table);
} else {
writer.removeStyle(styleName, table);
}
}));
}
/**
* Reduces the full top, right, bottom, left object to a single string if all sides are equal.
* Returns original style otherwise.
*/ function reduceBoxSidesValue(style) {
if (!style) {
return;
}
const sides = [
'top',
'right',
'bottom',
'left'
];
const allSidesDefined = sides.every((side)=>style[side]);
if (!allSidesDefined) {
return style;
}
const topSideStyle = style.top;
const allSidesEqual = sides.every((side)=>style[side] === topSideStyle);
if (!allSidesEqual) {
return style;
}
return topSideStyle;
}
/**
* A common method to update the numeric value. If a value is the default one, it will be unset.
*
* @param key An attribute key.
* @param value The new attribute value.
* @param item A model item on which the attribute will be set.
* @param defaultValue The default attribute value. If a value is lower or equal, it will be unset.
*/ function updateNumericAttribute(key, value, item, writer, defaultValue = 1) {
if (value !== undefined && value !== null && defaultValue !== undefined && defaultValue !== null && value > defaultValue) {
writer.setAttribute(key, value, item);
} else {
writer.removeAttribute(key, item);
}
}
/**
* A common method to create an empty table cell. It creates a proper model structure as a table cell must have at least one block inside.
*
* @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.
*/ 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.
*
* @param options.defaultValue The default value for the specified `modelAttribute`.
*/ function enableProperty$1(schema, conversion, options) {
const { modelAttribute } = options;
schema.extend('tableCell', {
allowAttributes: [
modelAttribute
]
});
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.
*/ function getSelectionAffectedTable(selection) {
const selectedElement = selection.getSelectedElement();
// Is the command triggered from the `tableToolbar`?
if (selectedElement && selectedElement.is('element', 'table')) {
return selectedElement;
}
return selection.getFirstPosition().findAncestor('table');
}
/**
* Returns a function that converts the table view representation:
*
* ```xml
* <figure class="table"><table>...</table></figure>
* ```
*
* to the model representation:
*
* ```xml
* <table></table>
* ```
*/ function upcastTableFigure() {
return (dispatcher)=>{
dispatcher.on('element:figure', (evt, data, conversionApi)=>{
// Do not convert if this is not a "table figure".
if (!conversionApi.consumable.test(data.viewItem, {
name: true,
classes: 'table'
})) {
return;
}
// Find a table element inside the figure element.
const viewTable = getViewTableFromFigure(data.viewItem);
// Do not convert if table element is absent or was already converted.
if (!viewTable || !conversionApi.consumable.test(viewTable, {
name: true
})) {
return;
}
// Consume the figure to prevent other converters from processing it again.
conversionApi.consumable.consume(data.viewItem, {
name: true,
classes: 'table'
});
// Convert view table to model table.
const conversionResult = conversionApi.convertItem(viewTable, data.modelCursor);
// Get table element from conversion result.
const modelTable = first(conversionResult.modelRange.getItems());
// When table wasn't successfully converted then finish conversion.
if (!modelTable) {
// Revert consumed figure so other features can convert it.
conversionApi.consumable.revert(data.viewItem, {
name: true,
classes: 'table'
});
return;
}
conversionApi.convertChildren(data.viewItem, conversionApi.writer.createPositionAt(modelTable, 'end'));
conversionApi.updateConversionResult(modelTable, data);
});
};
}
/**
* View table element to model table element conversion helper.
*
* This conversion helper converts the table element as well as table rows.
*
* @returns Conversion helper.
*/ function upcastTable() {
return (dispatcher)=>{
dispatcher.on('element:table', (evt, data, conversionApi)=>{
const viewTable = data.viewItem;
// When element was already consumed then skip it.
if (!conversionApi.consumable.test(viewTable, {
name: true
})) {
return;
}
const { rows, headingRows, headingColumns } = scanTable(viewTable);
// Only set attributes if values is greater then 0.
const attributes = {};
if (headingColumns) {
attributes.headingColumns = headingColumns;
}
if (headingRows) {
attributes.headingRows = headingRows;
}
const table = conversionApi.writer.createElement('table', attributes);
if (!conversionApi.safeInsert(table, data.modelCursor)) {
return;
}
conversionApi.consumable.consume(viewTable, {
name: true
});
// Upcast table rows in proper order (heading rows first).
rows.forEach((row)=>conversionApi.convertItem(row, conversionApi.writer.createPositionAt(table, 'end')));
// Convert everything else.
conversionApi.convertChildren(viewTable, conversionApi.writer.createPositionAt(table, 'end'));
// Create one row and one table cell for empty table.
if (table.isEmpty) {
const row = conversionApi.writer.createElement('tableRow');
conversionApi.writer.insert(row, conversionApi.writer.createPositionAt(table, 'end'));
createEmptyTableCell(conversionApi.writer, conversionApi.writer.createPositionAt(row, 'end'));
}
conversionApi.updateConversionResult(table, data);
});
};
}
/**
* A conversion helper that skips empty <tr> elements from upcasting at the beginning of the table.
*
* An empty row is considered a table model error but when handling clipboard data there could be rows that contain only row-spanned cells
* and empty TR-s are used to maintain the table structure (also {@link module:table/tablewalker~TableWalker} assumes that there are only
* rows that have related `tableRow` elements).
*
* *Note:* Only the first empty rows are removed because they have no meaning and it solves the issue
* of an improper table with all empty rows.
*
* @returns Conversion helper.
*/ function skipEmptyTableRow() {
return (dispatcher)=>{
dispatcher.on('element:tr', (evt, data)=>{
if (data.viewItem.isEmpty && data.modelCursor.index == 0) {
evt.stop();
}
}, {
priority: 'high'
});
};
}
/**
* A converter that ensures an empty paragraph is inserted in a table cell if no other content was converted.
*
* @returns Conversion helper.
*/ function ensureParagraphInTableCell(elementName) {
return (dispatcher)=>{
dispatcher.on(`element:${elementName}`, (evt, data, { writer })=>{
// The default converter will create a model range on converted table cell.
if (!data.modelRange) {
return;
}
const tableCell = data.modelRange.start.nodeAfter;
const modelCursor = writer.createPositionAt(tableCell, 0);
// Ensure a paragraph in the model for empty table cells for converted table cells.
if (data.viewItem.isEmpty) {
writer.insertElement('paragraph', modelCursor);
return;
}
const childNodes = Array.from(tableCell.getChildren());
// In case there are only markers inside the table cell then move them to the paragraph.
if (childNodes.every((node)=>node.is('element', '$marker'))) {
const paragraph = writer.createElement('paragraph');
writer.insert(paragraph, writer.createPositionAt(tableCell, 0));
for (const node of childNodes){
writer.move(writer.createRangeOn(node), writer.createPositionAt(paragraph, 'end'));
}
}
}, {
priority: 'low'
});
};
}
/**
* Get view `<table>` element from the view widget (`<figure>`).
*/ function getViewTableFromFigure(figureView) {
for (const figureChild of figureView.getChildren()){
if (figureChild.is('element', 'table')) {
return figureChild;
}
}
}
/**
* Scans table rows and extracts required metadata from the table:
*
* headingRows - The number of rows that go as table headers.
* headingColumns - The maximum number of row headings.
* rows - Sorted `<tr>` elements as they should go into the model - ie. if `<thead>` is inserted after `<tbody>` in the view.
*/ function scanTable(viewTable) {
let headingRows = 0;
let headingColumns = undefined;
// The `<tbody>` and `<thead>` sections in the DOM do not have to be in order `<thead>` -> `<tbody>` and there might be more than one
// of them.
// As the model does not have these sections, rows from different sections must be sorted.
// For example, below is a valid HTML table:
//
// <table>
// <tbody><tr><td>2</td></tr></tbody>
// <thead><tr><td>1</td></tr></thead>
// <tbody><tr><td>3</td></tr></tbody>
// </table>
//
// But browsers will render rows in order as: 1 as the heading and 2 and 3 as the body.
const headRows = [];
const bodyRows = [];
// Currently the editor does not support more then one <thead> section.
// Only the first <thead> from the view will be used as a heading row and the others will be converted to body rows.
let firstTheadElement;
for (const tableChild of Array.from(viewTable.getChildren())){
// Only `<thead>`, `<tbody>` & `<tfoot>` from allowed table children can have `<tr>`s.
// The else is for future purposes (mainly `<caption>`).
if (tableChild.name !== 'tbody' && tableChild.name !== 'thead' && tableChild.name !== 'tfoot') {
continue;
}
// Save the first `<thead>` in the table as table header - all other ones will be converted to table body rows.
if (tableChild.name === 'thead' && !firstTheadElement) {
firstTheadElement = tableChild;
}
// There might be some extra empty text nodes between the `<tr>`s.
// Make sure further code operates on `tr`s only. (#145)
const trs = Array.from(tableChild.getChildren()).filter((el)=>el.is('element', 'tr'));
// Keep tracking of the previous row columns count to improve detection of heading rows.
let maxPrevColumns = null;
for (const tr of trs){
const trColumns = Array.from(tr.getChildren()).filter((el)=>el.is('element', 'td') || el.is('element', 'th'));
// This <tr> is a child of a first <thead> element.
if (firstTheadElement && tableChild === firstTheadElement || tableChild.name === 'tbody' && trColumns.length > 0 && // These conditions handles the case when the first column is a <th> element and it's the only column in the row.
// This case is problematic because it's not clear if this row should be a heading row or not, as it may be result
// of the cell span from the previous row.
// Issue: https://github.com/ckeditor/ckeditor5/issues/17556
(maxPrevColumns === null || trColumns.length === maxPrevColumns) && trColumns.every((e)=>e.is('element', 'th'))) {
headingRows++;
headRows.push(tr);
} else {
bodyRows.push(tr);
// For other rows check how many column headings this row has.
const headingCols = scanRowForHeadingColumns(tr);
if (!headingColumns || headingCols < headingColumns) {
headingColumns = headingCols;
}
}
// We use the maximum number of columns to avoid false positives when detecting
// multiple rows with single column within `rowspan`. Without it the last row of `rowspan=3`
// would be detected as a heading row because it has only one column (identical to the previous row).
maxPrevColumns = Math.max(maxPrevColumns || 0, trColumns.length);
}
}
return {
headingRows,
headingColumns: headingColumns || 0,
rows: [
...headRows,
...bodyRows
]
};
}
/**
* Scans a `<tr>` element and its children for metadata:
* - For heading row:
* - Adds this row to either the heading or the body rows.
* - Updates the number of heading rows.
* - For body rows:
* - Calculates the number of column headings.
*/ function scanRowForHeadingColumns(tr) {
let headingColumns = 0;
let index = 0;
// Filter out empty text nodes from tr children.
const children = Array.from(tr.getChildren()).filter((child)=>child.name === 'th' || child.name === 'td');
// Count starting adjacent <th> elements of a <tr>.
while(index < children.length && children[index].name === 'th'){
const th = children[index];
// Adjust columns calculation by the number of spanned columns.
const colspan = parseInt(th.getAttribute('colspan') || '1');
headingColumns = headingColumns + colspan;
index++;
}
return headingColumns;
}
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/ /**
* @module table/tablewalker
*/ /**
* The table iterator class. It allows to iterate over table cells. For each cell the iterator yields
* {@link module:table/tablewalker~TableSlot} with proper table cell attributes.
*/ class TableWalker {
/**
* The walker's table element.
*
* @internal
*/ _table;
/**
* A row index from which this iterator will start.
*/ _startRow;
/**
* A row index at which this iterator will end.
*/ _endRow;
/**
* If set, the table walker will only output cells from a given column and following ones or cells that overlap them.
*/ _startColumn;
/**
* If set, the table walker will only output cells up to a given column.
*/ _endColumn;
/**
* Enables output of spanned cells that are normally not yielded.
*/ _includeAllSlots;
/**
* Row indexes to skip from the iteration.
*/ _skipRows;
/**
* The current row index.
*
* @internal
*/ _row;
/**
* The index of the current row element in the table.
*
* @internal
*/ _rowIndex;
/**
* The current column index.
*
* @internal
*/ _column;
/**
* The cell index in a parent row. For spanned cells when {@link #_includeAllSlots} is set to `true`,
* this represents the index of the next table cell.
*
* @internal
*/ _cellIndex;
/**
* Holds a map of spanned cells in a table.
*/ _spannedCells;
/**
* Index of the next column where a cell is anchored.
*/ _nextCellAtColumn;
/**
* Indicates whether the iterator jumped to (or close to) the start row, ignoring rows that don't need to be traversed.
*/ _jumpedToStartRow = false;
/**
* Creates an instance of the table walker.
*
* The table walker iterates internally by traversing the table from row index = 0 and column index = 0.
* It walks row by row and column by column in order to output values defined in the constructor.
* By default it will output only the locations that are occupied by a cell. To include also spanned rows and columns,
* pass the `includeAllSlots` option to the constructor.
*
* The most important values of the iterator are column and row indexes of a cell.
*
* See {@link module:table/tablewalker~TableSlot} what values are returned by the table walker.
*
* To iterate over a given row:
*
* ```ts
* const tableWalker = new TableWalker( table, { startRow: 1, endRow: 2 } );
*
* for ( const tableSlot of tableWalker ) {
* console.log( 'A cell at row', tableSlot.row, 'and column', tableSlot.column );
* }
* ```
*
* For instance the code above for the following table:
*
* +----+----+----+----+----+----+
* | 00 | 02 | 03 | 04 | 05 |
* | +----+----+----+----+
* | | 12 | 14 | 15 |
* | +----+----+----+ +
* | | 22 | |
* |----+----+----+----+----+ +
* | 30 | 31 | 32 | 33 | 34 | |
* +----+----+----+----+----+----+
*
* will log in the console:
*
* 'A cell at row 1 and column 2'
* 'A cell at row 1 and column 4'
* 'A cell at row 1 and column 5'
* 'A cell at row 2 and column 2'
*
* To also iterate over spanned cells:
*
* ```ts
* const tableWalker = new TableWalker( table, { row: 1, includeAllSlots: true } );
*
* for ( const tableSlot of tableWalker ) {
* console.log( 'Slot at', tableSlot.row, 'x', tableSlot.column, ':', tableSlot.isAnchor ? 'is anchored' : 'is spanned' );
* }
* ```
*
* will log in the console for the table from the previous example:
*
* 'Cell at 1 x 0 : is spanned'
* 'Cell at 1 x 1 : is spanned'
* 'Cell at 1 x 2 : is anchored'
* 'Cell at 1 x 3 : is spanned'
* 'Cell at 1 x 4 : is anchored'
* 'Cell at 1 x 5 : is anchored'
*
* **Note**: Option `row` is a shortcut that sets both `startRow` and `endRow` to the same row.
* (Use either `row` or `startRow` and `endRow` but never together). Similarly the `column` option sets both `startColumn`
* and `endColumn` to the same column (Use either `column` or `startColumn` and `endColumn` but never together).
*
* @param table A table over which the walker iterates.
* @param options An object with configuration.
* @param options.row A row index for which this iterator will output cells. Can't be used together with `startRow` and `endRow`.
* @param options.startRow A row index from which this iterator should start. Can't be used together with `row`. Default value is 0.
* @param options.endRow A row index at which this iterator should end. Can't be used together with `row`.
* @param options.column A column index for which this iterator will output cells.
* Can't be used together with `startColumn` and `endColumn`.
* @param options.startColumn A column index from which this iterator should start.
* Can't be used together with `column`. Default value is 0.
* @param options.endColumn A column index at which this iterator should end. Can't be used together with `column`.
* @param options.includeAllSlots Also return values for spanned cells. Default value is "false".
*/ constructor(table, options = {}){
this._table = table;
this._startRow = options.row !== undefined ? options.row : options.startRow || 0;
this._endRow = options.row !== undefined ? options.row : options.endRow;
this._startColumn = options.column !== undefined ? options.column : options.startColumn || 0;
this._endColumn = options.column !== undefined ? options.column : options.endColumn;
this._includeAllSlots = !!options.includeAllSlots;
this._skipRows = new Set();
this._row = 0;
this._rowIndex = 0;
this._column = 0;
this._cellIndex = 0;
this._spannedCells = new Map();
this._nextCellAtColumn = -1;
}
/**
* Iterable interface.
*/ [Symbol.iterator]() {
return this;
}
/**
* Gets the next table walker's value.
*
* @returns The next table walker's value.
*/ next() {
if (this._canJumpToStartRow()) {
this._jumpToNonSpannedRowClosestToStartRow();
}
const row = this._table.getChild(this._rowIndex);
// Iterator is done when there's no row (table ended) or the row is after `endRow` limit.
if (!row || this._isOverEndRow()) {
return {
done: true,
value: undefined
};
}
// We step over current element when it is not a tableRow instance.
if (!row.is('element', 'tableRow')) {
this._rowIndex++;
return this.next();
}
if (this._isOverEndColumn()) {
return this._advanceToNextRow();
}
let outValue = null;
const spanData = this._getSpanned();
if (spanData) {
if (this._includeAllSlots && !this._shouldSkipSlot()) {
outValue = this._formatOutValue(spanData.cell, spanData.row, spanData.column);
}
} else {
const cell = row.getChild(this._cellIndex);
if (!cell) {
// If there are no more cells left in row advance to the next row.
return this._advanceToNextRow();
}
const colspan = parseInt(cell.getAttribute('colspan') || '1');
const rowspan = parseInt(cell.getAttribute('rowspan') || '1');
// Record this cell spans if it's not 1x1 cell.
if (colspan > 1 || rowspan > 1) {
this._recordSpans(cell, rowspan, colspan);
}
if (!this._shouldSkipSlot()) {
outValue = this._formatOutValue(cell);
}
this._nextCellAtColumn = this._column + colspan;
}
// Advance to the next column before returning value.
this._column++;
if (this._column == this._nextCellAtColumn) {
this._cellIndex++;
}
// The current value will be returned only if current row and column are not skipped.
return outValue || this.next();
}
/**
* Marks a row to skip in the next iteration. It will also skip cells from the current row if there are any cells from the current row
* to output.
*
* @param row The row index to skip.
*/ skipRow(row) {
this._skipRows.add(row);
}
/**
* Advances internal cursor to the next row.
*/ _advanceToNextRow() {
this._row++;
this._rowIndex++;
this._column = 0;
this._cellIndex = 0;
this._nextCellAtColumn = -1;
return this.next();
}
/**
* Checks if the current row is over {@link #_endRow}.
*/ _isOverEndRow() {
// If #_endRow is defined skip all rows after it.
return this._endRow !== undefined && this._row > this._endRow;
}
/**
* Checks if the current cell is over {@link #_endColumn}
*/ _isOverEndColumn() {
// If #_endColumn is defined skip all cells after it.
return this._endColumn !== undefined && this._column > this._endColumn;
}
/**
* A common method for formatting the iterator's output value.
*
* @param cell The table cell to output.
* @param anchorRow The row index of a cell anchor slot.
* @param anchorColumn The column index of a cell anchor slot.
*/ _formatOutValue(cell, anchorRow = this._row, anchorColumn = this._column) {
return {
done: false,
value: new TableSlot(this, cell, anchorRow, anchorColumn)
};
}
/**
* Checks if the current slot should be skipped.
*/ _shouldSkipSlot() {
const rowIsMarkedAsSkipped = this._skipRows.has(this._row);
const rowIsBeforeStartRow = this._row < this._startRow;
const columnIsBeforeStartColumn = this._column < this._startColumn;
const columnIsAfterEndColumn = this._endColumn !== undefined && this._column > this._endColumn;
return rowIsMarkedAsSkipped || rowIsBeforeStartRow || columnIsBeforeStartColumn || columnIsAfterEndColumn;
}
/**
* Returns the cell element that is spanned over the current cell location.
*/ _getSpanned() {
const rowMap = this._spannedCells.get(this._row);
// No spans for given row.
if (!rowMap) {
return null;
}
// If spans for given rows has entry for column it means that this location if spanned by other cell.
return rowMap.get(this._column) || null;
}
/**
* Updates spanned cells map relative to the current cell location and its span dimensions.
*
* @param cell A cell that is spanned.
* @param rowspan Cell height.
* @param colspan Cell width.
*/ _recordSpans(cell, rowspan, colspan) {
const data = {
cell,
row: this._row,
column: this._column
};
for(let rowToUpdate = this._row; rowToUpdate < this._row + rowspan; rowToUpdate++){
for(let columnToUpdate = this._column; columnToUpdate < this._column + colspan; columnToUpdate++){
if (rowToUpdate != this._row || columnToUpdate != this._column) {
this._markSpannedCell(rowToUpdate, columnToUpdate, data);
}
}
}
}
/**
* Marks the cell location as spanned by another cell.
*
* @param row The row index of the cell location.
* @param column The column index of the cell location.
* @param data A spanned cell details (cell element, anchor row and column).
*/ _markSpannedCell(row, column, data) {
if (!this._spannedCells.has(row)) {
this._spannedCells.set(row, new Map());
}
const rowSpans = this._spannedCells.get(row);
rowSpans.set(column, data);
}
/**
* Checks if part of the table can be skipped.
*/ _canJumpToStartRow() {
return !!this._startRow && this._startRow > 0 && !this._jumpedToStartRow;
}
/**
* Sets the current row to `this._startRow` or the first row before it that has the number of cells
* equal to the number of columns in the table.
*
* Example:
* +----+----+----+
* | 00 | 01 | 02 |
* |----+----+----+
* | 10 | 12 |
* | +----+
* | | 22 |
* | +----+
* | | 32 | <--- Start row
* +----+----+----+
* | 40 | 41 | 42 |
* +----+----+----+
*
* If the 4th row is a `this._startRow`, this method will:
* 1.) Count the number of columns this table has based on the first row (3 columns in this case).
* 2.) Check if the 4th row contains 3 cells. It doesn't, so go to the row before it.
* 3.) Check if the 3rd row contains 3 cells. It doesn't, so go to the row before it.
* 4.) Check if the 2nd row contains 3 cells. It does, so set the current row to that row.
*
* Setting the current row this way is necessary to let the `next()` method loop over the cells
* spanning multiple rows or columns and update the `this._spannedCells` property.
*/ _jumpToNonSpannedRowClosestToStartRow() {
const firstRowLength = this._getRowLength(0);
for(let i = this._startRow; !this._jumpedToStartRow; i--){
if (firstRowLength === this._getRowLength(i)) {
this._row = i;
this._rowIndex = i;
this._jumpedToStartRow = true;
}
}
}
/**
* Returns a number of columns in a row taking `colspan` into consideration.
*/ _getRowLength(rowIndex) {
const row = this._table.getChild(rowIndex);
return [
...row.getChildren()
].reduce((cols, row)=>{
return cols + parseInt(row.getAttribute('colspan') || '1');
}, 0);
}
}
/**
* An object returned by {@link module:table/tablewalker~TableWalker} when traversing table cells.
*/ class TableSlot {
/**
* The current table cell.
*/ cell;
/**
* The row index of a table slot.
*/ row;
/**
* The column index of a table slot.
*/ column;
/**
* The row index of a cell anchor slot.
*/ cellAnchorRow;
/**
* The column index of a cell anchor slot.
*/ cellAnchorColumn;
/**
* The index of the current cell in the parent row.
*/ _cellIndex;
/**
* The index of the current row element in the table.
*/ _rowIndex;
/**
* The table element.
*/ _table;
/**
* Creates an instance of the table walker value.
*
* @param tableWalker The table walker instance.
* @param cell The current table cell.
* @param anchorRow The row index of a cell anchor slot.
* @param anchorColumn The column index of a cell anchor slot.
*/ constructor(tableWalker, cell, anchorRow, anchorColumn){
this.cell = cell;
this.row = tableWalker._row;
this.column = tableWalker._column;
this.cellAnchorRow = anchorRow;
this.cellAnchorColumn = anchorColumn;
this._cellIndex = tableWalker._cellIndex;
this._rowIndex = tableWalker._rowIndex;
this._table = tableWalker._table;
}
// @if CK_DEBUG // public get isSpanned(): unknown { return throwMissingGetterError( 'isSpanned' ); }
// @if CK_DEBUG // public get colspan(): unknown { return throwMissingGetterError( 'colspan' ); }
// @if CK_DEBUG // public get rowspan(): unknown { return throwMissingGetterError( 'rowspan' ); }
// @if CK_DEBUG // public get cellIndex(): unknown { return throwMissingGetterError( 'cellIndex' ); }
/**
* Whether the cell is anchored in the current slot.
*/ get isAnchor() {
return this.row === this.cellAnchorRow && this.column === this.cellAnchorColumn;
}
/**
* The width of a cell defined by a `colspan` attribute. If the model attribute is not present, it is set to `1`.
*/ get cellWidth() {
return parseInt(this.cell.getAttribute('colspan') || '1');
}
/**
* The height of a cell defined by a `rowspan` attribute. If the model attribute is not present, it is set to `1`.
*/ get cellHeight() {
return parseInt(this.cell.getAttribute('rowspan') || '1');
}
/**
* The index of the current row element in the table.
*/ get rowIndex() {
return this._rowIndex;
}
/**
* Returns the {@link module:engine/model/position~Position} before the table slot.
*/ getPositionBefore() {
const model = this._table.root.document.model;
return 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
*/ // @if CK_DEBUG // function throwMissingGetterError( getterName: string ): void {
// @if CK_DEBUG // throw new CKEditorError( 'tableslot-getter-removed', null, {
// @if CK_DEBUG // getterName
// @if CK_DEBUG // } );
// @if CK_DEBUG // }
/**
* Model table element to view table element conversion helper.
*/ function downcastTable(tableUtils, options) {
return (table, { writer })=>{
const headingRows = table.getAttribute('headingRows') || 0;
const tableElement = writer.createContainerElement('table', null, []);
const figureElement = writer.createContainerElement('figure', {
class: 'table'
}, tableElement);
// Table head slot.
if (headingRows > 0) {
writer.insert(writer.createPositionAt(tableElement, 'end'), writer.createContainerElement('thead', null, writer.createSlot((element)=>element.is('element', 'tableRow') && element.index < headingRows)));
}
// Table body slot.
if (headingRows < tableUtils.getRows(table)) {
writer.insert(writer.createPositionAt(tableElement, 'end'), writer.createContainerElement('tbody', null, writer.createSlot((element)=>element.is('element', 'tableRow') && element.index >= headingRows)));
}
// Dynamic slots.
for (const { positionOffset, filter } of options.additionalSlots){
writer.insert(writer.createPositionAt(tableElement, positionOffset), writer.createSlot(filter));
}
// Create a slot with items that don't fit into the table.
writer.insert(writer.createPositionAt(tableElement, 'after'), writer.createSlot((element)=>{
if (element.is('element', 'tableRow')) {
return false;
}
return !options.additionalSlots.some(({ filter })=>filter(element));
}));
return options.asWidget ? toTableWidget(figureElement, writer) : figureElement;
};
}
/**
* Model table row element to view `<tr>` element conversion helper.
*
* @returns Element creator.
*/ function downcastRow() {
return (tableRow, { writer })=>{
return tableRow.isEmpty ? writer.createEmptyElement('tr') : writer.createContainerElement('tr');
};
}
/**
* Model table cell element to view `<td>` or `<th>` element conversion helper.
*
* This conversion helper will create proper `<th>` elements for table cells that are in the heading section (heading row or column)
* and `<td>` otherwise.
*
* @param options.asWidget If set to `true`, the downcast conversion will produce a widget.
* @returns Element creator.
*/ function downcastCell(options = {}) {
return (tableCell, { writer })=>{
const tableRow = tableCell.parent;
const table = tableRow.parent;
const rowIndex = table.getChildIndex(tableRow);
const tableWalker = new TableWalker(table, {
row: rowIndex
});
const headingRows = table.getAttribute('headingRows') || 0;
const headingColumns = table.getAttribute('headingColumns') || 0;
let result = null;
// We need to iterate over a table in order to get proper row & column values from a walker.
for (const tableSlot of tableWalker){
if (tableSlot.cell == tableCell) {
const isHeading = tableSlot.row < headingRows || tableSlot.column < headingColumns;
const cellElementName = isHeading ? 'th' : 'td';
result = options.asWidget ? toWidgetEditable(writer.createEditableElement(cellElementName), writer) : writer.createContainerElement(cellElementName);
break;
}
}
return result;
};
}
/**
* Overrides paragraph inside table cell conversion.
*
* This converter:
* * should be used to override default paragraph conversion.
* * It will only convert `<paragraph>` placed directly inside `<tableCell>`.
* * For a single paragraph without attributes it returns `<span>` to simulate data table.
* * For all other cases it returns `<p>` element.
*
* @param options.asWidget If set to `true`, the downcast conversion will produce a widget.
* @returns Element creator.
*/ function convertParagraphInTableCell(options = {}) {
return (modelElement, { writer })=>{
if (!modelElement.parent.is('element', 'tableCell')) {
return null;
}
if (!isSingleParagraphWithoutAttributes(modelElement)) {
return null;
}
if (options.asWidget) {
return writer.createContainerElement('span', {
class: 'ck-table-bogus-paragraph'
});
} else {
// Using `<p>` in case there are some markers on it and transparentRendering will render it anyway.
const viewElement = writer.createContainerElement('p');
writer.setCustomProperty('dataPipeline:transparentRendering', true, viewElement);
return viewElement;
}
};
}
/**
* Checks if given model `<paragraph>` is an only child of a parent (`<tableCell>`) and if it has any attribute set.
*
* The paragraph should be converted in the editing view to:
*
* * If returned `true` - to a `<span class="ck-table-bogus-paragraph">`
* * If returned `false` - to a `<p>`
*/ function isSingleParagraphWithoutAttributes(modelElement) {
const tableCell = modelElement.parent;
const isSingleParagraph = tableCell.childCount == 1;
return isSingleParagraph && !hasAnyAttribute(modelElement);
}
/**
* Converts a given {@link module:engine/view/element~Element} to a table widget:
* * Adds a {@link module:engine/view/element~Element#_setCustomProperty custom property} allowing to recognize the table widget element.
* * Calls the {@link module:widget/utils~toWidget} function with the proper element's label creator.
*
* @param writer An instance of the view writer.
* @param label The element's label. It will be concatenated with the table `alt` attribute if one is present.
*/ function toTableWidget(viewElement, writer) {
writer.setCustomProperty('table', true, viewElement);
return toWidget(viewElement, writer, {
hasSelectionHandle: true
});
}
/**
* Checks if an element has any attributes set.
*/ function hasAnyAttribute(element) {
for (const attributeKey of element.getAttributeKeys()){
// Ignore selection attributes stored on block elements.
if (attributeKey.startsWith('selection:') || attributeKey == 'htmlEmptyBlock') {
continue;
}
return true;
}
return false;
}
/**
* The insert table command.
*
* The command is registered by {@link module:table/tableediting~TableEditing} as the `'insertTable'` editor command.
*
* To insert a table at the current selection, execute the command and specify the dimensions:
*
* ```ts
* editor.execute( 'insertTable', { rows: 20, columns: 5 } );
* ```
*/ class InsertTableCommand extends Command {
/**
* @inheritDoc
*/ refresh() {
const model = this.editor.model;
const selection = model.document.selection;
const schema = model.schema;
this.isEnabled = isAllowedInParent(selection, schema);
}
/**
* Executes the command.
*
* Inserts a table with the given number of rows and columns into the editor.
*
* @param options.rows The number of rows to create in the inserted table. Default value is 2.
* @param options.columns The number of columns to create in the inserted table. Default value is 2.
* @param options.headingRows The number of heading rows. If not provided it will default to
* {@link module:table/tableconfig~TableConfig#defaultHeadings `config.table.defaultHeadings.rows`} table config.
* @param options.headingColumns The number of heading columns. If not provided it will default to
* {@link module:table/tableconfig~TableConfig#defaultHeadings `config.table.defaultHeadings.columns`} table config.
* @fires execute
*/ execute(options = {}) {
const editor = this.editor;
const model = editor.model;
const tableUtils = editor.plugins.get('TableUtils');
const defaultRows = editor.config.get('table.defaultHeadings.rows');
const defaultColumns = editor.config.get('table.defaultHeadings.columns');
if (options.headingRows === undefined && defaultRows) {
options.headingRows = defaultRows;
}
if (options.headingColumns === undefined && defaultColumns) {
options.headingColumns = defaultColumns;
}
model.change((writer)=>{
const table = tableUtils.createTable(writer, options);
model.insertObject(table, null, null, {
findOptimalPosition: 'auto'
});
writer.setSelection(writer.createPositionAt(table.getNodeByPath([
0,
0,
0
]), 0));
});
}
}
/**
* Checks if the table is allowed in the parent.
*/ function isAllowedInParent(selection, schema) {
const positionParent = selection.getFirstPosition().parent;
const validParent = positionParent === positionParent.root ? positionParent : positionParent.parent;
return schema.checkChild(validParent, 'table');
}
/**
* The insert row command.
*
* The command is registered by {@link module:table/tableediting~TableEditing} as the `'insertTableRowBelow'` and
* `'insertTableRowAbove'` editor commands.
*
* To insert a row below the selected cell, execute the following command:
*
* ```ts
* editor.execute( 'insertTableRowBelow' );
* ```
*
* To insert a row above the selected cell, execute the following command:
*
* ```ts
* editor.execute( 'insertTableRowAbove' );
* ```
*/ class InsertRowCommand extends Command {
/**
* The order of insertion relative to the row in which the caret is located.
*/ order;
/**
* Creates a new `InsertRowCommand` instance.
*
* @param editor The editor on which this command will be used.
* @param options.order The order of insertion relative to the row in which the caret is located.
* Possible values: `"above"` and `"below"`. Default value is "below"
*/ constructor(editor, options = {}){
super(editor);
this.order = options.order || 'below';
}
/**
* @inheritDoc
*/ refresh() {
const selection = this.editor.model.document.selection;
const tableUtils = this.editor.plugins.get('TableUtils');
const isAnyCellSelected = !!tableUtils.getSelectionAffectedTableCells(selection).length;