selection.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562
  1. import Highlight, { AREA_TYPE, HEADER_TYPE, CELL_TYPE } from './highlight/highlight';
  2. import SelectionRange from './range';
  3. import { CellCoords } from './../3rdparty/walkontable/src';
  4. import { isPressedCtrlKey } from './../utils/keyStateObserver';
  5. import { createObjectPropListener, mixin } from './../helpers/object';
  6. import { isUndefined } from './../helpers/mixed';
  7. import { arrayEach } from './../helpers/array';
  8. import localHooks from './../mixins/localHooks';
  9. import Transformation from './transformation';
  10. import {
  11. detectSelectionType,
  12. isValidCoord,
  13. normalizeSelectionFactory,
  14. SELECTION_TYPE_EMPTY,
  15. SELECTION_TYPE_UNRECOGNIZED,
  16. } from './utils';
  17. import { toSingleLine } from './../helpers/templateLiteralTag';
  18. /**
  19. * @class Selection
  20. * @util
  21. */
  22. class Selection {
  23. constructor(settings, tableProps) {
  24. /**
  25. * Handsontable settings instance.
  26. *
  27. * @type {GridSettings}
  28. */
  29. this.settings = settings;
  30. /**
  31. * An additional object with dynamically defined properties which describes table state.
  32. *
  33. * @type {Object}
  34. */
  35. this.tableProps = tableProps;
  36. /**
  37. * The flag which determines if the selection is in progress.
  38. *
  39. * @type {Boolean}
  40. */
  41. this.inProgress = false;
  42. /**
  43. * The flag indicates that selection was performed by clicking the corner overlay.
  44. *
  45. * @type {Boolean}
  46. */
  47. this.selectedByCorner = false;
  48. /**
  49. * The collection of the selection layer levels where the whole row was selected using the row header.
  50. *
  51. * @type {Set.<Number>}
  52. */
  53. this.selectedByRowHeader = new Set();
  54. /**
  55. * The collection of the selection layer levels where the whole column was selected using the column header.
  56. *
  57. * @type {Set.<Number>}
  58. */
  59. this.selectedByColumnHeader = new Set();
  60. /**
  61. * Selection data layer.
  62. *
  63. * @type {SelectionRange}
  64. */
  65. this.selectedRange = new SelectionRange();
  66. /**
  67. * Visualization layer.
  68. *
  69. * @type {Highlight}
  70. */
  71. this.highlight = new Highlight({
  72. headerClassName: settings.currentHeaderClassName,
  73. activeHeaderClassName: settings.activeHeaderClassName,
  74. rowClassName: settings.currentRowClassName,
  75. columnClassName: settings.currentColClassName,
  76. disableHighlight: this.settings.disableVisualSelection,
  77. cellCornerVisible: (...args) => this.isCellCornerVisible(...args),
  78. areaCornerVisible: (...args) => this.isAreaCornerVisible(...args),
  79. });
  80. /**
  81. * The module for modifying coordinates.
  82. *
  83. * @type {Transformation}
  84. */
  85. this.transformation = new Transformation(this.selectedRange, {
  86. countRows: () => this.tableProps.countRows(),
  87. countCols: () => this.tableProps.countCols(),
  88. fixedRowsBottom: () => settings.fixedRowsBottom,
  89. minSpareRows: () => settings.minSpareRows,
  90. minSpareCols: () => settings.minSpareCols,
  91. autoWrapRow: () => settings.autoWrapRow,
  92. autoWrapCol: () => settings.autoWrapCol,
  93. });
  94. this.transformation.addLocalHook('beforeTransformStart', (...args) => this.runLocalHooks('beforeModifyTransformStart', ...args));
  95. this.transformation.addLocalHook('afterTransformStart', (...args) => this.runLocalHooks('afterModifyTransformStart', ...args));
  96. this.transformation.addLocalHook('beforeTransformEnd', (...args) => this.runLocalHooks('beforeModifyTransformEnd', ...args));
  97. this.transformation.addLocalHook('afterTransformEnd', (...args) => this.runLocalHooks('afterModifyTransformEnd', ...args));
  98. this.transformation.addLocalHook('insertRowRequire', (...args) => this.runLocalHooks('insertRowRequire', ...args));
  99. this.transformation.addLocalHook('insertColRequire', (...args) => this.runLocalHooks('insertColRequire', ...args));
  100. }
  101. /**
  102. * Get data layer for current selection.
  103. *
  104. * @return {SelectionRange}
  105. */
  106. getSelectedRange() {
  107. return this.selectedRange;
  108. }
  109. /**
  110. * Indicate that selection process began. It sets internaly `.inProgress` property to `true`.
  111. */
  112. begin() {
  113. this.inProgress = true;
  114. }
  115. /**
  116. * Indicate that selection process finished. It sets internaly `.inProgress` property to `false`.
  117. */
  118. finish() {
  119. this.runLocalHooks('afterSelectionFinished', Array.from(this.selectedRange));
  120. this.inProgress = false;
  121. }
  122. /**
  123. * Check if the process of selecting the cell/cells is in progress.
  124. *
  125. * @returns {Boolean}
  126. */
  127. isInProgress() {
  128. return this.inProgress;
  129. }
  130. /**
  131. * Starts selection range on given coordinate object.
  132. *
  133. * @param {CellCoords} coords Visual coords.
  134. * @param {Boolean} [multipleSelection] If `true`, selection will be worked in 'multiple' mode. This option works
  135. * only when 'selectionMode' is set as 'multiple'. If the argument is not defined
  136. * the default trigger will be used (isPressedCtrlKey() helper).
  137. * @param {Boolean} [fragment=false] If `true`, the selection will be treated as a partial selection where the
  138. * `setRangeEnd` method won't be called on every `setRangeStart` call.
  139. */
  140. setRangeStart(coords, multipleSelection, fragment = false) {
  141. const isMultipleMode = this.settings.selectionMode === 'multiple';
  142. const isMultipleSelection = isUndefined(multipleSelection) ? isPressedCtrlKey() : multipleSelection;
  143. const isRowNegative = coords.row < 0;
  144. const isColumnNegative = coords.col < 0;
  145. const selectedByCorner = isRowNegative && isColumnNegative;
  146. if (isRowNegative) {
  147. coords.row = 0;
  148. }
  149. if (isColumnNegative) {
  150. coords.col = 0;
  151. }
  152. this.selectedByCorner = selectedByCorner;
  153. this.runLocalHooks(`beforeSetRangeStart${fragment ? 'Only' : ''}`, coords);
  154. if (!isMultipleMode || (isMultipleMode && !isMultipleSelection && isUndefined(multipleSelection))) {
  155. this.selectedRange.clear();
  156. }
  157. this.selectedRange.add(coords);
  158. if (this.getLayerLevel() === 0) {
  159. this.selectedByRowHeader.clear();
  160. this.selectedByColumnHeader.clear();
  161. }
  162. if (!selectedByCorner && isColumnNegative) {
  163. this.selectedByRowHeader.add(this.getLayerLevel());
  164. }
  165. if (!selectedByCorner && isRowNegative) {
  166. this.selectedByColumnHeader.add(this.getLayerLevel());
  167. }
  168. if (!fragment) {
  169. this.setRangeEnd(coords);
  170. }
  171. }
  172. /**
  173. * Starts selection range on given coordinate object.
  174. *
  175. * @param {CellCoords} coords Visual coords.
  176. * @param {Boolean} [multipleSelection] If `true`, selection will be worked in 'multiple' mode. This option works
  177. * only when 'selectionMode' is set as 'multiple'. If the argument is not defined
  178. * the default trigger will be used (isPressedCtrlKey() helper).
  179. */
  180. setRangeStartOnly(coords, multipleSelection) {
  181. this.setRangeStart(coords, multipleSelection, true);
  182. }
  183. /**
  184. * Ends selection range on given coordinate object.
  185. *
  186. * @param {CellCoords} coords Visual coords.
  187. */
  188. setRangeEnd(coords) {
  189. if (this.selectedRange.isEmpty()) {
  190. return;
  191. }
  192. this.runLocalHooks('beforeSetRangeEnd', coords);
  193. this.begin();
  194. const cellRange = this.selectedRange.current();
  195. if (this.settings.selectionMode !== 'single') {
  196. cellRange.setTo(new CellCoords(coords.row, coords.col));
  197. }
  198. // Set up current selection.
  199. this.highlight.getCell().clear();
  200. if (this.highlight.isEnabledFor(CELL_TYPE)) {
  201. this.highlight.getCell().add(this.selectedRange.current().highlight);
  202. }
  203. const layerLevel = this.getLayerLevel();
  204. // If the next layer level is lower than previous then clear all area and header highlights. This is the
  205. // indication that the new selection is performing.
  206. if (layerLevel < this.highlight.layerLevel) {
  207. arrayEach(this.highlight.getAreas(), highlight => void highlight.clear());
  208. arrayEach(this.highlight.getHeaders(), highlight => void highlight.clear());
  209. arrayEach(this.highlight.getActiveHeaders(), highlight => void highlight.clear());
  210. }
  211. this.highlight.useLayerLevel(layerLevel);
  212. const areaHighlight = this.highlight.createOrGetArea();
  213. const headerHighlight = this.highlight.createOrGetHeader();
  214. const activeHeaderHighlight = this.highlight.createOrGetActiveHeader();
  215. areaHighlight.clear();
  216. headerHighlight.clear();
  217. activeHeaderHighlight.clear();
  218. if (this.highlight.isEnabledFor(AREA_TYPE) && (this.isMultiple() || layerLevel >= 1)) {
  219. areaHighlight
  220. .add(cellRange.from)
  221. .add(cellRange.to);
  222. if (layerLevel === 1) {
  223. // For single cell selection in the same layer, we do not create area selection to prevent blue background.
  224. // When non-consecutive selection is performed we have to add that missing area selection to the previous layer
  225. // based on previous coordinates. It only occurs when the previous selection wasn't select multiple cells.
  226. this.highlight
  227. .useLayerLevel(layerLevel - 1)
  228. .createOrGetArea()
  229. .add(this.selectedRange.previous().from);
  230. this.highlight.useLayerLevel(layerLevel);
  231. }
  232. }
  233. if (this.highlight.isEnabledFor(HEADER_TYPE)) {
  234. if (this.settings.selectionMode === 'single') {
  235. headerHighlight.add(cellRange.highlight);
  236. } else {
  237. headerHighlight
  238. .add(cellRange.from)
  239. .add(cellRange.to);
  240. }
  241. }
  242. if (this.isSelectedByRowHeader()) {
  243. const isRowSelected = this.tableProps.countCols() === cellRange.getWidth();
  244. // Make sure that the whole row is selected (in case where selectionMode is set to 'single')
  245. if (isRowSelected) {
  246. activeHeaderHighlight
  247. .add(new CellCoords(cellRange.from.row, -1))
  248. .add(new CellCoords(cellRange.to.row, -1));
  249. }
  250. }
  251. if (this.isSelectedByColumnHeader()) {
  252. const isColumnSelected = this.tableProps.countRows() === cellRange.getHeight();
  253. // Make sure that the whole column is selected (in case where selectionMode is set to 'single')
  254. if (isColumnSelected) {
  255. activeHeaderHighlight
  256. .add(new CellCoords(-1, cellRange.from.col))
  257. .add(new CellCoords(-1, cellRange.to.col));
  258. }
  259. }
  260. this.runLocalHooks('afterSetRangeEnd', coords);
  261. }
  262. /**
  263. * Returns information if we have a multiselection. This method check multiselection only on the latest layer of
  264. * the selection.
  265. *
  266. * @returns {Boolean}
  267. */
  268. isMultiple() {
  269. const isMultipleListener = createObjectPropListener(!this.selectedRange.current().isSingle());
  270. this.runLocalHooks('afterIsMultipleSelection', isMultipleListener);
  271. return isMultipleListener.value;
  272. }
  273. /**
  274. * Selects cell relative to the current cell (if possible).
  275. *
  276. * @param {Number} rowDelta Rows number to move, value can be passed as negative number.
  277. * @param {Number} colDelta Columns number to move, value can be passed as negative number.
  278. * @param {Boolean} force If `true` the new rows/columns will be created if necessary. Otherwise, row/column will
  279. * be created according to `minSpareRows/minSpareCols` settings of Handsontable.
  280. */
  281. transformStart(rowDelta, colDelta, force) {
  282. this.setRangeStart(this.transformation.transformStart(rowDelta, colDelta, force));
  283. }
  284. /**
  285. * Sets selection end cell relative to the current selection end cell (if possible).
  286. *
  287. * @param {Number} rowDelta Rows number to move, value can be passed as negative number.
  288. * @param {Number} colDelta Columns number to move, value can be passed as negative number.
  289. */
  290. transformEnd(rowDelta, colDelta) {
  291. this.setRangeEnd(this.transformation.transformEnd(rowDelta, colDelta));
  292. }
  293. /**
  294. * Returns currently used layer level.
  295. *
  296. * @return {Number} Returns layer level starting from 0. If no selection was added to the table -1 is returned.
  297. */
  298. getLayerLevel() {
  299. return this.selectedRange.size() - 1;
  300. }
  301. /**
  302. * Returns `true` if currently there is a selection on the screen, `false` otherwise.
  303. *
  304. * @returns {Boolean}
  305. */
  306. isSelected() {
  307. return !this.selectedRange.isEmpty();
  308. }
  309. /**
  310. * Returns `true` if the selection was applied by clicking to the row header. If the `layerLevel`
  311. * argument is passed then only that layer will be checked. Otherwise, it checks if any row header
  312. * was clicked on any selection layer level.
  313. *
  314. * @param {Number} [layerLevel=this.getLayerLevel()] Selection layer level to check.
  315. * @return {Boolean}
  316. */
  317. isSelectedByRowHeader(layerLevel = this.getLayerLevel()) {
  318. return layerLevel === -1 ? this.selectedByRowHeader.size > 0 : this.selectedByRowHeader.has(layerLevel);
  319. }
  320. /**
  321. * Returns `true` if the selection was applied by clicking to the column header. If the `layerLevel`
  322. * argument is passed then only that layer will be checked. Otherwise, it checks if any column header
  323. * was clicked on any selection layer level.
  324. *
  325. * @param {Number} [layerLevel=this.getLayerLevel()] Selection layer level to check.
  326. * @return {Boolean}
  327. */
  328. isSelectedByColumnHeader(layerLevel = this.getLayerLevel()) {
  329. return layerLevel === -1 ? this.selectedByColumnHeader.size > 0 : this.selectedByColumnHeader.has(layerLevel);
  330. }
  331. /**
  332. * Returns `true` if the selection was applied by clicking on the row or column header on any layer level.
  333. *
  334. * @return {Boolean}
  335. */
  336. isSelectedByAnyHeader() {
  337. return this.isSelectedByRowHeader(-1) || this.isSelectedByColumnHeader(-1);
  338. }
  339. /**
  340. * Returns `true` if the selection was applied by clicking on the left-top corner overlay.
  341. *
  342. * @return {Boolean}
  343. */
  344. isSelectedByCorner() {
  345. return this.selectedByCorner;
  346. }
  347. /**
  348. * Returns `true` if coords is within selection coords. This method iterates through all selection layers to check if
  349. * the coords object is within selection range.
  350. *
  351. * @param {CellCoords} coords The CellCoords instance with defined visual coordinates.
  352. * @returns {Boolean}
  353. */
  354. inInSelection(coords) {
  355. return this.selectedRange.includes(coords);
  356. }
  357. /**
  358. * Returns `true` if the cell corner should be visible.
  359. *
  360. * @private
  361. * @return {Boolean} `true` if the corner element has to be visible, `false` otherwise.
  362. */
  363. isCellCornerVisible() {
  364. return this.settings.fillHandle && !this.tableProps.isEditorOpened() && !this.isMultiple();
  365. }
  366. /**
  367. * Returns `true` if the area corner should be visible.
  368. *
  369. * @param {Number} layerLevel The layer level.
  370. * @return {Boolean} `true` if the corner element has to be visible, `false` otherwise.
  371. */
  372. isAreaCornerVisible(layerLevel) {
  373. if (Number.isInteger(layerLevel) && layerLevel !== this.getLayerLevel()) {
  374. return false;
  375. }
  376. return this.settings.fillHandle && !this.tableProps.isEditorOpened() && this.isMultiple();
  377. }
  378. /**
  379. * Clear the selection by resetting the collected ranges and highlights.
  380. */
  381. clear() {
  382. this.selectedRange.clear();
  383. this.highlight.clear();
  384. }
  385. /**
  386. * Deselects all selected cells.
  387. */
  388. deselect() {
  389. if (!this.isSelected()) {
  390. return;
  391. }
  392. this.inProgress = false;
  393. this.clear();
  394. this.runLocalHooks('afterDeselect');
  395. }
  396. /**
  397. * Select all cells.
  398. */
  399. selectAll() {
  400. this.clear();
  401. this.setRangeStart(new CellCoords(-1, -1));
  402. this.selectedByRowHeader.add(this.getLayerLevel());
  403. this.selectedByColumnHeader.add(this.getLayerLevel());
  404. this.setRangeEnd(new CellCoords(this.tableProps.countRows() - 1, this.tableProps.countCols() - 1));
  405. }
  406. /**
  407. * Make multiple, non-contiguous selection specified by `row` and `column` values or a range of cells
  408. * finishing at `endRow`, `endColumn`. The method supports two input formats, first as an array of arrays such
  409. * as `[[rowStart, columnStart, rowEnd, columnEnd]]` and second format as an array of CellRange objects.
  410. * If the passed ranges have another format the exception will be thrown.
  411. *
  412. * @param {Array[]|CellRange[]} selectionRanges The coordinates which define what the cells should be selected.
  413. * @return {Boolean} Returns `true` if selection was successful, `false` otherwise.
  414. */
  415. selectCells(selectionRanges) {
  416. const selectionType = detectSelectionType(selectionRanges);
  417. if (selectionType === SELECTION_TYPE_EMPTY) {
  418. return false;
  419. } else if (selectionType === SELECTION_TYPE_UNRECOGNIZED) {
  420. throw new Error(toSingleLine`Unsupported format of the selection ranges was passed. To select cells pass
  421. the coordinates as an array of arrays ([[rowStart, columnStart/columnPropStart, rowEnd, columnEnd/columnPropEnd]])
  422. or as an array of CellRange objects.`);
  423. }
  424. const selectionSchemaNormalizer = normalizeSelectionFactory(selectionType, {
  425. propToCol: prop => this.tableProps.propToCol(prop),
  426. keepDirection: true,
  427. });
  428. const countRows = this.tableProps.countRows();
  429. const countCols = this.tableProps.countCols();
  430. // Check if every layer of the coordinates are valid.
  431. const isValid = !selectionRanges.some((selection) => {
  432. const [rowStart, columnStart, rowEnd, columnEnd] = selectionSchemaNormalizer(selection);
  433. const _isValid = isValidCoord(rowStart, countRows) &&
  434. isValidCoord(columnStart, countCols) &&
  435. isValidCoord(rowEnd, countRows) &&
  436. isValidCoord(columnEnd, countCols);
  437. return !_isValid;
  438. });
  439. if (isValid) {
  440. this.clear();
  441. arrayEach(selectionRanges, (selection) => {
  442. const [rowStart, columnStart, rowEnd, columnEnd] = selectionSchemaNormalizer(selection);
  443. this.setRangeStartOnly(new CellCoords(rowStart, columnStart), false);
  444. this.setRangeEnd(new CellCoords(rowEnd, columnEnd));
  445. this.finish();
  446. });
  447. }
  448. return isValid;
  449. }
  450. /**
  451. * Select column specified by `startColumn` visual index or column property or a range of columns finishing at `endColumn`.
  452. *
  453. * @param {Number|String} startColumn Visual column index or column property from which the selection starts.
  454. * @param {Number|String} [endColumn] Visual column index or column property from to the selection finishes.
  455. * @returns {Boolean} Returns `true` if selection was successful, `false` otherwise.
  456. */
  457. selectColumns(startColumn, endColumn = startColumn) {
  458. const start = typeof startColumn === 'string' ? this.tableProps.propToCol(startColumn) : startColumn;
  459. const end = typeof endColumn === 'string' ? this.tableProps.propToCol(endColumn) : endColumn;
  460. const countCols = this.tableProps.countCols();
  461. const isValid = isValidCoord(start, countCols) && isValidCoord(end, countCols);
  462. if (isValid) {
  463. this.setRangeStartOnly(new CellCoords(-1, start));
  464. this.setRangeEnd(new CellCoords(this.tableProps.countRows() - 1, end));
  465. this.finish();
  466. }
  467. return isValid;
  468. }
  469. /**
  470. * Select row specified by `startRow` visual index or a range of rows finishing at `endRow`.
  471. *
  472. * @param {Number} startRow Visual row index from which the selection starts.
  473. * @param {Number} [endRow] Visual row index from to the selection finishes.
  474. * @returns {Boolean} Returns `true` if selection was successful, `false` otherwise.
  475. */
  476. selectRows(startRow, endRow = startRow) {
  477. const countRows = this.tableProps.countRows();
  478. const isValid = isValidCoord(startRow, countRows) && isValidCoord(endRow, countRows);
  479. if (isValid) {
  480. this.setRangeStartOnly(new CellCoords(startRow, -1));
  481. this.setRangeEnd(new CellCoords(endRow, this.tableProps.countCols() - 1));
  482. this.finish();
  483. }
  484. return isValid;
  485. }
  486. }
  487. mixin(Selection, localHooks);
  488. export default Selection;