mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +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 '@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'
|
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
@ -15,12 +15,14 @@ vi.mock('@tauri-apps/api/event', () => ({ listen: () => Promise.resolve(() => {}
|
|||||||
import { BrowserSurface } from './BrowserSurface'
|
import { BrowserSurface } from './BrowserSurface'
|
||||||
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
|
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
|
||||||
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
|
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
|
||||||
|
import { useOverlayStore } from '../../stores/overlayStore'
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
Object.values(bridge).forEach((f) => f.mockReset())
|
Object.values(bridge).forEach((f) => f.mockReset())
|
||||||
useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true)
|
useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true)
|
||||||
// browserPanelStore.open() now also opens the unified workbench; keep it isolated.
|
// browserPanelStore.open() now also opens the unified workbench; keep it isolated.
|
||||||
useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true)
|
useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true)
|
||||||
|
useOverlayStore.setState(useOverlayStore.getInitialState(), true)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('BrowserSurface', () => {
|
describe('BrowserSurface', () => {
|
||||||
@ -103,4 +105,37 @@ describe('BrowserSurface', () => {
|
|||||||
vi.useRealTimers()
|
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 { previewBridge } from '../../lib/previewBridge'
|
||||||
import { subscribePreviewEvents } from '../../lib/previewEvents'
|
import { subscribePreviewEvents } from '../../lib/previewEvents'
|
||||||
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
|
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
|
||||||
|
import { useOverlayStore } from '../../stores/overlayStore'
|
||||||
|
|
||||||
export function BrowserSurface({ sessionId }: { sessionId: string }) {
|
export function BrowserSurface({ sessionId }: { sessionId: string }) {
|
||||||
const hostRef = useRef<HTMLDivElement>(null)
|
const hostRef = useRef<HTMLDivElement>(null)
|
||||||
const session = useBrowserPanelStore((s) => s.bySession[sessionId])
|
const session = useBrowserPanelStore((s) => s.bySession[sessionId])
|
||||||
const store = useBrowserPanelStore.getState()
|
const store = useBrowserPanelStore.getState()
|
||||||
|
const overlayCount = useOverlayStore((s) => s.count)
|
||||||
|
|
||||||
const reportBounds = () => {
|
const reportBounds = () => {
|
||||||
const el = hostRef.current
|
const el = hostRef.current
|
||||||
@ -21,10 +23,21 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
|
|||||||
const el = hostRef.current
|
const el = hostRef.current
|
||||||
if (!el || !session) return
|
if (!el || !session) return
|
||||||
previewBridge.open(session.url, computeWebviewBounds(el.getBoundingClientRect()))
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [sessionId])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const el = hostRef.current
|
const el = hostRef.current
|
||||||
if (!el) return
|
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 { useEffect } from 'react'
|
||||||
import { Modal } from '../shared/Modal'
|
import { Modal } from '../shared/Modal'
|
||||||
|
import { useOverlayStore } from '../../stores/overlayStore'
|
||||||
|
|
||||||
type GalleryImage = {
|
type GalleryImage = {
|
||||||
src: string
|
src: string
|
||||||
@ -17,6 +18,16 @@ type Props = {
|
|||||||
export function ImageGalleryModal({ open, images, activeIndex, onClose, onSelect }: Props) {
|
export function ImageGalleryModal({ open, images, activeIndex, onClose, onSelect }: Props) {
|
||||||
const activeImage = images[activeIndex]
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open || images.length <= 1) return
|
if (!open || images.length <= 1) return
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
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