Skip to content
This repository was archived by the owner on Jun 26, 2020. It is now read-only.

Commit 01f9a3b

Browse files
authored
Merge pull request #89 from ckeditor/t/13
Feature: Implemented the table post–fixer which bulletproofs the feature in various complex use–cases (e.g. pasting and collaboration). Closes #13.
2 parents cb77e38 + 43eabed commit 01f9a3b

File tree

5 files changed

+837
-3
lines changed

5 files changed

+837
-3
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"devDependencies": {
1818
"@ckeditor/ckeditor5-editor-classic": "^11.0.0",
1919
"@ckeditor/ckeditor5-paragraph": "^10.0.2",
20+
"@ckeditor/ckeditor5-undo": "^10.0.1",
2021
"@ckeditor/ckeditor5-utils": "^10.2.0",
2122
"eslint": "^4.15.0",
2223
"eslint-config-ckeditor5": "^1.0.7",

src/converters/table-post-fixer.js

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

0 commit comments

Comments
 (0)