From 15b3acc85be0b432b73e1d7a72f7aa62e2019774 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 6 Jul 2026 18:48:28 +0800 Subject: [PATCH] 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 --- desktop/electron/main.ts | 9 ++ desktop/electron/services/preview.test.ts | 57 ++++++++++- desktop/electron/services/preview.ts | 57 +++++++++-- .../browser/BrowserSurface.test.tsx | 6 +- .../src/components/browser/BrowserSurface.tsx | 98 ++++++++++--------- .../browser/computeWebviewBounds.test.ts | 4 +- .../browser/computeWebviewBounds.ts | 8 +- 7 files changed, 172 insertions(+), 67 deletions(-) diff --git a/desktop/electron/main.ts b/desktop/electron/main.ts index 43815362..a76b374c 100644 --- a/desktop/electron/main.ts +++ b/desktop/electron/main.ts @@ -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) }) diff --git a/desktop/electron/services/preview.test.ts b/desktop/electron/services/preview.test.ts index d5b21ace..82c703fa 100644 --- a/desktop/electron/services/preview.test.ts +++ b/desktop/electron/services/preview.test.ts @@ -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, diff --git a/desktop/electron/services/preview.ts b/desktop/electron/services/preview.ts index e8f0343f..cde4a17d 100644 --- a/desktop/electron/services/preview.ts +++ b/desktop/electron/services/preview.ts @@ -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 { 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 { @@ -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 { try { renderer.send(ELECTRON_EVENT_CHANNELS.previewEvent, { diff --git a/desktop/src/components/browser/BrowserSurface.test.tsx b/desktop/src/components/browser/BrowserSurface.test.tsx index 3432e5ee..bde22b24 100644 --- a/desktop/src/components/browser/BrowserSurface.test.tsx +++ b/desktop/src/components/browser/BrowserSurface.test.tsx @@ -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() 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) diff --git a/desktop/src/components/browser/BrowserSurface.tsx b/desktop/src/components/browser/BrowserSurface.tsx index 22b46f3d..e7a25b0a 100644 --- a/desktop/src/components/browser/BrowserSurface.tsx +++ b/desktop/src/components/browser/BrowserSurface.tsx @@ -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 = ( +
+ + + {zoomPercent}% + + + +
+ ) + const previewActions = ( <> + {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 (
)}
-
-
- - - {zoomPercent}% - - - -
-
) diff --git a/desktop/src/components/browser/computeWebviewBounds.test.ts b/desktop/src/components/browser/computeWebviewBounds.test.ts index 0491bc5f..31e324eb 100644 --- a/desktop/src/components/browser/computeWebviewBounds.test.ts +++ b/desktop/src/components/browser/computeWebviewBounds.test.ts @@ -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', () => { diff --git a/desktop/src/components/browser/computeWebviewBounds.ts b/desktop/src/components/browser/computeWebviewBounds.ts index cc5568b6..2c6564b4 100644 --- a/desktop/src/components/browser/computeWebviewBounds.ts +++ b/desktop/src/components/browser/computeWebviewBounds.ts @@ -2,9 +2,9 @@ export type WebviewBounds = { x: number; y: number; width: number; height: numbe export function computeWebviewBounds(rect: Pick): 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), } }