slickgrid
Version:
A lightning fast JavaScript grid/spreadsheet
453 lines (393 loc) • 17.8 kB
text/typescript
import { CellSelectionMode as CellSelectionMode_, SlickEvent as SlickEvent_, type SlickEventData, SlickEventHandler as SlickEventHandler_, SlickRange as SlickRange_, Utils as Utils_, SelectionUtils as SelectionUtils_ } from '../slick.core.js';
import { Draggable as Draggable_ } from '../slick.interactions.js';
import { SlickCellRangeDecorator as SlickCellRangeDecorator_ } from './slick.cellrangedecorator.js';
import type { CellRangeSelectorOption, DragPosition, DragRange, DragRowMove, GridOption, MouseOffsetViewport, OnScrollEventArgs, SlickPlugin } from '../models/index.js';
import type { SlickGrid } from '../slick.grid.js';
// for (iife) load Slick methods from global Slick object, or use imports for (esm)
const SlickEvent = IIFE_ONLY ? Slick.Event : SlickEvent_;
const SlickEventHandler = IIFE_ONLY ? Slick.EventHandler : SlickEventHandler_;
const SlickRange = IIFE_ONLY ? Slick.Range : SlickRange_;
const Draggable = IIFE_ONLY ? Slick.Draggable : Draggable_;
const SlickCellRangeDecorator = IIFE_ONLY ? Slick.CellRangeDecorator : SlickCellRangeDecorator_;
const Utils = IIFE_ONLY ? Slick.Utils : Utils_;
const SelectionUtils = IIFE_ONLY ? Slick.SelectionUtils : SelectionUtils_;
const CellSelectionMode = IIFE_ONLY ? Slick.CellSelectionMode : CellSelectionMode_;
export class SlickCellRangeSelector implements SlickPlugin {
// --
// public API
pluginName = 'CellRangeSelector' as const;
onBeforeCellRangeSelected = new SlickEvent<{ row: number; cell: number; }>('onBeforeCellRangeSelected');
onCellRangeSelected = new SlickEvent<{ range: SlickRange_; selectionMode: string; allowAutoEdit: boolean; }>('onCellRangeSelected');
onCellRangeSelecting = new SlickEvent<{ range: SlickRange_; selectionMode: string; allowAutoEdit: boolean; }>('onCellRangeSelecting');
// --
// protected props
protected _grid!: SlickGrid;
protected _currentlySelectedRange: DragRange | null = null;
protected _previousSelectedRange: DragRange | null = null;
protected _canvas: HTMLElement | null = null;
protected _decorator!: SlickCellRangeDecorator_;
protected _gridOptions!: GridOption;
protected _activeCanvas!: HTMLElement;
protected _dragging = false;
protected _handler = new SlickEventHandler();
protected _options: CellRangeSelectorOption;
protected _selectionMode: string = CellSelectionMode.Select;
protected _dragReplaceHandleActive = false;
protected _dragReplaceHandleCell: { row : number, cell: number } | null = null;
protected _defaults = {
autoScroll: true,
minIntervalToShowNextCell: 30,
maxIntervalToShowNextCell: 600, // better to a multiple of minIntervalToShowNextCell
accelerateInterval: 5, // increase 5ms when cursor 1px outside the viewport.
selectionCss: {
border: '2px dashed blue'
}
} as CellRangeSelectorOption;
// Frozen row & column variables
protected _rowOffset = 0;
protected _columnOffset = 0;
protected _isRightCanvas = false;
protected _isBottomCanvas = false;
// autoScroll related constiables
protected _activeViewport!: HTMLElement;
protected _autoScrollTimerId?: number;
protected _draggingMouseOffset!: MouseOffsetViewport;
protected _moveDistanceForOneCell!: { x: number; y: number; };
protected _xDelayForNextCell = 0;
protected _yDelayForNextCell = 0;
protected _viewportHeight = 0;
protected _viewportWidth = 0;
protected _isRowMoveRegistered = false;
// Scrollings
protected _scrollLeft = 0;
protected _scrollTop = 0;
constructor(options?: Partial<CellRangeSelectorOption>) {
this._options = Utils.extend(true, {}, this._defaults, options);
}
init(grid: SlickGrid) {
if (Draggable === undefined) {
throw new Error('Slick.Draggable is undefined, make sure to import "slick.interactions.js"');
}
this._decorator = this._options.cellDecorator || new SlickCellRangeDecorator(grid, this._options);
this._grid = grid;
Utils.addSlickEventPubSubWhenDefined(grid.getPubSubService(), this);
this._canvas = this._grid.getCanvasNode();
this._gridOptions = this._grid.getOptions();
this._handler
.subscribe(this._grid.onScroll, this.handleScroll.bind(this))
.subscribe(this._grid.onDragInit, this.handleDragInit.bind(this))
.subscribe(this._grid.onDragStart, this.handleDragStart.bind(this))
.subscribe(this._grid.onDrag, this.handleDrag.bind(this))
.subscribe(this._grid.onDragEnd, this.handleDragEnd.bind(this));
}
destroy() {
this._handler.unsubscribeAll();
this._activeCanvas = null as any;
this._activeViewport = null as any;
this._canvas = null;
this._decorator?.destroy();
}
getCellDecorator() {
return this._decorator;
}
getSelectionMode() {
return this._selectionMode;
}
setSelectionMode(mode: string) {
this._selectionMode = mode;
}
protected handleScroll(_e: SlickEventData, args: OnScrollEventArgs) {
this._scrollTop = args.scrollTop;
this._scrollLeft = args.scrollLeft;
}
protected handleDragInit(e: SlickEventData, dd: DragRowMove) {
// Set the active canvas node because the decorator needs to append its
// box to the correct canvas
this._activeCanvas = this._grid.getActiveCanvasNode(e);
this._activeViewport = this._grid.getActiveViewportNode(e);
const scrollbarDimensions = this._grid.getDisplayedScrollbarDimensions();
this._viewportWidth = this._activeViewport.offsetWidth - scrollbarDimensions.width;
this._viewportHeight = this._activeViewport.offsetHeight - scrollbarDimensions.height;
this._moveDistanceForOneCell = {
x: this._grid.getAbsoluteColumnMinWidth() / 2,
y: this._grid.getOptions().rowHeight! / 2
};
this._isRowMoveRegistered = this.hasRowMoveManager();
this._rowOffset = 0;
this._columnOffset = 0;
this._isBottomCanvas = this._activeCanvas.classList.contains('grid-canvas-bottom');
if (this._gridOptions.frozenRow! > -1 && this._isBottomCanvas) {
const canvasSelector = `.${this._grid.getUID()} .grid-canvas-${this._gridOptions.frozenBottom ? 'bottom' : 'top'}`;
const canvasElm = document.querySelector(canvasSelector);
if (canvasElm) {
this._rowOffset = canvasElm.clientHeight || 0;
}
}
this._isRightCanvas = this._activeCanvas.classList.contains('grid-canvas-right');
if (this._gridOptions.frozenColumn! > -1 && this._isRightCanvas) {
const canvasLeftElm = document.querySelector(`.${this._grid.getUID()} .grid-canvas-left`);
if (canvasLeftElm) {
this._columnOffset = canvasLeftElm.clientWidth || 0;
}
}
this._dragReplaceHandleActive = (dd.matchClassTag === 'dragReplaceHandle');
if (this._dragReplaceHandleActive) {
this._dragReplaceHandleCell = this._grid.getCellFromEvent(e);
} else {
this._previousSelectedRange = null;
}
// prevent the grid from cancelling drag'n'drop by default
e.stopImmediatePropagation();
e.preventDefault();
}
protected handleDragStart(e: SlickEventData, dd: DragRowMove) {
let cell = this._grid.getCellFromEvent(e);
if (this._dragReplaceHandleActive) { cell = this._dragReplaceHandleCell; }
if (cell && this.onBeforeCellRangeSelected.notify(cell).getReturnValue() !== false && this._grid.canCellBeSelected(cell.row, cell.cell)) {
this._dragging = true;
e.stopImmediatePropagation();
}
if (!this._dragging) {
return;
}
this._grid.focus();
const canvasOffset = Utils.offset(this._canvas);
let startX = dd.startX - (canvasOffset?.left ?? 0);
if (this._gridOptions.frozenColumn! >= 0 && this._isRightCanvas) {
startX += this._scrollLeft;
}
let startY = dd.startY - (canvasOffset?.top ?? 0);
if (this._gridOptions.frozenRow! >= 0 && this._isBottomCanvas) {
startY += this._scrollTop;
}
let start: { row: number | undefined, cell: number | undefined; } | null;
this._selectionMode = this._dragReplaceHandleActive ? CellSelectionMode.Replace : CellSelectionMode.Select;
if (!this._dragReplaceHandleActive) {
start = this._grid.getCellFromPoint(startX, startY);
} else {
start = this._grid.getActiveCell() || { row: undefined, cell: undefined };
}
dd.range = { start, end: {} };
this._currentlySelectedRange = dd.range;
return this._decorator.show(new SlickRange(start.row ?? 0, start.cell ?? 0), this._dragReplaceHandleActive);
}
protected handleDrag(evt: SlickEventData, dd: DragRowMove) {
if (!this._dragging && !this._isRowMoveRegistered) {
return;
}
if (!this._isRowMoveRegistered) {
evt.stopImmediatePropagation();
}
const e = evt.getNativeEvent<MouseEvent>();
if (this._options.autoScroll) {
this._draggingMouseOffset = this.getMouseOffsetViewport(e, dd);
if (this._draggingMouseOffset.isOutsideViewport) {
return this.handleDragOutsideViewport();
}
}
this.stopIntervalTimer();
this.handleDragTo(e, dd);
}
protected getMouseOffsetViewport(e: MouseEvent | TouchEvent, dd: DragRowMove): MouseOffsetViewport {
const targetEvent: MouseEvent | Touch = (e as TouchEvent)?.touches?.[0] ?? e;
const viewportLeft = this._activeViewport.scrollLeft;
const viewportTop = this._activeViewport.scrollTop;
const viewportRight = viewportLeft + this._viewportWidth;
const viewportBottom = viewportTop + this._viewportHeight;
const viewportOffset = Utils.offset(this._activeViewport);
const viewportOffsetLeft = viewportOffset?.left ?? 0;
const viewportOffsetTop = viewportOffset?.top ?? 0;
const viewportOffsetRight = viewportOffsetLeft + this._viewportWidth;
const viewportOffsetBottom = viewportOffsetTop + this._viewportHeight;
const result = {
e,
dd,
viewport: {
left: viewportLeft,
top: viewportTop,
right: viewportRight,
bottom: viewportBottom,
offset: {
left: viewportOffsetLeft,
top: viewportOffsetTop,
right: viewportOffsetRight,
bottom: viewportOffsetBottom
}
},
// Consider the viewport as the origin, the `offset` is based on the coordinate system:
// the cursor is on the viewport's left/bottom when it is less than 0, and on the right/top when greater than 0.
offset: {
x: 0,
y: 0
},
isOutsideViewport: false
};
// ... horizontal
if (targetEvent.pageX < viewportOffsetLeft) {
result.offset.x = targetEvent.pageX - viewportOffsetLeft;
} else if (targetEvent.pageX > viewportOffsetRight) {
result.offset.x = targetEvent.pageX - viewportOffsetRight;
}
// ... vertical
if (targetEvent.pageY < viewportOffsetTop) {
result.offset.y = viewportOffsetTop - targetEvent.pageY;
} else if (targetEvent.pageY > viewportOffsetBottom) {
result.offset.y = viewportOffsetBottom - targetEvent.pageY;
}
result.isOutsideViewport = !!result.offset.x || !!result.offset.y;
return result;
}
protected handleDragOutsideViewport() {
this._xDelayForNextCell = this._options.maxIntervalToShowNextCell - Math.abs(this._draggingMouseOffset.offset.x) * this._options.accelerateInterval;
this._yDelayForNextCell = this._options.maxIntervalToShowNextCell - Math.abs(this._draggingMouseOffset.offset.y) * this._options.accelerateInterval;
// only one timer is created to handle the case that cursor outside the viewport
if (!this._autoScrollTimerId) {
let xTotalDelay = 0;
let yTotalDelay = 0;
this._autoScrollTimerId = window.setInterval(() => {
let xNeedUpdate = false;
let yNeedUpdate = false;
// ... horizontal
if (this._draggingMouseOffset.offset.x) {
xTotalDelay += this._options.minIntervalToShowNextCell;
xNeedUpdate = xTotalDelay >= this._xDelayForNextCell;
} else {
xTotalDelay = 0;
}
// ... vertical
if (this._draggingMouseOffset.offset.y) {
yTotalDelay += this._options.minIntervalToShowNextCell;
yNeedUpdate = yTotalDelay >= this._yDelayForNextCell;
} else {
yTotalDelay = 0;
}
if (xNeedUpdate || yNeedUpdate) {
if (xNeedUpdate) {
xTotalDelay = 0;
}
if (yNeedUpdate) {
yTotalDelay = 0;
}
this.handleDragToNewPosition(xNeedUpdate, yNeedUpdate);
}
}, this._options.minIntervalToShowNextCell);
}
}
protected handleDragToNewPosition(xNeedUpdate: boolean, yNeedUpdate: boolean) {
let pageX = this._draggingMouseOffset.e.pageX;
let pageY = this._draggingMouseOffset.e.pageY;
const mouseOffsetX = this._draggingMouseOffset.offset.x;
const mouseOffsetY = this._draggingMouseOffset.offset.y;
const viewportOffset = this._draggingMouseOffset.viewport.offset;
// ... horizontal
if (xNeedUpdate && mouseOffsetX) {
if (mouseOffsetX > 0) {
pageX = viewportOffset.right + this._moveDistanceForOneCell.x;
} else {
pageX = viewportOffset.left - this._moveDistanceForOneCell.x;
}
}
// ... vertical
if (yNeedUpdate && mouseOffsetY) {
if (mouseOffsetY > 0) {
pageY = viewportOffset.top - this._moveDistanceForOneCell.y;
} else {
pageY = viewportOffset.bottom + this._moveDistanceForOneCell.y;
}
}
this.handleDragTo({ pageX, pageY }, this._draggingMouseOffset.dd);
}
protected stopIntervalTimer() {
if (this._autoScrollTimerId) {
window.clearInterval(this._autoScrollTimerId);
this._autoScrollTimerId = undefined;
}
}
protected handleDragTo(e: { pageX: number; pageY: number; }, dd: DragPosition) {
//console.log('cellRangeSelector.handleDragTo: ' + JSON.stringify(dd.range));
const targetEvent: MouseEvent | Touch = (e as unknown as TouchEvent)?.touches?.[0] ?? e;
const canvasOffset = Utils.offset(this._activeCanvas);
const end = this._grid.getCellFromPoint(
targetEvent.pageX - (canvasOffset?.left ?? 0) + this._columnOffset,
targetEvent.pageY - (canvasOffset?.top ?? 0) + this._rowOffset
);
// ... frozen column(s),
if (this._gridOptions.frozenColumn! >= 0 && (!this._isRightCanvas && (end.cell > this._gridOptions.frozenColumn!)) || (this._isRightCanvas && (end.cell <= this._gridOptions.frozenColumn!))) {
return;
}
// ... or frozen row(s)
if (this._gridOptions.frozenRow! >= 0 && (!this._isBottomCanvas && (end.row >= this._gridOptions.frozenRow!)) || (this._isBottomCanvas && (end.row < this._gridOptions.frozenRow!))) {
return;
}
// scrolling the viewport to display the target `end` cell if it is not fully displayed
if (this._options.autoScroll && this._draggingMouseOffset) {
const endCellBox = this._grid.getCellNodeBox(end.row, end.cell);
if (!endCellBox) {
return;
}
const viewport = this._draggingMouseOffset.viewport;
if (endCellBox.left < viewport.left || endCellBox.right > viewport.right
|| endCellBox.top < viewport.top || endCellBox.bottom > viewport.bottom) {
this._grid.scrollCellIntoView(end.row, end.cell);
}
}
// ... or regular grid (without any frozen options)
if (!this._grid.canCellBeSelected(end.row, end.cell)) {
return;
}
if (dd?.range) {
dd.range.end = end;
const cornerCell = !this._previousSelectedRange ? dd.range.start : SelectionUtils.normalRangeOppositeCellFromCopy(this._previousSelectedRange, end);
this._currentlySelectedRange = dd.range;
const range = new SlickRange(cornerCell.row!, cornerCell.cell!, end.row, end.cell);
this._decorator.show(range, this._dragReplaceHandleActive);
this.onCellRangeSelecting.notify({
range, selectionMode: '',
allowAutoEdit: false
});
}
}
protected hasRowMoveManager() {
return !!(this._grid.getPluginByName('RowMoveManager') || this._grid.getPluginByName('CrossGridRowMoveManager'));
}
protected handleDragEnd(e: SlickEventData, dd: DragPosition) {
//console.log('cellRangeSelector.handleDragEnd: ' + JSON.stringify(dd.range));
this._decorator.hide();
if (!this._dragging || !dd.range) {
if (this._autoScrollTimerId) {
this.stopIntervalTimer(); // stop the auto-scroll timer if it was running
}
return;
}
this._dragging = false;
e.stopImmediatePropagation();
this.stopIntervalTimer();
const targetEvent: MouseEvent | Touch = (e as unknown as TouchEvent)?.touches?.[0] ?? e;
const canvasOffset = Utils.offset(this._activeCanvas);
const end = this._grid.getCellFromPoint(
targetEvent.pageX - (canvasOffset?.left ?? 0) + this._columnOffset,
targetEvent.pageY - (canvasOffset?.top ?? 0) + this._rowOffset
);
const cornerCell = !this._dragReplaceHandleActive || !this._previousSelectedRange ? dd.range.start : SelectionUtils.normalRangeOppositeCellFromCopy(this._previousSelectedRange, end);
const r = new SlickRange(
cornerCell.row ?? 0,
cornerCell.cell ?? 0,
dd.range.end.row,
dd.range.end.cell
);
this.onCellRangeSelected.notify({ range: r, selectionMode: this._selectionMode, allowAutoEdit: (this._selectionMode === "SEL" && r.isSingleCell()) });
this._previousSelectedRange = SelectionUtils.normaliseDragRange(dd.range);
}
getCurrentRange() {
return this._currentlySelectedRange;
}
getPreviousRange() {
return this._previousSelectedRange;
}
}
// extend Slick namespace on window object when building as iife
if (IIFE_ONLY && window.Slick) {
Utils.extend(Slick, {
CellRangeSelector: SlickCellRangeSelector
});
}