mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
fix(desktop): hide child webview while a fullscreen overlay (image preview) is open
This commit is contained in:
parent
144ee5a779
commit
1ca29d8a4a
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
88
desktop/src/components/chat/ImageGalleryModal.test.tsx
Normal file
88
desktop/src/components/chat/ImageGalleryModal.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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) => {
|
||||
|
||||
47
desktop/src/stores/overlayStore.test.ts
Normal file
47
desktop/src/stores/overlayStore.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
36
desktop/src/stores/overlayStore.ts
Normal file
36
desktop/src/stores/overlayStore.ts
Normal 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()
|
||||
}, [])
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user