UNPKG

react-konva-grid

Version:

Declarative React Canvas Grid primitive for Data table, Pivot table, Excel Worksheets

669 lines (602 loc) 18.9 kB
import React, { useState, useCallback, useRef, useEffect } from "react"; import { SelectionArea, CellInterface, GridRef } from "./../Grid"; import { findNextCellWithinBounds, Align, getBoundedCells, cellIndentifier, mergedCellBounds, } from "./../helpers"; import { KeyCodes, Direction, MouseButtonCodes } from "./../types"; export interface UseSelectionOptions { /** * Access grid functions */ gridRef?: React.MutableRefObject<GridRef>; /** * Initial selections */ initialSelections?: SelectionArea[]; /** * Option to set 0,0 as initially selected cell */ initialActiveCell?: CellInterface | null; /** * No of columns in the grid */ columnCount?: number; /** * No of rows in the grid */ rowCount?: number; /** * Allow multiple selection */ allowMultipleSelection?: boolean; /** * Allow deselect a selected area */ allowDeselectSelection?: boolean; /** * If true, user can select multiple selections without pressing Ctrl/Cmd. * Useful for formula mode */ persistantSelectionMode?: boolean; } export interface SelectionResults { /** * Active selected cell */ activeCell: CellInterface | null; /** * Use this to invoke a new selection. All old selection will be cleared */ newSelection: (coords: CellInterface) => void; /** * Use this to update selections without clearning old selection. */ setSelections: (selection: SelectionArea[]) => void; /** * Set the currently active cell */ setActiveCell: (coords: CellInterface | null) => void; /** * Array of all selection bounds */ selections: SelectionArea[]; /** * Handler for mousedown, use to set activeCell */ onMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void; /** * Used to move selections based on pressed key */ onKeyDown: (e: React.KeyboardEvent<HTMLElement>) => void; } const EMPTY_SELECTION: SelectionArea[] = []; /** * Hook to enable selection in datagrid * @param initialSelection */ const useSelection = (options?: UseSelectionOptions): SelectionResults => { const { gridRef, initialActiveCell = null, initialSelections = [], columnCount = 0, rowCount = 0, allowMultipleSelection = true, persistantSelectionMode = false, allowDeselectSelection = true, } = options || {}; const [activeCell, setActiveCell] = useState<CellInterface | null>( initialActiveCell ); const [selections, setSelections] = useState<SelectionArea[]>( initialSelections ); const selectionStart = useRef<CellInterface>(); const selectionEnd = useRef<CellInterface>(); const isSelecting = useRef<boolean>(); const firstActiveCell = useRef<CellInterface | null>(null); /** * Need to store in ref because on mousemove and mouseup event that are * registered in document */ const activeCellRef = useRef(activeCell); useEffect(() => { activeCellRef.current = activeCell; }); /* New selection */ const newSelection = (start: CellInterface, end: CellInterface = start) => { selectionStart.current = start; selectionEnd.current = end; const bounds = selectionFromStartEnd(start, end); if (!bounds) return; const coords = { rowIndex: bounds.top, columnIndex: bounds.left }; /* Keep track of first cell that was selected by user */ firstActiveCell.current = coords; setActiveCell(coords); clearSelections(); }; /** * selection object from start, end * @param start * @param end * * TODO * Cater to Merged cells */ const selectionFromStartEnd = (start: CellInterface, end: CellInterface) => { if (!gridRef) return null; const boundsStart = gridRef.current.getCellBounds(start); const boundsEnd = gridRef.current.getCellBounds(end); const bounds = { top: Math.min(boundsStart.top, boundsEnd.top), bottom: Math.max(boundsStart.bottom, boundsEnd.bottom), left: Math.min(boundsStart.left, boundsEnd.left), right: Math.max(boundsStart.right, boundsEnd.right), }; return mergedCellBounds(bounds, gridRef.current.getCellBounds); }; /* Modify current selection */ const modifySelection = (coords: CellInterface, setInProgress?: boolean) => { if (!selectionStart.current) return; selectionEnd.current = coords; const bounds = selectionFromStartEnd(selectionStart.current, coords); if (!bounds) return; /** * 1. Multiple selections on mousedown/mousemove * 2. Move the activeCell to newly selection. Done by appendSelection */ setSelections((prevSelection) => { const len = prevSelection.length; if (!len) { return [{ bounds, inProgress: setInProgress ? true : false }]; } return prevSelection.map((sel, i) => { if (len - 1 === i) { return { ...sel, bounds, inProgress: setInProgress ? true : false, }; } return sel; }); }); }; /* Adds a new selection, CMD key */ const appendSelection = (coords: CellInterface | null) => { if (!coords) return; selectionStart.current = coords; selectionEnd.current = coords; const bounds = selectionFromStartEnd(coords, coords); if (!bounds) return; setActiveCell({ rowIndex: bounds.top, columnIndex: bounds.left }); setSelections((prev) => [...prev, { bounds }]); }; const removeSelectionByIndex = useCallback( (index: number): SelectionArea[] => { const newSelection = selections.filter((_, idx) => idx !== index); setSelections(newSelection); return newSelection; }, [selections] ); const isEqualCells = (a: CellInterface | null, b: CellInterface | null) => { if (a === null || b === null) return false; return a.rowIndex === b.rowIndex && a.columnIndex === b.columnIndex; }; const clearSelections = () => { setSelections(EMPTY_SELECTION); }; const getPossibleActiveCellFromSelections = ( selections: SelectionArea[] ): CellInterface | null => { if (!selections.length) return null; const { bounds } = selections[selections.length - 1]; return { rowIndex: bounds.top, columnIndex: bounds.left, }; }; const cellIndexInSelection = ( cell: CellInterface, selections: SelectionArea[] ) => { return selections.findIndex((sel) => { const boundedCells = getBoundedCells(sel.bounds); return boundedCells.has(cellIndentifier(cell.rowIndex, cell.columnIndex)); }); }; const cellEqualsSelection = ( cell: CellInterface | null, selections: SelectionArea[] ): boolean => { if (cell === null) return false; return selections.some((sel) => { return ( sel.bounds.left === cell.columnIndex && sel.bounds.top === cell.rowIndex && sel.bounds.right === cell.columnIndex && sel.bounds.bottom === cell.rowIndex ); }); }; /** * Triggers a new selection start */ const handleMouseDown = useCallback( (e: React.MouseEvent<HTMLDivElement>) => { /* Exit early if grid is not initialized */ if ( !gridRef || !gridRef.current || e.nativeEvent.which === MouseButtonCodes.right ) return; const isShiftKey = e.nativeEvent.shiftKey; const isMetaKey = e.nativeEvent.ctrlKey || e.nativeEvent.metaKey; const allowMultiple = persistantSelectionMode || (isMetaKey && allowMultipleSelection); const allowDeselect = allowDeselectSelection; const hasSelections = selections.length > 0; const isDeselecting = isMetaKey && allowDeselect; /* Attaching mousemove to document, so we can detect drag move */ document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); /* Activate selection mode */ isSelecting.current = true; const { rowIndex, columnIndex } = gridRef.current.getCellCoordsFromOffset( e.clientX, e.clientY ); /** * Save the initial Selection in ref * so we can adjust the bounds in mousemove */ const coords = { rowIndex, columnIndex }; /* Shift key */ if (isShiftKey) { modifySelection(coords); return; } /* Is the current cell same as active cell */ const isSameAsActiveCell = isEqualCells(coords, activeCell); /* Command or Control key */ if (activeCell && allowMultiple) { /** * User is adding activeCell to selection * * 1. User is selecting and not de-selecting * 2. User has not made any selection * 3. Trying to add active cell to selection */ if (isSameAsActiveCell && (!isDeselecting || !hasSelections)) { return; } /** * User is manually trying to select multiple selections, * So add the current active cell to the list */ if (isMetaKey && !hasSelections) { appendSelection(activeCell); } /** * Check if this cell has already been selected (only for manual deselect) * Remove it from selection * * Future enhancements -> Split selection, so that 1 cell can be removed from range */ if (isMetaKey && allowDeselect) { const cellIndex = cellIndexInSelection(coords, selections); if (cellIndex !== -1) { const newSelection = removeSelectionByIndex(cellIndex); const nextActiveCell = getPossibleActiveCellFromSelections( newSelection ); if (nextActiveCell !== null) { setActiveCell(nextActiveCell); } if ( newSelection.length === 1 && cellEqualsSelection(nextActiveCell, newSelection) ) { /* Since we only have 1 cell, lets clear the selections and only keep activeCell */ clearSelections(); } return; } } /** * TODO * 1. Ability to remove selection * 2. Ability to remove from selection area * 3. Ability to switch activeCell if its part of removed selection */ appendSelection(coords); return; } /* Trigger new selection */ newSelection(coords); }, [activeCell, selections, allowMultipleSelection, allowDeselectSelection] ); /** * Mousemove handler */ const handleMouseMove = useCallback((e: globalThis.MouseEvent) => { /* Exit if user is not in selection mode */ if (!isSelecting.current || !gridRef) return; const coords = gridRef.current.getCellCoordsFromOffset( e.clientX, e.clientY ); if (isEqualCells(firstActiveCell.current, coords)) { return clearSelections(); } modifySelection(coords, true); gridRef.current.scrollToItem(coords); }, []); /** * Mouse up handler */ const handleMouseUp = useCallback(() => { /* Reset selection mode */ isSelecting.current = false; /* Remove listener */ document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); /* Update last selection */ setSelections((prevSelection) => { const len = prevSelection.length; return prevSelection.map((sel, i) => { if (len - 1 === i) { return { ...sel, inProgress: false, }; } return sel; }); }); }, []); /** * Navigate selection using keyboard * @param direction * @param modify */ const keyNavigate = useCallback( (direction: Direction, modify?: boolean, metaKeyPressed?: boolean) => { if ( !selectionStart.current || !selectionEnd.current || !gridRef || !activeCell ) return; var { rowIndex, columnIndex } = modify ? selectionEnd.current : activeCell; const isMergedCell = gridRef?.current.isMergedCell({ rowIndex, columnIndex, }); const currenBounds = gridRef.current.getCellBounds({ rowIndex, columnIndex, }); switch (direction) { case Direction.Up: if (isMergedCell) rowIndex = currenBounds.top; rowIndex = Math.max(rowIndex - 1, 0); // Shift + Ctrl/Commmand // TODO: Scroll to last contentful cell if (metaKeyPressed) rowIndex = 0; break; case Direction.Down: if (isMergedCell) rowIndex = currenBounds.bottom; rowIndex = Math.min(rowIndex + 1, rowCount - 1); // Shift + Ctrl/Commmand if (metaKeyPressed) rowIndex = rowCount - 1; break; case Direction.Left: if (isMergedCell) columnIndex = currenBounds.left; columnIndex = Math.max(columnIndex - 1, 0); // Shift + Ctrl/Commmand if (metaKeyPressed) columnIndex = 0; break; case Direction.Right: if (isMergedCell) columnIndex = currenBounds.right; columnIndex = Math.min(columnIndex + 1, columnCount - 1); // Shift + Ctrl/Commmand if (metaKeyPressed) columnIndex = columnCount - 1; break; } const newBounds = gridRef.current.getCellBounds({ rowIndex, columnIndex, }); const coords = { rowIndex: newBounds.top, columnIndex: newBounds.left }; const scrollToCell = modify ? selectionEnd.current.rowIndex === coords.rowIndex ? // Scroll to a column { columnIndex: coords.columnIndex } : // Scroll to row { rowIndex: coords.rowIndex } : // Scroll to cell { rowIndex, columnIndex }; const isUserNavigatingToActiveCell = isEqualCells( firstActiveCell.current, coords ); if (modify && !isUserNavigatingToActiveCell) { modifySelection(coords); } else { newSelection(coords); } /* Keep the item in view */ gridRef.current.scrollToItem(scrollToCell); }, [activeCell] ); // ⌘A or ⌘+Shift+Space const selectAll = () => { selectionStart.current = { rowIndex: 0, columnIndex: 0 }; modifySelection({ rowIndex: rowCount - 1, columnIndex: columnCount - 1 }); }; // Ctrl+Space const selectColumn = () => { if (!selectionEnd.current || !selectionStart.current) return; selectionStart.current = { rowIndex: 0, columnIndex: selectionStart.current.columnIndex, }; modifySelection({ rowIndex: rowCount - 1, columnIndex: selectionEnd.current.columnIndex, }); }; // Shift+Space const selectRow = () => { if (!selectionEnd.current || !selectionStart.current) return; selectionStart.current = { rowIndex: selectionStart.current.rowIndex, columnIndex: 0, }; modifySelection({ rowIndex: selectionEnd.current.rowIndex, columnIndex: columnCount - 1, }); }; // Home const selectFirstCellInRow = () => { if (!selectionStart.current) return; const cell = { rowIndex: selectionStart.current.rowIndex, columnIndex: 0, }; newSelection(cell); gridRef?.current.scrollToItem(cell); }; // End const selectLastCellInRow = () => { if (!selectionStart.current) return; const cell = { rowIndex: selectionStart.current.rowIndex, columnIndex: columnCount - 1, }; newSelection(cell); gridRef?.current.scrollToItem(cell); }; // ⌘+Home const selectFirstCellInColumn = () => { if (!selectionStart.current) return; const cell = { rowIndex: 0, columnIndex: selectionStart.current.columnIndex, }; newSelection(cell); gridRef?.current.scrollToItem(cell); }; // ⌘+End const selectLastCellInColumn = () => { if (!selectionStart.current) return; const cell = { rowIndex: rowCount - 1, columnIndex: selectionStart.current.columnIndex, }; newSelection(cell); gridRef?.current.scrollToItem(cell); }; // ⌘+Backspace const scrollToActiveCell = () => { if (!activeCell) return; gridRef?.current.scrollToItem(activeCell, Align.smart); }; const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { const isShiftKey = e.nativeEvent.shiftKey; const isMetaKey = e.nativeEvent.ctrlKey || e.nativeEvent.metaKey; switch (e.nativeEvent.which) { case KeyCodes.Right: keyNavigate(Direction.Right, isShiftKey, isMetaKey); break; case KeyCodes.Left: keyNavigate(Direction.Left, isShiftKey, isMetaKey); break; // Up case KeyCodes.Up: keyNavigate(Direction.Up, isShiftKey, isMetaKey); break; case KeyCodes.Down: keyNavigate(Direction.Down, isShiftKey, isMetaKey); break; case KeyCodes.A: if (isMetaKey) { selectAll(); } break; case KeyCodes.Home: if (isMetaKey) { selectFirstCellInColumn(); } else { selectFirstCellInRow(); } break; case KeyCodes.End: if (isMetaKey) { selectLastCellInColumn(); } else { selectLastCellInRow(); } break; case KeyCodes.BackSpace: if (isMetaKey) scrollToActiveCell(); break; case KeyCodes.SPACE: if (isMetaKey && isShiftKey) { selectAll(); } else if (isMetaKey) { selectColumn(); } else if (isShiftKey) { selectRow(); } break; case KeyCodes.Tab: /* Cycle through the selections if selections.length > 0 */ if (selections.length && activeCell && gridRef) { const { bounds } = selections[selections.length - 1]; const activeCellBounds = gridRef.current.getCellBounds(activeCell); const direction = isShiftKey ? Direction.Left : Direction.Right; const nextCell = findNextCellWithinBounds( activeCellBounds, bounds, direction ); if (nextCell) setActiveCell(nextCell); } else { if (isShiftKey) { keyNavigate(Direction.Left); } else { keyNavigate(Direction.Right); } } e.preventDefault(); break; } }, [rowCount, columnCount, activeCell, selections] ); return { activeCell, selections, onMouseDown: handleMouseDown, onKeyDown: handleKeyDown, newSelection, setSelections, setActiveCell, }; }; export default useSelection;