fix(web): 统一 API 请求语义并修复鼠标移动发送间隔

- 新增统一 request:同时处理 HTTP 非 2xx 与 success=false,并用 i18n toast 提示错误
- api/index.ts 与 api/config.ts 统一使用同一 request,避免错误处理不一致
- "发送间隔" 仅控制鼠标移动事件频率,WebRTC/WS 行为一致,不影响点击/滚轮
This commit is contained in:
mofeng-git
2026-01-11 11:37:35 +08:00
parent 206594e292
commit 0f52168e75
8 changed files with 296 additions and 210 deletions

View File

@@ -31,28 +31,7 @@ import type {
TtydStatus, TtydStatus,
} from '@/types/generated' } from '@/types/generated'
const API_BASE = '/api' import { request } from './request'
/**
* 通用请求函数
*/
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()
}
// ===== 全局配置 API ===== // ===== 全局配置 API =====
export const configApi = { export const configApi = {

View File

@@ -1,97 +1,9 @@
// API client for One-KVM backend // API client for One-KVM backend
import { toast } from 'vue-sonner' import { request, ApiError } from './request'
const API_BASE = '/api' 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 // Auth API
export const authApi = { export const authApi = {
login: (username: string, password: string) => login: (username: string, password: string) =>

133
web/src/api/request.ts Normal file
View 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'))
}
}

View File

@@ -23,7 +23,6 @@ import { MousePointer, Move, Loader2, RefreshCw } from 'lucide-vue-next'
import HelpTooltip from '@/components/HelpTooltip.vue' import HelpTooltip from '@/components/HelpTooltip.vue'
import { configApi } from '@/api' import { configApi } from '@/api'
import { useSystemStore } from '@/stores/system' import { useSystemStore } from '@/stores/system'
import { setMouseThrottle } from '@/composables/useHidWebSocket'
const props = defineProps<{ const props = defineProps<{
open: boolean open: boolean
@@ -39,9 +38,24 @@ const emit = defineEmits<{
const { t } = useI18n() const { t } = useI18n()
const systemStore = useSystemStore() 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) // Mouse Settings (real-time)
const mouseThrottle = ref<number>( const mouseThrottle = ref<number>(
Number(localStorage.getItem('hidMouseThrottle')) || 0 loadMouseMoveSendIntervalFromStorage()
) )
const showCursor = ref<boolean>( const showCursor = ref<boolean>(
localStorage.getItem('hidShowCursor') !== 'false' // default true localStorage.getItem('hidShowCursor') !== 'false' // default true
@@ -105,9 +119,7 @@ async function loadDevices() {
// Initialize from current config // Initialize from current config
function initializeFromCurrent() { function initializeFromCurrent() {
// Re-sync real-time settings from localStorage // Re-sync real-time settings from localStorage
const storedThrottle = Number(localStorage.getItem('hidMouseThrottle')) || 0 mouseThrottle.value = loadMouseMoveSendIntervalFromStorage()
mouseThrottle.value = storedThrottle
setMouseThrottle(storedThrottle)
const storedCursor = localStorage.getItem('hidShowCursor') !== 'false' const storedCursor = localStorage.getItem('hidShowCursor') !== 'false'
showCursor.value = storedCursor showCursor.value = storedCursor
@@ -138,11 +150,14 @@ function toggleMouseMode() {
// Update mouse throttle (real-time) // Update mouse throttle (real-time)
function handleThrottleChange(value: number[] | undefined) { function handleThrottleChange(value: number[] | undefined) {
if (!value || value.length === 0 || value[0] === undefined) return if (!value || value.length === 0 || value[0] === undefined) return
const throttleValue = value[0] const throttleValue = clampMouseMoveSendIntervalMs(value[0])
mouseThrottle.value = throttleValue mouseThrottle.value = throttleValue
setMouseThrottle(throttleValue)
// Save to localStorage // Save to localStorage
localStorage.setItem('hidMouseThrottle', String(throttleValue)) 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 // Handle backend change
@@ -273,7 +288,7 @@ watch(() => props.open, (isOpen) => {
@update:model-value="handleThrottleChange" @update:model-value="handleThrottleChange"
:min="0" :min="0"
:max="1000" :max="1000"
:step="10" :step="1"
class="py-2" class="py-2"
/> />
<div class="flex justify-between text-xs text-muted-foreground"> <div class="flex justify-between text-xs text-muted-foreground">

View File

@@ -25,12 +25,6 @@ const networkErrorMessage = ref<string | null>(null)
let reconnectTimeout: number | null = null let reconnectTimeout: number | null = null
const hidUnavailable = ref(false) // Track if HID is unavailable to prevent unnecessary reconnects 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 // Connection promise to avoid race conditions
let connectionPromise: Promise<boolean> | null = null let connectionPromise: Promise<boolean> | null = null
let connectionResolved = false 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 // Internal function to actually send mouse event
function _sendMouseInternal(event: HidMouseEvent): Promise<void> { function _sendMouseInternal(event: HidMouseEvent): Promise<void> {
return new Promise((resolve, reject) => { 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> { function sendMouse(event: HidMouseEvent): Promise<void> {
const now = Date.now() return _sendMouseInternal(event)
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()
}
} }
// Send consumer control event (multimedia keys) // Send consumer control event (multimedia keys)

View File

@@ -41,6 +41,13 @@ export default {
toggleTheme: 'Toggle theme', toggleTheme: 'Toggle theme',
toggleLanguage: 'Toggle language', 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: { nav: {
console: 'Console', console: 'Console',
msd: 'Virtual Media', msd: 'Virtual Media',

View File

@@ -41,6 +41,13 @@ export default {
toggleTheme: '切换主题', toggleTheme: '切换主题',
toggleLanguage: '切换语言', toggleLanguage: '切换语言',
}, },
api: {
operationFailed: '操作失败',
operationFailedDesc: '操作失败',
parseResponseFailed: '解析响应失败',
networkError: '网络错误',
networkErrorDesc: '无法连接到服务器,请检查网络连接。',
},
nav: { nav: {
console: '控制台', console: '控制台',
msd: '虚拟媒体', msd: '虚拟媒体',

View File

@@ -124,8 +124,10 @@ const lastMousePosition = ref({ x: 0, y: 0 }) // Track last position for relativ
const isPointerLocked = ref(false) // Track pointer lock state const isPointerLocked = ref(false) // Track pointer lock state
// Mouse move throttling (60 Hz = ~16.67ms interval) // Mouse move throttling (60 Hz = ~16.67ms interval)
const MOUSE_SEND_INTERVAL_MS = 16 const DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS = 16
let mouseSendTimer: ReturnType<typeof setInterval> | null = null 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 pendingMouseMove: { type: 'move' | 'move_abs'; x: number; y: number } | null = null
let accumulatedDelta = { x: 0, y: 0 } // For relative mode: accumulate deltas between sends 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 } mousePosition.value = { x, y }
// Queue for throttled sending (absolute mode: just update pending position) // Queue for throttled sending (absolute mode: just update pending position)
pendingMouseMove = { type: 'move_abs', x, y } pendingMouseMove = { type: 'move_abs', x, y }
ensureMouseSendTimer() requestMouseMoveFlush()
} else { } else {
// Relative mode: use movementX/Y when pointer is locked // Relative mode: use movementX/Y when pointer is locked
if (isPointerLocked.value) { if (isPointerLocked.value) {
@@ -1484,7 +1486,7 @@ function handleMouseMove(e: MouseEvent) {
// Accumulate deltas for throttled sending // Accumulate deltas for throttled sending
accumulatedDelta.x += dx accumulatedDelta.x += dx
accumulatedDelta.y += dy accumulatedDelta.y += dy
ensureMouseSendTimer() requestMouseMoveFlush()
} }
// Update display position (accumulated delta for display only) // 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 hasPendingMouseMove(): boolean {
function ensureMouseSendTimer() { if (mouseMode.value === 'absolute') return pendingMouseMove !== null
if (mouseSendTimer !== null) return return accumulatedDelta.x !== 0 || accumulatedDelta.y !== 0
// 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)
} }
// Flush pending mouse move data, returns true if data was sent function flushMouseMoveOnce(): boolean {
function flushMouseMove(): boolean {
if (mouseMode.value === 'absolute') { if (mouseMode.value === 'absolute') {
if (pendingMouseMove) { if (!pendingMouseMove) return false
sendMouseEvent(pendingMouseMove) sendMouseEvent(pendingMouseMove)
pendingMouseMove = null pendingMouseMove = null
return true 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
}
} }
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 // Track pressed mouse button for window-level mouseup handling
@@ -1656,6 +1690,41 @@ function handleCursorVisibilityChange(e: Event) {
cursorVisible.value = customEvent.detail.visible 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 // ActionBar handlers
// (MSD and Settings are now handled by ActionBar component directly) // (MSD and Settings are now handled by ActionBar component directly)
@@ -1687,6 +1756,8 @@ function handleToggleMouseMode() {
} }
mouseMode.value = mouseMode.value === 'absolute' ? 'relative' : 'absolute' mouseMode.value = mouseMode.value === 'absolute' ? 'relative' : 'absolute'
pendingMouseMove = null
accumulatedDelta = { x: 0, y: 0 }
// Reset position when switching modes // Reset position when switching modes
lastMousePosition.value = { x: 0, y: 0 } lastMousePosition.value = { x: 0, y: 0 }
mousePosition.value = { x: 0, y: 0 } mousePosition.value = { x: 0, y: 0 }
@@ -1727,8 +1798,12 @@ onMounted(async () => {
window.addEventListener('blur', handleBlur) window.addEventListener('blur', handleBlur)
window.addEventListener('mouseup', handleWindowMouseUp) window.addEventListener('mouseup', handleWindowMouseUp)
setMouseMoveSendInterval(loadMouseMoveSendIntervalFromStorage())
// Listen for cursor visibility changes from HidConfigPopover // Listen for cursor visibility changes from HidConfigPopover
window.addEventListener('hidCursorVisibilityChanged', handleCursorVisibilityChange as EventListener) window.addEventListener('hidCursorVisibilityChanged', handleCursorVisibilityChange as EventListener)
window.addEventListener('hidMouseSendIntervalChanged', handleMouseSendIntervalChange as EventListener)
window.addEventListener('storage', handleMouseSendIntervalStorage)
// Pointer Lock event listeners // Pointer Lock event listeners
document.addEventListener('pointerlockchange', handlePointerLockChange) document.addEventListener('pointerlockchange', handlePointerLockChange)
@@ -1757,10 +1832,10 @@ onUnmounted(() => {
// Reset initial device info flag // Reset initial device info flag
initialDeviceInfoReceived = false initialDeviceInfoReceived = false
// Clear mouse send timer // Clear mouse flush timer
if (mouseSendTimer !== null) { if (mouseFlushTimer !== null) {
clearInterval(mouseSendTimer) clearTimeout(mouseFlushTimer)
mouseSendTimer = null mouseFlushTimer = null
} }
// Clear ttyd poll interval // Clear ttyd poll interval
@@ -1799,6 +1874,8 @@ onUnmounted(() => {
window.removeEventListener('blur', handleBlur) window.removeEventListener('blur', handleBlur)
window.removeEventListener('mouseup', handleWindowMouseUp) window.removeEventListener('mouseup', handleWindowMouseUp)
window.removeEventListener('hidCursorVisibilityChanged', handleCursorVisibilityChange as EventListener) window.removeEventListener('hidCursorVisibilityChanged', handleCursorVisibilityChange as EventListener)
window.removeEventListener('hidMouseSendIntervalChanged', handleMouseSendIntervalChange as EventListener)
window.removeEventListener('storage', handleMouseSendIntervalStorage)
// Remove pointer lock event listeners // Remove pointer lock event listeners
document.removeEventListener('pointerlockchange', handlePointerLockChange) document.removeEventListener('pointerlockchange', handlePointerLockChange)