diff --git a/server/proto/hid.go b/server/proto/hid.go index 80286ef..ec167cb 100644 --- a/server/proto/hid.go +++ b/server/proto/hid.go @@ -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"` +} diff --git a/server/router/hid.go b/server/router/hid.go index 1e68645..67de45f 100644 --- a/server/router/hid.go +++ b/server/router/hid.go @@ -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 diff --git a/server/service/hid/leader_key.go b/server/service/hid/leader_key.go new file mode 100644 index 0000000..e414be9 --- /dev/null +++ b/server/service/hid/leader_key.go @@ -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") +} diff --git a/web/src/api/hid.ts b/web/src/api/hid.ts index ad73154..58f1caf 100644 --- a/web/src/api/hid.ts +++ b/web/src/api/hid.ts @@ -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 }); +} diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 02fe4b0..a1e39cd 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -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: { diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts index fddc70f..044bd7c 100644 --- a/web/src/i18n/locales/zh.ts +++ b/web/src/i18n/locales/zh.ts @@ -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: { diff --git a/web/src/jotai/keyboard.ts b/web/src/jotai/keyboard.ts index a67585f..c4955d6 100644 --- a/web/src/jotai/keyboard.ts +++ b/web/src/jotai/keyboard.ts @@ -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(''); diff --git a/web/src/pages/desktop/keyboard/index.tsx b/web/src/pages/desktop/keyboard/index.tsx index 8533f3d..0d679b7 100644 --- a/web/src/pages/desktop/keyboard/index.tsx +++ b/web/src/pages/desktop/keyboard/index.tsx @@ -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()); - const altGrState = useRef(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 ( + <> + + + ); }; diff --git a/web/src/pages/desktop/keyboard/recorder.tsx b/web/src/pages/desktop/keyboard/recorder.tsx new file mode 100644 index 0000000..461a7d8 --- /dev/null +++ b/web/src/pages/desktop/keyboard/recorder.tsx @@ -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 ( +
+ {recordedKeys.length === 0 ? ( + <> +
+
+ + +
+ + {t('keyboard.leaderKey.recorder.rec')} + +
+ +
+ + {t('keyboard.leaderKey.recorder.input')} + +
+ + ) : ( + <> +
+
+ + +
+ + {t('keyboard.leaderKey.recorder.activate')} + +
+ +
+ {recordedKeys.map((key) => ( + + {key} + + ))} +
+ + )} +
+ ); +}; diff --git a/web/src/pages/desktop/keyboard/useAltGr.ts b/web/src/pages/desktop/keyboard/useAltGr.ts new file mode 100644 index 0000000..470379b --- /dev/null +++ b/web/src/pages/desktop/keyboard/useAltGr.ts @@ -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>, + sendKeyEvent: (type: 'keydown' | 'keyup', code: string) => void +): AltGrHandlers { + const state = useRef(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 }; +} diff --git a/web/src/pages/desktop/keyboard/useLeaderKey.ts b/web/src/pages/desktop/keyboard/useLeaderKey.ts new file mode 100644 index 0000000..e48f0c3 --- /dev/null +++ b/web/src/pages/desktop/keyboard/useLeaderKey.ts @@ -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>, + sendKeyEvent: (type: 'keydown' | 'keyup', code: string) => void +): LeaderKeyHandlers { + const [leaderKeyCode, setLeaderKeyCode] = useAtom(leaderKeyAtom); + + const [recordMode, setRecordMode] = useState(false); + const [recordedKeys, setRecordedKeys] = useState([]); + + const leaderState = useRef({ + 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 }; +} diff --git a/web/src/pages/desktop/menu/keyboard/index.tsx b/web/src/pages/desktop/menu/keyboard/index.tsx index df4026f..6232da7 100644 --- a/web/src/pages/desktop/menu/keyboard/index.tsx +++ b/web/src/pages/desktop/menu/keyboard/index.tsx @@ -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(); + const content = ( +
+ + + + + + + +
+ ); + return ( - } - content={ -
- - - -
- } - /> + } content={content} /> ); }; diff --git a/web/src/pages/desktop/menu/keyboard/leader-key.tsx b/web/src/pages/desktop/menu/keyboard/leader-key.tsx new file mode 100644 index 0000000..0aba5bf --- /dev/null +++ b/web/src/pages/desktop/menu/keyboard/leader-key.tsx @@ -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: + }, + { + value: 'ControlRight', + label: t('keyboard.leaderKey.ctrlRight'), + icon: + }, + { + value: 'MetaRight', + label: t('keyboard.leaderKey.metaRight'), + icon: + } + ]; + + 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 ( + <> +
+ + {t('keyboard.leaderKey.title')} +
+ + +
+ {/* title */} +
+ {t('keyboard.leaderKey.title')} + {t('keyboard.leaderKey.desc')} +
+ + {/* Document */} +
+
setIsDocCollapsed((c) => !c)} + > + {t('keyboard.leaderKey.howToUse')} +
+ +
+
+ + {!isDocCollapsed && ( +
    + {['simultaneous', 'sequential'].map((key) => ( +
  • +
    + {t(`keyboard.leaderKey.${key}.title`)} + + {t(`keyboard.leaderKey.${key}.desc1`)} + + + {t(`keyboard.leaderKey.${key}.desc2`)} + +
    +
  • + ))} +
+ )} +
+ + {/* Switch */} +
+ {t('keyboard.leaderKey.enable')} + +
+ + {/* Keyboard recorder */} + {isLeaderKeyEnable && ( +
+ {t('keyboard.leaderKey.tip')} + } + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + /> + +
+ {optionalKeys.map((key) => ( +
setTempLeaderKey((k) => (k === key.value ? '' : key.value))} + > + {key.icon} + {key.label} +
+ ))} +
+
+ )} + +
+ +
+
+
+ + ); +};