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 | import { default as TableWalker } from '../tablewalker';
|
6 | import { createEmptyTableCell, updateNumericAttribute } from './common';
|
7 | /**
|
8 | * Returns a cropped table according to given dimensions.
|
9 |
|
10 | * To return a cropped table that starts at first row and first column and end in third row and column:
|
11 | *
|
12 | * ```ts
|
13 | * const croppedTable = cropTableToDimensions( table, {
|
14 | * startRow: 1,
|
15 | * endRow: 3,
|
16 | * startColumn: 1,
|
17 | * endColumn: 3
|
18 | * }, writer );
|
19 | * ```
|
20 | *
|
21 | * Calling the code above for the table below:
|
22 | *
|
23 | * 0 1 2 3 4 0 1 2
|
24 | * ┌───┬───┬───┬───┬───┐
|
25 | * 0 │ a │ b │ c │ d │ e │
|
26 | * ├───┴───┤ ├───┴───┤ ┌───┬───┬───┐
|
27 | * 1 │ f │ │ g │ │ │ │ g │ 0
|
28 | * ├───┬───┴───┼───┬───┤ will return: ├───┴───┼───┤
|
29 | * 2 │ h │ i │ j │ k │ │ i │ j │ 1
|
30 | * ├───┤ ├───┤ │ │ ├───┤
|
31 | * 3 │ l │ │ m │ │ │ │ m │ 2
|
32 | * ├───┼───┬───┤ ├───┤ └───────┴───┘
|
33 | * 4 │ n │ o │ p │ │ q │
|
34 | * └───┴───┴───┴───┴───┘
|
35 | */
|
36 | export function cropTableToDimensions(sourceTable, cropDimensions, writer) {
|
37 | const { startRow, startColumn, endRow, endColumn } = cropDimensions;
|
38 | // Create empty table with empty rows equal to crop height.
|
39 | const croppedTable = writer.createElement('table');
|
40 | const cropHeight = endRow - startRow + 1;
|
41 | for (let i = 0; i < cropHeight; i++) {
|
42 | writer.insertElement('tableRow', croppedTable, 'end');
|
43 | }
|
44 | const tableMap = [...new TableWalker(sourceTable, { startRow, endRow, startColumn, endColumn, includeAllSlots: true })];
|
45 | // Iterate over source table slots (including empty - spanned - ones).
|
46 | for (const { row: sourceRow, column: sourceColumn, cell: tableCell, isAnchor, cellAnchorRow, cellAnchorColumn } of tableMap) {
|
47 | // Row index in cropped table.
|
48 | const rowInCroppedTable = sourceRow - startRow;
|
49 | const row = croppedTable.getChild(rowInCroppedTable);
|
50 | // For empty slots: fill the gap with empty table cell.
|
51 | if (!isAnchor) {
|
52 | // But fill the gap only if the spanning cell is anchored outside cropped area.
|
53 | // In the table from method jsdoc those cells are: "c" & "f".
|
54 | if (cellAnchorRow < startRow || cellAnchorColumn < startColumn) {
|
55 | createEmptyTableCell(writer, writer.createPositionAt(row, 'end'));
|
56 | }
|
57 | }
|
58 | // Otherwise clone the cell with all children and trim if it exceeds cropped area.
|
59 | else {
|
60 | const tableCellCopy = writer.cloneElement(tableCell);
|
61 | writer.append(tableCellCopy, row);
|
62 | // Trim table if it exceeds cropped area.
|
63 | // In the table from method jsdoc those cells are: "g" & "m".
|
64 | trimTableCellIfNeeded(tableCellCopy, sourceRow, sourceColumn, endRow, endColumn, writer);
|
65 | }
|
66 | }
|
67 | // Adjust heading rows & columns in cropped table if crop selection includes headings parts.
|
68 | addHeadingsToCroppedTable(croppedTable, sourceTable, startRow, startColumn, writer);
|
69 | return croppedTable;
|
70 | }
|
71 | /**
|
72 | * Returns slot info of cells that starts above and overlaps a given row.
|
73 | *
|
74 | * In a table below, passing `overlapRow = 3`
|
75 | *
|
76 | * ┌───┬───┬───┬───┬───┐
|
77 | * 0 │ a │ b │ c │ d │ e │
|
78 | * │ ├───┼───┼───┼───┤
|
79 | * 1 │ │ f │ g │ h │ i │
|
80 | * ├───┤ ├───┼───┤ │
|
81 | * 2 │ j │ │ k │ l │ │
|
82 | * │ │ │ ├───┼───┤
|
83 | * 3 │ │ │ │ m │ n │ <- overlap row to check
|
84 | * ├───┼───┤ │ ├───│
|
85 | * 4 │ o │ p │ │ │ q │
|
86 | * └───┴───┴───┴───┴───┘
|
87 | *
|
88 | * will return slot info for cells: "j", "f", "k".
|
89 | *
|
90 | * @param table The table to check.
|
91 | * @param overlapRow The index of the row to check.
|
92 | * @param startRow row to start analysis. Use it when it is known that the cells above that row will not overlap. Default value is 0.
|
93 | */
|
94 | export function getVerticallyOverlappingCells(table, overlapRow, startRow = 0) {
|
95 | const cells = [];
|
96 | const tableWalker = new TableWalker(table, { startRow, endRow: overlapRow - 1 });
|
97 | for (const slotInfo of tableWalker) {
|
98 | const { row, cellHeight } = slotInfo;
|
99 | const cellEndRow = row + cellHeight - 1;
|
100 | if (row < overlapRow && overlapRow <= cellEndRow) {
|
101 | cells.push(slotInfo);
|
102 | }
|
103 | }
|
104 | return cells;
|
105 | }
|
106 | /**
|
107 | * Splits the table cell horizontally.
|
108 | *
|
109 | * @returns Created table cell, if any were created.
|
110 | */
|
111 | export function splitHorizontally(tableCell, splitRow, writer) {
|
112 | const tableRow = tableCell.parent;
|
113 | const table = tableRow.parent;
|
114 | const rowIndex = tableRow.index;
|
115 | const rowspan = parseInt(tableCell.getAttribute('rowspan'));
|
116 | const newRowspan = splitRow - rowIndex;
|
117 | const newCellAttributes = {};
|
118 | const newCellRowSpan = rowspan - newRowspan;
|
119 | if (newCellRowSpan > 1) {
|
120 | newCellAttributes.rowspan = newCellRowSpan;
|
121 | }
|
122 | const colspan = parseInt(tableCell.getAttribute('colspan') || '1');
|
123 | if (colspan > 1) {
|
124 | newCellAttributes.colspan = colspan;
|
125 | }
|
126 | const startRow = rowIndex;
|
127 | const endRow = startRow + newRowspan;
|
128 | const tableMap = [...new TableWalker(table, { startRow, endRow, includeAllSlots: true })];
|
129 | let newCell = null;
|
130 | let columnIndex;
|
131 | for (const tableSlot of tableMap) {
|
132 | const { row, column, cell } = tableSlot;
|
133 | if (cell === tableCell && columnIndex === undefined) {
|
134 | columnIndex = column;
|
135 | }
|
136 | if (columnIndex !== undefined && columnIndex === column && row === endRow) {
|
137 | newCell = createEmptyTableCell(writer, tableSlot.getPositionBefore(), newCellAttributes);
|
138 | }
|
139 | }
|
140 | // Update the rowspan attribute after updating table.
|
141 | updateNumericAttribute('rowspan', newRowspan, tableCell, writer);
|
142 | return newCell;
|
143 | }
|
144 | /**
|
145 | * Returns slot info of cells that starts before and overlaps a given column.
|
146 | *
|
147 | * In a table below, passing `overlapColumn = 3`
|
148 | *
|
149 | * 0 1 2 3 4
|
150 | * ┌───────┬───────┬───┐
|
151 | * │ a │ b │ c │
|
152 | * │───┬───┴───────┼───┤
|
153 | * │ d │ e │ f │
|
154 | * ├───┼───┬───────┴───┤
|
155 | * │ g │ h │ i │
|
156 | * ├───┼───┼───┬───────┤
|
157 | * │ j │ k │ l │ m │
|
158 | * ├───┼───┴───┼───┬───┤
|
159 | * │ n │ o │ p │ q │
|
160 | * └───┴───────┴───┴───┘
|
161 | * ^
|
162 | * Overlap column to check
|
163 | *
|
164 | * will return slot info for cells: "b", "e", "i".
|
165 | *
|
166 | * @param table The table to check.
|
167 | * @param overlapColumn The index of the column to check.
|
168 | */
|
169 | export function getHorizontallyOverlappingCells(table, overlapColumn) {
|
170 | const cellsToSplit = [];
|
171 | const tableWalker = new TableWalker(table);
|
172 | for (const slotInfo of tableWalker) {
|
173 | const { column, cellWidth } = slotInfo;
|
174 | const cellEndColumn = column + cellWidth - 1;
|
175 | if (column < overlapColumn && overlapColumn <= cellEndColumn) {
|
176 | cellsToSplit.push(slotInfo);
|
177 | }
|
178 | }
|
179 | return cellsToSplit;
|
180 | }
|
181 | /**
|
182 | * Splits the table cell vertically.
|
183 | *
|
184 | * @param columnIndex The table cell column index.
|
185 | * @param splitColumn The index of column to split cell on.
|
186 | * @returns Created table cell.
|
187 | */
|
188 | export function splitVertically(tableCell, columnIndex, splitColumn, writer) {
|
189 | const colspan = parseInt(tableCell.getAttribute('colspan'));
|
190 | const newColspan = splitColumn - columnIndex;
|
191 | const newCellAttributes = {};
|
192 | const newCellColSpan = colspan - newColspan;
|
193 | if (newCellColSpan > 1) {
|
194 | newCellAttributes.colspan = newCellColSpan;
|
195 | }
|
196 | const rowspan = parseInt(tableCell.getAttribute('rowspan') || '1');
|
197 | if (rowspan > 1) {
|
198 | newCellAttributes.rowspan = rowspan;
|
199 | }
|
200 | const newCell = createEmptyTableCell(writer, writer.createPositionAfter(tableCell), newCellAttributes);
|
201 | // Update the colspan attribute after updating table.
|
202 | updateNumericAttribute('colspan', newColspan, tableCell, writer);
|
203 | return newCell;
|
204 | }
|
205 | /**
|
206 | * Adjusts table cell dimensions to not exceed limit row and column.
|
207 | *
|
208 | * If table cell width (or height) covers a column (or row) that is after a limit column (or row)
|
209 | * this method will trim "colspan" (or "rowspan") attribute so the table cell will fit in a defined limits.
|
210 | */
|
211 | export function trimTableCellIfNeeded(tableCell, cellRow, cellColumn, limitRow, limitColumn, writer) {
|
212 | const colspan = parseInt(tableCell.getAttribute('colspan') || '1');
|
213 | const rowspan = parseInt(tableCell.getAttribute('rowspan') || '1');
|
214 | const endColumn = cellColumn + colspan - 1;
|
215 | if (endColumn > limitColumn) {
|
216 | const trimmedSpan = limitColumn - cellColumn + 1;
|
217 | updateNumericAttribute('colspan', trimmedSpan, tableCell, writer, 1);
|
218 | }
|
219 | const endRow = cellRow + rowspan - 1;
|
220 | if (endRow > limitRow) {
|
221 | const trimmedSpan = limitRow - cellRow + 1;
|
222 | updateNumericAttribute('rowspan', trimmedSpan, tableCell, writer, 1);
|
223 | }
|
224 | }
|
225 | /**
|
226 | * Sets proper heading attributes to a cropped table.
|
227 | */
|
228 | function addHeadingsToCroppedTable(croppedTable, sourceTable, startRow, startColumn, writer) {
|
229 | const headingRows = parseInt(sourceTable.getAttribute('headingRows') || '0');
|
230 | if (headingRows > 0) {
|
231 | const headingRowsInCrop = headingRows - startRow;
|
232 | updateNumericAttribute('headingRows', headingRowsInCrop, croppedTable, writer, 0);
|
233 | }
|
234 | const headingColumns = parseInt(sourceTable.getAttribute('headingColumns') || '0');
|
235 | if (headingColumns > 0) {
|
236 | const headingColumnsInCrop = headingColumns - startColumn;
|
237 | updateNumericAttribute('headingColumns', headingColumnsInCrop, croppedTable, writer, 0);
|
238 | }
|
239 | }
|
240 | /**
|
241 | * Removes columns that have no cells anchored.
|
242 | *
|
243 | * In table below:
|
244 | *
|
245 | * +----+----+----+----+----+----+----+
|
246 | * | 00 | 01 | 03 | 04 | 06 |
|
247 | * +----+----+----+----+ +----+
|
248 | * | 10 | 11 | 13 | | 16 |
|
249 | * +----+----+----+----+----+----+----+
|
250 | * | 20 | 21 | 23 | 24 | 26 |
|
251 | * +----+----+----+----+----+----+----+
|
252 | * ^--- empty ---^
|
253 | *
|
254 | * Will remove columns 2 and 5.
|
255 | *
|
256 | * **Note:** This is a low-level helper method for clearing invalid model state when doing table modifications.
|
257 | * To remove a column from a table use {@link module:table/tableutils~TableUtils#removeColumns `TableUtils.removeColumns()`}.
|
258 | *
|
259 | * @internal
|
260 | * @returns True if removed some columns.
|
261 | */
|
262 | export function removeEmptyColumns(table, tableUtils) {
|
263 | const width = tableUtils.getColumns(table);
|
264 | const columnsMap = new Array(width).fill(0);
|
265 | for (const { column } of new TableWalker(table)) {
|
266 | columnsMap[column]++;
|
267 | }
|
268 | const emptyColumns = columnsMap.reduce((result, cellsCount, column) => {
|
269 | return cellsCount ? result : [...result, column];
|
270 | }, []);
|
271 | if (emptyColumns.length > 0) {
|
272 | // Remove only last empty column because it will recurrently trigger removing empty rows.
|
273 | const emptyColumn = emptyColumns[emptyColumns.length - 1];
|
274 | // @if CK_DEBUG_TABLE // console.log( `Removing empty column: ${ emptyColumn }.` );
|
275 | tableUtils.removeColumns(table, { at: emptyColumn });
|
276 | return true;
|
277 | }
|
278 | return false;
|
279 | }
|
280 | /**
|
281 | * Removes rows that have no cells anchored.
|
282 | *
|
283 | * In table below:
|
284 | *
|
285 | * +----+----+----+
|
286 | * | 00 | 01 | 02 |
|
287 | * +----+----+----+
|
288 | * | 10 | 11 | 12 |
|
289 | * + + + +
|
290 | * | | | | <-- empty
|
291 | * +----+----+----+
|
292 | * | 30 | 31 | 32 |
|
293 | * +----+----+----+
|
294 | * | 40 | 42 |
|
295 | * + + +
|
296 | * | | | <-- empty
|
297 | * +----+----+----+
|
298 | * | 60 | 61 | 62 |
|
299 | * +----+----+----+
|
300 | *
|
301 | * Will remove rows 2 and 5.
|
302 | *
|
303 | * **Note:** This is a low-level helper method for clearing invalid model state when doing table modifications.
|
304 | * To remove a row from a table use {@link module:table/tableutils~TableUtils#removeRows `TableUtils.removeRows()`}.
|
305 | *
|
306 | * @internal
|
307 | * @returns True if removed some rows.
|
308 | */
|
309 | export function removeEmptyRows(table, tableUtils) {
|
310 | const emptyRows = [];
|
311 | const tableRowCount = tableUtils.getRows(table);
|
312 | for (let rowIndex = 0; rowIndex < tableRowCount; rowIndex++) {
|
313 | const tableRow = table.getChild(rowIndex);
|
314 | if (tableRow.isEmpty) {
|
315 | emptyRows.push(rowIndex);
|
316 | }
|
317 | }
|
318 | if (emptyRows.length > 0) {
|
319 | // Remove only last empty row because it will recurrently trigger removing empty columns.
|
320 | const emptyRow = emptyRows[emptyRows.length - 1];
|
321 | // @if CK_DEBUG_TABLE // console.log( `Removing empty row: ${ emptyRow }.` );
|
322 | tableUtils.removeRows(table, { at: emptyRow });
|
323 | return true;
|
324 | }
|
325 | return false;
|
326 | }
|
327 | /**
|
328 | * Removes rows and columns that have no cells anchored.
|
329 | *
|
330 | * In table below:
|
331 | *
|
332 | * +----+----+----+----+
|
333 | * | 00 | 02 |
|
334 | * +----+----+ +
|
335 | * | 10 | |
|
336 | * +----+----+----+----+
|
337 | * | 20 | 22 | 23 |
|
338 | * + + + +
|
339 | * | | | | <-- empty row
|
340 | * +----+----+----+----+
|
341 | * ^--- empty column
|
342 | *
|
343 | * Will remove row 3 and column 1.
|
344 | *
|
345 | * **Note:** This is a low-level helper method for clearing invalid model state when doing table modifications.
|
346 | * To remove a rows from a table use {@link module:table/tableutils~TableUtils#removeRows `TableUtils.removeRows()`} and
|
347 | * {@link module:table/tableutils~TableUtils#removeColumns `TableUtils.removeColumns()`} to remove a column.
|
348 | *
|
349 | * @internal
|
350 | */
|
351 | export function removeEmptyRowsColumns(table, tableUtils) {
|
352 | const removedColumns = removeEmptyColumns(table, tableUtils);
|
353 | // If there was some columns removed then cleaning empty rows was already triggered.
|
354 | if (!removedColumns) {
|
355 | removeEmptyRows(table, tableUtils);
|
356 | }
|
357 | }
|
358 | /**
|
359 | * Returns adjusted last row index if selection covers part of a row with empty slots (spanned by other cells).
|
360 | * The `dimensions.lastRow` is equal to last row index but selection might be bigger.
|
361 | *
|
362 | * This happens *only* on rectangular selection so we analyze a case like this:
|
363 | *
|
364 | * +---+---+---+---+
|
365 | * 0 | a | b | c | d |
|
366 | * + + +---+---+
|
367 | * 1 | | e | f | g |
|
368 | * + +---+ +---+
|
369 | * 2 | | h | | i | <- last row, each cell has rowspan = 2,
|
370 | * + + + + + so we need to return 3, not 2
|
371 | * 3 | | | | |
|
372 | * +---+---+---+---+
|
373 | *
|
374 | * @returns Adjusted last row index.
|
375 | */
|
376 | export function adjustLastRowIndex(table, dimensions) {
|
377 | const lastRowMap = Array.from(new TableWalker(table, {
|
378 | startColumn: dimensions.firstColumn,
|
379 | endColumn: dimensions.lastColumn,
|
380 | row: dimensions.lastRow
|
381 | }));
|
382 | const everyCellHasSingleRowspan = lastRowMap.every(({ cellHeight }) => cellHeight === 1);
|
383 | // It is a "flat" row, so the last row index is OK.
|
384 | if (everyCellHasSingleRowspan) {
|
385 | return dimensions.lastRow;
|
386 | }
|
387 | // Otherwise get any cell's rowspan and adjust the last row index.
|
388 | const rowspanAdjustment = lastRowMap[0].cellHeight - 1;
|
389 | return dimensions.lastRow + rowspanAdjustment;
|
390 | }
|
391 | /**
|
392 | * Returns adjusted last column index if selection covers part of a column with empty slots (spanned by other cells).
|
393 | * The `dimensions.lastColumn` is equal to last column index but selection might be bigger.
|
394 | *
|
395 | * This happens *only* on rectangular selection so we analyze a case like this:
|
396 | *
|
397 | * 0 1 2 3
|
398 | * +---+---+---+---+
|
399 | * | a |
|
400 | * +---+---+---+---+
|
401 | * | b | c | d |
|
402 | * +---+---+---+---+
|
403 | * | e | f |
|
404 | * +---+---+---+---+
|
405 | * | g | h |
|
406 | * +---+---+---+---+
|
407 | * ^
|
408 | * last column, each cell has colspan = 2, so we need to return 3, not 2
|
409 | *
|
410 | * @returns Adjusted last column index.
|
411 | */
|
412 | export function adjustLastColumnIndex(table, dimensions) {
|
413 | const lastColumnMap = Array.from(new TableWalker(table, {
|
414 | startRow: dimensions.firstRow,
|
415 | endRow: dimensions.lastRow,
|
416 | column: dimensions.lastColumn
|
417 | }));
|
418 | const everyCellHasSingleColspan = lastColumnMap.every(({ cellWidth }) => cellWidth === 1);
|
419 | // It is a "flat" column, so the last column index is OK.
|
420 | if (everyCellHasSingleColspan) {
|
421 | return dimensions.lastColumn;
|
422 | }
|
423 | // Otherwise get any cell's colspan and adjust the last column index.
|
424 | const colspanAdjustment = lastColumnMap[0].cellWidth - 1;
|
425 | return dimensions.lastColumn + colspanAdjustment;
|
426 | }
|