mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-26 11:44:28 +08:00
fix: intercept webview keyboard shortcuts for search functionality (#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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com>
This commit is contained in:
parent
26a9dba01a
commit
7b90dfb46c
@ -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',
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -21,11 +21,11 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, app
|
||||
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 activeWebview = webviewRef.current ?? null
|
||||
|
||||
const focusInput = useCallback(() => {
|
||||
if (focusFrameRef.current !== null) {
|
||||
@ -118,34 +118,66 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ 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<WebviewSearchProps> = ({ webviewRef, isWebviewReady, app
|
||||
return
|
||||
}
|
||||
performSearch(query)
|
||||
}, [currentWebview, isVisible, performSearch, query])
|
||||
}, [activeWebview, isVisible, performSearch, query])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
|
||||
@ -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<typeof vi.fn>
|
||||
let onFindShortcutMock: ReturnType<typeof vi.fn>
|
||||
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<WebviewTag | null>
|
||||
|
||||
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
|
||||
|
||||
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<WebviewTag | null>
|
||||
|
||||
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
|
||||
|
||||
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<WebviewTag | null>
|
||||
@ -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<WebviewTag | null>
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
|
||||
|
||||
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<WebviewTag | null>
|
||||
@ -219,6 +322,7 @@ describe('WebviewSearch', () => {
|
||||
unmount()
|
||||
|
||||
expect(stopFindInPageMock).toHaveBeenCalledWith('clearSelection')
|
||||
expect(removeFindShortcutListenerMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores keyboard shortcut when webview is not ready', async () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user