mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
790b0925ec
commit
15b3acc85b
@ -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)
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user