From 7b90dfb46cbf7df9de7b30e46816701ec42511b9 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Sun, 12 Oct 2025 18:45:37 +0800 Subject: [PATCH] fix: intercept webview keyboard shortcuts for search functionality (#10641) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: intercept webview keyboard shortcuts for search functionality Implemented keyboard shortcut interception in webview to enable search functionality (Ctrl/Cmd+F) and navigation (Enter/Escape) within mini app pages. Previously, these shortcuts were consumed by the webview content and not propagated to the host application. Changes: - Added Webview_SearchHotkey IPC channel for forwarding keyboard events - Implemented before-input-event handler in WebviewService to intercept Ctrl/Cmd+F, Escape, and Enter - Extended preload API with onFindShortcut callback for webview shortcut events - Updated WebviewSearch component to handle shortcuts from both window and webview - Added comprehensive test coverage for webview shortcut handling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix lint * refactor: improve webview hotkey initialization and error handling Refactored webview keyboard shortcut handler for better code organization and reliability. Changes: - Extracted keyboard handler logic into reusable attachKeyboardHandler function - Added initWebviewHotkeys() to initialize handlers for existing webviews on startup - Integrated initialization in main app entry point - Added explanatory comment for event.preventDefault() behavior - Added warning log when webContentsId is unavailable in WebviewSearch 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * feat: add WebviewKeyEvent type and update related components - Introduced WebviewKeyEvent type to standardize keyboard event handling for webviews. - Updated preload index to utilize the new WebviewKeyEvent type in the onFindShortcut callback. - Refactored WebviewSearch component and its tests to accommodate the new type, enhancing type safety and clarity. 🤖 Generated with [Claude Code](https://claude.com/claude-code) * fix lint --------- Co-authored-by: Claude --- packages/shared/IpcChannel.ts | 1 + packages/shared/config/types.ts | 9 ++ src/main/index.ts | 2 + src/main/ipc.ts | 1 - src/main/services/WebviewService.ts | 61 +++++++++- src/preload/index.ts | 13 ++- .../minapps/components/WebviewSearch.tsx | 66 ++++++++--- .../__tests__/WebviewSearch.test.tsx | 104 ++++++++++++++++++ 8 files changed, 236 insertions(+), 21 deletions(-) diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 27f35f9840..720d1ec4bb 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -53,6 +53,7 @@ export enum IpcChannel { Webview_SetOpenLinkExternal = 'webview:set-open-link-external', Webview_SetSpellCheckEnabled = 'webview:set-spell-check-enabled', + Webview_SearchHotkey = 'webview:search-hotkey', // Open Open_Path = 'open:path', diff --git a/packages/shared/config/types.ts b/packages/shared/config/types.ts index 90e5c64579..8012ed9022 100644 --- a/packages/shared/config/types.ts +++ b/packages/shared/config/types.ts @@ -22,3 +22,12 @@ export type MCPProgressEvent = { callId: string progress: number // 0-1 range } + +export type WebviewKeyEvent = { + webviewId: number + key: string + control: boolean + meta: boolean + shift: boolean + alt: boolean +} diff --git a/src/main/index.ts b/src/main/index.ts index fa5139bb5c..da9736c2bb 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -30,6 +30,7 @@ import selectionService, { initSelectionService } from './services/SelectionServ import { registerShortcuts } from './services/ShortcutService' import { TrayService } from './services/TrayService' import { windowService } from './services/WindowService' +import { initWebviewHotkeys } from './services/WebviewService' const logger = loggerService.withContext('MainEntry') @@ -108,6 +109,7 @@ if (!app.requestSingleInstanceLock()) { // Some APIs can only be used after this event occurs. app.whenReady().then(async () => { + initWebviewHotkeys() // Set app user model id for windows electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio') diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 514fa18cec..55d60fa203 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -786,7 +786,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.Webview_SetOpenLinkExternal, (_, webviewId: number, isExternal: boolean) => setOpenLinkExternal(webviewId, isExternal) ) - ipcMain.handle(IpcChannel.Webview_SetSpellCheckEnabled, (_, webviewId: number, isEnable: boolean) => { const webview = webContents.fromId(webviewId) if (!webview) return diff --git a/src/main/services/WebviewService.ts b/src/main/services/WebviewService.ts index e437b82ce5..1b60cc6643 100644 --- a/src/main/services/WebviewService.ts +++ b/src/main/services/WebviewService.ts @@ -1,4 +1,5 @@ -import { session, shell, webContents } from 'electron' +import { IpcChannel } from '@shared/IpcChannel' +import { app, session, shell, webContents } from 'electron' /** * init the useragent of the webview session @@ -36,3 +37,61 @@ export function setOpenLinkExternal(webviewId: number, isExternal: boolean) { } }) } + +const attachKeyboardHandler = (contents: Electron.WebContents) => { + if (contents.getType?.() !== 'webview') { + return + } + + const handleBeforeInput = (event: Electron.Event, input: Electron.Input) => { + if (!input) { + return + } + + const key = input.key?.toLowerCase() + if (!key) { + return + } + + const isFindShortcut = (input.control || input.meta) && key === 'f' + const isEscape = key === 'escape' + const isEnter = key === 'enter' + + if (!isFindShortcut && !isEscape && !isEnter) { + return + } + // Prevent default to override the guest page's native find dialog + // and keep shortcuts routed to our custom search overlay + event.preventDefault() + + const host = contents.hostWebContents + if (!host || host.isDestroyed()) { + return + } + + host.send(IpcChannel.Webview_SearchHotkey, { + webviewId: contents.id, + key, + control: Boolean(input.control), + meta: Boolean(input.meta), + shift: Boolean(input.shift), + alt: Boolean(input.alt) + }) + } + + contents.on('before-input-event', handleBeforeInput) + contents.once('destroyed', () => { + contents.removeListener('before-input-event', handleBeforeInput) + }) +} + +export function initWebviewHotkeys() { + webContents.getAllWebContents().forEach((contents) => { + if (contents.isDestroyed()) return + attachKeyboardHandler(contents) + }) + + app.on('web-contents-created', (_, contents) => { + attachKeyboardHandler(contents) + }) +} diff --git a/src/preload/index.ts b/src/preload/index.ts index b8211813b4..a90174baa5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3,7 +3,7 @@ import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' import { SpanContext } from '@opentelemetry/api' import { TerminalConfig, UpgradeChannel } from '@shared/config/constant' import type { LogLevel, LogSourceWithContext } from '@shared/config/logger' -import type { FileChangeEvent } from '@shared/config/types' +import type { FileChangeEvent, WebviewKeyEvent } from '@shared/config/types' import { IpcChannel } from '@shared/IpcChannel' import type { Notification } from '@types' import { @@ -390,7 +390,16 @@ const api = { setOpenLinkExternal: (webviewId: number, isExternal: boolean) => ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal), setSpellCheckEnabled: (webviewId: number, isEnable: boolean) => - ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable) + ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable), + onFindShortcut: (callback: (payload: WebviewKeyEvent) => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: WebviewKeyEvent) => { + callback(payload) + } + ipcRenderer.on(IpcChannel.Webview_SearchHotkey, listener) + return () => { + ipcRenderer.off(IpcChannel.Webview_SearchHotkey, listener) + } + } }, storeSync: { subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe), diff --git a/src/renderer/src/pages/minapps/components/WebviewSearch.tsx b/src/renderer/src/pages/minapps/components/WebviewSearch.tsx index 20970147d6..bff5605c1c 100644 --- a/src/renderer/src/pages/minapps/components/WebviewSearch.tsx +++ b/src/renderer/src/pages/minapps/components/WebviewSearch.tsx @@ -21,11 +21,11 @@ const WebviewSearch: FC = ({ webviewRef, isWebviewReady, app 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 activeWebview = webviewRef.current ?? null const focusInput = useCallback(() => { if (focusFrameRef.current !== null) { @@ -118,34 +118,66 @@ const WebviewSearch: FC = ({ webviewRef, isWebviewReady, app }, [performSearch, query]) useEffect(() => { - const nextWebview = webviewRef.current ?? null - if (currentWebview === nextWebview) return - setCurrentWebview(nextWebview) - }, [currentWebview, webviewRef]) - - useEffect(() => { - const target = currentWebview - if (!target) { - attachedWebviewRef.current = null + attachedWebviewRef.current = activeWebview + if (!activeWebview) { return } const handle = handleFoundInPage - attachedWebviewRef.current = target - target.addEventListener('found-in-page', handle) + activeWebview.addEventListener('found-in-page', handle) return () => { - target.removeEventListener('found-in-page', handle) - if (attachedWebviewRef.current === target) { + activeWebview.removeEventListener('found-in-page', handle) + if (attachedWebviewRef.current === activeWebview) { try { - target.stopFindInPage('clearSelection') + activeWebview.stopFindInPage('clearSelection') } catch (error) { logger.error('stopFindInPage failed', { error }) } attachedWebviewRef.current = null } } - }, [currentWebview, handleFoundInPage]) + }, [activeWebview, handleFoundInPage]) + + useEffect(() => { + if (!activeWebview) return + const onFindShortcut = window.api?.webview?.onFindShortcut + if (!onFindShortcut) return + + const webContentsId = activeWebview.getWebContentsId?.() + if (!webContentsId) { + logger.warn('WebviewSearch: missing webContentsId', { appId }) + return + } + + const unsubscribe = onFindShortcut(({ webviewId, key, control, meta, shift }) => { + if (webviewId !== webContentsId) return + + if ((control || meta) && key === 'f') { + openSearch() + return + } + + if (!isVisible) return + + if (key === 'escape') { + closeSearch() + return + } + + if (key === 'enter') { + if (shift) { + goToPrevious() + } else { + goToNext() + } + } + }) + + return () => { + unsubscribe?.() + } + }, [appId, activeWebview, closeSearch, goToNext, goToPrevious, isVisible, openSearch]) useEffect(() => { if (!isVisible) return @@ -159,7 +191,7 @@ const WebviewSearch: FC = ({ webviewRef, isWebviewReady, app return } performSearch(query) - }, [currentWebview, isVisible, performSearch, query]) + }, [activeWebview, isVisible, performSearch, query]) useEffect(() => { const handleKeydown = (event: KeyboardEvent) => { diff --git a/src/renderer/src/pages/minapps/components/__tests__/WebviewSearch.test.tsx b/src/renderer/src/pages/minapps/components/__tests__/WebviewSearch.test.tsx index 0f1985f6bb..50e8c60ca2 100644 --- a/src/renderer/src/pages/minapps/components/__tests__/WebviewSearch.test.tsx +++ b/src/renderer/src/pages/minapps/components/__tests__/WebviewSearch.test.tsx @@ -1,3 +1,4 @@ +import type { WebviewKeyEvent } from '@shared/config/types' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import type { WebviewTag } from 'electron' @@ -36,6 +37,7 @@ const createWebviewMock = () => { listeners.get(type)?.delete(listener) } ), + getWebContentsId: vi.fn(() => 1), findInPage: findInPageMock as unknown as WebviewTag['findInPage'], stopFindInPage: stopFindInPageMock as unknown as WebviewTag['stopFindInPage'] } as unknown as WebviewTag @@ -102,13 +104,34 @@ describe('WebviewSearch', () => { info: vi.fn(), addToast: vi.fn() } + let removeFindShortcutListenerMock: ReturnType + let onFindShortcutMock: ReturnType + const invokeLatestShortcut = (payload: WebviewKeyEvent) => { + const handler = onFindShortcutMock.mock.calls.at(-1)?.[0] as ((args: WebviewKeyEvent) => void) | undefined + if (!handler) { + throw new Error('Shortcut handler not registered') + } + act(() => { + handler(payload) + }) + } beforeEach(() => { + removeFindShortcutListenerMock = vi.fn() + onFindShortcutMock = vi.fn(() => removeFindShortcutListenerMock) + Object.assign(window as any, { + api: { + webview: { + onFindShortcut: onFindShortcutMock + } + } + }) Object.assign(window, { toast: toastMock }) }) afterEach(() => { vi.clearAllMocks() + Reflect.deleteProperty(window, 'api') }) it('opens the search overlay with keyboard shortcut', async () => { @@ -124,6 +147,47 @@ describe('WebviewSearch', () => { expect(screen.getByPlaceholderText('Search')).toBeInTheDocument() }) + it('opens the search overlay when webview shortcut is forwarded', async () => { + const { webview } = createWebviewMock() + const webviewRef = { current: webview } as React.RefObject + + render() + + await waitFor(() => { + expect(onFindShortcutMock).toHaveBeenCalled() + }) + + invokeLatestShortcut({ webviewId: 1, key: 'f', control: true, meta: false, shift: false, alt: false }) + + await waitFor(() => { + expect(screen.getByPlaceholderText('Search')).toBeInTheDocument() + }) + }) + + it('closes the search overlay when escape is forwarded from the webview', async () => { + const { webview } = createWebviewMock() + const webviewRef = { current: webview } as React.RefObject + + render() + + await waitFor(() => { + expect(onFindShortcutMock).toHaveBeenCalled() + }) + invokeLatestShortcut({ webviewId: 1, key: 'f', control: true, meta: false, shift: false, alt: false }) + await waitFor(() => { + expect(screen.getByPlaceholderText('Search')).toBeInTheDocument() + }) + + await waitFor(() => { + expect(onFindShortcutMock.mock.calls.length).toBeGreaterThanOrEqual(2) + }) + + invokeLatestShortcut({ webviewId: 1, key: 'escape', control: false, meta: false, shift: false, alt: false }) + await waitFor(() => { + expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument() + }) + }) + it('performs searches and navigates between results', async () => { const { emit, findInPageMock, webview } = createWebviewMock() const webviewRef = { current: webview } as React.RefObject @@ -165,6 +229,45 @@ describe('WebviewSearch', () => { }) }) + it('navigates results when enter is forwarded from the webview', async () => { + const { findInPageMock, webview } = createWebviewMock() + const webviewRef = { current: webview } as React.RefObject + const user = userEvent.setup() + + render() + + await waitFor(() => { + expect(onFindShortcutMock).toHaveBeenCalled() + }) + invokeLatestShortcut({ webviewId: 1, key: 'f', control: true, meta: false, shift: false, alt: false }) + await waitFor(() => { + expect(screen.getByPlaceholderText('Search')).toBeInTheDocument() + }) + + await waitFor(() => { + expect(onFindShortcutMock.mock.calls.length).toBeGreaterThanOrEqual(2) + }) + + const input = screen.getByRole('textbox') + await user.type(input, 'Cherry') + + await waitFor(() => { + expect(findInPageMock).toHaveBeenCalledWith('Cherry', undefined) + }) + findInPageMock.mockClear() + + invokeLatestShortcut({ webviewId: 1, key: 'enter', control: false, meta: false, shift: false, alt: false }) + await waitFor(() => { + expect(findInPageMock).toHaveBeenCalledWith('Cherry', { forward: true, findNext: true }) + }) + + findInPageMock.mockClear() + invokeLatestShortcut({ webviewId: 1, key: 'enter', control: false, meta: false, shift: true, alt: false }) + await waitFor(() => { + expect(findInPageMock).toHaveBeenCalledWith('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 @@ -219,6 +322,7 @@ describe('WebviewSearch', () => { unmount() expect(stopFindInPageMock).toHaveBeenCalledWith('clearSelection') + expect(removeFindShortcutListenerMock).toHaveBeenCalled() }) it('ignores keyboard shortcut when webview is not ready', async () => {