程序员阿江(Relakkes) e64cd80662 fix: add browser preview zoom controls (#952)
Add per-session zoom state, route the zoom factor through the desktop preview bridge, and apply it to the Electron WebContentsView before preview capture so screenshot and annotation coordinates stay on the native preview surface.

Tested:
- cd desktop && bun run test -- src/components/browser/BrowserSurface.test.tsx src/stores/browserPanelStore.test.ts src/components/workbench/WorkbenchPanel.webview.test.tsx src/lib/previewBridge.test.ts src/lib/desktopHost/electronHost.test.ts
- cd desktop && bun test electron/services/preview.test.ts
- cd desktop && bun run lint
- cd desktop && bun run build
- cd desktop && bun run check:electron

Not-tested:
- cd desktop && bun run check:desktop (blocked by existing MessageList relative-time assertion and TabBar AbortSignal errors)

Confidence: medium
Scope-risk: moderate
2026-07-02 20:31:14 +08:00

243 lines
7.7 KiB
TypeScript

import { existsSync, readFileSync } from 'node:fs'
import { ELECTRON_EVENT_CHANNELS } from '../ipc/channels'
import { parsePreviewAgentMessage, type PreviewAgentMessage } from '../ipc/previewMessage'
import { normalizeZoomFactor } from './zoom'
export { parsePreviewAgentMessage, shouldForwardPreviewMessage } from '../ipc/previewMessage'
export type PreviewBounds = {
x: number
y: number
width: number
height: number
}
export type PreviewWebContentsLike = {
loadURL(url: string): Promise<unknown>
executeJavaScript(script: string): Promise<unknown>
on(event: 'did-finish-load', handler: () => void): unknown
close?(): void
isDestroyed?(): boolean
capturePage?(): Promise<{ toDataURL(): string }>
setZoomFactor?(factor: number): void
send(channel: string, payload: unknown): void
}
export type PreviewViewLike = {
webContents: PreviewWebContentsLike
setBounds(bounds: PreviewBounds): void
setVisible?(visible: boolean): void
}
export type PreviewParentWindowLike = {
contentView: {
addChildView(view: unknown): void
removeChildView(view: unknown): void
}
}
export type ElectronPreviewServiceOptions = {
createView: () => PreviewViewLike
previewScriptPath: string
}
type PreviewHostCaptureMessage = {
v: 1
type: 'capture'
kind: 'full' | 'viewport' | 'element'
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isHostCaptureMessage(payload: unknown): payload is PreviewHostCaptureMessage {
return isPlainRecord(payload) &&
payload.v === 1 &&
payload.type === 'capture' &&
(payload.kind === 'full' || payload.kind === 'viewport' || payload.kind === 'element')
}
export function normalizePreviewUrl(input: string): string {
const trimmed = input.trim()
if (!trimmed) throw new Error('empty url')
const parsed = new URL(trimmed)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`unsupported url scheme: ${trimmed}`)
}
return trimmed
}
export function normalizePreviewBounds(bounds: PreviewBounds): PreviewBounds {
for (const [key, value] of Object.entries(bounds)) {
if (!Number.isFinite(value)) throw new Error(`invalid preview bounds ${key}`)
}
return {
x: Math.round(bounds.x),
y: Math.round(bounds.y),
width: Math.max(0, Math.round(bounds.width)),
height: Math.max(0, Math.round(bounds.height)),
}
}
export function resolvePreviewScriptPath(previewScriptPath: string): string {
if (existsSync(previewScriptPath)) return previewScriptPath
const unpackedPath = previewScriptPath.replace(/\.asar([/\\])/, '.asar.unpacked$1')
if (unpackedPath !== previewScriptPath && existsSync(unpackedPath)) return unpackedPath
return previewScriptPath
}
export class ElectronPreviewService {
private readonly createView: () => PreviewViewLike
private readonly previewScriptPath: string
private view: PreviewViewLike | null = null
private parent: PreviewParentWindowLike | null = null
private zoomFactor = 1
constructor(options: ElectronPreviewServiceOptions) {
this.createView = options.createView
this.previewScriptPath = options.previewScriptPath
}
async open(parent: PreviewParentWindowLike, url: string, bounds: PreviewBounds): Promise<void> {
const normalizedUrl = normalizePreviewUrl(url)
const normalizedBounds = normalizePreviewBounds(bounds)
const view = this.ensureView(parent)
view.setBounds(normalizedBounds)
await view.webContents.loadURL(normalizedUrl)
}
async navigate(url: string): Promise<void> {
const view = this.requireView()
await view.webContents.loadURL(normalizePreviewUrl(url))
}
setBounds(bounds: PreviewBounds): void {
this.view?.setBounds(normalizePreviewBounds(bounds))
}
setVisible(visible: boolean): void {
this.view?.setVisible?.(visible)
}
setZoomFactor(value: unknown): void {
this.zoomFactor = normalizeZoomFactor(value)
this.applyZoomFactor(this.view)
}
close(): void {
if (!this.view) return
this.parent?.contentView.removeChildView(this.view)
if (!this.view.webContents.isDestroyed?.()) {
this.view.webContents.close?.()
}
this.view = null
this.parent = null
}
async message(payload: unknown, renderer?: PreviewWebContentsLike | null): Promise<void> {
if (isHostCaptureMessage(payload) && renderer) {
await this.captureScreenshotToRenderer(payload.kind, renderer)
return
}
const raw = JSON.stringify(payload)
const script = `globalThis.__PREVIEW_BRIDGE__?.handleHostRaw(${JSON.stringify(raw)})`
await this.requireView().webContents.executeJavaScript(script)
}
async sendMessageToRenderer(sender: PreviewWebContentsLike, raw: unknown, renderer: PreviewWebContentsLike | null | undefined): Promise<void> {
if (sender !== this.view?.webContents) return
if (typeof raw !== 'string') return
const message = parsePreviewAgentMessage(raw)
if (!message) return
const event = message.type === 'selection'
? await this.withNativeSelectionScreenshot(message)
: message
renderer?.send(ELECTRON_EVENT_CHANNELS.previewEvent, event)
}
private ensureView(parent: PreviewParentWindowLike): PreviewViewLike {
if (this.view) return this.view
const view = this.createView()
parent.contentView.addChildView(view)
view.webContents.on('did-finish-load', () => {
void this.injectPreviewAgent(view)
})
this.applyZoomFactor(view)
this.view = view
this.parent = parent
return view
}
private requireView(): PreviewViewLike {
if (!this.view) throw new Error('preview not open')
return this.view
}
private async injectPreviewAgent(view: PreviewViewLike): Promise<void> {
if (view.webContents.isDestroyed?.()) return
const script = readFileSync(resolvePreviewScriptPath(this.previewScriptPath), 'utf8')
await view.webContents.executeJavaScript(script)
}
private async captureNativeDataUrl(): Promise<string> {
const webContents = this.requireView().webContents
if (!webContents.capturePage) throw new Error('native preview capture unavailable')
const image = await webContents.capturePage()
return image.toDataURL()
}
private applyZoomFactor(view: PreviewViewLike | null): void {
view?.webContents.setZoomFactor?.(this.zoomFactor)
}
private async captureScreenshotToRenderer(kind: PreviewHostCaptureMessage['kind'], renderer: PreviewWebContentsLike): Promise<void> {
try {
renderer.send(ELECTRON_EVENT_CHANNELS.previewEvent, {
v: 1,
type: 'screenshot',
dataUrl: await this.captureNativeDataUrl(),
kind,
})
} catch (error) {
renderer.send(ELECTRON_EVENT_CHANNELS.previewEvent, {
v: 1,
type: 'error',
message: String(error),
})
}
}
private async withNativeSelectionScreenshot(message: Extract<PreviewAgentMessage, { type: 'selection' }>): Promise<PreviewAgentMessage> {
try {
const payload = message.payload
const screenshot = isPlainRecord(payload.screenshot) ? payload.screenshot : {}
return {
...message,
payload: {
...payload,
screenshot: {
...screenshot,
kind: screenshot.kind ?? 'region',
dataUrl: await this.captureNativeDataUrl(),
},
},
}
} catch {
return message
} finally {
await this.clearSelectionOverlay()
}
}
private async clearSelectionOverlay(): Promise<void> {
const webContents = this.view?.webContents
if (!webContents || webContents.isDestroyed?.()) return
try {
await webContents.executeJavaScript('globalThis.__PREVIEW_AGENT_CLEAR_SELECTION_OVERLAY__?.()')
} catch {
// The page may navigate while the native capture is in flight.
}
}
}