mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 00:22:56 -05:00
feat: support keyboard leader key functionality
This commit is contained in:
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
63
server/service/hid/leader_key.go
Normal file
63
server/service/hid/leader_key.go
Normal 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")
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
61
web/src/pages/desktop/keyboard/recorder.tsx
Normal file
61
web/src/pages/desktop/keyboard/recorder.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
77
web/src/pages/desktop/keyboard/useAltGr.ts
Normal file
77
web/src/pages/desktop/keyboard/useAltGr.ts
Normal 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 };
|
||||
}
|
||||
132
web/src/pages/desktop/keyboard/useLeaderKey.ts
Normal file
132
web/src/pages/desktop/keyboard/useLeaderKey.ts
Normal 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 };
|
||||
}
|
||||
@@ -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 = (
|
||||
<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={
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Paste />
|
||||
<VirtualKeyboard />
|
||||
<Shortcuts />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<MenuItem title={t('keyboard.title')} icon={<KeyboardIcon size={18} />} content={content} />
|
||||
);
|
||||
};
|
||||
|
||||
205
web/src/pages/desktop/menu/keyboard/leader-key.tsx
Normal file
205
web/src/pages/desktop/menu/keyboard/leader-key.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user