feat: harden local shell, SFTP, and desktop session reliability
Finder-launched terminals now inherit a login PATH so tools like ollama resolve, destructive SFTP/editor actions require confirmation, and terminal search plus window dragging work again. SSH forwards and RDP sessions keep listeners and survive reconnect without FastPath crashes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
188f3556ed
commit
08696896f9
223 files changed
+13493
-3423
No files matched your search
@@ -1,16 +1,18 @@
|
||||
import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Cast,
|
||||
ClipboardPaste,
|
||||
FolderInput,
|
||||
FolderOpen,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Monitor,
|
||||
RefreshCw,
|
||||
Unplug,
|
||||
} from 'lucide-react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useDesktopFullscreenStore } from '../../stores/desktopFullscreenStore'
|
||||
import type {
|
||||
DesktopFrameEvent,
|
||||
DesktopPointerButton,
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
rdpDisconnect,
|
||||
rdpInputKey,
|
||||
rdpInputPointer,
|
||||
rdpResize,
|
||||
rdpUploadFile,
|
||||
} from '../../platform/rdp'
|
||||
import {
|
||||
@@ -41,6 +44,7 @@ import {
|
||||
vncInputPointer,
|
||||
} from '../../platform/vnc'
|
||||
import { sessionToRdpConfig, sessionToVncConfig } from '../../utils/sessionProtocol'
|
||||
import { clampDesktopResolution } from '../../utils/desktopResolution'
|
||||
|
||||
interface DesktopSessionViewProps {
|
||||
tabId: string
|
||||
@@ -70,6 +74,7 @@ function decodeRgbaBase64(base64: string): Uint8ClampedArray<ArrayBuffer> {
|
||||
export default function DesktopSessionView({ tabId, isActive }: DesktopSessionViewProps) {
|
||||
const { t } = useTranslation('desktop')
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const paneRef = useRef<HTMLDivElement>(null)
|
||||
const canvasCtxRef = useRef<CanvasRenderingContext2D | null>(null)
|
||||
const nativeSessionIdRef = useRef<string | null>(null)
|
||||
const isActiveRef = useRef(isActive)
|
||||
@@ -77,7 +82,11 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
const buttonsRef = useRef({ left: false, middle: false, right: false })
|
||||
const pointerRafRef = useRef<number | null>(null)
|
||||
const paintRafRef = useRef<number | null>(null)
|
||||
const hideBarTimerRef = useRef<number | null>(null)
|
||||
const pendingFramesRef = useRef<DesktopFrameEvent[]>([])
|
||||
const lastRemoteResizeRef = useRef<{ width: number; height: number } | null>(null)
|
||||
const remoteResizeTimerRef = useRef<number | null>(null)
|
||||
const connectedRef = useRef(false)
|
||||
const pendingPointerRef = useRef<{
|
||||
x: number
|
||||
y: number
|
||||
@@ -87,10 +96,17 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
const tab = useTabStore((state) => state.tabs.find((item) => item.id === tabId))
|
||||
const sessions = useSessionStore((state) => state.sessions)
|
||||
const updateTabStatus = useTabStore((state) => state.updateTabStatus)
|
||||
const fullscreenActive = useDesktopFullscreenStore(
|
||||
(state) => state.active && state.tabId === tabId
|
||||
)
|
||||
const enterFullscreen = useDesktopFullscreenStore((state) => state.enter)
|
||||
const exitFullscreen = useDesktopFullscreenStore((state) => state.exit)
|
||||
const toggleFullscreen = useDesktopFullscreenStore((state) => state.toggle)
|
||||
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 [chromeVisible, setChromeVisible] = useState(true)
|
||||
|
||||
const protocol: DesktopProtocol = tab?.type === 'vnc' ? 'vnc' : 'rdp'
|
||||
isActiveRef.current = isActive
|
||||
@@ -203,6 +219,8 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
updateTabStatus(tabId, 'connecting')
|
||||
setStatusMessage(t('status.connecting'))
|
||||
setError(null)
|
||||
connectedRef.current = false
|
||||
lastRemoteResizeRef.current = null
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
@@ -210,13 +228,20 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
unlistenStatus = await onRdpStatus((payload) => {
|
||||
if (nativeSessionIdRef.current && payload.sessionId !== nativeSessionIdRef.current) return
|
||||
if (!nativeSessionIdRef.current) nativeSessionIdRef.current = payload.sessionId
|
||||
if (payload.width && payload.height) {
|
||||
setFbSize({ width: payload.width, height: payload.height })
|
||||
lastRemoteResizeRef.current = { width: payload.width, height: payload.height }
|
||||
}
|
||||
if (payload.status === 'connected') {
|
||||
connectedRef.current = true
|
||||
updateTabStatus(tabId, 'connected')
|
||||
setStatusMessage(payload.message || t('status.connected'))
|
||||
} else if (payload.status === 'error') {
|
||||
connectedRef.current = false
|
||||
updateTabStatus(tabId, 'error')
|
||||
setError(payload.message || t('status.connectFailed'))
|
||||
} else if (payload.status === 'disconnected') {
|
||||
connectedRef.current = false
|
||||
updateTabStatus(tabId, 'disconnected')
|
||||
setStatusMessage(t('status.disconnected'))
|
||||
} else if (payload.message) {
|
||||
@@ -288,6 +313,11 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
connectedRef.current = false
|
||||
if (remoteResizeTimerRef.current != null) {
|
||||
window.clearTimeout(remoteResizeTimerRef.current)
|
||||
remoteResizeTimerRef.current = null
|
||||
}
|
||||
if (paintRafRef.current != null) {
|
||||
window.cancelAnimationFrame(paintRafRef.current)
|
||||
paintRafRef.current = null
|
||||
@@ -311,6 +341,123 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
canvasRef.current?.focus()
|
||||
}, [isActive])
|
||||
|
||||
// Fit canvas to pane; for RDP also request remote dynamic resolution (mstsc / FreeRDP style).
|
||||
useEffect(() => {
|
||||
const pane = paneRef.current
|
||||
const canvas = canvasRef.current
|
||||
if (!pane || !canvas || fbSize.width <= 0 || fbSize.height <= 0) return
|
||||
|
||||
const updateFit = () => {
|
||||
const availW = Math.max(1, pane.clientWidth)
|
||||
const availH = Math.max(1, pane.clientHeight)
|
||||
const scale = Math.min(availW / fbSize.width, availH / fbSize.height)
|
||||
const displayW = Math.max(1, Math.floor(fbSize.width * scale))
|
||||
const displayH = Math.max(1, Math.floor(fbSize.height * scale))
|
||||
canvas.style.width = `${displayW}px`
|
||||
canvas.style.height = `${displayH}px`
|
||||
}
|
||||
|
||||
const requestRemoteResize = () => {
|
||||
if (protocol !== 'rdp' || !connectedRef.current || !isActive) return
|
||||
const sessionId = nativeSessionIdRef.current
|
||||
if (!sessionId) return
|
||||
const next = clampDesktopResolution(pane.clientWidth, pane.clientHeight)
|
||||
const last = lastRemoteResizeRef.current
|
||||
if (last && last.width === next.width && last.height === next.height) return
|
||||
// Skip tiny panes (drawer/animation) that would thrash the session.
|
||||
if (next.width < 640 || next.height < 480) return
|
||||
lastRemoteResizeRef.current = next
|
||||
void rdpResize(sessionId, next.width, next.height).catch(() => {
|
||||
// Allow retry on next size change if Display Control was not ready.
|
||||
lastRemoteResizeRef.current = last
|
||||
})
|
||||
}
|
||||
|
||||
const onPaneResize = () => {
|
||||
updateFit()
|
||||
if (remoteResizeTimerRef.current != null) {
|
||||
window.clearTimeout(remoteResizeTimerRef.current)
|
||||
}
|
||||
remoteResizeTimerRef.current = window.setTimeout(requestRemoteResize, 280)
|
||||
}
|
||||
|
||||
updateFit()
|
||||
// After fullscreen / maximize layout settles, request matching remote size once.
|
||||
remoteResizeTimerRef.current = window.setTimeout(requestRemoteResize, fullscreenActive ? 120 : 280)
|
||||
const observer = new ResizeObserver(onPaneResize)
|
||||
observer.observe(pane)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
if (remoteResizeTimerRef.current != null) {
|
||||
window.clearTimeout(remoteResizeTimerRef.current)
|
||||
remoteResizeTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [fbSize.height, fbSize.width, fullscreenActive, isActive, protocol])
|
||||
|
||||
// Leave OS fullscreen when this tab is hidden or unmounted.
|
||||
useEffect(() => {
|
||||
if (!isActive && fullscreenActive) {
|
||||
void exitFullscreen()
|
||||
}
|
||||
}, [exitFullscreen, fullscreenActive, isActive])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hideBarTimerRef.current != null) {
|
||||
window.clearTimeout(hideBarTimerRef.current)
|
||||
}
|
||||
// Only exit when this session view is truly torn down (tab closed), not on HMR-only paths.
|
||||
const state = useDesktopFullscreenStore.getState()
|
||||
if (state.active && state.tabId === tabId) {
|
||||
const stillOpen = useTabStore.getState().tabs.some((item) => item.id === tabId)
|
||||
if (!stillOpen) void state.exit()
|
||||
}
|
||||
}
|
||||
}, [tabId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!fullscreenActive) {
|
||||
setChromeVisible(true)
|
||||
if (hideBarTimerRef.current != null) {
|
||||
window.clearTimeout(hideBarTimerRef.current)
|
||||
hideBarTimerRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
// If the user leaves OS fullscreen via system UI, drop session chrome-hide.
|
||||
const timer = window.setInterval(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const state = useDesktopFullscreenStore.getState()
|
||||
if (!state.active || state.tabId !== tabId) return
|
||||
if (Date.now() - state.enteredAt < 1600) return
|
||||
const osFs = await window.opsxApi?.isFullscreen?.()
|
||||
if (osFs === false) {
|
||||
useDesktopFullscreenStore.setState({ active: false, tabId: null, enteredAt: 0 })
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})()
|
||||
}, 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [fullscreenActive, tabId])
|
||||
|
||||
const scheduleHideChrome = useCallback(() => {
|
||||
if (!fullscreenActive) return
|
||||
if (hideBarTimerRef.current != null) window.clearTimeout(hideBarTimerRef.current)
|
||||
hideBarTimerRef.current = window.setTimeout(() => {
|
||||
setChromeVisible(false)
|
||||
hideBarTimerRef.current = null
|
||||
}, 1800)
|
||||
}, [fullscreenActive])
|
||||
|
||||
const revealChrome = useCallback(() => {
|
||||
setChromeVisible(true)
|
||||
scheduleHideChrome()
|
||||
}, [scheduleHideChrome])
|
||||
|
||||
const flushPointer = () => {
|
||||
pointerRafRef.current = null
|
||||
const sessionId = nativeSessionIdRef.current
|
||||
@@ -383,6 +530,21 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
const onKey = (event: KeyboardEvent<HTMLCanvasElement>, pressed: boolean) => {
|
||||
const sessionId = nativeSessionIdRef.current
|
||||
if (!sessionId) return
|
||||
|
||||
// Local fullscreen controls — never forward to the remote desktop.
|
||||
if (event.key === 'F11') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (pressed) void toggleFullscreen(tabId)
|
||||
return
|
||||
}
|
||||
if (fullscreenActive && event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (pressed) void exitFullscreen()
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const isPasteChord =
|
||||
@@ -434,20 +596,6 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -458,91 +606,140 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
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'
|
||||
}`}
|
||||
/>
|
||||
const toolbar = (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs ${
|
||||
fullscreenActive
|
||||
? 'rounded-b-lg border border-t-0 border-dark-700/60 bg-dark-900/95 shadow-lg shadow-black/40 backdrop-blur'
|
||||
: 'border-b border-dark-700/50 bg-dark-800/80'
|
||||
}`}
|
||||
onMouseEnter={() => {
|
||||
if (fullscreenActive) {
|
||||
setChromeVisible(true)
|
||||
if (hideBarTimerRef.current != null) {
|
||||
window.clearTimeout(hideBarTimerRef.current)
|
||||
hideBarTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (fullscreenActive) scheduleHideChrome()
|
||||
}}
|
||||
>
|
||||
<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>}
|
||||
{fullscreenActive && (
|
||||
<span className="hidden shrink-0 text-[10px] text-txt-faint sm:inline">{t('toolbar.exitFullscreenHint')}</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>
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
className={toolBtn}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void (fullscreenActive ? exitFullscreen() : enterFullscreen(tabId))
|
||||
}}
|
||||
title={fullscreenActive ? t('toolbar.exitFullscreen') : t('toolbar.fullscreen')}
|
||||
aria-label={fullscreenActive ? t('toolbar.exitFullscreenAria') : t('toolbar.fullscreenAria')}
|
||||
>
|
||||
{fullscreenActive ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 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">
|
||||
return (
|
||||
<div
|
||||
className={`relative flex h-full flex-col bg-dark-900 ${fullscreenActive ? 'bg-black' : ''}`}
|
||||
onMouseMove={(event) => {
|
||||
if (!fullscreenActive) return
|
||||
if (event.clientY <= 56) revealChrome()
|
||||
}}
|
||||
>
|
||||
{fullscreenActive ? (
|
||||
<div
|
||||
className={`pointer-events-none absolute inset-x-0 top-0 z-20 flex justify-center px-3 transition-opacity duration-200 ${
|
||||
chromeVisible ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="pointer-events-auto w-full max-w-5xl">{toolbar}</div>
|
||||
</div>
|
||||
) : (
|
||||
toolbar
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={paneRef}
|
||||
className={`flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-black ${
|
||||
fullscreenActive ? 'p-0' : 'p-2'
|
||||
}`}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
tabIndex={0}
|
||||
className="max-h-full max-w-full cursor-default outline-none shadow-lg shadow-black/40"
|
||||
className="cursor-default outline-none shadow-lg shadow-black/40"
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
onMouseMove={(event) => mapPointer(event)}
|
||||
onMouseDown={(event) => mapPointer(event, true)}
|
||||
|
||||
Reference in new issue
Block a user