@ckeditor/ckeditor5-table
Version:
Table feature for CKEditor 5.
379 lines (378 loc) • 17.1 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 { createEmptyTableCell } from '../utils/common.js';
import { getViewTableFromWrapper } from '../utils/structure.js';
import { first } from 'ckeditor5/src/utils.js';
/**
* Returns a function that converts the table view representation:
*
* ```xml
* <figure class="table"><table>...</table></figure>
* ```
*
* to the model representation:
*
* ```xml
* <table></table>
* ```
*
* @internal
*/
export 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 = getViewTableFromWrapper(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 || !modelTable.is('element', 'table')) {
// Revert consumed figure so other features can convert it.
conversionApi.consumable.revert(data.viewItem, { name: true, classes: 'table' });
// If anyway some table content was converted, we have to pass the model range and cursor.
if (conversionResult.modelRange && !conversionResult.modelRange.isCollapsed) {
data.modelRange = conversionResult.modelRange;
data.modelCursor = conversionResult.modelCursor;
}
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.
* @internal
*/
export 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.
*
* @internal
* @returns Conversion helper.
*/
export 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.
*
* @internal
* @returns Conversion helper.
*/
export 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' });
};
}
/**
* 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);
}
// 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);
}
}
// Generate the cell matrix so we can calculate the heading columns.
const bodyMatrix = generateCellMatrix(bodyRows);
for (const rowSlots of bodyMatrix) {
// Look for the first non-`<th>` entry (either a `<td>` or a missing cell).
let index = 0;
while (index < rowSlots.length) {
if (rowSlots[index]?.name !== 'th') {
break;
}
index += 1;
}
// Update headingColumns.
if (!headingColumns || index < headingColumns) {
headingColumns = index;
}
}
return {
headingRows,
headingColumns: headingColumns || 0,
rows: [...headRows, ...bodyRows]
};
}
/**
* Takes an array of `<tr>` elements and generates a "matrix" (square
* two-dimensional array) describing which `<th>`s and `<td>`s fill which
* "slots", factoring in `rowspan`s and `colspan`s. For example, given
*
* ```xml
* <table>
* <tr> <td>11</td> <td rowspan="2">12-22</td> <td>13</td> </tr>
* <tr> <td>21</td> <td>23</td> </tr>
* <tr> <td colspan="2">31-32</td> <td>33</rd> </tr>
* </table>
* ```
*
* The result would be (with cell elements' text content in place of the element
* objects for readability):
*
* ```js
* [
* [ '11', '12-22', '13' ],
* [ '21', '12-22', '23' ],
* [ '31-32', '31-32', '33' ],
* ]
* ```
*
* This allows for a computation of heading columns that factors in the case
* where a cell from a previous rows with a `rowspan` attribute effectively adds
* an additional header cell to a subsequent row.
*
* There are also cases where cells are "missing" from a row. A simple one is
* the case where a row simply has fewer cells than another row in the same
* table. But another is one where a row has a cell with a `rowspan` that
* effectively adds a cell to a subsequent row "off the end" of the row. In this
* case, there will be a `null` value instead of an element object in that
* position. For example,
*
* ```xml
* <table>
* <tr> <td>11</td> <td>12</td> <td rowspan="2">13-23</td> </tr>
* <tr> <td>21</td> </tr>
* <tr> <td>31</td> </tr>
* </table>
* ```
*
* would result in
*
* ```js
* [
* [ '11', '12', '13-23' ],
* [ '21', null, '13-23' ],
* [ '31', null, null ]
* ]
* ```
*
* @param trs the array of `<tr>` elements
* @returns the cell matrix
*/
function generateCellMatrix(trs) {
// As we iterate, we keep track of cells with rowspans >1 so later rows can
// factor them in. This trackes any such cells from previous rows.
let prevRowspans = new Map();
// This is the maximum number of columns we've encountered.
let maxColumns = 0;
const slots = trs.map(tr => {
// This will be the slots that are in this row, including cells from
// previous rows with a big enough "rowspan" to affect this row.
const curSlots = [];
// Get the cell elements
const children = Array.from(tr.getChildren())
.filter(child => child.name === 'th' || child.name === 'td');
// This will be any cells in this row that have a rowspan >1, so we can
// combine it with `prevRowspans` when we're done processing this row.
const curRowspans = new Map();
// We need to process all the cells in this row, but also previous rows'
// cells with rowspans might add additional slots to the end of this row, so
// we need to iterate until we've both consumed all the children _and_
// filled out slots to the max number of columns we've encountered so far.
while (children.length || curSlots.length < maxColumns) {
const rowSpan = prevRowspans.get(curSlots.length);
if (rowSpan && rowSpan.remaining > 0) {
// We have a cell at this index in a previous row whose rowspan extends
// it into this row, so we insert a copy of it here.
curSlots.push(rowSpan.cell);
}
else {
// See if we have more cells in the row.
const cell = children.shift();
if (cell) {
// We do, so process it
const colspan = parseInt(cell.getAttribute('colspan') || '1');
const rowspan = parseInt(cell.getAttribute('rowspan') || '1');
// Process this cell as many times as needed according to its colspan.
for (let i = 0; i < colspan; i++) {
// if we have a >1 rowspan, create a record in the rowSpans map for
// this column index keeping track of it.
if (rowspan > 1) {
curRowspans.set(curSlots.length, { cell, remaining: rowspan - 1 });
}
curSlots.push(cell);
}
}
else {
// No remaining children in this row, so no cell in this slot.
curSlots.push(null);
continue;
}
}
}
// Now update the row spans. In weird edge cases where colspan and rowspan
// conflict, we can end up with a cell in a column in this row that
// "truncates" a row-spanning cell from a previous column, so make sure in
// those cases, the value in `curRowspans` always "wins". We do this by
// copying (and decrementing) values from `prevRowspans` into `curRowspans`
// as long as there is no conflict, and then re-assigning `prevRowspans`.
for (const [index, entry] of prevRowspans.entries()) {
entry.remaining -= 1;
if (entry.remaining > 0 && !curRowspans.has(index)) {
curRowspans.set(index, entry);
}
}
prevRowspans = curRowspans;
// Finally, update `maxColumns`.
maxColumns = Math.max(maxColumns, curSlots.length);
return curSlots;
});
// Now expand any rows that have fewer than `maxColumns` with nulls so we have
// a proper matrix.
for (const rowSlots of slots) {
while (rowSlots.length < maxColumns) {
rowSlots.push(null);
}
}
return slots;
}