Skip to content

Commit 7b90dfb

Browse files
beyondkmpclaude
andauthored
fix: intercept webview keyboard shortcuts for search functionality (CherryHQ#10641)
* 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]>
1 parent 26a9dba commit 7b90dfb

File tree

8 files changed

+236
-21
lines changed

8 files changed

+236
-21
lines changed

packages/shared/IpcChannel.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export enum IpcChannel {
5353

5454
Webview_SetOpenLinkExternal = 'webview:set-open-link-external',
5555
Webview_SetSpellCheckEnabled = 'webview:set-spell-check-enabled',
56+
Webview_SearchHotkey = 'webview:search-hotkey',
5657

5758
// Open
5859
Open_Path = 'open:path',

packages/shared/config/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,12 @@ export type MCPProgressEvent = {
2222
callId: string
2323
progress: number // 0-1 range
2424
}
25+
26+
export type WebviewKeyEvent = {
27+
webviewId: number
28+
key: string
29+
control: boolean
30+
meta: boolean
31+
shift: boolean
32+
alt: boolean
33+
}

src/main/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import selectionService, { initSelectionService } from './services/SelectionServ
3030
import { registerShortcuts } from './services/ShortcutService'
3131
import { TrayService } from './services/TrayService'
3232
import { windowService } from './services/WindowService'
33+
import { initWebviewHotkeys } from './services/WebviewService'
3334

3435
const logger = loggerService.withContext('MainEntry')
3536

@@ -108,6 +109,7 @@ if (!app.requestSingleInstanceLock()) {
108109
// Some APIs can only be used after this event occurs.
109110

110111
app.whenReady().then(async () => {
112+
initWebviewHotkeys()
111113
// Set app user model id for windows
112114
electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio')
113115

src/main/ipc.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -786,7 +786,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
786786
ipcMain.handle(IpcChannel.Webview_SetOpenLinkExternal, (_, webviewId: number, isExternal: boolean) =>
787787
setOpenLinkExternal(webviewId, isExternal)
788788
)
789-
790789
ipcMain.handle(IpcChannel.Webview_SetSpellCheckEnabled, (_, webviewId: number, isEnable: boolean) => {
791790
const webview = webContents.fromId(webviewId)
792791
if (!webview) return

src/main/services/WebviewService.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { session, shell, webContents } from 'electron'
1+
import { IpcChannel } from '@shared/IpcChannel'
2+
import { app, session, shell, webContents } from 'electron'
23

34
/**
45
* init the useragent of the webview session
@@ -36,3 +37,61 @@ export function setOpenLinkExternal(webviewId: number, isExternal: boolean) {
3637
}
3738
})
3839
}
40+
41+
const attachKeyboardHandler = (contents: Electron.WebContents) => {
42+
if (contents.getType?.() !== 'webview') {
43+
return
44+
}
45+
46+
const handleBeforeInput = (event: Electron.Event, input: Electron.Input) => {
47+
if (!input) {
48+
return
49+
}
50+
51+
const key = input.key?.toLowerCase()
52+
if (!key) {
53+
return
54+
}
55+
56+
const isFindShortcut = (input.control || input.meta) && key === 'f'
57+
const isEscape = key === 'escape'
58+
const isEnter = key === 'enter'
59+
60+
if (!isFindShortcut && !isEscape && !isEnter) {
61+
return
62+
}
63+
// Prevent default to override the guest page's native find dialog
64+
// and keep shortcuts routed to our custom search overlay
65+
event.preventDefault()
66+
67+
const host = contents.hostWebContents
68+
if (!host || host.isDestroyed()) {
69+
return
70+
}
71+
72+
host.send(IpcChannel.Webview_SearchHotkey, {
73+
webviewId: contents.id,
74+
key,
75+
control: Boolean(input.control),
76+
meta: Boolean(input.meta),
77+
shift: Boolean(input.shift),
78+
alt: Boolean(input.alt)
79+
})
80+
}
81+
82+
contents.on('before-input-event', handleBeforeInput)
83+
contents.once('destroyed', () => {
84+
contents.removeListener('before-input-event', handleBeforeInput)
85+
})
86+
}
87+
88+
export function initWebviewHotkeys() {
89+
webContents.getAllWebContents().forEach((contents) => {
90+
if (contents.isDestroyed()) return
91+
attachKeyboardHandler(contents)
92+
})
93+
94+
app.on('web-contents-created', (_, contents) => {
95+
attachKeyboardHandler(contents)
96+
})
97+
}

src/preload/index.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
33
import { SpanContext } from '@opentelemetry/api'
44
import { TerminalConfig, UpgradeChannel } from '@shared/config/constant'
55
import type { LogLevel, LogSourceWithContext } from '@shared/config/logger'
6-
import type { FileChangeEvent } from '@shared/config/types'
6+
import type { FileChangeEvent, WebviewKeyEvent } from '@shared/config/types'
77
import { IpcChannel } from '@shared/IpcChannel'
88
import type { Notification } from '@types'
99
import {
@@ -390,7 +390,16 @@ const api = {
390390
setOpenLinkExternal: (webviewId: number, isExternal: boolean) =>
391391
ipcRenderer.invoke(IpcChannel.Webview_SetOpenLinkExternal, webviewId, isExternal),
392392
setSpellCheckEnabled: (webviewId: number, isEnable: boolean) =>
393-
ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable)
393+
ipcRenderer.invoke(IpcChannel.Webview_SetSpellCheckEnabled, webviewId, isEnable),
394+
onFindShortcut: (callback: (payload: WebviewKeyEvent) => void) => {
395+
const listener = (_event: Electron.IpcRendererEvent, payload: WebviewKeyEvent) => {
396+
callback(payload)
397+
}
398+
ipcRenderer.on(IpcChannel.Webview_SearchHotkey, listener)
399+
return () => {
400+
ipcRenderer.off(IpcChannel.Webview_SearchHotkey, listener)
401+
}
402+
}
394403
},
395404
storeSync: {
396405
subscribe: () => ipcRenderer.invoke(IpcChannel.StoreSync_Subscribe),

src/renderer/src/pages/minapps/components/WebviewSearch.tsx

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, app
2121
const [query, setQuery] = useState('')
2222
const [matchCount, setMatchCount] = useState(0)
2323
const [activeIndex, setActiveIndex] = useState(0)
24-
const [currentWebview, setCurrentWebview] = useState<WebviewTag | null>(null)
2524
const inputRef = useRef<HTMLInputElement>(null)
2625
const focusFrameRef = useRef<number | null>(null)
2726
const lastAppIdRef = useRef<string>(appId)
2827
const attachedWebviewRef = useRef<WebviewTag | null>(null)
28+
const activeWebview = webviewRef.current ?? null
2929

3030
const focusInput = useCallback(() => {
3131
if (focusFrameRef.current !== null) {
@@ -118,34 +118,66 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, app
118118
}, [performSearch, query])
119119

120120
useEffect(() => {
121-
const nextWebview = webviewRef.current ?? null
122-
if (currentWebview === nextWebview) return
123-
setCurrentWebview(nextWebview)
124-
}, [currentWebview, webviewRef])
125-
126-
useEffect(() => {
127-
const target = currentWebview
128-
if (!target) {
129-
attachedWebviewRef.current = null
121+
attachedWebviewRef.current = activeWebview
122+
if (!activeWebview) {
130123
return
131124
}
132125

133126
const handle = handleFoundInPage
134-
attachedWebviewRef.current = target
135-
target.addEventListener('found-in-page', handle)
127+
activeWebview.addEventListener('found-in-page', handle)
136128

137129
return () => {
138-
target.removeEventListener('found-in-page', handle)
139-
if (attachedWebviewRef.current === target) {
130+
activeWebview.removeEventListener('found-in-page', handle)
131+
if (attachedWebviewRef.current === activeWebview) {
140132
try {
141-
target.stopFindInPage('clearSelection')
133+
activeWebview.stopFindInPage('clearSelection')
142134
} catch (error) {
143135
logger.error('stopFindInPage failed', { error })
144136
}
145137
attachedWebviewRef.current = null
146138
}
147139
}
148-
}, [currentWebview, handleFoundInPage])
140+
}, [activeWebview, handleFoundInPage])
141+
142+
useEffect(() => {
143+
if (!activeWebview) return
144+
const onFindShortcut = window.api?.webview?.onFindShortcut
145+
if (!onFindShortcut) return
146+
147+
const webContentsId = activeWebview.getWebContentsId?.()
148+
if (!webContentsId) {
149+
logger.warn('WebviewSearch: missing webContentsId', { appId })
150+
return
151+
}
152+
153+
const unsubscribe = onFindShortcut(({ webviewId, key, control, meta, shift }) => {
154+
if (webviewId !== webContentsId) return
155+
156+
if ((control || meta) && key === 'f') {
157+
openSearch()
158+
return
159+
}
160+
161+
if (!isVisible) return
162+
163+
if (key === 'escape') {
164+
closeSearch()
165+
return
166+
}
167+
168+
if (key === 'enter') {
169+
if (shift) {
170+
goToPrevious()
171+
} else {
172+
goToNext()
173+
}
174+
}
175+
})
176+
177+
return () => {
178+
unsubscribe?.()
179+
}
180+
}, [appId, activeWebview, closeSearch, goToNext, goToPrevious, isVisible, openSearch])
149181

150182
useEffect(() => {
151183
if (!isVisible) return
@@ -159,7 +191,7 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, app
159191
return
160192
}
161193
performSearch(query)
162-
}, [currentWebview, isVisible, performSearch, query])
194+
}, [activeWebview, isVisible, performSearch, query])
163195

164196
useEffect(() => {
165197
const handleKeydown = (event: KeyboardEvent) => {

src/renderer/src/pages/minapps/components/__tests__/WebviewSearch.test.tsx

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { WebviewKeyEvent } from '@shared/config/types'
12
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
23
import userEvent from '@testing-library/user-event'
34
import type { WebviewTag } from 'electron'
@@ -36,6 +37,7 @@ const createWebviewMock = () => {
3637
listeners.get(type)?.delete(listener)
3738
}
3839
),
40+
getWebContentsId: vi.fn(() => 1),
3941
findInPage: findInPageMock as unknown as WebviewTag['findInPage'],
4042
stopFindInPage: stopFindInPageMock as unknown as WebviewTag['stopFindInPage']
4143
} as unknown as WebviewTag
@@ -102,13 +104,34 @@ describe('WebviewSearch', () => {
102104
info: vi.fn(),
103105
addToast: vi.fn()
104106
}
107+
let removeFindShortcutListenerMock: ReturnType<typeof vi.fn>
108+
let onFindShortcutMock: ReturnType<typeof vi.fn>
109+
const invokeLatestShortcut = (payload: WebviewKeyEvent) => {
110+
const handler = onFindShortcutMock.mock.calls.at(-1)?.[0] as ((args: WebviewKeyEvent) => void) | undefined
111+
if (!handler) {
112+
throw new Error('Shortcut handler not registered')
113+
}
114+
act(() => {
115+
handler(payload)
116+
})
117+
}
105118

106119
beforeEach(() => {
120+
removeFindShortcutListenerMock = vi.fn()
121+
onFindShortcutMock = vi.fn(() => removeFindShortcutListenerMock)
122+
Object.assign(window as any, {
123+
api: {
124+
webview: {
125+
onFindShortcut: onFindShortcutMock
126+
}
127+
}
128+
})
107129
Object.assign(window, { toast: toastMock })
108130
})
109131

110132
afterEach(() => {
111133
vi.clearAllMocks()
134+
Reflect.deleteProperty(window, 'api')
112135
})
113136

114137
it('opens the search overlay with keyboard shortcut', async () => {
@@ -124,6 +147,47 @@ describe('WebviewSearch', () => {
124147
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
125148
})
126149

150+
it('opens the search overlay when webview shortcut is forwarded', async () => {
151+
const { webview } = createWebviewMock()
152+
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
153+
154+
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
155+
156+
await waitFor(() => {
157+
expect(onFindShortcutMock).toHaveBeenCalled()
158+
})
159+
160+
invokeLatestShortcut({ webviewId: 1, key: 'f', control: true, meta: false, shift: false, alt: false })
161+
162+
await waitFor(() => {
163+
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
164+
})
165+
})
166+
167+
it('closes the search overlay when escape is forwarded from the webview', async () => {
168+
const { webview } = createWebviewMock()
169+
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
170+
171+
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
172+
173+
await waitFor(() => {
174+
expect(onFindShortcutMock).toHaveBeenCalled()
175+
})
176+
invokeLatestShortcut({ webviewId: 1, key: 'f', control: true, meta: false, shift: false, alt: false })
177+
await waitFor(() => {
178+
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
179+
})
180+
181+
await waitFor(() => {
182+
expect(onFindShortcutMock.mock.calls.length).toBeGreaterThanOrEqual(2)
183+
})
184+
185+
invokeLatestShortcut({ webviewId: 1, key: 'escape', control: false, meta: false, shift: false, alt: false })
186+
await waitFor(() => {
187+
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument()
188+
})
189+
})
190+
127191
it('performs searches and navigates between results', async () => {
128192
const { emit, findInPageMock, webview } = createWebviewMock()
129193
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
@@ -165,6 +229,45 @@ describe('WebviewSearch', () => {
165229
})
166230
})
167231

232+
it('navigates results when enter is forwarded from the webview', async () => {
233+
const { findInPageMock, webview } = createWebviewMock()
234+
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
235+
const user = userEvent.setup()
236+
237+
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
238+
239+
await waitFor(() => {
240+
expect(onFindShortcutMock).toHaveBeenCalled()
241+
})
242+
invokeLatestShortcut({ webviewId: 1, key: 'f', control: true, meta: false, shift: false, alt: false })
243+
await waitFor(() => {
244+
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument()
245+
})
246+
247+
await waitFor(() => {
248+
expect(onFindShortcutMock.mock.calls.length).toBeGreaterThanOrEqual(2)
249+
})
250+
251+
const input = screen.getByRole('textbox')
252+
await user.type(input, 'Cherry')
253+
254+
await waitFor(() => {
255+
expect(findInPageMock).toHaveBeenCalledWith('Cherry', undefined)
256+
})
257+
findInPageMock.mockClear()
258+
259+
invokeLatestShortcut({ webviewId: 1, key: 'enter', control: false, meta: false, shift: false, alt: false })
260+
await waitFor(() => {
261+
expect(findInPageMock).toHaveBeenCalledWith('Cherry', { forward: true, findNext: true })
262+
})
263+
264+
findInPageMock.mockClear()
265+
invokeLatestShortcut({ webviewId: 1, key: 'enter', control: false, meta: false, shift: true, alt: false })
266+
await waitFor(() => {
267+
expect(findInPageMock).toHaveBeenCalledWith('Cherry', { forward: false, findNext: true })
268+
})
269+
})
270+
168271
it('clears search state when appId changes', async () => {
169272
const { findInPageMock, stopFindInPageMock, webview } = createWebviewMock()
170273
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
@@ -219,6 +322,7 @@ describe('WebviewSearch', () => {
219322
unmount()
220323

221324
expect(stopFindInPageMock).toHaveBeenCalledWith('clearSelection')
325+
expect(removeFindShortcutListenerMock).toHaveBeenCalled()
222326
})
223327

224328
it('ignores keyboard shortcut when webview is not ready', async () => {

0 commit comments

Comments
 (0)