From e64cd80662d5cbb40e9eae22fef2534143df2e15 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: Thu, 2 Jul 2026 20:30:59 +0800 Subject: [PATCH] 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 --- desktop/electron/ipc/capabilities.ts | 1 + desktop/electron/ipc/channels.ts | 1 + desktop/electron/main.ts | 1 + desktop/electron/services/preview.test.ts | 30 +++++++ desktop/electron/services/preview.ts | 13 +++ .../browser/BrowserSurface.test.tsx | 69 ++++++++++++++-- .../src/components/browser/BrowserSurface.tsx | 81 +++++++++++++++++-- .../workbench/WorkbenchPanel.webview.test.tsx | 15 ++-- desktop/src/lib/desktopHost/browserHost.ts | 3 + .../src/lib/desktopHost/electronHost.test.ts | 12 +++ desktop/src/lib/desktopHost/electronHost.ts | 1 + desktop/src/lib/desktopHost/types.ts | 1 + desktop/src/lib/previewBridge.test.ts | 16 +++- desktop/src/lib/previewBridge.ts | 1 + desktop/src/stores/browserPanelStore.test.ts | 29 +++++++ desktop/src/stores/browserPanelStore.ts | 28 ++++++- 16 files changed, 277 insertions(+), 25 deletions(-) diff --git a/desktop/electron/ipc/capabilities.ts b/desktop/electron/ipc/capabilities.ts index 0c1ca754..bc5c4fae 100644 --- a/desktop/electron/ipc/capabilities.ts +++ b/desktop/electron/ipc/capabilities.ts @@ -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, diff --git a/desktop/electron/ipc/channels.ts b/desktop/electron/ipc/channels.ts index 0580ce05..4fb5a31a 100644 --- a/desktop/electron/ipc/channels.ts +++ b/desktop/electron/ipc/channels.ts @@ -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', diff --git a/desktop/electron/main.ts b/desktop/electron/main.ts index ec234056..43815362 100644 --- a/desktop/electron/main.ts +++ b/desktop/electron/main.ts @@ -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)) diff --git a/desktop/electron/services/preview.test.ts b/desktop/electron/services/preview.test.ts index 58d3567d..d5b21ace 100644 --- a/desktop/electron/services/preview.test.ts +++ b/desktop/electron/services/preview.test.ts @@ -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({ diff --git a/desktop/electron/services/preview.ts b/desktop/electron/services/preview.ts index 4aec4cad..e8f0343f 100644 --- a/desktop/electron/services/preview.ts +++ b/desktop/electron/services/preview.ts @@ -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 { 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 6b20417c..3432e5ee 100644 --- a/desktop/src/components/browser/BrowserSurface.test.tsx +++ b/desktop/src/components/browser/BrowserSurface.test.tsx @@ -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() - 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() 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() 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() + + 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() + + 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() @@ -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() 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() }) diff --git a/desktop/src/components/browser/BrowserSurface.tsx b/desktop/src/components/browser/BrowserSurface.tsx index 5a27ca98..22b46f3d 100644 --- a/desktop/src/components/browser/BrowserSurface.tsx +++ b/desktop/src/components/browser/BrowserSurface.tsx @@ -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 (
-
- {session.loading && ( -
- +
+
+ {session.loading && ( +
+ +
+ )} +
+
+
+ + + {zoomPercent}% + + +
- )} +
) diff --git a/desktop/src/components/workbench/WorkbenchPanel.webview.test.tsx b/desktop/src/components/workbench/WorkbenchPanel.webview.test.tsx index 91d868c8..feeb9afe 100644 --- a/desktop/src/components/workbench/WorkbenchPanel.webview.test.tsx +++ b/desktop/src/components/workbench/WorkbenchPanel.webview.test.tsx @@ -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() // 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() diff --git a/desktop/src/lib/desktopHost/browserHost.ts b/desktop/src/lib/desktopHost/browserHost.ts index 9851fb61..2f8c9903 100644 --- a/desktop/src/lib/desktopHost/browserHost.ts +++ b/desktop/src/lib/desktopHost/browserHost.ts @@ -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') }, diff --git a/desktop/src/lib/desktopHost/electronHost.test.ts b/desktop/src/lib/desktopHost/electronHost.test.ts index 0bcb0396..db751cb9 100644 --- a/desktop/src/lib/desktopHost/electronHost.test.ts +++ b/desktop/src/lib/desktopHost/electronHost.test.ts @@ -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) diff --git a/desktop/src/lib/desktopHost/electronHost.ts b/desktop/src/lib/desktopHost/electronHost.ts index 55ab31eb..25779fbb 100644 --- a/desktop/src/lib/desktopHost/electronHost.ts +++ b/desktop/src/lib/desktopHost/electronHost.ts @@ -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), diff --git a/desktop/src/lib/desktopHost/types.ts b/desktop/src/lib/desktopHost/types.ts index d0005a9f..02492687 100644 --- a/desktop/src/lib/desktopHost/types.ts +++ b/desktop/src/lib/desktopHost/types.ts @@ -214,6 +214,7 @@ export type DesktopHost = { navigate(url: string): Promise setBounds(bounds: PreviewBounds): Promise setVisible(visible: boolean): Promise + setZoom(level: number): Promise close(): Promise message(payload: PreviewHostMessage): Promise onEvent(handler: (event: unknown) => void): Promise diff --git a/desktop/src/lib/previewBridge.test.ts b/desktop/src/lib/previewBridge.test.ts index a41b786b..53339105 100644 --- a/desktop/src/lib/previewBridge.test.ts +++ b/desktop/src/lib/previewBridge.test.ts @@ -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() }) diff --git a/desktop/src/lib/previewBridge.ts b/desktop/src/lib/previewBridge.ts index 527d9abf..8faa4919 100644 --- a/desktop/src/lib/previewBridge.ts +++ b/desktop/src/lib/previewBridge.ts @@ -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(), } diff --git a/desktop/src/stores/browserPanelStore.test.ts b/desktop/src/stores/browserPanelStore.test.ts index 5354a8b2..1a8824f8 100644 --- a/desktop/src/stores/browserPanelStore.test.ts +++ b/desktop/src/stores/browserPanelStore.test.ts @@ -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) diff --git a/desktop/src/stores/browserPanelStore.ts b/desktop/src/stores/browserPanelStore.ts index 55a9e014..654e2a49 100644 --- a/desktop/src/stores/browserPanelStore.ts +++ b/desktop/src/stores/browserPanelStore.ts @@ -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((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((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 } } }