8
0

structure.js 19 KB

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