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 } } }