Files
One-KVM/web/src/components/ActionBar.vue
mofeng-git 3c15168ddc feat(hid): 添加 Linux 键盘与 SysRq 快捷键 (#307)
控制台增加独立快捷键菜单,提供常用组合键和 Linux SysRq 指令,虚拟键盘补充 Linux 布局。
通过可靠且有序的 WebSocket 发送组合键,取消或失败时逐一释放按键,防止修饰键残留。
执行期间避免物理键盘与虚拟键盘交错输入,断连、失焦和切换布局时停止执行。
修正焦点移动后的物理按键释放,并将原虚拟键盘组合键迁移到统一菜单。

验证:独立暂存快照前端构建通过;6 项快捷键单测及 5 组浏览器测试通过,覆盖组合键序列、失败清理、取消及键盘焦点。

Refs #307
2026-10-28 07:21:52 +08:00

835 lines
29 KiB
Vue

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSystemStore } from '@/stores/system'
import type { VideoRotation, VideoScaleMode } from '@/composables/useVideoScaling'
import { Button } from '@/components/ui/button'
import { ButtonGroup } from '@/components/ui/button-group'
import {
PopoverContent,
PopoverTrigger,
Popover,
} from '@/components/ui/popover'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import {
ClipboardPaste,
HardDrive,
Power,
BarChart3,
Terminal,
MoreHorizontal,
ChevronUp,
ChevronDown,
Keyboard,
Scaling,
} from 'lucide-vue-next'
import PasteModal from '@/components/PasteModal.vue'
import AtxPopover from '@/components/AtxPopover.vue'
import VideoConfigPopover, { type VideoMode } from '@/components/VideoConfigPopover.vue'
import HidConfigPopover from '@/components/HidConfigPopover.vue'
import AudioConfigPopover from '@/components/AudioConfigPopover.vue'
import MsdDialog from '@/components/MsdDialog.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'
const { t, locale } = useI18n()
const systemStore = useSystemStore()
const overflowMenuOpen = ref(false)
const showMsd = computed(() => !!systemStore.msd?.available)
const props = defineProps<{
layout?: ConsoleLayout
mouseMode?: 'absolute' | 'relative'
videoMode?: VideoMode
videoRotation?: VideoRotation
ttydRunning?: boolean
showPower?: boolean
atxEnabled?: boolean
showTerminal?: boolean
showPasteText?: boolean
showMic?: boolean
scaleMode?: VideoScaleMode
sourceSizeAvailable?: boolean
keyboardVisible?: boolean
fullscreen?: boolean
statsOpen?: boolean
terminalOpen?: boolean
shortcutsReady?: boolean
shortcutInputBusy?: boolean
shortcutBusy?: boolean
}>()
const isSidebarLayout = computed(() => props.layout === 'sidebar')
const isFloatingLayout = computed(() => props.layout === 'floating')
const floatingCollapsed = ref(false)
const expandButtonRef = ref<InstanceType<typeof Button> | null>(null)
const collapseButtonRef = ref<InstanceType<typeof Button> | null>(null)
async function setFloatingCollapsed(collapsed: boolean) {
floatingCollapsed.value = collapsed
await nextTick()
const target = collapsed ? expandButtonRef.value : collapseButtonRef.value
target?.$el?.focus()
}
const showAtx = computed(() => props.showPower !== false)
const atxEnabled = computed(() => props.atxEnabled === true)
const showStats = computed(() => (props.videoMode ?? 'mjpeg') !== 'mjpeg')
const showPasteText = computed(() => props.showPasteText !== false)
const showMic = computed(() => props.showMic === true)
const emit = defineEmits<{
(e: 'toggleFullscreen'): void
(e: 'update:scaleMode', mode: VideoScaleMode): void
(e: 'toggleStats'): void
(e: 'toggleVirtualKeyboard'): void
(e: 'toggleMouseMode'): void
(e: 'update:videoMode', mode: VideoMode): void
(e: 'update:videoRotation', rotation: VideoRotation): void
(e: 'powerShort'): void
(e: 'powerLong'): void
(e: 'reset'): void
(e: 'wol', macAddress: string): void
(e: 'openTerminal'): void
(e: 'executeShortcut', shortcut: KeyboardShortcut): void
(e: 'cancelShortcut'): void
}>()
const pasteOpen = ref(false)
const atxOpen = ref(false)
const videoPopoverOpen = ref(false)
const hidPopoverOpen = ref(false)
const audioPopoverOpen = ref(false)
const msdDialogOpen = ref(false)
const shortcutsOpen = ref(false)
const mobileAtxOpen = ref(false)
const mobilePasteOpen = ref(false)
const mobileAtxOpenTime = ref(0)
const mobilePasteOpenTime = ref(0)
const OPEN_GUARD_MS = 350
const guardOutside = (openTime: number, e: Event) => {
if (Date.now() - openTime < OPEN_GUARD_MS) {
e.preventDefault()
}
}
const openFromOverflow = (setter: () => void) => {
overflowMenuOpen.value = false
setTimeout(setter, 50)
}
const openMobileAtx = () => openFromOverflow(() => {
if (!showAtx.value) return
mobileAtxOpen.value = true
mobileAtxOpenTime.value = Date.now()
})
const openMobilePaste = () => openFromOverflow(() => {
if (!showPasteText.value) return
mobilePasteOpen.value = true
mobilePasteOpenTime.value = Date.now()
})
const barRef = ref<HTMLElement | null>(null)
const measureRef = ref<HTMLElement | null>(null)
const barWidth = ref(0)
const barHeight = ref(0)
const coreWidth = ref(0)
const coreHeight = ref(0)
const fixedHeight = ref(0)
const actionHeight = ref(36)
const minimalDisplayControls = computed(() => isFloatingLayout.value && barWidth.value < 640)
const alwaysRightWidth = ref(152)
let layoutResizeObserver: ResizeObserver | null = null
type CollapsibleItem =
| 'msd' | 'atx' | 'paste'
| 'stats' | 'terminal'
interface ItemSpec {
id: CollapsibleItem
side: 'left' | 'right'
}
const ITEM_SPECS: ItemSpec[] = [
{ id: 'msd', side: 'left' },
{ id: 'atx', side: 'left' },
{ id: 'paste', side: 'left' },
{ id: 'stats', side: 'right' },
{ id: 'terminal', side: 'right' },
]
const measuredWidths = ref<Map<CollapsibleItem, { icon: number; label: number }>>(new Map())
const measurementReady = ref(false)
const measureLayout = async () => {
await nextTick()
const bar = barRef.value
const measureContainer = measureRef.value
if (!bar || !measureContainer) return
const style = window.getComputedStyle(bar)
barWidth.value = bar.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight)
barHeight.value = bar.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom)
const core = Array.from(bar.querySelectorAll<HTMLElement>('[data-core-action]'))
coreWidth.value = core.reduce((sum, element) => sum + element.offsetWidth, 0)
coreHeight.value = core.reduce((sum, element) => sum + element.offsetHeight, 0)
actionHeight.value = core[0]?.offsetHeight || 36
const newWidths = new Map<CollapsibleItem, { icon: number; label: number }>()
for (const spec of ITEM_SPECS) {
const iconEl = measureContainer.querySelector(`[data-measure="${spec.id}-icon"]`) as HTMLElement
const labelEl = measureContainer.querySelector(`[data-measure="${spec.id}-label"]`) as HTMLElement
if (iconEl && labelEl) {
newWidths.set(spec.id, {
icon: Math.ceil(iconEl.offsetWidth) + 8,
label: Math.ceil(labelEl.offsetWidth) + 8,
})
}
}
measuredWidths.value = newWidths
const elements = Array.from(bar.querySelectorAll('[data-fixed-action]')) as HTMLElement[]
const width = elements.reduce((sum, element) => {
const style = window.getComputedStyle(element)
return sum
+ element.getBoundingClientRect().width
+ Number.parseFloat(style.marginLeft || '0')
+ Number.parseFloat(style.marginRight || '0')
}, 0)
alwaysRightWidth.value = Math.ceil(width)
fixedHeight.value = elements.reduce((sum, element) => {
const style = window.getComputedStyle(element)
return sum + element.getBoundingClientRect().height
+ parseFloat(style.marginTop || '0') + parseFloat(style.marginBottom || '0')
}, 0)
measurementReady.value = true
}
const observeLayout = async () => {
await measureLayout()
layoutResizeObserver?.disconnect()
layoutResizeObserver = new ResizeObserver(() => {
void measureLayout()
})
if (barRef.value) layoutResizeObserver.observe(barRef.value)
barRef.value?.querySelectorAll('[data-fixed-action], [data-core-action]').forEach((element) => {
layoutResizeObserver?.observe(element)
})
}
onMounted(() => {
void observeLayout()
})
onUnmounted(() => {
layoutResizeObserver?.disconnect()
})
watch(locale, () => {
measurementReady.value = false
void measureLayout()
})
watch([() => props.layout, floatingCollapsed, minimalDisplayControls], () => {
// Layout changes can move this bar while its portal based controls are open.
// Close every transient surface first so no old portal remains attached to
// the document after the new layout is painted.
overflowMenuOpen.value = false
pasteOpen.value = false
atxOpen.value = false
videoPopoverOpen.value = false
hidPopoverOpen.value = false
audioPopoverOpen.value = false
msdDialogOpen.value = false
shortcutsOpen.value = false
mobileAtxOpen.value = false
mobilePasteOpen.value = false
void observeLayout()
})
watch(showAtx, (visible) => {
if (!visible) {
atxOpen.value = false
mobileAtxOpen.value = false
}
})
watch(showPasteText, (visible) => {
if (!visible) {
pasteOpen.value = false
mobilePasteOpen.value = false
}
})
const OVERFLOW_BUTTON_BUDGET_PX = 36
const collapsibleItems = computed(() => {
const items = ITEM_SPECS.filter(item => {
if (isFloatingLayout.value && item.side === 'right') return false
if (item.id === 'msd' && !showMsd.value) return false
if (item.id === 'atx' && !showAtx.value) return false
if (item.id === 'paste' && !showPasteText.value) return false
if (item.id === 'stats' && !showStats.value) return false
if (item.id === 'terminal' && props.showTerminal === false) return false
return true
})
return items
})
const visibleSet = computed(() => {
const result = new Map<CollapsibleItem, 'icon' | 'label'>()
if (!measurementReady.value) return result
if (isSidebarLayout.value) {
// Reserve More and a gap between the two groups before assigning vertical slots.
let available = barHeight.value - coreHeight.value - fixedHeight.value - actionHeight.value - 16
const priority: CollapsibleItem[] = ['paste', 'msd', 'atx', 'stats', 'terminal']
for (const id of priority) {
if (!collapsibleItems.value.some(item => item.id === id)) continue
if (available < actionHeight.value) break
result.set(id, 'icon')
available -= actionHeight.value
}
return result
}
const available = barWidth.value - alwaysRightWidth.value - Math.max(OVERFLOW_BUTTON_BUDGET_PX, actionHeight.value) - (isFloatingLayout.value ? 48 : 12)
let used = coreWidth.value
// Keep actions reachable before spending the remaining space on labels.
for (const item of collapsibleItems.value) {
const widths = measuredWidths.value.get(item.id)
if (!widths || used + widths.icon > available) continue
result.set(item.id, 'icon')
used += widths.icon
}
if (isFloatingLayout.value && barWidth.value < 640) return result
for (const item of collapsibleItems.value) {
const widths = measuredWidths.value.get(item.id)
if (!widths || !result.has(item.id)) continue
const extra = widths.label - widths.icon
if (used + extra <= available) {
result.set(item.id, 'label')
used += extra
}
}
return result
})
const isVisible = (id: CollapsibleItem) => visibleSet.value.has(id)
const hasOverflow = computed(() => {
return collapsibleItems.value.some(i => !visibleSet.value.has(i.id))
})
const hasLeftOverflow = computed(() => {
return collapsibleItems.value.some(i => i.side === 'left' && !visibleSet.value.has(i.id))
})
const hasRightOverflow = computed(() => {
return collapsibleItems.value.some(i => i.side === 'right' && !visibleSet.value.has(i.id))
})
</script>
<template>
<div
:class="[
'console-action-bar bg-background',
props.layout === 'floating' && 'console-action-bar--floating',
isFloatingLayout && floatingCollapsed && 'console-action-bar--collapsed',
props.layout === 'sidebar' && 'console-action-bar--sidebar',
(!props.layout || props.layout === 'current') && 'w-full border-b',
]"
>
<Button
v-if="isFloatingLayout && floatingCollapsed"
ref="expandButtonRef"
variant="ghost"
size="sm"
class="console-action-bar__expand gap-1.5 rounded-xl text-xs"
:aria-label="t('actionbar.expandToolbar')"
:aria-expanded="false"
@click="setFloatingCollapsed(false)"
>
<ChevronDown class="size-4" /><span class="hidden sm:inline">{{ t('actionbar.expandToolbar') }}</span>
</Button>
<div
v-show="!isFloatingLayout || !floatingCollapsed"
ref="barRef"
class="console-action-bar__inner flex items-center"
:class="isSidebarLayout
? 'h-full flex-col px-1 py-2 sm:px-1.5'
: 'px-2 py-1 sm:px-4 sm:py-1.5'"
>
<!-- Left side buttons -->
<ButtonGroup
class="left-buttons min-w-0"
:class="isSidebarLayout
? 'flex-none flex-col overflow-visible'
: 'flex-1 overflow-hidden'"
:orientation="isSidebarLayout ? 'vertical' : 'horizontal'"
>
<!-- Video Config - Always visible -->
<div data-core-action class="flex shrink-0">
<VideoConfigPopover
v-model:open="videoPopoverOpen"
:video-mode="props.videoMode || 'mjpeg'"
:video-rotation="props.videoRotation ?? 0"
:side="isSidebarLayout ? 'right' : 'bottom'"
@update:video-mode="emit('update:videoMode', $event)"
@update:video-rotation="emit('update:videoRotation', $event)"
/>
</div>
<!-- Audio Config - Always visible -->
<div data-core-action class="flex shrink-0">
<AudioConfigPopover
v-model:open="audioPopoverOpen"
:microphone-enabled="showMic"
:side="isSidebarLayout ? 'right' : 'bottom'"
/>
</div>
<!-- HID Config - Always visible -->
<div data-core-action class="flex shrink-0">
<HidConfigPopover
v-model:open="hidPopoverOpen"
:mouse-mode="mouseMode"
:side="isSidebarLayout ? 'right' : 'bottom'"
@update:mouse-mode="emit('toggleMouseMode')"
/>
</div>
<!-- Virtual Media (MSD) - Adaptive -->
<div v-if="showMsd && isVisible('msd')">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="sm"
class="h-8 gap-1.5 text-xs"
:aria-label="t('actionbar.virtualMedia')"
aria-haspopup="dialog"
:aria-expanded="msdDialogOpen"
:title="t('actionbar.virtualMedia')"
@click="msdDialogOpen = true"
>
<HardDrive class="size-4" />
<span v-if="visibleSet.get('msd') === 'label'">{{ t('actionbar.virtualMedia') }}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('actionbar.virtualMediaTip') }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<!-- ATX Power Control - Adaptive -->
<div v-if="showAtx && isVisible('atx')">
<Popover v-model:open="atxOpen">
<PopoverTrigger as-child>
<Button
variant="ghost"
size="sm"
class="h-8 gap-1.5 text-xs"
:aria-label="t('actionbar.power')"
:title="t('actionbar.power')"
>
<Power class="size-4" />
<span v-if="visibleSet.get('atx') === 'label'">{{ t('actionbar.power') }}</span>
</Button>
</PopoverTrigger>
<PopoverContent
class="w-[min(280px,90vw)] p-0"
align="start"
:side="isSidebarLayout ? 'right' : 'bottom'"
>
<AtxPopover
:atx-enabled="atxEnabled"
@close="atxOpen = false"
@power-short="emit('powerShort')"
@power-long="emit('powerLong')"
@reset="emit('reset')"
@wol="(mac) => emit('wol', mac)"
/>
</PopoverContent>
</Popover>
</div>
<!-- Paste Text - Adaptive -->
<div v-if="showPasteText && isVisible('paste')">
<Popover v-model:open="pasteOpen">
<PopoverTrigger as-child>
<Button
variant="ghost"
size="sm"
class="h-8 gap-1.5 text-xs"
:aria-label="t('actionbar.paste')"
:title="t('actionbar.paste')"
>
<ClipboardPaste class="size-4" />
<span v-if="visibleSet.get('paste') === 'label'">{{ t('actionbar.paste') }}</span>
</Button>
</PopoverTrigger>
<PopoverContent
:class="isSidebarLayout
? 'w-[min(400px,calc(100vw-4.5rem))] p-0'
: 'w-[min(400px,90vw)] p-0'"
align="start"
:side="isSidebarLayout ? 'right' : 'bottom'"
>
<PasteModal v-if="pasteOpen" @close="pasteOpen = false" />
</PopoverContent>
</Popover>
</div>
</ButtonGroup>
<!-- Right side buttons -->
<ButtonGroup
class="shrink-0"
:class="isSidebarLayout
? 'mt-auto flex-none flex-col'
: 'ml-1 sm:ml-2'"
:orientation="isSidebarLayout ? 'vertical' : 'horizontal'"
>
<VideoDisplayControls
:minimal="minimalDisplayControls"
:text-only-scale="isSidebarLayout"
:scale-mode="props.scaleMode"
:source-size-available="props.sourceSizeAvailable"
:keyboard-visible="props.keyboardVisible"
:fullscreen="props.fullscreen"
@toggle-fullscreen="emit('toggleFullscreen')"
@update:scale-mode="emit('update:scaleMode', $event)"
@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 -->
<div v-if="isVisible('stats')">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" :aria-label="t('actionbar.stats')" :aria-pressed="!!props.statsOpen" @click="emit('toggleStats')">
<BarChart3 class="size-4" />
<span v-if="visibleSet.get('stats') === 'label'">{{ t('actionbar.stats') }}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('actionbar.statsTip') }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<!-- Web Terminal - Adaptive -->
<div v-if="props.showTerminal !== false && isVisible('terminal')">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="sm"
class="h-8 gap-1.5 text-xs"
:disabled="!props.ttydRunning"
:aria-label="t('actionbar.webTerminal')"
:aria-pressed="!!props.terminalOpen"
@click="emit('openTerminal')"
>
<Terminal class="size-4" />
<span v-if="visibleSet.get('terminal') === 'label'">{{ t('actionbar.webTerminal') }}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('extensions.ttyd.title') }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<!-- Overflow Menu - Only show if there are overflowed items -->
<DropdownMenu v-if="hasOverflow || minimalDisplayControls" v-model:open="overflowMenuOpen">
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" class="size-8 p-0" :aria-label="t('actionbar.more')" :title="t('actionbar.more')">
<MoreHorizontal class="size-3.5 sm:size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" :side="isSidebarLayout ? 'right' : 'bottom'" class="w-56 max-h-[70dvh] overflow-y-auto">
<template v-if="minimalDisplayControls">
<DropdownMenuItem @click="openFromOverflow(() => emit('toggleVirtualKeyboard'))">
<Keyboard class="size-4 mr-2" />{{ t('actionbar.keyboard') }}
</DropdownMenuItem>
<DropdownMenuItem
:disabled="!props.sourceSizeAvailable"
@click="emit('update:scaleMode', props.scaleMode === 'actual' ? 'fit' : 'actual')"
>
<Scaling class="size-4 mr-2" />{{ t(props.scaleMode === 'actual' ? 'actionbar.fitSizeAria' : 'actionbar.actualSizeAria') }}
</DropdownMenuItem>
<DropdownMenuSeparator />
</template>
<!-- MSD -->
<DropdownMenuItem v-if="showMsd && !isVisible('msd')" @click="openFromOverflow(() => msdDialogOpen = true)">
<HardDrive class="size-4 mr-2" />
{{ t('actionbar.virtualMedia') }}
</DropdownMenuItem>
<!-- ATX -->
<DropdownMenuItem v-if="showAtx && !isVisible('atx')" @click="openMobileAtx">
<Power class="size-4 mr-2" />
{{ t('actionbar.power') }}
</DropdownMenuItem>
<!-- Paste -->
<DropdownMenuItem v-if="showPasteText && !isVisible('paste')" @click="openMobilePaste">
<ClipboardPaste class="size-4 mr-2" />
{{ t('actionbar.paste') }}
</DropdownMenuItem>
<DropdownMenuSeparator v-if="hasLeftOverflow && hasRightOverflow" />
<!-- Stats -->
<DropdownMenuItem v-if="!isFloatingLayout && showStats && !isVisible('stats')" @click="openFromOverflow(() => emit('toggleStats'))">
<BarChart3 class="size-4 mr-2" />
{{ t('actionbar.stats') }}
</DropdownMenuItem>
<!-- Web Terminal -->
<DropdownMenuItem
v-if="!isFloatingLayout && props.showTerminal !== false && !isVisible('terminal')"
:disabled="!props.ttydRunning"
@click="openFromOverflow(() => emit('openTerminal'))"
>
<Terminal class="size-4 mr-2" />
{{ t('actionbar.webTerminal') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
v-if="isFloatingLayout"
ref="collapseButtonRef"
data-fixed-action
variant="ghost"
size="icon-sm"
:aria-label="t('actionbar.collapseToolbar')"
:title="t('actionbar.collapseToolbar')"
:aria-expanded="true"
@click="setFloatingCollapsed(true)"
>
<ChevronUp class="size-4" />
</Button>
</ButtonGroup>
</div>
</div>
<!-- MSD Dialog -->
<MsdDialog v-if="showMsd" v-model:open="msdDialogOpen" />
<!-- Mobile ATX Sheet — used when ATX is opened from the overflow menu.
A Sheet avoids the Popover anchor-positioning issues on mobile. -->
<Sheet v-if="showAtx" v-model:open="mobileAtxOpen">
<SheetContent
side="bottom"
class="max-h-[90dvh] overflow-y-auto"
@pointer-down-outside="(e) => guardOutside(mobileAtxOpenTime, e)"
@interact-outside="(e) => guardOutside(mobileAtxOpenTime, e)"
>
<SheetHeader class="mb-2">
<SheetTitle>{{ t('actionbar.power') }}</SheetTitle>
</SheetHeader>
<AtxPopover
:atx-enabled="atxEnabled"
@close="mobileAtxOpen = false"
@power-short="emit('powerShort')"
@power-long="emit('powerLong')"
@reset="emit('reset')"
@wol="(mac) => emit('wol', mac)"
/>
</SheetContent>
</Sheet>
<!-- Mobile Paste Sheet — used when Paste is opened from the overflow menu. -->
<Sheet v-if="showPasteText" v-model:open="mobilePasteOpen">
<SheetContent
side="bottom"
class="max-h-[90dvh] overflow-y-auto"
@pointer-down-outside="(e) => guardOutside(mobilePasteOpenTime, e)"
@interact-outside="(e) => guardOutside(mobilePasteOpenTime, e)"
>
<SheetHeader class="mb-2">
<SheetTitle>{{ t('actionbar.paste') }}</SheetTitle>
</SheetHeader>
<PasteModal v-if="mobilePasteOpen" @close="mobilePasteOpen = false" />
</SheetContent>
</Sheet>
<!-- Hidden measurement container: renders each collapsible button in both
icon-only and with-label forms so we can read their real offsetWidth. -->
<div ref="measureRef" aria-hidden="true" class="console-action-measure fixed pointer-events-none" style="visibility: hidden; top: -9999px; left: -9999px; white-space: nowrap;">
<div class="flex items-center gap-0.5 sm:gap-1.5 px-2 sm:px-4 py-1 sm:py-1.5">
<!-- MSD -->
<Button data-measure="msd-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="size-4" /></Button>
<Button data-measure="msd-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="size-4" />{{ t('actionbar.virtualMedia') }}</Button>
<!-- ATX -->
<Button data-measure="atx-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Power class="size-4" /></Button>
<Button data-measure="atx-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Power class="size-4" />{{ t('actionbar.power') }}</Button>
<!-- Paste -->
<Button data-measure="paste-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><ClipboardPaste class="size-4" /></Button>
<Button data-measure="paste-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><ClipboardPaste class="size-4" />{{ t('actionbar.paste') }}</Button>
<!-- Stats -->
<Button data-measure="stats-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><BarChart3 class="size-4" /></Button>
<Button data-measure="stats-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><BarChart3 class="size-4" />{{ t('actionbar.stats') }}</Button>
<!-- Web Terminal -->
<Button data-measure="terminal-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Terminal class="size-4" /></Button>
<Button data-measure="terminal-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Terminal class="size-4" />{{ t('actionbar.webTerminal') }}</Button>
</div>
</div>
</template>
<style scoped>
.console-action-bar--floating {
position: relative;
z-index: 40;
width: 100%;
max-width: 64rem;
border: 1px solid var(--border);
border-radius: 1rem;
background: color-mix(in srgb, var(--background) 96%, transparent);
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
backdrop-filter: blur(14px);
}
.console-action-bar--floating .console-action-bar__inner {
border-radius: inherit;
}
.console-action-bar--sidebar {
position: absolute;
z-index: 40;
top: 2.5rem;
bottom: 1.75rem;
left: 0;
width: 3.5rem;
border-right: 1px solid var(--border);
background: color-mix(in srgb, var(--background) 94%, transparent);
backdrop-filter: blur(12px);
}
.console-action-bar--sidebar :deep([data-slot='button-group']) {
width: 100%;
}
.console-action-bar--sidebar :deep(button) {
height: 36px;
flex-shrink: 0;
width: 100%;
min-width: 0;
gap: 0;
overflow: hidden;
padding-inline: 0.5rem;
border-radius: 10px;
}
.console-action-bar--sidebar :deep(button > span) {
display: none;
}
.console-action-bar--sidebar .console-action-bar__inner {
overflow-y: auto;
gap: 1rem;
scrollbar-width: thin;
}
.console-action-bar--floating .console-action-bar__inner {
padding: 4px 12px;
gap: 8px;
}
.console-action-bar--floating :deep([data-slot='button-group']) {
gap: 4px;
}
.console-action-bar--floating .console-action-bar__inner :deep(button) {
min-width: 36px;
height: 38px;
padding-inline: 10px;
flex-shrink: 0;
border-radius: 10px;
}
@media (max-width: 639px) {
.console-action-bar--floating .console-action-bar__inner {
padding-inline: 4px;
gap: 0;
}
.console-action-bar--floating :deep([data-slot='button-group']) {
gap: 0;
}
.console-action-bar--floating .console-action-bar__inner :deep(button) {
width: 36px;
padding-inline: 0;
}
}
.console-action-bar--floating.console-action-bar--collapsed {
width: auto;
}
@media (pointer: coarse) {
.console-action-bar__expand {
min-height: 44px;
}
.console-action-bar--sidebar :deep(button) {
height: 44px;
}
.console-action-bar--floating .console-action-bar__inner :deep(button) {
min-width: 44px;
height: 44px;
}
.console-action-bar--floating .console-action-bar__inner {
padding-inline: 4px;
}
}
@media (min-width: 640px) {
.console-action-bar--sidebar {
top: 3.5rem;
width: 4rem;
}
}
</style>