react-konva-grid
Version:
Declarative React Canvas Grid primitive for Data table, Pivot table, Excel Worksheets
628 lines • 24.3 kB
JavaScript
;
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