8
0

table-layout-post-fixer.js 11 KB

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