cc-haha/desktop/src/components/browser/BrowserSurface.tsx
程序员阿江(Relakkes) f269887fe7 fix: float browser preview zoom controls (#952)
Move the preview zoom control out of the address toolbar and into a bottom-right floating control area inside the browser preview stage. Keep the native WebContentsView bounds clear of the control strip so the DOM control remains interactive on high-DPI Windows displays.

Tested: cd desktop && bun run test -- src/components/browser/BrowserSurface.test.tsx

Tested: cd desktop && bun run lint

Tested: cd desktop && bun run build

Not-tested: Windows high-DPI real-device smoke.

Confidence: medium

Scope-risk: narrow
2026-07-08 14:53:06 +08:00

343 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useLayoutEffect, useRef } from 'react'
import { Camera, Loader2, Minus, MousePointer2, Plus, RotateCcw } from 'lucide-react'
import { BrowserAddressBar } from './BrowserAddressBar'
import { computeWebviewBounds } from './computeWebviewBounds'
import { getServerBaseUrl, isLoopbackHostname } from '../../lib/desktopRuntime'
import { classifyPreviewLink } from '../../lib/previewLinkRouter'
import { isAbsoluteLocalPath, localFileUrl, previewFsUrl } from '../../lib/handlePreviewLink'
import { previewBridge } from '../../lib/previewBridge'
import { subscribePreviewEvents } from '../../lib/previewEvents'
import {
BROWSER_ZOOM_STEP,
DEFAULT_BROWSER_ZOOM,
MAX_BROWSER_ZOOM,
MIN_BROWSER_ZOOM,
normalizeBrowserZoom,
useBrowserPanelStore,
} from '../../stores/browserPanelStore'
import { useOverlayStore } from '../../stores/overlayStore'
const LOCAL_PREVIEW_PATH_PREFIXES = ['/preview-fs/', '/local-file/']
const LOCAL_PREVIEW_READY_TIMEOUT_MS = 2500
function shouldWaitForLocalPreview(url: string): boolean {
try {
const parsed = new URL(url)
return isLoopbackHostname(parsed.hostname) &&
LOCAL_PREVIEW_PATH_PREFIXES.some((prefix) => parsed.pathname.startsWith(prefix))
} catch {
return false
}
}
async function waitForLocalPreview(url: string): Promise<void> {
const controller = new AbortController()
const timeout = window.setTimeout(() => controller.abort(), LOCAL_PREVIEW_READY_TIMEOUT_MS)
try {
await fetch(url, {
method: 'HEAD',
cache: 'no-store',
signal: controller.signal,
})
} catch {
// Best-effort warmup only. The native webview still navigates so users can
// see the server's own error page or use Reload if the first probe raced.
} finally {
window.clearTimeout(timeout)
}
}
function resolveBrowserNavigationUrl(input: string, sessionId: string): string {
const value = input.trim()
if (!value) return ''
const classified = classifyPreviewLink(value)
if (classified.kind === 'browser-file' && classified.path) {
const serverBaseUrl = getServerBaseUrl()
return isAbsoluteLocalPath(classified.path)
? localFileUrl(serverBaseUrl, classified.path)
: previewFsUrl(serverBaseUrl, sessionId, classified.path)
}
return value
}
export function BrowserSurface({ sessionId }: { sessionId: string }) {
const hostRef = useRef<HTMLDivElement>(null)
const loadSeqRef = useRef(0)
const requestedUrlRef = useRef<string | null>(null)
const hasNativePreviewRef = useRef(false)
const session = useBrowserPanelStore((s) => s.bySession[sessionId])
const store = useBrowserPanelStore.getState()
const overlayCount = useOverlayStore((s) => s.count)
const previewZoom = session?.zoom ?? DEFAULT_BROWSER_ZOOM
const zoomPercent = Math.round(previewZoom * 100)
const canZoomOut = previewZoom > MIN_BROWSER_ZOOM
const canZoomIn = previewZoom < MAX_BROWSER_ZOOM
const reportBounds = () => {
const el = hostRef.current
if (!el) return
previewBridge.setBounds(computeWebviewBounds(el.getBoundingClientRect()))
}
const loadNativePreview = (
url: string,
action: () => Promise<void>,
) => {
const seq = loadSeqRef.current + 1
loadSeqRef.current = seq
void (async () => {
if (shouldWaitForLocalPreview(url)) {
await waitForLocalPreview(url)
}
if (loadSeqRef.current !== seq) return
await action()
})().catch(() => {
if (loadSeqRef.current === seq) {
if (requestedUrlRef.current === url) {
requestedUrlRef.current = null
}
useBrowserPanelStore.getState().setLoading(sessionId, false)
}
})
}
const requestNativePreview = (url: string, options?: { force?: boolean }) => {
if (!url) return
if (!options?.force && requestedUrlRef.current === url) return
requestedUrlRef.current = url
loadNativePreview(url, async () => {
await previewBridge.setZoom(previewZoom)
if (hasNativePreviewRef.current) {
await previewBridge.navigate(url)
return
}
const el = hostRef.current
hasNativePreviewRef.current = true
if (el) {
await previewBridge.open(url, computeWebviewBounds(el.getBoundingClientRect()))
} else {
await previewBridge.navigate(url)
}
})
}
useLayoutEffect(() => {
if (session?.url) {
requestNativePreview(session.url)
}
return () => {
loadSeqRef.current += 1
requestedUrlRef.current = null
hasNativePreviewRef.current = false
previewBridge.close()
}
// The visibility-sync effect below owns setVisible() — including the
// initial reveal — so it always factors in overlayCount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId])
useEffect(() => {
if (!session?.url || !session.loading) return
requestNativePreview(session.url)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session?.url, session?.loading, sessionId])
// Visibility-sync: a fullscreen DOM overlay (e.g. ImageGalleryModal) would
// otherwise be partially covered by the native child webview, which always
// renders above the DOM. While overlayCount > 0 we hide the webview; when
// it returns to 0 (and we're still mounted in browser mode) we re-show it.
// The layout-effect teardown above still closes the webview on unmount.
useEffect(() => {
if (!session) return
previewBridge.setVisible(overlayCount === 0)
}, [overlayCount, session])
useEffect(() => {
if (!session) return
void previewBridge.setZoom(previewZoom)
}, [previewZoom, session])
useEffect(() => {
const el = hostRef.current
if (!el) return
const ro = new ResizeObserver(() => reportBounds())
ro.observe(el)
window.addEventListener('resize', reportBounds)
return () => { ro.disconnect(); window.removeEventListener('resize', reportBounds) }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId])
useEffect(() => {
let unsub: (() => void) | undefined
void subscribePreviewEvents(sessionId).then((u) => { unsub = u })
return () => { unsub?.() }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId])
// 兜底navigated/ready 依赖注入脚本,若外站 CSP 拦截则永不回灌。loading 变 true 后 ~15s 强制收尾。
const isLoading = session?.loading ?? false
const currentUrl = session?.url
useEffect(() => {
if (!isLoading) return
const timer = window.setTimeout(() => {
useBrowserPanelStore.getState().setLoading(sessionId, false)
}, 15000)
return () => window.clearTimeout(timer)
}, [isLoading, currentUrl, sessionId])
if (!session) return null
const openOrNavigate = (inputUrl: string) => {
const url = resolveBrowserNavigationUrl(inputUrl, sessionId)
if (!url) return
store.navigate(sessionId, url)
requestNativePreview(url)
}
const actionButtonClass = [
'inline-flex h-8 w-8 items-center justify-center rounded-full border transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]',
].join(' ')
const setPreviewZoom = (nextZoom: number) => {
store.setZoom(sessionId, normalizeBrowserZoom(nextZoom))
}
const zoomButtonClass = [
'inline-flex h-8 w-8 items-center justify-center rounded-full transition-colors',
'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-container-low)] hover:text-[var(--color-text-primary)]',
'disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-[var(--color-text-secondary)]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]',
].join(' ')
const zoomControls = (
<div
data-testid="browser-zoom-controls"
role="group"
aria-label="预览缩放控制"
className="inline-flex h-10 shrink-0 items-center gap-1 rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-1.5 shadow-lg"
>
<button
aria-label="缩小预览"
title="缩小预览"
disabled={!canZoomOut}
className={zoomButtonClass}
onClick={() => setPreviewZoom(previewZoom - BROWSER_ZOOM_STEP)}
>
<Minus size={14} />
</button>
<span className="min-w-11 select-none text-center text-xs font-medium tabular-nums text-[var(--color-text-secondary)]">
{zoomPercent}%
</span>
<button
aria-label="放大预览"
title="放大预览"
disabled={!canZoomIn}
className={zoomButtonClass}
onClick={() => setPreviewZoom(previewZoom + BROWSER_ZOOM_STEP)}
>
<Plus size={14} />
</button>
<button
aria-label="重置预览缩放"
title="重置预览缩放"
disabled={previewZoom === DEFAULT_BROWSER_ZOOM}
className={zoomButtonClass}
onClick={() => setPreviewZoom(DEFAULT_BROWSER_ZOOM)}
>
<RotateCcw size={14} />
</button>
</div>
)
const previewActions = (
<>
<button
aria-label="截图"
title="截图"
className={[
actionButtonClass,
'border-transparent text-[var(--color-text-secondary)] hover:border-[var(--color-border)]',
'hover:bg-[var(--color-surface-container-low)] hover:text-[var(--color-text-primary)]',
].join(' ')}
onClick={() => previewBridge.message({ v: 1, type: 'capture', kind: 'full' })}
>
<Camera size={16} />
</button>
<button
aria-label="选择元素"
aria-pressed={Boolean(session.pickerActive)}
title="选择元素"
className={[
actionButtonClass,
session.pickerActive
? 'border-[var(--color-brand)]/45 bg-[var(--color-surface-selected)] text-[var(--color-brand)]'
: 'border-transparent text-[var(--color-text-secondary)] hover:border-[var(--color-border)] hover:bg-[var(--color-surface-container-low)] hover:text-[var(--color-text-primary)]',
].join(' ')}
onClick={() => {
const cur = useBrowserPanelStore.getState().bySession[sessionId]
const next = !cur?.pickerActive
store.setPicker(sessionId, next)
previewBridge.message({ v: 1, type: next ? 'enter-picker' : 'exit-picker' })
}}
>
<MousePointer2 size={16} />
</button>
</>
)
return (
<div className="flex h-full flex-col">
<BrowserAddressBar
url={session.url}
canGoBack={session.canGoBack}
canGoForward={session.canGoForward}
loading={session.loading}
onNavigate={openOrNavigate}
onBack={() => {
store.goBack(sessionId)
store.setLoading(sessionId, true)
const url = useBrowserPanelStore.getState().bySession[sessionId]!.url
requestNativePreview(url)
}}
onForward={() => {
store.goForward(sessionId)
store.setLoading(sessionId, true)
const url = useBrowserPanelStore.getState().bySession[sessionId]!.url
requestNativePreview(url)
}}
onReload={() => {
if (!session.url) return
store.setLoading(sessionId, true)
requestNativePreview(session.url, { force: true })
}}
rightActions={previewActions}
/>
<div className="flex min-h-0 flex-1 flex-col bg-[var(--color-surface)]">
<div className="relative min-h-0 flex-1 overflow-hidden" data-testid="browser-preview-stage">
{/* WebContentsView renders above DOM, so keep the floating controls outside its bounds. */}
<div ref={hostRef} className="absolute inset-x-0 top-0 bottom-12 overflow-hidden" data-testid="preview-host">
{session.loading && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-[var(--color-surface)] text-[var(--color-text-tertiary)]">
<Loader2 size={18} className="animate-spin" aria-label="加载中" />
</div>
)}
</div>
<div
data-testid="browser-preview-floating-controls"
className="pointer-events-none absolute inset-x-0 bottom-0 z-10 flex h-12 items-end justify-end px-3 pb-2"
>
<div className="pointer-events-auto">
{zoomControls}
</div>
</div>
</div>
</div>
</div>
)
}