mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 00:22:56 -05:00
feat: support custom keyboard shortcuts
This commit is contained in:
@@ -7,3 +7,25 @@ type GetHidModeRsp struct {
|
||||
type SetHidModeReq struct {
|
||||
Mode string `validate:"required"` // normal or hid-only
|
||||
}
|
||||
|
||||
type ShortcutKey struct {
|
||||
Code string `json:"code"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
type Shortcut struct {
|
||||
ID string `json:"id"`
|
||||
Keys []ShortcutKey `json:"keys"`
|
||||
}
|
||||
|
||||
type GetShortcutsRsp struct {
|
||||
Shortcuts []Shortcut `json:"shortcuts"`
|
||||
}
|
||||
|
||||
type AddShortcutReq struct {
|
||||
Keys []ShortcutKey `validate:"required"`
|
||||
}
|
||||
|
||||
type DeleteShortcutReq struct {
|
||||
ID string `validate:"required"`
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ func hidRouter(r *gin.Engine) {
|
||||
|
||||
api.POST("/hid/paste", service.Paste) // paste
|
||||
|
||||
api.GET("/hid/shortcuts", service.GetShortcuts) // get shortcuts
|
||||
api.POST("/hid/shortcut", service.AddShortcut) // add shortcut
|
||||
api.DELETE("/hid/shortcut", service.DeleteShortcut) // delete shortcut
|
||||
|
||||
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
|
||||
|
||||
172
server/service/hid/shortcut.go
Normal file
172
server/service/hid/shortcut.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package hid
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"NanoKVM-Server/proto"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var (
|
||||
shortcutFile = "/etc/kvm/shortcuts.json"
|
||||
shortcutMutex = sync.RWMutex{}
|
||||
)
|
||||
|
||||
type ShortcutStore struct {
|
||||
Shortcuts []proto.Shortcut `json:"shortcuts"`
|
||||
}
|
||||
|
||||
func (s *Service) GetShortcuts(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
shortcuts, err := listShortcuts()
|
||||
if err != nil {
|
||||
log.Errorf("failed to get shortcuts: %v", err)
|
||||
rsp.ErrRsp(c, -1, "get shortcuts failed")
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, &proto.GetShortcutsRsp{
|
||||
Shortcuts: shortcuts,
|
||||
})
|
||||
log.Debugf("get shortcuts success, total: %d", len(shortcuts))
|
||||
}
|
||||
|
||||
func (s *Service) AddShortcut(c *gin.Context) {
|
||||
var req proto.AddShortcutReq
|
||||
var rsp proto.Response
|
||||
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid arguments")
|
||||
return
|
||||
}
|
||||
|
||||
shortcut, err := addShortcut(req.Keys)
|
||||
if err != nil {
|
||||
log.Errorf("failed to add shortcut: %v", err)
|
||||
rsp.ErrRsp(c, -2, "add shortcut failed")
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debugf("add shortcut %s", shortcut.ID)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteShortcut(c *gin.Context) {
|
||||
var req proto.DeleteShortcutReq
|
||||
var rsp proto.Response
|
||||
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid arguments")
|
||||
return
|
||||
}
|
||||
|
||||
err := deleteShortcut(req.ID)
|
||||
if err != nil {
|
||||
log.Errorf("failed to delete shortcut: %v", err)
|
||||
rsp.ErrRsp(c, -2, "delete shortcut failed")
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debugf("delete shortcut %s", req.ID)
|
||||
}
|
||||
|
||||
func loadShortcuts() (*ShortcutStore, error) {
|
||||
if _, err := os.Stat(shortcutFile); os.IsNotExist(err) {
|
||||
return &ShortcutStore{Shortcuts: []proto.Shortcut{}}, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(shortcutFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return &ShortcutStore{Shortcuts: []proto.Shortcut{}}, nil
|
||||
}
|
||||
|
||||
var store ShortcutStore
|
||||
if err := json.Unmarshal(data, &store); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &store, nil
|
||||
}
|
||||
|
||||
func saveShortcuts(store *ShortcutStore) error {
|
||||
data, err := json.Marshal(store)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(shortcutFile, data, 0644)
|
||||
}
|
||||
|
||||
func listShortcuts() ([]proto.Shortcut, error) {
|
||||
shortcutMutex.RLock()
|
||||
defer shortcutMutex.RUnlock()
|
||||
|
||||
store, err := loadShortcuts()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return store.Shortcuts, nil
|
||||
}
|
||||
|
||||
func addShortcut(keys []proto.ShortcutKey) (*proto.Shortcut, error) {
|
||||
shortcutMutex.Lock()
|
||||
defer shortcutMutex.Unlock()
|
||||
|
||||
store, err := loadShortcuts()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
shortcut := proto.Shortcut{
|
||||
ID: uuid.New().String(),
|
||||
Keys: keys,
|
||||
}
|
||||
|
||||
store.Shortcuts = append(store.Shortcuts, shortcut)
|
||||
|
||||
if err := saveShortcuts(store); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &shortcut, nil
|
||||
}
|
||||
|
||||
func deleteShortcut(id string) error {
|
||||
shortcutMutex.Lock()
|
||||
defer shortcutMutex.Unlock()
|
||||
|
||||
store, err := loadShortcuts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
found := false
|
||||
newShortcuts := make([]proto.Shortcut, 0, len(store.Shortcuts))
|
||||
for _, shortcut := range store.Shortcuts {
|
||||
if shortcut.ID == id {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
newShortcuts = append(newShortcuts, shortcut)
|
||||
}
|
||||
|
||||
if !found {
|
||||
return errors.New("shortcut not found")
|
||||
}
|
||||
|
||||
store.Shortcuts = newShortcuts
|
||||
return saveShortcuts(store)
|
||||
}
|
||||
@@ -22,3 +22,24 @@ export function setHidMode(mode: string) {
|
||||
};
|
||||
return http.post('/api/hid/mode', data);
|
||||
}
|
||||
|
||||
// get shortcuts
|
||||
export function getShortcuts() {
|
||||
return http.get('/api/hid/shortcuts');
|
||||
}
|
||||
|
||||
// add shortcut
|
||||
export function addShortcut(keys: any[]) {
|
||||
const data = {
|
||||
keys
|
||||
};
|
||||
return http.post('/api/hid/shortcut', data);
|
||||
}
|
||||
|
||||
// delete shortcut
|
||||
export function deleteShortcut(id: string) {
|
||||
const data = {
|
||||
id
|
||||
};
|
||||
return http.delete('/api/hid/shortcut', data);
|
||||
}
|
||||
|
||||
28
web/src/components/ui/kbd.tsx
Normal file
28
web/src/components/ui/kbd.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd"
|
||||
className={clsx(
|
||||
'pointer-events-none inline-flex h-5 w-fit min-w-9 select-none items-center justify-center gap-1 rounded border-b border-b-neutral-600 bg-neutral-700/70 px-1 font-sans text-xs font-medium text-neutral-300',
|
||||
"[&_svg:not([class*='size-'])]:size-3",
|
||||
'[[data-slot=tooltip-content]_&]:text-background [[data-slot=tooltip-content]_&]:bg-background/10',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function KbdGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd-group"
|
||||
className={clsx('inline-flex items-center gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Kbd, KbdGroup };
|
||||
@@ -76,14 +76,23 @@ const en = {
|
||||
placeholder: 'Please input',
|
||||
submit: 'Submit',
|
||||
virtual: 'Keyboard',
|
||||
ctrlaltdel: 'Ctrl+Alt+Del',
|
||||
readClipboard: 'Read from Clipboard',
|
||||
clipboardPermissionDenied:
|
||||
'Clipboard permission denied. Please allow clipboard access in your browser.',
|
||||
clipboardReadError: 'Failed to read clipboard',
|
||||
dropdownEnglish: 'English',
|
||||
dropdownGerman: 'German',
|
||||
dropdownRussian: 'Russian'
|
||||
dropdownRussian: 'Russian',
|
||||
shortcut: {
|
||||
title: 'Shortcuts',
|
||||
custom: 'Custom',
|
||||
capture: 'Click here to capture shortcut',
|
||||
clear: 'Clear',
|
||||
save: 'Save',
|
||||
captureTips:
|
||||
'Capturing system-level keys (such as the Windows key) requires full-screen permission.',
|
||||
enterFullScreen: 'Toggle full-screen mode.'
|
||||
}
|
||||
},
|
||||
mouse: {
|
||||
title: 'Mouse',
|
||||
|
||||
@@ -73,7 +73,15 @@ const zh = {
|
||||
placeholder: '请输入内容',
|
||||
submit: '确定',
|
||||
virtual: '虚拟键盘',
|
||||
ctrlaltdel: 'Ctrl+Alt+Del'
|
||||
shortcut: {
|
||||
title: '快捷键',
|
||||
custom: '自定义',
|
||||
capture: '点击此处捕获快捷键',
|
||||
clear: '清空',
|
||||
save: '保存',
|
||||
captureTips: '捕获系统级按键(如 Windows 键)需要全屏权限。',
|
||||
enterFullScreen: '切换全屏模式。'
|
||||
}
|
||||
},
|
||||
mouse: {
|
||||
title: '鼠标',
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import clsx from 'clsx';
|
||||
import { OctagonMinus } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { getKeycode, getModifierBit } from '@/lib/keymap.ts';
|
||||
import { client, MessageEvent } from '@/lib/websocket.ts';
|
||||
|
||||
export const CtrlAltDel = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
function sendCtrlAltDel() {
|
||||
const ctrl = getModifierBit('ControlLeft')!;
|
||||
const alt = getModifierBit('AltLeft')!;
|
||||
const modifier = ctrl | alt;
|
||||
|
||||
const del = getKeycode('Delete')!;
|
||||
|
||||
send(modifier, del);
|
||||
send(0, 0);
|
||||
}
|
||||
|
||||
function send(modifier: number, code: number) {
|
||||
const data = new Uint8Array([MessageEvent.Keyboard, modifier, 0, code, 0, 0, 0, 0, 0]);
|
||||
client.send(data);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex cursor-pointer select-none items-center space-x-2 rounded py-1 pl-2 pr-5 hover:bg-neutral-700/70'
|
||||
)}
|
||||
onClick={sendCtrlAltDel}
|
||||
>
|
||||
<OctagonMinus size={18} />
|
||||
<span>{t('keyboard.ctrlaltdel')}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,8 +3,8 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { MenuItem } from '@/components/menu-item.tsx';
|
||||
|
||||
import { CtrlAltDel } from './ctrl-alt-del.tsx';
|
||||
import { Paste } from './paste.tsx';
|
||||
import { Shortcuts } from './shortcuts';
|
||||
import { VirtualKeyboard } from './virtual-keyboard.tsx';
|
||||
|
||||
export const Keyboard = () => {
|
||||
@@ -15,11 +15,11 @@ export const Keyboard = () => {
|
||||
title={t('keyboard.title')}
|
||||
icon={<KeyboardIcon size={18} />}
|
||||
content={
|
||||
<>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Paste />
|
||||
<VirtualKeyboard />
|
||||
<CtrlAltDel />
|
||||
</>
|
||||
<Shortcuts />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
140
web/src/pages/desktop/menu/keyboard/shortcuts/index.tsx
Normal file
140
web/src/pages/desktop/menu/keyboard/shortcuts/index.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Divider, Popover } from 'antd';
|
||||
import { CommandIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as api from '@/api/hid.ts';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
import { Recorder } from './recorder.tsx';
|
||||
import { Shortcut } from './shortcut.tsx';
|
||||
import type { Shortcut as ShortcutInterface } from './types.ts';
|
||||
|
||||
export const Shortcuts = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [customShortcuts, setCustomShortcuts] = useState<ShortcutInterface[]>([]);
|
||||
|
||||
const defaultShortcuts: ShortcutInterface[] = [
|
||||
{
|
||||
keys: [
|
||||
{ code: 'MetaLeft', label: 'Win' },
|
||||
{ code: 'Tab', label: 'Tab' }
|
||||
]
|
||||
},
|
||||
{
|
||||
keys: [
|
||||
{ code: 'ControlLeft', label: 'Ctrl' },
|
||||
{ code: 'AltLeft', label: 'Alt' },
|
||||
{ code: 'Delete', label: '⌫' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
getShortcuts();
|
||||
}, [isOpen]);
|
||||
|
||||
async function getShortcuts() {
|
||||
try {
|
||||
const rsp = await api.getShortcuts();
|
||||
if (rsp.code !== 0) {
|
||||
console.log(rsp.msg);
|
||||
return;
|
||||
}
|
||||
|
||||
setCustomShortcuts(rsp.data.shortcuts);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function addShortcut(shortcut: ShortcutInterface) {
|
||||
try {
|
||||
const rsp = await api.addShortcut(shortcut.keys);
|
||||
if (rsp.code !== 0) {
|
||||
console.log(rsp.msg);
|
||||
return;
|
||||
}
|
||||
|
||||
await getShortcuts();
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function delShortcut(shortcut: ShortcutInterface) {
|
||||
try {
|
||||
if (!shortcut.id) return;
|
||||
|
||||
const rsp = await api.deleteShortcut(shortcut.id);
|
||||
if (rsp.code !== 0) {
|
||||
console.log(rsp.msg);
|
||||
return;
|
||||
}
|
||||
|
||||
await getShortcuts();
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenChange(open: boolean) {
|
||||
if (open) {
|
||||
setIsOpen(true);
|
||||
return;
|
||||
}
|
||||
if (isRecording) {
|
||||
return;
|
||||
}
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
const content = (
|
||||
<ScrollArea className="max-w-[400px] [&>[data-radix-scroll-area-viewport]]:max-h-[350px]">
|
||||
{/* custom shortcuts */}
|
||||
{customShortcuts.length > 0 && (
|
||||
<>
|
||||
{customShortcuts.map((shortcut) => (
|
||||
<Shortcut key={shortcut.id} shortcut={shortcut}></Shortcut>
|
||||
))}
|
||||
|
||||
<Divider style={{ margin: '5px 0 5px 0' }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* default shortcuts */}
|
||||
{defaultShortcuts.map((shortcut, index) => (
|
||||
<Shortcut key={index} shortcut={shortcut}></Shortcut>
|
||||
))}
|
||||
|
||||
<Divider style={{ margin: '5px 0 5px 0' }} />
|
||||
|
||||
<Recorder
|
||||
shortcuts={customShortcuts}
|
||||
addShortcut={addShortcut}
|
||||
delShortcut={delShortcut}
|
||||
setIsRecording={setIsRecording}
|
||||
/>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
content={content}
|
||||
trigger="hover"
|
||||
placement="rightTop"
|
||||
align={{ offset: [14, 0] }}
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
arrow={false}
|
||||
>
|
||||
<div className="flex cursor-pointer select-none items-center space-x-2 rounded py-1 pl-2 pr-5 hover:bg-neutral-700/70">
|
||||
<CommandIcon size={18} />
|
||||
<span>{t('keyboard.shortcut.title')}</span>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
262
web/src/pages/desktop/menu/keyboard/shortcuts/recorder.tsx
Normal file
262
web/src/pages/desktop/menu/keyboard/shortcuts/recorder.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button, Divider, Input, InputRef, Modal } from 'antd';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { KeyboardIcon, Trash2Icon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { isModifier } from '@/lib/keymap.ts';
|
||||
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
|
||||
import { Kbd, KbdGroup } from '@/components/ui/kbd.tsx';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area.tsx';
|
||||
|
||||
import { KeyInfo, Shortcut } from './types.ts';
|
||||
|
||||
const MAX_KEYS = 6;
|
||||
|
||||
const SpecialKeyMap: Record<string, string> = {
|
||||
Space: 'Space',
|
||||
Backspace: '⌫',
|
||||
Enter: '↵',
|
||||
Tab: 'Tab',
|
||||
CapsLock: 'Caps',
|
||||
Escape: 'Esc',
|
||||
ArrowUp: '↑',
|
||||
ArrowDown: '↓',
|
||||
ArrowLeft: '←',
|
||||
ArrowRight: '→',
|
||||
Delete: 'Del',
|
||||
Insert: 'Ins',
|
||||
Home: 'Home',
|
||||
End: 'End',
|
||||
PageUp: 'PgUp',
|
||||
PageDown: 'PgDn'
|
||||
};
|
||||
|
||||
const PunctuationMap: Record<string, string> = {
|
||||
Minus: '-',
|
||||
Equal: '=',
|
||||
BracketLeft: '[',
|
||||
BracketRight: ']',
|
||||
Backslash: '\\',
|
||||
Semicolon: ';',
|
||||
Quote: "'",
|
||||
Backquote: '`',
|
||||
Comma: ',',
|
||||
Period: '.',
|
||||
Slash: '/'
|
||||
};
|
||||
|
||||
interface RecorderProps {
|
||||
shortcuts: Shortcut[];
|
||||
addShortcut: (shortcut: Shortcut) => void;
|
||||
delShortcut: (shortcut: Shortcut) => void;
|
||||
setIsRecording: (isRecording: boolean) => void;
|
||||
}
|
||||
|
||||
export const Recorder = ({
|
||||
shortcuts,
|
||||
addShortcut,
|
||||
delShortcut,
|
||||
setIsRecording
|
||||
}: RecorderProps) => {
|
||||
const { t } = useTranslation();
|
||||
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [shortcutLabel, setShortcutLabel] = useState('');
|
||||
|
||||
const inputRef = useRef<InputRef | null>(null);
|
||||
const recordedKeysRef = useRef<KeyInfo[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsKeyboardEnable(!isModalOpen);
|
||||
setIsRecording(isModalOpen);
|
||||
|
||||
if (!isModalOpen) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [isModalOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFocused) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const { key, code } = event;
|
||||
|
||||
const isRecorded = recordedKeysRef.current.some((k) => k.code === code);
|
||||
if (isRecorded) return;
|
||||
|
||||
if (recordedKeysRef.current.length >= MAX_KEYS) return;
|
||||
|
||||
const keyInfo: KeyInfo = {
|
||||
code: code,
|
||||
label: formatKeyDisplay(key, code)
|
||||
};
|
||||
|
||||
setShortcutLabel((prev) => (prev ? `${prev} + ${keyInfo.label}` : keyInfo.label));
|
||||
recordedKeysRef.current.push(keyInfo);
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isFocused]);
|
||||
|
||||
const formatKeyDisplay = (key: string, code: string): string => {
|
||||
if (isModifier(code)) {
|
||||
if (code.startsWith('Control')) {
|
||||
return 'Ctrl';
|
||||
}
|
||||
if (code.startsWith('Shift')) {
|
||||
return 'Shift';
|
||||
}
|
||||
if (code.startsWith('Alt')) {
|
||||
return 'Alt';
|
||||
}
|
||||
if (code.startsWith('Meta')) {
|
||||
return 'Win';
|
||||
}
|
||||
}
|
||||
|
||||
if (code.startsWith('Digit')) {
|
||||
return code.replace('Digit', '');
|
||||
}
|
||||
if (code.startsWith('Key')) {
|
||||
return code.replace('Key', '');
|
||||
}
|
||||
if (code.startsWith('Numpad')) {
|
||||
const numpadKey = code.replace('Numpad', '');
|
||||
return `Num${numpadKey}`;
|
||||
}
|
||||
if (code.startsWith('F') && /^F\d+$/.test(code)) {
|
||||
return code; // F1, F2, etc.
|
||||
}
|
||||
|
||||
if (SpecialKeyMap[code]) {
|
||||
return SpecialKeyMap[code];
|
||||
}
|
||||
if (PunctuationMap[code]) {
|
||||
return PunctuationMap[code];
|
||||
}
|
||||
|
||||
return key.toUpperCase();
|
||||
};
|
||||
|
||||
function saveShortcut() {
|
||||
if (recordedKeysRef.current.length > 0) {
|
||||
addShortcut({ keys: recordedKeysRef.current });
|
||||
}
|
||||
|
||||
clearShortcut();
|
||||
}
|
||||
|
||||
function clearShortcut() {
|
||||
setShortcutLabel('');
|
||||
recordedKeysRef.current = [];
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
clearShortcut();
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
// @ts-expect-error - https://developer.mozilla.org/en-US/docs/Web/API/Keyboard/lock
|
||||
navigator.keyboard?.lock();
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
// @ts-expect-error - https://developer.mozilla.org/en-US/docs/Web/API/Keyboard/unlock
|
||||
navigator.keyboard?.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex h-[30px] cursor-pointer items-center space-x-1 rounded px-3 text-neutral-300 hover:bg-neutral-700/60"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
<span>{t('keyboard.shortcut.custom')}</span>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
width={500}
|
||||
title={t('keyboard.shortcut.title')}
|
||||
keyboard={false}
|
||||
footer={null}
|
||||
open={isModalOpen}
|
||||
onCancel={closeModal}
|
||||
>
|
||||
<div>
|
||||
<span className="text-neutral-500">{t('keyboard.shortcut.captureTips')}</span>
|
||||
<a className="px-1 text-blue-500/80" onClick={toggleFullscreen}>
|
||||
{t('keyboard.shortcut.enterFullScreen')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-0.5 py-6">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder={t('keyboard.shortcut.capture')}
|
||||
prefix={<KeyboardIcon size={16} className="text-neutral-500" />}
|
||||
value={shortcutLabel}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
|
||||
<Button onClick={clearShortcut}>{t('keyboard.shortcut.clear')}</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full justify-center pb-3">
|
||||
<Button type="primary" className="min-w-24" onClick={saveShortcut}>
|
||||
{t('keyboard.shortcut.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{shortcuts.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
|
||||
<ScrollArea className="[&>[data-radix-scroll-area-viewport]]:max-h-[300px]">
|
||||
{shortcuts.map((shortcut) => (
|
||||
<div
|
||||
key={shortcut.id}
|
||||
className="flex items-center justify-between rounded p-2 hover:bg-neutral-700/50"
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
{shortcut.keys.map((key, index) => (
|
||||
<KbdGroup key={index}>
|
||||
<Kbd>{key.label}</Kbd>
|
||||
</KbdGroup>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex size-[20px] cursor-pointer items-center justify-center rounded-sm text-neutral-500 hover:text-red-500"
|
||||
onClick={() => delShortcut(shortcut)}
|
||||
>
|
||||
<Trash2Icon size={16} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
58
web/src/pages/desktop/menu/keyboard/shortcuts/shortcut.tsx
Normal file
58
web/src/pages/desktop/menu/keyboard/shortcuts/shortcut.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { KeyboardReport } from '@/lib/keyboard.ts';
|
||||
import { client, MessageEvent } from '@/lib/websocket.ts';
|
||||
import { Kbd, KbdGroup } from '@/components/ui/kbd.tsx';
|
||||
|
||||
import type { Shortcut as ShortcutInterface } from './types.ts';
|
||||
|
||||
type ShortcutProps = {
|
||||
shortcut: ShortcutInterface;
|
||||
};
|
||||
|
||||
export const Shortcut = ({ shortcut }: ShortcutProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
async function sendShortcut() {
|
||||
const keyboard = new KeyboardReport();
|
||||
|
||||
shortcut.keys.forEach((key) => {
|
||||
const report = keyboard.keyDown(key.code);
|
||||
send(report);
|
||||
});
|
||||
|
||||
const report = keyboard.reset();
|
||||
send(report);
|
||||
}
|
||||
|
||||
function send(report: Uint8Array) {
|
||||
const data = new Uint8Array([MessageEvent.Keyboard, ...report]);
|
||||
client.send(data);
|
||||
}
|
||||
|
||||
async function handleClick(): Promise<void> {
|
||||
if (isLoading) return;
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await sendShortcut();
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-[32px] w-full cursor-pointer items-center space-x-1 rounded px-3 hover:bg-neutral-700/30"
|
||||
onClick={handleClick}
|
||||
>
|
||||
{shortcut.keys.map((key, index) => (
|
||||
<KbdGroup key={index}>
|
||||
<Kbd>{key.label}</Kbd>
|
||||
</KbdGroup>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
9
web/src/pages/desktop/menu/keyboard/shortcuts/types.ts
Normal file
9
web/src/pages/desktop/menu/keyboard/shortcuts/types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface KeyInfo {
|
||||
code: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface Shortcut {
|
||||
id?: string;
|
||||
keys: KeyInfo[];
|
||||
}
|
||||
Reference in New Issue
Block a user