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.previewSetBounds]: boundsPayload,
[ELECTRON_IPC_CHANNELS.previewSetVisible]: booleanPayload,
[ELECTRON_IPC_CHANNELS.previewSetZoom]: zoomPayload,
[ELECTRON_IPC_CHANNELS.previewClose]: noPayload,
[ELECTRON_IPC_CHANNELS.previewMessage]: () => true,
[ELECTRON_IPC_CHANNELS.appModeGet]: noPayload,

View File

@ -36,6 +36,7 @@ export const ELECTRON_IPC_CHANNELS = {
previewNavigate: 'desktop:preview:navigate',
previewSetBounds: 'desktop:preview:set-bounds',
previewSetVisible: 'desktop:preview:set-visible',
previewSetZoom: 'desktop:preview:set-zoom',
previewClose: 'desktop:preview:close',
previewMessage: 'desktop:preview:message',
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.previewSetBounds, (_event, payload) => getPreviewService().setBounds(payload as PreviewBounds))
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.previewMessage, (event, payload) => getPreviewService().message(payload, event.sender))
registerHandler(ELECTRON_IPC_CHANNELS.appModeGet, () => getAppMode(app))

View File

@ -17,6 +17,7 @@ import {
class FakeWebContents implements PreviewWebContentsLike {
loadedUrls: string[] = []
scripts: string[] = []
zoomFactors: number[] = []
sent: Array<{ channel: string, payload: unknown }> = []
close = vi.fn()
capturePage = vi.fn(async () => ({ toDataURL: () => 'data:image/png;base64,NATIVE' }))
@ -40,6 +41,10 @@ class FakeWebContents implements PreviewWebContentsLike {
return false
}
setZoomFactor(factor: number) {
this.zoomFactors.push(factor)
}
send(channel: string, payload: unknown) {
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 () => {
const view = new FakeView()
const service = new ElectronPreviewService({

View File

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

View File

@ -10,7 +10,15 @@ beforeAll(() => {
})
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('@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', () => {
useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/')
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 () => {
@ -68,13 +78,15 @@ describe('BrowserSurface', () => {
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')
render(<BrowserSurface sessionId="s1" />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'localhost:3000' } })
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()
})
@ -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/')
render(<BrowserSurface sessionId="s1" />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'http://localhost:3000/' } })
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/')
})
@ -168,6 +182,43 @@ 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 () => {
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)', () => {
useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/')
render(<BrowserSurface sessionId="s1" />)
@ -183,14 +234,16 @@ describe('BrowserSurface', () => {
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().setReady('s1')
render(<BrowserSurface sessionId="s1" />)
expect(screen.queryByTestId('browser-loading-bar')).not.toBeInTheDocument()
fireEvent.click(screen.getByLabelText('刷新'))
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()
})

View File

@ -1,5 +1,5 @@
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 { computeWebviewBounds } from './computeWebviewBounds'
import { getServerBaseUrl, isLoopbackHostname } from '../../lib/desktopRuntime'
@ -7,7 +7,14 @@ import { classifyPreviewLink } from '../../lib/previewLinkRouter'
import { isAbsoluteLocalPath, localFileUrl, previewFsUrl } from '../../lib/handlePreviewLink'
import { previewBridge } from '../../lib/previewBridge'
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'
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 store = useBrowserPanelStore.getState()
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 el = hostRef.current
@ -98,6 +109,7 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
requestedUrlRef.current = url
loadNativePreview(url, async () => {
await previewBridge.setZoom(previewZoom)
if (hasNativePreviewRef.current) {
await previewBridge.navigate(url)
return
@ -144,6 +156,11 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
previewBridge.setVisible(overlayCount === 0)
}, [overlayCount, session])
useEffect(() => {
if (!session) return
void previewBridge.setZoom(previewZoom)
}, [previewZoom, session])
useEffect(() => {
const el = hostRef.current
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 (
<div className="flex h-full flex-col">
<BrowserAddressBar
@ -249,12 +277,51 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
}}
rightActions={previewActions}
/>
<div ref={hostRef} className="relative flex-1 overflow-hidden" data-testid="preview-host">
{session.loading && (
<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 className="flex min-h-0 flex-1 flex-col bg-[var(--color-surface)]">
<div ref={hostRef} className="relative min-h-0 flex-1 overflow-hidden" data-testid="preview-host">
{session.loading && (
<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>
)

View File

@ -2,7 +2,7 @@
// 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.
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'
beforeAll(() => {
@ -19,6 +19,7 @@ const { bridge } = vi.hoisted(() => ({
navigate: vi.fn(),
setBounds: vi.fn(),
setVisible: vi.fn(),
setZoom: vi.fn(),
close: vi.fn(),
eval: vi.fn(),
},
@ -56,15 +57,17 @@ afterEach(() => {
})
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} />)
// Browser mode mounts BrowserSurface, which opens + shows the native webview.
expect(screen.getByTestId('preview-host')).toBeInTheDocument()
expect(bridge.open).toHaveBeenCalledWith(
'http://localhost:5173/',
expect.objectContaining({ width: expect.any(Number) }),
)
await waitFor(() => {
expect(bridge.open).toHaveBeenCalledWith(
'http://localhost:5173/',
expect.objectContaining({ width: expect.any(Number) }),
)
})
expect(bridge.setVisible).toHaveBeenCalledWith(true)
bridge.close.mockClear()

View File

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

View File

@ -83,6 +83,18 @@ describe('electron desktop host', () => {
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 () => {
const unlisten = vi.fn()
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),
setBounds: bounds => invoke(ELECTRON_IPC_CHANNELS.previewSetBounds, bounds),
setVisible: visible => invoke(ELECTRON_IPC_CHANNELS.previewSetVisible, visible),
setZoom: level => invoke(ELECTRON_IPC_CHANNELS.previewSetZoom, level),
close: () => invoke(ELECTRON_IPC_CHANNELS.previewClose),
message: payload => invoke(ELECTRON_IPC_CHANNELS.previewMessage, payload),
onEvent: handler => subscribe(ELECTRON_EVENT_CHANNELS.previewEvent, handler),

View File

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

View File

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

View File

@ -12,6 +12,7 @@ export const previewBridge = {
navigate: (url: string) => getPreviewHost()?.navigate(url) ?? Promise.resolve(),
setBounds: (bounds: WebviewBounds) => getPreviewHost()?.setBounds(bounds) ?? Promise.resolve(),
setVisible: (visible: boolean) => getPreviewHost()?.setVisible(visible) ?? Promise.resolve(),
setZoom: (level: number) => getPreviewHost()?.setZoom(level) ?? Promise.resolve(),
close: () => getPreviewHost()?.close() ?? 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)
})
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', () => {
useBrowserPanelStore.getState().open('s1', 'http://localhost/a')
expect(useBrowserPanelStore.getState().bySession['s1']!.loading).toBe(true)

View File

@ -1,6 +1,18 @@
import { create } from 'zustand'
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 = {
isOpen: boolean
url: string
@ -11,6 +23,7 @@ export type BrowserSessionState = {
pickerActive: boolean
canGoBack: boolean
canGoForward: boolean
zoom: number
}
type BrowserPanelState = {
@ -22,6 +35,7 @@ type BrowserPanelState = {
goForward: (sessionId: string) => void
setLoading: (sessionId: string, loading: boolean) => void
setPicker: (sessionId: string, active: boolean) => void
setZoom: (sessionId: string, zoom: number) => void
close: (sessionId: string) => void
setNavigated: (sessionId: string, url: string, title: string) => void
setReady: (sessionId: string) => void
@ -37,6 +51,7 @@ const empty = (url = ''): BrowserSessionState => ({
pickerActive: false,
canGoBack: false,
canGoForward: false,
zoom: DEFAULT_BROWSER_ZOOM,
})
const withNav = (s: BrowserSessionState): BrowserSessionState => ({
@ -49,9 +64,12 @@ const withNav = (s: BrowserSessionState): BrowserSessionState => ({
export const useBrowserPanelStore = create<BrowserPanelState>((set) => ({
bySession: {},
open: (sessionId, url) => {
set((st) => ({
bySession: { ...st.bySession, [sessionId]: { ...empty(url), loading: true } },
}))
set((st) => {
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
// link / localhost url surfaces the workbench in BROWSER mode. Import is
// 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
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) => {
const cur = st.bySession[sessionId]; if (!cur) return st
return { bySession: { ...st.bySession, [sessionId]: { ...cur, isOpen: false, pickerActive: false } } }