mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-10-28 08:20:49 +08:00
feat(hid): 添加 Linux 键盘与 SysRq 快捷键 (#307)
控制台增加独立快捷键菜单,提供常用组合键和 Linux SysRq 指令,虚拟键盘补充 Linux 布局。 通过可靠且有序的 WebSocket 发送组合键,取消或失败时逐一释放按键,防止修饰键残留。 执行期间避免物理键盘与虚拟键盘交错输入,断连、失焦和切换布局时停止执行。 修正焦点移动后的物理按键释放,并将原虚拟键盘组合键迁移到统一菜单。 验证:独立暂存快照前端构建通过;6 项快捷键单测及 5 组浏览器测试通过,覆盖组合键序列、失败清理、取消及键盘焦点。 Refs #307
This commit is contained in:
@@ -48,6 +48,8 @@ import HidConfigPopover from '@/components/HidConfigPopover.vue'
|
|||||||
import AudioConfigPopover from '@/components/AudioConfigPopover.vue'
|
import AudioConfigPopover from '@/components/AudioConfigPopover.vue'
|
||||||
import MsdDialog from '@/components/MsdDialog.vue'
|
import MsdDialog from '@/components/MsdDialog.vue'
|
||||||
import VideoDisplayControls from '@/components/VideoDisplayControls.vue'
|
import VideoDisplayControls from '@/components/VideoDisplayControls.vue'
|
||||||
|
import KeyboardShortcutsPopover from '@/components/KeyboardShortcutsPopover.vue'
|
||||||
|
import type { KeyboardShortcut } from '@/lib/keyboardShortcuts'
|
||||||
import type { ConsoleLayout } from '@/composables/useConsoleLayout'
|
import type { ConsoleLayout } from '@/composables/useConsoleLayout'
|
||||||
|
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
@@ -73,6 +75,9 @@ const props = defineProps<{
|
|||||||
fullscreen?: boolean
|
fullscreen?: boolean
|
||||||
statsOpen?: boolean
|
statsOpen?: boolean
|
||||||
terminalOpen?: boolean
|
terminalOpen?: boolean
|
||||||
|
shortcutsReady?: boolean
|
||||||
|
shortcutInputBusy?: boolean
|
||||||
|
shortcutBusy?: boolean
|
||||||
}>()
|
}>()
|
||||||
const isSidebarLayout = computed(() => props.layout === 'sidebar')
|
const isSidebarLayout = computed(() => props.layout === 'sidebar')
|
||||||
const isFloatingLayout = computed(() => props.layout === 'floating')
|
const isFloatingLayout = computed(() => props.layout === 'floating')
|
||||||
@@ -106,6 +111,8 @@ const emit = defineEmits<{
|
|||||||
(e: 'reset'): void
|
(e: 'reset'): void
|
||||||
(e: 'wol', macAddress: string): void
|
(e: 'wol', macAddress: string): void
|
||||||
(e: 'openTerminal'): void
|
(e: 'openTerminal'): void
|
||||||
|
(e: 'executeShortcut', shortcut: KeyboardShortcut): void
|
||||||
|
(e: 'cancelShortcut'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const pasteOpen = ref(false)
|
const pasteOpen = ref(false)
|
||||||
@@ -114,6 +121,7 @@ const videoPopoverOpen = ref(false)
|
|||||||
const hidPopoverOpen = ref(false)
|
const hidPopoverOpen = ref(false)
|
||||||
const audioPopoverOpen = ref(false)
|
const audioPopoverOpen = ref(false)
|
||||||
const msdDialogOpen = ref(false)
|
const msdDialogOpen = ref(false)
|
||||||
|
const shortcutsOpen = ref(false)
|
||||||
|
|
||||||
const mobileAtxOpen = ref(false)
|
const mobileAtxOpen = ref(false)
|
||||||
const mobilePasteOpen = ref(false)
|
const mobilePasteOpen = ref(false)
|
||||||
@@ -259,6 +267,7 @@ watch([() => props.layout, floatingCollapsed, minimalDisplayControls], () => {
|
|||||||
hidPopoverOpen.value = false
|
hidPopoverOpen.value = false
|
||||||
audioPopoverOpen.value = false
|
audioPopoverOpen.value = false
|
||||||
msdDialogOpen.value = false
|
msdDialogOpen.value = false
|
||||||
|
shortcutsOpen.value = false
|
||||||
mobileAtxOpen.value = false
|
mobileAtxOpen.value = false
|
||||||
mobilePasteOpen.value = false
|
mobilePasteOpen.value = false
|
||||||
void observeLayout()
|
void observeLayout()
|
||||||
@@ -521,6 +530,16 @@ const hasRightOverflow = computed(() => {
|
|||||||
@toggle-virtual-keyboard="emit('toggleVirtualKeyboard')"
|
@toggle-virtual-keyboard="emit('toggleVirtualKeyboard')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<KeyboardShortcutsPopover
|
||||||
|
v-model:open="shortcutsOpen"
|
||||||
|
:ready="props.shortcutsReady === true"
|
||||||
|
:input-busy="props.shortcutInputBusy"
|
||||||
|
:busy="props.shortcutBusy"
|
||||||
|
:side="isSidebarLayout ? 'right' : 'bottom'"
|
||||||
|
@execute="emit('executeShortcut', $event)"
|
||||||
|
@cancel="emit('cancelShortcut')"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Connection Stats - Adaptive -->
|
<!-- Connection Stats - Adaptive -->
|
||||||
<div v-if="isVisible('stats')">
|
<div v-if="isVisible('stats')">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
|
|||||||
107
web/src/components/KeyboardShortcutsPopover.vue
Normal file
107
web/src/components/KeyboardShortcutsPopover.vue
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { Command, CircleHelp, Loader2, X } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
|
import { focusConsolePanel } from '@/composables/useConsoleAppearance'
|
||||||
|
import {
|
||||||
|
keyboardShortcuts, sysrqDiagnostics, sysrqRecovery,
|
||||||
|
type KeyboardShortcut,
|
||||||
|
} from '@/lib/keyboardShortcuts'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
ready: boolean
|
||||||
|
inputBusy?: boolean
|
||||||
|
busy?: boolean
|
||||||
|
side?: 'right' | 'bottom'
|
||||||
|
}>()
|
||||||
|
const open = defineModel<boolean>('open', { default: false })
|
||||||
|
const emit = defineEmits<{
|
||||||
|
execute: [shortcut: KeyboardShortcut]
|
||||||
|
cancel: []
|
||||||
|
}>()
|
||||||
|
const { t } = useI18n()
|
||||||
|
const disabled = computed(() => !props.ready || props.inputBusy || props.busy)
|
||||||
|
const groups = [keyboardShortcuts, sysrqDiagnostics, sysrqRecovery]
|
||||||
|
|
||||||
|
function label(shortcut: KeyboardShortcut) {
|
||||||
|
return shortcut.id.startsWith('sysrq-') ? `Alt+SysRq+${shortcut.label}` : shortcut.label
|
||||||
|
}
|
||||||
|
function description(shortcut: KeyboardShortcut) {
|
||||||
|
return `${t(`shortcuts.actions.${shortcut.description}`)} · ${label(shortcut)}`
|
||||||
|
}
|
||||||
|
function select(shortcut: KeyboardShortcut) {
|
||||||
|
if (disabled.value) return
|
||||||
|
emit('execute', shortcut)
|
||||||
|
}
|
||||||
|
watch(open, value => {
|
||||||
|
if (!value && props.busy) emit('cancel')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Popover v-model:open="open">
|
||||||
|
<PopoverTrigger as-child>
|
||||||
|
<Button
|
||||||
|
data-fixed-action data-shortcut-surface
|
||||||
|
variant="ghost" size="sm"
|
||||||
|
class="size-8 p-0 text-xs sm:w-auto sm:gap-1.5 sm:px-2"
|
||||||
|
:aria-label="t('actionbar.shortcuts')" :title="t('actionbar.shortcuts')"
|
||||||
|
>
|
||||||
|
<Loader2 v-if="busy" class="size-3.5 animate-spin sm:size-4" />
|
||||||
|
<Command v-else class="size-3.5 sm:size-4" />
|
||||||
|
<span class="hidden xl:inline">{{ t('actionbar.shortcuts') }}</span>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
data-shortcut-surface data-keyboard-shortcuts
|
||||||
|
class="console-config-panel shortcuts-panel w-[330px] max-h-[min(80dvh,var(--reka-popover-content-available-height))] overflow-y-auto p-3.5"
|
||||||
|
align="end" :side="side ?? 'bottom'" @open-auto-focus="focusConsolePanel"
|
||||||
|
>
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<h4 class="text-sm font-medium">{{ t('actionbar.shortcuts') }}</h4>
|
||||||
|
<button type="button" class="text-muted-foreground" :aria-label="t('shortcuts.close')" @click="open = false"><X class="size-4" /></button>
|
||||||
|
</div>
|
||||||
|
<template v-for="(group, index) in groups" :key="index">
|
||||||
|
<div v-if="index === 1" class="mt-3 flex items-center justify-between border-t pt-3 text-[13px] text-muted-foreground">
|
||||||
|
<span>Alt + SysRq +</span>
|
||||||
|
<a
|
||||||
|
href="https://docs.kernel.org/admin-guide/sysrq.html" target="_blank" rel="noopener noreferrer"
|
||||||
|
:title="t('shortcuts.sysrqHelp')" :aria-label="t('shortcuts.sysrqHelp')"
|
||||||
|
><CircleHelp class="size-3.5" /></a>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-1.5" :class="index === 0 ? 'grid-cols-2' : index === 1 ? 'mt-2 grid-cols-4' : 'mt-1.5 grid-cols-6'">
|
||||||
|
<button
|
||||||
|
v-for="shortcut in group" :key="shortcut.id" type="button"
|
||||||
|
class="shortcut-key" :class="{ 'shortcut-key--danger': shortcut.destructive }"
|
||||||
|
:data-shortcut="shortcut.id" :disabled="disabled"
|
||||||
|
:title="!ready ? t('shortcuts.unavailable') : inputBusy ? t('shortcuts.releaseKeys') : description(shortcut)"
|
||||||
|
:aria-label="description(shortcut)" @click="select(shortcut)"
|
||||||
|
>{{ shortcut.label }}</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.shortcut-key {
|
||||||
|
height: 35px;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: color-mix(in srgb, var(--muted) 45%, var(--background));
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background-color 120ms;
|
||||||
|
}
|
||||||
|
.shortcut-key:hover:not(:disabled) { background: var(--muted); }
|
||||||
|
.shortcut-key:active:not(:disabled) { background: var(--accent); }
|
||||||
|
.shortcut-key:disabled { cursor: not-allowed; opacity: .45; }
|
||||||
|
.shortcut-key--danger {
|
||||||
|
color: var(--destructive);
|
||||||
|
border-color: color-mix(in srgb, var(--destructive) 20%, var(--border));
|
||||||
|
background: color-mix(in srgb, var(--destructive) 3%, var(--background));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
consumerKeys,
|
consumerKeys,
|
||||||
latchingKeys,
|
latchingKeys,
|
||||||
modifiers,
|
modifiers,
|
||||||
updateModifierMaskForKey,
|
|
||||||
type KeyName,
|
type KeyName,
|
||||||
type ConsumerKeyName,
|
type ConsumerKeyName,
|
||||||
} from '@/lib/keyboardMappings'
|
} from '@/lib/keyboardMappings'
|
||||||
@@ -29,6 +28,7 @@ const props = defineProps<{
|
|||||||
capsLock?: boolean
|
capsLock?: boolean
|
||||||
pressedKeys?: CanonicalKey[]
|
pressedKeys?: CanonicalKey[]
|
||||||
consumerEnabled?: boolean
|
consumerEnabled?: boolean
|
||||||
|
disabled?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -88,7 +88,6 @@ const getBottomRow = () =>
|
|||||||
const keyboardLayout = {
|
const keyboardLayout = {
|
||||||
main: {
|
main: {
|
||||||
default: [
|
default: [
|
||||||
'CtrlAltDelete AltMetaEscape CtrlAltBackspace',
|
|
||||||
'Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12 PrintScreen ScrollLock Pause',
|
'Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12 PrintScreen ScrollLock Pause',
|
||||||
'Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace Insert Home PageUp',
|
'Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace Insert Home PageUp',
|
||||||
'Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash Delete End PageDown',
|
'Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash Delete End PageDown',
|
||||||
@@ -97,7 +96,6 @@ const keyboardLayout = {
|
|||||||
'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight ArrowLeft ArrowDown ArrowRight',
|
'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight ArrowLeft ArrowDown ArrowRight',
|
||||||
],
|
],
|
||||||
shift: [
|
shift: [
|
||||||
'CtrlAltDelete AltMetaEscape CtrlAltBackspace',
|
|
||||||
'Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12 PrintScreen ScrollLock Pause',
|
'Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12 PrintScreen ScrollLock Pause',
|
||||||
'(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) Backspace Insert Home PageUp',
|
'(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) Backspace Insert Home PageUp',
|
||||||
'Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash) Delete End PageDown',
|
'Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash) Delete End PageDown',
|
||||||
@@ -142,10 +140,6 @@ const keyDisplayMap = computed<Record<string, string>>(() => {
|
|||||||
: selectedOs.value === 'mac' ? '⌘ Cmd' : 'Meta'
|
: selectedOs.value === 'mac' ? '⌘ Cmd' : 'Meta'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
CtrlAltDelete: 'Ctrl+Alt+Del',
|
|
||||||
AltMetaEscape: 'Alt+Meta+Esc',
|
|
||||||
CtrlAltBackspace: 'Ctrl+Alt+Bksp',
|
|
||||||
|
|
||||||
ControlLeft: 'Ctrl',
|
ControlLeft: 'Ctrl',
|
||||||
ControlRight: 'Ctrl',
|
ControlRight: 'Ctrl',
|
||||||
ShiftLeft: 'Shift',
|
ShiftLeft: 'Shift',
|
||||||
@@ -258,6 +252,7 @@ const keyDisplayMap = computed<Record<string, string>>(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function onMediaKeyPress(key: string) {
|
async function onMediaKeyPress(key: string) {
|
||||||
|
if (props.disabled) return
|
||||||
if (key in consumerKeys) {
|
if (key in consumerKeys) {
|
||||||
const usage = consumerKeys[key as ConsumerKeyName]
|
const usage = consumerKeys[key as ConsumerKeyName]
|
||||||
try {
|
try {
|
||||||
@@ -293,26 +288,7 @@ function updateKeyboardLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onKeyDown(key: string) {
|
async function onKeyDown(key: string) {
|
||||||
if (key === 'CtrlAltDelete') {
|
if (props.disabled) return
|
||||||
await executeMacro([
|
|
||||||
{ keys: ['Delete'], modifiers: ['ControlLeft', 'AltLeft'] },
|
|
||||||
])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key === 'AltMetaEscape') {
|
|
||||||
await executeMacro([
|
|
||||||
{ keys: ['Escape'], modifiers: ['AltLeft', 'MetaLeft'] },
|
|
||||||
])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key === 'CtrlAltBackspace') {
|
|
||||||
await executeMacro([
|
|
||||||
{ keys: ['Backspace'], modifiers: ['ControlLeft', 'AltLeft'] },
|
|
||||||
])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanKey = key.replace(/[()]/g, '')
|
const cleanKey = key.replace(/[()]/g, '')
|
||||||
|
|
||||||
@@ -377,54 +353,9 @@ async function sendKeyPress(keyCode: CanonicalKey, press: boolean, modifierMask:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MacroStep {
|
|
||||||
keys: string[]
|
|
||||||
modifiers: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeMacro(steps: MacroStep[]) {
|
|
||||||
let macroModifierMask = pressedModifiers.value & 0xff
|
|
||||||
|
|
||||||
for (const step of steps) {
|
|
||||||
for (const mod of step.modifiers) {
|
|
||||||
if (mod in keys) {
|
|
||||||
const modHid = keys[mod as KeyName]
|
|
||||||
macroModifierMask = updateModifierMaskForKey(macroModifierMask, modHid, true)
|
|
||||||
await sendKeyPress(modHid, true, macroModifierMask)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const key of step.keys) {
|
|
||||||
if (key in keys) {
|
|
||||||
await sendKeyPress(keys[key as KeyName], true, macroModifierMask)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 50))
|
|
||||||
|
|
||||||
for (const key of step.keys) {
|
|
||||||
if (key in keys) {
|
|
||||||
await sendKeyPress(keys[key as KeyName], false, macroModifierMask)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const mod of step.modifiers) {
|
|
||||||
if (mod in keys) {
|
|
||||||
const modHid = keys[mod as KeyName]
|
|
||||||
macroModifierMask = updateModifierMaskForKey(macroModifierMask, modHid, false)
|
|
||||||
await sendKeyPress(modHid, false, macroModifierMask)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateKeyboardButtonTheme() {
|
function updateKeyboardButtonTheme() {
|
||||||
const downKeys = keyNamesForDownKeys.value.join(' ')
|
const downKeys = keyNamesForDownKeys.value.join(' ')
|
||||||
const buttonTheme = [
|
const buttonTheme = [
|
||||||
{
|
|
||||||
class: 'combination-key',
|
|
||||||
buttons: 'CtrlAltDelete AltMetaEscape CtrlAltBackspace',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
class: 'down-key',
|
class: 'down-key',
|
||||||
buttons: downKeys,
|
buttons: downKeys,
|
||||||
@@ -460,12 +391,6 @@ function initKeyboards() {
|
|||||||
theme: 'hg-theme-default hg-layout-default vkb-keyboard',
|
theme: 'hg-theme-default hg-layout-default vkb-keyboard',
|
||||||
onKeyPress: onKeyDown,
|
onKeyPress: onKeyDown,
|
||||||
onKeyReleased: onKeyUp,
|
onKeyReleased: onKeyUp,
|
||||||
buttonTheme: [
|
|
||||||
{
|
|
||||||
class: 'combination-key',
|
|
||||||
buttons: 'CtrlAltDelete AltMetaEscape CtrlAltBackspace',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
disableButtonHold: true,
|
disableButtonHold: true,
|
||||||
preventMouseDownDefault: true,
|
preventMouseDownDefault: true,
|
||||||
preventMouseUpDefault: true,
|
preventMouseUpDefault: true,
|
||||||
@@ -559,7 +484,7 @@ watch(() => props.attached, (value) => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const savedOs = localStorage.getItem('vkb-os-layout') as KeyboardOsType | null
|
const savedOs = localStorage.getItem('vkb-os-layout') as KeyboardOsType | null
|
||||||
if (savedOs && ['windows', 'mac', 'android'].includes(savedOs)) {
|
if (savedOs && ['windows', 'linux', 'mac', 'android'].includes(savedOs)) {
|
||||||
selectedOs.value = savedOs
|
selectedOs.value = savedOs
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,7 +542,7 @@ onUnmounted(() => {
|
|||||||
</Button>
|
</Button>
|
||||||
<div class="vkb-os-selector">
|
<div class="vkb-os-selector">
|
||||||
<Button
|
<Button
|
||||||
v-for="os in (['windows', 'mac', 'android'] as KeyboardOsType[])"
|
v-for="os in (['windows', 'linux', 'mac', 'android'] as KeyboardOsType[])"
|
||||||
:key="os"
|
:key="os"
|
||||||
class="vkb-os-btn"
|
class="vkb-os-btn"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -625,7 +550,7 @@ onUnmounted(() => {
|
|||||||
:class="{ 'vkb-os-btn--active': selectedOs === os }"
|
:class="{ 'vkb-os-btn--active': selectedOs === os }"
|
||||||
@click.stop="switchOsLayout(os)"
|
@click.stop="switchOsLayout(os)"
|
||||||
>
|
>
|
||||||
{{ os === 'windows' ? 'Win' : os === 'mac' ? 'Mac' : 'Android' }}
|
{{ os === 'windows' ? 'Win' : os === 'linux' ? 'Linux' : os === 'mac' ? 'Mac' : 'Android' }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -702,15 +627,6 @@ onUnmounted(() => {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Combination/macro keys */
|
|
||||||
.vkb .simple-keyboard .hg-button.combination-key {
|
|
||||||
font-size: 10px;
|
|
||||||
height: 28px;
|
|
||||||
min-width: auto !important;
|
|
||||||
max-width: fit-content !important;
|
|
||||||
flex-grow: 0 !important;
|
|
||||||
padding: 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pressed keys */
|
/* Pressed keys */
|
||||||
.vkb .simple-keyboard .hg-button.down-key {
|
.vkb .simple-keyboard .hg-button.down-key {
|
||||||
@@ -735,15 +651,8 @@ onUnmounted(() => {
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* First row (macros) - left aligned */
|
/* Function row spacing */
|
||||||
.kb-main-container .hg-row:first-child {
|
.kb-main-container .hg-row:first-child {
|
||||||
justify-content: flex-start !important;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Second row (function keys) spacing */
|
|
||||||
.kb-main-container .hg-row:nth-child(2) {
|
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1119,11 +1028,6 @@ html.dark .hg-theme-default .hg-button.down-key,
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vkb .simple-keyboard .hg-button.combination-key {
|
|
||||||
font-size: 9px;
|
|
||||||
height: 24px;
|
|
||||||
padding: 0 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vkb .simple-keyboard .hg-button[data-skbtn="Backspace"],
|
.vkb .simple-keyboard .hg-button[data-skbtn="Backspace"],
|
||||||
.vkb .simple-keyboard .hg-button[data-skbtn="Tab"],
|
.vkb .simple-keyboard .hg-button[data-skbtn="Tab"],
|
||||||
@@ -1182,11 +1086,6 @@ html.dark .hg-theme-default .hg-button.down-key,
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vkb .simple-keyboard .hg-button.combination-key {
|
|
||||||
font-size: 8px;
|
|
||||||
height: 22px;
|
|
||||||
padding: 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vkb .simple-keyboard .hg-button[data-skbtn="Insert"],
|
.vkb .simple-keyboard .hg-button[data-skbtn="Insert"],
|
||||||
.vkb .simple-keyboard .hg-button[data-skbtn="Delete"],
|
.vkb .simple-keyboard .hg-button[data-skbtn="Delete"],
|
||||||
@@ -1234,10 +1133,6 @@ html.dark .hg-theme-default .hg-button.down-key,
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vkb--floating :deep(.simple-keyboard .hg-button.combination-key) {
|
|
||||||
height: 26px;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animation */
|
/* Animation */
|
||||||
.keyboard-fade-enter-active,
|
.keyboard-fade-enter-active,
|
||||||
|
|||||||
@@ -109,6 +109,36 @@ export default {
|
|||||||
selectResolution: 'Select resolution...',
|
selectResolution: 'Select resolution...',
|
||||||
selectFps: 'Select FPS...',
|
selectFps: 'Select FPS...',
|
||||||
},
|
},
|
||||||
|
shortcuts: {
|
||||||
|
close: "Close shortcuts",
|
||||||
|
unavailable: "HID is disconnected",
|
||||||
|
releaseKeys: "Release held keys first",
|
||||||
|
stopped: "Shortcut stopped",
|
||||||
|
failed: "Shortcut interrupted. Check the HID connection.",
|
||||||
|
sysrqHelp: "SysRq is PrintScreen. SysRq must be enabled on the target Linux system. Open kernel documentation",
|
||||||
|
actions: {
|
||||||
|
'ctrl-alt-delete': "Ctrl+Alt+Del",
|
||||||
|
'task-manager': "Task Manager",
|
||||||
|
'switch-window': "Switch windows",
|
||||||
|
'close-window': "Close window",
|
||||||
|
'lock-windows': "Lock Windows",
|
||||||
|
'run': "Run",
|
||||||
|
'tty1': "Switch to virtual terminal 1",
|
||||||
|
'tty2': "Switch to virtual terminal 2",
|
||||||
|
'restart-x': "Restart the X server",
|
||||||
|
'force-quit': "Force Quit Applications",
|
||||||
|
'sysrq-h': "Show help",
|
||||||
|
'sysrq-f': "Invoke the OOM killer",
|
||||||
|
'sysrq-m': "Show memory information",
|
||||||
|
'sysrq-t': "Show tasks",
|
||||||
|
'sysrq-r': "Restore keyboard mode",
|
||||||
|
'sysrq-e': "Terminate all processes except init",
|
||||||
|
'sysrq-i': "Kill all processes except init",
|
||||||
|
'sysrq-s': "Sync disks",
|
||||||
|
'sysrq-u': "Remount filesystems read-only",
|
||||||
|
'sysrq-b': "Reboot immediately without syncing disks",
|
||||||
|
},
|
||||||
|
},
|
||||||
common: {
|
common: {
|
||||||
loading: 'Loading...',
|
loading: 'Loading...',
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
@@ -236,6 +266,7 @@ export default {
|
|||||||
power: 'Power',
|
power: 'Power',
|
||||||
keyboard: 'Virtual Keyboard',
|
keyboard: 'Virtual Keyboard',
|
||||||
keyboardTip: 'Show virtual keyboard',
|
keyboardTip: 'Show virtual keyboard',
|
||||||
|
shortcuts: 'Shortcuts',
|
||||||
webTerminal: 'Web Terminal',
|
webTerminal: 'Web Terminal',
|
||||||
stats: 'Stats',
|
stats: 'Stats',
|
||||||
statsTip: 'View connection statistics',
|
statsTip: 'View connection statistics',
|
||||||
|
|||||||
@@ -109,6 +109,36 @@ export default {
|
|||||||
selectResolution: '选择分辨率...',
|
selectResolution: '选择分辨率...',
|
||||||
selectFps: '选择帧率...',
|
selectFps: '选择帧率...',
|
||||||
},
|
},
|
||||||
|
shortcuts: {
|
||||||
|
close: "关闭快捷键",
|
||||||
|
unavailable: "HID 未连接",
|
||||||
|
releaseKeys: "请先释放已按下的键",
|
||||||
|
stopped: "已停止快捷键发送",
|
||||||
|
failed: "快捷键发送中断,请检查 HID 连接",
|
||||||
|
sysrqHelp: "SysRq 对应 PrintScreen;被控 Linux 需已启用。查看内核文档",
|
||||||
|
actions: {
|
||||||
|
'ctrl-alt-delete': "Ctrl+Alt+Del",
|
||||||
|
'task-manager': "任务管理器",
|
||||||
|
'switch-window': "切换窗口",
|
||||||
|
'close-window': "关闭窗口",
|
||||||
|
'lock-windows': "锁定 Windows",
|
||||||
|
'run': "运行",
|
||||||
|
'tty1': "切换到虚拟终端 1",
|
||||||
|
'tty2': "切换到虚拟终端 2",
|
||||||
|
'restart-x': "重启 X 服务",
|
||||||
|
'force-quit': "强制退出应用",
|
||||||
|
'sysrq-h': "显示帮助",
|
||||||
|
'sysrq-f': "调用 OOM killer",
|
||||||
|
'sysrq-m': "显示内存信息",
|
||||||
|
'sysrq-t': "显示任务信息",
|
||||||
|
'sysrq-r': "恢复键盘模式",
|
||||||
|
'sysrq-e': "终止所有进程(除 init)",
|
||||||
|
'sysrq-i': "强制终止所有进程(除 init)",
|
||||||
|
'sysrq-s': "同步磁盘",
|
||||||
|
'sysrq-u': "重新挂载为只读",
|
||||||
|
'sysrq-b': "立即重启,不先同步磁盘",
|
||||||
|
},
|
||||||
|
},
|
||||||
common: {
|
common: {
|
||||||
loading: '加载中...',
|
loading: '加载中...',
|
||||||
save: '保存',
|
save: '保存',
|
||||||
@@ -236,6 +266,7 @@ export default {
|
|||||||
power: '电源',
|
power: '电源',
|
||||||
keyboard: '虚拟键盘',
|
keyboard: '虚拟键盘',
|
||||||
keyboardTip: '显示虚拟键盘',
|
keyboardTip: '显示虚拟键盘',
|
||||||
|
shortcuts: '快捷键',
|
||||||
webTerminal: '网页终端',
|
webTerminal: '网页终端',
|
||||||
stats: '连接统计',
|
stats: '连接统计',
|
||||||
statsTip: '查看连接状态',
|
statsTip: '查看连接状态',
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
|
||||||
export type KeyboardOsType = 'windows' | 'mac' | 'android'
|
export type KeyboardOsType = 'windows' | 'linux' | 'mac' | 'android'
|
||||||
|
|
||||||
export const osBottomRows: Record<KeyboardOsType, string[]> = {
|
export const osBottomRows: Record<KeyboardOsType, string[]> = {
|
||||||
windows: ['ControlLeft', 'MetaLeft', 'AltLeft', 'Space', 'AltRight', 'MetaRight', 'ContextMenu', 'ControlRight'],
|
windows: ['ControlLeft', 'MetaLeft', 'AltLeft', 'Space', 'AltRight', 'MetaRight', 'ContextMenu', 'ControlRight'],
|
||||||
|
linux: ['ControlLeft', 'MetaLeft', 'AltLeft', 'Space', 'AltRight', 'MetaRight', 'ContextMenu', 'ControlRight'],
|
||||||
mac: ['ControlLeft', 'AltLeft', 'MetaLeft', 'Space', 'MetaRight', 'AltRight', 'ControlRight'],
|
mac: ['ControlLeft', 'AltLeft', 'MetaLeft', 'Space', 'MetaRight', 'AltRight', 'ControlRight'],
|
||||||
android: ['ControlLeft', 'AltLeft', 'Space', 'AltRight', 'ControlRight'],
|
android: ['ControlLeft', 'AltLeft', 'Space', 'AltRight', 'ControlRight'],
|
||||||
}
|
}
|
||||||
|
|||||||
108
web/src/lib/keyboardShortcuts.ts
Normal file
108
web/src/lib/keyboardShortcuts.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { CanonicalKey as Key } from '@/types/generated'
|
||||||
|
import { updateModifierMaskForKey } from '@/lib/keyboardMappings'
|
||||||
|
|
||||||
|
export interface KeyboardShortcut {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
keys: Key[]
|
||||||
|
destructive?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function combination(id: string, label: string, keys: Key[]): KeyboardShortcut {
|
||||||
|
return { id, label, description: id, keys }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const keyboardShortcuts: KeyboardShortcut[] = [
|
||||||
|
combination('ctrl-alt-delete', 'Ctrl+Alt+Del', [Key.ControlLeft, Key.AltLeft, Key.Delete]),
|
||||||
|
combination('task-manager', 'Ctrl+Shift+Esc', [Key.ControlLeft, Key.ShiftLeft, Key.Escape]),
|
||||||
|
combination('switch-window', 'Alt+Tab', [Key.AltLeft, Key.Tab]),
|
||||||
|
combination('close-window', 'Alt+F4', [Key.AltLeft, Key.F4]),
|
||||||
|
combination('lock-windows', 'Win+L', [Key.MetaLeft, Key.KeyL]),
|
||||||
|
combination('run', 'Win+R', [Key.MetaLeft, Key.KeyR]),
|
||||||
|
combination('tty1', 'Ctrl+Alt+F1', [Key.ControlLeft, Key.AltLeft, Key.F1]),
|
||||||
|
combination('tty2', 'Ctrl+Alt+F2', [Key.ControlLeft, Key.AltLeft, Key.F2]),
|
||||||
|
combination('restart-x', 'Ctrl+Alt+Bksp', [Key.ControlLeft, Key.AltLeft, Key.Backspace]),
|
||||||
|
combination('force-quit', 'Cmd+Opt+Esc', [Key.MetaLeft, Key.AltLeft, Key.Escape]),
|
||||||
|
]
|
||||||
|
|
||||||
|
function sysrq(letter: string, key: Key, destructive = false): KeyboardShortcut {
|
||||||
|
return {
|
||||||
|
id: `sysrq-${letter.toLowerCase()}`, label: letter,
|
||||||
|
description: `sysrq-${letter.toLowerCase()}`, destructive,
|
||||||
|
keys: [Key.AltLeft, Key.PrintScreen, key],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sysrqDiagnostics = [
|
||||||
|
sysrq('H', Key.KeyH), sysrq('F', Key.KeyF, true),
|
||||||
|
sysrq('M', Key.KeyM), sysrq('T', Key.KeyT),
|
||||||
|
]
|
||||||
|
export const sysrqRecovery = [
|
||||||
|
sysrq('R', Key.KeyR), sysrq('E', Key.KeyE, true), sysrq('I', Key.KeyI, true),
|
||||||
|
sysrq('S', Key.KeyS), sysrq('U', Key.KeyU), sysrq('B', Key.KeyB, true),
|
||||||
|
]
|
||||||
|
|
||||||
|
export type ShortcutSender = (type: 'down' | 'up', key: Key, modifiers: number) => Promise<void>
|
||||||
|
|
||||||
|
function checkCancelled(signal?: AbortSignal) {
|
||||||
|
if (signal?.aborted) throw signal.reason ?? new DOMException('Cancelled', 'AbortError')
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
|
checkCancelled(signal)
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const abort = () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
reject(signal?.reason ?? new DOMException('Cancelled', 'AbortError'))
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
signal?.removeEventListener('abort', abort)
|
||||||
|
resolve()
|
||||||
|
}, ms)
|
||||||
|
signal?.addEventListener('abort', abort, { once: true })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send on one ordered transport; always attempt to release every attempted key. */
|
||||||
|
export async function executeKeyboardShortcut(
|
||||||
|
shortcut: KeyboardShortcut,
|
||||||
|
send: ShortcutSender,
|
||||||
|
options: {
|
||||||
|
signal?: AbortSignal
|
||||||
|
wait?: typeof delay
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const wait = options.wait ?? delay
|
||||||
|
checkCancelled(options.signal)
|
||||||
|
const held: Key[] = []
|
||||||
|
let mask = 0
|
||||||
|
let failure: unknown
|
||||||
|
try {
|
||||||
|
for (const key of shortcut.keys) {
|
||||||
|
checkCancelled(options.signal)
|
||||||
|
// A failed send may still have reached the device, so track it first.
|
||||||
|
held.push(key)
|
||||||
|
mask = updateModifierMaskForKey(mask, key, true)
|
||||||
|
await send('down', key, mask)
|
||||||
|
await wait(40, options.signal)
|
||||||
|
}
|
||||||
|
await wait(100, options.signal)
|
||||||
|
} catch (error) {
|
||||||
|
failure = error
|
||||||
|
} finally {
|
||||||
|
// Cleanup must not inherit cancellation, and one failed release must not
|
||||||
|
// prevent the remaining modifiers from being released.
|
||||||
|
for (const key of held.reverse()) {
|
||||||
|
mask = updateModifierMaskForKey(mask, key, false)
|
||||||
|
try {
|
||||||
|
await send('up', key, mask)
|
||||||
|
await wait(20)
|
||||||
|
} catch (error) {
|
||||||
|
failure ??= error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failure) throw failure
|
||||||
|
checkCancelled(options.signal)
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import { streamApi, hidApi, atxApi, atxConfigApi, authApi, uacApi } from '@/api'
|
|||||||
import { CanonicalKey, HidBackend } from '@/types/generated'
|
import { CanonicalKey, HidBackend } from '@/types/generated'
|
||||||
import type { HidKeyboardEvent, HidMouseEvent } from '@/types/hid'
|
import type { HidKeyboardEvent, HidMouseEvent } from '@/types/hid'
|
||||||
import { keyboardEventToCanonicalKey, updateModifierMaskForKey } from '@/lib/keyboardMappings'
|
import { keyboardEventToCanonicalKey, updateModifierMaskForKey } from '@/lib/keyboardMappings'
|
||||||
|
import { executeKeyboardShortcut, type KeyboardShortcut } from '@/lib/keyboardShortcuts'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import { cn, generateUUID } from '@/lib/utils'
|
import { cn, generateUUID } from '@/lib/utils'
|
||||||
import { formatFpsValue } from '@/lib/fps'
|
import { formatFpsValue } from '@/lib/fps'
|
||||||
@@ -167,6 +168,8 @@ const keyboardLed = computed(() => ({
|
|||||||
}))
|
}))
|
||||||
const keyboardLedEnabled = computed(() => systemStore.hid?.keyboardLedsEnabled ?? false)
|
const keyboardLedEnabled = computed(() => systemStore.hid?.keyboardLedsEnabled ?? false)
|
||||||
const keyboardLockPending = ref(false)
|
const keyboardLockPending = ref(false)
|
||||||
|
const shortcutBusy = ref(false)
|
||||||
|
let shortcutController: AbortController | null = null
|
||||||
const activeModifierMask = ref(0)
|
const activeModifierMask = ref(0)
|
||||||
const mousePosition = ref({ x: 0, y: 0 })
|
const mousePosition = ref({ x: 0, y: 0 })
|
||||||
const lastMousePosition = ref({ x: 0, y: 0 })
|
const lastMousePosition = ref({ x: 0, y: 0 })
|
||||||
@@ -2076,9 +2079,53 @@ function sendKeyboardEvent(type: 'down' | 'up', key: CanonicalKey, modifier?: nu
|
|||||||
transmitKeyboardEvent(type, key, modifier).catch(err => handleHidError(err, `keyboard ${type}`))
|
transmitKeyboardEvent(type, key, modifier).catch(err => handleHidError(err, `keyboard ${type}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cancelShortcut() {
|
||||||
|
shortcutController?.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeShortcut(shortcut: KeyboardShortcut) {
|
||||||
|
if (shortcutBusy.value || !isConsoleActive.value || hidStatus.value !== 'connected') return
|
||||||
|
if (pressedKeys.value.length || activeModifierMask.value || keyboardLockPending.value) {
|
||||||
|
toast.info(t('shortcuts.releaseKeys'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const controller = new AbortController()
|
||||||
|
shortcutController = controller
|
||||||
|
shortcutBusy.value = true
|
||||||
|
// Unlike the low-latency WebRTC channel (limited retransmissions), WebSocket
|
||||||
|
// delivers the whole chord reliably and in order, independent of video mode.
|
||||||
|
try {
|
||||||
|
await executeKeyboardShortcut(shortcut, async (type, key, modifier) => {
|
||||||
|
if (type === 'down' && hidStatus.value !== 'connected') throw new Error('HID disconnected')
|
||||||
|
const event: HidKeyboardEvent = { type: type === 'down' ? 'keydown' : 'keyup', key, modifier }
|
||||||
|
await hidWs.sendKeyboard(event)
|
||||||
|
}, {
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||||
|
if (isConsoleActive.value) toast.info(t('shortcuts.stopped'))
|
||||||
|
} else {
|
||||||
|
console.error('[Shortcuts] Send failed:', error)
|
||||||
|
toast.error(t('shortcuts.failed'))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
shortcutController = null
|
||||||
|
shortcutBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(hidStatus, status => {
|
||||||
|
if (status !== 'connected') shortcutController?.abort(new Error('HID disconnected'))
|
||||||
|
}, { flush: 'sync' })
|
||||||
|
watch([hidWs.connected, hidWs.networkError, hidWs.hidUnavailable], ([connected, error, unavailable]) => {
|
||||||
|
if (!connected || error || unavailable) shortcutController?.abort(new Error('HID WebSocket disconnected'))
|
||||||
|
}, { flush: 'sync' })
|
||||||
|
watch([videoMode, consoleLayout], cancelShortcut, { flush: 'sync' })
|
||||||
|
|
||||||
async function toggleKeyboardLock(key: CanonicalKey.CapsLock | CanonicalKey.NumLock | CanonicalKey.ScrollLock) {
|
async function toggleKeyboardLock(key: CanonicalKey.CapsLock | CanonicalKey.NumLock | CanonicalKey.ScrollLock) {
|
||||||
if (!keyboardLedEnabled.value || hidStatus.value !== 'connected'
|
if (!keyboardLedEnabled.value || hidStatus.value !== 'connected'
|
||||||
|| keyboardLockPending.value || pressedKeys.value.includes(key)) return
|
|| shortcutBusy.value || keyboardLockPending.value || pressedKeys.value.includes(key)) return
|
||||||
|
|
||||||
keyboardLockPending.value = true
|
keyboardLockPending.value = true
|
||||||
try {
|
try {
|
||||||
@@ -2127,6 +2174,8 @@ function shouldBlockKey(e: KeyboardEvent): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (shortcutBusy.value || document.querySelector('[data-keyboard-shortcuts]')
|
||||||
|
|| (e.target instanceof Element && e.target.closest('[data-shortcut-surface]'))) return
|
||||||
const container = videoContainerRef.value
|
const container = videoContainerRef.value
|
||||||
if (!container) return
|
if (!container) return
|
||||||
|
|
||||||
@@ -2156,22 +2205,18 @@ function handleKeyDown(e: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyUp(e: KeyboardEvent) {
|
function handleKeyUp(e: KeyboardEvent) {
|
||||||
const container = videoContainerRef.value
|
if (shortcutBusy.value) return
|
||||||
if (!container) return
|
const canonicalKey = keyboardEventToCanonicalKey(e.code, e.key)
|
||||||
|
// A local Tab/Enter can move focus before keyup. Only release keys whose
|
||||||
if (!isFullscreen.value && !container.contains(document.activeElement)) return
|
// keydown was actually forwarded; do release them after focus moves away.
|
||||||
|
if (canonicalKey === undefined || !pressedKeys.value.includes(canonicalKey)) return
|
||||||
if (shouldBlockKey(e)) {
|
const localShortcut = document.querySelector('[data-keyboard-shortcuts]')
|
||||||
|
|| (e.target instanceof Element && e.target.closest('[data-shortcut-surface]'))
|
||||||
|
if (!localShortcut && shouldBlockKey(e)) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
const canonicalKey = keyboardEventToCanonicalKey(e.code, e.key)
|
|
||||||
if (canonicalKey === undefined) {
|
|
||||||
console.warn(`[HID] Unmapped key up: code=${e.code}, key=${e.key}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pressedKeys.value = pressedKeys.value.filter(k => k !== canonicalKey)
|
pressedKeys.value = pressedKeys.value.filter(k => k !== canonicalKey)
|
||||||
|
|
||||||
const modifierMask = updateModifierMaskForKey(activeModifierMask.value, canonicalKey, false)
|
const modifierMask = updateModifierMaskForKey(activeModifierMask.value, canonicalKey, false)
|
||||||
@@ -2837,6 +2882,7 @@ function handleFullscreenChange() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleBlur() {
|
function handleBlur() {
|
||||||
|
cancelShortcut()
|
||||||
resetTouchInput()
|
resetTouchInput()
|
||||||
pressedKeys.value = []
|
pressedKeys.value = []
|
||||||
activeModifierMask.value = 0
|
activeModifierMask.value = 0
|
||||||
@@ -3266,6 +3312,11 @@ onUnmounted(() => {
|
|||||||
:fullscreen="isFullscreen"
|
:fullscreen="isFullscreen"
|
||||||
:stats-open="statsSheetOpen"
|
:stats-open="statsSheetOpen"
|
||||||
:terminal-open="showTerminalDialog"
|
:terminal-open="showTerminalDialog"
|
||||||
|
:shortcuts-ready="isConsoleActive && hidStatus === 'connected' && hidWs.connected.value && !hidWs.networkError.value && !hidWs.hidUnavailable.value"
|
||||||
|
:shortcut-input-busy="pressedKeys.length > 0 || activeModifierMask !== 0 || keyboardLockPending"
|
||||||
|
:shortcut-busy="shortcutBusy"
|
||||||
|
@execute-shortcut="executeShortcut"
|
||||||
|
@cancel-shortcut="cancelShortcut"
|
||||||
@toggle-fullscreen="toggleFullscreen"
|
@toggle-fullscreen="toggleFullscreen"
|
||||||
@update:scale-mode="setVideoScaleMode"
|
@update:scale-mode="setVideoScaleMode"
|
||||||
@toggle-stats="openStatsSheet"
|
@toggle-stats="openStatsSheet"
|
||||||
@@ -3509,6 +3560,7 @@ onUnmounted(() => {
|
|||||||
:caps-lock="keyboardLed.capsLock"
|
:caps-lock="keyboardLed.capsLock"
|
||||||
:pressed-keys="pressedKeys"
|
:pressed-keys="pressedKeys"
|
||||||
:consumer-enabled="virtualKeyboardConsumerEnabled"
|
:consumer-enabled="virtualKeyboardConsumerEnabled"
|
||||||
|
:disabled="shortcutBusy"
|
||||||
@key-down="handleVirtualKeyDown"
|
@key-down="handleVirtualKeyDown"
|
||||||
@key-up="handleVirtualKeyUp"
|
@key-up="handleVirtualKeyUp"
|
||||||
/>
|
/>
|
||||||
@@ -3528,7 +3580,7 @@ onUnmounted(() => {
|
|||||||
:num-lock="keyboardLed.numLock"
|
:num-lock="keyboardLed.numLock"
|
||||||
:scroll-lock="keyboardLed.scrollLock"
|
:scroll-lock="keyboardLed.scrollLock"
|
||||||
:keyboard-led-enabled="keyboardLedEnabled"
|
:keyboard-led-enabled="keyboardLedEnabled"
|
||||||
:keyboard-lock-disabled="hidStatus !== 'connected' || keyboardLockPending"
|
:keyboard-lock-disabled="hidStatus !== 'connected' || keyboardLockPending || shortcutBusy"
|
||||||
:mouse-position="mousePosition"
|
:mouse-position="mousePosition"
|
||||||
:debug-mode="false"
|
:debug-mode="false"
|
||||||
@toggle-lock="toggleKeyboardLock"
|
@toggle-lock="toggleKeyboardLock"
|
||||||
|
|||||||
Reference in New Issue
Block a user