程序员阿江(Relakkes) 15b3acc85b fix: stabilize browser preview zoom on high DPI
Constraint: Keep preview zoom native through WebContentsView.setZoomFactor so screenshot and selection coordinates stay aligned.
Tested: cd desktop && bun run test -- src/components/browser/BrowserSurface.test.tsx src/components/browser/computeWebviewBounds.test.ts
Tested: cd desktop && bun test electron/services/preview.test.ts
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run check:electron
Tested: cd desktop && bun run build
Not-tested: Windows 225% display-scaling smoke; no Windows host is available in this worktree.
Confidence: medium
Scope-risk: moderate
2026-07-06 18:48:42 +08:00

286 lines
9.3 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
}
getBounds?(): PreviewBounds
}
export type ElectronPreviewServiceOptions = {
createView: () => PreviewViewLike
previewScriptPath: string
resolveScaleFactor?: (parent: PreviewParentWindowLike) => number
}
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: bounds.x,
y: bounds.y,
width: Math.max(0, bounds.width),
height: Math.max(0, bounds.height),
}
}
function normalizeScaleFactor(value: unknown): number {
const numeric = typeof value === 'number' ? value : Number(value)
return Number.isFinite(numeric) && numeric > 0 ? numeric : 1
}
function roundDip(value: number): number {
return Math.round(value * 1000000) / 1000000
}
export function snapPreviewBoundsToScaleFactor(bounds: PreviewBounds, scaleFactor: unknown): PreviewBounds {
const normalized = normalizePreviewBounds(bounds)
const factor = normalizeScaleFactor(scaleFactor)
const left = Math.round(normalized.x * factor)
const top = Math.round(normalized.y * factor)
const right = Math.round((normalized.x + normalized.width) * factor)
const bottom = Math.round((normalized.y + normalized.height) * factor)
return {
x: roundDip(left / factor),
y: roundDip(top / factor),
width: roundDip(Math.max(0, right - left) / factor),
height: roundDip(Math.max(0, bottom - top) / factor),
}
}
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 readonly resolveScaleFactor?: (parent: PreviewParentWindowLike) => number
private view: PreviewViewLike | null = null
private parent: PreviewParentWindowLike | null = null
private requestedBounds: PreviewBounds | null = null
private zoomFactor = 1
constructor(options: ElectronPreviewServiceOptions) {
this.createView = options.createView
this.previewScriptPath = options.previewScriptPath
this.resolveScaleFactor = options.resolveScaleFactor
}
async open(parent: PreviewParentWindowLike, url: string, bounds: PreviewBounds): Promise<void> {
const normalizedUrl = normalizePreviewUrl(url)
this.parent = parent
this.requestedBounds = normalizePreviewBounds(bounds)
const view = this.ensureView(parent)
this.applyBounds(view)
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.requestedBounds = normalizePreviewBounds(bounds)
this.applyBounds(this.view)
}
setVisible(visible: boolean): void {
this.view?.setVisible?.(visible)
}
setZoomFactor(value: unknown): void {
this.zoomFactor = normalizeZoomFactor(value)
this.applyZoomFactor(this.view)
}
refreshBounds(): void {
this.applyBounds(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
this.requestedBounds = 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 applyBounds(view: PreviewViewLike | null): void {
if (!view || !this.parent || !this.requestedBounds) return
const scaleFactor = this.resolveScaleFactor?.(this.parent) ?? 1
view.setBounds(snapPreviewBoundsToScaleFactor(this.requestedBounds, scaleFactor))
}
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.
}
}
}