UNPKG

13.1 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 */
5import TableWalker from './../tablewalker';
6import { 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 */
224export default function injectTableLayoutPostFixer(model) {
225 model.document.registerPostFixer(writer => tableLayoutPostFixer(writer, model));
226}
227/**
228 * The table layout post-fixer.
229 */
230function 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 */
264function 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 */
281function 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 */
326function 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 */
350function 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 */
361function 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}