diff --git a/packages/frontend/core/src/atoms/index.ts b/packages/frontend/core/src/atoms/index.ts index f4d28f5b6f7dd..e90313aad678f 100644 --- a/packages/frontend/core/src/atoms/index.ts +++ b/packages/frontend/core/src/atoms/index.ts @@ -15,6 +15,8 @@ export const openStarAFFiNEModalAtom = atom(false); export const openIssueFeedbackModalAtom = atom(false); export const openHistoryTipsModalAtom = atom(false); +export const rightSidebarWidthAtom = atom(320); + export type SettingAtom = Pick< SettingProps, 'activeTab' | 'workspaceMetadata' diff --git a/packages/frontend/core/src/hooks/affine/use-register-find-in-page-commands.ts b/packages/frontend/core/src/hooks/affine/use-register-find-in-page-commands.ts new file mode 100644 index 0000000000000..156925a0afd7c --- /dev/null +++ b/packages/frontend/core/src/hooks/affine/use-register-find-in-page-commands.ts @@ -0,0 +1,34 @@ +import { FindInPageService } from '@affine/core/modules/find-in-page/services/find-in-page'; +import { registerAffineCommand, useService } from '@toeverything/infra'; +import { useCallback, useEffect } from 'react'; + +export function useRegisterFindInPageCommands() { + const findInPage = useService(FindInPageService).findInPage; + const toggleVisible = useCallback(() => { + findInPage.toggleVisible(); + }, [findInPage]); + + useEffect(() => { + if (!environment.isDesktop) { + return; + } + const unsubs: Array<() => void> = []; + unsubs.push( + registerAffineCommand({ + id: `editor:find-in-page`, + keyBinding: { + binding: '$mod+f', + }, + icon: null, + label: '', + run() { + toggleVisible(); + }, + }) + ); + + return () => { + unsubs.forEach(unsub => unsub()); + }; + }, [toggleVisible]); +} diff --git a/packages/frontend/core/src/layouts/workspace-layout.tsx b/packages/frontend/core/src/layouts/workspace-layout.tsx index 3a72c691b0189..89692e25b1171 100644 --- a/packages/frontend/core/src/layouts/workspace-layout.tsx +++ b/packages/frontend/core/src/layouts/workspace-layout.tsx @@ -35,6 +35,7 @@ import { resolveDragEndIntent, useGlobalDNDHelper, } from '../hooks/affine/use-global-dnd-helper'; +import { useRegisterFindInPageCommands } from '../hooks/affine/use-register-find-in-page-commands'; import { useNavigateHelper } from '../hooks/use-navigate-helper'; import { useRegisterWorkspaceCommands } from '../hooks/use-register-workspace-commands'; import { useRegisterNavigationCommands } from '../modules/navigation/view/use-register-navigation-commands'; @@ -117,6 +118,7 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => { useRegisterWorkspaceCommands(); useRegisterNavigationCommands(); + useRegisterFindInPageCommands(); useEffect(() => { // hotfix for blockVersions diff --git a/packages/frontend/core/src/modules/find-in-page/entities/find-in-page.ts b/packages/frontend/core/src/modules/find-in-page/entities/find-in-page.ts new file mode 100644 index 0000000000000..86619fab6805c --- /dev/null +++ b/packages/frontend/core/src/modules/find-in-page/entities/find-in-page.ts @@ -0,0 +1,108 @@ +import { cmdFind } from '@affine/electron-api'; +import { Entity, LiveData } from '@toeverything/infra'; +import { Observable, of, switchMap } from 'rxjs'; + +type FindInPageResult = { + requestId: number; + activeMatchOrdinal: number; + matches: number; + finalUpdate: boolean; +}; +export class FindInPage extends Entity { + // modal open/close + + readonly searchText$ = new LiveData(null); + private readonly direction$ = new LiveData<'forward' | 'backward'>('forward'); + readonly isSearching$ = new LiveData(false); + + readonly visible$ = new LiveData(false); + + readonly result$ = LiveData.from( + this.searchText$.pipe( + switchMap(searchText => { + if (!searchText) { + return of(null); + } else { + return new Observable(subscriber => { + const handleResult = (result: FindInPageResult) => { + subscriber.next(result); + if (result.finalUpdate) { + subscriber.complete(); + this.isSearching$.next(false); + } + }; + this.isSearching$.next(true); + cmdFind + ?.findInPage(searchText, { + forward: this.direction$.value === 'forward', + }) + .then(() => cmdFind?.onFindInPageResult(handleResult)) + .catch(e => { + console.error(e); + this.isSearching$.next(false); + }); + + return () => { + cmdFind?.offFindInPageResult(handleResult); + }; + }); + } + }) + ), + { requestId: 0, activeMatchOrdinal: 0, matches: 0, finalUpdate: true } + ); + + constructor() { + super(); + } + + findInPage(searchText: string) { + this.searchText$.next(searchText); + } + + private updateResult(result: FindInPageResult) { + this.result$.next(result); + } + + onChangeVisible(visible: boolean) { + this.visible$.next(visible); + if (!visible) { + this.stopFindInPage('clearSelection'); + } + } + + toggleVisible() { + const nextVisible = !this.visible$.value; + this.visible$.next(nextVisible); + if (!nextVisible) { + this.stopFindInPage('clearSelection'); + } + } + + backward() { + if (!this.searchText$.value) { + return; + } + this.direction$.next('backward'); + this.searchText$.next(this.searchText$.value); + cmdFind?.onFindInPageResult(result => this.updateResult(result)); + } + + forward() { + if (!this.searchText$.value) { + return; + } + this.direction$.next('forward'); + this.searchText$.next(this.searchText$.value); + cmdFind?.onFindInPageResult(result => this.updateResult(result)); + } + + stopFindInPage( + action: 'clearSelection' | 'keepSelection' | 'activateSelection' + ) { + if (action === 'clearSelection') { + this.searchText$.next(null); + } + cmdFind?.stopFindInPage(action).catch(e => console.error(e)); + } +} diff --git a/packages/frontend/core/src/modules/find-in-page/index.ts b/packages/frontend/core/src/modules/find-in-page/index.ts new file mode 100644 index 0000000000000..5b4e747cf9c4b --- /dev/null +++ b/packages/frontend/core/src/modules/find-in-page/index.ts @@ -0,0 +1,8 @@ +import type { Framework } from '@toeverything/infra'; + +import { FindInPage } from './entities/find-in-page'; +import { FindInPageService } from './services/find-in-page'; + +export function configureFindInPageModule(framework: Framework) { + framework.service(FindInPageService).entity(FindInPage); +} diff --git a/packages/frontend/core/src/modules/find-in-page/services/find-in-page.ts b/packages/frontend/core/src/modules/find-in-page/services/find-in-page.ts new file mode 100644 index 0000000000000..9e2bfc17cc422 --- /dev/null +++ b/packages/frontend/core/src/modules/find-in-page/services/find-in-page.ts @@ -0,0 +1,7 @@ +import { Service } from '@toeverything/infra'; + +import { FindInPage } from '../entities/find-in-page'; + +export class FindInPageService extends Service { + public readonly findInPage = this.framework.createEntity(FindInPage); +} diff --git a/packages/frontend/core/src/modules/find-in-page/view/find-in-page-modal.css.ts b/packages/frontend/core/src/modules/find-in-page/view/find-in-page-modal.css.ts new file mode 100644 index 0000000000000..63c4e82a2ee74 --- /dev/null +++ b/packages/frontend/core/src/modules/find-in-page/view/find-in-page-modal.css.ts @@ -0,0 +1,58 @@ +import { cssVar } from '@toeverything/theme'; +import { createVar, style } from '@vanilla-extract/css'; + +export const panelWidthVar = createVar('panel-width'); + +export const container = style({ + vars: { + [panelWidthVar]: '0px', + }, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '8px 12px 8px 8px', + position: 'fixed', + right: '28px', + top: '80px', + transform: `translateX(calc(${panelWidthVar} * -1))`, +}); + +export const leftContent = style({ + display: 'flex', + alignItems: 'center', +}); + +export const input = style({ + padding: '0 10px', + height: '32px', + gap: '8px', + color: cssVar('iconColor'), + background: cssVar('white10'), +}); + +export const count = style({ + color: cssVar('textSecondaryColor'), + fontSize: cssVar('fontXs'), + userSelect: 'none', +}); + +export const arrowButton = style({ + padding: '4px', + fontSize: '24px', + width: '32px', + height: '32px', + border: '1px solid', + borderColor: cssVar('borderColor'), + alignItems: 'baseline', + background: 'transparent', + selectors: { + '&.backward': { + marginLeft: '8px', + borderRadius: '4px 0 0 4px', + }, + '&.forward': { + borderLeft: 'none', + borderRadius: '0 4px 4px 0', + }, + }, +}); diff --git a/packages/frontend/core/src/modules/find-in-page/view/find-in-page-modal.tsx b/packages/frontend/core/src/modules/find-in-page/view/find-in-page-modal.tsx new file mode 100644 index 0000000000000..f3b123b06b693 --- /dev/null +++ b/packages/frontend/core/src/modules/find-in-page/view/find-in-page-modal.tsx @@ -0,0 +1,159 @@ +import { Button, Input, Modal } from '@affine/component'; +import { rightSidebarWidthAtom } from '@affine/core/atoms'; +import { + ArrowDownSmallIcon, + ArrowUpSmallIcon, + SearchIcon, +} from '@blocksuite/icons'; +import { useLiveData, useService } from '@toeverything/infra'; +import { assignInlineVars } from '@vanilla-extract/dynamic'; +import clsx from 'clsx'; +import { useDebouncedValue } from 'foxact/use-debounced-value'; +import { useAtomValue } from 'jotai'; +import { useCallback, useDeferredValue, useEffect, useState } from 'react'; + +import { RightSidebarService } from '../../right-sidebar'; +import { FindInPageService } from '../services/find-in-page'; +import * as styles from './find-in-page-modal.css'; +export const FindInPageModal = () => { + const [value, setValue] = useState(''); + const debouncedValue = useDebouncedValue(value, 300); + const deferredValue = useDeferredValue(debouncedValue); + + const findInPage = useService(FindInPageService).findInPage; + const visible = useLiveData(findInPage.visible$); + const result = useLiveData(findInPage.result$); + const isSearching = useLiveData(findInPage.isSearching$); + + const rightSidebarWidth = useAtomValue(rightSidebarWidthAtom); + const rightSidebar = useService(RightSidebarService).rightSidebar; + const frontView = useLiveData(rightSidebar.front$); + const open = useLiveData(rightSidebar.isOpen$) && frontView !== undefined; + + const handleSearch = useCallback(() => { + findInPage.findInPage(deferredValue); + }, [deferredValue, findInPage]); + + const handleBackWard = useCallback(() => { + findInPage.backward(); + }, [findInPage]); + + const handleForward = useCallback(() => { + findInPage.forward(); + }, [findInPage]); + + const onChangeVisible = useCallback( + (visible: boolean) => { + if (!visible) { + findInPage.stopFindInPage('clearSelection'); + } + findInPage.onChangeVisible(visible); + }, + [findInPage] + ); + const handleDone = useCallback(() => { + onChangeVisible(false); + }, [onChangeVisible]); + + useEffect(() => { + // add keyboard event listener for arrow up and down + const keyArrowDown = (event: KeyboardEvent) => { + if (event.key === 'ArrowDown') { + handleForward(); + } + }; + const keyArrowUp = (event: KeyboardEvent) => { + if (event.key === 'ArrowUp') { + handleBackWard(); + } + }; + document.addEventListener('keydown', keyArrowDown); + document.addEventListener('keydown', keyArrowUp); + return () => { + document.removeEventListener('keydown', keyArrowDown); + document.removeEventListener('keydown', keyArrowUp); + }; + }, [findInPage, handleBackWard, handleForward]); + + const panelWidth = assignInlineVars({ + [styles.panelWidthVar]: open ? `${rightSidebarWidth}px` : '0', + }); + + useEffect(() => { + // auto search when value change + if (deferredValue) { + handleSearch(); + } + }, [deferredValue, handleSearch]); + + useEffect(() => { + // clear highlight when value is empty + if (value.length === 0) { + findInPage.stopFindInPage('keepSelection'); + } + }, [value, findInPage]); + + return ( + +
+ } + endFix={ +
+ {value.length > 0 && result && result.matches !== 0 ? ( + <> + {result?.activeMatchOrdinal || 0} + / + {result?.matches || 0} + + ) : ( + No matches + )} +
+ } + style={{ + width: 239, + }} + className={styles.input} + inputStyle={{ + padding: '0', + }} + /> + + + +
+ +
+ ); +}; diff --git a/packages/frontend/core/src/modules/index.ts b/packages/frontend/core/src/modules/index.ts index 0379b7d71f250..c3bce752d6d6b 100644 --- a/packages/frontend/core/src/modules/index.ts +++ b/packages/frontend/core/src/modules/index.ts @@ -3,6 +3,7 @@ import { configureInfraModules, type Framework } from '@toeverything/infra'; import { configureCloudModule } from './cloud'; import { configureCollectionModule } from './collection'; +import { configureFindInPageModule } from './find-in-page'; import { configureNavigationModule } from './navigation'; import { configurePermissionsModule } from './permissions'; import { configureWorkspacePropertiesModule } from './properties'; @@ -26,6 +27,7 @@ export function configureCommonModules(framework: Framework) { configurePermissionsModule(framework); configureShareDocsModule(framework); configureTelemetryModule(framework); + configureFindInPageModule(framework); } export function configureImpls(framework: Framework) { diff --git a/packages/frontend/core/src/modules/right-sidebar/view/container.tsx b/packages/frontend/core/src/modules/right-sidebar/view/container.tsx index a45fafa55ffb4..2900596d5b5df 100644 --- a/packages/frontend/core/src/modules/right-sidebar/view/container.tsx +++ b/packages/frontend/core/src/modules/right-sidebar/view/container.tsx @@ -1,6 +1,7 @@ import { ResizePanel } from '@affine/component/resize-panel'; +import { rightSidebarWidthAtom } from '@affine/core/atoms'; import { appSettingAtom, useLiveData, useService } from '@toeverything/infra'; -import { useAtomValue } from 'jotai'; +import { useAtom, useAtomValue } from 'jotai'; import { useCallback, useEffect, useState } from 'react'; import { RightSidebarService } from '../services/right-sidebar'; @@ -12,7 +13,8 @@ const MAX_SIDEBAR_WIDTH = 800; export const RightSidebarContainer = () => { const { clientBorder } = useAtomValue(appSettingAtom); - const [width, setWidth] = useState(300); + + const [width, setWidth] = useAtom(rightSidebarWidthAtom); const [resizing, setResizing] = useState(false); const rightSidebar = useService(RightSidebarService).rightSidebar; 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 67f2c1db4c29c..21112af429be3 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 @@ -135,7 +135,6 @@ const DetailPageImpl = memo(function DetailPageImpl() { }, [doc, globalContext, isActiveView, mode]); const isInTrash = useLiveData(doc.meta$.map(meta => meta.trash)); - useRegisterBlocksuiteEditorCommands(); const title = useLiveData(doc.title$); usePageDocumentTitle(title); diff --git a/packages/frontend/core/src/providers/modal-provider.tsx b/packages/frontend/core/src/providers/modal-provider.tsx index 2bcd3910d5574..3b5cd62da4790 100644 --- a/packages/frontend/core/src/providers/modal-provider.tsx +++ b/packages/frontend/core/src/providers/modal-provider.tsx @@ -93,6 +93,11 @@ const AiLoginRequiredModal = lazy(() => default: module.AiLoginRequiredModal, })) ); +const FindInPageModal = lazy(() => + import('../modules/find-in-page/view/find-in-page-modal').then(module => ({ + default: module.FindInPageModal, + })) +); export const Setting = () => { const [{ open, workspaceMetadata, activeTab }, setOpenSettingModalAtom] = @@ -213,6 +218,7 @@ export function CurrentWorkspaceModals() { )} + {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/find-in-page/handlers.ts b/packages/frontend/electron/src/main/find-in-page/handlers.ts new file mode 100644 index 0000000000000..9143bd1f94e9c --- /dev/null +++ b/packages/frontend/electron/src/main/find-in-page/handlers.ts @@ -0,0 +1,17 @@ +export const findInPageHandlers = { + findInPage: async ( + event: Electron.IpcMainInvokeEvent, + text: string, + options?: Electron.FindInPageOptions + ) => { + const webContents = event.sender; + return webContents.findInPage(text, options); + }, + stopFindInPage: async ( + event: Electron.IpcMainInvokeEvent, + action: 'clearSelection' | 'keepSelection' | 'activateSelection' + ) => { + const webContents = event.sender; + return webContents.stopFindInPage(action); + }, +}; diff --git a/packages/frontend/electron/src/main/find-in-page/index.ts b/packages/frontend/electron/src/main/find-in-page/index.ts new file mode 100644 index 0000000000000..6c6f862d0182a --- /dev/null +++ b/packages/frontend/electron/src/main/find-in-page/index.ts @@ -0,0 +1 @@ +export * from './handlers'; diff --git a/packages/frontend/electron/src/main/handlers.ts b/packages/frontend/electron/src/main/handlers.ts index c97bdafb94076..e03debbb9c948 100644 --- a/packages/frontend/electron/src/main/handlers.ts +++ b/packages/frontend/electron/src/main/handlers.ts @@ -3,6 +3,7 @@ import { ipcMain } from 'electron'; import { clipboardHandlers } from './clipboard'; import { configStorageHandlers } from './config-storage'; import { exportHandlers } from './export'; +import { findInPageHandlers } from './find-in-page'; import { getLogFilePath, logger, revealLogFile } from './logger'; import { uiHandlers } from './ui/handlers'; import { updaterHandlers } from './updater'; @@ -24,6 +25,7 @@ export const allHandlers = { export: exportHandlers, updater: updaterHandlers, configStorage: configStorageHandlers, + findInPage: findInPageHandlers, }; export const registerHandlers = () => { diff --git a/packages/frontend/electron/src/main/main-window.ts b/packages/frontend/electron/src/main/main-window.ts index d4db3fd8bdcad..c255e6f8f9947 100644 --- a/packages/frontend/electron/src/main/main-window.ts +++ b/packages/frontend/electron/src/main/main-window.ts @@ -169,6 +169,16 @@ 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, + }); + }); + /** * 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..e397529dcf6a0 100644 --- a/packages/frontend/electron/src/preload/electron-api.ts +++ b/packages/frontend/electron/src/preload/electron-api.ts @@ -47,6 +47,20 @@ export const affine = { }, }; +export const cmdFind = { + findInPage: (text: string, options?: Electron.FindInPageOptions) => + ipcRenderer.invoke('findInPage:findInPage', text, options), + stopFindInPage: ( + action: 'clearSelection' | 'keepSelection' | 'activateSelection' + ) => ipcRenderer.invoke('findInPage:stopFindInPage', action), + onFindInPageResult: (callBack: (data: any) => void) => + ipcRenderer.on('found-in-page-result', (_event, data) => callBack(data)), + offFindInPageResult: (callBack: (data: any) => void) => + ipcRenderer.removeListener('found-in-page-result', (_event, data) => + callBack(data) + ), +}; + export function getElectronAPIs() { const mainAPIs = getMainAPIs(); const helperAPIs = getHelperAPIs();