UNPKG

@ckeditor/ckeditor5-table

Version:

Table feature for CKEditor 5.

1,191 lines (1,183 loc) • 468 kB
/** * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options */ import { Command, Plugin, 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;