From b7372ad018fa30451f5ea29faf9c0ccaae1cf4bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=E1=BA=BF=20Anh=20=C4=90=E1=BA=B7ng?= Date: Sun, 11 Oct 2026 19:54:58 +0700 Subject: [PATCH 1/3] Add right-click context menu to browser tab - Headless Chrome renders no native context menu, so suppress the browser default and surface our own with copy, paste, select all, back, forward, and reload actions. - Keep the right button untouched in onPointerDown so the contextmenu event still fires while left/middle presses keep suppressing focus. - Close the menu on outside click, Escape, resize, and scroll, and clamp its position within the viewport. - Add en/vi labels for the new menu entries. --- web/src/components/ide/BrowserTab.tsx | 119 +++++++++++++++++++++++++- web/src/i18n/locales/en.ts | 3 + web/src/i18n/locales/vi.ts | 3 + 3 files changed, 123 insertions(+), 2 deletions(-) diff --git a/web/src/components/ide/BrowserTab.tsx b/web/src/components/ide/BrowserTab.tsx index b871e09..f30d595 100644 --- a/web/src/components/ide/BrowserTab.tsx +++ b/web/src/components/ide/BrowserTab.tsx @@ -26,9 +26,15 @@ interface FrameMessage { type ConnectionState = 'connecting' | 'open' | 'closed' +type ContextMenuEntry = { label: string; run: () => void } | { separator: true } + const RECONNECT_BASE_MS = 500 const RECONNECT_MAX_MS = 15000 +const CONTEXT_MENU_WIDTH = 176 +const CONTEXT_MENU_HEIGHT = 232 +const CONTEXT_MENU_MARGIN = 8 + function normalizeUrl(raw: string): string { const value = raw.trim() if (value === '') return '' @@ -84,6 +90,7 @@ export function BrowserTab({ const [reconnectNonce, setReconnectNonce] = useState(0) const [isFullscreen, setIsFullscreen] = useState(false) const [caret, setCaret] = useState<{ left: number; top: number }>({ left: 0, top: 0 }) + const [menu, setMenu] = useState<{ x: number; y: number } | null>(null) const send = useCallback((payload: unknown) => { const socket = socketRef.current @@ -276,6 +283,41 @@ export function BrowserTab({ return () => document.removeEventListener('fullscreenchange', onChange) }, []) + useEffect(() => { + if (!menu) return + function close() { + setMenu(null) + } + function onKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') setMenu(null) + } + document.addEventListener('mousedown', close) + document.addEventListener('keydown', onKeyDown) + window.addEventListener('resize', close) + window.addEventListener('scroll', close, true) + return () => { + document.removeEventListener('mousedown', close) + document.removeEventListener('keydown', onKeyDown) + window.removeEventListener('resize', close) + window.removeEventListener('scroll', close, true) + } + }, [menu]) + + function openContextMenu(clientX: number, clientY: number) { + const maxX = Math.max( + CONTEXT_MENU_MARGIN, + window.innerWidth - CONTEXT_MENU_WIDTH - CONTEXT_MENU_MARGIN, + ) + const maxY = Math.max( + CONTEXT_MENU_MARGIN, + window.innerHeight - CONTEXT_MENU_HEIGHT - CONTEXT_MENU_MARGIN, + ) + setMenu({ + x: Math.min(clientX, maxX), + y: Math.min(clientY, maxY), + }) + } + function toggleFullscreen() { if (document.fullscreenElement) { void document.exitFullscreen() @@ -309,7 +351,9 @@ export function BrowserTab({ } function onPointerDown(event: ReactPointerEvent) { - event.preventDefault() + // Left/middle presses suppress native focus and selection. The right button + // is left alone so the browser still emits `contextmenu` for our menu. + if (event.button !== 2) event.preventDefault() const { x, y } = targetPoint(event) const canvas = canvasRef.current const rect = canvas?.getBoundingClientRect() @@ -493,6 +537,42 @@ export function BrowserTab({ } } + function selectAll() { + for (const eventType of ['keyDown', 'keyUp'] as const) { + send({ + type: 'input_keyboard', + eventType, + key: 'a', + code: 'KeyA', + modifiers: 2, + windowsVirtualKeyCode: 65, + nativeVirtualKeyCode: 65, + }) + } + } + + async function pasteFromClipboard() { + try { + const text = await navigator.clipboard.readText() + if (text) await insertBrowserText(projectId, sessionId, text) + } catch (err) { + setError(err instanceof Error ? err.message : t('apiErrors.unknown')) + } + } + + const menuItem = + 'flex w-full items-center px-3 py-1.5 text-left text-[var(--fg-2)] transition hover:bg-[var(--active)]' + + const contextMenuEntries: ContextMenuEntry[] = [ + { label: t('ide.browserMenuCopy'), run: () => void copyOrCut(false) }, + { label: t('ide.browserMenuPaste'), run: () => void pasteFromClipboard() }, + { label: t('ide.browserMenuSelectAll'), run: selectAll }, + { separator: true }, + { label: t('ide.browserBack'), run: () => void control('back') }, + { label: t('ide.browserForward'), run: () => void control('forward') }, + { label: t('ide.browserReload'), run: () => void control('reload') }, + ] + const toolbarButton = 'shrink-0 rounded-md p-1.5 text-[var(--muted)] transition hover:bg-[var(--hover)] hover:text-[var(--fg-strong)] disabled:opacity-40 disabled:hover:bg-transparent' @@ -581,7 +661,14 @@ export function BrowserTab({ onPointerDown={onPointerDown} onPointerUp={onPointerUp} onWheel={onWheel} - onContextMenu={(event) => event.preventDefault()} + onContextMenu={(event) => { + // Headless Chrome renders no browser chrome, so its native context + // menu never reaches the streamed frames. Suppress it and surface + // our own menu; the right-click was already forwarded for pages + // that draw a custom context menu themselves. + event.preventDefault() + openContextMenu(event.clientX, event.clientY) + }} className="block cursor-default outline-none" />