UNPKG

react-konva-grid

Version:

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

376 lines (332 loc) 10.7 kB
import React, { useState, useCallback, useRef } from "react"; import { SelectionArea, CellInterface, GridRef } from "./../Grid"; import { findNextCellWithinBounds } from "./../helpers"; import { KeyCodes, Direction, Movement } from "./../types"; export interface UseSelectionOptions { gridRef?: React.MutableRefObject<GridRef>; initialSelections?: SelectionArea[]; initialActiveCell?: CellInterface | null; columnCount?: number; rowCount?: number; } export interface SelectionResults { activeCell: CellInterface | null; newSelection: (coords: CellInterface) => void; setSelections: (selection: SelectionArea[]) => void; setActiveCell: (coords: CellInterface | null) => void; selections: SelectionArea[]; onMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void; onMouseMove: (e: React.MouseEvent<HTMLDivElement>) => void; onMouseUp: (e: React.MouseEvent<HTMLDivElement>) => void; onKeyDown: (e: React.KeyboardEvent<HTMLElement>) => void; } const EMPTY_SELECTION: SelectionArea[] = []; /** * useSelection hook to enable selection in datagrid * @param initialSelection */ const useSelection = (options?: UseSelectionOptions): SelectionResults => { const { gridRef, initialActiveCell = null, initialSelections = [], columnCount = 0, rowCount = 0, } = options || {}; const [activeCell, setActiveCell] = useState<CellInterface | null>( initialActiveCell ); const [selections, setSelections] = useState<SelectionArea[]>( initialSelections ); const selectionStart = useRef<CellInterface>(); const selectionEnd = useRef<CellInterface>(); const isSelectionMode = useRef<boolean>(); /* New selection */ const newSelection = (start: CellInterface, end: CellInterface = start) => { selectionStart.current = start; selectionEnd.current = end; const bounds = selectionFromStartEnd(start, end); if (!bounds) return; setActiveCell({ rowIndex: bounds.top, columnIndex: bounds.left }); setSelections(EMPTY_SELECTION); }; /* selection object from start, end */ const selectionFromStartEnd = (start: CellInterface, end: CellInterface) => { if (!gridRef) return null; const boundsStart = gridRef.current.getCellBounds(start); const boundsEnd = gridRef.current.getCellBounds(end); return { 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), }; }; /* 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) => { 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 }]); }; /** * Triggers a new selection start */ const handleMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => { /* Exit early if grid is not initialized */ if (!gridRef || !gridRef.current) return; /* Activate selection mode */ isSelectionMode.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 (e.nativeEvent.shiftKey) { modifySelection(coords); return; } /* Command or Control key */ if (e.nativeEvent.metaKey || e.nativeEvent.ctrlKey) { appendSelection(coords); return; } /* Trigger new selection */ newSelection(coords); }, []); /** * Mousemove handler */ const handleMouseMove = useCallback( (e: React.MouseEvent<HTMLDivElement>) => { /* Exit if user is not in selection mode */ if (!isSelectionMode.current || !gridRef || !selectionEnd.current) return; const { rowIndex, columnIndex } = gridRef.current.getCellCoordsFromOffset( e.clientX, e.clientY ); /** * If the user is moving across the Active Cell, lets not add it to selection */ if ( activeCell?.rowIndex === rowIndex && activeCell?.columnIndex === columnIndex ) return; modifySelection({ rowIndex, columnIndex }, true); }, [activeCell] ); /** * Mouse up handler */ const handleMouseUp = useCallback(() => { /* Reset selection mode */ isSelectionMode.current = false; if (!selections.length) return; /* Update last selection */ setSelections((prevSelection) => { const len = prevSelection.length; return prevSelection.map((sel, i) => { if (len - 1 === i) { return { ...sel, inProgress: false, }; } return sel; }); }); }, [selections]); /** * Navigate selection using keyboard * @param direction * @param modify */ const keyNavigate = useCallback( (direction: Direction, modify?: boolean) => { if ( !selectionStart.current || !selectionEnd.current || !gridRef || !activeCell ) return; var { rowIndex, columnIndex } = modify ? selectionEnd.current : activeCell; const isMergedCell = gridRef?.current.isMergedCell({ rowIndex, columnIndex, }); const bounds = gridRef.current.getCellBounds({ rowIndex, columnIndex }); switch (direction) { case Direction.Up: if (isMergedCell) rowIndex = bounds.top; rowIndex = Math.max(rowIndex - 1, 0); break; case Direction.Down: if (isMergedCell) rowIndex = bounds.bottom; rowIndex = Math.min(rowIndex + 1, rowCount - 1); break; case Direction.Left: if (isMergedCell) columnIndex = bounds.left; columnIndex = Math.max(columnIndex - 1, 0); break; case Direction.Right: if (isMergedCell) columnIndex = bounds.right; columnIndex = Math.min(columnIndex + 1, columnCount - 1); break; } const scrollToCell = modify ? selectionEnd.current.rowIndex === rowIndex ? { columnIndex } : { rowIndex } : { rowIndex, columnIndex }; if (modify) { modifySelection({ rowIndex, columnIndex }); } else { newSelection({ rowIndex, columnIndex }); } /* 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, }); }; 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); break; case KeyCodes.Left: keyNavigate(Direction.Left, isShiftKey); break; // Up case KeyCodes.Up: keyNavigate(Direction.Up, isShiftKey); break; case KeyCodes.Down: keyNavigate(Direction.Down, isShiftKey); break; case KeyCodes.A: // Select All if (isMetaKey) { selectAll(); } 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[0]; const activeCellBounds = gridRef.current.getCellBounds(activeCell); const direction = isShiftKey ? Movement.backwards : Movement.forwards; 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, onMouseMove: handleMouseMove, onMouseUp: handleMouseUp, onKeyDown: handleKeyDown, newSelection, setSelections, setActiveCell, }; }; export default useSelection;