diff --git a/package.json b/package.json index 53fb464e..4fac6672 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,6 @@ "dependencies": { "@emotion/react": "^11.10.5", "@emotion/styled": "^11.10.5", - "@mantine/hooks": "^5.10.0", "@mdi/js": "^6.6.96", "@monaco-editor/react": "^4.4.4", "@mui/icons-material": "^5.10.16", diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index 0b772a2b..76d504b5 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -30,12 +30,14 @@ import { tableNextPageAtom, tablePageAtom, updateColumnAtom, + selectedCellAtom, } from "@src/atoms/tableScope"; +import { useMenuAction } from "@src/contexts/TableKbShortcutContext"; import { getFieldType, getFieldProp } from "@src/components/fields"; import { useKeyboardNavigation } from "./useKeyboardNavigation"; import { useSaveColumnSizing } from "./useSaveColumnSizing"; +import useHotKeys from "./useHotKeys"; import type { TableRow, ColumnConfig } from "@src/types/table"; -import { TableKbShortcutProvider } from "@src/contexts/TableKbShortcutContext"; export const DEFAULT_ROW_HEIGHT = 41; export const DEFAULT_COL_WIDTH = 150; @@ -182,6 +184,13 @@ export default function Table({ tableRows, leafColumns, }); + const [selectedCell] = useAtom(selectedCellAtom, tableScope); + const { handleCopy, handlePaste, handleCut } = useMenuAction(selectedCell); + const { handler } = useHotKeys([ + ["mod+C", handleCopy], + ["mod+X", handleCut], + ["mod+V", handlePaste], + ]); // Handle prompt to save local column sizes if user `canEditColumns` useSaveColumnSizing(columnSizing, canEditColumns); @@ -243,7 +252,10 @@ export default function Table({ "--row-height": `${tableSchema.rowHeight || DEFAULT_ROW_HEIGHT}px`, } as any } - onKeyDown={handleKeyDown} + onKeyDown={(e) => { + handleKeyDown(e); + handler(e); + }} >
) : ( - - - + )} diff --git a/src/components/Table/useHotKeys.tsx b/src/components/Table/useHotKeys.tsx new file mode 100644 index 00000000..2791dc8f --- /dev/null +++ b/src/components/Table/useHotKeys.tsx @@ -0,0 +1,99 @@ +import { useCallback } from "react"; + +type HotKeysAction = [ + string, + (event: React.KeyboardEvent | KeyboardEvent) => void +]; + +export default function useHotKeys(actions: HotKeysAction[]) { + // master event handler + const handler = useCallback( + (event: React.KeyboardEvent) => { + const event_ = "nativeEvent" in event ? event.nativeEvent : event; + actions.forEach(([hotkey, handler_]) => { + if (getHotkeyMatcher(hotkey)(event_)) { + event.preventDefault(); + handler_(event_); + } + }); + }, + [actions] + ); + + return { handler }; +} + +type KeyboardModifiers = { + alt: boolean; + ctrl: boolean; + meta: boolean; + mod: boolean; + shift: boolean; +}; + +export type Hotkey = KeyboardModifiers & { + key?: string; +}; +function isExactHotkey(hotkey: Hotkey, event: KeyboardEvent): boolean { + const { alt, ctrl, meta, mod, shift, key } = hotkey; + const { altKey, ctrlKey, metaKey, shiftKey, key: pressedKey } = event; + + if (alt !== altKey) { + return false; + } + + if (mod) { + if (!ctrlKey && !metaKey) { + return false; + } + } else { + if (ctrl !== ctrlKey) { + return false; + } + if (meta !== metaKey) { + return false; + } + } + if (shift !== shiftKey) { + return false; + } + + if ( + key && + (pressedKey.toLowerCase() === key.toLowerCase() || + event.code.replace("Key", "").toLowerCase() === key.toLowerCase()) + ) { + return true; + } + + return false; +} + +type CheckHotkeyMatch = (event: KeyboardEvent) => boolean; +export function getHotkeyMatcher(hotkey: string): CheckHotkeyMatch { + return (event) => isExactHotkey(parseHotkey(hotkey), event); +} + +function parseHotkey(hotkey: string): Hotkey { + const keys = hotkey + .toLowerCase() + .split("+") + .map((part) => part.trim()); + + const modifiers: KeyboardModifiers = { + alt: keys.includes("alt"), + ctrl: keys.includes("ctrl"), + meta: keys.includes("meta"), + mod: keys.includes("mod"), + shift: keys.includes("shift"), + }; + + const reservedKeys = ["alt", "ctrl", "meta", "shift", "mod"]; + + const freeKey = keys.find((key) => !reservedKeys.includes(key)); + + return { + ...modifiers, + key: freeKey, + }; +} diff --git a/src/contexts/TableKbShortcutContext.tsx b/src/contexts/TableKbShortcutContext.tsx index 1576bd00..516124d3 100644 --- a/src/contexts/TableKbShortcutContext.tsx +++ b/src/contexts/TableKbShortcutContext.tsx @@ -1,21 +1,13 @@ -import { - createContext, - useContext, - useCallback, - useState, - useEffect, -} from "react"; +import { useCallback, useState, useEffect } from "react"; import { useAtom, useSetAtom } from "jotai"; import { useSnackbar } from "notistack"; import { get, find } from "lodash-es"; -import { useHotkeys } from "@mantine/hooks"; import { tableScope, tableSchemaAtom, tableRowsAtom, updateFieldAtom, - selectedCellAtom, SelectedCell, } from "@src/atoms/tableScope"; import { getFieldProp, getFieldType } from "@src/components/fields"; @@ -45,7 +37,6 @@ export function useMenuAction( const updateField = useSetAtom(updateFieldAtom, tableScope); const [cellValue, setCellValue] = useState(); const [selectedCol, setSelectedCol] = useState(); - const [enableAction, setEnableAction] = useState(false); const handleCopy = useCallback(async () => { try { @@ -134,10 +125,6 @@ export function useMenuAction( if (handleClose) handleClose(); }, [selectedCell, selectedCol, updateField, enqueueSnackbar, handleClose]); - useEffect(() => { - setEnableAction(SUPPORTED_TYPES.has(selectedCol?.type)); - }, [selectedCol]); - useEffect(() => { if (!selectedCell) return setCellValue(""); const selectedCol = tableSchema.columns?.[selectedCell.columnKey]; @@ -147,20 +134,23 @@ export function useMenuAction( setCellValue(get(selectedRow, selectedCol.fieldName)); }, [selectedCell, tableSchema, tableRows]); - const checkEnabled = (func: Function) => { - return function () { - if (enableAction) { - return func(); - } else { - enqueueSnackbar( - `${selectedCol?.type} field cannot be copied using keyboard shortcut`, - { - variant: "info", - } - ); - } - }; - }; + const checkEnabled = useCallback( + (func: Function) => { + return function () { + if (SUPPORTED_TYPES.has(selectedCol?.type)) { + return func(); + } else { + enqueueSnackbar( + `${selectedCol?.type} field cannot be copied using keyboard shortcut`, + { + variant: "info", + } + ); + } + }; + }, + [selectedCol] + ); return { handleCopy: checkEnabled(handleCopy), @@ -169,26 +159,3 @@ export function useMenuAction( cellValue, }; } - -const TableKbShortcutContext = createContext(null); - -export function useTableKbShortcut() { - return useContext(TableKbShortcutContext); -} - -export function TableKbShortcutProvider(props: { children: any }) { - const [selectedCell] = useAtom(selectedCellAtom, tableScope); - const { handleCopy, handlePaste, handleCut } = useMenuAction(selectedCell); - - useHotkeys([ - ["mod+C", handleCopy], - ["mod+X", handleCut], - ["mod+V", handlePaste], - ]); - - return ( - - {props.children} - - ); -} diff --git a/yarn.lock b/yarn.lock index 61ea6da5..003c37e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2217,11 +2217,6 @@ resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz#b2ac626d6cb9c8718ab459166d4bb405b8ffa78b" integrity sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A== -"@mantine/hooks@^5.10.0": - version "5.10.0" - resolved "https://registry.yarnpkg.com/@mantine/hooks/-/hooks-5.10.0.tgz#e7886025a11dfa25f99c8c7fb7186d6a065c9d5c" - integrity sha512-dAefxpvqjFtXNeKse+awkIa4U1XGnMMOqWg1+07Y2Ino2G6EiT8AEnYqQyTXgcPoNaWwG9533Q/DDadmyweqaQ== - "@mark.probst/unicode-properties@~1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@mark.probst/unicode-properties/-/unicode-properties-1.1.0.tgz#5caafeab4737df93163d6d288007df33f9939b80"