From be3b451d1d3d13c1150a4272238c0cf2e74ff5bd Mon Sep 17 00:00:00 2001 From: Moritz Klack Date: Tue, 5 Oct 2021 15:27:07 +0200 Subject: [PATCH] Refactor: add possibility to use different keys or combinations as a key code (#1554) * feat(keys): support for combinations and multiple possibilites for one action * refactor(globalKeyHandler): pass deps to useEffect * refactor(examples): define key codes outside of the component --- example/src/EdgeTypes/index.tsx | 8 +++ src/hooks/useGlobalKeyHandler.ts | 2 +- src/hooks/useKeyPress.ts | 103 ++++++++++++++++++++++++++----- src/types/index.ts | 2 +- src/utils/index.ts | 11 ---- 5 files changed, 97 insertions(+), 29 deletions(-) diff --git a/example/src/EdgeTypes/index.tsx b/example/src/EdgeTypes/index.tsx index 7d87e879..7c87fe7e 100644 --- a/example/src/EdgeTypes/index.tsx +++ b/example/src/EdgeTypes/index.tsx @@ -23,6 +23,9 @@ const onLoad = (reactFlowInstance: OnLoadParams) => { const initialElements = getElements(); +const multiSelectionKeyCode = ['ShiftLeft', 'ShiftRight']; +const deleteKeyCode = ['AltLeft+KeyD', 'Backspace']; + const EdgeTypesFlow = () => { const [elements, setElements] = useState(initialElements); const onElementsRemove = (elementsToRemove: Elements) => setElements((els) => removeElements(elementsToRemove, els)); @@ -35,6 +38,11 @@ const EdgeTypesFlow = () => { onElementsRemove={onElementsRemove} onConnect={onConnect} minZoom={0.2} + zoomOnScroll={false} + selectionKeyCode="a+s" + multiSelectionKeyCode={multiSelectionKeyCode} + deleteKeyCode={deleteKeyCode} + zoomActivationKeyCode="z" > diff --git a/src/hooks/useGlobalKeyHandler.ts b/src/hooks/useGlobalKeyHandler.ts index c43997a9..340620f5 100644 --- a/src/hooks/useGlobalKeyHandler.ts +++ b/src/hooks/useGlobalKeyHandler.ts @@ -36,7 +36,7 @@ export default ({ deleteKeyCode, multiSelectionKeyCode, onElementsRemove }: Hook unsetNodesSelection(); resetSelectedElements(); } - }, [deleteKeyPressed]); + }, [deleteKeyPressed, onElementsRemove]); useEffect(() => { setMultiSelectionActive(multiSelectionKeyPressed); diff --git a/src/hooks/useKeyPress.ts b/src/hooks/useKeyPress.ts index 358a6d1a..3b1b94d0 100644 --- a/src/hooks/useKeyPress.ts +++ b/src/hooks/useKeyPress.ts @@ -1,40 +1,111 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef, useMemo } from 'react'; -import { isInputDOMNode } from '../utils'; import { KeyCode } from '../types'; -export default (keyCode?: KeyCode): boolean => { +type Keys = Array; +type PressedKeys = Set; +type KeyOrCode = 'key' | 'code'; +export interface UseKeyPressOptions { + target: Document | HTMLElement | ShadowRoot; +} + +// the keycode can be a string 'a' or an array of strings ['a', 'a+d'] +// a string means a single key 'a' or a combination when '+' is used 'a+d' +// an array means different possibilites. Explainer: ['a', 'd+s'] here the +// user can use the single key 'a' or the combination 'd' + 's' +export default (keyCode: KeyCode | null = null, options: UseKeyPressOptions = { target: document }): boolean => { const [keyPressed, setKeyPressed] = useState(false); - useEffect(() => { - if (typeof keyCode !== 'undefined') { - const downHandler = (event: KeyboardEvent) => { - if (!isInputDOMNode(event) && (event.key === keyCode || event.keyCode === keyCode)) { - event.preventDefault(); + // we need to remember the pressed keys in order to support combinations + const pressedKeys = useRef(new Set([])); + // keyCodes = array with single keys [['a']] or key combinations [['a', 's']] + // keysToWatch = array with all keys flattened ['a', 'd', 'ShiftLeft'] + // used to check if we store event.code or event.key. When the code is in the list of keysToWatch + // we use the code otherwise the key. Explainer: When you press the left "command" key, the code is "MetaLeft" + // and the key is "Meta". We want users to be able to pass keys and codes so we assume that the key is meant when + // we can't find it in the list of keysToWatch. + const [keyCodes, keysToWatch] = useMemo<[Array, Keys]>(() => { + if (keyCode !== null) { + const keyCodeArr = Array.isArray(keyCode) ? keyCode : [keyCode]; + const keys = keyCodeArr.map((kc) => kc.split('+')); + const keysFlat = keys.reduce((res: Keys, item) => res.concat(...item), []); + + return [keys, keysFlat]; + } + + return [[], []]; + }, [keyCode]); + + useEffect(() => { + if (keyCode !== null) { + const downHandler = (event: KeyboardEvent) => { + const keyOrCode = useKeyOrCode(event.code, keysToWatch); + pressedKeys.current.add(event[keyOrCode]); + + if (isMatchingKey(event, keyCodes, pressedKeys.current)) { + event.preventDefault(); setKeyPressed(true); } }; const upHandler = (event: KeyboardEvent) => { - if (!isInputDOMNode(event) && (event.key === keyCode || event.keyCode === keyCode)) { + const keyOrCode = useKeyOrCode(event.code, keysToWatch); + + if (isMatchingKey(event, keyCodes, pressedKeys.current)) { setKeyPressed(false); } + + pressedKeys.current.delete(event[keyOrCode]); }; - const resetHandler = () => setKeyPressed(false); + const resetHandler = () => { + pressedKeys.current.clear(); + setKeyPressed(false); + }; - window.addEventListener('keydown', downHandler); - window.addEventListener('keyup', upHandler); - window.addEventListener('blur', resetHandler); + options.target.addEventListener('keydown', downHandler as EventListenerOrEventListenerObject); + options.target.addEventListener('keyup', upHandler as EventListenerOrEventListenerObject); + options.target.addEventListener('blur', resetHandler); return () => { - window.removeEventListener('keydown', downHandler); - window.removeEventListener('keyup', upHandler); - window.removeEventListener('blur', resetHandler); + pressedKeys.current.clear(); + + options.target.removeEventListener('keydown', downHandler as EventListenerOrEventListenerObject); + options.target.removeEventListener('keyup', upHandler as EventListenerOrEventListenerObject); + options.target.removeEventListener('blur', resetHandler); }; } }, [keyCode, setKeyPressed]); return keyPressed; }; + +// utils + +function isMatchingKey(event: KeyboardEvent, keyCodes: Array, pressedKeys: PressedKeys): boolean { + if (isInputDOMNode(event)) { + return false; + } + + return ( + keyCodes + // we only want to compare same sizes of keyCode definitions + // and pressed keys. When the user specified 'Meta' as a key somewhere + // this would also be truthy without this filter when user presses 'Meta' + 'r' + .filter((keys) => keys.length === pressedKeys.size) + // since we want to support multiple possibilities only one of the + // combinations need to be part of the pressed keys + .some((keys) => keys.every((k) => pressedKeys.has(k))) + ); +} + +function useKeyOrCode(eventCode: string, keysToWatch: KeyCode): KeyOrCode { + return keysToWatch.includes(eventCode) ? 'code' : 'key'; +} + +function isInputDOMNode(e: KeyboardEvent): boolean { + const target = e?.target as HTMLElement; + + return ['INPUT', 'SELECT', 'TEXTAREA'].includes(target?.nodeName) || target?.hasAttribute('contenteditable'); +} diff --git a/src/types/index.ts b/src/types/index.ts index 8bb678ea..754e10e2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -363,7 +363,7 @@ export type FlowTransform = { export type TranslateExtent = [[number, number], [number, number]]; export type NodeExtent = TranslateExtent; -export type KeyCode = number | string; +export type KeyCode = string | Array; export enum PanOnScrollMode { Free = 'free', diff --git a/src/utils/index.ts b/src/utils/index.ts index 77d1eef5..29c3149f 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,16 +1,5 @@ -import { DraggableEvent } from 'react-draggable'; -import { MouseEvent as ReactMouseEvent } from 'react'; - import { Dimensions, XYPosition, NodeExtent } from '../types'; -export const isInputDOMNode = (e: ReactMouseEvent | DraggableEvent | KeyboardEvent) => { - const target = e?.target as HTMLElement; - - return ( - ['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON'].includes(target?.nodeName) || target?.hasAttribute('contenteditable') - ); -}; - export const getDimensions = (node: HTMLDivElement): Dimensions => ({ width: node.offsetWidth, height: node.offsetHeight,