|
| 1 | +import { useState, forwardRef, useRef } from 'react'; |
| 2 | +import { Typography } from './Typography'; |
| 3 | +import classNames from 'classnames'; |
| 4 | +import { useFormContext } from 'react-hook-form'; |
| 5 | + |
| 6 | +const excludedKeys = [' ', 'SPACE', 'META']; |
| 7 | +const maxKeybindLength = 4; |
| 8 | + |
| 9 | +export const KeybindRecorder = forwardRef< |
| 10 | + HTMLInputElement, |
| 11 | + { |
| 12 | + keys: string[]; |
| 13 | + onKeysChange: (v: string[]) => void; |
| 14 | + error?: string; |
| 15 | + } |
| 16 | +>(function KeybindRecorder({ keys, onKeysChange, error }) { |
| 17 | + const [localKeys, setLocalKeys] = useState<string[]>(keys); |
| 18 | + const [isRecording, setIsRecording] = useState(false); |
| 19 | + const [oldKeys, setOldKeys] = useState<string[]>([]); |
| 20 | + const [invalidSlot, setInvalidSlot] = useState<number | null>(null); |
| 21 | + const [errorText, setErrorText] = useState<string>(''); |
| 22 | + const inputRef = useRef<HTMLInputElement>(null); |
| 23 | + const displayKeys = isRecording ? localKeys : keys; |
| 24 | + const activeIndex = isRecording ? displayKeys.length : -1; |
| 25 | + const displayError = errorText || error; |
| 26 | + |
| 27 | + const { clearErrors } = useFormContext(); |
| 28 | + |
| 29 | + const handleKeyDown = (e: React.KeyboardEvent) => { |
| 30 | + e.preventDefault(); |
| 31 | + const key = e.key.toUpperCase(); |
| 32 | + const errorMsg = excludedKeys.includes(key) |
| 33 | + ? `Cannot use ${key}!` |
| 34 | + : displayKeys.includes(key) |
| 35 | + ? `${key} is a Duplicate Key!` |
| 36 | + : null; |
| 37 | + if (errorMsg) { |
| 38 | + setErrorText(errorMsg); |
| 39 | + setInvalidSlot(activeIndex); |
| 40 | + setTimeout(() => { |
| 41 | + setInvalidSlot(null); |
| 42 | + }, 350); |
| 43 | + return; |
| 44 | + } |
| 45 | + |
| 46 | + if (displayKeys.length < maxKeybindLength) { |
| 47 | + const updatedKeys = [...displayKeys, key]; |
| 48 | + setLocalKeys(updatedKeys); |
| 49 | + onKeysChange(updatedKeys); |
| 50 | + if (updatedKeys.length == maxKeybindLength) { |
| 51 | + inputRef.current?.blur(); |
| 52 | + } |
| 53 | + } |
| 54 | + }; |
| 55 | + |
| 56 | + const handleOnBlur = () => { |
| 57 | + setIsRecording(false); |
| 58 | + if (displayKeys.length < maxKeybindLength - 2 || error) { |
| 59 | + onKeysChange(oldKeys); |
| 60 | + setLocalKeys(oldKeys); |
| 61 | + } |
| 62 | + }; |
| 63 | + |
| 64 | + const handleOnFocus = () => { |
| 65 | + clearErrors('keybinds'); |
| 66 | + const initialKeys: string[] = []; |
| 67 | + setOldKeys(keys); |
| 68 | + setLocalKeys(initialKeys); |
| 69 | + onKeysChange(initialKeys); |
| 70 | + setIsRecording(true); |
| 71 | + }; |
| 72 | + |
| 73 | + return ( |
| 74 | + <div className="w-full justify-center items-center flex flex-col gap-2"> |
| 75 | + <div className="flex gap-2 p-2 items-center rounded-lg relative"> |
| 76 | + <input |
| 77 | + autoFocus |
| 78 | + ref={inputRef} |
| 79 | + className="opacity-0 absolute cursor-pointer w-full" |
| 80 | + onFocus={handleOnFocus} |
| 81 | + onBlur={handleOnBlur} |
| 82 | + onKeyDown={handleKeyDown} |
| 83 | + /> |
| 84 | + <div className="flex flex-grow gap-2 justify-center h-full"> |
| 85 | + {Array.from({ length: maxKeybindLength }).map((_, i) => { |
| 86 | + const key = displayKeys[i]; |
| 87 | + const isActive = isRecording && i === activeIndex; |
| 88 | + const isInvalid = invalidSlot === i; |
| 89 | + return ( |
| 90 | + <div key={i} className="flex flex-row"> |
| 91 | + <div |
| 92 | + className={classNames( |
| 93 | + 'flex p-2 rounded-lg min-w-[50px] min-h-[50px] text-main-title justify-center items-center bg-background-80 mobile:text-sm', |
| 94 | + { |
| 95 | + 'keyslot-invalid ring-2 ring-status-critical': isInvalid, |
| 96 | + 'keyslot-animate ring-2 ring-accent': |
| 97 | + isActive && !isInvalid, |
| 98 | + 'ring-accent': !isInvalid && !isInvalid, |
| 99 | + } |
| 100 | + )} |
| 101 | + > |
| 102 | + {key ?? ''} |
| 103 | + </div> |
| 104 | + <div className="flex pl-2 text-main-title justify-center items-center mobile:text-sm"> |
| 105 | + {i < maxKeybindLength - 1 ? '+' : ''} |
| 106 | + </div> |
| 107 | + </div> |
| 108 | + ); |
| 109 | + })} |
| 110 | + </div> |
| 111 | + </div> |
| 112 | + {displayError && ( |
| 113 | + <div className="isInvalid keyslot-invalid"> |
| 114 | + <Typography color="text-status-critical">{`${errorText} ${error}`}</Typography> |
| 115 | + </div> |
| 116 | + )} |
| 117 | + </div> |
| 118 | + ); |
| 119 | +}); |
0 commit comments