feat: support keyboard leader key functionality

This commit is contained in:
wj-xiao
2026-01-26 17:10:16 +08:00
parent 94051c3037
commit a611e5ef56
13 changed files with 690 additions and 91 deletions

View File

@@ -29,3 +29,11 @@ type AddShortcutReq struct {
type DeleteShortcutReq struct {
ID string `validate:"required"`
}
type SetLeaderKeyReq struct {
Key string `validate:"omitempty"`
}
type GetLeaderKeyRsp struct {
Key string `json:"key"`
}

View File

@@ -17,6 +17,9 @@ func hidRouter(r *gin.Engine) {
api.POST("/hid/shortcut", service.AddShortcut) // add shortcut
api.DELETE("/hid/shortcut", service.DeleteShortcut) // delete shortcut
api.GET("/hid/shortcut/leader-key", service.GetLeaderKey) // set shortcut leader key
api.POST("/hid/shortcut/leader-key", service.SetLeaderKey) // set shortcut leader key
api.GET("/hid/mode", service.GetHidMode) // get hid mode
api.POST("/hid/mode", service.SetHidMode) // set hid mode
api.POST("/hid/reset", service.ResetHid) // reset hid

View File

@@ -0,0 +1,63 @@
package hid
import (
"os"
"strings"
"NanoKVM-Server/proto"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
const (
LeaderKeyFile = "/etc/kvm/leader-key"
)
func (s *Service) SetLeaderKey(c *gin.Context) {
var req proto.SetLeaderKeyReq
var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, -1, "invalid arguments")
return
}
if req.Key == "" {
err := os.Remove(LeaderKeyFile)
if err != nil && !os.IsNotExist(err) {
rsp.ErrRsp(c, -2, "reset failed")
return
}
} else {
err := os.WriteFile(LeaderKeyFile, []byte(req.Key), 0o644)
if err != nil {
rsp.ErrRsp(c, -3, "write failed")
return
}
}
rsp.OkRsp(c)
log.Debugf("set leader key: %s", req.Key)
}
func (s *Service) GetLeaderKey(c *gin.Context) {
var rsp proto.Response
data, err := os.ReadFile(LeaderKeyFile)
if err != nil {
if os.IsNotExist(err) {
rsp.OkRspWithData(c, &proto.GetLeaderKeyRsp{
Key: "",
})
return
}
rsp.ErrRsp(c, -1, "read leader key failed")
return
}
rsp.OkRspWithData(c, &proto.GetLeaderKeyRsp{
Key: strings.Replace(string(data), "\n", "", -1),
})
log.Debugf("get leader key successful")
}

View File

@@ -43,3 +43,13 @@ export function deleteShortcut(id: string) {
};
return http.delete('/api/hid/shortcut', data);
}
// get shortcut leader key
export function getLeaderKey() {
return http.get('/api/hid/shortcut/leader-key');
}
// set shortcut leader key
export function setLeaderKey(key: string) {
return http.post('/api/hid/shortcut/leader-key', { key });
}

View File

@@ -92,6 +92,34 @@ const en = {
captureTips:
'Capturing system-level keys (such as the Windows key) requires full-screen permission.',
enterFullScreen: 'Toggle full-screen mode.'
},
leaderKey: {
title: 'Leader Key',
desc: 'Bypass browser restrictions and send system shortcuts directly to the remote host.',
howToUse: 'How to Use',
simultaneous: {
title: 'Simultaneous Mode',
desc1: 'Press and hold the Leader Key, then press the shortcut.',
desc2: 'Intuitive, but may conflict with system shortcuts.'
},
sequential: {
title: 'Sequential Mode',
desc1:
'Press the Leader Key → press the shortcut in sequence → press the Leader Key again.',
desc2: 'Requires more steps, but completely avoids system conflicts.'
},
enable: 'Enable Leader Key',
tip: 'When assigned as a Leader Key, this key functions exclusively as a shortcut trigger and loses its default behavior.',
placeholder: 'Please press the Leader Key',
shiftRight: 'Right Shift',
ctrlRight: 'Right Ctrl',
metaRight: 'Right Win',
submit: 'Submit',
recorder: {
rec: 'REC',
activate: 'Activate keys',
input: 'Please press the shortcut...'
}
}
},
mouse: {

View File

@@ -81,6 +81,33 @@ const zh = {
save: '保存',
captureTips: '捕获系统级按键(如 Windows 键)需要全屏权限。',
enterFullScreen: '切换全屏模式。'
},
leaderKey: {
title: '引导键',
desc: '绕过浏览器限制,向远程主机发送被系统拦截的快捷键。',
howToUse: '使用方法',
simultaneous: {
title: '组合按键模式',
desc1: '按住引导键不放,同时按下目标快捷键。',
desc2: '操作直观,但少数快捷键可能因系统占用而无法生效。'
},
sequential: {
title: '序列输入模式',
desc1: '点击【引导键】开始 → 依次点击【快捷键】→ 点击【引导键】结束。',
desc2: '步骤较多,但能完美避开系统键位冲突。'
},
enable: '启用引导键',
tip: '设为引导键后,该按键将仅用于触发快捷键,不再作为普通按键使用。',
placeholder: '请按下引导键',
shiftRight: '右 Shift',
ctrlRight: '右 Ctrl',
metaRight: '右 Win',
submit: '确定',
recorder: {
rec: '录制中',
activate: '激活按键',
input: '请按下快捷键...'
}
}
},
mouse: {

View File

@@ -5,3 +5,6 @@ export const isKeyboardEnableAtom = atom(true);
// is the virtual keyboard opened
export const isKeyboardOpenAtom = atom(false);
// leader key code for bypassing browser shortcuts (empty string means disabled)
export const leaderKeyAtom = atom('');

View File

@@ -7,34 +7,38 @@ import { isModifier } from '@/lib/keymap';
import { client, MessageEvent } from '@/lib/websocket.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { Recorder } from './recorder.tsx';
import { useAltGr } from './useAltGr.ts';
import { useLeaderKey } from './useLeaderKey.ts';
import { normalizeKeyCode } from './utils.ts';
interface AltGrState {
active: boolean;
ctrlLeftTimestamp: number;
}
const ALTGR_THRESHOLD_MS = 10;
export const Keyboard = () => {
const os = getOperatingSystem();
const isKeyboardEnabled = useAtomValue(isKeyboardEnableAtom);
const keyboardRef = useRef(new KeyboardReport());
const pressedKeys = useRef(new Set<string>());
const altGrState = useRef<AltGrState | null>(null);
const isComposing = useRef(false);
// Send key event helper
const sendKeyEvent = (type: 'keydown' | 'keyup', code: string) => {
const kb = keyboardRef.current;
const report = type === 'keydown' ? kb.keyDown(code) : kb.keyUp(code);
sendReport(report);
};
// Init leader key handler
const leaderKey = useLeaderKey(pressedKeys, sendKeyEvent);
const leaderKeyRef = useRef(leaderKey);
leaderKeyRef.current = leaderKey;
// Init AltGr key handler
const altGr = useAltGr(os, pressedKeys, sendKeyEvent);
const altGrRef = useRef(altGr);
altGrRef.current = altGr;
useEffect(() => {
const os = getOperatingSystem();
if (os === 'Windows' && !altGrState.current) {
altGrState.current = { active: false, ctrlLeftTimestamp: 0 };
}
if (!isKeyboardEnabled) {
releaseKeys();
return;
}
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('keyup', handleKeyUp);
document.addEventListener('compositionstart', handleCompositionStart);
@@ -42,116 +46,94 @@ export const Keyboard = () => {
window.addEventListener('blur', handleBlur);
document.addEventListener('visibilitychange', handleVisibilityChange);
// Key down event
function handleKeyDown(event: KeyboardEvent) {
if (!isKeyboardEnabled) return;
// Skip during IME composition
if (isComposing.current || event.isComposing) return;
event.preventDefault();
event.stopPropagation();
const code = normalizeKeyCode(event, os);
if (!code || pressedKeys.current.has(code)) {
if (!code || pressedKeys.current.has(code)) return;
// Handle leader key
const leaderHandled = leaderKeyRef.current.handleKeyDown(code);
if (leaderHandled) {
return;
}
// When AltGr is pressed, browsers send ControlLeft followed immediately by AltRight
if (altGrState.current) {
if (code === 'ControlLeft') {
altGrState.current.ctrlLeftTimestamp = event.timeStamp;
} else if (code === 'AltRight') {
const timeDiff = event.timeStamp - altGrState.current.ctrlLeftTimestamp;
if (timeDiff < ALTGR_THRESHOLD_MS && pressedKeys.current.has('ControlLeft')) {
pressedKeys.current.delete('ControlLeft');
handleKeyEvent({ type: 'keyup', code: 'ControlLeft' });
altGrState.current.active = true;
}
}
}
// Handle AltGr key
altGrRef.current.handleKeyDown(code, event.timeStamp);
pressedKeys.current.add(code);
handleKeyEvent({ type: 'keydown', code });
sendKeyEvent('keydown', code);
}
// Key up event
function handleKeyUp(event: KeyboardEvent) {
if (!isKeyboardEnabled) return;
if (isComposing.current || event.isComposing) return;
event.preventDefault();
event.stopPropagation();
const code = normalizeKeyCode(event, os);
if (!code) {
if (!code) return;
// Handle leader key release
const leaderHandled = leaderKeyRef.current.handleKeyUp(code);
if (leaderHandled) {
return;
}
// Handle AltGr state for Windows
if (altGrState.current?.active) {
if (code === 'ControlLeft') return;
if (code === 'AltRight') {
altGrState.current.active = false;
}
// Handle AltGr key release
const altGrHandled = altGrRef.current.handleKeyUp(code);
if (altGrHandled) {
return;
}
// Compatible with macOS's command key combinations
// Release Meta combinations (macOS compatibility)
if (code === 'MetaLeft' || code === 'MetaRight') {
const keysToRelease: string[] = [];
pressedKeys.current.forEach((pressedCode) => {
if (!isModifier(pressedCode)) {
keysToRelease.push(pressedCode);
}
});
for (const key of keysToRelease) {
handleKeyEvent({ type: 'keyup', code: key });
pressedKeys.current.delete(key);
}
releaseMetaCombinations();
}
pressedKeys.current.delete(code);
handleKeyEvent({ type: 'keyup', code });
sendKeyEvent('keyup', code);
}
// Composition start event
function handleCompositionStart() {
isComposing.current = true;
}
// Composition end event
function handleCompositionEnd() {
isComposing.current = false;
}
// Release all keys when window loses focus
function handleBlur() {
releaseKeys();
}
// Release all keys before window closes
function handleVisibilityChange() {
if (document.hidden) {
releaseKeys();
}
}
// Release all keys
function releaseMetaCombinations() {
const keysToRelease = Array.from(pressedKeys.current).filter((code) => !isModifier(code));
for (const key of keysToRelease) {
sendKeyEvent('keyup', key);
pressedKeys.current.delete(key);
}
}
function releaseKeys() {
pressedKeys.current.forEach((code) => {
handleKeyEvent({ type: 'keyup', code });
sendKeyEvent('keyup', code);
});
pressedKeys.current.clear();
// Reset AltGr state
if (altGrState.current) {
altGrState.current.active = false;
altGrState.current.ctrlLeftTimestamp = 0;
}
leaderKeyRef.current.reset();
altGrRef.current.reset();
const report = keyboardRef.current.reset();
sendReport(report);
@@ -169,18 +151,14 @@ export const Keyboard = () => {
};
}, [isKeyboardEnabled]);
// Keyboard handler
function handleKeyEvent(event: { type: 'keydown' | 'keyup'; code: string }) {
const kb = keyboardRef.current;
const report = event.type === 'keydown' ? kb.keyDown(event.code) : kb.keyUp(event.code);
sendReport(report);
}
// Send keyboard report
function sendReport(report: Uint8Array) {
const data = new Uint8Array([MessageEvent.Keyboard, ...report]);
client.send(data);
}
return <></>;
return (
<>
<Recorder recordMode={leaderKey.recordMode} recordedKeys={leaderKey.recordedKeys} />
</>
);
};

View File

@@ -0,0 +1,61 @@
import { useTranslation } from 'react-i18next';
interface RecorderProps {
recordMode: boolean;
recordedKeys: string[];
}
export const Recorder = ({ recordMode, recordedKeys }: RecorderProps) => {
const { t } = useTranslation();
if (!recordMode) {
return <></>;
}
return (
<div className="animate-in fade-in slide-in-from-top-4 pointer-events-none fixed right-5 top-5 z-[9999] flex min-w-[150px] flex-col gap-2 rounded-xl border border-white/10 bg-black/60 p-4 shadow-2xl backdrop-blur-md transition-all duration-200">
{recordedKeys.length === 0 ? (
<>
<div className="flex items-center gap-2">
<div className="relative flex size-2.5">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-amber-400 opacity-75"></span>
<span className="relative inline-flex size-2.5 rounded-full bg-amber-500"></span>
</div>
<span className="text-xs font-semibold tracking-wider text-white/90">
{t('keyboard.leaderKey.recorder.rec')}
</span>
</div>
<div className="flex flex-wrap gap-2 pt-1">
<span className="text-sm italic text-white/50">
{t('keyboard.leaderKey.recorder.input')}
</span>
</div>
</>
) : (
<>
<div className="flex items-center gap-2">
<div className="relative flex size-2.5">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex size-2.5 rounded-full bg-green-500"></span>
</div>
<span className="text-xs text-white/90">
{t('keyboard.leaderKey.recorder.activate')}
</span>
</div>
<div className="flex flex-wrap gap-2 pt-1">
{recordedKeys.map((key) => (
<span
key={key}
className="flex items-center justify-center rounded border-b-2 border-white/20 bg-white/10 px-2 py-0.5 font-mono text-sm font-medium text-white shadow-sm"
>
{key}
</span>
))}
</div>
</>
)}
</div>
);
};

View File

@@ -0,0 +1,77 @@
import React, { useRef } from 'react';
const ALTGR_THRESHOLD_MS = 10;
interface AltGrState {
active: boolean;
ctrlLeftTimestamp: number;
}
export interface AltGrHandlers {
handleKeyDown: (code: string, timestamp: number) => void;
handleKeyUp: (code: string) => boolean;
reset: () => void;
isActive: () => boolean;
}
export function useAltGr(
os: string,
pressedKeys: React.MutableRefObject<Set<string>>,
sendKeyEvent: (type: 'keydown' | 'keyup', code: string) => void
): AltGrHandlers {
const state = useRef<AltGrState | null>(null);
if (os === 'Windows' && !state.current) {
state.current = { active: false, ctrlLeftTimestamp: 0 };
}
const handleKeyDown = (code: string, timestamp: number): void => {
if (!state.current) return;
// When AltGr is pressed, browsers send ControlLeft followed immediately by AltRight
if (code === 'ControlLeft') {
state.current.ctrlLeftTimestamp = timestamp;
return;
}
if (code === 'AltRight') {
const timeDiff = timestamp - state.current.ctrlLeftTimestamp;
if (timeDiff < ALTGR_THRESHOLD_MS && pressedKeys.current.has('ControlLeft')) {
pressedKeys.current.delete('ControlLeft');
sendKeyEvent('keyup', 'ControlLeft');
state.current.active = true;
return;
}
}
};
const handleKeyUp = (code: string): boolean => {
if (!state.current?.active) {
return false;
}
if (code === 'ControlLeft') {
pressedKeys.current.delete('ControlLeft');
return true;
}
if (code === 'AltRight') {
pressedKeys.current.delete('ControlLeft');
state.current.active = false;
return false;
}
return false;
};
const reset = () => {
if (state.current) {
state.current.active = false;
state.current.ctrlLeftTimestamp = 0;
}
};
const isActive = () => state.current?.active ?? false;
return { handleKeyDown, handleKeyUp, reset, isActive };
}

View File

@@ -0,0 +1,132 @@
import React, { useEffect, useRef, useState } from 'react';
import { useAtom } from 'jotai';
import * as api from '@/api/hid.ts';
import { isModifier } from '@/lib/keymap';
import { leaderKeyAtom } from '@/jotai/keyboard.ts';
interface LeaderKeyState {
code: string;
startTime: number;
recordMode: boolean;
recordModifiers: string[];
}
interface LeaderKeyHandlers {
handleKeyDown: (code: string) => boolean;
handleKeyUp: (code: string) => boolean;
reset: () => void;
recordMode: boolean;
recordedKeys: string[];
}
const RECORD_MODE_THRESHOLD_MS = 350;
export function useLeaderKey(
pressedKeys: React.MutableRefObject<Set<string>>,
sendKeyEvent: (type: 'keydown' | 'keyup', code: string) => void
): LeaderKeyHandlers {
const [leaderKeyCode, setLeaderKeyCode] = useAtom(leaderKeyAtom);
const [recordMode, setRecordMode] = useState(false);
const [recordedKeys, setRecordedKeys] = useState<string[]>([]);
const leaderState = useRef<LeaderKeyState>({
code: '',
startTime: 0,
recordMode: false,
recordModifiers: []
});
// Get leader key
useEffect(() => {
api.getLeaderKey().then((rsp: any) => {
if (rsp.code === 0 && rsp.data?.key !== undefined) {
setLeaderKeyCode(rsp.data.key);
}
});
}, [setLeaderKeyCode]);
// Update leader key
useEffect(() => {
leaderState.current.code = leaderKeyCode;
}, [leaderKeyCode]);
const handleKeyDown = (code: string): boolean => {
const leader = leaderState.current;
// Leader key pressed
if (leader.code && code === leader.code) {
if (!leader.recordMode) {
// Enter record mode
leader.startTime = Date.now();
leader.recordMode = true;
setRecordMode(true);
}
pressedKeys.current.add(code);
return true;
}
// Record mode
if (leader.recordMode) {
if (isModifier(code)) {
// Modifier: send keydown immediately and track it
if (!leader.recordModifiers.includes(code)) {
leader.recordModifiers.push(code);
setRecordedKeys([...leaderState.current.recordModifiers]);
sendKeyEvent('keydown', code);
}
} else {
// Non-modifier: send keydown + keyup immediately
sendKeyEvent('keydown', code);
sendKeyEvent('keyup', code);
}
pressedKeys.current.add(code);
return true;
}
return false;
};
const handleKeyUp = (code: string): boolean => {
const leader = leaderState.current;
// Leader key released
if (leader.code && code === leader.code) {
if (leader.recordMode && Date.now() - leader.startTime > RECORD_MODE_THRESHOLD_MS) {
// Exit record mode
reset();
}
pressedKeys.current.delete(code);
return true;
}
// In record mode, handle keyup
if (leader.recordMode && code !== leader.code) {
pressedKeys.current.delete(code);
return true;
}
return false;
};
const reset = () => {
const leader = leaderState.current;
for (const key of leader.recordModifiers) {
sendKeyEvent('keyup', key);
}
leader.startTime = 0;
leader.recordMode = false;
leader.recordModifiers = [];
setRecordMode(false);
setRecordedKeys([]);
};
return { handleKeyDown, handleKeyUp, reset, recordMode, recordedKeys };
}

View File

@@ -1,8 +1,10 @@
import { Divider } from 'antd';
import { KeyboardIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { MenuItem } from '@/components/menu-item.tsx';
import { LeaderKey } from './leader-key.tsx';
import { Paste } from './paste.tsx';
import { Shortcuts } from './shortcuts';
import { VirtualKeyboard } from './virtual-keyboard.tsx';
@@ -10,17 +12,19 @@ import { VirtualKeyboard } from './virtual-keyboard.tsx';
export const Keyboard = () => {
const { t } = useTranslation();
return (
<MenuItem
title={t('keyboard.title')}
icon={<KeyboardIcon size={18} />}
content={
const content = (
<div className="flex flex-col space-y-1">
<Paste />
<VirtualKeyboard />
<Shortcuts />
<Divider style={{ margin: '5px 0', opacity: 0.5 }} />
<LeaderKey />
</div>
}
/>
);
return (
<MenuItem title={t('keyboard.title')} icon={<KeyboardIcon size={18} />} content={content} />
);
};

View File

@@ -0,0 +1,205 @@
import { useEffect, useState } from 'react';
import { Button, Input, Modal, Switch } from 'antd';
import clsx from 'clsx';
import { useAtom, useSetAtom } from 'jotai';
import {
ArrowBigUpIcon,
ChevronRightIcon,
ChevronUpIcon,
CommandIcon,
KeyboardIcon,
KeyIcon
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/hid.ts';
import { isKeyboardEnableAtom, leaderKeyAtom } from '@/jotai/keyboard.ts';
export const LeaderKey = () => {
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const [leaderKey, setLeaderKey] = useAtom(leaderKeyAtom);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isLeaderKeyEnable, setIsLeaderKeyEnable] = useState(false);
const [tempLeaderKey, setTempLeaderKey] = useState('');
const [isFocused, setIsFocused] = useState(false);
const [isDocCollapsed, setIsDocCollapsed] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const optionalKeys = [
{
value: 'ShiftRight',
label: t('keyboard.leaderKey.shiftRight'),
icon: <ArrowBigUpIcon size={16} />
},
{
value: 'ControlRight',
label: t('keyboard.leaderKey.ctrlRight'),
icon: <ChevronUpIcon size={16} />
},
{
value: 'MetaRight',
label: t('keyboard.leaderKey.metaRight'),
icon: <CommandIcon size={16} />
}
];
useEffect(() => {
if (!isFocused) return;
const handleKeyDown = (event: KeyboardEvent) => {
event.preventDefault();
event.stopPropagation();
setTempLeaderKey(event.code);
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [isFocused]);
function openModal() {
setIsKeyboardEnable(false);
setIsLeaderKeyEnable(!!leaderKey);
setTempLeaderKey(leaderKey);
setIsDocCollapsed(true);
setIsModalOpen(true);
}
function closeModal() {
setIsKeyboardEnable(true);
setIsModalOpen(false);
}
function submit() {
if (isLoading) return;
const key = isLeaderKeyEnable ? tempLeaderKey : '';
if (key === leaderKey) {
closeModal();
return;
}
setIsLoading(true);
api
.setLeaderKey(key)
.then((rsp) => {
if (rsp.code === 0) {
setLeaderKey(key);
closeModal();
}
})
.finally(() => {
setIsLoading(false);
});
}
return (
<>
<div
className="flex cursor-pointer select-none items-center space-x-2 rounded py-1 pl-2 pr-5 hover:bg-neutral-700/70"
onClick={openModal}
>
<KeyIcon size={18} />
<span>{t('keyboard.leaderKey.title')}</span>
</div>
<Modal width={510} keyboard={false} footer={null} open={isModalOpen} onCancel={closeModal}>
<div className="my-3 flex flex-col space-y-3">
{/* title */}
<div className="flex flex-col space-y-1">
<span className="text-lg"> {t('keyboard.leaderKey.title')}</span>
<span className="text-xs text-neutral-500">{t('keyboard.leaderKey.desc')}</span>
</div>
{/* Document */}
<div className="rounded-lg bg-neutral-800 py-3">
<div
className="flex cursor-pointer items-center justify-between px-5"
onClick={() => setIsDocCollapsed((c) => !c)}
>
<span>{t('keyboard.leaderKey.howToUse')}</span>
<div
className={clsx(
'transition-300 size-[18px] text-neutral-500 transition',
isDocCollapsed ? 'rotate-0' : 'rotate-90'
)}
>
<ChevronRightIcon size={18} />
</div>
</div>
{!isDocCollapsed && (
<ul className="list-outside space-y-3">
{['simultaneous', 'sequential'].map((key) => (
<li>
<div className="flex flex-col space-y-2 pr-1">
<span className="">{t(`keyboard.leaderKey.${key}.title`)}</span>
<span className="text-sm text-neutral-400">
{t(`keyboard.leaderKey.${key}.desc1`)}
</span>
<span className="text-sm text-neutral-400">
{t(`keyboard.leaderKey.${key}.desc2`)}
</span>
</div>
</li>
))}
</ul>
)}
</div>
{/* Switch */}
<div className="flex items-center justify-between rounded-lg bg-neutral-800 px-5 py-3">
<span className="select-none">{t('keyboard.leaderKey.enable')}</span>
<Switch value={isLeaderKeyEnable} onChange={setIsLeaderKeyEnable} />
</div>
{/* Keyboard recorder */}
{isLeaderKeyEnable && (
<div className="flex flex-col space-y-5 rounded-lg bg-neutral-800 px-5 py-5">
<span className="text-sm text-neutral-400">{t('keyboard.leaderKey.tip')}</span>
<Input
value={tempLeaderKey}
className="flex-1"
placeholder={t('keyboard.leaderKey.placeholder')}
prefix={<KeyboardIcon size={16} className="text-neutral-500" />}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
<div className="flex items-center justify-center space-x-3">
{optionalKeys.map((key) => (
<div
key={key.value}
className={clsx(
'flex w-28 cursor-pointer items-center justify-center space-x-1 rounded py-1 shadow-lg shadow-neutral-800 outline',
key.value === tempLeaderKey
? 'text-neutral-300 outline-blue-500'
: 'text-neutral-500 outline-neutral-700 hover:shadow-xl hover:outline-neutral-600'
)}
onClick={() => setTempLeaderKey((k) => (k === key.value ? '' : key.value))}
>
{key.icon}
<span>{key.label}</span>
</div>
))}
</div>
</div>
)}
<div className="flex w-full justify-center pt-5">
<Button className="w-24" type="primary" loading={isLoading} onClick={submit}>
{t('keyboard.leaderKey.submit')}
</Button>
</div>
</div>
</Modal>
</>
);
};