From 80fc11846595a91ab5c42e56a0d12d7cf090cb45 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Fri, 10 Oct 2025 07:00:45 -0700 Subject: [PATCH] feat: support search in mini app page (#10609) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ 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 --------- Co-authored-by: Payne Fu Co-authored-by: Payne Fu Co-authored-by: Claude --- src/renderer/src/pages/minapps/MinAppPage.tsx | 2 + .../minapps/components/WebviewSearch.tsx | 298 ++++++++++++++++++ .../__tests__/WebviewSearch.test.tsx | 237 ++++++++++++++ 3 files changed, 537 insertions(+) create mode 100644 src/renderer/src/pages/minapps/components/WebviewSearch.tsx create mode 100644 src/renderer/src/pages/minapps/components/__tests__/WebviewSearch.test.tsx 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() + }) +})