kvm/ui/src/components/VirtualKeyboard.tsx

336 lines
13 KiB
TypeScript

import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { AnimatePresence, motion } from "framer-motion";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Keyboard from "react-simple-keyboard";
import Card from "@components/Card";
// eslint-disable-next-line import/order
import { Button } from "@components/Button";
import "react-simple-keyboard/build/css/index.css";
import AttachIconRaw from "@/assets/attach-icon.svg";
import DetachIconRaw from "@/assets/detach-icon.svg";
import { cx } from "@/cva.config";
import { useHidStore, useUiStore } from "@/hooks/stores";
import useKeyboard from "@/hooks/useKeyboard";
import { useKeyboardLayout } from "@/hooks/useKeyboardLayout";
import { keys, modifiers, latchingKeys, decodeModifiers } from "@/keyboardMappings";
export const DetachIcon = ({ className }: { className?: string }) => {
return <img src={DetachIconRaw} alt="Detach Icon" className={className} />;
};
const AttachIcon = ({ className }: { className?: string }) => {
return <img src={AttachIconRaw} alt="Attach Icon" className={className} />;
};
function KeyboardWrapper() {
const keyboardRef = useRef<HTMLDivElement>(null);
const { isAttachedVirtualKeyboardVisible, setAttachedVirtualKeyboardVisibility } = useUiStore();
const { keysDownState, /* keyboardLedState,*/ isVirtualKeyboardEnabled, setVirtualKeyboardEnabled } = useHidStore();
const { handleKeyPress, executeMacro } = useKeyboard();
const [isDragging, setIsDragging] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [newPosition, setNewPosition] = useState({ x: 0, y: 0 });
const { keyboard } = useKeyboardLayout();
//const isCapsLockActive = useMemo(() => {
// return (keyboardLedState.caps_lock);
//}, [keyboardLedState]);
const { isShiftActive, /*isControlActive, isAltActive, isMetaActive, isAltGrActive*/ } = useMemo(() => {
return decodeModifiers(keysDownState.modifier);
}, [keysDownState]);
const mainLayoutName = useMemo(() => {
const layoutName = isShiftActive ? "shift": "default";
return layoutName;
}, [isShiftActive]);
const keyNamesForDownKeys = useMemo(() => {
const activeModifierMask = keysDownState.modifier || 0;
const modifierNames = Object.entries(modifiers).filter(([_, mask]) => (activeModifierMask & mask) !== 0).map(([name, _]) => name);
const keysDown = keysDownState.keys || [];
const keyNames = Object.entries(keys).filter(([_, value]) => keysDown.includes(value)).map(([name, _]) => name);
return [...modifierNames,...keyNames, ' ']; // we have to have at least one space to avoid keyboard whining
}, [keysDownState]);
const startDrag = useCallback((e: MouseEvent | TouchEvent) => {
if (!keyboardRef.current) return;
if (e instanceof TouchEvent && e.touches.length > 1) return;
setIsDragging(true);
const clientX = e instanceof TouchEvent ? e.touches[0].clientX : e.clientX;
const clientY = e instanceof TouchEvent ? e.touches[0].clientY : e.clientY;
const rect = keyboardRef.current.getBoundingClientRect();
setPosition({
x: clientX - rect.left,
y: clientY - rect.top,
});
}, []);
const onDrag = useCallback(
(e: MouseEvent | TouchEvent) => {
if (!keyboardRef.current) return;
if (isDragging) {
const clientX = e instanceof TouchEvent ? e.touches[0].clientX : e.clientX;
const clientY = e instanceof TouchEvent ? e.touches[0].clientY : e.clientY;
const newX = clientX - position.x;
const newY = clientY - position.y;
const rect = keyboardRef.current.getBoundingClientRect();
const maxX = window.innerWidth - rect.width;
const maxY = window.innerHeight - rect.height;
setNewPosition({
x: Math.min(maxX, Math.max(0, newX)),
y: Math.min(maxY, Math.max(0, newY)),
});
}
},
[isDragging, position.x, position.y],
);
const endDrag = useCallback(() => {
setIsDragging(false);
}, []);
useEffect(() => {
const handle = keyboardRef.current;
if (handle) {
handle.addEventListener("touchstart", startDrag);
handle.addEventListener("mousedown", startDrag);
}
document.addEventListener("mouseup", endDrag);
document.addEventListener("touchend", endDrag);
document.addEventListener("mousemove", onDrag);
document.addEventListener("touchmove", onDrag);
return () => {
if (handle) {
handle.removeEventListener("touchstart", startDrag);
handle.removeEventListener("mousedown", startDrag);
}
document.removeEventListener("mouseup", endDrag);
document.removeEventListener("touchend", endDrag);
document.removeEventListener("mousemove", onDrag);
document.removeEventListener("touchmove", onDrag);
};
}, [endDrag, onDrag, startDrag]);
const onKeyUp = useCallback(
async (_: string, e: MouseEvent | undefined) => {
e?.preventDefault();
e?.stopPropagation();
},
[]
);
const onKeyDown = useCallback(
async (key: string, e: MouseEvent | undefined) => {
e?.preventDefault();
e?.stopPropagation();
// handle the fake key-macros we have defined for common combinations
if (key === "CtrlAltDelete") {
await executeMacro([ { keys: ["Delete"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 } ]);
return;
}
if (key === "AltMetaEscape") {
await executeMacro([ { keys: ["Escape"], modifiers: ["AltLeft", "MetaLeft"], delay: 100 } ]);
return;
}
if (key === "CtrlAltBackspace") {
await executeMacro([ { keys: ["Backspace"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 } ]);
return;
}
// if they press any of the latching keys, we send a keypress down event and the release it automatically (on timer)
if (latchingKeys.includes(key)) {
console.debug(`Latching key pressed: ${key} sending down and delayed up pair`);
handleKeyPress(keys[key], true)
setTimeout(() => handleKeyPress(keys[key], false), 100);
return;
}
// if they press any of the dynamic keys, we send a keypress down event but we don't release it until they click it again
if (Object.keys(modifiers).includes(key)) {
const currentlyDown = keyNamesForDownKeys.includes(key);
console.debug(`Dynamic key pressed: ${key} was currently down: ${currentlyDown}, toggling state`);
handleKeyPress(keys[key], !currentlyDown)
return;
}
// otherwise, just treat it as a down+up pair
const cleanKey = key.replace(/[()]/g, "");
console.debug(`Regular key pressed: ${cleanKey} sending down and up pair`);
handleKeyPress(keys[cleanKey], true);
setTimeout(() => handleKeyPress(keys[cleanKey], false), 50);
},
[executeMacro, handleKeyPress, keyNamesForDownKeys],
);
return (
<div
className="transition-all duration-500 ease-in-out"
style={{
marginBottom: isVirtualKeyboardEnabled ? "0px" : `-${350}px`,
}}
>
<AnimatePresence>
{isVirtualKeyboardEnabled && (
<motion.div
initial={{ opacity: 0, y: "100%" }}
animate={{ opacity: 1, y: "0%" }}
exit={{ opacity: 0, y: "100%" }}
transition={{
duration: 0.5,
ease: "easeInOut",
}}
>
<div
className={cx(
!isAttachedVirtualKeyboardVisible
? "fixed left-0 top-0 z-50 select-none"
: "relative",
)}
ref={keyboardRef}
style={{
...(!isAttachedVirtualKeyboardVisible
? { transform: `translate(${newPosition.x}px, ${newPosition.y}px)` }
: {}),
}}
>
<Card
className={cx("overflow-hidden", {
"rounded-none": isAttachedVirtualKeyboardVisible,
})}
>
<div className="flex items-center justify-center border-b border-b-slate-800/30 bg-white px-2 py-1 dark:border-b-slate-300/20 dark:bg-slate-800">
<div className="absolute left-2 flex items-center gap-x-2">
{isAttachedVirtualKeyboardVisible ? (
<Button
size="XS"
theme="light"
text="Detach"
onClick={() => setAttachedVirtualKeyboardVisibility(false)}
/>
) : (
<Button
size="XS"
theme="light"
text="Attach"
LeadingIcon={AttachIcon}
onClick={() => setAttachedVirtualKeyboardVisibility(true)}
/>
)}
</div>
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
Virtual Keyboard
</h2>
<div className="absolute right-2">
<Button
size="XS"
theme="light"
text="Hide"
LeadingIcon={ChevronDownIcon}
onClick={() => setVirtualKeyboardEnabled(false)}
/>
</div>
</div>
<div>
<div className="flex flex-col bg-blue-50/80 md:flex-row dark:bg-slate-700">
<Keyboard
baseClass="simple-keyboard-main"
layoutName={mainLayoutName}
onKeyPress={onKeyDown}
onKeyReleased={onKeyUp}
buttonTheme={[
{
class: "combination-key",
buttons: "CtrlAltDelete AltMetaEscape CtrlAltBackspace",
},
{
class: "down-key",
buttons: keyNamesForDownKeys.join(" "),
},
]}
display={keyboard.keyDisplayMap}
layout={keyboard.virtualKeyboard.main}
disableButtonHold={true}
debug={false}
preventMouseDownDefault={true}
preventMouseUpDefault={true}
stopMouseDownPropagation={true}
stopMouseUpPropagation={true}
physicalKeyboardHighlight={true}
physicalKeyboardHighlightPress={true}
physicalKeyboardHighlightPreventDefault={true}
enableLayoutCandidates={false}
/>
<div className="controlArrows">
<Keyboard
baseClass="simple-keyboard-control"
theme="simple-keyboard hg-theme-default hg-layout-default"
layoutName="default"
onKeyPress={onKeyDown}
onKeyReleased={onKeyUp}
display={keyboard.keyDisplayMap}
layout={keyboard.virtualKeyboard.control}
debug={false}
preventMouseDownDefault={true}
preventMouseUpDefault={true}
stopMouseDownPropagation={true}
stopMouseUpPropagation={true}
physicalKeyboardHighlight={true}
physicalKeyboardHighlightPress={true}
physicalKeyboardHighlightPreventDefault={true}
enableLayoutCandidates={false}
/>
<Keyboard
baseClass="simple-keyboard-arrows"
theme="simple-keyboard hg-theme-default hg-layout-default"
onKeyPress={onKeyDown}
onKeyReleased={onKeyUp}
display={keyboard.keyDisplayMap}
layout={keyboard.virtualKeyboard.arrows}
debug={false}
preventMouseDownDefault={true}
preventMouseUpDefault={true}
stopMouseDownPropagation={true}
stopMouseUpPropagation={true}
physicalKeyboardHighlight={true}
physicalKeyboardHighlightPress={true}
physicalKeyboardHighlightPreventDefault={true}
enableLayoutCandidates={false}
/>
</div>
{ /* TODO add optional number pad */ }
</div>
</div>
</Card>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export default KeyboardWrapper;