mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
82be8f45dd
commit
e64cd80662
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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')
|
||||
},
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 } } }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user