diff --git a/src/renderer/src/pages/minapps/MinAppPage.tsx b/src/renderer/src/pages/minapps/MinAppPage.tsx index c85afab22c..75c6c284c2 100644 --- a/src/renderer/src/pages/minapps/MinAppPage.tsx +++ b/src/renderer/src/pages/minapps/MinAppPage.tsx @@ -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} /> + {!isReady && ( diff --git a/src/renderer/src/pages/minapps/components/WebviewSearch.tsx b/src/renderer/src/pages/minapps/components/WebviewSearch.tsx new file mode 100644 index 0000000000..80b88f9c1f --- /dev/null +++ b/src/renderer/src/pages/minapps/components/WebviewSearch.tsx @@ -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 + isWebviewReady: boolean + appId: string +} + +const logger = loggerService.withContext('WebviewSearch') + +const WebviewSearch: FC = ({ 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(null) + const inputRef = useRef(null) + const focusFrameRef = useRef(null) + const lastAppIdRef = useRef(appId) + const attachedWebviewRef = useRef(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 ( +
+ + + {matchLabel} + +
+ + +
+ +
+ ) +} + +export default WebviewSearch diff --git a/src/renderer/src/pages/minapps/components/__tests__/WebviewSearch.test.tsx b/src/renderer/src/pages/minapps/components/__tests__/WebviewSearch.test.tsx new file mode 100644 index 0000000000..0f1985f6bb --- /dev/null +++ b/src/renderer/src/pages/minapps/components/__tests__/WebviewSearch.test.tsx @@ -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 = { + '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 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 + + render() + + 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 + const user = userEvent.setup() + + render() + 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 + const user = userEvent.setup() + + const { rerender } = render() + await openSearchOverlay() + + const input = screen.getByRole('textbox') + await user.type(input, 'Cherry') + await waitFor(() => { + expect(findInPageMock).toHaveBeenCalled() + }) + + await act(async () => { + rerender() + }) + + 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 + const user = userEvent.setup() + + render() + 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 + + const { unmount } = render() + 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 + + render() + + await act(async () => { + fireEvent.keyDown(window, { key: 'f', ctrlKey: true }) + }) + + expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument() + expect(findInPageMock).not.toHaveBeenCalled() + }) +})