feat(i18n): add multi-language support (ja/ko/vi/ru/zh-TW) with system auto-detection
- Extend AppLanguage to 7 locales: zh-CN, zh-TW, en, ja, ko, vi, ru - Auto-detect system language via navigator.languages; fallback to English - Add BCP47 locale mapping for date formatting and sorting - Create 50 translation JSON files (5 languages × 10 namespaces, 1137 keys each) - Update language selector with native language names - Extend locale key parity test to cover all 7 languages (70 assertions) - Add VNC/RDP desktop, serial terminal, AI agent, port forwarding, and SFTP features - Add credential encryption, host metrics, session backup crypto, and zmodem bridge - Update Rust backend: desktop services, host metrics, SSH/SFTP enhancements
This commit is contained in:
1 parent
fea6d8db04
commit
b0d0e3a5f3
301 files changed
+28002
-2705
No files matched your search
@@ -0,0 +1,559 @@
|
||||
import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Cast,
|
||||
ClipboardPaste,
|
||||
FolderInput,
|
||||
FolderOpen,
|
||||
Monitor,
|
||||
RefreshCw,
|
||||
Unplug,
|
||||
} from 'lucide-react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import type {
|
||||
DesktopFrameEvent,
|
||||
DesktopPointerButton,
|
||||
DesktopProtocol,
|
||||
RdpConfig,
|
||||
VncConfig,
|
||||
} from '../../../shared/types/desktop.types'
|
||||
import {
|
||||
onRdpClipboard,
|
||||
onRdpFileDownload,
|
||||
onRdpFrame,
|
||||
onRdpStatus,
|
||||
rdpClipboardSet,
|
||||
rdpConnect,
|
||||
rdpDisconnect,
|
||||
rdpInputKey,
|
||||
rdpInputPointer,
|
||||
rdpUploadFile,
|
||||
} from '../../platform/rdp'
|
||||
import {
|
||||
onVncClipboard,
|
||||
onVncFrame,
|
||||
onVncStatus,
|
||||
vncClipboardSet,
|
||||
vncConnect,
|
||||
vncDisconnect,
|
||||
vncInputKey,
|
||||
vncInputPointer,
|
||||
} from '../../platform/vnc'
|
||||
import { sessionToRdpConfig, sessionToVncConfig } from '../../utils/sessionProtocol'
|
||||
|
||||
interface DesktopSessionViewProps {
|
||||
tabId: string
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
function decodeBase64Bytes(base64: string): Uint8Array<ArrayBuffer> {
|
||||
const binary = atob(base64)
|
||||
const buffer = new ArrayBuffer(binary.length)
|
||||
const bytes = new Uint8Array(buffer)
|
||||
const len = binary.length
|
||||
let i = 0
|
||||
for (; i + 4 <= len; i += 4) {
|
||||
bytes[i] = binary.charCodeAt(i)
|
||||
bytes[i + 1] = binary.charCodeAt(i + 1)
|
||||
bytes[i + 2] = binary.charCodeAt(i + 2)
|
||||
bytes[i + 3] = binary.charCodeAt(i + 3)
|
||||
}
|
||||
for (; i < len; i++) bytes[i] = binary.charCodeAt(i)
|
||||
return bytes
|
||||
}
|
||||
|
||||
function decodeRgbaBase64(base64: string): Uint8ClampedArray<ArrayBuffer> {
|
||||
return new Uint8ClampedArray(decodeBase64Bytes(base64).buffer)
|
||||
}
|
||||
|
||||
export default function DesktopSessionView({ tabId, isActive }: DesktopSessionViewProps) {
|
||||
const { t } = useTranslation('desktop')
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const canvasCtxRef = useRef<CanvasRenderingContext2D | null>(null)
|
||||
const nativeSessionIdRef = useRef<string | null>(null)
|
||||
const isActiveRef = useRef(isActive)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const buttonsRef = useRef({ left: false, middle: false, right: false })
|
||||
const pointerRafRef = useRef<number | null>(null)
|
||||
const paintRafRef = useRef<number | null>(null)
|
||||
const pendingFramesRef = useRef<DesktopFrameEvent[]>([])
|
||||
const pendingPointerRef = useRef<{
|
||||
x: number
|
||||
y: number
|
||||
button?: DesktopPointerButton
|
||||
pressed?: boolean
|
||||
} | null>(null)
|
||||
const tab = useTabStore((state) => state.tabs.find((item) => item.id === tabId))
|
||||
const sessions = useSessionStore((state) => state.sessions)
|
||||
const updateTabStatus = useTabStore((state) => state.updateTabStatus)
|
||||
const [statusMessage, setStatusMessage] = useState<string>(() => t('status.connecting'))
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [fbSize, setFbSize] = useState({ width: 1920, height: 1080 })
|
||||
const [resolvedConfig, setResolvedConfig] = useState<RdpConfig | VncConfig | null>(null)
|
||||
|
||||
const protocol: DesktopProtocol = tab?.type === 'vnc' ? 'vnc' : 'rdp'
|
||||
isActiveRef.current = isActive
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const resolve = async () => {
|
||||
if (protocol === 'rdp' && tab?.rdpConfig) {
|
||||
if (!cancelled) setResolvedConfig(tab.rdpConfig)
|
||||
return
|
||||
}
|
||||
if (protocol === 'vnc' && tab?.vncConfig) {
|
||||
if (!cancelled) setResolvedConfig(tab.vncConfig)
|
||||
return
|
||||
}
|
||||
if (tab?.sessionId) {
|
||||
const local = sessions.find((item) => item.id === tab.sessionId)
|
||||
if (local) {
|
||||
if (!cancelled) {
|
||||
setResolvedConfig(
|
||||
protocol === 'rdp' ? sessionToRdpConfig(local) : sessionToVncConfig(local)
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
const response = await window.opsxApi?.sessionGetById?.(tab.sessionId)
|
||||
if (!cancelled && response?.success && response.session) {
|
||||
setResolvedConfig(
|
||||
protocol === 'rdp'
|
||||
? sessionToRdpConfig(response.session)
|
||||
: sessionToVncConfig(response.session)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
void resolve()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [protocol, sessions, tab?.rdpConfig, tab?.sessionId, tab?.vncConfig])
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedConfig) return
|
||||
let disposed = false
|
||||
let unlistenStatus: (() => void) | undefined
|
||||
let unlistenFrame: (() => void) | undefined
|
||||
let unlistenClipboard: (() => void) | undefined
|
||||
let unlistenFile: (() => void) | undefined
|
||||
|
||||
const ensureCanvas = (fbWidth: number, fbHeight: number) => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return null
|
||||
if (canvas.width !== fbWidth || canvas.height !== fbHeight) {
|
||||
canvas.width = fbWidth
|
||||
canvas.height = fbHeight
|
||||
canvasCtxRef.current = null
|
||||
setFbSize({ width: fbWidth, height: fbHeight })
|
||||
}
|
||||
if (!canvasCtxRef.current) {
|
||||
canvasCtxRef.current = canvas.getContext('2d', { alpha: false })
|
||||
}
|
||||
return canvasCtxRef.current
|
||||
}
|
||||
|
||||
const paintOne = async (payload: DesktopFrameEvent) => {
|
||||
const ctx = ensureCanvas(payload.fbWidth, payload.fbHeight)
|
||||
if (!ctx) return
|
||||
if (payload.encoding === 'jpeg') {
|
||||
const bytes = decodeBase64Bytes(payload.dataBase64)
|
||||
const blob = new Blob([bytes], { type: 'image/jpeg' })
|
||||
const bitmap = await createImageBitmap(blob)
|
||||
if (disposed) {
|
||||
bitmap.close()
|
||||
return
|
||||
}
|
||||
ctx.drawImage(bitmap, payload.x, payload.y)
|
||||
bitmap.close()
|
||||
return
|
||||
}
|
||||
const rgba = decodeRgbaBase64(payload.dataBase64)
|
||||
const image = new ImageData(rgba, payload.width, payload.height)
|
||||
ctx.putImageData(image, payload.x, payload.y)
|
||||
}
|
||||
|
||||
const flushPendingFrames = () => {
|
||||
paintRafRef.current = null
|
||||
const batch = pendingFramesRef.current
|
||||
pendingFramesRef.current = []
|
||||
if (!batch.length || !isActiveRef.current) return
|
||||
void (async () => {
|
||||
for (const frame of batch) {
|
||||
if (disposed) return
|
||||
try {
|
||||
await paintOne(frame)
|
||||
} catch {
|
||||
// Ignore a single bad frame; keep painting the rest.
|
||||
}
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
const queueFrame = (payload: DesktopFrameEvent) => {
|
||||
if (!isActiveRef.current) return
|
||||
pendingFramesRef.current.push(payload)
|
||||
if (paintRafRef.current == null) {
|
||||
paintRafRef.current = window.requestAnimationFrame(flushPendingFrames)
|
||||
}
|
||||
}
|
||||
|
||||
updateTabStatus(tabId, 'connecting')
|
||||
setStatusMessage(t('status.connecting'))
|
||||
setError(null)
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
if (protocol === 'rdp') {
|
||||
unlistenStatus = await onRdpStatus((payload) => {
|
||||
if (nativeSessionIdRef.current && payload.sessionId !== nativeSessionIdRef.current) return
|
||||
if (!nativeSessionIdRef.current) nativeSessionIdRef.current = payload.sessionId
|
||||
if (payload.status === 'connected') {
|
||||
updateTabStatus(tabId, 'connected')
|
||||
setStatusMessage(payload.message || t('status.connected'))
|
||||
} else if (payload.status === 'error') {
|
||||
updateTabStatus(tabId, 'error')
|
||||
setError(payload.message || t('status.connectFailed'))
|
||||
} else if (payload.status === 'disconnected') {
|
||||
updateTabStatus(tabId, 'disconnected')
|
||||
setStatusMessage(t('status.disconnected'))
|
||||
} else if (payload.message) {
|
||||
setStatusMessage(payload.message)
|
||||
}
|
||||
})
|
||||
unlistenFrame = await onRdpFrame((payload) => {
|
||||
if (payload.sessionId !== nativeSessionIdRef.current) return
|
||||
queueFrame(payload)
|
||||
})
|
||||
unlistenClipboard = await onRdpClipboard((payload) => {
|
||||
if (payload.sessionId !== nativeSessionIdRef.current) return
|
||||
if (payload.text) {
|
||||
void navigator.clipboard.writeText(payload.text).catch(() => undefined)
|
||||
setStatusMessage(t('status.clipboardSynced'))
|
||||
}
|
||||
})
|
||||
unlistenFile = await onRdpFileDownload((payload) => {
|
||||
if (payload.sessionId !== nativeSessionIdRef.current) return
|
||||
setStatusMessage(t('status.savedToDownloads', { name: payload.name }))
|
||||
})
|
||||
const sessionId = await rdpConnect(resolvedConfig as RdpConfig)
|
||||
if (disposed) {
|
||||
await rdpDisconnect(sessionId)
|
||||
return
|
||||
}
|
||||
nativeSessionIdRef.current = sessionId
|
||||
} else {
|
||||
unlistenStatus = await onVncStatus((payload) => {
|
||||
if (nativeSessionIdRef.current && payload.sessionId !== nativeSessionIdRef.current) return
|
||||
if (!nativeSessionIdRef.current) nativeSessionIdRef.current = payload.sessionId
|
||||
if (payload.status === 'connected') {
|
||||
updateTabStatus(tabId, 'connected')
|
||||
setStatusMessage(payload.message || t('status.connected'))
|
||||
} else if (payload.status === 'error') {
|
||||
updateTabStatus(tabId, 'error')
|
||||
setError(payload.message || t('status.connectFailed'))
|
||||
} else if (payload.status === 'disconnected') {
|
||||
updateTabStatus(tabId, 'disconnected')
|
||||
setStatusMessage(t('status.disconnected'))
|
||||
} else if (payload.message) {
|
||||
setStatusMessage(payload.message)
|
||||
}
|
||||
})
|
||||
unlistenFrame = await onVncFrame((payload) => {
|
||||
if (payload.sessionId !== nativeSessionIdRef.current) return
|
||||
queueFrame(payload)
|
||||
})
|
||||
unlistenClipboard = await onVncClipboard((payload) => {
|
||||
if (payload.sessionId !== nativeSessionIdRef.current) return
|
||||
if (payload.text) {
|
||||
void navigator.clipboard.writeText(payload.text).catch(() => undefined)
|
||||
setStatusMessage(t('status.clipboardSynced'))
|
||||
}
|
||||
})
|
||||
const sessionId = await vncConnect(resolvedConfig as VncConfig)
|
||||
if (disposed) {
|
||||
await vncDisconnect(sessionId)
|
||||
return
|
||||
}
|
||||
nativeSessionIdRef.current = sessionId
|
||||
}
|
||||
} catch (reason) {
|
||||
const message = reason instanceof Error ? reason.message : String(reason)
|
||||
setError(message)
|
||||
updateTabStatus(tabId, 'error')
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
if (paintRafRef.current != null) {
|
||||
window.cancelAnimationFrame(paintRafRef.current)
|
||||
paintRafRef.current = null
|
||||
}
|
||||
pendingFramesRef.current = []
|
||||
unlistenStatus?.()
|
||||
unlistenFrame?.()
|
||||
unlistenClipboard?.()
|
||||
unlistenFile?.()
|
||||
const sessionId = nativeSessionIdRef.current
|
||||
nativeSessionIdRef.current = null
|
||||
if (sessionId) {
|
||||
if (protocol === 'rdp') void rdpDisconnect(sessionId)
|
||||
else void vncDisconnect(sessionId)
|
||||
}
|
||||
}
|
||||
}, [protocol, resolvedConfig, tabId, updateTabStatus, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
canvasRef.current?.focus()
|
||||
}, [isActive])
|
||||
|
||||
const flushPointer = () => {
|
||||
pointerRafRef.current = null
|
||||
const sessionId = nativeSessionIdRef.current
|
||||
const payload = pendingPointerRef.current
|
||||
pendingPointerRef.current = null
|
||||
if (!sessionId || !payload) return
|
||||
if (protocol === 'rdp') void rdpInputPointer(sessionId, payload)
|
||||
else void vncInputPointer(sessionId, payload)
|
||||
}
|
||||
|
||||
const mapPointer = (event: MouseEvent<HTMLCanvasElement>, pressed?: boolean) => {
|
||||
const canvas = canvasRef.current
|
||||
const sessionId = nativeSessionIdRef.current
|
||||
if (!canvas || !sessionId) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const scaleX = canvas.width / Math.max(rect.width, 1)
|
||||
const scaleY = canvas.height / Math.max(rect.height, 1)
|
||||
const x = Math.max(0, Math.min(canvas.width - 1, Math.round((event.clientX - rect.left) * scaleX)))
|
||||
const y = Math.max(0, Math.min(canvas.height - 1, Math.round((event.clientY - rect.top) * scaleY)))
|
||||
|
||||
const isMove = pressed === undefined
|
||||
let button: DesktopPointerButton | undefined
|
||||
let pressedOut: boolean | undefined = pressed
|
||||
|
||||
if (!isMove) {
|
||||
button = event.button === 1 ? 'middle' : event.button === 2 ? 'right' : 'left'
|
||||
if (button === 'left') buttonsRef.current.left = !!pressed
|
||||
if (button === 'middle') buttonsRef.current.middle = !!pressed
|
||||
if (button === 'right') buttonsRef.current.right = !!pressed
|
||||
} else if (buttonsRef.current.left) {
|
||||
button = 'left'
|
||||
pressedOut = true
|
||||
} else if (buttonsRef.current.right) {
|
||||
button = 'right'
|
||||
pressedOut = true
|
||||
} else if (buttonsRef.current.middle) {
|
||||
button = 'middle'
|
||||
pressedOut = true
|
||||
}
|
||||
|
||||
const payload = { x, y, button, pressed: pressedOut, moving: isMove }
|
||||
|
||||
if (!isMove) {
|
||||
// Position first, then button — improves Windows hit-testing reliability.
|
||||
if (protocol === 'rdp') {
|
||||
void rdpInputPointer(sessionId, { x, y, moving: true })
|
||||
void rdpInputPointer(sessionId, payload)
|
||||
} else {
|
||||
void vncInputPointer(sessionId, payload)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
pendingPointerRef.current = payload
|
||||
if (pointerRafRef.current == null) {
|
||||
pointerRafRef.current = window.requestAnimationFrame(flushPointer)
|
||||
}
|
||||
}
|
||||
|
||||
const pasteToRemote = async () => {
|
||||
const sessionId = nativeSessionIdRef.current
|
||||
if (!sessionId) return
|
||||
const text = await navigator.clipboard.readText()
|
||||
if (!text) return
|
||||
if (protocol === 'rdp') await rdpClipboardSet(sessionId, text)
|
||||
else await vncClipboardSet(sessionId, text)
|
||||
setStatusMessage(t('status.pastedToRemote'))
|
||||
}
|
||||
|
||||
const onKey = (event: KeyboardEvent<HTMLCanvasElement>, pressed: boolean) => {
|
||||
const sessionId = nativeSessionIdRef.current
|
||||
if (!sessionId) return
|
||||
event.preventDefault()
|
||||
|
||||
const isPasteChord =
|
||||
pressed &&
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
event.code === 'KeyV' &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey
|
||||
|
||||
if (isPasteChord) {
|
||||
// Do not forward Ctrl/Cmd+V — sync remote clipboard then native injects Ctrl+V.
|
||||
void pasteToRemote()
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
key: event.key,
|
||||
code: event.code,
|
||||
pressed,
|
||||
altKey: event.altKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
metaKey: event.metaKey,
|
||||
shiftKey: event.shiftKey,
|
||||
}
|
||||
if (protocol === 'rdp') void rdpInputKey(sessionId, payload)
|
||||
else void vncInputKey(sessionId, payload)
|
||||
}
|
||||
|
||||
const onShareFileSelected = async (fileList: FileList | null) => {
|
||||
const sessionId = nativeSessionIdRef.current
|
||||
const file = fileList?.[0]
|
||||
if (!sessionId || !file || protocol !== 'rdp') return
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
setError(t('status.fileTooLarge'))
|
||||
return
|
||||
}
|
||||
const buffer = new Uint8Array(await file.arrayBuffer())
|
||||
let binary = ''
|
||||
for (let i = 0; i < buffer.length; i++) binary += String.fromCharCode(buffer[i])
|
||||
await rdpUploadFile(sessionId, file.name, btoa(binary))
|
||||
setStatusMessage(t('status.fileShared', { name: file.name }))
|
||||
}
|
||||
|
||||
const openSharedFolder = async () => {
|
||||
try {
|
||||
await window.opsxApi?.openDownloadsFolder?.()
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : String(reason))
|
||||
}
|
||||
}
|
||||
|
||||
const disconnectSession = async () => {
|
||||
const sessionId = nativeSessionIdRef.current
|
||||
if (!sessionId) return
|
||||
try {
|
||||
if (protocol === 'rdp') await rdpDisconnect(sessionId)
|
||||
else await vncDisconnect(sessionId)
|
||||
nativeSessionIdRef.current = null
|
||||
updateTabStatus(tabId, 'disconnected')
|
||||
setStatusMessage(t('status.disconnected'))
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : String(reason))
|
||||
}
|
||||
}
|
||||
|
||||
const Icon = protocol === 'rdp' ? Monitor : Cast
|
||||
const hostLabel =
|
||||
resolvedConfig && 'host' in resolvedConfig
|
||||
? resolvedConfig.host
|
||||
: tab?.title
|
||||
const connected = tab?.connectionStatus === 'connected'
|
||||
|
||||
const toolBtn =
|
||||
'inline-flex h-7 w-7 items-center justify-center rounded-md text-content-secondary transition-colors hover:bg-dark-700 hover:text-content-primary disabled:cursor-not-allowed disabled:opacity-40'
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-dark-900">
|
||||
<div className="flex items-center gap-2 border-b border-dark-700/50 bg-dark-800/80 px-2.5 py-1.5 text-xs">
|
||||
<Icon className="h-3.5 w-3.5 flex-shrink-0 text-accent-blue" />
|
||||
<span className="max-w-[10rem] truncate font-mono text-content-secondary" title={hostLabel}>
|
||||
{hostLabel}
|
||||
</span>
|
||||
<span className="tabular-nums text-txt-faint">
|
||||
{fbSize.width}×{fbSize.height}
|
||||
</span>
|
||||
{tab?.connectionStatus === 'connecting' ? (
|
||||
<RefreshCw className="h-3 w-3 flex-shrink-0 animate-spin text-yellow-400" />
|
||||
) : (
|
||||
<span
|
||||
className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${
|
||||
connected ? 'bg-green-500' : tab?.connectionStatus === 'error' ? 'bg-red-500' : 'bg-dark-500'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-txt-muted">{statusMessage}</span>
|
||||
{error && <span className="max-w-[30%] truncate text-red-400">{error}</span>}
|
||||
|
||||
<div className="ml-1 flex flex-shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className={toolBtn}
|
||||
disabled={!connected}
|
||||
onClick={() => void pasteToRemote()}
|
||||
title={t('toolbar.pasteClipboard')}
|
||||
aria-label={t('toolbar.pasteAria')}
|
||||
>
|
||||
<ClipboardPaste className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
{protocol === 'rdp' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={toolBtn}
|
||||
disabled={!connected}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title={t('toolbar.shareDrive')}
|
||||
aria-label={t('toolbar.shareDriveAria')}
|
||||
>
|
||||
<FolderInput className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void onShareFileSelected(event.target.files)
|
||||
event.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={toolBtn}
|
||||
onClick={() => void openSharedFolder()}
|
||||
title={t('toolbar.openShareDir')}
|
||||
aria-label={t('toolbar.openShareDirAria')}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`${toolBtn} text-red-400 hover:bg-red-500/15 hover:text-red-300`}
|
||||
disabled={!connected}
|
||||
onClick={() => void disconnectSession()}
|
||||
title={t('toolbar.disconnect')}
|
||||
aria-label={t('toolbar.disconnectAria')}
|
||||
>
|
||||
<Unplug className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center overflow-auto bg-black p-2">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
tabIndex={0}
|
||||
className="max-h-full max-w-full cursor-default outline-none shadow-lg shadow-black/40"
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
onMouseMove={(event) => mapPointer(event)}
|
||||
onMouseDown={(event) => mapPointer(event, true)}
|
||||
onMouseUp={(event) => mapPointer(event, false)}
|
||||
onMouseLeave={() => {
|
||||
buttonsRef.current = { left: false, middle: false, right: false }
|
||||
}}
|
||||
onKeyDown={(event) => onKey(event, true)}
|
||||
onKeyUp={(event) => onKey(event, false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user