mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-01-28 16:41:52 +08:00
fix(web): 统一 API 请求语义并修复鼠标移动发送间隔
- 新增统一 request:同时处理 HTTP 非 2xx 与 success=false,并用 i18n toast 提示错误 - api/index.ts 与 api/config.ts 统一使用同一 request,避免错误处理不一致 - "发送间隔" 仅控制鼠标移动事件频率,WebRTC/WS 行为一致,不影响点击/滚轮
This commit is contained in:
@@ -31,28 +31,7 @@ import type {
|
||||
TtydStatus,
|
||||
} from '@/types/generated'
|
||||
|
||||
const API_BASE = '/api'
|
||||
|
||||
/**
|
||||
* 通用请求函数
|
||||
*/
|
||||
async function request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'Request failed' }))
|
||||
throw new Error(error.message || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
import { request } from './request'
|
||||
|
||||
// ===== 全局配置 API =====
|
||||
export const configApi = {
|
||||
|
||||
@@ -1,97 +1,9 @@
|
||||
// API client for One-KVM backend
|
||||
|
||||
import { toast } from 'vue-sonner'
|
||||
import { request, ApiError } from './request'
|
||||
|
||||
const API_BASE = '/api'
|
||||
|
||||
// Toast debounce mechanism - prevent toast spam (5 seconds)
|
||||
const toastDebounceMap = new Map<string, number>()
|
||||
const TOAST_DEBOUNCE_TIME = 5000
|
||||
|
||||
function shouldShowToast(key: string): boolean {
|
||||
const now = Date.now()
|
||||
const lastToastTime = toastDebounceMap.get(key)
|
||||
|
||||
if (!lastToastTime || now - lastToastTime >= TOAST_DEBOUNCE_TIME) {
|
||||
toastDebounceMap.set(key, now)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
class ApiError extends Error {
|
||||
status: number
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const url = `${API_BASE}${endpoint}`
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
// Parse response body (all responses are 200 OK with success field)
|
||||
const data = await response.json().catch(() => ({
|
||||
success: false,
|
||||
message: 'Failed to parse response'
|
||||
}))
|
||||
|
||||
// Check success field - all errors are indicated by success=false
|
||||
if (data && typeof data.success === 'boolean' && !data.success) {
|
||||
const errorMessage = data.message || 'Operation failed'
|
||||
const apiError = new ApiError(response.status, errorMessage)
|
||||
|
||||
console.info(`[API] ${endpoint} failed:`, errorMessage)
|
||||
|
||||
// Show toast notification to user (with debounce)
|
||||
if (shouldShowToast(`error_${endpoint}`)) {
|
||||
toast.error('Operation Failed', {
|
||||
description: errorMessage,
|
||||
duration: 4000,
|
||||
})
|
||||
}
|
||||
|
||||
throw apiError
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
// Network errors or JSON parsing errors
|
||||
if (error instanceof ApiError) {
|
||||
throw error // Already handled above
|
||||
}
|
||||
|
||||
// Network connectivity issues
|
||||
console.info(`[API] Network error for ${endpoint}:`, error)
|
||||
|
||||
// Show toast for network errors (with debounce)
|
||||
if (shouldShowToast('network_error')) {
|
||||
toast.error('Network Error', {
|
||||
description: 'Unable to connect to server. Please check your connection.',
|
||||
duration: 4000,
|
||||
})
|
||||
}
|
||||
|
||||
throw new ApiError(0, 'Network error')
|
||||
}
|
||||
}
|
||||
|
||||
// Auth API
|
||||
export const authApi = {
|
||||
login: (username: string, password: string) =>
|
||||
|
||||
133
web/src/api/request.ts
Normal file
133
web/src/api/request.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { toast } from 'vue-sonner'
|
||||
import i18n from '@/i18n'
|
||||
|
||||
const API_BASE = '/api'
|
||||
|
||||
// Toast debounce mechanism - prevent toast spam (5 seconds)
|
||||
const toastDebounceMap = new Map<string, number>()
|
||||
const TOAST_DEBOUNCE_TIME = 5000
|
||||
|
||||
function shouldShowToast(key: string): boolean {
|
||||
const now = Date.now()
|
||||
const lastToastTime = toastDebounceMap.get(key)
|
||||
|
||||
if (!lastToastTime || now - lastToastTime >= TOAST_DEBOUNCE_TIME) {
|
||||
toastDebounceMap.set(key, now)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function t(key: string, params?: Record<string, unknown>): string {
|
||||
return String(i18n.global.t(key, params as any))
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiRequestConfig {
|
||||
/**
|
||||
* Enable toast notifications on errors.
|
||||
* Defaults to true to match existing behavior in api/index.ts.
|
||||
*/
|
||||
toastOnError?: boolean
|
||||
/**
|
||||
* Toast debounce key. Defaults to `error_${endpoint}`.
|
||||
*/
|
||||
toastKey?: string
|
||||
}
|
||||
|
||||
function getToastKey(endpoint: string, config?: ApiRequestConfig): string {
|
||||
return config?.toastKey ?? `error_${endpoint}`
|
||||
}
|
||||
|
||||
function getErrorMessage(data: unknown, fallback: string): string {
|
||||
if (data && typeof data === 'object') {
|
||||
const message = (data as any).message
|
||||
if (typeof message === 'string' && message.trim()) return message
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
export async function request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {},
|
||||
config: ApiRequestConfig = {}
|
||||
): Promise<T> {
|
||||
const url = `${API_BASE}${endpoint}`
|
||||
const toastOnError = config.toastOnError !== false
|
||||
const toastKey = getToastKey(endpoint, config)
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
const data = await response.json().catch(() => null)
|
||||
|
||||
// Handle HTTP errors (in case backend returns non-2xx)
|
||||
if (!response.ok) {
|
||||
const message = getErrorMessage(data, `HTTP ${response.status}`)
|
||||
if (toastOnError && shouldShowToast(toastKey)) {
|
||||
toast.error(t('api.operationFailed'), {
|
||||
description: message,
|
||||
duration: 4000,
|
||||
})
|
||||
}
|
||||
throw new ApiError(response.status, message)
|
||||
}
|
||||
|
||||
// Handle backend "success=false" convention (even when HTTP is 200)
|
||||
if (data && typeof (data as any).success === 'boolean' && !(data as any).success) {
|
||||
const message = getErrorMessage(data, t('api.operationFailedDesc'))
|
||||
|
||||
if (toastOnError && shouldShowToast(toastKey)) {
|
||||
toast.error(t('api.operationFailed'), {
|
||||
description: message,
|
||||
duration: 4000,
|
||||
})
|
||||
}
|
||||
|
||||
throw new ApiError(response.status, message)
|
||||
}
|
||||
|
||||
// If response body isn't JSON (or empty), treat as failure for callers expecting JSON.
|
||||
if (data === null) {
|
||||
const message = t('api.parseResponseFailed')
|
||||
if (toastOnError && shouldShowToast(toastKey)) {
|
||||
toast.error(t('api.operationFailed'), {
|
||||
description: message,
|
||||
duration: 4000,
|
||||
})
|
||||
}
|
||||
throw new ApiError(response.status, message)
|
||||
}
|
||||
|
||||
return data as T
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) throw error
|
||||
|
||||
if (toastOnError && shouldShowToast('network_error')) {
|
||||
toast.error(t('api.networkError'), {
|
||||
description: t('api.networkErrorDesc'),
|
||||
duration: 4000,
|
||||
})
|
||||
}
|
||||
|
||||
throw new ApiError(0, t('api.networkError'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import { MousePointer, Move, Loader2, RefreshCw } from 'lucide-vue-next'
|
||||
import HelpTooltip from '@/components/HelpTooltip.vue'
|
||||
import { configApi } from '@/api'
|
||||
import { useSystemStore } from '@/stores/system'
|
||||
import { setMouseThrottle } from '@/composables/useHidWebSocket'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
@@ -39,9 +38,24 @@ const emit = defineEmits<{
|
||||
const { t } = useI18n()
|
||||
const systemStore = useSystemStore()
|
||||
|
||||
const DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS = 16
|
||||
|
||||
function clampMouseMoveSendIntervalMs(ms: number): number {
|
||||
if (!Number.isFinite(ms)) return DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS
|
||||
return Math.max(0, Math.min(1000, Math.floor(ms)))
|
||||
}
|
||||
|
||||
function loadMouseMoveSendIntervalFromStorage(): number {
|
||||
const raw = localStorage.getItem('hidMouseThrottle')
|
||||
const parsed = raw === null ? NaN : Number(raw)
|
||||
return clampMouseMoveSendIntervalMs(
|
||||
Number.isFinite(parsed) ? parsed : DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS
|
||||
)
|
||||
}
|
||||
|
||||
// Mouse Settings (real-time)
|
||||
const mouseThrottle = ref<number>(
|
||||
Number(localStorage.getItem('hidMouseThrottle')) || 0
|
||||
loadMouseMoveSendIntervalFromStorage()
|
||||
)
|
||||
const showCursor = ref<boolean>(
|
||||
localStorage.getItem('hidShowCursor') !== 'false' // default true
|
||||
@@ -105,9 +119,7 @@ async function loadDevices() {
|
||||
// Initialize from current config
|
||||
function initializeFromCurrent() {
|
||||
// Re-sync real-time settings from localStorage
|
||||
const storedThrottle = Number(localStorage.getItem('hidMouseThrottle')) || 0
|
||||
mouseThrottle.value = storedThrottle
|
||||
setMouseThrottle(storedThrottle)
|
||||
mouseThrottle.value = loadMouseMoveSendIntervalFromStorage()
|
||||
|
||||
const storedCursor = localStorage.getItem('hidShowCursor') !== 'false'
|
||||
showCursor.value = storedCursor
|
||||
@@ -138,11 +150,14 @@ function toggleMouseMode() {
|
||||
// Update mouse throttle (real-time)
|
||||
function handleThrottleChange(value: number[] | undefined) {
|
||||
if (!value || value.length === 0 || value[0] === undefined) return
|
||||
const throttleValue = value[0]
|
||||
const throttleValue = clampMouseMoveSendIntervalMs(value[0])
|
||||
mouseThrottle.value = throttleValue
|
||||
setMouseThrottle(throttleValue)
|
||||
// Save to localStorage
|
||||
localStorage.setItem('hidMouseThrottle', String(throttleValue))
|
||||
// Notify ConsoleView (storage event doesn't fire in same tab)
|
||||
window.dispatchEvent(new CustomEvent('hidMouseSendIntervalChanged', {
|
||||
detail: { intervalMs: throttleValue },
|
||||
}))
|
||||
}
|
||||
|
||||
// Handle backend change
|
||||
@@ -273,7 +288,7 @@ watch(() => props.open, (isOpen) => {
|
||||
@update:model-value="handleThrottleChange"
|
||||
:min="0"
|
||||
:max="1000"
|
||||
:step="10"
|
||||
:step="1"
|
||||
class="py-2"
|
||||
/>
|
||||
<div class="flex justify-between text-xs text-muted-foreground">
|
||||
|
||||
@@ -25,12 +25,6 @@ const networkErrorMessage = ref<string | null>(null)
|
||||
let reconnectTimeout: number | null = null
|
||||
const hidUnavailable = ref(false) // Track if HID is unavailable to prevent unnecessary reconnects
|
||||
|
||||
// Mouse throttle mechanism (10ms = 100Hz for smoother cursor movement)
|
||||
let mouseThrottleMs = 10
|
||||
let lastMouseSendTime = 0
|
||||
let pendingMouseEvent: HidMouseEvent | null = null
|
||||
let throttleTimer: number | null = null
|
||||
|
||||
// Connection promise to avoid race conditions
|
||||
let connectionPromise: Promise<boolean> | null = null
|
||||
let connectionResolved = false
|
||||
@@ -160,11 +154,6 @@ function sendKeyboard(event: HidKeyboardEvent): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
// Set mouse throttle interval (0-1000ms, 0 = no throttle)
|
||||
export function setMouseThrottle(ms: number) {
|
||||
mouseThrottleMs = Math.max(0, Math.min(1000, ms))
|
||||
}
|
||||
|
||||
// Internal function to actually send mouse event
|
||||
function _sendMouseInternal(event: HidMouseEvent): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -182,41 +171,8 @@ function _sendMouseInternal(event: HidMouseEvent): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
// Throttled mouse event sender
|
||||
// Note: Returns immediately for throttled events to avoid Promise memory leak.
|
||||
// When an event is throttled, we store it as pending and resolve immediately.
|
||||
// A timer will send the pending event later, but that's fire-and-forget.
|
||||
function sendMouse(event: HidMouseEvent): Promise<void> {
|
||||
const now = Date.now()
|
||||
const elapsed = now - lastMouseSendTime
|
||||
|
||||
if (elapsed >= mouseThrottleMs) {
|
||||
// Send immediately if enough time has passed
|
||||
lastMouseSendTime = now
|
||||
return _sendMouseInternal(event)
|
||||
} else {
|
||||
// Throttle: store event for later, resolve immediately to avoid Promise leak
|
||||
pendingMouseEvent = event
|
||||
|
||||
// Clear existing timer and set a new one
|
||||
if (throttleTimer !== null) {
|
||||
clearTimeout(throttleTimer)
|
||||
}
|
||||
|
||||
// Schedule send after remaining throttle time (fire-and-forget)
|
||||
throttleTimer = window.setTimeout(() => {
|
||||
if (pendingMouseEvent) {
|
||||
lastMouseSendTime = Date.now()
|
||||
_sendMouseInternal(pendingMouseEvent).catch(() => {
|
||||
// Silently ignore errors for throttled events
|
||||
})
|
||||
pendingMouseEvent = null
|
||||
}
|
||||
}, mouseThrottleMs - elapsed)
|
||||
|
||||
// Resolve immediately - the event is queued, caller doesn't need to wait
|
||||
return Promise.resolve()
|
||||
}
|
||||
return _sendMouseInternal(event)
|
||||
}
|
||||
|
||||
// Send consumer control event (multimedia keys)
|
||||
|
||||
@@ -41,6 +41,13 @@ export default {
|
||||
toggleTheme: 'Toggle theme',
|
||||
toggleLanguage: 'Toggle language',
|
||||
},
|
||||
api: {
|
||||
operationFailed: 'Operation Failed',
|
||||
operationFailedDesc: 'Operation failed',
|
||||
parseResponseFailed: 'Failed to parse response',
|
||||
networkError: 'Network Error',
|
||||
networkErrorDesc: 'Unable to connect to server. Please check your connection.',
|
||||
},
|
||||
nav: {
|
||||
console: 'Console',
|
||||
msd: 'Virtual Media',
|
||||
|
||||
@@ -41,6 +41,13 @@ export default {
|
||||
toggleTheme: '切换主题',
|
||||
toggleLanguage: '切换语言',
|
||||
},
|
||||
api: {
|
||||
operationFailed: '操作失败',
|
||||
operationFailedDesc: '操作失败',
|
||||
parseResponseFailed: '解析响应失败',
|
||||
networkError: '网络错误',
|
||||
networkErrorDesc: '无法连接到服务器,请检查网络连接。',
|
||||
},
|
||||
nav: {
|
||||
console: '控制台',
|
||||
msd: '虚拟媒体',
|
||||
|
||||
@@ -124,8 +124,10 @@ const lastMousePosition = ref({ x: 0, y: 0 }) // Track last position for relativ
|
||||
const isPointerLocked = ref(false) // Track pointer lock state
|
||||
|
||||
// Mouse move throttling (60 Hz = ~16.67ms interval)
|
||||
const MOUSE_SEND_INTERVAL_MS = 16
|
||||
let mouseSendTimer: ReturnType<typeof setInterval> | null = null
|
||||
const DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS = 16
|
||||
let mouseMoveSendIntervalMs = DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS
|
||||
let mouseFlushTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let lastMouseMoveSendTime = 0
|
||||
let pendingMouseMove: { type: 'move' | 'move_abs'; x: number; y: number } | null = null
|
||||
let accumulatedDelta = { x: 0, y: 0 } // For relative mode: accumulate deltas between sends
|
||||
|
||||
@@ -1472,7 +1474,7 @@ function handleMouseMove(e: MouseEvent) {
|
||||
mousePosition.value = { x, y }
|
||||
// Queue for throttled sending (absolute mode: just update pending position)
|
||||
pendingMouseMove = { type: 'move_abs', x, y }
|
||||
ensureMouseSendTimer()
|
||||
requestMouseMoveFlush()
|
||||
} else {
|
||||
// Relative mode: use movementX/Y when pointer is locked
|
||||
if (isPointerLocked.value) {
|
||||
@@ -1484,7 +1486,7 @@ function handleMouseMove(e: MouseEvent) {
|
||||
// Accumulate deltas for throttled sending
|
||||
accumulatedDelta.x += dx
|
||||
accumulatedDelta.y += dy
|
||||
ensureMouseSendTimer()
|
||||
requestMouseMoveFlush()
|
||||
}
|
||||
|
||||
// Update display position (accumulated delta for display only)
|
||||
@@ -1496,48 +1498,80 @@ function handleMouseMove(e: MouseEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// Start the mouse send timer if not already running
|
||||
function ensureMouseSendTimer() {
|
||||
if (mouseSendTimer !== null) return
|
||||
|
||||
// Send immediately on first event, then throttle
|
||||
flushMouseMove()
|
||||
|
||||
mouseSendTimer = setInterval(() => {
|
||||
if (!flushMouseMove()) {
|
||||
// No pending data, stop the timer
|
||||
if (mouseSendTimer !== null) {
|
||||
clearInterval(mouseSendTimer)
|
||||
mouseSendTimer = null
|
||||
}
|
||||
}
|
||||
}, MOUSE_SEND_INTERVAL_MS)
|
||||
function hasPendingMouseMove(): boolean {
|
||||
if (mouseMode.value === 'absolute') return pendingMouseMove !== null
|
||||
return accumulatedDelta.x !== 0 || accumulatedDelta.y !== 0
|
||||
}
|
||||
|
||||
// Flush pending mouse move data, returns true if data was sent
|
||||
function flushMouseMove(): boolean {
|
||||
function flushMouseMoveOnce(): boolean {
|
||||
if (mouseMode.value === 'absolute') {
|
||||
if (pendingMouseMove) {
|
||||
sendMouseEvent(pendingMouseMove)
|
||||
pendingMouseMove = null
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
// Relative mode: send accumulated delta
|
||||
if (accumulatedDelta.x !== 0 || accumulatedDelta.y !== 0) {
|
||||
// Clamp to i8 range (-127 to 127)
|
||||
const clampedDx = Math.max(-127, Math.min(127, accumulatedDelta.x))
|
||||
const clampedDy = Math.max(-127, Math.min(127, accumulatedDelta.y))
|
||||
|
||||
sendMouseEvent({ type: 'move', x: clampedDx, y: clampedDy })
|
||||
|
||||
// Subtract sent amount (keep remainder for next send if clamped)
|
||||
accumulatedDelta.x -= clampedDx
|
||||
accumulatedDelta.y -= clampedDy
|
||||
return true
|
||||
}
|
||||
if (!pendingMouseMove) return false
|
||||
sendMouseEvent(pendingMouseMove)
|
||||
pendingMouseMove = null
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
if (accumulatedDelta.x === 0 && accumulatedDelta.y === 0) return false
|
||||
|
||||
// Clamp to i8 range (-127 to 127)
|
||||
const clampedDx = Math.max(-127, Math.min(127, accumulatedDelta.x))
|
||||
const clampedDy = Math.max(-127, Math.min(127, accumulatedDelta.y))
|
||||
|
||||
sendMouseEvent({ type: 'move', x: clampedDx, y: clampedDy })
|
||||
|
||||
// Subtract sent amount (keep remainder for next send if clamped)
|
||||
accumulatedDelta.x -= clampedDx
|
||||
accumulatedDelta.y -= clampedDy
|
||||
return true
|
||||
}
|
||||
|
||||
function scheduleMouseMoveFlush() {
|
||||
if (mouseFlushTimer !== null) return
|
||||
|
||||
const interval = mouseMoveSendIntervalMs
|
||||
const now = Date.now()
|
||||
const elapsed = now - lastMouseMoveSendTime
|
||||
const delay = interval <= 0 ? 0 : Math.max(0, interval - elapsed)
|
||||
|
||||
mouseFlushTimer = setTimeout(() => {
|
||||
mouseFlushTimer = null
|
||||
|
||||
const burstLimit = mouseMoveSendIntervalMs <= 0 ? 8 : 1
|
||||
let sent = false
|
||||
for (let i = 0; i < burstLimit; i++) {
|
||||
if (!flushMouseMoveOnce()) break
|
||||
sent = true
|
||||
if (!hasPendingMouseMove()) break
|
||||
}
|
||||
if (sent) lastMouseMoveSendTime = Date.now()
|
||||
|
||||
if (hasPendingMouseMove()) {
|
||||
scheduleMouseMoveFlush()
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
|
||||
function requestMouseMoveFlush() {
|
||||
const interval = mouseMoveSendIntervalMs
|
||||
const now = Date.now()
|
||||
|
||||
if (interval <= 0 || now - lastMouseMoveSendTime >= interval) {
|
||||
const burstLimit = interval <= 0 ? 8 : 1
|
||||
let sent = false
|
||||
for (let i = 0; i < burstLimit; i++) {
|
||||
if (!flushMouseMoveOnce()) break
|
||||
sent = true
|
||||
if (!hasPendingMouseMove()) break
|
||||
}
|
||||
if (sent) lastMouseMoveSendTime = Date.now()
|
||||
|
||||
if (hasPendingMouseMove()) {
|
||||
scheduleMouseMoveFlush()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
scheduleMouseMoveFlush()
|
||||
}
|
||||
|
||||
// Track pressed mouse button for window-level mouseup handling
|
||||
@@ -1656,6 +1690,41 @@ function handleCursorVisibilityChange(e: Event) {
|
||||
cursorVisible.value = customEvent.detail.visible
|
||||
}
|
||||
|
||||
function clampMouseMoveSendIntervalMs(ms: number): number {
|
||||
if (!Number.isFinite(ms)) return DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS
|
||||
return Math.max(0, Math.min(1000, Math.floor(ms)))
|
||||
}
|
||||
|
||||
function loadMouseMoveSendIntervalFromStorage(): number {
|
||||
const raw = localStorage.getItem('hidMouseThrottle')
|
||||
const parsed = raw === null ? NaN : Number(raw)
|
||||
return clampMouseMoveSendIntervalMs(
|
||||
Number.isFinite(parsed) ? parsed : DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS
|
||||
)
|
||||
}
|
||||
|
||||
function setMouseMoveSendInterval(ms: number) {
|
||||
mouseMoveSendIntervalMs = clampMouseMoveSendIntervalMs(ms)
|
||||
|
||||
if (mouseFlushTimer !== null) {
|
||||
clearTimeout(mouseFlushTimer)
|
||||
mouseFlushTimer = null
|
||||
}
|
||||
if (hasPendingMouseMove()) {
|
||||
requestMouseMoveFlush()
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseSendIntervalChange(e: Event) {
|
||||
const customEvent = e as CustomEvent<{ intervalMs: number }>
|
||||
setMouseMoveSendInterval(customEvent.detail?.intervalMs)
|
||||
}
|
||||
|
||||
function handleMouseSendIntervalStorage(e: StorageEvent) {
|
||||
if (e.key !== 'hidMouseThrottle') return
|
||||
setMouseMoveSendInterval(loadMouseMoveSendIntervalFromStorage())
|
||||
}
|
||||
|
||||
// ActionBar handlers
|
||||
// (MSD and Settings are now handled by ActionBar component directly)
|
||||
|
||||
@@ -1687,6 +1756,8 @@ function handleToggleMouseMode() {
|
||||
}
|
||||
|
||||
mouseMode.value = mouseMode.value === 'absolute' ? 'relative' : 'absolute'
|
||||
pendingMouseMove = null
|
||||
accumulatedDelta = { x: 0, y: 0 }
|
||||
// Reset position when switching modes
|
||||
lastMousePosition.value = { x: 0, y: 0 }
|
||||
mousePosition.value = { x: 0, y: 0 }
|
||||
@@ -1727,8 +1798,12 @@ onMounted(async () => {
|
||||
window.addEventListener('blur', handleBlur)
|
||||
window.addEventListener('mouseup', handleWindowMouseUp)
|
||||
|
||||
setMouseMoveSendInterval(loadMouseMoveSendIntervalFromStorage())
|
||||
|
||||
// Listen for cursor visibility changes from HidConfigPopover
|
||||
window.addEventListener('hidCursorVisibilityChanged', handleCursorVisibilityChange as EventListener)
|
||||
window.addEventListener('hidMouseSendIntervalChanged', handleMouseSendIntervalChange as EventListener)
|
||||
window.addEventListener('storage', handleMouseSendIntervalStorage)
|
||||
|
||||
// Pointer Lock event listeners
|
||||
document.addEventListener('pointerlockchange', handlePointerLockChange)
|
||||
@@ -1757,10 +1832,10 @@ onUnmounted(() => {
|
||||
// Reset initial device info flag
|
||||
initialDeviceInfoReceived = false
|
||||
|
||||
// Clear mouse send timer
|
||||
if (mouseSendTimer !== null) {
|
||||
clearInterval(mouseSendTimer)
|
||||
mouseSendTimer = null
|
||||
// Clear mouse flush timer
|
||||
if (mouseFlushTimer !== null) {
|
||||
clearTimeout(mouseFlushTimer)
|
||||
mouseFlushTimer = null
|
||||
}
|
||||
|
||||
// Clear ttyd poll interval
|
||||
@@ -1799,6 +1874,8 @@ onUnmounted(() => {
|
||||
window.removeEventListener('blur', handleBlur)
|
||||
window.removeEventListener('mouseup', handleWindowMouseUp)
|
||||
window.removeEventListener('hidCursorVisibilityChanged', handleCursorVisibilityChange as EventListener)
|
||||
window.removeEventListener('hidMouseSendIntervalChanged', handleMouseSendIntervalChange as EventListener)
|
||||
window.removeEventListener('storage', handleMouseSendIntervalStorage)
|
||||
|
||||
// Remove pointer lock event listeners
|
||||
document.removeEventListener('pointerlockchange', handlePointerLockChange)
|
||||
|
||||
Reference in New Issue
Block a user