UNPKG

react-konva-grid

Version:

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

628 lines 24.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const react_1 = require("react"); const helpers_1 = require("./../helpers"); const types_1 = require("./../types"); const EMPTY_SELECTION = []; /** * Hook to enable selection in datagrid * @param initialSelection */ const useSelection = (options) => { const { gridRef, initialActiveCell = null, initialSelections = [], columnCount = 0, rowCount = 0, allowMultipleSelection = true, persistantSelectionMode = false, allowDeselectSelection = true, onFill, } = options || {}; const [activeCell, setActiveCell] = react_1.useState(initialActiveCell); const [selections, setSelections] = react_1.useState(initialSelections); const [fillSelection, setFillSelection] = react_1.useState(null); const selectionStart = react_1.useRef(null); const selectionEnd = react_1.useRef(null); const isSelecting = react_1.useRef(); const isFilling = react_1.useRef(); const firstActiveCell = react_1.useRef(null); /** * Need to store in ref because on mousemove and mouseup event that are * registered in document */ const activeCellRef = react_1.useRef(activeCell); react_1.useEffect(() => { activeCellRef.current = activeCell; }); /* New selection */ const newSelection = (start, end = 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, end) => { 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 helpers_1.mergedCellBounds(bounds, gridRef.current.getCellBounds); }; /* Modify current selection */ const modifySelection = (coords, setInProgress) => { 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 Object.assign(Object.assign({}, sel), { bounds, inProgress: setInProgress ? true : false }); } return sel; }); }); }; /* Adds a new selection, CMD key */ const appendSelection = (coords) => { 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 = react_1.useCallback((index) => { const newSelection = selections.filter((_, idx) => idx !== index); setSelections(newSelection); return newSelection; }, [selections]); const isEqualCells = (a, b) => { if (a === null || b === null) return false; return a.rowIndex === b.rowIndex && a.columnIndex === b.columnIndex; }; const clearSelections = () => { setSelections(EMPTY_SELECTION); }; const getPossibleActiveCellFromSelections = (selections) => { if (!selections.length) return null; const { bounds } = selections[selections.length - 1]; return { rowIndex: bounds.top, columnIndex: bounds.left, }; }; const cellIndexInSelection = (cell, selections) => { return selections.findIndex((sel) => { const boundedCells = helpers_1.getBoundedCells(sel.bounds); return boundedCells.has(helpers_1.cellIndentifier(cell.rowIndex, cell.columnIndex)); }); }; const cellEqualsSelection = (cell, selections) => { 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); }); }; const boundsSubsetOfSelection = (bounds, selection) => { return (bounds.top >= selection.top && bounds.bottom <= selection.bottom && bounds.left >= selection.left && bounds.right <= selection.right); }; /** * Triggers a new selection start */ const handleMouseDown = react_1.useCallback((e) => { /* Exit early if grid is not initialized */ if (!gridRef || !gridRef.current) return; const coords = gridRef.current.getCellCoordsFromOffset(e.clientX, e.clientY); if (!coords) return; /* Check if its context menu click */ const isContextMenuClick = e.nativeEvent.which === types_1.MouseButtonCodes.right; if (isContextMenuClick) { const cellIndex = cellIndexInSelection(coords, selections); if (cellIndex !== -1) 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 */ if (!isContextMenuClick) { /* Prevent mousemove if its contextmenu click */ document.addEventListener("mousemove", handleMouseMove); } document.addEventListener("mouseup", handleMouseUp); /* Activate selection mode */ isSelecting.current = true; /* 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 = react_1.useCallback((e) => { /* Exit if user is not in selection mode */ if (!isSelecting.current || !gridRef) return; const coords = gridRef.current.getCellCoordsFromOffset(e.clientX, e.clientY); if (!coords) return; if (isEqualCells(firstActiveCell.current, coords)) { return clearSelections(); } modifySelection(coords, true); gridRef.current.scrollToItem(coords); }, []); /** * Mouse up handler */ const handleMouseUp = react_1.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 Object.assign(Object.assign({}, sel), { inProgress: false }); } return sel; }); }); }, []); /** * Navigate selection using keyboard * @param direction * @param modify */ const keyNavigate = react_1.useCallback((direction, modify, metaKeyPressed) => { if (!selectionStart.current || !selectionEnd.current || !gridRef || !activeCell) return; var { rowIndex, columnIndex } = modify ? selectionEnd.current : activeCell; const isMergedCell = gridRef === null || gridRef === void 0 ? void 0 : gridRef.current.isMergedCell({ rowIndex, columnIndex, }); const currenBounds = gridRef.current.getCellBounds({ rowIndex, columnIndex, }); switch (direction) { case types_1.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 types_1.Direction.Down: if (isMergedCell) rowIndex = currenBounds.bottom; rowIndex = Math.min(rowIndex + 1, rowCount - 1); // Shift + Ctrl/Commmand if (metaKeyPressed) rowIndex = rowCount - 1; break; case types_1.Direction.Left: if (isMergedCell) columnIndex = currenBounds.left; columnIndex = Math.max(columnIndex - 1, 0); // Shift + Ctrl/Commmand if (metaKeyPressed) columnIndex = 0; break; case types_1.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 === null || gridRef === void 0 ? void 0 : gridRef.current.scrollToItem(cell); }; // End const selectLastCellInRow = () => { if (!selectionStart.current) return; const cell = { rowIndex: selectionStart.current.rowIndex, columnIndex: columnCount - 1, }; newSelection(cell); gridRef === null || gridRef === void 0 ? void 0 : gridRef.current.scrollToItem(cell); }; // ⌘+Home const selectFirstCellInColumn = () => { if (!selectionStart.current) return; const cell = { rowIndex: 0, columnIndex: selectionStart.current.columnIndex, }; newSelection(cell); gridRef === null || gridRef === void 0 ? void 0 : gridRef.current.scrollToItem(cell); }; // ⌘+End const selectLastCellInColumn = () => { if (!selectionStart.current) return; const cell = { rowIndex: rowCount - 1, columnIndex: selectionStart.current.columnIndex, }; newSelection(cell); gridRef === null || gridRef === void 0 ? void 0 : gridRef.current.scrollToItem(cell); }; // ⌘+Backspace const scrollToActiveCell = () => { if (!activeCell) return; gridRef === null || gridRef === void 0 ? void 0 : gridRef.current.scrollToItem(activeCell, helpers_1.Align.smart); }; const handleKeyDown = react_1.useCallback((e) => { const isShiftKey = e.nativeEvent.shiftKey; const isMetaKey = e.nativeEvent.ctrlKey || e.nativeEvent.metaKey; switch (e.nativeEvent.which) { case types_1.KeyCodes.Right: keyNavigate(types_1.Direction.Right, isShiftKey, isMetaKey); break; case types_1.KeyCodes.Left: keyNavigate(types_1.Direction.Left, isShiftKey, isMetaKey); break; // Up case types_1.KeyCodes.Up: keyNavigate(types_1.Direction.Up, isShiftKey, isMetaKey); break; case types_1.KeyCodes.Down: keyNavigate(types_1.Direction.Down, isShiftKey, isMetaKey); break; case types_1.KeyCodes.A: if (isMetaKey) { selectAll(); } break; case types_1.KeyCodes.Home: if (isMetaKey) { selectFirstCellInColumn(); } else { selectFirstCellInRow(); } break; case types_1.KeyCodes.End: if (isMetaKey) { selectLastCellInColumn(); } else { selectLastCellInRow(); } break; case types_1.KeyCodes.BackSpace: if (isMetaKey) scrollToActiveCell(); break; case types_1.KeyCodes.SPACE: if (isMetaKey && isShiftKey) { selectAll(); } else if (isMetaKey) { selectColumn(); } else if (isShiftKey) { selectRow(); } break; case types_1.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 ? types_1.Direction.Left : types_1.Direction.Right; const nextCell = helpers_1.findNextCellWithinBounds(activeCellBounds, bounds, direction); if (nextCell) setActiveCell(nextCell); } else { if (isShiftKey) { keyNavigate(types_1.Direction.Left); } else { keyNavigate(types_1.Direction.Right); } } e.preventDefault(); break; } }, [rowCount, columnCount, activeCell, selections]); /** * User modified active cell deliberately */ const handleSetActiveCell = react_1.useCallback((coords) => { selectionStart.current = coords; firstActiveCell.current = coords; selectionEnd.current = coords; setActiveCell(coords); }, []); const handleFillHandleMouseDown = react_1.useCallback((e) => { e.stopPropagation(); isFilling.current = true; document.addEventListener("mousemove", handleFillHandleMouseMove); document.addEventListener("mouseup", handleFillHandleMouseUp); }, [selections]); /** * TODO * 1. Fill does not extend to merged cells */ const handleFillHandleMouseMove = react_1.useCallback((e) => { /* Exit if user is not in selection mode */ if (!isFilling.current || !gridRef || !activeCellRef.current) return; const coords = gridRef.current.getCellCoordsFromOffset(e.clientX, e.clientY); if (!coords) return; let bounds = selectionFromStartEnd(activeCellRef.current, coords); const hasSelections = selections.length > 0; const activeCellBounds = hasSelections ? selections[selections.length - 1].bounds : gridRef.current.getCellBounds(activeCellRef.current); if (!bounds) return; const direction = bounds.right > activeCellBounds.right ? types_1.Direction.Right : bounds.top < activeCellBounds.top ? types_1.Direction.Up : bounds.left < activeCellBounds.left ? types_1.Direction.Left : types_1.Direction.Down; if (direction === types_1.Direction.Right) { bounds = Object.assign(Object.assign({}, activeCellBounds), { right: bounds.right }); } if (direction === types_1.Direction.Up) { bounds = Object.assign(Object.assign({}, activeCellBounds), { top: bounds.top }); } if (direction === types_1.Direction.Left) { bounds = Object.assign(Object.assign({}, activeCellBounds), { left: bounds.left }); } if (direction === types_1.Direction.Down) { bounds = Object.assign(Object.assign({}, activeCellBounds), { bottom: bounds.bottom }); } /** * If user moves back to the same selection, clear */ if (hasSelections && boundsSubsetOfSelection(bounds, selections[0].bounds)) { setFillSelection(null); return; } setFillSelection({ bounds }); gridRef.current.scrollToItem(coords); }, [selections]); const handleFillHandleMouseUp = react_1.useCallback((e) => { var _a; isFilling.current = false; /* Remove listener */ document.removeEventListener("mousemove", handleFillHandleMouseMove); document.removeEventListener("mouseup", handleFillHandleMouseUp); /* Exit early */ if (!gridRef || !activeCellRef.current) return; /* Update last selection */ let fillSelection = null; setFillSelection((prev) => { fillSelection = prev; return null; }); if (!activeCell || !fillSelection) return; const newBounds = (_a = fillSelection) === null || _a === void 0 ? void 0 : _a.bounds; if (!newBounds) return; /* Callback */ onFill && onFill(activeCellRef.current, fillSelection, selections); /* Modify last selection */ setSelections((prevSelection) => { const len = prevSelection.length; if (!len) { return [{ bounds: newBounds }]; } return prevSelection.map((sel, i) => { if (len - 1 === i) { return Object.assign(Object.assign({}, sel), { bounds: newBounds }); } return sel; }); }); }, [selections]); /** * Remove the last selection from state */ const handleClearLastSelection = () => { setSelections((prev) => prev.slice(0, -1)); }; return { activeCell, selections, onMouseDown: handleMouseDown, onKeyDown: handleKeyDown, newSelection, setSelections, setActiveCell: handleSetActiveCell, fillHandleProps: { onMouseDown: handleFillHandleMouseDown, }, fillSelection, clearLastSelection: handleClearLastSelection, }; }; exports.default = useSelection; //# sourceMappingURL=useSelection.js.map