Files
One-KVM/web/src/components/VirtualKeyboard.vue
2026-07-17 01:33:44 +08:00

1260 lines
34 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 { Button } from '@/components/ui/button'
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()
const isAttached = ref(props.attached ?? true)
const selectedOs = ref<KeyboardOsType>('windows')
const mainKeyboard = ref<Keyboard | null>(null)
const pressedModifiers = ref<number>(0)
const keysDown = ref<CanonicalKey[]>([])
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'
})
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'] : []),
]))
})
const keyboardRef = ref<HTMLDivElement | null>(null)
const isDragging = ref(false)
const dragOffset = ref({ x: 0, y: 0 })
const position = ref({ x: 100, y: 100 })
const keyboardId = ref(`kb-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
const getBottomRow = () =>
[...osBottomRows[selectedOs.value], 'ArrowLeft', 'ArrowDown', 'ArrowRight'].join(' ')
const keyboardLayout = {
main: {
default: [
'CtrlAltDelete AltMetaEscape CtrlAltBackspace',
'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',
'Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash Delete End PageDown',
'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 ArrowUp',
'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight ArrowLeft ArrowDown ArrowRight',
],
shift: [
'CtrlAltDelete AltMetaEscape CtrlAltBackspace',
'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',
'Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash) Delete End PageDown',
'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 ArrowUp',
'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight ArrowLeft ArrowDown ArrowRight',
],
},
}
const compactMainLayout = {
default: [
'Escape Insert Delete Home End PageUp PageDown PrintScreen',
'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 ArrowUp',
'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight ArrowLeft ArrowDown ArrowRight',
],
shift: [
'Escape Insert Delete Home End PageUp PageDown PrintScreen',
'(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 ArrowUp',
'ControlLeft MetaLeft AltLeft Space AltRight MetaRight ContextMenu ControlRight ArrowLeft ArrowDown ArrowRight',
],
}
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()
}
const keyDisplayMap = computed<Record<string, string>>(() => {
const metaLabel = selectedOs.value === 'windows' ? '⊞ Win'
: selectedOs.value === 'mac' ? '⌘ Cmd' : 'Meta'
return {
CtrlAltDelete: 'Ctrl+Alt+Del',
AltMetaEscape: 'Alt+Meta+Esc',
CtrlAltBackspace: 'Ctrl+Alt+Bksp',
ControlLeft: 'Ctrl',
ControlRight: 'Ctrl',
ShiftLeft: 'Shift',
ShiftRight: 'Shift',
AltLeft: 'Alt',
AltRight: 'AltGr',
MetaLeft: metaLabel,
MetaRight: metaLabel,
ContextMenu: 'Menu',
Escape: 'Esc',
Backspace: '⌫',
Tab: 'Tab',
CapsLock: 'Caps',
Enter: 'Enter',
Space: ' ',
Insert: 'Ins',
Delete: 'Del',
Home: 'Home',
End: 'End',
PageUp: 'PgUp',
PageDown: 'PgDn',
ArrowUp: '↑',
ArrowDown: '↓',
ArrowLeft: '←',
ArrowRight: '→',
PrintScreen: 'PrtSc',
ScrollLock: 'ScrLk',
Pause: 'Pause',
F1: 'F1', F2: 'F2', F3: 'F3', F4: 'F4',
F5: 'F5', F6: 'F6', F7: 'F7', F8: 'F8',
F9: 'F9', F10: 'F10', F11: 'F11', F12: 'F12',
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',
'(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',
Digit1: '1', Digit2: '2', Digit3: '3', Digit4: '4', Digit5: '5',
Digit6: '6', Digit7: '7', Digit8: '8', Digit9: '9', Digit0: '0',
'(Digit1)': '!', '(Digit2)': '@', '(Digit3)': '#', '(Digit4)': '$', '(Digit5)': '%',
'(Digit6)': '^', '(Digit7)': '&', '(Digit8)': '*', '(Digit9)': '(', '(Digit0)': ')',
Minus: '-', '(Minus)': '_',
Equal: '=', '(Equal)': '+',
BracketLeft: '[', '(BracketLeft)': '{',
BracketRight: ']', '(BracketRight)': '}',
Backslash: '\\', '(Backslash)': '|',
Semicolon: ';', '(Semicolon)': ':',
Quote: "'", '(Quote)': '"',
Comma: ',', '(Comma)': '<',
Period: '.', '(Period)': '>',
Slash: '/', '(Slash)': '?',
Backquote: '`', '(Backquote)': '~',
}
})
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)
}
}
}
function switchOsLayout(os: KeyboardOsType) {
selectedOs.value = os
localStorage.setItem('vkb-os-layout', os)
updateKeyboardLayout()
}
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 })
updateKeyboardButtonTheme()
}
async function onKeyDown(key: string) {
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
}
const cleanKey = key.replace(/[()]/g, '')
if (!(cleanKey in keys)) {
console.warn(`[VirtualKeyboard] Unknown key: ${cleanKey}`)
return
}
const keyCode = keys[cleanKey as KeyName]
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
}
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
}
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() {
}
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)
}
}
}
}
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 })
}
watch([layoutName, () => props.capsLock], ([name]) => {
mainKeyboard.value?.setOptions({
layoutName: name,
display: keyDisplayMap.value,
})
updateKeyboardButtonTheme()
})
function initKeyboards() {
const id = keyboardId.value
const mainEl = document.querySelector(`#${id}-main`)
if (!mainEl) {
console.warn('[VirtualKeyboard] DOM elements not ready, retrying...', id)
setTimeout(initKeyboards, 50)
return
}
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,
})
updateKeyboardLayout()
console.log('[VirtualKeyboard] Keyboards initialized:', id)
}
function destroyKeyboards() {
mainKeyboard.value?.destroy()
mainKeyboard.value = null
}
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)
await nextTick()
await nextTick() // Extra tick for Teleport
setTimeout(() => {
initKeyboards()
}, 100)
}
function close() {
emit('update:visible', false)
}
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(() => {
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 variant="ghost" size="sm" 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"
variant="ghost"
size="sm"
: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 variant="ghost" size="sm" 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"
variant="outline"
size="sm"
@click="onMediaKeyPress(key)"
>
{{ mediaKeyLabels[key] || key }}
</Button>
</div>
<div class="vkb-keyboards">
<div :id="`${keyboardId}-main`" class="kb-main-container"></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: var(--radius-md);
background: var(--keyboard-button-bg);
color: var(--keyboard-button-color);
border: 1px solid var(--keyboard-button-border);
border-bottom-width: 2px;
box-shadow: 0 1px 2px 0 color-mix(in oklch, var(--foreground) 5%, transparent);
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);
}
.vkb .simple-keyboard .hg-button:active {
background: var(--primary);
color: var(--primary-foreground);
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: var(--primary);
color: var(--primary-foreground);
font-weight: 600;
border-color: var(--primary);
}
/* 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;
}
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftRight"] {
flex-grow: 1.75;
min-width: 70px;
}
/* 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;
}
.vkb .simple-keyboard .hg-button[data-skbtn="PrintScreen"],
.vkb .simple-keyboard .hg-button[data-skbtn="ScrollLock"],
.vkb .simple-keyboard .hg-button[data-skbtn="Pause"],
.vkb .simple-keyboard .hg-button[data-skbtn="Insert"],
.vkb .simple-keyboard .hg-button[data-skbtn="Delete"],
.vkb .simple-keyboard .hg-button[data-skbtn="Home"],
.vkb .simple-keyboard .hg-button[data-skbtn="End"],
.vkb .simple-keyboard .hg-button[data-skbtn="PageUp"],
.vkb .simple-keyboard .hg-button[data-skbtn="PageDown"] {
font-size: 11px;
flex-grow: 0.95;
}
.vkb .simple-keyboard .hg-button[data-skbtn="PrintScreen"],
.vkb .simple-keyboard .hg-button[data-skbtn="Insert"],
.vkb .simple-keyboard .hg-button[data-skbtn="Delete"],
.vkb .simple-keyboard .hg-button[data-skbtn="ArrowUp"],
.vkb .simple-keyboard .hg-button[data-skbtn="ArrowLeft"] {
margin-left: 6px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="ArrowUp"],
.vkb .simple-keyboard .hg-button[data-skbtn="ArrowDown"],
.vkb .simple-keyboard .hg-button[data-skbtn="ArrowLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="ArrowRight"] {
font-size: 14px;
flex-grow: 1;
}
/* 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: var(--keyboard-button-bg) !important;
color: var(--keyboard-button-color) !important;
border-color: var(--keyboard-button-border) !important;
border-bottom-color: var(--keyboard-button-border) !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: var(--keyboard-button-hover-bg) !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: var(--primary) !important;
color: var(--primary-foreground) !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: var(--primary) !important;
color: var(--primary-foreground) !important;
border-color: var(--primary) !important;
border-bottom-color: var(--primary) !important;
}
</style>
<style scoped>
.vkb {
z-index: 100;
user-select: none;
background: var(--background);
border: 1px solid var(--border);
}
:global(.dark .vkb) {
background: var(--background);
border-color: var(--border);
}
.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: var(--radius-lg);
box-shadow: 0 25px 50px -12px color-mix(in oklch, var(--foreground) 25%, transparent);
}
.vkb--dragging {
cursor: move;
}
/* Header - compact */
.vkb-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
border-bottom: 1px solid var(--border);
background: var(--muted);
min-height: 28px;
position: relative;
}
:global(.dark .vkb-header) {
background: var(--muted);
border-color: var(--border);
}
.vkb--floating .vkb-header {
cursor: move;
border-radius: var(--radius-lg) var(--radius-lg) 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: var(--foreground);
}
:global(.dark .vkb-title) {
color: var(--foreground);
}
.vkb-btn {
padding: 2px 8px;
font-size: 11px;
font-weight: 500;
color: var(--foreground);
background: var(--background);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
line-height: 1.4;
}
.vkb-btn:hover {
background: var(--accent);
}
:global(.dark .vkb-btn) {
color: var(--foreground);
background: var(--background);
border-color: var(--border);
}
:global(.dark .vkb-btn:hover) {
background: var(--accent);
}
/* OS selector */
.vkb-os-selector {
display: flex;
gap: 2px;
background: var(--muted);
border-radius: var(--radius-sm);
padding: 2px;
}
:global(.dark .vkb-os-selector) {
background: var(--muted);
}
.vkb-os-btn {
padding: 2px 8px;
font-size: 10px;
font-weight: 500;
color: var(--muted-foreground);
background: transparent;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
}
.vkb-os-btn:hover {
color: var(--foreground);
}
.vkb-os-btn--active {
background: var(--background);
color: var(--foreground);
box-shadow: 0 1px 2px color-mix(in oklch, var(--foreground) 10%, transparent);
}
:global(.dark .vkb-os-btn) {
color: var(--muted-foreground);
}
:global(.dark .vkb-os-btn:hover) {
color: var(--foreground);
}
:global(.dark .vkb-os-btn--active) {
background: var(--background);
color: var(--foreground);
}
/* Keyboard body */
.vkb-body {
display: flex;
flex-direction: column;
padding: 8px;
gap: 8px;
background: color-mix(in oklch, var(--muted) 65%, var(--background));
}
:global(.dark .vkb-body) {
background: color-mix(in oklch, var(--muted) 65%, var(--background));
}
.vkb--floating .vkb-body {
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
}
/* Media keys row */
.vkb-media-row {
display: flex;
gap: 4px;
justify-content: center;
padding-bottom: 4px;
border-bottom: 1px solid var(--border);
}
:global(.dark .vkb-media-row) {
border-color: var(--border);
}
.vkb-media-btn {
padding: 4px 12px;
font-size: 16px;
background: var(--background);
border: 1px solid var(--border);
border-radius: var(--radius-md);
cursor: pointer;
min-width: 40px;
}
.vkb-media-btn:hover {
background: var(--accent);
}
.vkb-media-btn:active {
background: var(--primary);
color: var(--primary-foreground);
}
:global(.dark .vkb-media-btn) {
background: var(--background);
border-color: var(--border);
color: var(--foreground);
}
:global(.dark .vkb-media-btn:hover) {
background: var(--accent);
}
/* Keyboards container */
.vkb-keyboards {
display: flex;
flex-direction: row;
gap: 8px;
}
.kb-main-container {
flex: 1;
}
/* Responsive */
@media (max-width: 640px) {
.vkb-body {
padding: 4px;
gap: 4px;
}
.vkb .simple-keyboard .hg-button {
height: 30px;
font-size: 11px;
padding: 0 2px;
margin: 0 1px 2px 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="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"],
.vkb .simple-keyboard .hg-button[data-skbtn="Enter"],
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftRight"],
.vkb .simple-keyboard .hg-button[data-skbtn="Space"],
.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: 0;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Insert"],
.vkb .simple-keyboard .hg-button[data-skbtn="Delete"],
.vkb .simple-keyboard .hg-button[data-skbtn="Home"],
.vkb .simple-keyboard .hg-button[data-skbtn="End"],
.vkb .simple-keyboard .hg-button[data-skbtn="PageUp"],
.vkb .simple-keyboard .hg-button[data-skbtn="PageDown"],
.vkb .simple-keyboard .hg-button[data-skbtn="PrintScreen"] {
font-size: 11px;
flex-grow: 1;
}
.vkb .simple-keyboard .hg-button[data-skbtn="PrintScreen"],
.vkb .simple-keyboard .hg-button[data-skbtn="Insert"],
.vkb .simple-keyboard .hg-button[data-skbtn="Delete"],
.vkb .simple-keyboard .hg-button[data-skbtn="ArrowLeft"] {
margin-left: 0;
}
.vkb .simple-keyboard .hg-button[data-skbtn="ArrowUp"] {
margin-left: 4px;
}
.vkb-media-btn {
padding: 4px 8px;
font-size: 14px;
min-width: 32px;
}
}
@media (max-width: 400px) {
.vkb .simple-keyboard .hg-button {
height: 28px;
font-size: 10px;
padding: 0 1px;
margin: 0 1px 2px 0;
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="Delete"],
.vkb .simple-keyboard .hg-button[data-skbtn="Home"],
.vkb .simple-keyboard .hg-button[data-skbtn="End"],
.vkb .simple-keyboard .hg-button[data-skbtn="PageUp"],
.vkb .simple-keyboard .hg-button[data-skbtn="PageDown"],
.vkb .simple-keyboard .hg-button[data-skbtn="PrintScreen"] {
font-size: 10px;
}
.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;
}
/* 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>