UNPKG

slickgrid

Version:

A lightning fast JavaScript grid/spreadsheet

572 lines (499 loc) 21.8 kB
import { keyCode as keyCode_, SlickEvent as SlickEvent_, SlickEventData as SlickEventData_, SlickEventHandler as SlickEventHandler_, SlickRange as SlickRange_, Utils as Utils_ } from '../slick.core.js'; import { Draggable as Draggable_ } from '../slick.interactions.js'; import { SlickCellRangeDecorator as SlickCellRangeDecorator_ } from './slick.cellrangedecorator.js'; import { SlickCellRangeSelector as SlickCellRangeSelector_ } from './slick.cellrangeselector.js'; import type { CustomDataView, HybridSelectionModelOption, OnActiveCellChangedEventArgs, SelectionModel } from '../models/index.js'; import type { SlickDataView } from '../slick.dataview.js'; import type { SlickCrossGridRowMoveManager as SlickCrossGridRowMoveManager_ } from './slick.crossgridrowmovemanager.js'; import type { SlickRowMoveManager as SlickRowMoveManager_ } from './slick.rowmovemanager.js'; import type { SlickGrid } from '../slick.grid.js'; // for (iife) load Slick methods from global Slick object, or use imports for (esm) const Draggable = IIFE_ONLY ? Slick.Draggable : Draggable_; const keyCode = IIFE_ONLY ? Slick.keyCode : keyCode_; const SlickEvent = IIFE_ONLY ? Slick.Event : SlickEvent_; const SlickEventData = IIFE_ONLY ? Slick.EventData : SlickEventData_; const SlickEventHandler = IIFE_ONLY ? Slick.EventHandler : SlickEventHandler_; const SlickRange = IIFE_ONLY ? Slick.Range : SlickRange_; const SlickCellRangeDecorator = IIFE_ONLY ? Slick.CellRangeDecorator : SlickCellRangeDecorator_; const SlickCellRangeSelector = IIFE_ONLY ? Slick.CellRangeSelector : SlickCellRangeSelector_; const Utils = IIFE_ONLY ? Slick.Utils : Utils_; export class SlickHybridSelectionModel implements SelectionModel { // hybrid selection model is CellSelectionModel except when selecting // specific columns, which behave as RowSelectionModel // -- // public API pluginName = 'HybridSelectionModel' as const; onSelectedRangesChanged = new SlickEvent<SlickRange_[]>('onSelectedRangesChanged'); // -- // protected props protected _cachedPageRowCount = 0; protected _dataView?: CustomDataView | SlickDataView; protected _eventHandler = new SlickEventHandler(); protected _grid!: SlickGrid; protected _prevSelectedRow?: number; protected _prevKeyDown = ''; protected _ranges: SlickRange_[] = []; protected _selector?: SlickCellRangeSelector_; protected _isRowMoveManagerHandler: any; protected _activeSelectionIsRow = false; protected _options: HybridSelectionModelOption; protected _defaults: HybridSelectionModelOption = { selectActiveCell: true, selectActiveRow: true, dragToSelect: false, autoScrollWhenDrag: true, handleRowMoveManagerColumn: true, // Row Selection on RowMoveManage column rowSelectColumnIds: [], // Row Selection on these columns rowSelectOverride: undefined, // function to toggle Row Selection Models cellRangeSelector: undefined, selectionType: 'mixed', }; constructor(options?: HybridSelectionModelOption) { this._options = Utils.extend(true, {}, this._defaults, options); } // Region: Setup // ----------------------------------------------------------------------------- init(grid: SlickGrid) { if (Draggable === undefined) { throw new Error('Slick.Draggable is undefined, make sure to import "slick.interactions.js"'); } this._grid = grid; Utils.addSlickEventPubSubWhenDefined(grid.getPubSubService(), this); if (this._options?.selectionType === 'cell') { this._activeSelectionIsRow = false; } else if (this._options?.selectionType === 'row') { this._activeSelectionIsRow = true; } if (!this._selector && (!this._activeSelectionIsRow || (this._activeSelectionIsRow && this._options.dragToSelect))) { if (!SlickCellRangeDecorator) { throw new Error('Slick.CellRangeDecorator is required when option dragToSelect set to true'); } this._selector = new SlickCellRangeSelector( this._options?.dragToSelect ? { selectionCss: { border: 'none' } as CSSStyleDeclaration, autoScroll: this._options?.autoScrollWhenDrag, } : { selectionCss: { border: '2px solid gray' } as CSSStyleDeclaration, copyToSelectionCss: { border: '2px solid purple' } as CSSStyleDeclaration, } ); this._options.cellRangeSelector = this._selector; } if (grid.hasDataView()) { this._dataView = grid.getData<CustomDataView | SlickDataView>(); } this._eventHandler .subscribe(this._grid.onActiveCellChanged, this.handleActiveCellChange.bind(this)) .subscribe(this._grid.onClick, this.handleClick.bind(this)) .subscribe(this._grid.onKeyDown, this.handleKeyDown.bind(this)) if (this._selector) { grid.registerPlugin(this._selector); this._eventHandler .subscribe(this._selector.onCellRangeSelecting, (e, args) => this.handleCellRangeSelected(e, { ...args, caller: 'onCellRangeSelecting' })) .subscribe(this._selector.onCellRangeSelected, (e, args) => this.handleCellRangeSelected(e, { ...args, caller: 'onCellRangeSelected' })) this._selector.onBeforeCellRangeSelected.subscribe(this.handleBeforeCellRangeSelected.bind(this)); } } destroy() { this._eventHandler.unsubscribeAll(); if (this._selector) { this._grid?.unregisterPlugin(this._selector); } this._selector?.destroy(); } getOptions(): HybridSelectionModelOption { return this._options; } // Region: CellSelectionModel Members // ----------------------------------------------------------------------------- protected removeInvalidRanges(ranges: SlickRange_[]) { const result: SlickRange_[] = []; for (let i = 0; i < ranges.length; i++) { const r = ranges[i]; if (this._grid.canCellBeSelected(r.fromRow, r.fromCell) && this._grid.canCellBeSelected(r.toRow, r.toCell)) { result.push(r); } } return result; } protected rangesAreEqual(range1: SlickRange_[], range2: SlickRange_[]) { let areDifferent = (range1.length !== range2.length); if (!areDifferent) { for (let i = 0; i < range1.length; i++) { if ( range1[i].fromCell !== range2[i].fromCell || range1[i].fromRow !== range2[i].fromRow || range1[i].toCell !== range2[i].toCell || range1[i].toRow !== range2[i].toRow ) { areDifferent = true; break; } } } return !areDifferent; } // Region: RowSelectionModel Members // ----------------------------------------------------------------------------- protected rangesToRows(ranges: SlickRange_[]): number[] { const rows: number[] = []; for (let i = 0; i < ranges.length; i++) { for (let j = ranges[i].fromRow; j <= ranges[i].toRow; j++) { rows.push(j); } } return rows; } protected rowsToRanges(rows: number[]) { const ranges: SlickRange_[] = []; const lastCell = this._grid.getColumns().length - 1; rows.forEach(row => ranges.push(new SlickRange(row, 0, row, lastCell))); return ranges; } protected getRowsRange(from: number, to: number) { let i; const rows: number[] = []; for (i = from; i <= to; i++) { rows.push(i); } for (i = to; i < from; i++) { rows.push(i); } return rows; } getCellRangeSelector(): SlickCellRangeSelector_ | undefined { return this._selector; } getSelectedRanges(): SlickRange_[] { return this._ranges; } getSelectedRows(): number[] { return this.rangesToRows(this._ranges); } setSelectedRows(rows: number[]): void { this.setSelectedRanges(this.rowsToRanges(rows), 'SlickHybridSelectionModel.setSelectedRows', ''); } // Region: Shared Members // ----------------------------------------------------------------------------- /** Provide a way to force a recalculation of page row count (for example on grid resize) */ resetPageRowCount() { this._cachedPageRowCount = 0; } setSelectedRanges(ranges: SlickRange_[], caller = 'SlickHybridSelectionModel.setSelectedRanges', selectionMode = '') { // simple check for: empty selection didn't change, prevent firing onSelectedRangesChanged if ((!this._ranges || this._ranges.length === 0) && (!ranges || ranges.length === 0)) { return; } // if range has not changed, don't fire onSelectedRangesChanged const rangeHasChanged = !this.rangesAreEqual(this._ranges, ranges); if (this._activeSelectionIsRow) { this._ranges = ranges; // provide extra "caller" argument through SlickEventData event to avoid breaking the previous pubsub event structure // that only accepts an array of selected range `SlickRange[]`, the SlickEventData args will be merged and used later by `onSelectedRowsChanged` const eventData = new SlickEventData(new CustomEvent('click', { detail: { caller, selectionMode } }), this._ranges); this.onSelectedRangesChanged.notify(this._ranges, eventData); } else { this._ranges = this.removeInvalidRanges(ranges); if (rangeHasChanged) { // provide extra "caller" argument through SlickEventData event to avoid breaking the previous pubsub event structure // that only accepts an array of selected range `SlickRange[]`, the SlickEventData args will be merged and used later by `onSelectedRowsChanged` const eventData = new SlickEventData(new CustomEvent('click', { detail: { caller, selectionMode, addDragHandle: true } }), this._ranges); this.onSelectedRangesChanged.notify(this._ranges, eventData); } } } currentSelectionModeIsRow() { return this._activeSelectionIsRow; } refreshSelections() { if (this._activeSelectionIsRow) { this.setSelectedRows(this.getSelectedRows()); } else { this.setSelectedRanges(this.getSelectedRanges(), undefined, ''); } } getRowMoveManagerPlugin(): SlickRowMoveManager_ | SlickCrossGridRowMoveManager_ | undefined { return this._grid.getPluginByName('RowMoveManager') || this._grid.getPluginByName('CrossGridRowMoveManager'); } rowSelectionModelIsActive(data: OnActiveCellChangedEventArgs): boolean { if (this._options?.selectionType === 'cell') { return false; } else if (this._options?.selectionType === 'row') { return true; } // work out required selection mode if (this._options?.rowSelectOverride) { return this._options?.rowSelectOverride(data, this, this._grid); } if (!Utils.isDefined(data.cell)) { return false; } if (this._options?.handleRowMoveManagerColumn) { const rowMoveManager = this.getRowMoveManagerPlugin(); if (rowMoveManager?.isHandlerColumn(data.cell)) { return true; } } const targetColumn = this._grid.getVisibleColumns()[data.cell]; if (targetColumn) { return this._options?.rowSelectColumnIds?.includes('' + targetColumn.id) || false; } return false; } protected handleActiveCellChange(_e: SlickEventData_, args: OnActiveCellChangedEventArgs) { this._prevSelectedRow = undefined; const isCellDefined = Utils.isDefined(args.cell); const isRowDefined = Utils.isDefined(args.row); this._activeSelectionIsRow = this.rowSelectionModelIsActive(args); if (this._activeSelectionIsRow) { if (this._options?.selectActiveRow && isRowDefined) { this.setSelectedRanges([new SlickRange(args.row, 0, args.row, this._grid.getColumns().length - 1)], undefined, ''); } } else { if (this._options?.selectActiveCell && isRowDefined && isCellDefined) { // if any row selections are visible, leave them untouched unless `selectActiveCell` is enabled if (this._options.selectActiveRow) { this.setSelectedRanges([new SlickRange(args.row, args.cell)], undefined, ''); } } else if (!this._options?.selectActiveCell || (!isRowDefined && !isCellDefined)) { // clear the previous selection once the cell changes this.setSelectedRanges([], undefined, ''); } } } protected isKeyAllowed(key: string, isShiftKeyPressed?: boolean): boolean { return [ 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'PageDown', 'PageUp', 'Home', 'End', ...(!isShiftKeyPressed ? ['a', 'A'] : []), ].some((k) => k === key); } protected handleKeyDown(e: SlickEventData_) { if (!this._activeSelectionIsRow) { let ranges: SlickRange_[], last: SlickRange_; const colLn = this._grid.getColumns().length; const active = this._grid.getActiveCell(); let dataLn = 0; if (this._dataView && 'getPagingInfo' in this._dataView) { dataLn = this._dataView?.getPagingInfo().pageSize || this._dataView.getLength(); } else { dataLn = this._grid.getDataLength(); } if (active && (e.shiftKey || e.ctrlKey) && !e.altKey && this.isKeyAllowed(e.key as string, e.shiftKey)) { ranges = this.getSelectedRanges().slice(); if (!ranges.length) { ranges.push(new SlickRange(active.row, active.cell)); } // keyboard can work with last range only last = ranges.pop() as SlickRange_; // can't handle selection out of active cell if (!last.contains(active.row, active.cell)) { last = new SlickRange(active.row, active.cell); } let dRow = last.toRow - last.fromRow; let dCell = last.toCell - last.fromCell; let toCell: undefined | number; let toRow = 0; // when using Ctrl+{a, A} we will change our position to cell 0,0 and select all grid cells if (e.ctrlKey && e.key?.toLowerCase() === 'a') { this._grid.setActiveCell(0, 0, false, false, true); active.row = 0; active.cell = 0; toCell = colLn - 1; toRow = dataLn - 1; } // walking direction const dirRow = active.row === last.fromRow ? 1 : -1; const dirCell = active.cell === last.fromCell ? 1 : -1; const isSingleKeyMove = e.key!.startsWith('Arrow'); if (isSingleKeyMove && !e.ctrlKey) { // single cell move: (Arrow{Up/ArrowDown/ArrowLeft/ArrowRight}) if (e.key === 'ArrowLeft') { dCell -= dirCell; } else if (e.key === 'ArrowRight') { dCell += dirCell; } else if (e.key === 'ArrowUp') { dRow -= dirRow; } else if (e.key === 'ArrowDown') { dRow += dirRow; } toRow = active.row + dirRow * dRow; } else { // multiple cell moves: (Home, End, Page{Up/Down}) if (this._cachedPageRowCount < 1) { this._cachedPageRowCount = this._grid.getViewportRowCount(); } if (this._prevSelectedRow === undefined) { this._prevSelectedRow = active.row; } if ((!e.ctrlKey && e.shiftKey && e.key === 'Home') || (e.ctrlKey && e.shiftKey && e.key === 'ArrowLeft')) { toCell = 0; toRow = active.row; } else if ((!e.ctrlKey && e.shiftKey && e.key === 'End') || (e.ctrlKey && e.shiftKey && e.key === 'ArrowRight')) { toCell = colLn - 1; toRow = active.row; } else if (e.ctrlKey && e.shiftKey && e.key === 'ArrowUp') { toRow = 0; } else if (e.ctrlKey && e.shiftKey && e.key === 'ArrowDown') { toRow = dataLn - 1; } else if (e.ctrlKey && e.shiftKey && e.key === 'Home') { toCell = 0; toRow = 0; } else if (e.ctrlKey && e.shiftKey && e.key === 'End') { toCell = colLn - 1; toRow = dataLn - 1; } else if (e.key === 'PageUp') { if (this._prevSelectedRow >= 0) { toRow = this._prevSelectedRow - this._cachedPageRowCount; } if (toRow < 0) { toRow = 0; } } else if (e.key === 'PageDown') { if (this._prevSelectedRow <= dataLn - 1) { toRow = this._prevSelectedRow + this._cachedPageRowCount; } if (toRow > dataLn - 1) { toRow = dataLn - 1; } } this._prevSelectedRow = toRow; } // define new selection range toCell ??= active.cell + dirCell * dCell; const new_last = new SlickRange(active.row, active.cell, toRow, toCell); if (this.removeInvalidRanges([new_last]).length) { ranges.push(new_last); const viewRow = dirRow > 0 ? new_last.toRow : new_last.fromRow; const viewCell = dirCell > 0 ? new_last.toCell : new_last.fromCell; if (isSingleKeyMove) { this._grid.scrollRowIntoView(viewRow); this._grid.scrollCellIntoView(viewRow, viewCell); } else { this._grid.scrollRowIntoView(toRow); this._grid.scrollCellIntoView(toRow, viewCell); } } else { ranges.push(last); } this.setSelectedRanges(ranges, undefined, ''); e.preventDefault(); e.stopPropagation(); this._prevKeyDown = e.key as string; } } else { const activeRow = this._grid.getActiveCell(); if (this._grid.getOptions().multiSelect && activeRow && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey && (e.which === keyCode.UP || e.which === keyCode.DOWN)) { let selectedRows = this.getSelectedRows(); selectedRows.sort((x, y) => x - y); if (!selectedRows.length) { selectedRows = [activeRow.row]; } let active: number; let top = selectedRows[0]; let bottom = selectedRows[selectedRows.length - 1]; if (e.which === keyCode.DOWN) { active = activeRow.row < bottom || top === bottom ? ++bottom : ++top; } else { active = activeRow.row < bottom ? --bottom : --top; } if (active >= 0 && active < this._grid.getDataLength()) { this._grid.scrollRowIntoView(active); const tempRanges = this.rowsToRanges(this.getRowsRange(top, bottom)); this.setSelectedRanges(tempRanges); } e.preventDefault(); e.stopPropagation(); } } } protected handleClick(e: SlickEventData_): boolean | void { if (!this._activeSelectionIsRow) { return; } const cell = this._grid.getCellFromEvent(e); if (!cell || !this._grid.canCellBeActive(cell.row, cell.cell)) { return false; } if (!this._grid.getOptions().multiSelect || ( !e.ctrlKey && !e.shiftKey && !e.metaKey)) { return false; } let selection = this.rangesToRows(this._ranges); const idx = selection.indexOf(cell.row); if (idx === -1 && (e.ctrlKey || e.metaKey)) { selection.push(cell.row); this._grid.setActiveCell(cell.row, cell.cell); } else if (idx !== -1 && (e.ctrlKey || e.metaKey)) { selection = selection.filter((o) => o !== cell.row); this._grid.setActiveCell(cell.row, cell.cell); } else if (selection.length && e.shiftKey) { const last = selection.pop() as number; const from = Math.min(cell.row, last); const to = Math.max(cell.row, last); selection = []; for (let i = from; i <= to; i++) { if (i !== last) { selection.push(i); } } selection.push(last); this._grid.setActiveCell(cell.row, cell.cell); } const tempRanges = this.rowsToRanges(selection); this.setSelectedRanges(tempRanges); e.stopImmediatePropagation(); return true; } protected handleBeforeCellRangeSelected(e: SlickEventData_, cell: { row: number; cell: number; }): boolean | void { if (this._activeSelectionIsRow) { if (!this._isRowMoveManagerHandler) { const rowMoveManager = this._grid.getPluginByName<SlickRowMoveManager_>('RowMoveManager') || this._grid.getPluginByName<SlickCrossGridRowMoveManager_>('CrossGridRowMoveManager'); this._isRowMoveManagerHandler = rowMoveManager ? rowMoveManager.isHandlerColumn : Utils.noop; } if (this._grid.getEditorLock().isActive() || this._isRowMoveManagerHandler(cell.cell)) { e.stopPropagation(); return false; } this._grid.setActiveCell(cell.row, cell.cell); } else { if (this._grid.getEditorLock().isActive()) { e.stopPropagation(); return false; } } } protected handleCellRangeSelected(_e: SlickEventData_, args: { range: SlickRange_; selectionMode: string; allowAutoEdit?: boolean; caller: 'onCellRangeSelecting' | 'onCellRangeSelected' }) { //console.log('hybridSelectionModel.handleCellRangeSelected: ' + JSON.stringify(args.range) + '/' + args.selectionMode); if (this._activeSelectionIsRow) { if (!this._grid.getOptions().multiSelect || (!this._options?.selectActiveRow && this._options?.selectionType !== 'row')) { return false; } this.setSelectedRanges([new SlickRange(args.range.fromRow, 0, args.range.toRow, this._grid.getColumns().length - 1)], undefined, args.selectionMode); } else { if (args.caller === 'onCellRangeSelecting') { return false; } this._grid.setActiveCell(args.range.fromRow, args.range.fromCell, (args.allowAutoEdit ? undefined : false), false, true); this.setSelectedRanges([args.range], undefined, args.selectionMode); } return true; } } // extend Slick namespace on window object when building as iife if (IIFE_ONLY && window.Slick) { Utils.extend(true, window, { Slick: { HybridSelectionModel: SlickHybridSelectionModel } }); }