mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-01-28 16:41:52 +08:00
feat(hid): 添加 Consumer Control 多媒体按键和多平台键盘布局
- 新增 Consumer Control HID 支持(播放/暂停、音量控制等) - 虚拟键盘支持 Windows/Mac/Android 三种布局切换 - 移除键盘 LED 反馈以节省 USB 端点(从 2 减至 1) - InfoBar 优化:按键名称友好显示,移除未实现的 Num/Scroll 指示器 - 更新 HID 模块文档
This commit is contained in:
@@ -355,6 +355,12 @@ export const hidApi = {
|
||||
reset: () =>
|
||||
request<{ success: boolean }>('/hid/reset', { method: 'POST' }),
|
||||
|
||||
consumer: async (usage: number) => {
|
||||
await ensureHidConnection()
|
||||
await hidWs.sendConsumer({ usage })
|
||||
return { success: true }
|
||||
},
|
||||
|
||||
// WebSocket connection management
|
||||
connectWebSocket: () => hidWs.connect(),
|
||||
disconnectWebSocket: () => hidWs.disconnect(),
|
||||
|
||||
@@ -6,8 +6,6 @@ import { cn } from '@/lib/utils'
|
||||
const props = defineProps<{
|
||||
pressedKeys?: string[]
|
||||
capsLock?: boolean
|
||||
numLock?: boolean
|
||||
scrollLock?: boolean
|
||||
mousePosition?: { x: number; y: number }
|
||||
debugMode?: boolean
|
||||
compact?: boolean
|
||||
@@ -15,34 +13,39 @@ const props = defineProps<{
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Key name mapping for friendly display
|
||||
const keyNameMap: Record<string, string> = {
|
||||
MetaLeft: 'Win', MetaRight: 'Win',
|
||||
ControlLeft: 'Ctrl', ControlRight: 'Ctrl',
|
||||
ShiftLeft: 'Shift', ShiftRight: 'Shift',
|
||||
AltLeft: 'Alt', AltRight: 'Alt',
|
||||
CapsLock: 'Caps', NumLock: 'Num', ScrollLock: 'Scroll',
|
||||
Backspace: 'Back', Delete: 'Del',
|
||||
ArrowUp: '↑', ArrowDown: '↓', ArrowLeft: '←', ArrowRight: '→',
|
||||
Escape: 'Esc', Enter: 'Enter', Tab: 'Tab', Space: 'Space',
|
||||
PageUp: 'PgUp', PageDown: 'PgDn',
|
||||
Insert: 'Ins', Home: 'Home', End: 'End',
|
||||
}
|
||||
|
||||
const keysDisplay = computed(() => {
|
||||
if (!props.pressedKeys || props.pressedKeys.length === 0) return ''
|
||||
return props.pressedKeys.join(', ')
|
||||
return props.pressedKeys
|
||||
.map(key => keyNameMap[key] || key.replace(/^(Key|Digit)/, ''))
|
||||
.join(', ')
|
||||
})
|
||||
|
||||
// Has any LED active
|
||||
const hasActiveLed = computed(() => props.capsLock || props.numLock || props.scrollLock)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full border-t border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
|
||||
<!-- Compact mode for small screens -->
|
||||
<div v-if="compact" class="flex items-center justify-between text-xs px-2 py-0.5">
|
||||
<!-- LED indicators only in compact mode -->
|
||||
<!-- LED indicator only in compact mode -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span
|
||||
v-if="capsLock"
|
||||
class="px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium"
|
||||
>C</span>
|
||||
<span
|
||||
v-if="numLock"
|
||||
class="px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium"
|
||||
>N</span>
|
||||
<span
|
||||
v-if="scrollLock"
|
||||
class="px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium"
|
||||
>S</span>
|
||||
<span v-if="!hasActiveLed" class="text-muted-foreground/40 text-[10px]">-</span>
|
||||
<span v-else class="text-muted-foreground/40 text-[10px]">-</span>
|
||||
</div>
|
||||
<!-- Keys in compact mode -->
|
||||
<div v-if="keysDisplay" class="text-[10px] text-muted-foreground truncate max-w-[150px]">
|
||||
@@ -67,8 +70,8 @@ const hasActiveLed = computed(() => props.capsLock || props.numLock || props.scr
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side: Keyboard LED states -->
|
||||
<div class="flex items-center divide-x divide-slate-200 dark:divide-slate-700 shrink-0">
|
||||
<!-- Right side: Caps Lock LED state -->
|
||||
<div class="flex items-center shrink-0">
|
||||
<div
|
||||
:class="cn(
|
||||
'px-2 py-1 select-none transition-colors',
|
||||
@@ -78,24 +81,6 @@ const hasActiveLed = computed(() => props.capsLock || props.numLock || props.scr
|
||||
<span class="hidden sm:inline">{{ t('infobar.caps') }}</span>
|
||||
<span class="sm:hidden">C</span>
|
||||
</div>
|
||||
<div
|
||||
:class="cn(
|
||||
'px-2 py-1 select-none transition-colors',
|
||||
numLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
|
||||
)"
|
||||
>
|
||||
<span class="hidden sm:inline">{{ t('infobar.num') }}</span>
|
||||
<span class="sm:hidden">N</span>
|
||||
</div>
|
||||
<div
|
||||
:class="cn(
|
||||
'px-2 py-1 select-none transition-colors',
|
||||
scrollLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
|
||||
)"
|
||||
>
|
||||
<span class="hidden sm:inline">{{ t('infobar.scroll') }}</span>
|
||||
<span class="sm:hidden">S</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,10 +6,18 @@ import 'simple-keyboard/build/css/index.css'
|
||||
import { hidApi } from '@/api'
|
||||
import {
|
||||
keys,
|
||||
consumerKeys,
|
||||
latchingKeys,
|
||||
modifiers,
|
||||
type KeyName,
|
||||
type ConsumerKeyName,
|
||||
} from '@/lib/keyboardMappings'
|
||||
import {
|
||||
type KeyboardOsType,
|
||||
osBottomRows,
|
||||
mediaKeys,
|
||||
mediaKeyLabels,
|
||||
} from '@/lib/keyboardLayouts'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
@@ -27,6 +35,7 @@ const { t } = useI18n()
|
||||
|
||||
// State
|
||||
const isAttached = ref(props.attached ?? true)
|
||||
const selectedOs = ref<KeyboardOsType>('windows')
|
||||
|
||||
// Keyboard instances
|
||||
const mainKeyboard = ref<Keyboard | null>(null)
|
||||
@@ -65,6 +74,9 @@ const position = ref({ x: 100, y: 100 })
|
||||
// Unique ID for this keyboard instance
|
||||
const keyboardId = ref(`kb-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
|
||||
|
||||
// Get bottom row based on selected OS
|
||||
const getBottomRow = () => osBottomRows[selectedOs.value].join(' ')
|
||||
|
||||
// Keyboard layouts - matching JetKVM style
|
||||
const keyboardLayout = {
|
||||
main: {
|
||||
@@ -103,91 +115,139 @@ const keyboardLayout = {
|
||||
}
|
||||
|
||||
// Key display mapping with Unicode symbols (JetKVM style)
|
||||
const keyDisplayMap: Record<string, string> = {
|
||||
// Macros - compact format
|
||||
CtrlAltDelete: 'Ctrl+Alt+Del',
|
||||
AltMetaEscape: 'Alt+Meta+Esc',
|
||||
CtrlAltBackspace: 'Ctrl+Alt+Bksp',
|
||||
const keyDisplayMap = computed<Record<string, string>>(() => {
|
||||
// OS-specific Meta key labels
|
||||
const metaLabel = selectedOs.value === 'windows' ? '⊞ Win'
|
||||
: selectedOs.value === 'mac' ? '⌘ Cmd' : 'Meta'
|
||||
|
||||
// Modifiers with symbols
|
||||
ControlLeft: '^Ctrl',
|
||||
ControlRight: 'Ctrl^',
|
||||
ShiftLeft: '⇧Shift',
|
||||
ShiftRight: 'Shift⇧',
|
||||
AltLeft: '⌥Alt',
|
||||
AltGr: 'AltGr',
|
||||
MetaLeft: '⌘Meta',
|
||||
MetaRight: 'Meta⌘',
|
||||
Menu: 'Menu',
|
||||
return {
|
||||
// Macros - compact format
|
||||
CtrlAltDelete: 'Ctrl+Alt+Del',
|
||||
AltMetaEscape: 'Alt+Meta+Esc',
|
||||
CtrlAltBackspace: 'Ctrl+Alt+Bksp',
|
||||
|
||||
// Special keys with symbols
|
||||
Escape: 'Esc',
|
||||
Backspace: '⌫',
|
||||
Tab: '⇥Tab',
|
||||
CapsLock: '⇪Caps',
|
||||
Enter: '↵',
|
||||
Space: ' ',
|
||||
// Modifiers - simplified
|
||||
ControlLeft: 'Ctrl',
|
||||
ControlRight: 'Ctrl',
|
||||
ShiftLeft: 'Shift',
|
||||
ShiftRight: 'Shift',
|
||||
AltLeft: 'Alt',
|
||||
AltRight: 'Alt',
|
||||
AltGr: 'AltGr',
|
||||
MetaLeft: metaLabel,
|
||||
MetaRight: metaLabel,
|
||||
Menu: 'Menu',
|
||||
|
||||
// Navigation with symbols
|
||||
Insert: 'Ins',
|
||||
Delete: '⌫Del',
|
||||
Home: 'Home',
|
||||
End: 'End',
|
||||
PageUp: 'PgUp',
|
||||
PageDown: 'PgDn',
|
||||
// Special keys
|
||||
Escape: 'Esc',
|
||||
Backspace: '⌫',
|
||||
Tab: 'Tab',
|
||||
CapsLock: 'Caps',
|
||||
Enter: 'Enter',
|
||||
Space: ' ',
|
||||
|
||||
// Arrows
|
||||
ArrowUp: '↑',
|
||||
ArrowDown: '↓',
|
||||
ArrowLeft: '←',
|
||||
ArrowRight: '→',
|
||||
// Navigation
|
||||
Insert: 'Ins',
|
||||
Delete: 'Del',
|
||||
Home: 'Home',
|
||||
End: 'End',
|
||||
PageUp: 'PgUp',
|
||||
PageDown: 'PgDn',
|
||||
|
||||
// Control cluster
|
||||
PrintScreen: 'PrtSc',
|
||||
ScrollLock: 'ScrLk',
|
||||
Pause: 'Pause',
|
||||
// Arrows
|
||||
ArrowUp: '↑',
|
||||
ArrowDown: '↓',
|
||||
ArrowLeft: '←',
|
||||
ArrowRight: '→',
|
||||
|
||||
// Function keys
|
||||
F1: 'F1', F2: 'F2', F3: 'F3', F4: 'F4',
|
||||
F5: 'F5', F6: 'F6', F7: 'F7', F8: 'F8',
|
||||
F9: 'F9', F10: 'F10', F11: 'F11', F12: 'F12',
|
||||
// Control cluster
|
||||
PrintScreen: 'PrtSc',
|
||||
ScrollLock: 'ScrLk',
|
||||
Pause: 'Pause',
|
||||
|
||||
// Letters
|
||||
KeyA: 'a', KeyB: 'b', KeyC: 'c', KeyD: 'd', KeyE: 'e',
|
||||
KeyF: 'f', KeyG: 'g', KeyH: 'h', KeyI: 'i', KeyJ: 'j',
|
||||
KeyK: 'k', KeyL: 'l', KeyM: 'm', KeyN: 'n', KeyO: 'o',
|
||||
KeyP: 'p', KeyQ: 'q', KeyR: 'r', KeyS: 's', KeyT: 't',
|
||||
KeyU: 'u', KeyV: 'v', KeyW: 'w', KeyX: 'x', KeyY: 'y',
|
||||
KeyZ: 'z',
|
||||
// Function keys
|
||||
F1: 'F1', F2: 'F2', F3: 'F3', F4: 'F4',
|
||||
F5: 'F5', F6: 'F6', F7: 'F7', F8: 'F8',
|
||||
F9: 'F9', F10: 'F10', F11: 'F11', F12: 'F12',
|
||||
|
||||
// Capital letters
|
||||
'(KeyA)': 'A', '(KeyB)': 'B', '(KeyC)': 'C', '(KeyD)': 'D', '(KeyE)': 'E',
|
||||
'(KeyF)': 'F', '(KeyG)': 'G', '(KeyH)': 'H', '(KeyI)': 'I', '(KeyJ)': 'J',
|
||||
'(KeyK)': 'K', '(KeyL)': 'L', '(KeyM)': 'M', '(KeyN)': 'N', '(KeyO)': 'O',
|
||||
'(KeyP)': 'P', '(KeyQ)': 'Q', '(KeyR)': 'R', '(KeyS)': 'S', '(KeyT)': 'T',
|
||||
'(KeyU)': 'U', '(KeyV)': 'V', '(KeyW)': 'W', '(KeyX)': 'X', '(KeyY)': 'Y',
|
||||
'(KeyZ)': 'Z',
|
||||
// Letters
|
||||
KeyA: 'a', KeyB: 'b', KeyC: 'c', KeyD: 'd', KeyE: 'e',
|
||||
KeyF: 'f', KeyG: 'g', KeyH: 'h', KeyI: 'i', KeyJ: 'j',
|
||||
KeyK: 'k', KeyL: 'l', KeyM: 'm', KeyN: 'n', KeyO: 'o',
|
||||
KeyP: 'p', KeyQ: 'q', KeyR: 'r', KeyS: 's', KeyT: 't',
|
||||
KeyU: 'u', KeyV: 'v', KeyW: 'w', KeyX: 'x', KeyY: 'y',
|
||||
KeyZ: 'z',
|
||||
|
||||
// Numbers
|
||||
Digit1: '1', Digit2: '2', Digit3: '3', Digit4: '4', Digit5: '5',
|
||||
Digit6: '6', Digit7: '7', Digit8: '8', Digit9: '9', Digit0: '0',
|
||||
// Capital letters
|
||||
'(KeyA)': 'A', '(KeyB)': 'B', '(KeyC)': 'C', '(KeyD)': 'D', '(KeyE)': 'E',
|
||||
'(KeyF)': 'F', '(KeyG)': 'G', '(KeyH)': 'H', '(KeyI)': 'I', '(KeyJ)': 'J',
|
||||
'(KeyK)': 'K', '(KeyL)': 'L', '(KeyM)': 'M', '(KeyN)': 'N', '(KeyO)': 'O',
|
||||
'(KeyP)': 'P', '(KeyQ)': 'Q', '(KeyR)': 'R', '(KeyS)': 'S', '(KeyT)': 'T',
|
||||
'(KeyU)': 'U', '(KeyV)': 'V', '(KeyW)': 'W', '(KeyX)': 'X', '(KeyY)': 'Y',
|
||||
'(KeyZ)': 'Z',
|
||||
|
||||
// Shifted Numbers
|
||||
'(Digit1)': '!', '(Digit2)': '@', '(Digit3)': '#', '(Digit4)': '$', '(Digit5)': '%',
|
||||
'(Digit6)': '^', '(Digit7)': '&', '(Digit8)': '*', '(Digit9)': '(', '(Digit0)': ')',
|
||||
// Numbers
|
||||
Digit1: '1', Digit2: '2', Digit3: '3', Digit4: '4', Digit5: '5',
|
||||
Digit6: '6', Digit7: '7', Digit8: '8', Digit9: '9', Digit0: '0',
|
||||
|
||||
// Symbols
|
||||
Minus: '-', '(Minus)': '_',
|
||||
Equal: '=', '(Equal)': '+',
|
||||
BracketLeft: '[', '(BracketLeft)': '{',
|
||||
BracketRight: ']', '(BracketRight)': '}',
|
||||
Backslash: '\\', '(Backslash)': '|',
|
||||
Semicolon: ';', '(Semicolon)': ':',
|
||||
Quote: "'", '(Quote)': '"',
|
||||
Comma: ',', '(Comma)': '<',
|
||||
Period: '.', '(Period)': '>',
|
||||
Slash: '/', '(Slash)': '?',
|
||||
Backquote: '`', '(Backquote)': '~',
|
||||
// Shifted Numbers
|
||||
'(Digit1)': '!', '(Digit2)': '@', '(Digit3)': '#', '(Digit4)': '$', '(Digit5)': '%',
|
||||
'(Digit6)': '^', '(Digit7)': '&', '(Digit8)': '*', '(Digit9)': '(', '(Digit0)': ')',
|
||||
|
||||
// Symbols
|
||||
Minus: '-', '(Minus)': '_',
|
||||
Equal: '=', '(Equal)': '+',
|
||||
BracketLeft: '[', '(BracketLeft)': '{',
|
||||
BracketRight: ']', '(BracketRight)': '}',
|
||||
Backslash: '\\', '(Backslash)': '|',
|
||||
Semicolon: ';', '(Semicolon)': ':',
|
||||
Quote: "'", '(Quote)': '"',
|
||||
Comma: ',', '(Comma)': '<',
|
||||
Period: '.', '(Period)': '>',
|
||||
Slash: '/', '(Slash)': '?',
|
||||
Backquote: '`', '(Backquote)': '~',
|
||||
}
|
||||
})
|
||||
|
||||
// Handle media key press (Consumer Control)
|
||||
async function onMediaKeyPress(key: string) {
|
||||
if (key in consumerKeys) {
|
||||
const usage = consumerKeys[key as ConsumerKeyName]
|
||||
try {
|
||||
await hidApi.consumer(usage)
|
||||
} catch (err) {
|
||||
console.error('[VirtualKeyboard] Media key send failed:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Switch OS layout
|
||||
function switchOsLayout(os: KeyboardOsType) {
|
||||
selectedOs.value = os
|
||||
// Save preference to localStorage
|
||||
localStorage.setItem('vkb-os-layout', os)
|
||||
// Update keyboard layout
|
||||
updateKeyboardLayout()
|
||||
}
|
||||
|
||||
// Update keyboard layout based on selected OS
|
||||
function updateKeyboardLayout() {
|
||||
const bottomRow = getBottomRow()
|
||||
const newLayout = {
|
||||
...keyboardLayout.main,
|
||||
default: [
|
||||
...keyboardLayout.main.default.slice(0, -1),
|
||||
bottomRow,
|
||||
],
|
||||
shift: [
|
||||
...keyboardLayout.main.shift.slice(0, -1),
|
||||
bottomRow,
|
||||
],
|
||||
}
|
||||
mainKeyboard.value?.setOptions({ layout: newLayout, display: keyDisplayMap.value })
|
||||
controlKeyboard.value?.setOptions({ display: keyDisplayMap.value })
|
||||
arrowsKeyboard.value?.setOptions({ display: keyDisplayMap.value })
|
||||
updateKeyboardButtonTheme()
|
||||
}
|
||||
|
||||
// Key press handler
|
||||
@@ -364,7 +424,7 @@ function initKeyboards() {
|
||||
mainKeyboard.value = new Keyboard(mainEl, {
|
||||
layout: keyboardLayout.main,
|
||||
layoutName: layoutName.value,
|
||||
display: keyDisplayMap,
|
||||
display: keyDisplayMap.value,
|
||||
theme: 'hg-theme-default hg-layout-default vkb-keyboard',
|
||||
onKeyPress: onKeyDown,
|
||||
onKeyReleased: onKeyUp,
|
||||
@@ -385,7 +445,7 @@ function initKeyboards() {
|
||||
controlKeyboard.value = new Keyboard(controlEl, {
|
||||
layout: keyboardLayout.control,
|
||||
layoutName: 'default',
|
||||
display: keyDisplayMap,
|
||||
display: keyDisplayMap.value,
|
||||
theme: 'hg-theme-default hg-layout-default vkb-keyboard',
|
||||
onKeyPress: onKeyDown,
|
||||
onKeyReleased: onKeyUp,
|
||||
@@ -400,7 +460,7 @@ function initKeyboards() {
|
||||
arrowsKeyboard.value = new Keyboard(arrowsEl, {
|
||||
layout: keyboardLayout.arrows,
|
||||
layoutName: 'default',
|
||||
display: keyDisplayMap,
|
||||
display: keyDisplayMap.value,
|
||||
theme: 'hg-theme-default hg-layout-default vkb-keyboard',
|
||||
onKeyPress: onKeyDown,
|
||||
onKeyReleased: onKeyUp,
|
||||
@@ -504,6 +564,12 @@ watch(() => props.attached, (value) => {
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// Load saved OS layout preference
|
||||
const savedOs = localStorage.getItem('vkb-os-layout') as KeyboardOsType | null
|
||||
if (savedOs && ['windows', 'mac', 'android'].includes(savedOs)) {
|
||||
selectedOs.value = savedOs
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onDrag)
|
||||
document.addEventListener('touchmove', onDrag)
|
||||
document.addEventListener('mouseup', endDrag)
|
||||
@@ -539,9 +605,22 @@ onUnmounted(() => {
|
||||
@mousedown="startDrag"
|
||||
@touchstart="startDrag"
|
||||
>
|
||||
<button class="vkb-btn" @click="toggleAttached">
|
||||
{{ isAttached ? t('virtualKeyboard.detach') : t('virtualKeyboard.attach') }}
|
||||
</button>
|
||||
<div class="vkb-header-left">
|
||||
<button class="vkb-btn" @click="toggleAttached">
|
||||
{{ isAttached ? t('virtualKeyboard.detach') : t('virtualKeyboard.attach') }}
|
||||
</button>
|
||||
<div class="vkb-os-selector">
|
||||
<button
|
||||
v-for="os in (['windows', 'mac', 'android'] as KeyboardOsType[])"
|
||||
:key="os"
|
||||
class="vkb-os-btn"
|
||||
:class="{ 'vkb-os-btn--active': selectedOs === os }"
|
||||
@click.stop="switchOsLayout(os)"
|
||||
>
|
||||
{{ os === 'windows' ? 'Win' : os === 'mac' ? 'Mac' : 'Android' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="vkb-title">{{ t('virtualKeyboard.title') }}</span>
|
||||
<button class="vkb-btn" @click="close">
|
||||
{{ t('virtualKeyboard.hide') }}
|
||||
@@ -550,10 +629,23 @@ onUnmounted(() => {
|
||||
|
||||
<!-- Keyboard body -->
|
||||
<div class="vkb-body">
|
||||
<div :id="`${keyboardId}-main`" class="kb-main-container"></div>
|
||||
<div class="vkb-side">
|
||||
<div :id="`${keyboardId}-control`" class="kb-control-container"></div>
|
||||
<div :id="`${keyboardId}-arrows`" class="kb-arrows-container"></div>
|
||||
<!-- Media keys row -->
|
||||
<div class="vkb-media-row">
|
||||
<button
|
||||
v-for="key in mediaKeys"
|
||||
:key="key"
|
||||
class="vkb-media-btn"
|
||||
@click="onMediaKeyPress(key)"
|
||||
>
|
||||
{{ mediaKeyLabels[key] || key }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="vkb-keyboards">
|
||||
<div :id="`${keyboardId}-main`" class="kb-main-container"></div>
|
||||
<div class="vkb-side">
|
||||
<div :id="`${keyboardId}-control`" class="kb-control-container"></div>
|
||||
<div :id="`${keyboardId}-arrows`" class="kb-arrows-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -820,6 +912,7 @@ html.dark .hg-theme-default .hg-button.down-key,
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
min-height: 28px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
:global(.dark .vkb-header) {
|
||||
@@ -832,7 +925,16 @@ html.dark .hg-theme-default .hg-button.down-key,
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.vkb-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vkb-title {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
@@ -868,10 +970,57 @@ html.dark .hg-theme-default .hg-button.down-key,
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
/* OS selector */
|
||||
.vkb-os-selector {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
:global(.dark .vkb-os-selector) {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
.vkb-os-btn {
|
||||
padding: 2px 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vkb-os-btn:hover {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.vkb-os-btn--active {
|
||||
background: white;
|
||||
color: #374151;
|
||||
box-shadow: 0 1px 2px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
:global(.dark .vkb-os-btn) {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
:global(.dark .vkb-os-btn:hover) {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
:global(.dark .vkb-os-btn--active) {
|
||||
background: #4b5563;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
/* Keyboard body */
|
||||
.vkb-body {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-direction: column;
|
||||
padding: 8px;
|
||||
gap: 8px;
|
||||
background: #f3f4f6;
|
||||
@@ -885,6 +1034,55 @@ html.dark .hg-theme-default .hg-button.down-key,
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
|
||||
/* Media keys row */
|
||||
.vkb-media-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
justify-content: center;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
:global(.dark .vkb-media-row) {
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.vkb-media-btn {
|
||||
padding: 4px 12px;
|
||||
font-size: 16px;
|
||||
background: white;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.vkb-media-btn:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.vkb-media-btn:active {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
:global(.dark .vkb-media-btn) {
|
||||
background: #374151;
|
||||
border-color: #4b5563;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
:global(.dark .vkb-media-btn:hover) {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
/* Keyboards container */
|
||||
.vkb-keyboards {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kb-main-container {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -903,7 +1101,7 @@ html.dark .hg-theme-default .hg-button.down-key,
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 900px) {
|
||||
.vkb-body {
|
||||
.vkb-keyboards {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,15 +5,17 @@ import { ref, onUnmounted } from 'vue'
|
||||
import {
|
||||
type HidKeyboardEvent,
|
||||
type HidMouseEvent,
|
||||
type HidConsumerEvent,
|
||||
encodeKeyboardEvent,
|
||||
encodeMouseEvent,
|
||||
encodeConsumerEvent,
|
||||
RESP_OK,
|
||||
RESP_ERR_HID_UNAVAILABLE,
|
||||
RESP_ERR_INVALID_MESSAGE,
|
||||
} from '@/types/hid'
|
||||
import { buildWsUrl, WS_RECONNECT_DELAY } from '@/types/websocket'
|
||||
|
||||
export type { HidKeyboardEvent, HidMouseEvent }
|
||||
export type { HidKeyboardEvent, HidMouseEvent, HidConsumerEvent }
|
||||
|
||||
let wsInstance: WebSocket | null = null
|
||||
const connected = ref(false)
|
||||
@@ -213,6 +215,23 @@ function sendMouse(event: HidMouseEvent): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
// Send consumer control event (multimedia keys)
|
||||
function sendConsumer(event: HidConsumerEvent): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!wsInstance || wsInstance.readyState !== WebSocket.OPEN) {
|
||||
reject(new Error('WebSocket not connected'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
wsInstance.send(encodeConsumerEvent(event))
|
||||
resolve()
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useHidWebSocket() {
|
||||
onUnmounted(() => {
|
||||
// Don't disconnect on component unmount - WebSocket is shared
|
||||
@@ -229,6 +248,7 @@ export function useHidWebSocket() {
|
||||
disconnect,
|
||||
sendKeyboard,
|
||||
sendMouse,
|
||||
sendConsumer,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -550,6 +550,10 @@ export default {
|
||||
hide: 'Hide',
|
||||
show: 'Show Virtual Keyboard',
|
||||
layoutSelect: 'Keyboard Layout',
|
||||
osWindows: 'Windows',
|
||||
osMac: 'Mac',
|
||||
osAndroid: 'Android',
|
||||
mediaKeys: 'Media Keys',
|
||||
},
|
||||
config: {
|
||||
applied: 'Configuration applied',
|
||||
|
||||
@@ -550,6 +550,10 @@ export default {
|
||||
hide: '隐藏',
|
||||
show: '显示虚拟键盘',
|
||||
layoutSelect: '键盘布局',
|
||||
osWindows: 'Windows',
|
||||
osMac: 'Mac',
|
||||
osAndroid: 'Android',
|
||||
mediaKeys: '多媒体键',
|
||||
},
|
||||
config: {
|
||||
applied: '配置已应用',
|
||||
|
||||
@@ -17,9 +17,71 @@ export interface KeyboardLayout {
|
||||
}
|
||||
control: string[][]
|
||||
arrows: string[][]
|
||||
media: string[] // Media keys row
|
||||
}
|
||||
}
|
||||
|
||||
// OS-specific keyboard layout type
|
||||
export type KeyboardOsType = 'windows' | 'mac' | 'android'
|
||||
|
||||
// Bottom row layouts for different OS
|
||||
export const osBottomRows: Record<KeyboardOsType, string[]> = {
|
||||
// Windows: Ctrl - Win - Alt - Space - Alt - Win - Menu - Ctrl
|
||||
windows: ['ControlLeft', 'MetaLeft', 'AltLeft', 'Space', 'AltRight', 'MetaRight', 'Menu', 'ControlRight'],
|
||||
// Mac: Ctrl - Option - Cmd - Space - Cmd - Option - Ctrl
|
||||
mac: ['ControlLeft', 'AltLeft', 'MetaLeft', 'Space', 'MetaRight', 'AltRight', 'ControlRight'],
|
||||
// Android: simplified layout
|
||||
android: ['ControlLeft', 'AltLeft', 'Space', 'AltRight', 'ControlRight'],
|
||||
}
|
||||
|
||||
// OS-specific modifier display names
|
||||
export const osModifierLabels: Record<KeyboardOsType, Record<string, string>> = {
|
||||
windows: {
|
||||
ControlLeft: '^Ctrl',
|
||||
ControlRight: 'Ctrl^',
|
||||
MetaLeft: '⊞Win',
|
||||
MetaRight: 'Win⊞',
|
||||
AltLeft: 'Alt',
|
||||
AltRight: 'Alt',
|
||||
AltGr: 'AltGr',
|
||||
Menu: 'Menu',
|
||||
},
|
||||
mac: {
|
||||
ControlLeft: '^Ctrl',
|
||||
ControlRight: 'Ctrl^',
|
||||
MetaLeft: '⌘Cmd',
|
||||
MetaRight: 'Cmd⌘',
|
||||
AltLeft: '⌥Opt',
|
||||
AltRight: 'Opt⌥',
|
||||
AltGr: '⌥Opt',
|
||||
Menu: 'Menu',
|
||||
},
|
||||
android: {
|
||||
ControlLeft: 'Ctrl',
|
||||
ControlRight: 'Ctrl',
|
||||
MetaLeft: 'Meta',
|
||||
MetaRight: 'Meta',
|
||||
AltLeft: 'Alt',
|
||||
AltRight: 'Alt',
|
||||
AltGr: 'Alt',
|
||||
Menu: 'Menu',
|
||||
},
|
||||
}
|
||||
|
||||
// Media keys (Consumer Control)
|
||||
export const mediaKeys = ['PrevTrack', 'PlayPause', 'NextTrack', 'Stop', 'Mute', 'VolumeDown', 'VolumeUp']
|
||||
|
||||
// Media key display names
|
||||
export const mediaKeyLabels: Record<string, string> = {
|
||||
PlayPause: '⏯',
|
||||
Stop: '⏹',
|
||||
NextTrack: '⏭',
|
||||
PrevTrack: '⏮',
|
||||
Mute: '🔇',
|
||||
VolumeUp: '🔊',
|
||||
VolumeDown: '🔉',
|
||||
}
|
||||
|
||||
// English US Layout
|
||||
export const enUSLayout: KeyboardLayout = {
|
||||
id: 'en-US',
|
||||
@@ -153,6 +215,7 @@ export const enUSLayout: KeyboardLayout = {
|
||||
['ArrowUp'],
|
||||
['ArrowLeft', 'ArrowDown', 'ArrowRight'],
|
||||
],
|
||||
media: ['PrevTrack', 'PlayPause', 'NextTrack', 'Stop', 'Mute', 'VolumeDown', 'VolumeUp'],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -136,12 +136,6 @@ export const keys = {
|
||||
F23: 0x72,
|
||||
F24: 0x73,
|
||||
|
||||
// Media/System keys (Consumer Control)
|
||||
// Note: These are Consumer Control keys, may need special handling
|
||||
Mute: 0x7f,
|
||||
VolumeUp: 0x80,
|
||||
VolumeDown: 0x81,
|
||||
|
||||
// Modifiers (these are special - HID codes 0xE0-0xE7)
|
||||
ControlLeft: 0xe0,
|
||||
ShiftLeft: 0xe1,
|
||||
@@ -156,6 +150,20 @@ export const keys = {
|
||||
|
||||
export type KeyName = keyof typeof keys
|
||||
|
||||
// Consumer Control Usage codes (for multimedia keys)
|
||||
// These are sent via a separate Consumer Control HID report
|
||||
export const consumerKeys = {
|
||||
PlayPause: 0x00cd,
|
||||
Stop: 0x00b7,
|
||||
NextTrack: 0x00b5,
|
||||
PrevTrack: 0x00b6,
|
||||
Mute: 0x00e2,
|
||||
VolumeUp: 0x00e9,
|
||||
VolumeDown: 0x00ea,
|
||||
} as const
|
||||
|
||||
export type ConsumerKeyName = keyof typeof consumerKeys
|
||||
|
||||
// Modifier bitmasks for HID report byte 0
|
||||
export const modifiers = {
|
||||
ControlLeft: 0x01,
|
||||
|
||||
@@ -22,9 +22,15 @@ export interface HidMouseEvent {
|
||||
scroll?: number
|
||||
}
|
||||
|
||||
/** Consumer control event for HID input (multimedia keys) */
|
||||
export interface HidConsumerEvent {
|
||||
usage: number // Consumer Control Usage code (e.g., 0x00CD for Play/Pause)
|
||||
}
|
||||
|
||||
// Binary message constants (must match datachannel.rs / ws_hid.rs)
|
||||
export const MSG_KEYBOARD = 0x01
|
||||
export const MSG_MOUSE = 0x02
|
||||
export const MSG_CONSUMER = 0x03
|
||||
|
||||
// Keyboard event types
|
||||
export const KB_EVENT_DOWN = 0x00
|
||||
@@ -107,3 +113,15 @@ export function encodeMouseEvent(event: HidMouseEvent): ArrayBuffer {
|
||||
|
||||
return buffer
|
||||
}
|
||||
|
||||
/** Encode consumer control event to binary format (3 bytes) */
|
||||
export function encodeConsumerEvent(event: HidConsumerEvent): ArrayBuffer {
|
||||
const buffer = new ArrayBuffer(3)
|
||||
const view = new DataView(buffer)
|
||||
|
||||
view.setUint8(0, MSG_CONSUMER)
|
||||
// Usage code as u16 LE
|
||||
view.setUint16(1, event.usage, true)
|
||||
|
||||
return buffer
|
||||
}
|
||||
|
||||
@@ -98,8 +98,6 @@ const mouseMode = ref<'absolute' | 'relative'>('absolute')
|
||||
const pressedKeys = ref<string[]>([])
|
||||
const keyboardLed = ref({
|
||||
capsLock: false,
|
||||
numLock: false,
|
||||
scrollLock: false,
|
||||
})
|
||||
const mousePosition = ref({ x: 0, y: 0 })
|
||||
const lastMousePosition = ref({ x: 0, y: 0 }) // Track last position for relative mode
|
||||
@@ -1284,8 +1282,6 @@ function handleKeyDown(e: KeyboardEvent) {
|
||||
}
|
||||
|
||||
keyboardLed.value.capsLock = e.getModifierState('CapsLock')
|
||||
keyboardLed.value.numLock = e.getModifierState('NumLock')
|
||||
keyboardLed.value.scrollLock = e.getModifierState('ScrollLock')
|
||||
|
||||
const modifiers = {
|
||||
ctrl: e.ctrlKey,
|
||||
@@ -1482,6 +1478,10 @@ function handleVirtualKeyDown(key: string) {
|
||||
if (!pressedKeys.value.includes(key)) {
|
||||
pressedKeys.value = [...pressedKeys.value, key]
|
||||
}
|
||||
// Toggle CapsLock state when virtual keyboard presses CapsLock
|
||||
if (key === 'CapsLock') {
|
||||
keyboardLed.value.capsLock = !keyboardLed.value.capsLock
|
||||
}
|
||||
}
|
||||
|
||||
function handleVirtualKeyUp(key: string) {
|
||||
@@ -1910,8 +1910,6 @@ onUnmounted(() => {
|
||||
<InfoBar
|
||||
:pressed-keys="pressedKeys"
|
||||
:caps-lock="keyboardLed.capsLock"
|
||||
:num-lock="keyboardLed.numLock"
|
||||
:scroll-lock="keyboardLed.scrollLock"
|
||||
:mouse-position="mousePosition"
|
||||
:debug-mode="false"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user