fix(desktop): hide child webview while a fullscreen overlay (image preview) is open

This commit is contained in:
程序员阿江(Relakkes) 2026-05-31 00:13:46 +08:00
parent 144ee5a779
commit 1ca29d8a4a
6 changed files with 232 additions and 2 deletions

View File

@ -1,5 +1,5 @@
import '@testing-library/jest-dom'
import { fireEvent, render, screen } from '@testing-library/react'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
@ -15,12 +15,14 @@ vi.mock('@tauri-apps/api/event', () => ({ listen: () => Promise.resolve(() => {}
import { BrowserSurface } from './BrowserSurface'
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
import { useOverlayStore } from '../../stores/overlayStore'
afterEach(() => {
Object.values(bridge).forEach((f) => f.mockReset())
useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true)
// browserPanelStore.open() now also opens the unified workbench; keep it isolated.
useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true)
useOverlayStore.setState(useOverlayStore.getInitialState(), true)
})
describe('BrowserSurface', () => {
@ -103,4 +105,37 @@ describe('BrowserSurface', () => {
vi.useRealTimers()
}
})
it('hides the native webview when a fullscreen overlay opens, then re-shows it when the overlay closes', () => {
useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/')
render(<BrowserSurface sessionId="s1" />)
// Initial mount: visibility-sync effect reveals the webview (count === 0).
expect(bridge.setVisible).toHaveBeenLastCalledWith(true)
// Overlay opens → webview must hide.
act(() => { useOverlayStore.getState().push() })
expect(bridge.setVisible).toHaveBeenLastCalledWith(false)
// Overlay closes → webview must re-show (panel still mounted in browser mode).
act(() => { useOverlayStore.getState().pop() })
expect(bridge.setVisible).toHaveBeenLastCalledWith(true)
})
it('keeps the native webview hidden while multiple overlays stack', () => {
useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/')
render(<BrowserSurface sessionId="s1" />)
act(() => { useOverlayStore.getState().push() })
act(() => { useOverlayStore.getState().push() })
expect(bridge.setVisible).toHaveBeenLastCalledWith(false)
// Popping just one leaves count === 1 → still hidden.
act(() => { useOverlayStore.getState().pop() })
expect(bridge.setVisible).toHaveBeenLastCalledWith(false)
// Popping the last one → re-shown.
act(() => { useOverlayStore.getState().pop() })
expect(bridge.setVisible).toHaveBeenLastCalledWith(true)
})
})

View File

@ -5,11 +5,13 @@ import { computeWebviewBounds } from './computeWebviewBounds'
import { previewBridge } from '../../lib/previewBridge'
import { subscribePreviewEvents } from '../../lib/previewEvents'
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
import { useOverlayStore } from '../../stores/overlayStore'
export function BrowserSurface({ sessionId }: { sessionId: string }) {
const hostRef = useRef<HTMLDivElement>(null)
const session = useBrowserPanelStore((s) => s.bySession[sessionId])
const store = useBrowserPanelStore.getState()
const overlayCount = useOverlayStore((s) => s.count)
const reportBounds = () => {
const el = hostRef.current
@ -21,10 +23,21 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
const el = hostRef.current
if (!el || !session) return
previewBridge.open(session.url, computeWebviewBounds(el.getBoundingClientRect()))
previewBridge.setVisible(true)
// The visibility-sync effect below owns setVisible() — including the
// initial reveal — so it always factors in overlayCount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId])
// Visibility-sync: a fullscreen DOM overlay (e.g. ImageGalleryModal) would
// otherwise be partially covered by the native child webview, which always
// renders above the DOM. While overlayCount > 0 we hide the webview; when
// it returns to 0 (and we're still mounted in browser mode) we re-show it.
// The Workbench-mode unmount teardown effect below still runs on unmount.
useEffect(() => {
if (!session) return
previewBridge.setVisible(overlayCount === 0)
}, [overlayCount, session])
useEffect(() => {
const el = hostRef.current
if (!el) return

View File

@ -0,0 +1,88 @@
import '@testing-library/jest-dom'
import { render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { ImageGalleryModal } from './ImageGalleryModal'
import { useOverlayStore } from '../../stores/overlayStore'
const images = [{ src: 'data:image/png;base64,AAAA', name: 'a.png' }]
const reset = () => {
useOverlayStore.setState(useOverlayStore.getInitialState(), true)
}
beforeEach(reset)
afterEach(reset)
describe('ImageGalleryModal · overlay suppression', () => {
it('increments overlay count while open and decrements on unmount', () => {
expect(useOverlayStore.getState().count).toBe(0)
const { unmount } = render(
<ImageGalleryModal
open
images={images}
activeIndex={0}
onClose={() => {}}
onSelect={() => {}}
/>,
)
expect(useOverlayStore.getState().count).toBe(1)
unmount()
expect(useOverlayStore.getState().count).toBe(0)
})
it('does not increment when rendered with open=false', () => {
const { unmount } = render(
<ImageGalleryModal
open={false}
images={images}
activeIndex={0}
onClose={() => {}}
onSelect={() => {}}
/>,
)
expect(useOverlayStore.getState().count).toBe(0)
unmount()
expect(useOverlayStore.getState().count).toBe(0)
})
it('toggles count when open prop flips closed → open → closed', () => {
const { rerender, unmount } = render(
<ImageGalleryModal
open={false}
images={images}
activeIndex={0}
onClose={() => {}}
onSelect={() => {}}
/>,
)
expect(useOverlayStore.getState().count).toBe(0)
rerender(
<ImageGalleryModal
open
images={images}
activeIndex={0}
onClose={() => {}}
onSelect={() => {}}
/>,
)
expect(useOverlayStore.getState().count).toBe(1)
rerender(
<ImageGalleryModal
open={false}
images={images}
activeIndex={0}
onClose={() => {}}
onSelect={() => {}}
/>,
)
expect(useOverlayStore.getState().count).toBe(0)
unmount()
expect(useOverlayStore.getState().count).toBe(0)
})
})

View File

@ -1,5 +1,6 @@
import { useEffect } from 'react'
import { Modal } from '../shared/Modal'
import { useOverlayStore } from '../../stores/overlayStore'
type GalleryImage = {
src: string
@ -17,6 +18,16 @@ type Props = {
export function ImageGalleryModal({ open, images, activeIndex, onClose, onSelect }: Props) {
const activeImage = images[activeIndex]
// Native child webviews (e.g. the in-app browser preview) always render
// ABOVE the DOM, so this fullscreen overlay would be partially covered.
// Bump the overlay count while open so BrowserSurface can hide the webview.
useEffect(() => {
if (!open) return
const { push, pop } = useOverlayStore.getState()
push()
return () => pop()
}, [open])
useEffect(() => {
if (!open || images.length <= 1) return
const handleKeyDown = (event: KeyboardEvent) => {

View File

@ -0,0 +1,47 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { useOverlayStore } from './overlayStore'
const reset = () => {
useOverlayStore.setState(useOverlayStore.getInitialState(), true)
}
describe('overlayStore', () => {
beforeEach(reset)
it('starts at count 0', () => {
expect(useOverlayStore.getState().count).toBe(0)
})
it('push increments by 1', () => {
useOverlayStore.getState().push()
expect(useOverlayStore.getState().count).toBe(1)
useOverlayStore.getState().push()
expect(useOverlayStore.getState().count).toBe(2)
})
it('pop decrements by 1', () => {
const { push, pop } = useOverlayStore.getState()
push()
push()
pop()
expect(useOverlayStore.getState().count).toBe(1)
})
it('pop at 0 stays clamped at 0', () => {
useOverlayStore.getState().pop()
expect(useOverlayStore.getState().count).toBe(0)
useOverlayStore.getState().pop()
expect(useOverlayStore.getState().count).toBe(0)
})
it('balances pushes and pops back to 0', () => {
const { push, pop } = useOverlayStore.getState()
push(); push(); push()
expect(useOverlayStore.getState().count).toBe(3)
pop(); pop(); pop()
expect(useOverlayStore.getState().count).toBe(0)
// extra pop is still clamped
pop()
expect(useOverlayStore.getState().count).toBe(0)
})
})

View File

@ -0,0 +1,36 @@
import { useEffect } from 'react'
import { create } from 'zustand'
/**
* Tracks how many fullscreen DOM overlays (image preview modals, etc.) are
* currently mounted. A native child webview (e.g. the in-app browser preview)
* always renders ABOVE the DOM, so it covers any fullscreen overlay; surfaces
* driving such webviews read this count and hide the webview while count > 0.
*
* Reusable: any fullscreen overlay can opt in via `push()` / `pop()` (or the
* helper hook `useSuppressBrowserOverlay()` below).
*/
type OverlayStore = {
count: number
push: () => void
pop: () => void
}
export const useOverlayStore = create<OverlayStore>((set) => ({
count: 0,
push: () => set((state) => ({ count: state.count + 1 })),
pop: () => set((state) => ({ count: Math.max(0, state.count - 1) })),
}))
/**
* Mount-scoped helper: increments the overlay count on mount, decrements on
* unmount. Pairs cleanly with strict-mode double-invoke because each effect
* run does exactly one inc + one dec.
*/
export function useSuppressBrowserOverlay() {
useEffect(() => {
const { push, pop } = useOverlayStore.getState()
push()
return () => pop()
}, [])
}