selection.js 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  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/selection
  7. */
  8. import TableWalker from '../tablewalker';
  9. /**
  10. * Returns all model table cells that are fully selected (from the outside)
  11. * within the provided model selection's ranges.
  12. *
  13. * To obtain the cells selected from the inside, use
  14. * {@link module:table/utils/selection~getTableCellsContainingSelection}.
  15. *
  16. * @param {module:engine/model/selection~Selection} selection
  17. * @returns {Array.<module:engine/model/element~Element>}
  18. */
  19. export function getSelectedTableCells( selection ) {
  20. const cells = [];
  21. for ( const range of sortRanges( selection.getRanges() ) ) {
  22. const element = range.getContainedElement();
  23. if ( element && element.is( 'tableCell' ) ) {
  24. cells.push( element );
  25. }
  26. }
  27. return cells;
  28. }
  29. /**
  30. * Returns all model table cells that the provided model selection's ranges
  31. * {@link module:engine/model/range~Range#start} inside.
  32. *
  33. * To obtain the cells selected from the outside, use
  34. * {@link module:table/utils/selection~getSelectedTableCells}.
  35. *
  36. * @param {module:engine/model/selection~Selection} selection
  37. * @returns {Array.<module:engine/model/element~Element>}
  38. */
  39. export function getTableCellsContainingSelection( selection ) {
  40. const cells = [];
  41. for ( const range of selection.getRanges() ) {
  42. const cellWithSelection = range.start.findAncestor( 'tableCell' );
  43. if ( cellWithSelection ) {
  44. cells.push( cellWithSelection );
  45. }
  46. }
  47. return cells;
  48. }
  49. /**
  50. * Returns all model table cells that are either completely selected
  51. * by selection ranges or host selection range
  52. * {@link module:engine/model/range~Range#start start positions} inside them.
  53. *
  54. * Combines {@link module:table/utils/selection~getTableCellsContainingSelection} and
  55. * {@link module:table/utils/selection~getSelectedTableCells}.
  56. *
  57. * @param {module:engine/model/selection~Selection} selection
  58. * @returns {Array.<module:engine/model/element~Element>}
  59. */
  60. export function getSelectionAffectedTableCells( selection ) {
  61. const selectedCells = getSelectedTableCells( selection );
  62. if ( selectedCells.length ) {
  63. return selectedCells;
  64. }
  65. return getTableCellsContainingSelection( selection );
  66. }
  67. /**
  68. * Returns an object with the `first` and `last` row index contained in the given `tableCells`.
  69. *
  70. * const selectedTableCells = getSelectedTableCells( editor.model.document.selection );
  71. *
  72. * const { first, last } = getRowIndexes( selectedTableCells );
  73. *
  74. * console.log( `Selected rows: ${ first } to ${ last }` );
  75. *
  76. * @param {Array.<module:engine/model/element~Element>} tableCells
  77. * @returns {Object} Returns an object with the `first` and `last` table row indexes.
  78. */
  79. export function getRowIndexes( tableCells ) {
  80. const indexes = tableCells.map( cell => cell.parent.index );
  81. return getFirstLastIndexesObject( indexes );
  82. }
  83. /**
  84. * Returns an object with the `first` and `last` column index contained in the given `tableCells`.
  85. *
  86. * const selectedTableCells = getSelectedTableCells( editor.model.document.selection );
  87. *
  88. * const { first, last } = getColumnIndexes( selectedTableCells );
  89. *
  90. * console.log( `Selected columns: ${ first } to ${ last }` );
  91. *
  92. * @param {Array.<module:engine/model/element~Element>} tableCells
  93. * @returns {Object} Returns an object with the `first` and `last` table column indexes.
  94. */
  95. export function getColumnIndexes( tableCells ) {
  96. const table = tableCells[ 0 ].findAncestor( 'table' );
  97. const tableMap = [ ...new TableWalker( table ) ];
  98. const indexes = tableMap
  99. .filter( entry => tableCells.includes( entry.cell ) )
  100. .map( entry => entry.column );
  101. return getFirstLastIndexesObject( indexes );
  102. }
  103. /**
  104. * Checks if the selection contains cells that do not exceed rectangular selection.
  105. *
  106. * In a table below:
  107. *
  108. * ┌───┬───┬───┬───┐
  109. * │ a │ b │ c │ d │
  110. * ├───┴───┼───┤ │
  111. * │ e │ f │ │
  112. * │ ├───┼───┤
  113. * │ │ g │ h │
  114. * └───────┴───┴───┘
  115. *
  116. * Valid selections are these which create a solid rectangle (without gaps), such as:
  117. * - a, b (two horizontal cells)
  118. * - c, f (two vertical cells)
  119. * - a, b, e (cell "e" spans over four cells)
  120. * - c, d, f (cell d spans over a cell in the row below)
  121. *
  122. * While an invalid selection would be:
  123. * - a, c (the unselected cell "b" creates a gap)
  124. * - f, g, h (cell "d" spans over a cell from the row of "f" cell - thus creates a gap)
  125. *
  126. * @param {Array.<module:engine/model/element~Element>} selectedTableCells
  127. * @param {module:table/tableutils~TableUtils} tableUtils
  128. * @returns {Boolean}
  129. */
  130. export function isSelectionRectangular( selectedTableCells, tableUtils ) {
  131. if ( selectedTableCells.length < 2 || !areCellInTheSameTableSection( selectedTableCells ) ) {
  132. return false;
  133. }
  134. // A valid selection is a fully occupied rectangle composed of table cells.
  135. // Below we will calculate the area of a selected table cells and the area of valid selection.
  136. // The area of a valid selection is defined by top-left and bottom-right cells.
  137. const rows = new Set();
  138. const columns = new Set();
  139. let areaOfSelectedCells = 0;
  140. for ( const tableCell of selectedTableCells ) {
  141. const { row, column } = tableUtils.getCellLocation( tableCell );
  142. const rowspan = parseInt( tableCell.getAttribute( 'rowspan' ) || 1 );
  143. const colspan = parseInt( tableCell.getAttribute( 'colspan' ) || 1 );
  144. // Record row & column indexes of current cell.
  145. rows.add( row );
  146. columns.add( column );
  147. // For cells that spans over multiple rows add also the last row that this cell spans over.
  148. if ( rowspan > 1 ) {
  149. rows.add( row + rowspan - 1 );
  150. }
  151. // For cells that spans over multiple columns add also the last column that this cell spans over.
  152. if ( colspan > 1 ) {
  153. columns.add( column + colspan - 1 );
  154. }
  155. areaOfSelectedCells += ( rowspan * colspan );
  156. }
  157. // We can only merge table cells that are in adjacent rows...
  158. const areaOfValidSelection = getBiggestRectangleArea( rows, columns );
  159. return areaOfValidSelection == areaOfSelectedCells;
  160. }
  161. /**
  162. * Returns array of sorted ranges.
  163. *
  164. * @param {Iterable.<module:engine/model/range~Range>} ranges
  165. * @return {Array.<module:engine/model/range~Range>}
  166. */
  167. export function sortRanges( ranges ) {
  168. return Array.from( ranges ).sort( compareRangeOrder );
  169. }
  170. // Helper method to get an object with `first` and `last` indexes from an unsorted array of indexes.
  171. function getFirstLastIndexesObject( indexes ) {
  172. const allIndexesSorted = indexes.sort( ( indexA, indexB ) => indexA - indexB );
  173. const first = allIndexesSorted[ 0 ];
  174. const last = allIndexesSorted[ allIndexesSorted.length - 1 ];
  175. return { first, last };
  176. }
  177. function compareRangeOrder( rangeA, rangeB ) {
  178. // Since table cell ranges are disjoint, it's enough to check their start positions.
  179. const posA = rangeA.start;
  180. const posB = rangeB.start;
  181. // Checking for equal position (returning 0) is not needed because this would be either:
  182. // a. Intersecting range (not allowed by model)
  183. // b. Collapsed range on the same position (allowed by model but should not happen).
  184. return posA.isBefore( posB ) ? -1 : 1;
  185. }
  186. // Calculates the area of a maximum rectangle that can span over the provided row & column indexes.
  187. //
  188. // @param {Array.<Number>} rows
  189. // @param {Array.<Number>} columns
  190. // @returns {Number}
  191. function getBiggestRectangleArea( rows, columns ) {
  192. const rowsIndexes = Array.from( rows.values() );
  193. const columnIndexes = Array.from( columns.values() );
  194. const lastRow = Math.max( ...rowsIndexes );
  195. const firstRow = Math.min( ...rowsIndexes );
  196. const lastColumn = Math.max( ...columnIndexes );
  197. const firstColumn = Math.min( ...columnIndexes );
  198. return ( lastRow - firstRow + 1 ) * ( lastColumn - firstColumn + 1 );
  199. }
  200. // Checks if the selection does not mix a header (column or row) with other cells.
  201. //
  202. // For instance, in the table below valid selections consist of cells with the same letter only.
  203. // So, a-a (same heading row and column) or d-d (body cells) are valid while c-d or a-b are not.
  204. //
  205. // header columns
  206. // ↓ ↓
  207. // ┌───┬───┬───┬───┐
  208. // │ a │ a │ b │ b │ ← header row
  209. // ├───┼───┼───┼───┤
  210. // │ c │ c │ d │ d │
  211. // ├───┼───┼───┼───┤
  212. // │ c │ c │ d │ d │
  213. // └───┴───┴───┴───┘
  214. function areCellInTheSameTableSection( tableCells ) {
  215. const table = tableCells[ 0 ].findAncestor( 'table' );
  216. const rowIndexes = getRowIndexes( tableCells );
  217. const headingRows = parseInt( table.getAttribute( 'headingRows' ) || 0 );
  218. // Calculating row indexes is a bit cheaper so if this check fails we can't merge.
  219. if ( !areIndexesInSameSection( rowIndexes, headingRows ) ) {
  220. return false;
  221. }
  222. const headingColumns = parseInt( table.getAttribute( 'headingColumns' ) || 0 );
  223. const columnIndexes = getColumnIndexes( tableCells );
  224. // Similarly cells must be in same column section.
  225. return areIndexesInSameSection( columnIndexes, headingColumns );
  226. }
  227. // Unified check if table rows/columns indexes are in the same heading/body section.
  228. function areIndexesInSameSection( { first, last }, headingSectionSize ) {
  229. const firstCellIsInHeading = first < headingSectionSize;
  230. const lastCellIsInHeading = last < headingSectionSize;
  231. return firstCellIsInHeading === lastCellIsInHeading;
  232. }