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
This commit is contained in:
程序员阿江(Relakkes) 2026-07-02 20:30:59 +08:00
parent 82be8f45dd
commit e64cd80662
16 changed files with 277 additions and 25 deletions

View File

@ -107,6 +107,7 @@ export const ELECTRON_IPC_VALIDATORS = {
[ELECTRON_IPC_CHANNELS.previewNavigate]: stringPayload, [ELECTRON_IPC_CHANNELS.previewNavigate]: stringPayload,
[ELECTRON_IPC_CHANNELS.previewSetBounds]: boundsPayload, [ELECTRON_IPC_CHANNELS.previewSetBounds]: boundsPayload,
[ELECTRON_IPC_CHANNELS.previewSetVisible]: booleanPayload, [ELECTRON_IPC_CHANNELS.previewSetVisible]: booleanPayload,
[ELECTRON_IPC_CHANNELS.previewSetZoom]: zoomPayload,
[ELECTRON_IPC_CHANNELS.previewClose]: noPayload, [ELECTRON_IPC_CHANNELS.previewClose]: noPayload,
[ELECTRON_IPC_CHANNELS.previewMessage]: () => true, [ELECTRON_IPC_CHANNELS.previewMessage]: () => true,
[ELECTRON_IPC_CHANNELS.appModeGet]: noPayload, [ELECTRON_IPC_CHANNELS.appModeGet]: noPayload,

View File

@ -36,6 +36,7 @@ export const ELECTRON_IPC_CHANNELS = {
previewNavigate: 'desktop:preview:navigate', previewNavigate: 'desktop:preview:navigate',
previewSetBounds: 'desktop:preview:set-bounds', previewSetBounds: 'desktop:preview:set-bounds',
previewSetVisible: 'desktop:preview:set-visible', previewSetVisible: 'desktop:preview:set-visible',
previewSetZoom: 'desktop:preview:set-zoom',
previewClose: 'desktop:preview:close', previewClose: 'desktop:preview:close',
previewMessage: 'desktop:preview:message', previewMessage: 'desktop:preview:message',
appModeGet: 'desktop:app-mode:get', appModeGet: 'desktop:app-mode:get',

View File

@ -327,6 +327,7 @@ function registerIpcHandlers() {
registerHandler(ELECTRON_IPC_CHANNELS.previewNavigate, (_event, payload) => getPreviewService().navigate(String(payload))) registerHandler(ELECTRON_IPC_CHANNELS.previewNavigate, (_event, payload) => getPreviewService().navigate(String(payload)))
registerHandler(ELECTRON_IPC_CHANNELS.previewSetBounds, (_event, payload) => getPreviewService().setBounds(payload as PreviewBounds)) registerHandler(ELECTRON_IPC_CHANNELS.previewSetBounds, (_event, payload) => getPreviewService().setBounds(payload as PreviewBounds))
registerHandler(ELECTRON_IPC_CHANNELS.previewSetVisible, (_event, payload) => getPreviewService().setVisible(Boolean(payload))) registerHandler(ELECTRON_IPC_CHANNELS.previewSetVisible, (_event, payload) => getPreviewService().setVisible(Boolean(payload)))
registerHandler(ELECTRON_IPC_CHANNELS.previewSetZoom, (_event, payload) => getPreviewService().setZoomFactor(payload))
registerHandler(ELECTRON_IPC_CHANNELS.previewClose, () => getPreviewService().close()) registerHandler(ELECTRON_IPC_CHANNELS.previewClose, () => getPreviewService().close())
registerHandler(ELECTRON_IPC_CHANNELS.previewMessage, (event, payload) => getPreviewService().message(payload, event.sender)) registerHandler(ELECTRON_IPC_CHANNELS.previewMessage, (event, payload) => getPreviewService().message(payload, event.sender))
registerHandler(ELECTRON_IPC_CHANNELS.appModeGet, () => getAppMode(app)) registerHandler(ELECTRON_IPC_CHANNELS.appModeGet, () => getAppMode(app))

View File

@ -17,6 +17,7 @@ import {
class FakeWebContents implements PreviewWebContentsLike { class FakeWebContents implements PreviewWebContentsLike {
loadedUrls: string[] = [] loadedUrls: string[] = []
scripts: string[] = [] scripts: string[] = []
zoomFactors: number[] = []
sent: Array<{ channel: string, payload: unknown }> = [] sent: Array<{ channel: string, payload: unknown }> = []
close = vi.fn() close = vi.fn()
capturePage = vi.fn(async () => ({ toDataURL: () => 'data:image/png;base64,NATIVE' })) capturePage = vi.fn(async () => ({ toDataURL: () => 'data:image/png;base64,NATIVE' }))
@ -40,6 +41,10 @@ class FakeWebContents implements PreviewWebContentsLike {
return false return false
} }
setZoomFactor(factor: number) {
this.zoomFactors.push(factor)
}
send(channel: string, payload: unknown) { send(channel: string, payload: unknown) {
this.sent.push({ channel, payload }) this.sent.push({ channel, payload })
} }
@ -203,6 +208,31 @@ describe('Electron preview service', () => {
]) ])
}) })
it('applies preview zoom to the native WebContentsView before screenshot capture', async () => {
const view = new FakeView()
const renderer = new FakeWebContents()
const service = new ElectronPreviewService({
createView: () => view,
previewScriptPath: previewScript(),
})
await service.open({ contentView: { addChildView: vi.fn(), removeChildView: vi.fn() } }, 'https://example.com', {
x: 0,
y: 0,
width: 100,
height: 100,
})
service.setZoomFactor(0.8)
await service.message({ v: 1, type: 'capture', kind: 'full' }, renderer)
expect(view.webContents.zoomFactors.at(-1)).toBe(0.8)
expect(view.webContents.capturePage).toHaveBeenCalledTimes(1)
expect(renderer.sent.at(-1)).toEqual({
channel: ELECTRON_EVENT_CHANNELS.previewEvent,
payload: { v: 1, type: 'screenshot', dataUrl: 'data:image/png;base64,NATIVE', kind: 'full' },
})
})
it('forwards picker host messages into the injected preview bridge', async () => { it('forwards picker host messages into the injected preview bridge', async () => {
const view = new FakeView() const view = new FakeView()
const service = new ElectronPreviewService({ const service = new ElectronPreviewService({

View File

@ -1,6 +1,7 @@
import { existsSync, readFileSync } from 'node:fs' import { existsSync, readFileSync } from 'node:fs'
import { ELECTRON_EVENT_CHANNELS } from '../ipc/channels' import { ELECTRON_EVENT_CHANNELS } from '../ipc/channels'
import { parsePreviewAgentMessage, type PreviewAgentMessage } from '../ipc/previewMessage' import { parsePreviewAgentMessage, type PreviewAgentMessage } from '../ipc/previewMessage'
import { normalizeZoomFactor } from './zoom'
export { parsePreviewAgentMessage, shouldForwardPreviewMessage } from '../ipc/previewMessage' export { parsePreviewAgentMessage, shouldForwardPreviewMessage } from '../ipc/previewMessage'
export type PreviewBounds = { export type PreviewBounds = {
@ -17,6 +18,7 @@ export type PreviewWebContentsLike = {
close?(): void close?(): void
isDestroyed?(): boolean isDestroyed?(): boolean
capturePage?(): Promise<{ toDataURL(): string }> capturePage?(): Promise<{ toDataURL(): string }>
setZoomFactor?(factor: number): void
send(channel: string, payload: unknown): void send(channel: string, payload: unknown): void
} }
@ -89,6 +91,7 @@ export class ElectronPreviewService {
private readonly previewScriptPath: string private readonly previewScriptPath: string
private view: PreviewViewLike | null = null private view: PreviewViewLike | null = null
private parent: PreviewParentWindowLike | null = null private parent: PreviewParentWindowLike | null = null
private zoomFactor = 1
constructor(options: ElectronPreviewServiceOptions) { constructor(options: ElectronPreviewServiceOptions) {
this.createView = options.createView this.createView = options.createView
@ -116,6 +119,11 @@ export class ElectronPreviewService {
this.view?.setVisible?.(visible) this.view?.setVisible?.(visible)
} }
setZoomFactor(value: unknown): void {
this.zoomFactor = normalizeZoomFactor(value)
this.applyZoomFactor(this.view)
}
close(): void { close(): void {
if (!this.view) return if (!this.view) return
this.parent?.contentView.removeChildView(this.view) this.parent?.contentView.removeChildView(this.view)
@ -155,6 +163,7 @@ export class ElectronPreviewService {
view.webContents.on('did-finish-load', () => { view.webContents.on('did-finish-load', () => {
void this.injectPreviewAgent(view) void this.injectPreviewAgent(view)
}) })
this.applyZoomFactor(view)
this.view = view this.view = view
this.parent = parent this.parent = parent
return view return view
@ -178,6 +187,10 @@ export class ElectronPreviewService {
return image.toDataURL() return image.toDataURL()
} }
private applyZoomFactor(view: PreviewViewLike | null): void {
view?.webContents.setZoomFactor?.(this.zoomFactor)
}
private async captureScreenshotToRenderer(kind: PreviewHostCaptureMessage['kind'], renderer: PreviewWebContentsLike): Promise<void> { private async captureScreenshotToRenderer(kind: PreviewHostCaptureMessage['kind'], renderer: PreviewWebContentsLike): Promise<void> {
try { try {
renderer.send(ELECTRON_EVENT_CHANNELS.previewEvent, { renderer.send(ELECTRON_EVENT_CHANNELS.previewEvent, {

View File

@ -10,7 +10,15 @@ beforeAll(() => {
}) })
const { bridge } = vi.hoisted(() => ({ const { bridge } = vi.hoisted(() => ({
bridge: { open: vi.fn(), navigate: vi.fn(), setBounds: vi.fn(), setVisible: vi.fn(), close: vi.fn(), message: vi.fn() }, bridge: {
open: vi.fn(),
navigate: vi.fn(),
setBounds: vi.fn(),
setVisible: vi.fn(),
setZoom: vi.fn(),
close: vi.fn(),
message: vi.fn(),
},
})) }))
vi.mock('../../lib/previewBridge', () => ({ previewBridge: bridge })) vi.mock('../../lib/previewBridge', () => ({ previewBridge: bridge }))
vi.mock('@tauri-apps/api/event', () => ({ listen: () => Promise.resolve(() => {}) })) vi.mock('@tauri-apps/api/event', () => ({ listen: () => Promise.resolve(() => {}) }))
@ -36,7 +44,9 @@ describe('BrowserSurface', () => {
it('opens the preview at the session url on mount when surface is open', () => { it('opens the preview at the session url on mount when surface is open', () => {
useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/') useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/')
render(<BrowserSurface sessionId="s1" />) render(<BrowserSurface sessionId="s1" />)
expect(bridge.open).toHaveBeenCalledWith('http://localhost:5173/', expect.objectContaining({ width: expect.any(Number) })) return waitFor(() => {
expect(bridge.open).toHaveBeenCalledWith('http://localhost:5173/', expect.objectContaining({ width: expect.any(Number) }))
})
}) })
it('waits for local preview URLs before opening the native preview', async () => { it('waits for local preview URLs before opening the native preview', async () => {
@ -68,13 +78,15 @@ describe('BrowserSurface', () => {
expect(bridge.open).not.toHaveBeenCalled() expect(bridge.open).not.toHaveBeenCalled()
}) })
it('first navigation from a blank session opens the native preview', () => { it('first navigation from a blank session opens the native preview', async () => {
useBrowserPanelStore.getState().ensureBlank('s1') useBrowserPanelStore.getState().ensureBlank('s1')
render(<BrowserSurface sessionId="s1" />) render(<BrowserSurface sessionId="s1" />)
const input = screen.getByRole('textbox') const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'localhost:3000' } }) fireEvent.change(input, { target: { value: 'localhost:3000' } })
fireEvent.submit(input.closest('form')!) fireEvent.submit(input.closest('form')!)
expect(bridge.open).toHaveBeenCalledWith('http://localhost:3000', expect.objectContaining({ width: expect.any(Number) })) await waitFor(() => {
expect(bridge.open).toHaveBeenCalledWith('http://localhost:3000', expect.objectContaining({ width: expect.any(Number) }))
})
expect(bridge.navigate).not.toHaveBeenCalled() expect(bridge.navigate).not.toHaveBeenCalled()
}) })
@ -99,13 +111,15 @@ describe('BrowserSurface', () => {
) )
}) })
it('navigating via address bar calls store + bridge', () => { it('navigating via address bar calls store + bridge', async () => {
useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/') useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/')
render(<BrowserSurface sessionId="s1" />) render(<BrowserSurface sessionId="s1" />)
const input = screen.getByRole('textbox') const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'http://localhost:3000/' } }) fireEvent.change(input, { target: { value: 'http://localhost:3000/' } })
fireEvent.submit(input.closest('form')!) fireEvent.submit(input.closest('form')!)
expect(bridge.navigate).toHaveBeenCalledWith('http://localhost:3000/') await waitFor(() => {
expect(bridge.navigate).toHaveBeenCalledWith('http://localhost:3000/')
})
expect(useBrowserPanelStore.getState().bySession['s1']!.url).toBe('http://localhost:3000/') expect(useBrowserPanelStore.getState().bySession['s1']!.url).toBe('http://localhost:3000/')
}) })
@ -168,6 +182,43 @@ describe('BrowserSurface', () => {
expect(bridge.message).toHaveBeenLastCalledWith({ v: 1, type: 'exit-picker' }) expect(bridge.message).toHaveBeenLastCalledWith({ v: 1, type: 'exit-picker' })
}) })
it('renders bottom-right 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')
expect(controls).toHaveTextContent('100%')
expect(screen.getByTestId('preview-host').compareDocumentPosition(controls) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
fireEvent.click(screen.getByLabelText('缩小预览'))
expect(useBrowserPanelStore.getState().bySession['s1']!.zoom).toBe(0.9)
await waitFor(() => {
expect(bridge.setZoom).toHaveBeenLastCalledWith(0.9)
})
expect(controls).toHaveTextContent('90%')
fireEvent.click(screen.getByLabelText('重置预览缩放'))
expect(useBrowserPanelStore.getState().bySession['s1']!.zoom).toBe(1)
await waitFor(() => {
expect(bridge.setZoom).toHaveBeenLastCalledWith(1)
})
})
it('applies the session zoom before opening the native preview', async () => {
useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/')
useBrowserPanelStore.getState().setZoom('s1', 0.8)
render(<BrowserSurface sessionId="s1" />)
await waitFor(() => {
expect(bridge.open).toHaveBeenCalled()
})
expect(bridge.setZoom).toHaveBeenCalledWith(0.8)
expect(bridge.setZoom.mock.invocationCallOrder[0]!).toBeLessThan(
bridge.open.mock.invocationCallOrder[0]!,
)
})
it('renders the loading indicator while the session is loading (open starts loading)', () => { it('renders the loading indicator while the session is loading (open starts loading)', () => {
useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/') useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/')
render(<BrowserSurface sessionId="s1" />) render(<BrowserSurface sessionId="s1" />)
@ -183,14 +234,16 @@ describe('BrowserSurface', () => {
expect(screen.getByLabelText('刷新')).toHaveAttribute('aria-busy', 'false') expect(screen.getByLabelText('刷新')).toHaveAttribute('aria-busy', 'false')
}) })
it('reload flips the session back into loading and shows the indicator', () => { it('reload flips the session back into loading and shows the indicator', async () => {
useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/') useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/')
useBrowserPanelStore.getState().setReady('s1') useBrowserPanelStore.getState().setReady('s1')
render(<BrowserSurface sessionId="s1" />) render(<BrowserSurface sessionId="s1" />)
expect(screen.queryByTestId('browser-loading-bar')).not.toBeInTheDocument() expect(screen.queryByTestId('browser-loading-bar')).not.toBeInTheDocument()
fireEvent.click(screen.getByLabelText('刷新')) fireEvent.click(screen.getByLabelText('刷新'))
expect(useBrowserPanelStore.getState().bySession['s1']!.loading).toBe(true) expect(useBrowserPanelStore.getState().bySession['s1']!.loading).toBe(true)
expect(bridge.navigate).toHaveBeenCalledWith('http://localhost:5173/') await waitFor(() => {
expect(bridge.navigate).toHaveBeenCalledWith('http://localhost:5173/')
})
expect(screen.getByTestId('browser-loading-bar')).toBeInTheDocument() expect(screen.getByTestId('browser-loading-bar')).toBeInTheDocument()
}) })

View File

@ -1,5 +1,5 @@
import { useEffect, useLayoutEffect, useRef } from 'react' import { useEffect, useLayoutEffect, useRef } from 'react'
import { Camera, Loader2, MousePointer2 } from 'lucide-react' import { Camera, Loader2, Minus, MousePointer2, Plus, RotateCcw } from 'lucide-react'
import { BrowserAddressBar } from './BrowserAddressBar' import { BrowserAddressBar } from './BrowserAddressBar'
import { computeWebviewBounds } from './computeWebviewBounds' import { computeWebviewBounds } from './computeWebviewBounds'
import { getServerBaseUrl, isLoopbackHostname } from '../../lib/desktopRuntime' import { getServerBaseUrl, isLoopbackHostname } from '../../lib/desktopRuntime'
@ -7,7 +7,14 @@ import { classifyPreviewLink } from '../../lib/previewLinkRouter'
import { isAbsoluteLocalPath, localFileUrl, previewFsUrl } from '../../lib/handlePreviewLink' import { isAbsoluteLocalPath, localFileUrl, previewFsUrl } from '../../lib/handlePreviewLink'
import { previewBridge } from '../../lib/previewBridge' import { previewBridge } from '../../lib/previewBridge'
import { subscribePreviewEvents } from '../../lib/previewEvents' import { subscribePreviewEvents } from '../../lib/previewEvents'
import { useBrowserPanelStore } from '../../stores/browserPanelStore' import {
BROWSER_ZOOM_STEP,
DEFAULT_BROWSER_ZOOM,
MAX_BROWSER_ZOOM,
MIN_BROWSER_ZOOM,
normalizeBrowserZoom,
useBrowserPanelStore,
} from '../../stores/browserPanelStore'
import { useOverlayStore } from '../../stores/overlayStore' import { useOverlayStore } from '../../stores/overlayStore'
const LOCAL_PREVIEW_PATH_PREFIXES = ['/preview-fs/', '/local-file/'] const LOCAL_PREVIEW_PATH_PREFIXES = ['/preview-fs/', '/local-file/']
@ -63,6 +70,10 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
const session = useBrowserPanelStore((s) => s.bySession[sessionId]) const session = useBrowserPanelStore((s) => s.bySession[sessionId])
const store = useBrowserPanelStore.getState() const store = useBrowserPanelStore.getState()
const overlayCount = useOverlayStore((s) => s.count) const overlayCount = useOverlayStore((s) => s.count)
const previewZoom = session?.zoom ?? DEFAULT_BROWSER_ZOOM
const zoomPercent = Math.round(previewZoom * 100)
const canZoomOut = previewZoom > MIN_BROWSER_ZOOM
const canZoomIn = previewZoom < MAX_BROWSER_ZOOM
const reportBounds = () => { const reportBounds = () => {
const el = hostRef.current const el = hostRef.current
@ -98,6 +109,7 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
requestedUrlRef.current = url requestedUrlRef.current = url
loadNativePreview(url, async () => { loadNativePreview(url, async () => {
await previewBridge.setZoom(previewZoom)
if (hasNativePreviewRef.current) { if (hasNativePreviewRef.current) {
await previewBridge.navigate(url) await previewBridge.navigate(url)
return return
@ -144,6 +156,11 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
previewBridge.setVisible(overlayCount === 0) previewBridge.setVisible(overlayCount === 0)
}, [overlayCount, session]) }, [overlayCount, session])
useEffect(() => {
if (!session) return
void previewBridge.setZoom(previewZoom)
}, [previewZoom, session])
useEffect(() => { useEffect(() => {
const el = hostRef.current const el = hostRef.current
if (!el) return if (!el) return
@ -222,6 +239,17 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
</> </>
) )
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 ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<BrowserAddressBar <BrowserAddressBar
@ -249,12 +277,51 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
}} }}
rightActions={previewActions} rightActions={previewActions}
/> />
<div ref={hostRef} className="relative flex-1 overflow-hidden" data-testid="preview-host"> <div className="flex min-h-0 flex-1 flex-col bg-[var(--color-surface)]">
{session.loading && ( <div ref={hostRef} className="relative min-h-0 flex-1 overflow-hidden" data-testid="preview-host">
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-[var(--color-surface)] text-[var(--color-text-tertiary)]"> {session.loading && (
<Loader2 size={18} className="animate-spin" aria-label="加载中" /> <div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-[var(--color-surface)] text-[var(--color-text-tertiary)]">
<Loader2 size={18} className="animate-spin" aria-label="加载中" />
</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> </div>
</div> </div>
) )

View File

@ -2,7 +2,7 @@
// the unified workbench switches from browser mode to file mode. Uses the REAL // the unified workbench switches from browser mode to file mode. Uses the REAL
// BrowserSurface so the unmount-cleanup path that closes the webview is exercised. // BrowserSurface so the unmount-cleanup path that closes the webview is exercised.
import '@testing-library/jest-dom' import '@testing-library/jest-dom'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => { beforeAll(() => {
@ -19,6 +19,7 @@ const { bridge } = vi.hoisted(() => ({
navigate: vi.fn(), navigate: vi.fn(),
setBounds: vi.fn(), setBounds: vi.fn(),
setVisible: vi.fn(), setVisible: vi.fn(),
setZoom: vi.fn(),
close: vi.fn(), close: vi.fn(),
eval: vi.fn(), eval: vi.fn(),
}, },
@ -56,15 +57,17 @@ afterEach(() => {
}) })
describe('WorkbenchPanel native webview lifecycle', () => { describe('WorkbenchPanel native webview lifecycle', () => {
it('shows the webview in browser mode and hides it when switching to file mode', () => { it('shows the webview in browser mode and hides it when switching to file mode', async () => {
render(<WorkbenchPanel sessionId={SESSION_ID} />) render(<WorkbenchPanel sessionId={SESSION_ID} />)
// Browser mode mounts BrowserSurface, which opens + shows the native webview. // Browser mode mounts BrowserSurface, which opens + shows the native webview.
expect(screen.getByTestId('preview-host')).toBeInTheDocument() expect(screen.getByTestId('preview-host')).toBeInTheDocument()
expect(bridge.open).toHaveBeenCalledWith( await waitFor(() => {
'http://localhost:5173/', expect(bridge.open).toHaveBeenCalledWith(
expect.objectContaining({ width: expect.any(Number) }), 'http://localhost:5173/',
) expect.objectContaining({ width: expect.any(Number) }),
)
})
expect(bridge.setVisible).toHaveBeenCalledWith(true) expect(bridge.setVisible).toHaveBeenCalledWith(true)
bridge.close.mockClear() bridge.close.mockClear()

View File

@ -220,6 +220,9 @@ export const browserHost: DesktopHost = {
async setVisible() { async setVisible() {
unsupported('Native preview webview') unsupported('Native preview webview')
}, },
async setZoom() {
unsupported('Native preview webview')
},
async close() { async close() {
unsupported('Native preview webview') unsupported('Native preview webview')
}, },

View File

@ -83,6 +83,18 @@ describe('electron desktop host', () => {
expect(invoke).toHaveBeenCalledWith(ELECTRON_IPC_CHANNELS.traceOpenWindow, 'session-123') expect(invoke).toHaveBeenCalledWith(ELECTRON_IPC_CHANNELS.traceOpenWindow, 'session-123')
}) })
it('routes preview zoom through the preview IPC channel', async () => {
const invoke = vi.fn().mockResolvedValue(undefined)
const host = createElectronHost({
invoke,
subscribe: vi.fn(),
})
await host.preview.setZoom(0.8)
expect(invoke).toHaveBeenCalledWith(ELECTRON_IPC_CHANNELS.previewSetZoom, 0.8)
})
it('keeps event subscriptions behind named event channels', async () => { it('keeps event subscriptions behind named event channels', async () => {
const unlisten = vi.fn() const unlisten = vi.fn()
const subscribe = vi.fn().mockResolvedValue(unlisten) const subscribe = vi.fn().mockResolvedValue(unlisten)

View File

@ -145,6 +145,7 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost {
navigate: url => invoke(ELECTRON_IPC_CHANNELS.previewNavigate, url), navigate: url => invoke(ELECTRON_IPC_CHANNELS.previewNavigate, url),
setBounds: bounds => invoke(ELECTRON_IPC_CHANNELS.previewSetBounds, bounds), setBounds: bounds => invoke(ELECTRON_IPC_CHANNELS.previewSetBounds, bounds),
setVisible: visible => invoke(ELECTRON_IPC_CHANNELS.previewSetVisible, visible), setVisible: visible => invoke(ELECTRON_IPC_CHANNELS.previewSetVisible, visible),
setZoom: level => invoke(ELECTRON_IPC_CHANNELS.previewSetZoom, level),
close: () => invoke(ELECTRON_IPC_CHANNELS.previewClose), close: () => invoke(ELECTRON_IPC_CHANNELS.previewClose),
message: payload => invoke(ELECTRON_IPC_CHANNELS.previewMessage, payload), message: payload => invoke(ELECTRON_IPC_CHANNELS.previewMessage, payload),
onEvent: handler => subscribe(ELECTRON_EVENT_CHANNELS.previewEvent, handler), onEvent: handler => subscribe(ELECTRON_EVENT_CHANNELS.previewEvent, handler),

View File

@ -214,6 +214,7 @@ export type DesktopHost = {
navigate(url: string): Promise<void> navigate(url: string): Promise<void>
setBounds(bounds: PreviewBounds): Promise<void> setBounds(bounds: PreviewBounds): Promise<void>
setVisible(visible: boolean): Promise<void> setVisible(visible: boolean): Promise<void>
setZoom(level: number): Promise<void>
close(): Promise<void> close(): Promise<void>
message(payload: PreviewHostMessage): Promise<void> message(payload: PreviewHostMessage): Promise<void>
onEvent(handler: (event: unknown) => void): Promise<DesktopHostUnlisten> onEvent(handler: (event: unknown) => void): Promise<DesktopHostUnlisten>

View File

@ -7,6 +7,7 @@ const invoke = vi.fn()
function installElectronPreviewHost() { function installElectronPreviewHost() {
const open = vi.fn().mockResolvedValue(undefined) const open = vi.fn().mockResolvedValue(undefined)
const setBounds = vi.fn().mockResolvedValue(undefined) const setBounds = vi.fn().mockResolvedValue(undefined)
const setZoom = vi.fn().mockResolvedValue(undefined)
const message = vi.fn().mockResolvedValue(undefined) const message = vi.fn().mockResolvedValue(undefined)
window.desktopHost = { window.desktopHost = {
@ -21,11 +22,12 @@ function installElectronPreviewHost() {
...browserHost.preview, ...browserHost.preview,
open, open,
setBounds, setBounds,
setZoom,
message, message,
}, },
} }
return { open, setBounds, message } return { open, setBounds, setZoom, message }
} }
beforeEach(() => { beforeEach(() => {
@ -59,6 +61,14 @@ describe('previewBridge', () => {
expect(invoke).not.toHaveBeenCalled() expect(invoke).not.toHaveBeenCalled()
}) })
it('setZoom forwards to the Electron preview host', async () => {
const { setZoom } = installElectronPreviewHost()
const { previewBridge } = await import('./previewBridge')
await previewBridge.setZoom(0.8)
expect(setZoom).toHaveBeenCalledWith(0.8)
expect(invoke).not.toHaveBeenCalled()
})
it('message forwards structured host messages to the Electron preview host', async () => { it('message forwards structured host messages to the Electron preview host', async () => {
const { message } = installElectronPreviewHost() const { message } = installElectronPreviewHost()
const { previewBridge } = await import('./previewBridge') const { previewBridge } = await import('./previewBridge')
@ -81,6 +91,7 @@ describe('previewBridge', () => {
Reflect.deleteProperty(window, '__TAURI_INTERNALS__') Reflect.deleteProperty(window, '__TAURI_INTERNALS__')
const open = vi.fn().mockResolvedValue(undefined) const open = vi.fn().mockResolvedValue(undefined)
const setBounds = vi.fn().mockResolvedValue(undefined) const setBounds = vi.fn().mockResolvedValue(undefined)
const setZoom = vi.fn().mockResolvedValue(undefined)
const message = vi.fn().mockResolvedValue(undefined) const message = vi.fn().mockResolvedValue(undefined)
window.desktopHost = { window.desktopHost = {
@ -95,6 +106,7 @@ describe('previewBridge', () => {
...browserHost.preview, ...browserHost.preview,
open, open,
setBounds, setBounds,
setZoom,
message, message,
}, },
} }
@ -104,10 +116,12 @@ describe('previewBridge', () => {
await previewBridge.open('http://localhost/a', bounds) await previewBridge.open('http://localhost/a', bounds)
await previewBridge.setBounds(bounds) await previewBridge.setBounds(bounds)
await previewBridge.setZoom(0.75)
await previewBridge.message({ v: 1, type: 'enter-picker' }) await previewBridge.message({ v: 1, type: 'enter-picker' })
expect(open).toHaveBeenCalledWith('http://localhost/a', bounds) expect(open).toHaveBeenCalledWith('http://localhost/a', bounds)
expect(setBounds).toHaveBeenCalledWith(bounds) expect(setBounds).toHaveBeenCalledWith(bounds)
expect(setZoom).toHaveBeenCalledWith(0.75)
expect(message).toHaveBeenCalledWith({ v: 1, type: 'enter-picker' }) expect(message).toHaveBeenCalledWith({ v: 1, type: 'enter-picker' })
expect(invoke).not.toHaveBeenCalled() expect(invoke).not.toHaveBeenCalled()
}) })

View File

@ -12,6 +12,7 @@ export const previewBridge = {
navigate: (url: string) => getPreviewHost()?.navigate(url) ?? Promise.resolve(), navigate: (url: string) => getPreviewHost()?.navigate(url) ?? Promise.resolve(),
setBounds: (bounds: WebviewBounds) => getPreviewHost()?.setBounds(bounds) ?? Promise.resolve(), setBounds: (bounds: WebviewBounds) => getPreviewHost()?.setBounds(bounds) ?? Promise.resolve(),
setVisible: (visible: boolean) => getPreviewHost()?.setVisible(visible) ?? Promise.resolve(), setVisible: (visible: boolean) => getPreviewHost()?.setVisible(visible) ?? Promise.resolve(),
setZoom: (level: number) => getPreviewHost()?.setZoom(level) ?? Promise.resolve(),
close: () => getPreviewHost()?.close() ?? Promise.resolve(), close: () => getPreviewHost()?.close() ?? Promise.resolve(),
message: (payload: PreviewHostMessage) => getPreviewHost()?.message(payload) ?? Promise.resolve(), message: (payload: PreviewHostMessage) => getPreviewHost()?.message(payload) ?? Promise.resolve(),
} }

View File

@ -63,6 +63,35 @@ describe('browserPanelStore', () => {
expect(useBrowserPanelStore.getState().bySession['s1']!.pickerActive).toBe(true) expect(useBrowserPanelStore.getState().bySession['s1']!.pickerActive).toBe(true)
}) })
it('tracks preview zoom per session and clamps to supported bounds', () => {
const st = useBrowserPanelStore.getState()
st.open('s1', 'http://localhost/a')
st.open('s2', 'http://localhost/b')
expect(useBrowserPanelStore.getState().bySession['s1']!.zoom).toBe(1)
st.setZoom('s1', 0.8)
expect(useBrowserPanelStore.getState().bySession['s1']!.zoom).toBe(0.8)
expect(useBrowserPanelStore.getState().bySession['s2']!.zoom).toBe(1)
st.setZoom('s1', 0.1)
expect(useBrowserPanelStore.getState().bySession['s1']!.zoom).toBe(0.5)
st.setZoom('s1', 2)
expect(useBrowserPanelStore.getState().bySession['s1']!.zoom).toBe(1.5)
})
it('preserves browser zoom when the same session opens another target', () => {
const st = useBrowserPanelStore.getState()
st.open('s1', 'http://localhost/a')
st.setZoom('s1', 0.8)
st.open('s1', 'http://localhost/b')
expect(useBrowserPanelStore.getState().bySession['s1']!.url).toBe('http://localhost/b')
expect(useBrowserPanelStore.getState().bySession['s1']!.zoom).toBe(0.8)
})
it('open starts a session in the loading state', () => { it('open starts a session in the loading state', () => {
useBrowserPanelStore.getState().open('s1', 'http://localhost/a') useBrowserPanelStore.getState().open('s1', 'http://localhost/a')
expect(useBrowserPanelStore.getState().bySession['s1']!.loading).toBe(true) expect(useBrowserPanelStore.getState().bySession['s1']!.loading).toBe(true)

View File

@ -1,6 +1,18 @@
import { create } from 'zustand' import { create } from 'zustand'
import { useWorkspacePanelStore } from './workspacePanelStore' import { useWorkspacePanelStore } from './workspacePanelStore'
export const MIN_BROWSER_ZOOM = 0.5
export const MAX_BROWSER_ZOOM = 1.5
export const DEFAULT_BROWSER_ZOOM = 1
export const BROWSER_ZOOM_STEP = 0.1
export function normalizeBrowserZoom(value: unknown): number {
const numeric = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(numeric)) return DEFAULT_BROWSER_ZOOM
const clamped = Math.min(Math.max(numeric, MIN_BROWSER_ZOOM), MAX_BROWSER_ZOOM)
return Math.round(clamped * 10) / 10
}
export type BrowserSessionState = { export type BrowserSessionState = {
isOpen: boolean isOpen: boolean
url: string url: string
@ -11,6 +23,7 @@ export type BrowserSessionState = {
pickerActive: boolean pickerActive: boolean
canGoBack: boolean canGoBack: boolean
canGoForward: boolean canGoForward: boolean
zoom: number
} }
type BrowserPanelState = { type BrowserPanelState = {
@ -22,6 +35,7 @@ type BrowserPanelState = {
goForward: (sessionId: string) => void goForward: (sessionId: string) => void
setLoading: (sessionId: string, loading: boolean) => void setLoading: (sessionId: string, loading: boolean) => void
setPicker: (sessionId: string, active: boolean) => void setPicker: (sessionId: string, active: boolean) => void
setZoom: (sessionId: string, zoom: number) => void
close: (sessionId: string) => void close: (sessionId: string) => void
setNavigated: (sessionId: string, url: string, title: string) => void setNavigated: (sessionId: string, url: string, title: string) => void
setReady: (sessionId: string) => void setReady: (sessionId: string) => void
@ -37,6 +51,7 @@ const empty = (url = ''): BrowserSessionState => ({
pickerActive: false, pickerActive: false,
canGoBack: false, canGoBack: false,
canGoForward: false, canGoForward: false,
zoom: DEFAULT_BROWSER_ZOOM,
}) })
const withNav = (s: BrowserSessionState): BrowserSessionState => ({ const withNav = (s: BrowserSessionState): BrowserSessionState => ({
@ -49,9 +64,12 @@ const withNav = (s: BrowserSessionState): BrowserSessionState => ({
export const useBrowserPanelStore = create<BrowserPanelState>((set) => ({ export const useBrowserPanelStore = create<BrowserPanelState>((set) => ({
bySession: {}, bySession: {},
open: (sessionId, url) => { open: (sessionId, url) => {
set((st) => ({ set((st) => {
bySession: { ...st.bySession, [sessionId]: { ...empty(url), loading: true } }, const zoom = st.bySession[sessionId]?.zoom ?? DEFAULT_BROWSER_ZOOM
})) return {
bySession: { ...st.bySession, [sessionId]: { ...empty(url), zoom, loading: true } },
}
})
// The browser is one mode of the unified workbench: opening a previewable // The browser is one mode of the unified workbench: opening a previewable
// link / localhost url surfaces the workbench in BROWSER mode. Import is // link / localhost url surfaces the workbench in BROWSER mode. Import is
// one-directional (workspacePanelStore never imports this store), so no cycle. // one-directional (workspacePanelStore never imports this store), so no cycle.
@ -87,6 +105,10 @@ export const useBrowserPanelStore = create<BrowserPanelState>((set) => ({
const cur = st.bySession[sessionId]; if (!cur) return st const cur = st.bySession[sessionId]; if (!cur) return st
return { bySession: { ...st.bySession, [sessionId]: { ...cur, pickerActive: active } } } return { bySession: { ...st.bySession, [sessionId]: { ...cur, pickerActive: active } } }
}), }),
setZoom: (sessionId, zoom) => set((st) => {
const cur = st.bySession[sessionId]; if (!cur) return st
return { bySession: { ...st.bySession, [sessionId]: { ...cur, zoom: normalizeBrowserZoom(zoom) } } }
}),
close: (sessionId) => set((st) => { close: (sessionId) => set((st) => {
const cur = st.bySession[sessionId]; if (!cur) return st const cur = st.bySession[sessionId]; if (!cur) return st
return { bySession: { ...st.bySession, [sessionId]: { ...cur, isOpen: false, pickerActive: false } } } return { bySession: { ...st.bySession, [sessionId]: { ...cur, isOpen: false, pickerActive: false } } }