Clean up Virtual Keyboard styling (#761)

* Clean up Virtual Keyboard styling

Make the header a bit taller
Add keyboard layout name to header
Make the detached keyboard render smaller key text so you can read them.
Updated the settings text for keyboard layout.

* Add the key graphics and missing keys

* style(ui): add cursor-pointer class to Button component for better UX

* refactor(ui): Improve header styling and detach bug

- Remove unused AttachIcon and related SVG asset.
- Replace icon usage with a styled LinkButton to improve consistency.
- Simplify and reformat VirtualKeyboard component for better readability.

* refactor(ui): Hide keyboard layout settings on mobile and fix minor styling

---------

Co-authored-by: Marc Brooks <IDisposable@gmail.com>
This commit is contained in:
Adam Shiervani 2025-09-03 11:33:07 +02:00 committed by GitHub
parent 94521ef6db
commit c68e15bf89
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 98 additions and 76 deletions

View File

@ -1,8 +0,0 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 6C2 4.89543 2.89543 4 4 4H20C21.1046 4 22 4.89543 22 6V18C22 19.1046 21.1046 20 20 20H4C2.89543 20 2 19.1046 2 18V6Z"
fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M20 6H4V18H20V6ZM4 4C2.89543 4 2 4.89543 2 6V18C2 19.1046 2.89543 20 4 20H20C21.1046 20 22 19.1046 22 18V6C22 4.89543 21.1046 4 20 4H4Z"
fill="black"/>
<path d="M4 13H20V18H4V13Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 511 B

View File

@ -175,7 +175,7 @@ type ButtonPropsType = Pick<
export const Button = React.forwardRef<HTMLButtonElement, ButtonPropsType>(
({ type, disabled, onClick, formNoValidate, loading, fetcher, ...props }, ref) => {
const classes = cx(
"group outline-hidden",
"group outline-hidden cursor-pointer",
props.fullWidth ? "w-full" : "",
loading ? "pointer-events-none" : "",
);

View File

@ -2,33 +2,31 @@ 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 { LuKeyboard } from "react-icons/lu";
import Card from "@components/Card";
// eslint-disable-next-line import/order
import { Button } from "@components/Button";
import { Button, LinkButton } 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";
import { decodeModifiers, keys, latchingKeys, modifiers } 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 { isAttachedVirtualKeyboardVisible, setAttachedVirtualKeyboardVisibility } =
useUiStore();
const { keysDownState, isVirtualKeyboardEnabled, setVirtualKeyboardEnabled } =
useHidStore();
const { handleKeyPress, executeMacro } = useKeyboard();
const { selectedKeyboard } = useKeyboardLayout();
@ -44,29 +42,28 @@ function KeyboardWrapper() {
return selectedKeyboard.virtualKeyboard;
}, [selectedKeyboard]);
//const isCapsLockActive = useMemo(() => {
// return (keyboardLedState.caps_lock);
//}, [keyboardLedState]);
const { isShiftActive, /*isControlActive, isAltActive, isMetaActive, isAltGrActive*/ } = useMemo(() => {
const { isShiftActive } = useMemo(() => {
return decodeModifiers(keysDownState.modifier);
}, [keysDownState]);
const mainLayoutName = useMemo(() => {
const layoutName = isShiftActive ? "shift": "default";
return layoutName;
return isShiftActive ? "shift" : "default";
}, [isShiftActive]);
const keyNamesForDownKeys = useMemo(() => {
const activeModifierMask = keysDownState.modifier || 0;
const modifierNames = Object.entries(modifiers).filter(([_, mask]) => (activeModifierMask & mask) !== 0).map(([name, _]) => name);
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);
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
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;
@ -110,6 +107,9 @@ function KeyboardWrapper() {
}, []);
useEffect(() => {
// Is the keyboard detached or attached?
if (isAttachedVirtualKeyboardVisible) return;
const handle = keyboardRef.current;
if (handle) {
handle.addEventListener("touchstart", startDrag);
@ -134,15 +134,12 @@ function KeyboardWrapper() {
document.removeEventListener("mousemove", onDrag);
document.removeEventListener("touchmove", onDrag);
};
}, [endDrag, onDrag, startDrag]);
}, [isAttachedVirtualKeyboardVisible, endDrag, onDrag, startDrag]);
const onKeyUp = useCallback(
async (_: string, e: MouseEvent | undefined) => {
e?.preventDefault();
e?.stopPropagation();
},
[]
);
const onKeyUp = useCallback(async (_: string, e: MouseEvent | undefined) => {
e?.preventDefault();
e?.stopPropagation();
}, []);
const onKeyDown = useCallback(
async (key: string, e: MouseEvent | undefined) => {
@ -151,24 +148,30 @@ function KeyboardWrapper() {
// handle the fake key-macros we have defined for common combinations
if (key === "CtrlAltDelete") {
await executeMacro([ { keys: ["Delete"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 } ]);
await executeMacro([
{ keys: ["Delete"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 },
]);
return;
}
if (key === "AltMetaEscape") {
await executeMacro([ { keys: ["Escape"], modifiers: ["AltLeft", "MetaLeft"], delay: 100 } ]);
await executeMacro([
{ keys: ["Escape"], modifiers: ["AltLeft", "MetaLeft"], delay: 100 },
]);
return;
}
if (key === "CtrlAltBackspace") {
await executeMacro([ { keys: ["Backspace"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 } ]);
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)
handleKeyPress(keys[key], true);
setTimeout(() => handleKeyPress(keys[key], false), 100);
return;
}
@ -176,8 +179,10 @@ function KeyboardWrapper() {
// 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)
console.debug(
`Dynamic key pressed: ${key} was currently down: ${currentlyDown}, toggling state`,
);
handleKeyPress(keys[key], !currentlyDown);
return;
}
@ -211,7 +216,7 @@ function KeyboardWrapper() {
<div
className={cx(
!isAttachedVirtualKeyboardVisible
? "fixed left-0 top-0 z-50 select-none"
? "fixed top-0 left-0 z-10 select-none"
: "relative",
)}
ref={keyboardRef}
@ -224,9 +229,10 @@ function KeyboardWrapper() {
<Card
className={cx("overflow-hidden", {
"rounded-none": isAttachedVirtualKeyboardVisible,
"keyboard-detached": !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="flex items-center justify-center border-b border-b-slate-800/30 bg-white px-2 py-4 dark:border-b-slate-300/20 dark:bg-slate-800">
<div className="absolute left-2 flex items-center gap-x-2">
{isAttachedVirtualKeyboardVisible ? (
<Button
@ -240,15 +246,25 @@ function KeyboardWrapper() {
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">
<h2 className="self-center font-sans text-sm leading-none font-medium text-slate-700 select-none dark:text-slate-300">
Virtual Keyboard
</h2>
<div className="absolute right-2">
<div className="absolute right-2 flex items-center gap-x-2">
<div className="hidden md:flex gap-x-2 items-center">
<LinkButton
size="XS"
to="settings/keyboard"
theme="light"
text={selectedKeyboard.name}
LeadingIcon={LuKeyboard}
/>
<div className="h-[20px] w-px bg-slate-800/20 dark:bg-slate-200/20" />
</div>
<Button
size="XS"
theme="light"
@ -317,7 +333,7 @@ function KeyboardWrapper() {
stopMouseUpPropagation={true}
/>
</div>
{ /* TODO add optional number pad */ }
{/* TODO add optional number pad */}
</div>
</div>
</Card>

View File

@ -325,6 +325,20 @@ video::-webkit-media-controls {
@apply mr-[2px]! md:mr-[5px]!;
}
/* Reduce font size for selected keys when keyboard is detached */
.keyboard-detached .simple-keyboard-main.simple-keyboard {
min-width: calc(14 * 7ch);
}
.keyboard-detached .simple-keyboard.hg-theme-default div.hg-button {
text-wrap: auto;
text-align: center;
min-width: 6ch;
}
.keyboard-detached .simple-keyboard.hg-theme-default .hg-button span {
font-size: 50%;
}
/* Hide the scrollbar by setting the scrollbar color to the background color */
.xterm .xterm-viewport {
scrollbar-color: var(--color-gray-900) #002b36;

View File

@ -144,33 +144,33 @@ export const keyDisplayMap: Record<string, string> = {
AltMetaEscape: "Alt + Meta + Escape",
CtrlAltBackspace: "Ctrl + Alt + Backspace",
AltGr: "AltGr",
AltLeft: "Alt",
AltRight: "Alt",
AltLeft: "Alt",
AltRight: "Alt",
ArrowDown: "↓",
ArrowLeft: "←",
ArrowRight: "→",
ArrowUp: "↑",
Backspace: "Backspace",
"(Backspace)": "Backspace",
CapsLock: "Caps Lock",
CapsLock: "Caps Lock",
Clear: "Clear",
ControlLeft: "Ctrl",
ControlRight: "Ctrl",
Delete: "Delete",
ControlLeft: "Ctrl",
ControlRight: "Ctrl",
Delete: "Delete",
End: "End",
Enter: "Enter",
Escape: "Esc",
Home: "Home",
Insert: "Insert",
Menu: "Menu",
MetaLeft: "Meta",
MetaRight: "Meta",
MetaLeft: "Meta",
MetaRight: "Meta",
PageDown: "PgDn",
PageUp: "PgUp",
ShiftLeft: "Shift",
ShiftRight: "Shift",
ShiftLeft: "Shift",
ShiftRight: "Shift",
Space: " ",
Tab: "Tab",
Tab: "Tab",
// Letters
KeyA: "a", KeyB: "b", KeyC: "c", KeyD: "d", KeyE: "e",

View File

@ -81,12 +81,6 @@ export const keys = {
Help: 0x75,
Home: 0x4a,
Insert: 0x49,
International1: 0x87,
International2: 0x88,
International3: 0x89,
International4: 0x8a,
International5: 0x8b,
International6: 0x8c,
International7: 0x8d,
International8: 0x8e,
International9: 0x8f,
@ -117,14 +111,20 @@ export const keys = {
KeyX: 0x1b,
KeyY: 0x1c,
KeyZ: 0x1d,
KeyRO: 0x87,
KatakanaHiragana: 0x88,
Yen: 0x89,
Henkan: 0x8a,
Muhenkan: 0x8b,
KPJPComma: 0x8c,
Hangeul: 0x90,
Hanja: 0x91,
Katakana: 0x92,
Hiragana: 0x93,
ZenkakuHankaku:0x94,
LockingCapsLock: 0x82,
LockingNumLock: 0x83,
LockingScrollLock: 0x84,
Lang1: 0x90, // Hangul/English toggle on Korean keyboards
Lang2: 0x91, // Hanja conversion on Korean keyboards
Lang3: 0x92, // Katakana on Japanese keyboards
Lang4: 0x93, // Hiragana on Japanese keyboards
Lang5: 0x94, // Zenkaku/Hankaku toggle on Japanese keyboards
Lang6: 0x95,
Lang7: 0x96,
Lang8: 0x97,
@ -157,7 +157,7 @@ export const keys = {
NumpadClearEntry: 0xd9,
NumpadColon: 0xcb,
NumpadComma: 0x85,
NumpadDecimal: 0x63,
NumpadDecimal: 0x63, // and Delete
NumpadDecimalBase: 0xdc,
NumpadDelete: 0x63,
NumpadDivide: 0x54,
@ -211,7 +211,7 @@ export const keys = {
PageUp: 0x4b,
Paste: 0x7d,
Pause: 0x48,
Period: 0x37,
Period: 0x37, // aka Dot
Power: 0x66,
PrintScreen: 0x46,
Prior: 0x9d,
@ -226,7 +226,7 @@ export const keys = {
Slash: 0x38,
Space: 0x2c,
Stop: 0x78,
SystemRequest: 0x9a,
SystemRequest: 0x9a, // aka Attention
Tab: 0x2b,
ThousandsSeparator: 0xb2,
Tilde: 0x35,

View File

@ -53,7 +53,7 @@ export default function SettingsKeyboardRoute() {
<div className="space-y-4">
<SettingsItem
title="Paste text"
title="Keyboard Layout"
description="Keyboard layout of target operating system"
>
<SelectMenuBasic
@ -66,7 +66,7 @@ export default function SettingsKeyboardRoute() {
/>
</SettingsItem>
<p className="text-xs text-slate-600 dark:text-slate-400">
Pasting text sends individual key strokes to the target device. The keyboard layout determines which key codes are being sent. Ensure that the keyboard layout in JetKVM matches the settings in the operating system.
The virtual keyboard, paste text, and keyboard macros send individual key strokes to the target device. The keyboard layout determines which key codes are being sent. Ensure that the keyboard layout in JetKVM matches the settings in the operating system.
</p>
</div>