UNPKG

48.9 kBJavaScriptView Raw
1/**
2 * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved.
3 * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
4 */
5/**
6 * @module table/tableutils
7 */
8import { CKEditorError } from 'ckeditor5/src/utils';
9import { Plugin } from 'ckeditor5/src/core';
10import TableWalker from './tablewalker';
11import { createEmptyTableCell, updateNumericAttribute } from './utils/common';
12import { removeEmptyColumns, removeEmptyRows } from './utils/structure';
13/**
14 * The table utilities plugin.
15 */
16export default class TableUtils extends Plugin {
17 /**
18 * @inheritDoc
19 */
20 static get pluginName() {
21 return 'TableUtils';
22 }
23 /**
24 * @inheritDoc
25 */
26 init() {
27 this.decorate('insertColumns');
28 this.decorate('insertRows');
29 }
30 /**
31 * Returns the table cell location as an object with table row and table column indexes.
32 *
33 * For instance, in the table below:
34 *
35 * 0 1 2 3
36 * +---+---+---+---+
37 * 0 | a | b | c |
38 * + + +---+
39 * 1 | | | d |
40 * +---+---+ +---+
41 * 2 | e | | f |
42 * +---+---+---+---+
43 *
44 * the method will return:
45 *
46 * ```ts
47 * const cellA = table.getNodeByPath( [ 0, 0 ] );
48 * editor.plugins.get( 'TableUtils' ).getCellLocation( cellA );
49 * // will return { row: 0, column: 0 }
50 *
51 * const cellD = table.getNodeByPath( [ 1, 0 ] );
52 * editor.plugins.get( 'TableUtils' ).getCellLocation( cellD );
53 * // will return { row: 1, column: 3 }
54 * ```
55 *
56 * @returns Returns a `{row, column}` object.
57 */
58 getCellLocation(tableCell) {
59 const tableRow = tableCell.parent;
60 const table = tableRow.parent;
61 const rowIndex = table.getChildIndex(tableRow);
62 const tableWalker = new TableWalker(table, { row: rowIndex });
63 for (const { cell, row, column } of tableWalker) {
64 if (cell === tableCell) {
65 return { row, column };
66 }
67 }
68 // Should be unreachable code.
69 /* istanbul ignore next -- @preserve */
70 return undefined;
71 }
72 /**
73 * Creates an empty table with a proper structure. The table needs to be inserted into the model,
74 * for example, by using the {@link module:engine/model/model~Model#insertContent} function.
75 *
76 * ```ts
77 * model.change( ( writer ) => {
78 * // Create a table of 2 rows and 7 columns:
79 * const table = tableUtils.createTable( writer, { rows: 2, columns: 7 } );
80 *
81 * // Insert a table to the model at the best position taking the current selection:
82 * model.insertContent( table );
83 * }
84 * ```
85 *
86 * @param writer The model writer.
87 * @param options.rows The number of rows to create. Default value is 2.
88 * @param options.columns The number of columns to create. Default value is 2.
89 * @param options.headingRows The number of heading rows. Default value is 0.
90 * @param options.headingColumns The number of heading columns. Default value is 0.
91 * @returns The created table element.
92 */
93 createTable(writer, options) {
94 const table = writer.createElement('table');
95 const rows = options.rows || 2;
96 const columns = options.columns || 2;
97 createEmptyRows(writer, table, 0, rows, columns);
98 if (options.headingRows) {
99 updateNumericAttribute('headingRows', Math.min(options.headingRows, rows), table, writer, 0);
100 }
101 if (options.headingColumns) {
102 updateNumericAttribute('headingColumns', Math.min(options.headingColumns, columns), table, writer, 0);
103 }
104 return table;
105 }
106 /**
107 * Inserts rows into a table.
108 *
109 * ```ts
110 * editor.plugins.get( 'TableUtils' ).insertRows( table, { at: 1, rows: 2 } );
111 * ```
112 *
113 * Assuming the table on the left, the above code will transform it to the table on the right:
114 *
115 * row index
116 * 0 +---+---+---+ `at` = 1, +---+---+---+ 0
117 * | a | b | c | `rows` = 2, | a | b | c |
118 * 1 + +---+---+ <-- insert here + +---+---+ 1
119 * | | d | e | | | | |
120 * 2 + +---+---+ will give: + +---+---+ 2
121 * | | f | g | | | | |
122 * 3 +---+---+---+ + +---+---+ 3
123 * | | d | e |
124 * + +---+---+ 4
125 * + + f | g |
126 * +---+---+---+ 5
127 *
128 * @param table The table model element where the rows will be inserted.
129 * @param options.at The row index at which the rows will be inserted. Default value is 0.
130 * @param options.rows The number of rows to insert. Default value is 1.
131 * @param options.copyStructureFromAbove The flag for copying row structure. Note that
132 * the row structure will not be copied if this option is not provided.
133 */
134 insertRows(table, options = {}) {
135 const model = this.editor.model;
136 const insertAt = options.at || 0;
137 const rowsToInsert = options.rows || 1;
138 const isCopyStructure = options.copyStructureFromAbove !== undefined;
139 const copyStructureFrom = options.copyStructureFromAbove ? insertAt - 1 : insertAt;
140 const rows = this.getRows(table);
141 const columns = this.getColumns(table);
142 if (insertAt > rows) {
143 /**
144 * The `options.at` points at a row position that does not exist.
145 *
146 * @error tableutils-insertrows-insert-out-of-range
147 */
148 throw new CKEditorError('tableutils-insertrows-insert-out-of-range', this, { options });
149 }
150 model.change(writer => {
151 const headingRows = table.getAttribute('headingRows') || 0;
152 // Inserting rows inside heading section requires to update `headingRows` attribute as the heading section will grow.
153 if (headingRows > insertAt) {
154 updateNumericAttribute('headingRows', headingRows + rowsToInsert, table, writer, 0);
155 }
156 // Inserting at the end or at the beginning of a table doesn't require to calculate anything special.
157 if (!isCopyStructure && (insertAt === 0 || insertAt === rows)) {
158 createEmptyRows(writer, table, insertAt, rowsToInsert, columns);
159 return;
160 }
161 // Iterate over all the rows above the inserted rows in order to check for the row-spanned cells.
162 const walkerEndRow = isCopyStructure ? Math.max(insertAt, copyStructureFrom) : insertAt;
163 const tableIterator = new TableWalker(table, { endRow: walkerEndRow });
164 // Store spans of the reference row to reproduce it's structure. This array is column number indexed.
165 const rowColSpansMap = new Array(columns).fill(1);
166 for (const { row, column, cellHeight, cellWidth, cell } of tableIterator) {
167 const lastCellRow = row + cellHeight - 1;
168 const isOverlappingInsertedRow = row < insertAt && insertAt <= lastCellRow;
169 const isReferenceRow = row <= copyStructureFrom && copyStructureFrom <= lastCellRow;
170 // If the cell is row-spanned and overlaps the inserted row, then reserve space for it in the row map.
171 if (isOverlappingInsertedRow) {
172 // This cell overlaps the inserted rows so we need to expand it further.
173 writer.setAttribute('rowspan', cellHeight + rowsToInsert, cell);
174 // Mark this cell with negative number to indicate how many cells should be skipped when adding the new cells.
175 rowColSpansMap[column] = -cellWidth;
176 }
177 // Store the colspan from reference row.
178 else if (isCopyStructure && isReferenceRow) {
179 rowColSpansMap[column] = cellWidth;
180 }
181 }
182 for (let rowIndex = 0; rowIndex < rowsToInsert; rowIndex++) {
183 const tableRow = writer.createElement('tableRow');
184 writer.insert(tableRow, table, insertAt);
185 for (let cellIndex = 0; cellIndex < rowColSpansMap.length; cellIndex++) {
186 const colspan = rowColSpansMap[cellIndex];
187 const insertPosition = writer.createPositionAt(tableRow, 'end');
188 // Insert the empty cell only if this slot is not row-spanned from any other cell.
189 if (colspan > 0) {
190 createEmptyTableCell(writer, insertPosition, colspan > 1 ? { colspan } : undefined);
191 }
192 // Skip the col-spanned slots, there won't be any cells.
193 cellIndex += Math.abs(colspan) - 1;
194 }
195 }
196 });
197 }
198 /**
199 * Inserts columns into a table.
200 *
201 * ```ts
202 * editor.plugins.get( 'TableUtils' ).insertColumns( table, { at: 1, columns: 2 } );
203 * ```
204 *
205 * Assuming the table on the left, the above code will transform it to the table on the right:
206 *
207 * 0 1 2 3 0 1 2 3 4 5
208 * +---+---+---+ +---+---+---+---+---+
209 * | a | b | | a | b |
210 * + +---+ + +---+
211 * | | c | | | c |
212 * +---+---+---+ will give: +---+---+---+---+---+
213 * | d | e | f | | d | | | e | f |
214 * +---+ +---+ +---+---+---+ +---+
215 * | g | | h | | g | | | | h |
216 * +---+---+---+ +---+---+---+---+---+
217 * | i | | i |
218 * +---+---+---+ +---+---+---+---+---+
219 * ^---- insert here, `at` = 1, `columns` = 2
220 *
221 * @param table The table model element where the columns will be inserted.
222 * @param options.at The column index at which the columns will be inserted. Default value is 0.
223 * @param options.columns The number of columns to insert. Default value is 1.
224 */
225 insertColumns(table, options = {}) {
226 const model = this.editor.model;
227 const insertAt = options.at || 0;
228 const columnsToInsert = options.columns || 1;
229 model.change(writer => {
230 const headingColumns = table.getAttribute('headingColumns');
231 // Inserting columns inside heading section requires to update `headingColumns` attribute as the heading section will grow.
232 if (insertAt < headingColumns) {
233 writer.setAttribute('headingColumns', headingColumns + columnsToInsert, table);
234 }
235 const tableColumns = this.getColumns(table);
236 // Inserting at the end and at the beginning of a table doesn't require to calculate anything special.
237 if (insertAt === 0 || tableColumns === insertAt) {
238 for (const tableRow of table.getChildren()) {
239 // Ignore non-row elements inside the table (e.g. caption).
240 if (!tableRow.is('element', 'tableRow')) {
241 continue;
242 }
243 createCells(columnsToInsert, writer, writer.createPositionAt(tableRow, insertAt ? 'end' : 0));
244 }
245 return;
246 }
247 const tableWalker = new TableWalker(table, { column: insertAt, includeAllSlots: true });
248 for (const tableSlot of tableWalker) {
249 const { row, cell, cellAnchorColumn, cellAnchorRow, cellWidth, cellHeight } = tableSlot;
250 // When iterating over column the table walker outputs either:
251 // - cells at given column index (cell "e" from method docs),
252 // - spanned columns (spanned cell from row between cells "g" and "h" - spanned by "e", only if `includeAllSlots: true`),
253 // - or a cell from the same row which spans over this column (cell "a").
254 if (cellAnchorColumn < insertAt) {
255 // If cell is anchored in previous column, it is a cell that spans over an inserted column (cell "a" & "i").
256 // For such cells expand them by a number of columns inserted.
257 writer.setAttribute('colspan', cellWidth + columnsToInsert, cell);
258 // This cell will overlap cells in rows below so skip them (because of `includeAllSlots` option) - (cell "a")
259 const lastCellRow = cellAnchorRow + cellHeight - 1;
260 for (let i = row; i <= lastCellRow; i++) {
261 tableWalker.skipRow(i);
262 }
263 }
264 else {
265 // It's either cell at this column index or spanned cell by a row-spanned cell from row above.
266 // In table above it's cell "e" and a spanned position from row below (empty cell between cells "g" and "h")
267 createCells(columnsToInsert, writer, tableSlot.getPositionBefore());
268 }
269 }
270 });
271 }
272 /**
273 * Removes rows from the given `table`.
274 *
275 * This method re-calculates the table geometry including `rowspan` attribute of table cells overlapping removed rows
276 * and table headings values.
277 *
278 * ```ts
279 * editor.plugins.get( 'TableUtils' ).removeRows( table, { at: 1, rows: 2 } );
280 * ```
281 *
282 * Executing the above code in the context of the table on the left will transform its structure as presented on the right:
283 *
284 * row index
285 * ┌───┬───┬───┐ `at` = 1 ┌───┬───┬───┐
286 * 0 │ a │ b │ c │ `rows` = 2 │ a │ b │ c │ 0
287 * │ ├───┼───┤ │ ├───┼───┤
288 * 1 │ │ d │ e │ <-- remove from here │ │ d │ g │ 1
289 * │ │ ├───┤ will give: ├───┼───┼───┤
290 * 2 │ │ │ f │ │ h │ i │ j │ 2
291 * │ │ ├───┤ └───┴───┴───┘
292 * 3 │ │ │ g │
293 * ├───┼───┼───┤
294 * 4 │ h │ i │ j │
295 * └───┴───┴───┘
296 *
297 * @param options.at The row index at which the removing rows will start.
298 * @param options.rows The number of rows to remove. Default value is 1.
299 */
300 removeRows(table, options) {
301 const model = this.editor.model;
302 const rowsToRemove = options.rows || 1;
303 const rowCount = this.getRows(table);
304 const first = options.at;
305 const last = first + rowsToRemove - 1;
306 if (last > rowCount - 1) {
307 /**
308 * The `options.at` param must point at existing row and `options.rows` must not exceed the rows in the table.
309 *
310 * @error tableutils-removerows-row-index-out-of-range
311 */
312 throw new CKEditorError('tableutils-removerows-row-index-out-of-range', this, { table, options });
313 }
314 model.change(writer => {
315 const indexesObject = { first, last };
316 // Removing rows from the table require that most calculations to be done prior to changing table structure.
317 // Preparations must be done in the same enqueueChange callback to use the current table structure.
318 // 1. Preparation - get row-spanned cells that have to be modified after removing rows.
319 const { cellsToMove, cellsToTrim } = getCellsToMoveAndTrimOnRemoveRow(table, indexesObject);
320 // 2. Execution
321 // 2a. Move cells from removed rows that extends over a removed section - must be done before removing rows.
322 // This will fill any gaps in a rows below that previously were empty because of row-spanned cells.
323 if (cellsToMove.size) {
324 const rowAfterRemovedSection = last + 1;
325 moveCellsToRow(table, rowAfterRemovedSection, cellsToMove, writer);
326 }
327 // 2b. Remove all required rows.
328 for (let i = last; i >= first; i--) {
329 writer.remove(table.getChild(i));
330 }
331 // 2c. Update cells from rows above that overlap removed section. Similar to step 2 but does not involve moving cells.
332 for (const { rowspan, cell } of cellsToTrim) {
333 updateNumericAttribute('rowspan', rowspan, cell, writer);
334 }
335 // 2d. Adjust heading rows if removed rows were in a heading section.
336 updateHeadingRows(table, indexesObject, writer);
337 // 2e. Remove empty columns (without anchored cells) if there are any.
338 if (!removeEmptyColumns(table, this)) {
339 // If there wasn't any empty columns then we still need to check if this wasn't called
340 // because of cleaning empty rows and we only removed one of them.
341 removeEmptyRows(table, this);
342 }
343 });
344 }
345 /**
346 * Removes columns from the given `table`.
347 *
348 * This method re-calculates the table geometry including the `colspan` attribute of table cells overlapping removed columns
349 * and table headings values.
350 *
351 * ```ts
352 * editor.plugins.get( 'TableUtils' ).removeColumns( table, { at: 1, columns: 2 } );
353 * ```
354 *
355 * Executing the above code in the context of the table on the left will transform its structure as presented on the right:
356 *
357 * 0 1 2 3 4 0 1 2
358 * ┌───────────────┬───┐ ┌───────┬───┐
359 * │ a │ b │ │ a │ b │
360 * │ ├───┤ │ ├───┤
361 * │ │ c │ │ │ c │
362 * ├───┬───┬───┬───┼───┤ will give: ├───┬───┼───┤
363 * │ d │ e │ f │ g │ h │ │ d │ g │ h │
364 * ├───┼───┼───┤ ├───┤ ├───┤ ├───┤
365 * │ i │ j │ k │ │ l │ │ i │ │ l │
366 * ├───┴───┴───┴───┴───┤ ├───┴───┴───┤
367 * │ m │ │ m │
368 * └───────────────────┘ └───────────┘
369 * ^---- remove from here, `at` = 1, `columns` = 2
370 *
371 * @param options.at The row index at which the removing columns will start.
372 * @param options.columns The number of columns to remove.
373 */
374 removeColumns(table, options) {
375 const model = this.editor.model;
376 const first = options.at;
377 const columnsToRemove = options.columns || 1;
378 const last = options.at + columnsToRemove - 1;
379 model.change(writer => {
380 adjustHeadingColumns(table, { first, last }, writer);
381 for (let removedColumnIndex = last; removedColumnIndex >= first; removedColumnIndex--) {
382 for (const { cell, column, cellWidth } of [...new TableWalker(table)]) {
383 // If colspaned cell overlaps removed column decrease its span.
384 if (column <= removedColumnIndex && cellWidth > 1 && column + cellWidth > removedColumnIndex) {
385 updateNumericAttribute('colspan', cellWidth - 1, cell, writer);
386 }
387 else if (column === removedColumnIndex) {
388 // The cell in removed column has colspan of 1.
389 writer.remove(cell);
390 }
391 }
392 }
393 // Remove empty rows that could appear after removing columns.
394 if (!removeEmptyRows(table, this)) {
395 // If there wasn't any empty rows then we still need to check if this wasn't called
396 // because of cleaning empty columns and we only removed one of them.
397 removeEmptyColumns(table, this);
398 }
399 });
400 }
401 /**
402 * Divides a table cell vertically into several ones.
403 *
404 * The cell will be visually split into more cells by updating colspans of other cells in a column
405 * and inserting cells (columns) after that cell.
406 *
407 * In the table below, if cell "a" is split into 3 cells:
408 *
409 * +---+---+---+
410 * | a | b | c |
411 * +---+---+---+
412 * | d | e | f |
413 * +---+---+---+
414 *
415 * it will result in the table below:
416 *
417 * +---+---+---+---+---+
418 * | a | | | b | c |
419 * +---+---+---+---+---+
420 * | d | e | f |
421 * +---+---+---+---+---+
422 *
423 * So cell "d" will get its `colspan` updated to `3` and 2 cells will be added (2 columns will be created).
424 *
425 * Splitting a cell that already has a `colspan` attribute set will distribute the cell `colspan` evenly and the remainder
426 * will be left to the original cell:
427 *
428 * +---+---+---+
429 * | a |
430 * +---+---+---+
431 * | b | c | d |
432 * +---+---+---+
433 *
434 * Splitting cell "a" with `colspan=3` into 2 cells will create 1 cell with a `colspan=a` and cell "a" that will have `colspan=2`:
435 *
436 * +---+---+---+
437 * | a | |
438 * +---+---+---+
439 * | b | c | d |
440 * +---+---+---+
441 */
442 splitCellVertically(tableCell, numberOfCells = 2) {
443 const model = this.editor.model;
444 const tableRow = tableCell.parent;
445 const table = tableRow.parent;
446 const rowspan = parseInt(tableCell.getAttribute('rowspan') || '1');
447 const colspan = parseInt(tableCell.getAttribute('colspan') || '1');
448 model.change(writer => {
449 // First check - the cell spans over multiple rows so before doing anything else just split this cell.
450 if (colspan > 1) {
451 // Get spans of new (inserted) cells and span to update of split cell.
452 const { newCellsSpan, updatedSpan } = breakSpanEvenly(colspan, numberOfCells);
453 updateNumericAttribute('colspan', updatedSpan, tableCell, writer);
454 // Each inserted cell will have the same attributes:
455 const newCellsAttributes = {};
456 // Do not store default value in the model.
457 if (newCellsSpan > 1) {
458 newCellsAttributes.colspan = newCellsSpan;
459 }
460 // Copy rowspan of split cell.
461 if (rowspan > 1) {
462 newCellsAttributes.rowspan = rowspan;
463 }
464 const cellsToInsert = colspan > numberOfCells ? numberOfCells - 1 : colspan - 1;
465 createCells(cellsToInsert, writer, writer.createPositionAfter(tableCell), newCellsAttributes);
466 }
467 // Second check - the cell has colspan of 1 or we need to create more cells then the currently one spans over.
468 if (colspan < numberOfCells) {
469 const cellsToInsert = numberOfCells - colspan;
470 // First step: expand cells on the same column as split cell.
471 const tableMap = [...new TableWalker(table)];
472 // Get the column index of split cell.
473 const { column: splitCellColumn } = tableMap.find(({ cell }) => cell === tableCell);
474 // Find cells which needs to be expanded vertically - those on the same column or those that spans over split cell's column.
475 const cellsToUpdate = tableMap.filter(({ cell, cellWidth, column }) => {
476 const isOnSameColumn = cell !== tableCell && column === splitCellColumn;
477 const spansOverColumn = (column < splitCellColumn && column + cellWidth > splitCellColumn);
478 return isOnSameColumn || spansOverColumn;
479 });
480 // Expand cells vertically.
481 for (const { cell, cellWidth } of cellsToUpdate) {
482 writer.setAttribute('colspan', cellWidth + cellsToInsert, cell);
483 }
484 // Second step: create columns after split cell.
485 // Each inserted cell will have the same attributes:
486 const newCellsAttributes = {};
487 // Do not store default value in the model.
488 // Copy rowspan of split cell.
489 if (rowspan > 1) {
490 newCellsAttributes.rowspan = rowspan;
491 }
492 createCells(cellsToInsert, writer, writer.createPositionAfter(tableCell), newCellsAttributes);
493 const headingColumns = table.getAttribute('headingColumns') || 0;
494 // Update heading section if split cell is in heading section.
495 if (headingColumns > splitCellColumn) {
496 updateNumericAttribute('headingColumns', headingColumns + cellsToInsert, table, writer);
497 }
498 }
499 });
500 }
501 /**
502 * Divides a table cell horizontally into several ones.
503 *
504 * The cell will be visually split into more cells by updating rowspans of other cells in the row and inserting rows with a single cell
505 * below.
506 *
507 * If in the table below cell "b" is split into 3 cells:
508 *
509 * +---+---+---+
510 * | a | b | c |
511 * +---+---+---+
512 * | d | e | f |
513 * +---+---+---+
514 *
515 * It will result in the table below:
516 *
517 * +---+---+---+
518 * | a | b | c |
519 * + +---+ +
520 * | | | |
521 * + +---+ +
522 * | | | |
523 * +---+---+---+
524 * | d | e | f |
525 * +---+---+---+
526 *
527 * So cells "a" and "b" will get their `rowspan` updated to `3` and 2 rows with a single cell will be added.
528 *
529 * Splitting a cell that already has a `rowspan` attribute set will distribute the cell `rowspan` evenly and the remainder
530 * will be left to the original cell:
531 *
532 * +---+---+---+
533 * | a | b | c |
534 * + +---+---+
535 * | | d | e |
536 * + +---+---+
537 * | | f | g |
538 * + +---+---+
539 * | | h | i |
540 * +---+---+---+
541 *
542 * Splitting cell "a" with `rowspan=4` into 3 cells will create 2 cells with a `rowspan=1` and cell "a" will have `rowspan=2`:
543 *
544 * +---+---+---+
545 * | a | b | c |
546 * + +---+---+
547 * | | d | e |
548 * +---+---+---+
549 * | | f | g |
550 * +---+---+---+
551 * | | h | i |
552 * +---+---+---+
553 */
554 splitCellHorizontally(tableCell, numberOfCells = 2) {
555 const model = this.editor.model;
556 const tableRow = tableCell.parent;
557 const table = tableRow.parent;
558 const splitCellRow = table.getChildIndex(tableRow);
559 const rowspan = parseInt(tableCell.getAttribute('rowspan') || '1');
560 const colspan = parseInt(tableCell.getAttribute('colspan') || '1');
561 model.change(writer => {
562 // First check - the cell spans over multiple rows so before doing anything else just split this cell.
563 if (rowspan > 1) {
564 // Cache table map before updating table.
565 const tableMap = [...new TableWalker(table, {
566 startRow: splitCellRow,
567 endRow: splitCellRow + rowspan - 1,
568 includeAllSlots: true
569 })];
570 // Get spans of new (inserted) cells and span to update of split cell.
571 const { newCellsSpan, updatedSpan } = breakSpanEvenly(rowspan, numberOfCells);
572 updateNumericAttribute('rowspan', updatedSpan, tableCell, writer);
573 const { column: cellColumn } = tableMap.find(({ cell }) => cell === tableCell);
574 // Each inserted cell will have the same attributes:
575 const newCellsAttributes = {};
576 // Do not store default value in the model.
577 if (newCellsSpan > 1) {
578 newCellsAttributes.rowspan = newCellsSpan;
579 }
580 // Copy colspan of split cell.
581 if (colspan > 1) {
582 newCellsAttributes.colspan = colspan;
583 }
584 for (const tableSlot of tableMap) {
585 const { column, row } = tableSlot;
586 // As both newly created cells and the split cell might have rowspan,
587 // the insertion of new cells must go to appropriate rows:
588 //
589 // 1. It's a row after split cell + it's height.
590 const isAfterSplitCell = row >= splitCellRow + updatedSpan;
591 // 2. Is on the same column.
592 const isOnSameColumn = column === cellColumn;
593 // 3. And it's row index is after previous cell height.
594 const isInEvenlySplitRow = (row + splitCellRow + updatedSpan) % newCellsSpan === 0;
595 if (isAfterSplitCell && isOnSameColumn && isInEvenlySplitRow) {
596 createCells(1, writer, tableSlot.getPositionBefore(), newCellsAttributes);
597 }
598 }
599 }
600 // Second check - the cell has rowspan of 1 or we need to create more cells than the current cell spans over.
601 if (rowspan < numberOfCells) {
602 // We already split the cell in check one so here we split to the remaining number of cells only.
603 const cellsToInsert = numberOfCells - rowspan;
604 // This check is needed since we need to check if there are any cells from previous rows than spans over this cell's row.
605 const tableMap = [...new TableWalker(table, { startRow: 0, endRow: splitCellRow })];
606 // First step: expand cells.
607 for (const { cell, cellHeight, row } of tableMap) {
608 // Expand rowspan of cells that are either:
609 // - on the same row as current cell,
610 // - or are below split cell row and overlaps that row.
611 if (cell !== tableCell && row + cellHeight > splitCellRow) {
612 const rowspanToSet = cellHeight + cellsToInsert;
613 writer.setAttribute('rowspan', rowspanToSet, cell);
614 }
615 }
616 // Second step: create rows with single cell below split cell.
617 const newCellsAttributes = {};
618 // Copy colspan of split cell.
619 if (colspan > 1) {
620 newCellsAttributes.colspan = colspan;
621 }
622 createEmptyRows(writer, table, splitCellRow + 1, cellsToInsert, 1, newCellsAttributes);
623 // Update heading section if split cell is in heading section.
624 const headingRows = table.getAttribute('headingRows') || 0;
625 if (headingRows > splitCellRow) {
626 updateNumericAttribute('headingRows', headingRows + cellsToInsert, table, writer);
627 }
628 }
629 });
630 }
631 /**
632 * Returns the number of columns for a given table.
633 *
634 * ```ts
635 * editor.plugins.get( 'TableUtils' ).getColumns( table );
636 * ```
637 *
638 * @param table The table to analyze.
639 */
640 getColumns(table) {
641 // Analyze first row only as all the rows should have the same width.
642 // Using the first row without checking if it's a tableRow because we expect
643 // that table will have only tableRow model elements at the beginning.
644 const row = table.getChild(0);
645 return [...row.getChildren()].reduce((columns, row) => {
646 const columnWidth = parseInt(row.getAttribute('colspan') || '1');
647 return columns + columnWidth;
648 }, 0);
649 }
650 /**
651 * Returns the number of rows for a given table. Any other element present in the table model is omitted.
652 *
653 * ```ts
654 * editor.plugins.get( 'TableUtils' ).getRows( table );
655 * ```
656 *
657 * @param table The table to analyze.
658 */
659 getRows(table) {
660 // Rowspan not included due to #6427.
661 return Array.from(table.getChildren())
662 .reduce((rowCount, child) => child.is('element', 'tableRow') ? rowCount + 1 : rowCount, 0);
663 }
664 /**
665 * Creates an instance of the table walker.
666 *
667 * The table walker iterates internally by traversing the table from row index = 0 and column index = 0.
668 * It walks row by row and column by column in order to output values defined in the options.
669 * By default it will output only the locations that are occupied by a cell. To include also spanned rows and columns,
670 * pass the `includeAllSlots` option.
671 *
672 * @internal
673 * @param table A table over which the walker iterates.
674 * @param options An object with configuration.
675 */
676 createTableWalker(table, options = {}) {
677 return new TableWalker(table, options);
678 }
679 /**
680 * Returns all model table cells that are fully selected (from the outside)
681 * within the provided model selection's ranges.
682 *
683 * To obtain the cells selected from the inside, use
684 * {@link #getTableCellsContainingSelection}.
685 */
686 getSelectedTableCells(selection) {
687 const cells = [];
688 for (const range of this.sortRanges(selection.getRanges())) {
689 const element = range.getContainedElement();
690 if (element && element.is('element', 'tableCell')) {
691 cells.push(element);
692 }
693 }
694 return cells;
695 }
696 /**
697 * Returns all model table cells that the provided model selection's ranges
698 * {@link module:engine/model/range~Range#start} inside.
699 *
700 * To obtain the cells selected from the outside, use
701 * {@link #getSelectedTableCells}.
702 */
703 getTableCellsContainingSelection(selection) {
704 const cells = [];
705 for (const range of selection.getRanges()) {
706 const cellWithSelection = range.start.findAncestor('tableCell');
707 if (cellWithSelection) {
708 cells.push(cellWithSelection);
709 }
710 }
711 return cells;
712 }
713 /**
714 * Returns all model table cells that are either completely selected
715 * by selection ranges or host selection range
716 * {@link module:engine/model/range~Range#start start positions} inside them.
717 *
718 * Combines {@link #getTableCellsContainingSelection} and
719 * {@link #getSelectedTableCells}.
720 */
721 getSelectionAffectedTableCells(selection) {
722 const selectedCells = this.getSelectedTableCells(selection);
723 if (selectedCells.length) {
724 return selectedCells;
725 }
726 return this.getTableCellsContainingSelection(selection);
727 }
728 /**
729 * Returns an object with the `first` and `last` row index contained in the given `tableCells`.
730 *
731 * ```ts
732 * const selectedTableCells = getSelectedTableCells( editor.model.document.selection );
733 *
734 * const { first, last } = getRowIndexes( selectedTableCells );
735 *
736 * console.log( `Selected rows: ${ first } to ${ last }` );
737 * ```
738 *
739 * @returns Returns an object with the `first` and `last` table row indexes.
740 */
741 getRowIndexes(tableCells) {
742 const indexes = tableCells.map(cell => cell.parent.index);
743 return this._getFirstLastIndexesObject(indexes);
744 }
745 /**
746 * Returns an object with the `first` and `last` column index contained in the given `tableCells`.
747 *
748 * ```ts
749 * const selectedTableCells = getSelectedTableCells( editor.model.document.selection );
750 *
751 * const { first, last } = getColumnIndexes( selectedTableCells );
752 *
753 * console.log( `Selected columns: ${ first } to ${ last }` );
754 * ```
755 *
756 * @returns Returns an object with the `first` and `last` table column indexes.
757 */
758 getColumnIndexes(tableCells) {
759 const table = tableCells[0].findAncestor('table');
760 const tableMap = [...new TableWalker(table)];
761 const indexes = tableMap
762 .filter(entry => tableCells.includes(entry.cell))
763 .map(entry => entry.column);
764 return this._getFirstLastIndexesObject(indexes);
765 }
766 /**
767 * Checks if the selection contains cells that do not exceed rectangular selection.
768 *
769 * In a table below:
770 *
771 * ┌───┬───┬───┬───┐
772 * │ a │ b │ c │ d │
773 * ├───┴───┼───┤ │
774 * │ e │ f │ │
775 * │ ├───┼───┤
776 * │ │ g │ h │
777 * └───────┴───┴───┘
778 *
779 * Valid selections are these which create a solid rectangle (without gaps), such as:
780 * - a, b (two horizontal cells)
781 * - c, f (two vertical cells)
782 * - a, b, e (cell "e" spans over four cells)
783 * - c, d, f (cell d spans over a cell in the row below)
784 *
785 * While an invalid selection would be:
786 * - a, c (the unselected cell "b" creates a gap)
787 * - f, g, h (cell "d" spans over a cell from the row of "f" cell - thus creates a gap)
788 */
789 isSelectionRectangular(selectedTableCells) {
790 if (selectedTableCells.length < 2 || !this._areCellInTheSameTableSection(selectedTableCells)) {
791 return false;
792 }
793 // A valid selection is a fully occupied rectangle composed of table cells.
794 // Below we will calculate the area of a selected table cells and the area of valid selection.
795 // The area of a valid selection is defined by top-left and bottom-right cells.
796 const rows = new Set();
797 const columns = new Set();
798 let areaOfSelectedCells = 0;
799 for (const tableCell of selectedTableCells) {
800 const { row, column } = this.getCellLocation(tableCell);
801 const rowspan = parseInt(tableCell.getAttribute('rowspan')) || 1;
802 const colspan = parseInt(tableCell.getAttribute('colspan')) || 1;
803 // Record row & column indexes of current cell.
804 rows.add(row);
805 columns.add(column);
806 // For cells that spans over multiple rows add also the last row that this cell spans over.
807 if (rowspan > 1) {
808 rows.add(row + rowspan - 1);
809 }
810 // For cells that spans over multiple columns add also the last column that this cell spans over.
811 if (colspan > 1) {
812 columns.add(column + colspan - 1);
813 }
814 areaOfSelectedCells += (rowspan * colspan);
815 }
816 // We can only merge table cells that are in adjacent rows...
817 const areaOfValidSelection = getBiggestRectangleArea(rows, columns);
818 return areaOfValidSelection == areaOfSelectedCells;
819 }
820 /**
821 * Returns array of sorted ranges.
822 */
823 sortRanges(ranges) {
824 return Array.from(ranges).sort(compareRangeOrder);
825 }
826 /**
827 * Helper method to get an object with `first` and `last` indexes from an unsorted array of indexes.
828 */
829 _getFirstLastIndexesObject(indexes) {
830 const allIndexesSorted = indexes.sort((indexA, indexB) => indexA - indexB);
831 const first = allIndexesSorted[0];
832 const last = allIndexesSorted[allIndexesSorted.length - 1];
833 return { first, last };
834 }
835 /**
836 * Checks if the selection does not mix a header (column or row) with other cells.
837 *
838 * For instance, in the table below valid selections consist of cells with the same letter only.
839 * So, a-a (same heading row and column) or d-d (body cells) are valid while c-d or a-b are not.
840 *
841 * header columns
842 * ↓ ↓
843 * ┌───┬───┬───┬───┐
844 * │ a │ a │ b │ b │ ← header row
845 * ├───┼───┼───┼───┤
846 * │ c │ c │ d │ d │
847 * ├───┼───┼───┼───┤
848 * │ c │ c │ d │ d │
849 * └───┴───┴───┴───┘
850 */
851 _areCellInTheSameTableSection(tableCells) {
852 const table = tableCells[0].findAncestor('table');
853 const rowIndexes = this.getRowIndexes(tableCells);
854 const headingRows = parseInt(table.getAttribute('headingRows')) || 0;
855 // Calculating row indexes is a bit cheaper so if this check fails we can't merge.
856 if (!this._areIndexesInSameSection(rowIndexes, headingRows)) {
857 return false;
858 }
859 const columnIndexes = this.getColumnIndexes(tableCells);
860 const headingColumns = parseInt(table.getAttribute('headingColumns')) || 0;
861 // Similarly cells must be in same column section.
862 return this._areIndexesInSameSection(columnIndexes, headingColumns);
863 }
864 /**
865 * Unified check if table rows/columns indexes are in the same heading/body section.
866 */
867 _areIndexesInSameSection({ first, last }, headingSectionSize) {
868 const firstCellIsInHeading = first < headingSectionSize;
869 const lastCellIsInHeading = last < headingSectionSize;
870 return firstCellIsInHeading === lastCellIsInHeading;
871 }
872}
873/**
874 * Creates empty rows at the given index in an existing table.
875 *
876 * @param insertAt The row index of row insertion.
877 * @param rows The number of rows to create.
878 * @param tableCellToInsert The number of cells to insert in each row.
879 */
880function createEmptyRows(writer, table, insertAt, rows, tableCellToInsert, attributes = {}) {
881 for (let i = 0; i < rows; i++) {
882 const tableRow = writer.createElement('tableRow');
883 writer.insert(tableRow, table, insertAt);
884 createCells(tableCellToInsert, writer, writer.createPositionAt(tableRow, 'end'), attributes);
885 }
886}
887/**
888 * Creates cells at a given position.
889 *
890 * @param cells The number of cells to create
891 */
892function createCells(cells, writer, insertPosition, attributes = {}) {
893 for (let i = 0; i < cells; i++) {
894 createEmptyTableCell(writer, insertPosition, attributes);
895 }
896}
897/**
898 * Evenly distributes the span of a cell to a number of provided cells.
899 * The resulting spans will always be integer values.
900 *
901 * For instance breaking a span of 7 into 3 cells will return:
902 *
903 * ```ts
904 * { newCellsSpan: 2, updatedSpan: 3 }
905 * ```
906 *
907 * as two cells will have a span of 2 and the remainder will go the first cell so its span will change to 3.
908 *
909 * @param span The span value do break.
910 * @param numberOfCells The number of resulting spans.
911 */
912function breakSpanEvenly(span, numberOfCells) {
913 if (span < numberOfCells) {
914 return { newCellsSpan: 1, updatedSpan: 1 };
915 }
916 const newCellsSpan = Math.floor(span / numberOfCells);
917 const updatedSpan = (span - newCellsSpan * numberOfCells) + newCellsSpan;
918 return { newCellsSpan, updatedSpan };
919}
920/**
921 * Updates heading columns attribute if removing a row from head section.
922 */
923function adjustHeadingColumns(table, removedColumnIndexes, writer) {
924 const headingColumns = table.getAttribute('headingColumns') || 0;
925 if (headingColumns && removedColumnIndexes.first < headingColumns) {
926 const headingsRemoved = Math.min(headingColumns - 1 /* Other numbers are 0-based */, removedColumnIndexes.last) -
927 removedColumnIndexes.first + 1;
928 writer.setAttribute('headingColumns', headingColumns - headingsRemoved, table);
929 }
930}
931/**
932 * Calculates a new heading rows value for removing rows from heading section.
933 */
934function updateHeadingRows(table, { first, last }, writer) {
935 const headingRows = table.getAttribute('headingRows') || 0;
936 if (first < headingRows) {
937 const newRows = last < headingRows ? headingRows - (last - first + 1) : first;
938 updateNumericAttribute('headingRows', newRows, table, writer, 0);
939 }
940}
941/**
942 * Finds cells that will be:
943 * - trimmed - Cells that are "above" removed rows sections and overlap the removed section - their rowspan must be trimmed.
944 * - moved - Cells from removed rows section might stick out of. These cells are moved to the next row after a removed section.
945 *
946 * Sample table with overlapping & sticking out cells:
947 *
948 * +----+----+----+----+----+
949 * | 00 | 01 | 02 | 03 | 04 |
950 * +----+ + + + +
951 * | 10 | | | | |
952 * +----+----+ + + +
953 * | 20 | 21 | | | | <-- removed row
954 * + + +----+ + +
955 * | | | 32 | | | <-- removed row
956 * +----+ + +----+ +
957 * | 40 | | | 43 | |
958 * +----+----+----+----+----+
959 *
960 * In a table above:
961 * - cells to trim: '02', '03' & '04'.
962 * - cells to move: '21' & '32'.
963 */
964function getCellsToMoveAndTrimOnRemoveRow(table, { first, last }) {
965 const cellsToMove = new Map();
966 const cellsToTrim = [];
967 for (const { row, column, cellHeight, cell } of new TableWalker(table, { endRow: last })) {
968 const lastRowOfCell = row + cellHeight - 1;
969 const isCellStickingOutFromRemovedRows = row >= first && row <= last && lastRowOfCell > last;
970 if (isCellStickingOutFromRemovedRows) {
971 const rowspanInRemovedSection = last - row + 1;
972 const rowSpanToSet = cellHeight - rowspanInRemovedSection;
973 cellsToMove.set(column, {
974 cell,
975 rowspan: rowSpanToSet
976 });
977 }
978 const isCellOverlappingRemovedRows = row < first && lastRowOfCell >= first;
979 if (isCellOverlappingRemovedRows) {
980 let rowspanAdjustment;
981 // Cell fully covers removed section - trim it by removed rows count.
982 if (lastRowOfCell >= last) {
983 rowspanAdjustment = last - first + 1;
984 }
985 // Cell partially overlaps removed section - calculate cell's span that is in removed section.
986 else {
987 rowspanAdjustment = lastRowOfCell - first + 1;
988 }
989 cellsToTrim.push({
990 cell,
991 rowspan: cellHeight - rowspanAdjustment
992 });
993 }
994 }
995 return { cellsToMove, cellsToTrim };
996}
997function moveCellsToRow(table, targetRowIndex, cellsToMove, writer) {
998 const tableWalker = new TableWalker(table, {
999 includeAllSlots: true,
1000 row: targetRowIndex
1001 });
1002 const tableRowMap = [...tableWalker];
1003 const row = table.getChild(targetRowIndex);
1004 let previousCell;
1005 for (const { column, cell, isAnchor } of tableRowMap) {
1006 if (cellsToMove.has(column)) {
1007 const { cell: cellToMove, rowspan } = cellsToMove.get(column);
1008 const targetPosition = previousCell ?
1009 writer.createPositionAfter(previousCell) :
1010 writer.createPositionAt(row, 0);
1011 writer.move(writer.createRangeOn(cellToMove), targetPosition);
1012 updateNumericAttribute('rowspan', rowspan, cellToMove, writer);
1013 previousCell = cellToMove;
1014 }
1015 else if (isAnchor) {
1016 // If cell is spanned then `cell` holds reference to overlapping cell. See ckeditor/ckeditor5#6502.
1017 previousCell = cell;
1018 }
1019 }
1020}
1021function compareRangeOrder(rangeA, rangeB) {
1022 // Since table cell ranges are disjoint, it's enough to check their start positions.
1023 const posA = rangeA.start;
1024 const posB = rangeB.start;
1025 // Checking for equal position (returning 0) is not needed because this would be either:
1026 // a. Intersecting range (not allowed by model)
1027 // b. Collapsed range on the same position (allowed by model but should not happen).
1028 return posA.isBefore(posB) ? -1 : 1;
1029}
1030/**
1031 * Calculates the area of a maximum rectangle that can span over the provided row & column indexes.
1032 */
1033function getBiggestRectangleArea(rows, columns) {
1034 const rowsIndexes = Array.from(rows.values());
1035 const columnIndexes = Array.from(columns.values());
1036 const lastRow = Math.max(...rowsIndexes);
1037 const firstRow = Math.min(...rowsIndexes);
1038 const lastColumn = Math.max(...columnIndexes);
1039 const firstColumn = Math.min(...columnIndexes);
1040 return (lastRow - firstRow + 1) * (lastColumn - firstColumn + 1);
1041}