mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-04-29 17:36:35 +08:00
feat: 优化控制台页面状态工具栏在不同宽度网页下的自适应能力
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useSystemStore } from '@/stores/system'
|
||||
@@ -47,14 +47,13 @@ import HidConfigPopover from '@/components/HidConfigPopover.vue'
|
||||
import AudioConfigPopover from '@/components/AudioConfigPopover.vue'
|
||||
import MsdDialog from '@/components/MsdDialog.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
const router = useRouter()
|
||||
const systemStore = useSystemStore()
|
||||
|
||||
// Overflow menu state
|
||||
const overflowMenuOpen = ref(false)
|
||||
|
||||
// MSD is only available when HID backend is not CH9329 (CH9329 is serial-only, no USB gadget)
|
||||
const hidBackend = computed(() => (systemStore.hid?.backend ?? '').toLowerCase())
|
||||
const isCh9329Backend = computed(() => hidBackend.value.includes('ch9329'))
|
||||
const showMsd = computed(() => {
|
||||
@@ -89,16 +88,9 @@ const audioPopoverOpen = ref(false)
|
||||
const msdDialogOpen = ref(false)
|
||||
const extensionOpen = ref(false)
|
||||
|
||||
// Mobile Sheet state — opened from the overflow menu.
|
||||
// We use Sheet (bottom drawer) instead of Popover because Popover relies on an
|
||||
// anchor element that is hidden / clipped on small screens, causing it to
|
||||
// immediately close after opening.
|
||||
// Mobile Sheet state
|
||||
const mobileAtxOpen = ref(false)
|
||||
const mobilePasteOpen = ref(false)
|
||||
|
||||
// Timestamps used to suppress spurious "interact-outside" events that arrive
|
||||
// within ~300 ms of the Sheet opening (e.g. delayed synthetic pointer events
|
||||
// from the same touch gesture that opened the overflow menu).
|
||||
const mobileAtxOpenTime = ref(0)
|
||||
const mobilePasteOpenTime = ref(0)
|
||||
|
||||
@@ -110,9 +102,6 @@ const guardOutside = (openTime: number, e: Event) => {
|
||||
}
|
||||
}
|
||||
|
||||
// On mobile, clicking a DropdownMenuItem generates pointer events that can
|
||||
// immediately dismiss any overlay opened in the same tick. Close the dropdown
|
||||
// first, then open the target after a short delay.
|
||||
const openFromOverflow = (setter: () => void) => {
|
||||
overflowMenuOpen.value = false
|
||||
setTimeout(setter, 50)
|
||||
@@ -127,13 +116,154 @@ const openMobilePaste = () => openFromOverflow(() => {
|
||||
mobilePasteOpen.value = true
|
||||
mobilePasteOpenTime.value = Date.now()
|
||||
})
|
||||
|
||||
// ── Adaptive overflow: measure real width, show as many items as fit ──
|
||||
|
||||
const barRef = ref<HTMLElement | null>(null)
|
||||
const measureRef = ref<HTMLElement | null>(null)
|
||||
const barWidth = ref(0)
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
type CollapsibleItem =
|
||||
| 'video' | 'audio' | 'hid'
|
||||
| 'msd' | 'atx' | 'paste'
|
||||
| 'stats' | 'extension' | 'settings'
|
||||
|
||||
interface ItemSpec {
|
||||
id: CollapsibleItem
|
||||
side: 'left' | 'right'
|
||||
}
|
||||
|
||||
const ITEM_SPECS: ItemSpec[] = [
|
||||
{ id: 'video', side: 'left' },
|
||||
{ id: 'audio', side: 'left' },
|
||||
{ id: 'hid', side: 'left' },
|
||||
{ id: 'msd', side: 'left' },
|
||||
{ id: 'atx', side: 'left' },
|
||||
{ id: 'paste', side: 'left' },
|
||||
{ id: 'stats', side: 'right' },
|
||||
{ id: 'extension', side: 'right' },
|
||||
{ id: 'settings', side: 'right' },
|
||||
]
|
||||
|
||||
// Measured widths from DOM (icon-only and with-label)
|
||||
const measuredWidths = ref<Map<CollapsibleItem, { icon: number; label: number }>>(new Map())
|
||||
const measurementReady = ref(false)
|
||||
|
||||
// Measure button widths from hidden measurement container
|
||||
const measureButtonWidths = async () => {
|
||||
await nextTick()
|
||||
if (!measureRef.value) return
|
||||
|
||||
const newWidths = new Map<CollapsibleItem, { icon: number; label: number }>()
|
||||
|
||||
for (const spec of ITEM_SPECS) {
|
||||
const iconEl = measureRef.value.querySelector(`[data-measure="${spec.id}-icon"]`) as HTMLElement
|
||||
const labelEl = measureRef.value.querySelector(`[data-measure="${spec.id}-label"]`) as HTMLElement
|
||||
|
||||
if (iconEl && labelEl) {
|
||||
// Add small buffer (8px) for gaps and rounding errors
|
||||
newWidths.set(spec.id, {
|
||||
icon: Math.ceil(iconEl.offsetWidth) + 8,
|
||||
label: Math.ceil(labelEl.offsetWidth) + 8,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
measuredWidths.value = newWidths
|
||||
measurementReady.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (barRef.value) {
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
const entry = entries[0]
|
||||
if (entry) barWidth.value = entry.contentRect.width
|
||||
})
|
||||
resizeObserver.observe(barRef.value)
|
||||
barWidth.value = barRef.value.clientWidth
|
||||
}
|
||||
|
||||
measureButtonWidths()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
|
||||
// Re-measure when locale changes (different text widths)
|
||||
watch(locale, () => {
|
||||
measurementReady.value = false
|
||||
measureButtonWidths()
|
||||
})
|
||||
|
||||
// Fixed-width budget for always-visible items (right side):
|
||||
// keyboard + fullscreen + potential overflow button + gaps
|
||||
const RIGHT_FIXED_PX = 120
|
||||
|
||||
// First 3 items (video/audio/hid) are always visible
|
||||
const collapsibleItems = computed(() => {
|
||||
const items = ITEM_SPECS.slice(3).filter(item => {
|
||||
if (item.id === 'msd' && !showMsd.value) return false
|
||||
return true
|
||||
})
|
||||
return items
|
||||
})
|
||||
|
||||
// Determine which collapsible items are visible (icon-only or with label)
|
||||
const visibleSet = computed(() => {
|
||||
if (!measurementReady.value) {
|
||||
// Fallback to hardcoded estimates during initial render
|
||||
return new Map<CollapsibleItem, 'icon' | 'label'>()
|
||||
}
|
||||
|
||||
const available = barWidth.value - RIGHT_FIXED_PX
|
||||
|
||||
// Measure actual width of always-visible items (video/audio/hid)
|
||||
let used = 0
|
||||
if (barRef.value) {
|
||||
const leftContainer = barRef.value.querySelector('.left-buttons') as HTMLElement
|
||||
if (leftContainer) {
|
||||
// Get width of first 3 children (video/audio/hid)
|
||||
const children = Array.from(leftContainer.children).slice(0, 3) as HTMLElement[]
|
||||
used = children.reduce((sum, el) => sum + el.offsetWidth, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// If measurement failed, use estimate
|
||||
if (used === 0) used = 330
|
||||
|
||||
const result = new Map<CollapsibleItem, 'icon' | 'label'>()
|
||||
|
||||
for (const item of collapsibleItems.value) {
|
||||
const widths = measuredWidths.value.get(item.id)
|
||||
if (!widths) continue
|
||||
|
||||
if (used + widths.icon <= available) {
|
||||
if (used + widths.label <= available) {
|
||||
result.set(item.id, 'label')
|
||||
used += widths.label
|
||||
} else {
|
||||
result.set(item.id, 'icon')
|
||||
used += widths.icon
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const isVisible = (id: CollapsibleItem) => visibleSet.value.has(id)
|
||||
const hasOverflow = computed(() => {
|
||||
return collapsibleItems.value.some(i => !visibleSet.value.has(i.id))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
|
||||
<div class="flex items-center px-2 sm:px-4 py-1 sm:py-1.5">
|
||||
<!-- Left side buttons — overflow hidden so it never pushes into right side -->
|
||||
<div class="flex items-center gap-0.5 sm:gap-1.5 flex-1 min-w-0 overflow-hidden">
|
||||
<div ref="barRef" class="flex items-center px-2 sm:px-4 py-1 sm:py-1.5">
|
||||
<!-- Left side buttons -->
|
||||
<div class="left-buttons flex items-center gap-0.5 sm:gap-1.5 flex-1 min-w-0 overflow-hidden">
|
||||
<!-- Video Config - Always visible -->
|
||||
<VideoConfigPopover
|
||||
v-model:open="videoPopoverOpen"
|
||||
@@ -141,7 +271,7 @@ const openMobilePaste = () => openFromOverflow(() => {
|
||||
@update:video-mode="emit('update:videoMode', $event)"
|
||||
/>
|
||||
|
||||
<!-- Audio Config - Always visible (xs shows icon only) -->
|
||||
<!-- Audio Config - Always visible -->
|
||||
<AudioConfigPopover v-model:open="audioPopoverOpen" />
|
||||
|
||||
<!-- HID Config - Always visible -->
|
||||
@@ -151,14 +281,14 @@ const openMobilePaste = () => openFromOverflow(() => {
|
||||
@update:mouse-mode="emit('toggleMouseMode')"
|
||||
/>
|
||||
|
||||
<!-- Virtual Media (MSD) - Hidden below md, shown in overflow -->
|
||||
<div v-if="showMsd" class="hidden md:block">
|
||||
<!-- 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" @click="msdDialogOpen = true">
|
||||
<HardDrive class="h-4 w-4" />
|
||||
<span class="hidden lg:inline">{{ t('actionbar.virtualMedia') }}</span>
|
||||
<span v-if="visibleSet.get('msd') === 'label'">{{ t('actionbar.virtualMedia') }}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -168,13 +298,13 @@ const openMobilePaste = () => openFromOverflow(() => {
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<!-- ATX Power Control - Hidden below md; shown as Sheet on mobile -->
|
||||
<div class="hidden md:block">
|
||||
<!-- ATX Power Control - Adaptive -->
|
||||
<div v-if="isVisible('atx')">
|
||||
<Popover v-model:open="atxOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
||||
<Power class="h-4 w-4" />
|
||||
<span class="hidden lg:inline">{{ t('actionbar.power') }}</span>
|
||||
<span v-if="visibleSet.get('atx') === 'label'">{{ t('actionbar.power') }}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[min(280px,90vw)] p-0" align="start">
|
||||
@@ -189,13 +319,13 @@ const openMobilePaste = () => openFromOverflow(() => {
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<!-- Paste Text - Hidden below lg; shown as Sheet on mobile -->
|
||||
<div class="hidden lg:block">
|
||||
<!-- Paste Text - Adaptive -->
|
||||
<div v-if="isVisible('paste')">
|
||||
<Popover v-model:open="pasteOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
||||
<ClipboardPaste class="h-4 w-4" />
|
||||
<span class="hidden xl:inline">{{ t('actionbar.paste') }}</span>
|
||||
<span v-if="visibleSet.get('paste') === 'label'">{{ t('actionbar.paste') }}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[min(400px,90vw)] p-0" align="start">
|
||||
@@ -205,15 +335,32 @@ const openMobilePaste = () => openFromOverflow(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side buttons — always shrink-0, never compressed -->
|
||||
<!-- Right side buttons -->
|
||||
<div class="flex items-center gap-0.5 sm:gap-1.5 shrink-0 ml-1 sm:ml-2">
|
||||
<!-- Extension Menu - Hidden below xl -->
|
||||
<div class="hidden xl:block">
|
||||
<!-- 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" @click="emit('toggleStats')">
|
||||
<BarChart3 class="h-4 w-4" />
|
||||
<span v-if="visibleSet.get('stats') === 'label'">{{ t('actionbar.stats') }}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ t('actionbar.statsTip') }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<!-- Extension Menu - Adaptive -->
|
||||
<div v-if="isVisible('extension')">
|
||||
<Popover v-model:open="extensionOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
||||
<Cable class="h-4 w-4" />
|
||||
{{ t('actionbar.extension') }}
|
||||
<span v-if="visibleSet.get('extension') === 'label'">{{ t('actionbar.extension') }}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-48 p-1" align="start">
|
||||
@@ -233,14 +380,14 @@ const openMobilePaste = () => openFromOverflow(() => {
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<!-- Settings - Hidden below xl -->
|
||||
<div class="hidden xl:block">
|
||||
<!-- Settings - Adaptive -->
|
||||
<div v-if="isVisible('settings')">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="router.push('/settings')">
|
||||
<Settings class="h-4 w-4" />
|
||||
{{ t('actionbar.settings') }}
|
||||
<span v-if="visibleSet.get('settings') === 'label'">{{ t('actionbar.settings') }}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -250,26 +397,9 @@ const openMobilePaste = () => openFromOverflow(() => {
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<!-- Connection Stats - Hidden below md -->
|
||||
<div class="hidden md:block">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="emit('toggleStats')">
|
||||
<BarChart3 class="h-4 w-4" />
|
||||
<span class="hidden xl:inline">{{ t('actionbar.stats') }}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ t('actionbar.statsTip') }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div v-if="isVisible('stats') || isVisible('extension') || isVisible('settings')" class="h-5 w-px bg-slate-200 dark:bg-slate-700" />
|
||||
|
||||
<div class="h-5 w-px bg-slate-200 dark:bg-slate-700 hidden md:block" />
|
||||
|
||||
<!-- Virtual Keyboard - Always visible (important for mobile) -->
|
||||
<!-- Virtual Keyboard - Always visible -->
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
@@ -309,43 +439,43 @@ const openMobilePaste = () => openFromOverflow(() => {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<!-- Overflow Menu - Shows hidden items on smaller screens -->
|
||||
<DropdownMenu v-model:open="overflowMenuOpen">
|
||||
<!-- Overflow Menu - Only show if there are overflowed items -->
|
||||
<DropdownMenu v-if="hasOverflow" v-model:open="overflowMenuOpen">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-7 w-7 sm:h-8 sm:w-8 p-0 xl:hidden">
|
||||
<Button variant="ghost" size="sm" class="h-7 w-7 sm:h-8 sm:w-8 p-0">
|
||||
<MoreHorizontal class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-48">
|
||||
<!-- MSD - Below md, hidden when CH9329 backend -->
|
||||
<DropdownMenuItem v-if="showMsd" class="md:hidden" @click="openFromOverflow(() => msdDialogOpen = true)">
|
||||
<!-- MSD -->
|
||||
<DropdownMenuItem v-if="showMsd && !isVisible('msd')" @click="openFromOverflow(() => msdDialogOpen = true)">
|
||||
<HardDrive class="h-4 w-4 mr-2" />
|
||||
{{ t('actionbar.virtualMedia') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<!-- ATX - Opens a Sheet on mobile (below md) -->
|
||||
<DropdownMenuItem class="md:hidden" @click="openMobileAtx">
|
||||
<!-- ATX -->
|
||||
<DropdownMenuItem v-if="!isVisible('atx')" @click="openMobileAtx">
|
||||
<Power class="h-4 w-4 mr-2" />
|
||||
{{ t('actionbar.power') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<!-- Paste - Opens a Sheet on mobile (below lg) -->
|
||||
<DropdownMenuItem class="lg:hidden" @click="openMobilePaste">
|
||||
<!-- Paste -->
|
||||
<DropdownMenuItem v-if="!isVisible('paste')" @click="openMobilePaste">
|
||||
<ClipboardPaste class="h-4 w-4 mr-2" />
|
||||
{{ t('actionbar.paste') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSeparator v-if="(!isVisible('msd') || !isVisible('atx') || !isVisible('paste')) && (!isVisible('stats') || !isVisible('extension') || !isVisible('settings'))" />
|
||||
|
||||
<!-- Stats - Below md -->
|
||||
<DropdownMenuItem class="md:hidden" @click="openFromOverflow(() => emit('toggleStats'))">
|
||||
<!-- Stats -->
|
||||
<DropdownMenuItem v-if="!isVisible('stats')" @click="openFromOverflow(() => emit('toggleStats'))">
|
||||
<BarChart3 class="h-4 w-4 mr-2" />
|
||||
{{ t('actionbar.stats') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<!-- Extension - Below xl -->
|
||||
<!-- Extension -->
|
||||
<DropdownMenuItem
|
||||
class="xl:hidden"
|
||||
v-if="!isVisible('extension')"
|
||||
:disabled="!props.ttydRunning"
|
||||
@click="openFromOverflow(() => emit('openTerminal'))"
|
||||
>
|
||||
@@ -353,8 +483,8 @@ const openMobilePaste = () => openFromOverflow(() => {
|
||||
{{ t('extensions.ttyd.title') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<!-- Settings - Below xl -->
|
||||
<DropdownMenuItem class="xl:hidden" @click="openFromOverflow(() => router.push('/settings'))">
|
||||
<!-- Settings -->
|
||||
<DropdownMenuItem v-if="!isVisible('settings')" @click="openFromOverflow(() => router.push('/settings'))">
|
||||
<Settings class="h-4 w-4 mr-2" />
|
||||
{{ t('actionbar.settings') }}
|
||||
</DropdownMenuItem>
|
||||
@@ -403,4 +533,36 @@ const openMobilePaste = () => openFromOverflow(() => {
|
||||
<PasteModal @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="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="h-4 w-4" /></Button>
|
||||
<Button data-measure="msd-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="h-4 w-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="h-4 w-4" /></Button>
|
||||
<Button data-measure="atx-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Power class="h-4 w-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="h-4 w-4" /></Button>
|
||||
<Button data-measure="paste-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><ClipboardPaste class="h-4 w-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="h-4 w-4" /></Button>
|
||||
<Button data-measure="stats-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><BarChart3 class="h-4 w-4" />{{ t('actionbar.stats') }}</Button>
|
||||
<!-- Extension -->
|
||||
<Button data-measure="extension-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Cable class="h-4 w-4" /></Button>
|
||||
<Button data-measure="extension-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Cable class="h-4 w-4" />{{ t('actionbar.extension') }}</Button>
|
||||
<!-- Settings -->
|
||||
<Button data-measure="settings-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Settings class="h-4 w-4" /></Button>
|
||||
<Button data-measure="settings-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Settings class="h-4 w-4" />{{ t('actionbar.settings') }}</Button>
|
||||
<!-- Always-visible items (for measuring their actual width) -->
|
||||
<Button data-measure="video-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="h-4 w-4" /></Button>
|
||||
<Button data-measure="video-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="h-4 w-4" /></Button>
|
||||
<Button data-measure="audio-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="h-4 w-4" /></Button>
|
||||
<Button data-measure="audio-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="h-4 w-4" /></Button>
|
||||
<Button data-measure="hid-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="h-4 w-4" /></Button>
|
||||
<Button data-measure="hid-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -137,6 +137,9 @@ const mousePosition = ref({ x: 0, y: 0 })
|
||||
const lastMousePosition = ref({ x: 0, y: 0 }) // Track last position for relative mode
|
||||
const isPointerLocked = ref(false) // Track pointer lock state
|
||||
|
||||
/** Local overlay crosshair position (px, relative to video container); HID uses mousePosition separately */
|
||||
const localCrosshairPos = ref<{ x: number; y: number } | null>(null)
|
||||
|
||||
// Mouse move throttling (60 Hz = ~16.67ms interval)
|
||||
const DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS = 16
|
||||
let mouseMoveSendIntervalMs = DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS
|
||||
@@ -1982,7 +1985,43 @@ function getAbsoluteMousePosition(e: MouseEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
function updateLocalCrosshairFromEvent(e: MouseEvent) {
|
||||
if (!cursorVisible.value) {
|
||||
localCrosshairPos.value = null
|
||||
return
|
||||
}
|
||||
const container = videoContainerRef.value
|
||||
if (!container) return
|
||||
|
||||
const rect = container.getBoundingClientRect()
|
||||
if (rect.width <= 0 || rect.height <= 0) return
|
||||
|
||||
if (mouseMode.value === 'relative' && isPointerLocked.value) {
|
||||
const cur = localCrosshairPos.value
|
||||
const nx = cur ? cur.x + e.movementX : rect.width / 2
|
||||
const ny = cur ? cur.y + e.movementY : rect.height / 2
|
||||
localCrosshairPos.value = {
|
||||
x: Math.max(0, Math.min(rect.width, nx)),
|
||||
y: Math.max(0, Math.min(rect.height, ny)),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
localCrosshairPos.value = {
|
||||
x: Math.max(0, Math.min(rect.width, e.clientX - rect.left)),
|
||||
y: Math.max(0, Math.min(rect.height, e.clientY - rect.top)),
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseLeaveVideo() {
|
||||
if (!isPointerLocked.value) {
|
||||
localCrosshairPos.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseMove(e: MouseEvent) {
|
||||
updateLocalCrosshairFromEvent(e)
|
||||
|
||||
const videoElement = getActiveVideoElement()
|
||||
if (!videoElement) return
|
||||
|
||||
@@ -2192,6 +2231,12 @@ function handlePointerLockChange() {
|
||||
if (isPointerLocked.value) {
|
||||
// Reset mouse position display when locked
|
||||
mousePosition.value = { x: 0, y: 0 }
|
||||
if (cursorVisible.value && container) {
|
||||
const r = container.getBoundingClientRect()
|
||||
if (r.width > 0 && r.height > 0) {
|
||||
localCrosshairPos.value = { x: r.width / 2, y: r.height / 2 }
|
||||
}
|
||||
}
|
||||
toast.info(t('console.pointerLocked'), {
|
||||
description: t('console.pointerLockedDesc'),
|
||||
duration: 3000,
|
||||
@@ -2222,6 +2267,9 @@ function handleBlur() {
|
||||
function handleCursorVisibilityChange(e: Event) {
|
||||
const customEvent = e as CustomEvent<{ visible: boolean }>
|
||||
cursorVisible.value = customEvent.detail.visible
|
||||
if (!cursorVisible.value) {
|
||||
localCrosshairPos.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function clampMouseMoveSendIntervalMs(ms: number): number {
|
||||
@@ -2654,10 +2702,10 @@ onUnmounted(() => {
|
||||
}"
|
||||
:class="{
|
||||
'opacity-60': videoLoading || videoError,
|
||||
'cursor-crosshair': cursorVisible,
|
||||
'cursor-none': !cursorVisible
|
||||
'cursor-none': true,
|
||||
}"
|
||||
tabindex="0"
|
||||
@mouseleave="handleMouseLeaveVideo"
|
||||
@mousemove="handleMouseMove"
|
||||
@mousedown="handleMouseDown"
|
||||
@mouseup="handleMouseUp"
|
||||
@@ -2693,6 +2741,59 @@ onUnmounted(() => {
|
||||
alt=""
|
||||
/>
|
||||
|
||||
<!-- Stroked crosshair (native cursor cannot be outlined) -->
|
||||
<div
|
||||
v-if="cursorVisible && localCrosshairPos"
|
||||
class="pointer-events-none absolute z-[15] -translate-x-1/2 -translate-y-1/2"
|
||||
:style="{
|
||||
left: `${localCrosshairPos.x}px`,
|
||||
top: `${localCrosshairPos.y}px`,
|
||||
}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
width="23"
|
||||
height="23"
|
||||
viewBox="-11.5 -11.5 23 23"
|
||||
class="overflow-visible"
|
||||
>
|
||||
<g stroke-linecap="square">
|
||||
<line
|
||||
x1="0"
|
||||
y1="-10"
|
||||
x2="0"
|
||||
y2="10"
|
||||
stroke="rgba(0,0,0,0.88)"
|
||||
stroke-width="3"
|
||||
/>
|
||||
<line
|
||||
x1="-10"
|
||||
y1="0"
|
||||
x2="10"
|
||||
y2="0"
|
||||
stroke="rgba(0,0,0,0.88)"
|
||||
stroke-width="3"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
y1="-10"
|
||||
x2="0"
|
||||
y2="10"
|
||||
stroke="rgba(255,255,255,0.95)"
|
||||
stroke-width="1"
|
||||
/>
|
||||
<line
|
||||
x1="-10"
|
||||
y1="0"
|
||||
x2="10"
|
||||
y2="0"
|
||||
stroke="rgba(255,255,255,0.95)"
|
||||
stroke-width="1"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Loading Overlay with smooth transition and visual feedback -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user