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
This commit is contained in:
程序员阿江(Relakkes) 2026-07-06 18:48:28 +08:00
parent 790b0925ec
commit 15b3acc85b
7 changed files with 172 additions and 67 deletions

View File

@ -182,6 +182,10 @@ function getTerminalService() {
function getPreviewService() {
previewService ??= new ElectronPreviewService({
previewScriptPath: previewAgentPath(),
resolveScaleFactor: parent => {
const bounds = parent.getBounds?.()
return bounds ? screen.getDisplayMatching(bounds).scaleFactor : 1
},
createView: () => {
const view = new WebContentsView({
webPreferences: {
@ -400,6 +404,11 @@ registerIpcHandlers()
app.whenReady().then(async () => {
applyWindowsAppUserModelId(app)
applyStartupPortableMode(app)
screen.on('display-metrics-changed', (_event, _display, changedMetrics) => {
if (changedMetrics.includes('scaleFactor') || changedMetrics.includes('bounds')) {
previewService?.refreshBounds()
}
})
await getServerRuntime().startServer().catch(error => {
console.error('[desktop] failed to start Electron server sidecar', error)
})

View File

@ -9,6 +9,7 @@ import {
normalizePreviewUrl,
parsePreviewAgentMessage,
resolvePreviewScriptPath,
snapPreviewBoundsToScaleFactor,
shouldForwardPreviewMessage,
type PreviewViewLike,
type PreviewWebContentsLike,
@ -88,16 +89,37 @@ describe('Electron preview service', () => {
expect(() => normalizePreviewUrl('javascript:alert(1)')).toThrow('unsupported url scheme')
})
it('normalizes finite bounds for WebContentsView', () => {
it('normalizes finite bounds for WebContentsView without dropping high-DPI fractions', () => {
expect(normalizePreviewBounds({ x: 1.2, y: 2.7, width: 20.4, height: -1 })).toEqual({
x: 1,
y: 3,
width: 20,
x: 1.2,
y: 2.7,
width: 20.4,
height: 0,
})
expect(() => normalizePreviewBounds({ x: Number.NaN, y: 0, width: 1, height: 1 })).toThrow('invalid preview bounds x')
})
it('snaps preview bounds to physical pixels at fractional Windows scale factors', () => {
const snapped = snapPreviewBoundsToScaleFactor({
x: 1.1,
y: 2.2,
width: 10.3,
height: 4.4,
}, 2.25)
expect(snapped).toEqual({
x: 0.888889,
y: 2.222222,
width: 10.666667,
height: 4.444444,
})
expect(snapped.x * 2.25).toBeCloseTo(Math.round(snapped.x * 2.25), 5)
expect((snapped.x + snapped.width) * 2.25).toBeCloseTo(
Math.round((snapped.x + snapped.width) * 2.25),
5,
)
})
it('falls back from app.asar to app.asar.unpacked for the preview agent script', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-haha-preview-asar-'))
tempDirs.push(dir)
@ -138,6 +160,32 @@ describe('Electron preview service', () => {
expect(view.webContents.scripts).toEqual(['window.__previewInjected = true', 'window.__previewInjected = true'])
})
it('applies scale-aware snapped bounds and can refresh them after display metrics change', async () => {
const view = new FakeView()
const parent = {
contentView: {
addChildView: vi.fn(),
removeChildView: vi.fn(),
},
getBounds: () => ({ x: 0, y: 0, width: 1200, height: 800 }),
}
let scaleFactor = 1
const service = new ElectronPreviewService({
createView: () => view,
previewScriptPath: previewScript(),
resolveScaleFactor: () => scaleFactor,
})
await service.open(parent, 'http://localhost:5173', { x: 1.1, y: 2.2, width: 10.3, height: 4.4 })
scaleFactor = 2.25
service.refreshBounds()
expect(view.bounds).toEqual([
{ x: 1, y: 2, width: 10, height: 5 },
{ x: 0.888889, y: 2.222222, width: 10.666667, height: 4.444444 },
])
})
it('forwards only validated preview messages from the child view to the renderer', async () => {
const view = new FakeView()
const renderer = new FakeWebContents()
@ -226,6 +274,7 @@ describe('Electron preview service', () => {
await service.message({ v: 1, type: 'capture', kind: 'full' }, renderer)
expect(view.webContents.zoomFactors.at(-1)).toBe(0.8)
expect(view.bounds).toHaveLength(1)
expect(view.webContents.capturePage).toHaveBeenCalledTimes(1)
expect(renderer.sent.at(-1)).toEqual({
channel: ELECTRON_EVENT_CHANNELS.previewEvent,

View File

@ -33,11 +33,13 @@ export type PreviewParentWindowLike = {
addChildView(view: unknown): void
removeChildView(view: unknown): void
}
getBounds?(): PreviewBounds
}
export type ElectronPreviewServiceOptions = {
createView: () => PreviewViewLike
previewScriptPath: string
resolveScaleFactor?: (parent: PreviewParentWindowLike) => number
}
type PreviewHostCaptureMessage = {
@ -72,10 +74,35 @@ export function normalizePreviewBounds(bounds: PreviewBounds): PreviewBounds {
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)),
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),
}
}
@ -89,20 +116,24 @@ export function resolvePreviewScriptPath(previewScriptPath: string): string {
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)
const normalizedBounds = normalizePreviewBounds(bounds)
this.parent = parent
this.requestedBounds = normalizePreviewBounds(bounds)
const view = this.ensureView(parent)
view.setBounds(normalizedBounds)
this.applyBounds(view)
await view.webContents.loadURL(normalizedUrl)
}
@ -112,7 +143,8 @@ export class ElectronPreviewService {
}
setBounds(bounds: PreviewBounds): void {
this.view?.setBounds(normalizePreviewBounds(bounds))
this.requestedBounds = normalizePreviewBounds(bounds)
this.applyBounds(this.view)
}
setVisible(visible: boolean): void {
@ -124,6 +156,10 @@ export class ElectronPreviewService {
this.applyZoomFactor(this.view)
}
refreshBounds(): void {
this.applyBounds(this.view)
}
close(): void {
if (!this.view) return
this.parent?.contentView.removeChildView(this.view)
@ -132,6 +168,7 @@ export class ElectronPreviewService {
}
this.view = null
this.parent = null
this.requestedBounds = null
}
async message(payload: unknown, renderer?: PreviewWebContentsLike | null): Promise<void> {
@ -191,6 +228,12 @@ export class ElectronPreviewService {
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, {

View File

@ -182,14 +182,16 @@ describe('BrowserSurface', () => {
expect(bridge.message).toHaveBeenLastCalledWith({ v: 1, type: 'exit-picker' })
})
it('renders bottom-right preview zoom controls that update the native preview zoom', async () => {
it('renders toolbar preview zoom controls that update the native preview zoom', async () => {
useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/')
useBrowserPanelStore.getState().setReady('s1')
render(<BrowserSurface sessionId="s1" />)
const controls = screen.getByTestId('browser-zoom-controls')
const actions = screen.getByTestId('browser-toolbar-actions')
expect(controls).toHaveTextContent('100%')
expect(screen.getByTestId('preview-host').compareDocumentPosition(controls) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(actions).toContainElement(controls)
expect(controls.compareDocumentPosition(screen.getByTestId('preview-host')) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
fireEvent.click(screen.getByLabelText('缩小预览'))
expect(useBrowserPanelStore.getState().bySession['s1']!.zoom).toBe(0.9)

View File

@ -203,6 +203,55 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
'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-7 w-7 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"
className="inline-flex h-8 shrink-0 items-center gap-1 rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-1 shadow-sm"
>
<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
@ -236,20 +285,10 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
>
<MousePointer2 size={16} />
</button>
{zoomControls}
</>
)
const setPreviewZoom = (nextZoom: number) => {
store.setZoom(sessionId, normalizeBrowserZoom(nextZoom))
}
const zoomButtonClass = [
'inline-flex h-7 w-7 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(' ')
return (
<div className="flex h-full flex-col">
<BrowserAddressBar
@ -285,43 +324,6 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
</div>
)}
</div>
<div className="flex h-10 shrink-0 items-center justify-end border-t border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2">
<div
data-testid="browser-zoom-controls"
className="inline-flex h-8 items-center gap-1 rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-1 shadow-sm"
>
<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>
</div>
</div>
</div>
)

View File

@ -2,9 +2,9 @@ import { describe, expect, it } from 'vitest'
import { computeWebviewBounds } from './computeWebviewBounds'
describe('computeWebviewBounds', () => {
it('maps a DOMRect to logical bounds (rounded)', () => {
it('maps a DOMRect to logical bounds without rounding away high-DPI fractions', () => {
const rect = { left: 100.4, top: 50.6, width: 800.2, height: 600.9 } as DOMRect
expect(computeWebviewBounds(rect)).toEqual({ x: 100, y: 51, width: 800, height: 601 })
expect(computeWebviewBounds(rect)).toEqual({ x: 100.4, y: 50.6, width: 800.2, height: 600.9 })
})
it('clamps negative/zero sizes to 0', () => {

View File

@ -2,9 +2,9 @@ export type WebviewBounds = { x: number; y: number; width: number; height: numbe
export function computeWebviewBounds(rect: Pick<DOMRect, 'left' | 'top' | 'width' | 'height'>): WebviewBounds {
return {
x: Math.round(rect.left),
y: Math.round(rect.top),
width: Math.max(0, Math.round(rect.width)),
height: Math.max(0, Math.round(rect.height)),
x: rect.left,
y: rect.top,
width: Math.max(0, rect.width),
height: Math.max(0, rect.height),
}
}