utils.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
  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
  7. */
  8. import { isWidget, toWidget } from '@ckeditor/ckeditor5-widget/src/utils';
  9. import { createEmptyTableCell, findAncestor, updateNumericAttribute } from './commands/utils';
  10. import TableWalker from './tablewalker';
  11. /**
  12. * Converts a given {@link module:engine/view/element~Element} to a table widget:
  13. * * Adds a {@link module:engine/view/element~Element#_setCustomProperty custom property} allowing to recognize the table widget element.
  14. * * Calls the {@link module:widget/utils~toWidget} function with the proper element's label creator.
  15. *
  16. * @param {module:engine/view/element~Element} viewElement
  17. * @param {module:engine/view/downcastwriter~DowncastWriter} writer An instance of the view writer.
  18. * @param {String} label The element's label. It will be concatenated with the table `alt` attribute if one is present.
  19. * @returns {module:engine/view/element~Element}
  20. */
  21. export function toTableWidget( viewElement, writer ) {
  22. writer.setCustomProperty( 'table', true, viewElement );
  23. return toWidget( viewElement, writer, { hasSelectionHandle: true } );
  24. }
  25. /**
  26. * Checks if a given view element is a table widget.
  27. *
  28. * @param {module:engine/view/element~Element} viewElement
  29. * @returns {Boolean}
  30. */
  31. export function isTableWidget( viewElement ) {
  32. return !!viewElement.getCustomProperty( 'table' ) && isWidget( viewElement );
  33. }
  34. /**
  35. * Returns a table widget editing view element if one is selected.
  36. *
  37. * @param {module:engine/view/selection~Selection|module:engine/view/documentselection~DocumentSelection} selection
  38. * @returns {module:engine/view/element~Element|null}
  39. */
  40. export function getSelectedTableWidget( selection ) {
  41. const viewElement = selection.getSelectedElement();
  42. if ( viewElement && isTableWidget( viewElement ) ) {
  43. return viewElement;
  44. }
  45. return null;
  46. }
  47. /**
  48. * Returns a table widget editing view element if one is among the selection's ancestors.
  49. *
  50. * @param {module:engine/view/selection~Selection|module:engine/view/documentselection~DocumentSelection} selection
  51. * @returns {module:engine/view/element~Element|null}
  52. */
  53. export function getTableWidgetAncestor( selection ) {
  54. const parentTable = findAncestor( 'table', selection.getFirstPosition() );
  55. if ( parentTable && isTableWidget( parentTable.parent ) ) {
  56. return parentTable.parent;
  57. }
  58. return null;
  59. }
  60. /**
  61. * Returns all model table cells that are fully selected (from the outside)
  62. * within the provided model selection's ranges.
  63. *
  64. * To obtain the cells selected from the inside, use
  65. * {@link module:table/utils~getTableCellsContainingSelection}.
  66. *
  67. * @param {module:engine/model/selection~Selection} selection
  68. * @returns {Array.<module:engine/model/element~Element>}
  69. */
  70. export function getSelectedTableCells( selection ) {
  71. const cells = [];
  72. for ( const range of sortRanges( selection.getRanges() ) ) {
  73. const element = range.getContainedElement();
  74. if ( element && element.is( 'tableCell' ) ) {
  75. cells.push( element );
  76. }
  77. }
  78. return cells;
  79. }
  80. /**
  81. * Returns all model table cells that the provided model selection's ranges
  82. * {@link module:engine/model/range~Range#start} inside.
  83. *
  84. * To obtain the cells selected from the outside, use
  85. * {@link module:table/utils~getSelectedTableCells}.
  86. *
  87. * @param {module:engine/model/selection~Selection} selection
  88. * @returns {Array.<module:engine/model/element~Element>}
  89. */
  90. export function getTableCellsContainingSelection( selection ) {
  91. const cells = [];
  92. for ( const range of selection.getRanges() ) {
  93. const cellWithSelection = findAncestor( 'tableCell', range.start );
  94. if ( cellWithSelection ) {
  95. cells.push( cellWithSelection );
  96. }
  97. }
  98. return cells;
  99. }
  100. /**
  101. * Returns all model table cells that are either completely selected
  102. * by selection ranges or host selection range
  103. * {@link module:engine/model/range~Range#start start positions} inside them.
  104. *
  105. * Combines {@link module:table/utils~getTableCellsContainingSelection} and
  106. * {@link module:table/utils~getSelectedTableCells}.
  107. *
  108. * @param {module:engine/model/selection~Selection} selection
  109. * @returns {Array.<module:engine/model/element~Element>}
  110. */
  111. export function getSelectionAffectedTableCells( selection ) {
  112. const selectedCells = getSelectedTableCells( selection );
  113. if ( selectedCells.length ) {
  114. return selectedCells;
  115. }
  116. return getTableCellsContainingSelection( selection );
  117. }
  118. /**
  119. * Returns an object with the `first` and `last` row index contained in the given `tableCells`.
  120. *
  121. * const selectedTableCells = getSelectedTableCells( editor.model.document.selection );
  122. *
  123. * const { first, last } = getRowIndexes( selectedTableCells );
  124. *
  125. * console.log( `Selected rows: ${ first } to ${ last }` );
  126. *
  127. * @param {Array.<module:engine/model/element~Element>} tableCells
  128. * @returns {Object} Returns an object with the `first` and `last` table row indexes.
  129. */
  130. export function getRowIndexes( tableCells ) {
  131. const indexes = tableCells.map( cell => cell.parent.index );
  132. return getFirstLastIndexesObject( indexes );
  133. }
  134. /**
  135. * Returns an object with the `first` and `last` column index contained in the given `tableCells`.
  136. *
  137. * const selectedTableCells = getSelectedTableCells( editor.model.document.selection );
  138. *
  139. * const { first, last } = getColumnIndexes( selectedTableCells );
  140. *
  141. * console.log( `Selected columns: ${ first } to ${ last }` );
  142. *
  143. * @param {Array.<module:engine/model/element~Element>} tableCells
  144. * @returns {Object} Returns an object with the `first` and `last` table column indexes.
  145. */
  146. export function getColumnIndexes( tableCells ) {
  147. const table = findAncestor( 'table', tableCells[ 0 ] );
  148. const tableMap = [ ...new TableWalker( table ) ];
  149. const indexes = tableMap
  150. .filter( entry => tableCells.includes( entry.cell ) )
  151. .map( entry => entry.column );
  152. return getFirstLastIndexesObject( indexes );
  153. }
  154. /**
  155. * Checks if the selection contains cells that do not exceed rectangular selection.
  156. *
  157. * In a table below:
  158. *
  159. * ┌───┬───┬───┬───┐
  160. * │ a │ b │ c │ d │
  161. * ├───┴───┼───┤ │
  162. * │ e │ f │ │
  163. * ├ ├───┼───┤
  164. * │ │ g │ h │
  165. * └───────┴───┴───┘
  166. *
  167. * Valid selections are these which create a solid rectangle (without gaps), such as:
  168. * - a, b (two horizontal cells)
  169. * - c, f (two vertical cells)
  170. * - a, b, e (cell "e" spans over four cells)
  171. * - c, d, f (cell d spans over a cell in the row below)
  172. *
  173. * While an invalid selection would be:
  174. * - a, c (the unselected cell "b" creates a gap)
  175. * - f, g, h (cell "d" spans over a cell from the row of "f" cell - thus creates a gap)
  176. *
  177. * @param {Array.<module:engine/model/element~Element>} selectedTableCells
  178. * @param {module:table/tableutils~TableUtils} tableUtils
  179. * @returns {Boolean}
  180. */
  181. export function isSelectionRectangular( selectedTableCells, tableUtils ) {
  182. if ( selectedTableCells.length < 2 || !areCellInTheSameTableSection( selectedTableCells ) ) {
  183. return false;
  184. }
  185. // A valid selection is a fully occupied rectangle composed of table cells.
  186. // Below we will calculate the area of a selected table cells and the area of valid selection.
  187. // The area of a valid selection is defined by top-left and bottom-right cells.
  188. const rows = new Set();
  189. const columns = new Set();
  190. let areaOfSelectedCells = 0;
  191. for ( const tableCell of selectedTableCells ) {
  192. const { row, column } = tableUtils.getCellLocation( tableCell );
  193. const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
  194. const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
  195. // Record row & column indexes of current cell.
  196. rows.add( row );
  197. columns.add( column );
  198. // For cells that spans over multiple rows add also the last row that this cell spans over.
  199. if ( rowspan > 1 ) {
  200. rows.add( row + rowspan - 1 );
  201. }
  202. // For cells that spans over multiple columns add also the last column that this cell spans over.
  203. if ( colspan > 1 ) {
  204. columns.add( column + colspan - 1 );
  205. }
  206. areaOfSelectedCells += ( rowspan * colspan );
  207. }
  208. // We can only merge table cells that are in adjacent rows...
  209. const areaOfValidSelection = getBiggestRectangleArea( rows, columns );
  210. return areaOfValidSelection == areaOfSelectedCells;
  211. }
  212. // TODO: refactor it to a better, general util.
  213. export function cutCellsHorizontallyAt( table, headingRowsToSet, currentHeadingRows, writer, boundingBox ) {
  214. const overlappingCells = getHorizontallyOverlappingCells( table, headingRowsToSet, currentHeadingRows );
  215. let cellsToSplit;
  216. if ( boundingBox === undefined ) {
  217. cellsToSplit = overlappingCells;
  218. } else {
  219. cellsToSplit = overlappingCells.filter( filterToBoundingBox( boundingBox ) );
  220. }
  221. for ( const { cell } of cellsToSplit ) {
  222. splitHorizontally( cell, headingRowsToSet, writer );
  223. }
  224. }
  225. // TODO: refactor it to a better, general util.
  226. export function cutCellsVerticallyAt( table, headingColumnsToSet, currentHeadingColumns, writer, boundingBox ) {
  227. const overlappingCells = getVerticallyOverlappingCells( table, headingColumnsToSet, currentHeadingColumns );
  228. let cellsToSplit;
  229. if ( boundingBox === undefined ) {
  230. cellsToSplit = overlappingCells;
  231. } else {
  232. cellsToSplit = overlappingCells.filter( filterToBoundingBox( boundingBox ) );
  233. }
  234. for ( const { cell, column } of cellsToSplit ) {
  235. splitVertically( cell, column, headingColumnsToSet, writer );
  236. }
  237. }
  238. // TODO: better fit to bounding box to match criteria.. should check also spans because sometimes we need to split them.
  239. function filterToBoundingBox( boundingBox ) {
  240. const { firstRow, firstColumn, lastRow, lastColumn } = boundingBox;
  241. return ( { row, column } ) => {
  242. return ( ( firstRow <= row ) && ( row <= lastRow ) ) && ( firstColumn <= column && column <= lastColumn );
  243. };
  244. }
  245. // Returns cells that span beyond the new heading section.
  246. //
  247. // @param {module:engine/model/element~Element} table The table to check.
  248. // @param {Number} headingRowsToSet New heading rows attribute.
  249. // @param {Number} currentHeadingRows Current heading rows attribute.
  250. // @returns {Array.<module:engine/model/element~Element>}
  251. function getHorizontallyOverlappingCells( table, headingRowsToSet, currentHeadingRows ) {
  252. const cellsToSplit = [];
  253. const startAnalysisRow = headingRowsToSet > currentHeadingRows ? currentHeadingRows : 0;
  254. // We're analyzing only when headingRowsToSet > 0.
  255. const endAnalysisRow = headingRowsToSet - 1;
  256. const tableWalker = new TableWalker( table, { startRow: startAnalysisRow, endRow: endAnalysisRow } );
  257. for ( const twv of tableWalker ) {
  258. const { row, rowspan } = twv;
  259. if ( rowspan > 1 && row + rowspan > headingRowsToSet ) {
  260. cellsToSplit.push( twv );
  261. }
  262. }
  263. return cellsToSplit;
  264. }
  265. // Splits the table cell horizontally.
  266. //
  267. // @param {module:engine/model/element~Element} tableCell
  268. // @param {Number} headingRows
  269. // @param {module:engine/model/writer~Writer} writer
  270. function splitHorizontally( tableCell, headingRows, writer ) {
  271. const tableRow = tableCell.parent;
  272. const table = tableRow.parent;
  273. const rowIndex = tableRow.index;
  274. const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) );
  275. const newRowspan = headingRows - rowIndex;
  276. const attributes = {};
  277. const spanToSet = rowspan - newRowspan;
  278. if ( spanToSet > 1 ) {
  279. attributes.rowspan = spanToSet;
  280. }
  281. const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
  282. if ( colspan > 1 ) {
  283. attributes.colspan = colspan;
  284. }
  285. const startRow = table.getChildIndex( tableRow );
  286. const endRow = startRow + newRowspan;
  287. const tableMap = [ ...new TableWalker( table, { startRow, endRow, includeSpanned: true } ) ];
  288. let columnIndex;
  289. for ( const { row, column, cell, cellIndex } of tableMap ) {
  290. if ( cell === tableCell && columnIndex === undefined ) {
  291. columnIndex = column;
  292. }
  293. if ( columnIndex !== undefined && columnIndex === column && row === endRow ) {
  294. const tableRow = table.getChild( row );
  295. const tableCellPosition = writer.createPositionAt( tableRow, cellIndex );
  296. createEmptyTableCell( writer, tableCellPosition, attributes );
  297. }
  298. }
  299. // Update the rowspan attribute after updating table.
  300. updateNumericAttribute( 'rowspan', newRowspan, tableCell, writer );
  301. }
  302. // Returns cells that span beyond the new heading section.
  303. //
  304. // @param {module:engine/model/element~Element} table The table to check.
  305. // @param {Number} headingColumnsToSet New heading columns attribute.
  306. // @param {Number} currentHeadingColumns Current heading columns attribute.
  307. // @returns {Array.<module:engine/model/element~Element>}
  308. function getVerticallyOverlappingCells( table, headingColumnsToSet, currentHeadingColumns ) {
  309. const cellsToSplit = [];
  310. const startAnalysisColumn = headingColumnsToSet > currentHeadingColumns ? currentHeadingColumns : 0;
  311. // We're analyzing only when headingColumnsToSet > 0.
  312. const endAnalysisColumn = headingColumnsToSet - 1;
  313. // todo: end/start column
  314. const tableWalker = new TableWalker( table );
  315. for ( const twv of tableWalker ) {
  316. const { column, colspan } = twv;
  317. // Skip slots outside the cropped area.
  318. // Could use startColumn, endColumn. See: https://github.com/ckeditor/ckeditor5/issues/6785.
  319. if ( startAnalysisColumn > column || column > endAnalysisColumn ) {
  320. continue;
  321. }
  322. if ( colspan > 1 && column + colspan > headingColumnsToSet ) {
  323. cellsToSplit.push( twv );
  324. }
  325. }
  326. return cellsToSplit;
  327. }
  328. // Splits the table cell vertically.
  329. //
  330. // @param {module:engine/model/element~Element} tableCell
  331. // @param {Number} headingColumns
  332. // @param {module:engine/model/writer~Writer} writer
  333. function splitVertically( tableCell, columnIndex, headingColumns, writer ) {
  334. const colspan = parseInt( tableCell.getAttribute( 'colspan' ) );
  335. const newColspan = headingColumns - columnIndex;
  336. const attributes = {};
  337. const spanToSet = colspan - newColspan;
  338. if ( spanToSet > 1 ) {
  339. attributes.colspan = spanToSet;
  340. }
  341. const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
  342. if ( rowspan > 1 ) {
  343. attributes.rowspan = rowspan;
  344. }
  345. createEmptyTableCell( writer, writer.createPositionAfter( tableCell ), attributes );
  346. // Update the colspan attribute after updating table.
  347. updateNumericAttribute( 'colspan', newColspan, tableCell, writer );
  348. }
  349. // Helper method to get an object with `first` and `last` indexes from an unsorted array of indexes.
  350. function getFirstLastIndexesObject( indexes ) {
  351. const allIndexesSorted = indexes.sort( ( indexA, indexB ) => indexA - indexB );
  352. const first = allIndexesSorted[ 0 ];
  353. const last = allIndexesSorted[ allIndexesSorted.length - 1 ];
  354. return { first, last };
  355. }
  356. function sortRanges( rangesIterator ) {
  357. return Array.from( rangesIterator ).sort( compareRangeOrder );
  358. }
  359. function compareRangeOrder( rangeA, rangeB ) {
  360. // Since table cell ranges are disjoint, it's enough to check their start positions.
  361. const posA = rangeA.start;
  362. const posB = rangeB.start;
  363. // Checking for equal position (returning 0) is not needed because this would be either:
  364. // a. Intersecting range (not allowed by model)
  365. // b. Collapsed range on the same position (allowed by model but should not happen).
  366. return posA.isBefore( posB ) ? -1 : 1;
  367. }
  368. // Calculates the area of a maximum rectangle that can span over the provided row & column indexes.
  369. //
  370. // @param {Array.<Number>} rows
  371. // @param {Array.<Number>} columns
  372. // @returns {Number}
  373. function getBiggestRectangleArea( rows, columns ) {
  374. const rowsIndexes = Array.from( rows.values() );
  375. const columnIndexes = Array.from( columns.values() );
  376. const lastRow = Math.max( ...rowsIndexes );
  377. const firstRow = Math.min( ...rowsIndexes );
  378. const lastColumn = Math.max( ...columnIndexes );
  379. const firstColumn = Math.min( ...columnIndexes );
  380. return ( lastRow - firstRow + 1 ) * ( lastColumn - firstColumn + 1 );
  381. }
  382. // Checks if the selection does not mix a header (column or row) with other cells.
  383. //
  384. // For instance, in the table below valid selections consist of cells with the same letter only.
  385. // So, a-a (same heading row and column) or d-d (body cells) are valid while c-d or a-b are not.
  386. //
  387. // header columns
  388. // ↓ ↓
  389. // ┌───┬───┬───┬───┐
  390. // │ a │ a │ b │ b │ ← header row
  391. // ├───┼───┼───┼───┤
  392. // │ c │ c │ d │ d │
  393. // ├───┼───┼───┼───┤
  394. // │ c │ c │ d │ d │
  395. // └───┴───┴───┴───┘
  396. //
  397. function areCellInTheSameTableSection( tableCells ) {
  398. const table = findAncestor( 'table', tableCells[ 0 ] );
  399. const rowIndexes = getRowIndexes( tableCells );
  400. const headingRows = parseInt( table.getAttribute( 'headingRows' ) || 0 );
  401. // Calculating row indexes is a bit cheaper so if this check fails we can't merge.
  402. if ( !areIndexesInSameSection( rowIndexes, headingRows ) ) {
  403. return false;
  404. }
  405. const headingColumns = parseInt( table.getAttribute( 'headingColumns' ) || 0 );
  406. const columnIndexes = getColumnIndexes( tableCells );
  407. // Similarly cells must be in same column section.
  408. return areIndexesInSameSection( columnIndexes, headingColumns );
  409. }
  410. // Unified check if table rows/columns indexes are in the same heading/body section.
  411. function areIndexesInSameSection( { first, last }, headingSectionSize ) {
  412. const firstCellIsInHeading = first < headingSectionSize;
  413. const lastCellIsInHeading = last < headingSectionSize;
  414. return firstCellIsInHeading === lastCellIsInHeading;
  415. }