diff --git a/packages/frontend/core/src/components/affine/find-in-page/index.tsx b/packages/frontend/core/src/components/affine/find-in-page/index.tsx new file mode 100644 index 0000000000000..5a1f864bc2f88 --- /dev/null +++ b/packages/frontend/core/src/components/affine/find-in-page/index.tsx @@ -0,0 +1,134 @@ +import { cmdFind } from '@affine/electron-api'; +import type React from 'react'; +import { useDeferredValue, useEffect, useState } from 'react'; + +type Result = { + requestId: number; + activeMatchOrdinal: number; + matches: number; + finalUpdate: boolean; +}; + +export const FindInPage: React.FC = () => { + const [visible, setVisible] = useState(false); + const [searchText, setSearchText] = useState(''); + const deferredSearchText = useDeferredValue(searchText); + const [result, setResult] = useState(null); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'f' && (event.ctrlKey || event.metaKey)) { + setVisible(true); + const input = document.getElementById('find-input') as HTMLInputElement; + input.focus(); + input.select(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, []); + + useEffect(() => { + if (!visible) { + cmdFind?.stopFindInPage('clearSelection'); + setSearchText(''); + } + }, [visible]); + + const handleInputChange = (event: React.ChangeEvent) => { + setSearchText(event.target.value); + }; + + const handleResult = (data: Result) => { + setResult(data); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + cmdFind?.stopFindInPage('clearSelection'); + setSearchText(''); + setResult(null); + setVisible(false); + } + if (event.key === 'Enter') { + cmdFind?.findInPage(deferredSearchText); + cmdFind?.onFindInPageResult(handleResult); + } + }; + + const handleNextClick = () => { + cmdFind?.findInPage(deferredSearchText, { forward: true }); + }; + + const handlePrevClick = () => { + cmdFind?.findInPage(deferredSearchText, { forward: false }); + }; + + return ( +
+ + {result ? ( +
+ {result?.activeMatchOrdinal}/{result?.matches} +
+ ) : null} + + +
+ ); +}; diff --git a/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx b/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx index c2594c4e68358..72ff8f462d772 100644 --- a/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx +++ b/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx @@ -1,6 +1,7 @@ import { Scrollable } from '@affine/component'; import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton'; import { PageAIOnboarding } from '@affine/core/components/affine/ai-onboarding'; +import { FindInPage } from '@affine/core/components/affine/find-in-page'; import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper'; import type { PageRootService } from '@blocksuite/blocks'; import { @@ -240,6 +241,7 @@ const DetailPageImpl = memo(function DetailPageImpl() { <> + {environment.isDesktop && }
diff --git a/packages/frontend/electron-api/src/index.ts b/packages/frontend/electron-api/src/index.ts index ade64fcfca145..37e8c5ade84d0 100644 --- a/packages/frontend/electron-api/src/index.ts +++ b/packages/frontend/electron-api/src/index.ts @@ -9,6 +9,7 @@ import type { import type { affine as exposedAffineGlobal, appInfo as exposedAppInfo, + cmdFind as exposedCmdFind, } from '@affine/electron/preload/electron-api'; type MainHandlers = typeof mainHandlers; @@ -39,5 +40,8 @@ export const events = (globalThis as any).events as ClientEvents | null; export const affine = (globalThis as any).affine as | typeof exposedAffineGlobal | null; +export const cmdFind = (globalThis as any).cmdFind as + | typeof exposedCmdFind + | null; export type { UpdateMeta } from '@affine/electron/main/updater/event'; diff --git a/packages/frontend/electron/src/main/main-window.ts b/packages/frontend/electron/src/main/main-window.ts index d4db3fd8bdcad..ecbf5ad23eae4 100644 --- a/packages/frontend/electron/src/main/main-window.ts +++ b/packages/frontend/electron/src/main/main-window.ts @@ -2,7 +2,7 @@ import assert from 'node:assert'; import { join } from 'node:path'; import type { CookiesSetDetails } from 'electron'; -import { BrowserWindow, nativeTheme } from 'electron'; +import { BrowserWindow, ipcMain, nativeTheme } from 'electron'; import electronWindowState from 'electron-window-state'; import { isLinux, isMacOS, isWindows } from '../shared/utils'; @@ -169,6 +169,25 @@ async function createWindow(additionalArguments: string[]) { uiSubjects.onFullScreen$.next(false); }); + browserWindow.webContents.on('found-in-page', (_event, result) => { + const { requestId, activeMatchOrdinal, matches, finalUpdate } = result; + browserWindow.webContents.send('found-in-page-result', { + requestId, + activeMatchOrdinal, + matches, + finalUpdate, + }); + }); + + ipcMain.on('find-in-page', (_event, text, options) => { + browserWindow.webContents.findInPage(text, options); + }); + + ipcMain.on('stop-find-in-page', (_event, action) => { + browserWindow.webContents.stopFindInPage(action); + }); + // get-find-in-page-results + /** * URL for main window. */ diff --git a/packages/frontend/electron/src/preload/bootstrap.ts b/packages/frontend/electron/src/preload/bootstrap.ts index d1c1137517a37..ace2e1cb6be9e 100644 --- a/packages/frontend/electron/src/preload/bootstrap.ts +++ b/packages/frontend/electron/src/preload/bootstrap.ts @@ -1,6 +1,6 @@ import { contextBridge } from 'electron'; -import { affine, appInfo, getElectronAPIs } from './electron-api'; +import { affine, appInfo, cmdFind, getElectronAPIs } from './electron-api'; const { apis, events } = getElectronAPIs(); @@ -10,6 +10,7 @@ contextBridge.exposeInMainWorld('events', events); try { contextBridge.exposeInMainWorld('affine', affine); + contextBridge.exposeInMainWorld('cmdFind', cmdFind); } catch (error) { console.error('Failed to expose affine APIs to window object!', error); } diff --git a/packages/frontend/electron/src/preload/electron-api.ts b/packages/frontend/electron/src/preload/electron-api.ts index 30e3614895ae9..6d94c80f399e7 100644 --- a/packages/frontend/electron/src/preload/electron-api.ts +++ b/packages/frontend/electron/src/preload/electron-api.ts @@ -1,7 +1,7 @@ // Please add modules to `external` in `rollupOptions` to avoid wrong bundling. import type { EventBasedChannel } from 'async-call-rpc'; import { AsyncCall } from 'async-call-rpc'; -import { ipcRenderer } from 'electron'; +import { ipcRenderer, type IpcRendererEvent } from 'electron'; import { Subject } from 'rxjs'; import { z } from 'zod'; @@ -47,6 +47,16 @@ export const affine = { }, }; +export const cmdFind = { + findInPage: (text: string, options?: Electron.FindInPageOptions) => + ipcRenderer.send('find-in-page', text, options), + stopFindInPage: ( + action: 'clearSelection' | 'keepSelection' | 'activateSelection' + ) => ipcRenderer.send('stop-find-in-page', action), + onFindInPageResult: (callBack: (data: any) => void) => + ipcRenderer.on('found-in-page-result', (event, data) => callBack(data)), +}; + export function getElectronAPIs() { const mainAPIs = getMainAPIs(); const helperAPIs = getHelperAPIs();