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 TableWalker from './../tablewalker';
|
6 | import { createEmptyTableCell, updateNumericAttribute } from '../utils/common';
|
7 | /**
|
8 | * Injects a table layout post-fixer into the model.
|
9 | *
|
10 | * The role of the table layout post-fixer is to ensure that the table rows have the correct structure
|
11 | * after a {@link module:engine/model/model~Model#change `change()`} block was executed.
|
12 | *
|
13 | * The correct structure means that:
|
14 | *
|
15 | * * All table rows have the same size.
|
16 | * * None of the table cells extend vertically beyond their section (either header or body).
|
17 | * * A table cell has always at least one element as a child.
|
18 | *
|
19 | * If the table structure is not correct, the post-fixer will automatically correct it in two steps:
|
20 | *
|
21 | * 1. It will clip table cells that extend beyond their section.
|
22 | * 2. It will add empty table cells to the rows that are narrower than the widest table row.
|
23 | *
|
24 | * ## Clipping overlapping table cells
|
25 | *
|
26 | * Such situation may occur when pasting a table (or a part of a table) to the editor from external sources.
|
27 | *
|
28 | * For example, see the following table which has a cell (FOO) with the rowspan attribute (2):
|
29 | *
|
30 | * ```xml
|
31 | * <table headingRows="1">
|
32 | * <tableRow>
|
33 | * <tableCell rowspan="2"><paragraph>FOO</paragraph></tableCell>
|
34 | * <tableCell colspan="2"><paragraph>BAR</paragraph></tableCell>
|
35 | * </tableRow>
|
36 | * <tableRow>
|
37 | * <tableCell><paragraph>BAZ</paragraph></tableCell>
|
38 | * <tableCell><paragraph>XYZ</paragraph></tableCell>
|
39 | * </tableRow>
|
40 | * </table>
|
41 | * ```
|
42 | *
|
43 | * It will be rendered in the view as:
|
44 | *
|
45 | * ```xml
|
46 | * <table>
|
47 | * <thead>
|
48 | * <tr>
|
49 | * <td rowspan="2">FOO</td>
|
50 | * <td colspan="2">BAR</td>
|
51 | * </tr>
|
52 | * </thead>
|
53 | * <tbody>
|
54 | * <tr>
|
55 | * <td>BAZ</td>
|
56 | * <td>XYZ</td>
|
57 | * </tr>
|
58 | * </tbody>
|
59 | * </table>
|
60 | * ```
|
61 | *
|
62 | * In the above example the table will be rendered as a table with two rows: one in the header and second one in the body.
|
63 | * The table cell (FOO) cannot span over multiple rows as it would extend from the header to the body section.
|
64 | * The `rowspan` attribute must be changed to (1). The value (1) is the default value of the `rowspan` attribute
|
65 | * so the `rowspan` attribute will be removed from the model.
|
66 | *
|
67 | * The table cell with BAZ in the content will be in the first column of the table.
|
68 | *
|
69 | * ## Adding missing table cells
|
70 | *
|
71 | * The table post-fixer will insert empty table cells to equalize table row sizes (the number of columns).
|
72 | * The size of a table row is calculated by counting column spans of table cells, both horizontal (from the same row) and
|
73 | * vertical (from the rows above).
|
74 | *
|
75 | * In the above example, the table row in the body section of the table is narrower then the row from the header: it has two cells
|
76 | * with the default colspan (1). The header row has one cell with colspan (1) and the second with colspan (2).
|
77 | * The table cell (FOO) does not extend beyond the head section (and as such will be fixed in the first step of this post-fixer).
|
78 | * The post-fixer will add a missing table cell to the row in the body section of the table.
|
79 | *
|
80 | * The table from the above example will be fixed and rendered to the view as below:
|
81 | *
|
82 | * ```xml
|
83 | * <table>
|
84 | * <thead>
|
85 | * <tr>
|
86 | * <td rowspan="2">FOO</td>
|
87 | * <td colspan="2">BAR</td>
|
88 | * </tr>
|
89 | * </thead>
|
90 | * <tbody>
|
91 | * <tr>
|
92 | * <td>BAZ</td>
|
93 | * <td>XYZ</td>
|
94 | * </tr>
|
95 | * </tbody>
|
96 | * </table>
|
97 | * ```
|
98 | *
|
99 | * ## Collaboration and undo - Expectations vs post-fixer results
|
100 | *
|
101 | * The table post-fixer only ensures proper structure without a deeper analysis of the nature of the change. As such, it might lead
|
102 | * to a structure which was not intended by the user. In particular, it will also fix undo steps (in conjunction with collaboration)
|
103 | * in which the editor content might not return to the original state.
|
104 | *
|
105 | * This will usually happen when one or more users change the size of the table.
|
106 | *
|
107 | * As an example see the table below:
|
108 | *
|
109 | * ```xml
|
110 | * <table>
|
111 | * <tbody>
|
112 | * <tr>
|
113 | * <td>11</td>
|
114 | * <td>12</td>
|
115 | * </tr>
|
116 | * <tr>
|
117 | * <td>21</td>
|
118 | * <td>22</td>
|
119 | * </tr>
|
120 | * </tbody>
|
121 | * </table>
|
122 | * ```
|
123 | *
|
124 | * and the user actions:
|
125 | *
|
126 | * 1. Both users have a table with two rows and two columns.
|
127 | * 2. User A adds a column at the end of the table. This will insert empty table cells to two rows.
|
128 | * 3. User B adds a row at the end of the table. This will insert a row with two empty table cells.
|
129 | * 4. Both users will have a table as below:
|
130 | *
|
131 | * ```xml
|
132 | * <table>
|
133 | * <tbody>
|
134 | * <tr>
|
135 | * <td>11</td>
|
136 | * <td>12</td>
|
137 | * <td>(empty, inserted by A)</td>
|
138 | * </tr>
|
139 | * <tr>
|
140 | * <td>21</td>
|
141 | * <td>22</td>
|
142 | * <td>(empty, inserted by A)</td>
|
143 | * </tr>
|
144 | * <tr>
|
145 | * <td>(empty, inserted by B)</td>
|
146 | * <td>(empty, inserted by B)</td>
|
147 | * </tr>
|
148 | * </tbody>
|
149 | * </table>
|
150 | * ```
|
151 | *
|
152 | * The last row is shorter then others so the table post-fixer will add an empty row to the last row:
|
153 | *
|
154 | * ```xml
|
155 | * <table>
|
156 | * <tbody>
|
157 | * <tr>
|
158 | * <td>11</td>
|
159 | * <td>12</td>
|
160 | * <td>(empty, inserted by A)</td>
|
161 | * </tr>
|
162 | * <tr>
|
163 | * <td>21</td>
|
164 | * <td>22</td>
|
165 | * <td>(empty, inserted by A)</td>
|
166 | * </tr>
|
167 | * <tr>
|
168 | * <td>(empty, inserted by B)</td>
|
169 | * <td>(empty, inserted by B)</td>
|
170 | * <td>(empty, inserted by the post-fixer)</td>
|
171 | * </tr>
|
172 | * </tbody>
|
173 | * </table>
|
174 | * ```
|
175 | *
|
176 | * Unfortunately undo does not know the nature of the changes and depending on which user applies the post-fixer changes, undoing them
|
177 | * might lead to a broken table. If User B undoes inserting the column to the table, the undo engine will undo only the operations of
|
178 | * inserting empty cells to rows from the initial table state (row 1 and 2) but the cell in the post-fixed row will remain:
|
179 | *
|
180 | * ```xml
|
181 | * <table>
|
182 | * <tbody>
|
183 | * <tr>
|
184 | * <td>11</td>
|
185 | * <td>12</td>
|
186 | * </tr>
|
187 | * <tr>
|
188 | * <td>21</td>
|
189 | * <td>22</td>
|
190 | * </tr>
|
191 | * <tr>
|
192 | * <td>(empty, inserted by B)</td>
|
193 | * <td>(empty, inserted by B)</td>
|
194 | * <td>(empty, inserted by a post-fixer)</td>
|
195 | * </tr>
|
196 | * </tbody>
|
197 | * </table>
|
198 | * ```
|
199 | *
|
200 | * After undo, the table post-fixer will detect that two rows are shorter than others and will fix the table to:
|
201 | *
|
202 | * ```xml
|
203 | * <table>
|
204 | * <tbody>
|
205 | * <tr>
|
206 | * <td>11</td>
|
207 | * <td>12</td>
|
208 | * <td>(empty, inserted by a post-fixer after undo)</td>
|
209 | * </tr>
|
210 | * <tr>
|
211 | * <td>21</td>
|
212 | * <td>22</td>
|
213 | * <td>(empty, inserted by a post-fixer after undo)</td>
|
214 | * </tr>
|
215 | * <tr>
|
216 | * <td>(empty, inserted by B)</td>
|
217 | * <td>(empty, inserted by B)</td>
|
218 | * <td>(empty, inserted by a post-fixer)</td>
|
219 | * </tr>
|
220 | * </tbody>
|
221 | * </table>
|
222 | * ```
|
223 | */
|
224 | export default function injectTableLayoutPostFixer(model) {
|
225 | model.document.registerPostFixer(writer => tableLayoutPostFixer(writer, model));
|
226 | }
|
227 | /**
|
228 | * The table layout post-fixer.
|
229 | */
|
230 | function tableLayoutPostFixer(writer, model) {
|
231 | const changes = model.document.differ.getChanges();
|
232 | let wasFixed = false;
|
233 | // Do not analyze the same table more then once - may happen for multiple changes in the same table.
|
234 | const analyzedTables = new Set();
|
235 | for (const entry of changes) {
|
236 | let table = null;
|
237 | if (entry.type == 'insert' && entry.name == 'table') {
|
238 | table = entry.position.nodeAfter;
|
239 | }
|
240 | // Fix table on adding/removing table cells and rows.
|
241 | if ((entry.type == 'insert' || entry.type == 'remove') && (entry.name == 'tableRow' || entry.name == 'tableCell')) {
|
242 | table = entry.position.findAncestor('table');
|
243 | }
|
244 | // Fix table on any table's attribute change - including attributes of table cells.
|
245 | if (isTableAttributeEntry(entry)) {
|
246 | table = entry.range.start.findAncestor('table');
|
247 | }
|
248 | if (table && !analyzedTables.has(table)) {
|
249 | // Step 1: correct rowspans of table cells if necessary.
|
250 | // The wasFixed flag should be true if any of tables in batch was fixed - might be more then one.
|
251 | wasFixed = fixTableCellsRowspan(table, writer) || wasFixed;
|
252 | // Step 2: fix table rows sizes.
|
253 | wasFixed = fixTableRowsSizes(table, writer) || wasFixed;
|
254 | analyzedTables.add(table);
|
255 | }
|
256 | }
|
257 | return wasFixed;
|
258 | }
|
259 | /**
|
260 | * Fixes the invalid value of the `rowspan` attribute because a table cell cannot vertically extend beyond the table section it belongs to.
|
261 | *
|
262 | * @returns Returns `true` if the table was fixed.
|
263 | */
|
264 | function fixTableCellsRowspan(table, writer) {
|
265 | let wasFixed = false;
|
266 | const cellsToTrim = findCellsToTrim(table);
|
267 | if (cellsToTrim.length) {
|
268 | // @if CK_DEBUG_TABLE // console.log( `Post-fixing table: trimming cells row-spans (${ cellsToTrim.length }).` );
|
269 | wasFixed = true;
|
270 | for (const data of cellsToTrim) {
|
271 | updateNumericAttribute('rowspan', data.rowspan, data.cell, writer, 1);
|
272 | }
|
273 | }
|
274 | return wasFixed;
|
275 | }
|
276 | /**
|
277 | * Makes all table rows in a table the same size.
|
278 | *
|
279 | * @returns Returns `true` if the table was fixed.
|
280 | */
|
281 | function fixTableRowsSizes(table, writer) {
|
282 | let wasFixed = false;
|
283 | const childrenLengths = getChildrenLengths(table);
|
284 | const rowsToRemove = [];
|
285 | // Find empty rows.
|
286 | for (const [rowIndex, size] of childrenLengths.entries()) {
|
287 | // Ignore all non-row models.
|
288 | if (!size && table.getChild(rowIndex).is('element', 'tableRow')) {
|
289 | rowsToRemove.push(rowIndex);
|
290 | }
|
291 | }
|
292 | // Remove empty rows.
|
293 | if (rowsToRemove.length) {
|
294 | // @if CK_DEBUG_TABLE // console.log( `Post-fixing table: remove empty rows (${ rowsToRemove.length }).` );
|
295 | wasFixed = true;
|
296 | for (const rowIndex of rowsToRemove.reverse()) {
|
297 | writer.remove(table.getChild(rowIndex));
|
298 | childrenLengths.splice(rowIndex, 1);
|
299 | }
|
300 | }
|
301 | // Filter out everything that's not a table row.
|
302 | const rowsLengths = childrenLengths.filter((row, rowIndex) => table.getChild(rowIndex).is('element', 'tableRow'));
|
303 | // Verify if all the rows have the same number of columns.
|
304 | const tableSize = rowsLengths[0];
|
305 | const isValid = rowsLengths.every(length => length === tableSize);
|
306 | if (!isValid) {
|
307 | // @if CK_DEBUG_TABLE // console.log( 'Post-fixing table: adding missing cells.' );
|
308 | // Find the maximum number of columns.
|
309 | const maxColumns = rowsLengths.reduce((prev, current) => current > prev ? current : prev, 0);
|
310 | for (const [rowIndex, size] of rowsLengths.entries()) {
|
311 | const columnsToInsert = maxColumns - size;
|
312 | if (columnsToInsert) {
|
313 | for (let i = 0; i < columnsToInsert; i++) {
|
314 | createEmptyTableCell(writer, writer.createPositionAt(table.getChild(rowIndex), 'end'));
|
315 | }
|
316 | wasFixed = true;
|
317 | }
|
318 | }
|
319 | }
|
320 | return wasFixed;
|
321 | }
|
322 | /**
|
323 | * Searches for table cells that extend beyond the table section to which they belong to. It will return an array of objects
|
324 | * that stores table cells to be trimmed and the correct value of the `rowspan` attribute to set.
|
325 | */
|
326 | function findCellsToTrim(table) {
|
327 | const headingRows = parseInt(table.getAttribute('headingRows') || '0');
|
328 | const maxRows = Array.from(table.getChildren())
|
329 | .reduce((count, row) => row.is('element', 'tableRow') ? count + 1 : count, 0);
|
330 | const cellsToTrim = [];
|
331 | for (const { row, cell, cellHeight } of new TableWalker(table)) {
|
332 | // Skip cells that do not expand over its row.
|
333 | if (cellHeight < 2) {
|
334 | continue;
|
335 | }
|
336 | const isInHeader = row < headingRows;
|
337 | // Row limit is either end of header section or whole table as table body is after the header.
|
338 | const rowLimit = isInHeader ? headingRows : maxRows;
|
339 | // If table cell expands over its limit reduce it height to proper value.
|
340 | if (row + cellHeight > rowLimit) {
|
341 | const newRowspan = rowLimit - row;
|
342 | cellsToTrim.push({ cell, rowspan: newRowspan });
|
343 | }
|
344 | }
|
345 | return cellsToTrim;
|
346 | }
|
347 | /**
|
348 | * Returns an array with lengths of rows assigned to the corresponding row index.
|
349 | */
|
350 | function getChildrenLengths(table) {
|
351 | // TableWalker will not provide items for the empty rows, we need to pre-fill this array.
|
352 | const lengths = new Array(table.childCount).fill(0);
|
353 | for (const { rowIndex } of new TableWalker(table, { includeAllSlots: true })) {
|
354 | lengths[rowIndex]++;
|
355 | }
|
356 | return lengths;
|
357 | }
|
358 | /**
|
359 | * Checks if the differ entry for an attribute change is one of the table's attributes.
|
360 | */
|
361 | function isTableAttributeEntry(entry) {
|
362 | if (entry.type !== 'attribute') {
|
363 | return false;
|
364 | }
|
365 | const key = entry.attributeKey;
|
366 | return key === 'headingRows' || key === 'colspan' || key === 'rowspan';
|
367 | }
|