mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-26 20:12:38 +08:00
feat: support search in mini app page (#10609)
* ✨ feat: add webview find-in-page overlay * 🐛 fix: reset webview search on tab change * fix clear search issue * 🐛 fix: rebind webview search events * 🐛 fix: disable spellcheck in search input * fix spellcheck * 🐛 fix: webview search can now reopen after closing Fixed an issue where the search overlay couldn't be reopened after closing. The openSearch callback was unnecessarily depending on webviewRef.current, causing event listener rebinding issues. Removed the redundant webviewRef check as isWebviewReady is sufficient to ensure webview readiness. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Payne Fu <payne@Paynes-Mac-mini.rcoffice.ringcentral.com> Co-authored-by: Payne Fu <payne@Paynes-MBP.rcoffice.ringcentral.com> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
9a8d7640f5
commit
80fc118465
@ -14,6 +14,7 @@ import styled from 'styled-components'
|
||||
|
||||
// Tab 模式下新的页面壳,不再直接创建 WebView,而是依赖全局 MinAppTabsPool
|
||||
import MinimalToolbar from './components/MinimalToolbar'
|
||||
import WebviewSearch from './components/WebviewSearch'
|
||||
|
||||
const logger = loggerService.withContext('MinAppPage')
|
||||
|
||||
@ -184,6 +185,7 @@ const MinAppPage: FC = () => {
|
||||
onOpenDevTools={handleOpenDevTools}
|
||||
/>
|
||||
</ToolbarWrapper>
|
||||
<WebviewSearch webviewRef={webviewRef} isWebviewReady={isReady} appId={app.id} />
|
||||
{!isReady && (
|
||||
<LoadingMask>
|
||||
<Avatar src={app.logo} size={60} style={{ border: '1px solid var(--color-border)' }} />
|
||||
|
||||
298
src/renderer/src/pages/minapps/components/WebviewSearch.tsx
Normal file
298
src/renderer/src/pages/minapps/components/WebviewSearch.tsx
Normal file
@ -0,0 +1,298 @@
|
||||
import { Button, Input } from '@heroui/react'
|
||||
import { loggerService } from '@logger'
|
||||
import type { WebviewTag } from 'electron'
|
||||
import { ChevronDown, ChevronUp, X } from 'lucide-react'
|
||||
import { FC, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type FoundInPageResult = Electron.FoundInPageResult
|
||||
|
||||
interface WebviewSearchProps {
|
||||
webviewRef: React.RefObject<WebviewTag | null>
|
||||
isWebviewReady: boolean
|
||||
appId: string
|
||||
}
|
||||
|
||||
const logger = loggerService.withContext('WebviewSearch')
|
||||
|
||||
const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, appId }) => {
|
||||
const { t } = useTranslation()
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [matchCount, setMatchCount] = useState(0)
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const [currentWebview, setCurrentWebview] = useState<WebviewTag | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const focusFrameRef = useRef<number | null>(null)
|
||||
const lastAppIdRef = useRef<string>(appId)
|
||||
const attachedWebviewRef = useRef<WebviewTag | null>(null)
|
||||
|
||||
const focusInput = useCallback(() => {
|
||||
if (focusFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(focusFrameRef.current)
|
||||
focusFrameRef.current = null
|
||||
}
|
||||
focusFrameRef.current = window.requestAnimationFrame(() => {
|
||||
inputRef.current?.focus()
|
||||
inputRef.current?.select()
|
||||
})
|
||||
}, [])
|
||||
|
||||
const resetSearchState = useCallback((options?: { keepQuery?: boolean }) => {
|
||||
if (!options?.keepQuery) {
|
||||
setQuery('')
|
||||
}
|
||||
setMatchCount(0)
|
||||
setActiveIndex(0)
|
||||
}, [])
|
||||
|
||||
const stopSearch = useCallback(() => {
|
||||
const target = webviewRef.current ?? attachedWebviewRef.current
|
||||
if (!target) return
|
||||
try {
|
||||
target.stopFindInPage('clearSelection')
|
||||
} catch (error) {
|
||||
logger.error('stopFindInPage failed', { error })
|
||||
}
|
||||
}, [webviewRef])
|
||||
|
||||
const closeSearch = useCallback(() => {
|
||||
setIsVisible(false)
|
||||
stopSearch()
|
||||
resetSearchState({ keepQuery: true })
|
||||
}, [resetSearchState, stopSearch])
|
||||
|
||||
const performSearch = useCallback(
|
||||
(text: string, options?: Electron.FindInPageOptions) => {
|
||||
const target = webviewRef.current ?? attachedWebviewRef.current
|
||||
if (!target) {
|
||||
logger.debug('Skip performSearch: webview not attached')
|
||||
return
|
||||
}
|
||||
if (!text) {
|
||||
stopSearch()
|
||||
resetSearchState({ keepQuery: true })
|
||||
return
|
||||
}
|
||||
try {
|
||||
target.findInPage(text, options)
|
||||
} catch (error) {
|
||||
logger.error('findInPage failed', { error })
|
||||
window.toast?.error(t('common.error'))
|
||||
}
|
||||
},
|
||||
[resetSearchState, stopSearch, t, webviewRef]
|
||||
)
|
||||
|
||||
const handleFoundInPage = useCallback((event: Event & { result?: FoundInPageResult }) => {
|
||||
if (!event.result) return
|
||||
|
||||
const { activeMatchOrdinal, matches } = event.result
|
||||
|
||||
if (matches !== undefined) {
|
||||
setMatchCount(matches)
|
||||
}
|
||||
|
||||
if (activeMatchOrdinal !== undefined) {
|
||||
setActiveIndex(activeMatchOrdinal)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const openSearch = useCallback(() => {
|
||||
if (!isWebviewReady) {
|
||||
logger.debug('Skip openSearch: webview not ready')
|
||||
return
|
||||
}
|
||||
setIsVisible(true)
|
||||
focusInput()
|
||||
}, [focusInput, isWebviewReady])
|
||||
|
||||
const goToNext = useCallback(() => {
|
||||
if (!query) return
|
||||
performSearch(query, { forward: true, findNext: true })
|
||||
}, [performSearch, query])
|
||||
|
||||
const goToPrevious = useCallback(() => {
|
||||
if (!query) return
|
||||
performSearch(query, { forward: false, findNext: true })
|
||||
}, [performSearch, query])
|
||||
|
||||
useEffect(() => {
|
||||
const nextWebview = webviewRef.current ?? null
|
||||
if (currentWebview === nextWebview) return
|
||||
setCurrentWebview(nextWebview)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const target = currentWebview
|
||||
if (!target) {
|
||||
attachedWebviewRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
const handle = handleFoundInPage
|
||||
attachedWebviewRef.current = target
|
||||
target.addEventListener('found-in-page', handle)
|
||||
|
||||
return () => {
|
||||
target.removeEventListener('found-in-page', handle)
|
||||
if (attachedWebviewRef.current === target) {
|
||||
try {
|
||||
target.stopFindInPage('clearSelection')
|
||||
} catch (error) {
|
||||
logger.error('stopFindInPage failed', { error })
|
||||
}
|
||||
attachedWebviewRef.current = null
|
||||
}
|
||||
}
|
||||
}, [currentWebview, handleFoundInPage])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return
|
||||
focusInput()
|
||||
}, [focusInput, isVisible])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return
|
||||
if (!query) {
|
||||
performSearch('')
|
||||
return
|
||||
}
|
||||
performSearch(query)
|
||||
}, [currentWebview, isVisible, performSearch, query])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'f') {
|
||||
event.preventDefault()
|
||||
openSearch()
|
||||
return
|
||||
}
|
||||
|
||||
if (!isVisible) return
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
closeSearch()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
if (event.shiftKey) {
|
||||
goToPrevious()
|
||||
} else {
|
||||
goToNext()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeydown, true)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeydown, true)
|
||||
}
|
||||
}, [closeSearch, goToNext, goToPrevious, isVisible, openSearch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWebviewReady) {
|
||||
setIsVisible(false)
|
||||
resetSearchState()
|
||||
stopSearch()
|
||||
return
|
||||
}
|
||||
}, [isWebviewReady, resetSearchState, stopSearch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!appId) return
|
||||
if (lastAppIdRef.current === appId) return
|
||||
lastAppIdRef.current = appId
|
||||
setIsVisible(false)
|
||||
resetSearchState()
|
||||
stopSearch()
|
||||
}, [appId, resetSearchState, stopSearch])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopSearch()
|
||||
if (focusFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(focusFrameRef.current)
|
||||
focusFrameRef.current = null
|
||||
}
|
||||
}
|
||||
}, [stopSearch])
|
||||
|
||||
if (!isVisible) {
|
||||
return null
|
||||
}
|
||||
|
||||
const matchLabel = `${matchCount > 0 ? Math.max(activeIndex, 1) : 0}/${matchCount}`
|
||||
const noResultTitle = matchCount === 0 && query ? t('common.no_results') : undefined
|
||||
const disableNavigation = !query || matchCount === 0
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto absolute top-3 right-3 z-50 flex items-center gap-2 rounded-xl border border-default-200 bg-background px-2 py-1 shadow-lg">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
spellCheck={'false'}
|
||||
placeholder={t('common.search')}
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant="flat"
|
||||
classNames={{
|
||||
base: 'w-[240px]',
|
||||
inputWrapper:
|
||||
'h-8 bg-transparent border border-transparent shadow-none hover:border-transparent hover:bg-transparent focus:border-transparent data-[hover=true]:border-transparent data-[focus=true]:border-transparent data-[focus-visible=true]:outline-none data-[focus-visible=true]:ring-0',
|
||||
input: 'text-small focus:outline-none focus-visible:outline-none',
|
||||
innerWrapper: 'gap-0'
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="min-w-[44px] text-center text-default-500 text-small tabular-nums"
|
||||
title={noResultTitle}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true">
|
||||
{matchLabel}
|
||||
</span>
|
||||
<div className="h-4 w-px bg-default-200" />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
radius="full"
|
||||
isIconOnly
|
||||
onPress={goToPrevious}
|
||||
isDisabled={disableNavigation}
|
||||
aria-label="Previous match"
|
||||
className="text-default-500 hover:text-default-900">
|
||||
<ChevronUp size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
radius="full"
|
||||
isIconOnly
|
||||
onPress={goToNext}
|
||||
isDisabled={disableNavigation}
|
||||
aria-label="Next match"
|
||||
className="text-default-500 hover:text-default-900">
|
||||
<ChevronDown size={16} />
|
||||
</Button>
|
||||
<div className="h-4 w-px bg-default-200" />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
radius="full"
|
||||
isIconOnly
|
||||
onPress={closeSearch}
|
||||
aria-label={t('common.close')}
|
||||
className="text-default-500 hover:text-default-900">
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WebviewSearch
|
||||
@ -0,0 +1,237 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { WebviewTag } from 'electron'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import WebviewSearch from '../WebviewSearch'
|
||||
|
||||
const translations: Record<string, string> = {
|
||||
'common.close': 'Close',
|
||||
'common.error': 'Error',
|
||||
'common.no_results': 'No results',
|
||||
'common.search': 'Search'
|
||||
}
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => translations[key] ?? key
|
||||
})
|
||||
}))
|
||||
|
||||
const createWebviewMock = () => {
|
||||
const listeners = new Map<string, Set<(event: Event & { result?: Electron.FoundInPageResult }) => void>>()
|
||||
const findInPageMock = vi.fn()
|
||||
const stopFindInPageMock = vi.fn()
|
||||
const webview = {
|
||||
addEventListener: vi.fn(
|
||||
(type: string, listener: (event: Event & { result?: Electron.FoundInPageResult }) => void) => {
|
||||
if (!listeners.has(type)) {
|
||||
listeners.set(type, new Set())
|
||||
}
|
||||
listeners.get(type)!.add(listener)
|
||||
}
|
||||
),
|
||||
removeEventListener: vi.fn(
|
||||
(type: string, listener: (event: Event & { result?: Electron.FoundInPageResult }) => void) => {
|
||||
listeners.get(type)?.delete(listener)
|
||||
}
|
||||
),
|
||||
findInPage: findInPageMock as unknown as WebviewTag['findInPage'],
|
||||
stopFindInPage: stopFindInPageMock as unknown as WebviewTag['stopFindInPage']
|
||||
} as unknown as WebviewTag
|
||||
|
||||
const emit = (type: string, result?: Electron.FoundInPageResult) => {
|
||||
listeners.get(type)?.forEach((listener) => {
|
||||
const event = new CustomEvent(type) as Event & { result?: Electron.FoundInPageResult }
|
||||
event.result = result
|
||||
listener(event)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
emit,
|
||||
findInPageMock,
|
||||
stopFindInPageMock,
|
||||
webview
|
||||
}
|
||||
}
|
||||
|
||||
const openSearchOverlay = async () => {
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true }))
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
|
||||
const originalRAF = window.requestAnimationFrame
|
||||
const originalCAF = window.cancelAnimationFrame
|
||||
|
||||
const requestAnimationFrameMock = vi.fn((callback: FrameRequestCallback) => {
|
||||
callback(0)
|
||||
return 1
|
||||
})
|
||||
const cancelAnimationFrameMock = vi.fn()
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
value: requestAnimationFrameMock,
|
||||
writable: true
|
||||
})
|
||||
Object.defineProperty(window, 'cancelAnimationFrame', {
|
||||
value: cancelAnimationFrameMock,
|
||||
writable: true
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
value: originalRAF
|
||||
})
|
||||
Object.defineProperty(window, 'cancelAnimationFrame', {
|
||||
value: originalCAF
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebviewSearch', () => {
|
||||
const toastMock = {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
addToast: vi.fn()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(window, { toast: toastMock })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('opens the search overlay with keyboard shortcut', async () => {
|
||||
const { webview } = createWebviewMock()
|
||||
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
|
||||
|
||||
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
|
||||
|
||||
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
|
||||
|
||||
await openSearchOverlay()
|
||||
|
||||
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('performs searches and navigates between results', async () => {
|
||||
const { emit, findInPageMock, webview } = createWebviewMock()
|
||||
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
|
||||
await openSearchOverlay()
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.type(input, 'Cherry')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(findInPageMock).toHaveBeenCalledWith('Cherry', undefined)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
emit('found-in-page', {
|
||||
requestId: 1,
|
||||
matches: 3,
|
||||
activeMatchOrdinal: 1,
|
||||
selectionArea: undefined as unknown as Electron.Rectangle,
|
||||
finalUpdate: false
|
||||
} as Electron.FoundInPageResult)
|
||||
})
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: 'Next match' })
|
||||
await waitFor(() => {
|
||||
expect(nextButton).not.toBeDisabled()
|
||||
})
|
||||
await user.click(nextButton)
|
||||
await waitFor(() => {
|
||||
expect(findInPageMock).toHaveBeenLastCalledWith('Cherry', { forward: true, findNext: true })
|
||||
})
|
||||
|
||||
const previousButton = screen.getByRole('button', { name: 'Previous match' })
|
||||
await user.click(previousButton)
|
||||
await waitFor(() => {
|
||||
expect(findInPageMock).toHaveBeenLastCalledWith('Cherry', { forward: false, findNext: true })
|
||||
})
|
||||
})
|
||||
|
||||
it('clears search state when appId changes', async () => {
|
||||
const { findInPageMock, stopFindInPageMock, webview } = createWebviewMock()
|
||||
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
|
||||
const user = userEvent.setup()
|
||||
|
||||
const { rerender } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
|
||||
await openSearchOverlay()
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.type(input, 'Cherry')
|
||||
await waitFor(() => {
|
||||
expect(findInPageMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-2" />)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(stopFindInPageMock).toHaveBeenCalledWith('clearSelection')
|
||||
})
|
||||
|
||||
it('shows toast error when search fails', async () => {
|
||||
const { findInPageMock, webview } = createWebviewMock()
|
||||
findInPageMock.mockImplementation(() => {
|
||||
throw new Error('findInPage failed')
|
||||
})
|
||||
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
|
||||
await openSearchOverlay()
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.type(input, 'Cherry')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastMock.error).toHaveBeenCalledWith('Error')
|
||||
})
|
||||
})
|
||||
|
||||
it('stops search when component unmounts', async () => {
|
||||
const { stopFindInPageMock, webview } = createWebviewMock()
|
||||
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
|
||||
|
||||
const { unmount } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
|
||||
await openSearchOverlay()
|
||||
|
||||
stopFindInPageMock.mockClear()
|
||||
unmount()
|
||||
|
||||
expect(stopFindInPageMock).toHaveBeenCalledWith('clearSelection')
|
||||
})
|
||||
|
||||
it('ignores keyboard shortcut when webview is not ready', async () => {
|
||||
const { findInPageMock, webview } = createWebviewMock()
|
||||
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
|
||||
|
||||
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady={false} appId="app-1" />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(window, { key: 'f', ctrlKey: true })
|
||||
})
|
||||
|
||||
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
|
||||
expect(findInPageMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user