Files
One-KVM/web/src/components/VirtualKeyboard.vue
mofeng-git 9653e16a68 feat: CLI 改密、自定义 TLS、移动端适配与扩展校验
- 新增 one-kvm user set-password(交互式),改密后吊销该用户全部会话
- /api/config/web 支持 PEM 证书/密钥上传与清除,响应含 has_custom_cert
- 移动端:ActionBar 溢出菜单、ATX/粘贴底部 Sheet、BrandMark 与控制台等响应式优化
- GOSTC:校验服务器地址非空,管理器启动条件与 HTTP 热更新一致
- RustDesk:中继密钥 relay_key 校验为标准 Base64 且解码后恰好 32 字节
- StatusCard、InfoBar:合并精简冗余状态信息
2026-04-12 19:28:17 +08:00

1407 lines
35 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import Keyboard from 'simple-keyboard'
import 'simple-keyboard/build/css/index.css'
import { hidApi } from '@/api'
import { CanonicalKey } from '@/types/generated'
import {
keys,
consumerKeys,
latchingKeys,
modifiers,
updateModifierMaskForKey,
type KeyName,
type ConsumerKeyName,
} from '@/lib/keyboardMappings'
import {
type KeyboardOsType,
osBottomRows,
mediaKeys,
mediaKeyLabels,
} from '@/lib/keyboardLayouts'
const props = defineProps<{
visible: boolean
attached?: boolean
capsLock?: boolean
pressedKeys?: CanonicalKey[]
consumerEnabled?: boolean
}>()
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
(e: 'update:attached', value: boolean): void
(e: 'keyDown', key: CanonicalKey): void
(e: 'keyUp', key: CanonicalKey): void
}>()
const { t } = useI18n()
// State
const isAttached = ref(props.attached ?? true)
const selectedOs = ref<KeyboardOsType>('windows')
// Keyboard instances
const mainKeyboard = ref<Keyboard | null>(null)
const controlKeyboard = ref<Keyboard | null>(null)
const arrowsKeyboard = ref<Keyboard | null>(null)
// Pressed keys tracking
const pressedModifiers = ref<number>(0)
const keysDown = ref<CanonicalKey[]>([])
// Shift state for display
const isShiftActive = computed(() => {
return (pressedModifiers.value & 0x22) !== 0
})
const areLettersUppercase = computed(() => {
return Boolean(props.capsLock) !== isShiftActive.value
})
const layoutName = computed(() => {
return isShiftActive.value ? 'shift' : 'default'
})
// Keys currently pressed (for highlighting)
const keyNamesForDownKeys = computed(() => {
const activeModifierMask = pressedModifiers.value || 0
const modifierNames = Object.entries(modifiers)
.filter(([_, mask]) => (activeModifierMask & mask) !== 0)
.map(([name]) => name)
return Array.from(new Set([
...modifierNames,
...(props.pressedKeys ?? []),
...keysDown.value,
...(props.capsLock ? ['CapsLock'] : []),
]))
})
// Dragging state (for floating mode)
const keyboardRef = ref<HTMLDivElement | null>(null)
const isDragging = ref(false)
const dragOffset = ref({ x: 0, y: 0 })
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: {
default: [
'CtrlAltDelete AltMetaEscape CtrlAltBackspace',
'Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12',
'Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace',
'Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash',
'CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter',
'ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight',
'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight',
],
shift: [
'CtrlAltDelete AltMetaEscape CtrlAltBackspace',
'Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12',
'(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) Backspace',
'Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)',
'CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter',
'ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight',
'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight',
],
},
control: {
default: [
'PrintScreen ScrollLock Pause',
'Insert Home PageUp',
'Delete End PageDown',
],
},
arrows: {
default: [
'ArrowUp',
'ArrowLeft ArrowDown ArrowRight',
],
},
}
const compactMainLayout = {
default: keyboardLayout.main.default.slice(2),
shift: keyboardLayout.main.shift.slice(2),
}
const isCompactLayout = ref(false)
let compactLayoutMedia: MediaQueryList | null = null
let compactLayoutListener: ((event: MediaQueryListEvent) => void) | null = null
function setCompactLayout(active: boolean) {
if (isCompactLayout.value === active) return
isCompactLayout.value = active
updateKeyboardLayout()
}
// Key display mapping with Unicode symbols (JetKVM style)
const keyDisplayMap = computed<Record<string, string>>(() => {
// OS-specific Meta key labels
const metaLabel = selectedOs.value === 'windows' ? '⊞ Win'
: selectedOs.value === 'mac' ? '⌘ Cmd' : 'Meta'
return {
// Macros - compact format
CtrlAltDelete: 'Ctrl+Alt+Del',
AltMetaEscape: 'Alt+Meta+Esc',
CtrlAltBackspace: 'Ctrl+Alt+Bksp',
// Modifiers - simplified
ControlLeft: 'Ctrl',
ControlRight: 'Ctrl',
ShiftLeft: 'Shift',
ShiftRight: 'Shift',
AltLeft: 'Alt',
AltRight: 'AltGr',
MetaLeft: metaLabel,
MetaRight: metaLabel,
ContextMenu: 'Menu',
// Special keys
Escape: 'Esc',
Backspace: '⌫',
Tab: 'Tab',
CapsLock: 'Caps',
Enter: 'Enter',
Space: ' ',
// Navigation
Insert: 'Ins',
Delete: 'Del',
Home: 'Home',
End: 'End',
PageUp: 'PgUp',
PageDown: 'PgDn',
// Arrows
ArrowUp: '↑',
ArrowDown: '↓',
ArrowLeft: '←',
ArrowRight: '→',
// Control cluster
PrintScreen: 'PrtSc',
ScrollLock: 'ScrLk',
Pause: 'Pause',
// 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',
// Letters
KeyA: areLettersUppercase.value ? 'A' : 'a',
KeyB: areLettersUppercase.value ? 'B' : 'b',
KeyC: areLettersUppercase.value ? 'C' : 'c',
KeyD: areLettersUppercase.value ? 'D' : 'd',
KeyE: areLettersUppercase.value ? 'E' : 'e',
KeyF: areLettersUppercase.value ? 'F' : 'f',
KeyG: areLettersUppercase.value ? 'G' : 'g',
KeyH: areLettersUppercase.value ? 'H' : 'h',
KeyI: areLettersUppercase.value ? 'I' : 'i',
KeyJ: areLettersUppercase.value ? 'J' : 'j',
KeyK: areLettersUppercase.value ? 'K' : 'k',
KeyL: areLettersUppercase.value ? 'L' : 'l',
KeyM: areLettersUppercase.value ? 'M' : 'm',
KeyN: areLettersUppercase.value ? 'N' : 'n',
KeyO: areLettersUppercase.value ? 'O' : 'o',
KeyP: areLettersUppercase.value ? 'P' : 'p',
KeyQ: areLettersUppercase.value ? 'Q' : 'q',
KeyR: areLettersUppercase.value ? 'R' : 'r',
KeyS: areLettersUppercase.value ? 'S' : 's',
KeyT: areLettersUppercase.value ? 'T' : 't',
KeyU: areLettersUppercase.value ? 'U' : 'u',
KeyV: areLettersUppercase.value ? 'V' : 'v',
KeyW: areLettersUppercase.value ? 'W' : 'w',
KeyX: areLettersUppercase.value ? 'X' : 'x',
KeyY: areLettersUppercase.value ? 'Y' : 'y',
KeyZ: areLettersUppercase.value ? 'Z' : 'z',
// Letter labels in the shifted layout follow CapsLock xor Shift too
'(KeyA)': areLettersUppercase.value ? 'A' : 'a',
'(KeyB)': areLettersUppercase.value ? 'B' : 'b',
'(KeyC)': areLettersUppercase.value ? 'C' : 'c',
'(KeyD)': areLettersUppercase.value ? 'D' : 'd',
'(KeyE)': areLettersUppercase.value ? 'E' : 'e',
'(KeyF)': areLettersUppercase.value ? 'F' : 'f',
'(KeyG)': areLettersUppercase.value ? 'G' : 'g',
'(KeyH)': areLettersUppercase.value ? 'H' : 'h',
'(KeyI)': areLettersUppercase.value ? 'I' : 'i',
'(KeyJ)': areLettersUppercase.value ? 'J' : 'j',
'(KeyK)': areLettersUppercase.value ? 'K' : 'k',
'(KeyL)': areLettersUppercase.value ? 'L' : 'l',
'(KeyM)': areLettersUppercase.value ? 'M' : 'm',
'(KeyN)': areLettersUppercase.value ? 'N' : 'n',
'(KeyO)': areLettersUppercase.value ? 'O' : 'o',
'(KeyP)': areLettersUppercase.value ? 'P' : 'p',
'(KeyQ)': areLettersUppercase.value ? 'Q' : 'q',
'(KeyR)': areLettersUppercase.value ? 'R' : 'r',
'(KeyS)': areLettersUppercase.value ? 'S' : 's',
'(KeyT)': areLettersUppercase.value ? 'T' : 't',
'(KeyU)': areLettersUppercase.value ? 'U' : 'u',
'(KeyV)': areLettersUppercase.value ? 'V' : 'v',
'(KeyW)': areLettersUppercase.value ? 'W' : 'w',
'(KeyX)': areLettersUppercase.value ? 'X' : 'x',
'(KeyY)': areLettersUppercase.value ? 'Y' : 'y',
'(KeyZ)': areLettersUppercase.value ? 'Z' : 'z',
// Numbers
Digit1: '1', Digit2: '2', Digit3: '3', Digit4: '4', Digit5: '5',
Digit6: '6', Digit7: '7', Digit8: '8', Digit9: '9', Digit0: '0',
// 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 baseLayout = isCompactLayout.value ? compactMainLayout : keyboardLayout.main
const newLayout = {
...baseLayout,
default: [
...baseLayout.default.slice(0, -1),
bottomRow,
],
shift: [
...baseLayout.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
async function onKeyDown(key: string) {
// Handle macro keys
if (key === 'CtrlAltDelete') {
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
}
// Clean key name (remove parentheses for shifted keys)
const cleanKey = key.replace(/[()]/g, '')
// Check if key exists
if (!(cleanKey in keys)) {
console.warn(`[VirtualKeyboard] Unknown key: ${cleanKey}`)
return
}
const keyCode = keys[cleanKey as KeyName]
// Handle latching keys (Caps Lock, etc.)
if (latchingKeys.some(latchingKey => latchingKey === keyCode)) {
emit('keyDown', keyCode)
const currentMask = pressedModifiers.value & 0xff
await sendKeyPress(keyCode, true, currentMask)
setTimeout(() => {
sendKeyPress(keyCode, false, currentMask)
emit('keyUp', keyCode)
}, 100)
return
}
// Handle modifier keys (toggle)
const mask = modifiers[keyCode] ?? 0
if (mask !== 0) {
const isCurrentlyDown = (pressedModifiers.value & mask) !== 0
if (isCurrentlyDown) {
const nextMask = pressedModifiers.value & ~mask
pressedModifiers.value = nextMask
await sendKeyPress(keyCode, false, nextMask)
emit('keyUp', keyCode)
} else {
const nextMask = pressedModifiers.value | mask
pressedModifiers.value = nextMask
await sendKeyPress(keyCode, true, nextMask)
emit('keyDown', keyCode)
}
updateKeyboardButtonTheme()
return
}
// Regular key: press and release
keysDown.value.push(keyCode)
emit('keyDown', keyCode)
const currentMask = pressedModifiers.value & 0xff
await sendKeyPress(keyCode, true, currentMask)
updateKeyboardButtonTheme()
setTimeout(async () => {
keysDown.value = keysDown.value.filter(k => k !== keyCode)
await sendKeyPress(keyCode, false, currentMask)
emit('keyUp', keyCode)
updateKeyboardButtonTheme()
}, 50)
}
async function onKeyUp() {
// Not used for now - we handle up in onKeyDown with setTimeout
}
async function sendKeyPress(keyCode: CanonicalKey, press: boolean, modifierMask: number) {
try {
await hidApi.keyboard(press ? 'down' : 'up', keyCode, modifierMask & 0xff)
} catch (err) {
console.error('[VirtualKeyboard] Key send failed:', err)
}
}
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)
}
}
}
}
// Update keyboard button theme for pressed keys
function updateKeyboardButtonTheme() {
const downKeys = keyNamesForDownKeys.value.join(' ')
const buttonTheme = [
{
class: 'combination-key',
buttons: 'CtrlAltDelete AltMetaEscape CtrlAltBackspace',
},
{
class: 'down-key',
buttons: downKeys,
},
]
mainKeyboard.value?.setOptions({ buttonTheme })
controlKeyboard.value?.setOptions({ buttonTheme })
arrowsKeyboard.value?.setOptions({ buttonTheme })
}
// Update layout when shift state changes
watch([layoutName, () => props.capsLock], ([name]) => {
mainKeyboard.value?.setOptions({
layoutName: name,
display: keyDisplayMap.value,
})
updateKeyboardButtonTheme()
})
// Initialize keyboards with unique selectors
function initKeyboards() {
const id = keyboardId.value
// Check if elements exist - use full selector with #
const mainEl = document.querySelector(`#${id}-main`)
const controlEl = document.querySelector(`#${id}-control`)
const arrowsEl = document.querySelector(`#${id}-arrows`)
if (!mainEl || !controlEl || !arrowsEl) {
console.warn('[VirtualKeyboard] DOM elements not ready, retrying...', id)
setTimeout(initKeyboards, 50)
return
}
// Main keyboard - pass element directly instead of selector string
mainKeyboard.value = new Keyboard(mainEl, {
layout: isCompactLayout.value ? compactMainLayout : keyboardLayout.main,
layoutName: layoutName.value,
display: keyDisplayMap.value,
theme: 'hg-theme-default hg-layout-default vkb-keyboard',
onKeyPress: onKeyDown,
onKeyReleased: onKeyUp,
buttonTheme: [
{
class: 'combination-key',
buttons: 'CtrlAltDelete AltMetaEscape CtrlAltBackspace',
},
],
disableButtonHold: true,
preventMouseDownDefault: true,
preventMouseUpDefault: true,
stopMouseDownPropagation: true,
stopMouseUpPropagation: true,
})
// Control keyboard
controlKeyboard.value = new Keyboard(controlEl, {
layout: keyboardLayout.control,
layoutName: 'default',
display: keyDisplayMap.value,
theme: 'hg-theme-default hg-layout-default vkb-keyboard',
onKeyPress: onKeyDown,
onKeyReleased: onKeyUp,
disableButtonHold: true,
preventMouseDownDefault: true,
preventMouseUpDefault: true,
stopMouseDownPropagation: true,
stopMouseUpPropagation: true,
})
// Arrows keyboard
arrowsKeyboard.value = new Keyboard(arrowsEl, {
layout: keyboardLayout.arrows,
layoutName: 'default',
display: keyDisplayMap.value,
theme: 'hg-theme-default hg-layout-default vkb-keyboard',
onKeyPress: onKeyDown,
onKeyReleased: onKeyUp,
disableButtonHold: true,
preventMouseDownDefault: true,
preventMouseUpDefault: true,
stopMouseDownPropagation: true,
stopMouseUpPropagation: true,
})
updateKeyboardLayout()
console.log('[VirtualKeyboard] Keyboards initialized:', id)
}
// Destroy keyboards
function destroyKeyboards() {
mainKeyboard.value?.destroy()
controlKeyboard.value?.destroy()
arrowsKeyboard.value?.destroy()
mainKeyboard.value = null
controlKeyboard.value = null
arrowsKeyboard.value = null
}
// Dragging handlers
function getClientCoords(e: MouseEvent | TouchEvent): { x: number; y: number } | null {
if ('touches' in e) {
const touch = e.touches[0]
return touch ? { x: touch.clientX, y: touch.clientY } : null
}
return { x: e.clientX, y: e.clientY }
}
function startDrag(e: MouseEvent | TouchEvent) {
if (isAttached.value || !keyboardRef.value) return
const coords = getClientCoords(e)
if (!coords) return
isDragging.value = true
const rect = keyboardRef.value.getBoundingClientRect()
dragOffset.value = {
x: coords.x - rect.left,
y: coords.y - rect.top,
}
}
function onDrag(e: MouseEvent | TouchEvent) {
if (!isDragging.value || !keyboardRef.value) return
const coords = getClientCoords(e)
if (!coords) return
const rect = keyboardRef.value.getBoundingClientRect()
const maxX = window.innerWidth - rect.width
const maxY = window.innerHeight - rect.height
position.value = {
x: Math.min(maxX, Math.max(0, coords.x - dragOffset.value.x)),
y: Math.min(maxY, Math.max(0, coords.y - dragOffset.value.y)),
}
}
function endDrag() {
isDragging.value = false
}
async function toggleAttached() {
destroyKeyboards()
isAttached.value = !isAttached.value
emit('update:attached', isAttached.value)
// Wait for Teleport to move the component
await nextTick()
await nextTick() // Extra tick for Teleport
// Reinitialize keyboards in new location
setTimeout(() => {
initKeyboards()
}, 100)
}
function close() {
emit('update:visible', false)
}
// Watch visibility to init/destroy keyboards
watch(() => props.visible, async (visible) => {
console.log('[VirtualKeyboard] Visibility changed:', visible, 'attached:', isAttached.value, 'id:', keyboardId.value)
if (visible) {
await nextTick()
initKeyboards()
} else {
destroyKeyboards()
}
}, { immediate: true })
watch(() => props.attached, (value) => {
if (value !== undefined) {
isAttached.value = 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
}
if (window.matchMedia) {
compactLayoutMedia = window.matchMedia('(max-width: 640px)')
setCompactLayout(compactLayoutMedia.matches)
compactLayoutListener = (event: MediaQueryListEvent) => {
setCompactLayout(event.matches)
}
compactLayoutMedia.addEventListener('change', compactLayoutListener)
}
document.addEventListener('mousemove', onDrag)
document.addEventListener('touchmove', onDrag)
document.addEventListener('mouseup', endDrag)
document.addEventListener('touchend', endDrag)
})
onUnmounted(() => {
if (compactLayoutMedia && compactLayoutListener) {
compactLayoutMedia.removeEventListener('change', compactLayoutListener)
}
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('touchmove', onDrag)
document.removeEventListener('mouseup', endDrag)
document.removeEventListener('touchend', endDrag)
destroyKeyboards()
})
</script>
<template>
<Transition name="keyboard-fade">
<div
v-if="visible"
:id="keyboardId"
ref="keyboardRef"
class="vkb"
:class="{
'vkb--attached': isAttached,
'vkb--floating': !isAttached,
'vkb--dragging': isDragging,
}"
:style="!isAttached ? { transform: `translate(${position.x}px, ${position.y}px)` } : undefined"
>
<!-- Header -->
<div
class="vkb-header"
@mousedown="startDrag"
@touchstart="startDrag"
>
<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') }}
</button>
</div>
<!-- Keyboard body -->
<div class="vkb-body">
<!-- Media keys row -->
<div v-if="props.consumerEnabled !== false" 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>
</Transition>
</template>
<style>
/* Simple-keyboard global overrides */
.vkb .simple-keyboard.hg-theme-default {
font-family: inherit;
background: transparent;
padding: 0;
}
.vkb .simple-keyboard .hg-button {
height: 36px;
border-radius: 6px;
background: var(--keyboard-button-bg, white);
color: var(--keyboard-button-color, #1f2937);
border: 1px solid var(--keyboard-button-border, #e5e7eb);
border-bottom-width: 2px;
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
font-size: 12px;
font-weight: 500;
padding: 0 6px;
margin: 0 2px 4px 0;
/* Key sizing for alignment */
flex-grow: 1;
flex-shrink: 1;
flex-basis: 0;
min-width: 40px;
}
.vkb .simple-keyboard .hg-button:hover {
background: var(--keyboard-button-hover-bg, #f3f4f6);
}
.vkb .simple-keyboard .hg-button:active {
background: #3b82f6;
color: white;
border-bottom-width: 1px;
margin-top: 1px;
}
.vkb .simple-keyboard .hg-button span {
overflow: hidden;
text-overflow: ellipsis;
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 */
.vkb .simple-keyboard .hg-button.down-key {
background: #3b82f6;
color: white;
font-weight: 600;
border-color: #2563eb;
}
/* Space bar */
.vkb .simple-keyboard .hg-button[data-skbtn="Space"] {
min-width: 200px;
flex-grow: 6;
}
/* Row spacing */
.vkb .simple-keyboard .hg-row {
margin-bottom: 0;
}
.vkb .simple-keyboard .hg-row:last-child {
margin-bottom: 0;
}
/* First row (macros) - left aligned */
.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;
}
/* Backspace - wider */
.vkb .simple-keyboard .hg-button[data-skbtn="Backspace"] {
flex-grow: 2;
min-width: 80px;
}
/* Tab key */
.vkb .simple-keyboard .hg-button[data-skbtn="Tab"] {
flex-grow: 1.5;
min-width: 60px;
}
/* Backslash - adjust to match row width */
.vkb .simple-keyboard .hg-button[data-skbtn="Backslash"],
.vkb .simple-keyboard .hg-button[data-skbtn="(Backslash)"] {
flex-grow: 1.5;
min-width: 60px;
}
/* Caps Lock */
.vkb .simple-keyboard .hg-button[data-skbtn="CapsLock"] {
flex-grow: 1.75;
min-width: 70px;
}
/* Enter key */
.vkb .simple-keyboard .hg-button[data-skbtn="Enter"] {
flex-grow: 2.25;
min-width: 90px;
}
/* Left Shift - wider */
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftLeft"] {
flex-grow: 2.25;
min-width: 90px;
}
/* Right Shift - wider */
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftRight"] {
flex-grow: 2.75;
min-width: 110px;
}
/* Bottom row modifiers */
.vkb .simple-keyboard .hg-button[data-skbtn="ControlLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="ControlRight"] {
flex-grow: 1.25;
min-width: 55px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="MetaLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="MetaRight"] {
flex-grow: 1.25;
min-width: 55px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="AltLeft"] {
flex-grow: 1.25;
min-width: 55px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="AltRight"] {
flex-grow: 1.25;
min-width: 55px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="ContextMenu"] {
flex-grow: 1.25;
min-width: 55px;
}
/* Control keyboard */
.kb-control-container .hg-button {
min-width: 54px !important;
justify-content: center;
}
/* Arrow buttons */
.kb-arrows-container .hg-button {
min-width: 44px !important;
width: 44px !important;
justify-content: center;
}
.kb-arrows-container .hg-row {
justify-content: center;
}
/* Dark mode - must be after simple-keyboard CSS import */
/* Use multiple selectors to ensure matching */
:root.dark .hg-theme-default .hg-button,
html.dark .hg-theme-default .hg-button,
.dark .hg-theme-default .hg-button {
background: #374151 !important;
color: #f9fafb !important;
border-color: #4b5563 !important;
border-bottom-color: #4b5563 !important;
box-shadow: none !important;
}
:root.dark .hg-theme-default .hg-button:hover,
html.dark .hg-theme-default .hg-button:hover,
.dark .hg-theme-default .hg-button:hover {
background: #4b5563 !important;
}
:root.dark .hg-theme-default .hg-button:active,
html.dark .hg-theme-default .hg-button:active,
.dark .hg-theme-default .hg-button:active,
:root.dark .hg-theme-default .hg-button.hg-activeButton,
html.dark .hg-theme-default .hg-button.hg-activeButton,
.dark .hg-theme-default .hg-button.hg-activeButton {
background: #3b82f6 !important;
color: white !important;
}
:root.dark .hg-theme-default .hg-button.down-key,
html.dark .hg-theme-default .hg-button.down-key,
.dark .hg-theme-default .hg-button.down-key {
background: #3b82f6 !important;
color: white !important;
border-color: #2563eb !important;
border-bottom-color: #2563eb !important;
}
</style>
<style scoped>
.vkb {
z-index: 100;
user-select: none;
background: white;
border: 1px solid #e5e7eb;
}
:global(.dark .vkb) {
background: #1f2937;
border-color: #374151;
}
.vkb--attached {
position: relative;
width: 100%;
border-left: 0;
border-right: 0;
border-bottom: 0;
}
.vkb--floating {
position: fixed;
top: 0;
left: 0;
min-width: 1200px;
max-width: 1600px;
width: auto;
border-radius: 8px;
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
}
.vkb--dragging {
cursor: move;
}
/* Header - compact */
.vkb-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
border-bottom: 1px solid #e5e7eb;
background: #f9fafb;
min-height: 28px;
position: relative;
}
:global(.dark .vkb-header) {
background: #111827;
border-color: #374151;
}
.vkb--floating .vkb-header {
cursor: move;
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;
}
:global(.dark .vkb-title) {
color: #d1d5db;
}
.vkb-btn {
padding: 2px 8px;
font-size: 11px;
font-weight: 500;
color: #374151;
background: white;
border: 1px solid #d1d5db;
border-radius: 4px;
cursor: pointer;
line-height: 1.4;
}
.vkb-btn:hover {
background: #f3f4f6;
}
:global(.dark .vkb-btn) {
color: #d1d5db;
background: #374151;
border-color: #4b5563;
}
:global(.dark .vkb-btn:hover) {
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: column;
padding: 8px;
gap: 8px;
background: #f3f4f6;
}
:global(.dark .vkb-body) {
background: #111827;
}
.vkb--floating .vkb-body {
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;
}
.vkb-side {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-end;
}
.kb-control-container,
.kb-arrows-container {
display: inline-block;
}
/* Responsive */
@media (max-width: 900px) {
.vkb-keyboards {
flex-direction: column;
}
.vkb-side {
flex-direction: row;
justify-content: center;
gap: 16px;
}
}
@media (max-width: 640px) {
.vkb-body {
padding: 4px;
gap: 4px;
}
.vkb .simple-keyboard .hg-button {
height: 28px;
font-size: 10px;
padding: 0 3px;
margin: 0 1px 2px 0;
min-width: 24px;
}
.vkb .simple-keyboard .hg-button.combination-key {
font-size: 9px;
height: 24px;
padding: 0 6px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Backspace"] {
min-width: 60px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Tab"] {
min-width: 52px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Backslash"],
.vkb .simple-keyboard .hg-button[data-skbtn="(Backslash)"] {
min-width: 52px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="CapsLock"] {
min-width: 60px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Enter"] {
min-width: 70px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftLeft"] {
min-width: 70px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftRight"] {
min-width: 80px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="ControlLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="ControlRight"],
.vkb .simple-keyboard .hg-button[data-skbtn="MetaLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="MetaRight"],
.vkb .simple-keyboard .hg-button[data-skbtn="AltLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="AltRight"],
.vkb .simple-keyboard .hg-button[data-skbtn="ContextMenu"] {
min-width: 46px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Space"] {
min-width: 140px;
}
.kb-control-container .hg-button {
min-width: 44px !important;
}
.kb-arrows-container .hg-button {
min-width: 36px !important;
width: 36px !important;
}
.vkb-media-btn {
padding: 4px 8px;
font-size: 14px;
min-width: 32px;
}
}
@media (max-width: 400px) {
.vkb .simple-keyboard .hg-button {
height: 26px;
font-size: 9px;
padding: 0 2px;
margin: 0 1px 2px 0;
min-width: 20px;
border-radius: 4px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Space"] {
min-width: 100px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Backspace"],
.vkb .simple-keyboard .hg-button[data-skbtn="Tab"],
.vkb .simple-keyboard .hg-button[data-skbtn="Backslash"],
.vkb .simple-keyboard .hg-button[data-skbtn="(Backslash)"],
.vkb .simple-keyboard .hg-button[data-skbtn="CapsLock"] {
min-width: 44px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Enter"],
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftRight"] {
min-width: 50px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="ControlLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="ControlRight"],
.vkb .simple-keyboard .hg-button[data-skbtn="MetaLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="MetaRight"],
.vkb .simple-keyboard .hg-button[data-skbtn="AltLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="AltRight"],
.vkb .simple-keyboard .hg-button[data-skbtn="ContextMenu"] {
min-width: 34px;
}
.vkb .simple-keyboard .hg-button.combination-key {
font-size: 8px;
height: 22px;
padding: 0 4px;
}
.kb-control-container .hg-button {
min-width: 34px !important;
}
.kb-arrows-container .hg-button {
min-width: 30px !important;
width: 30px !important;
}
.vkb-media-btn {
padding: 3px 6px;
font-size: 12px;
min-width: 28px;
}
.vkb-header {
padding: 2px 6px;
min-height: 24px;
}
.vkb-btn {
padding: 1px 6px;
font-size: 10px;
}
.vkb-os-btn {
padding: 1px 6px;
font-size: 9px;
}
.vkb-title {
font-size: 10px;
}
}
/* Floating mode - slightly smaller keys but still readable */
.vkb--floating .vkb-body {
padding: 8px;
}
.vkb--floating :deep(.simple-keyboard .hg-button) {
height: 34px;
font-size: 12px;
}
.vkb--floating :deep(.simple-keyboard .hg-button.combination-key) {
height: 26px;
font-size: 10px;
}
.vkb--floating :deep(.kb-control-container .hg-button) {
min-width: 52px !important;
}
.vkb--floating :deep(.kb-arrows-container .hg-button) {
min-width: 42px !important;
width: 42px !important;
}
/* Animation */
.keyboard-fade-enter-active,
.keyboard-fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.keyboard-fade-enter-from,
.keyboard-fade-leave-to {
opacity: 0;
}
.vkb--attached.keyboard-fade-enter-from,
.vkb--attached.keyboard-fade-leave-to {
transform: translateY(20px);
}
.vkb--floating.keyboard-fade-enter-from,
.vkb--floating.keyboard-fade-leave-to {
transform: scale(0.95);
}
</style>