mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 12:55:00 +00:00
feat(core): cmd+f search in doc function (#7040)
You can use the cmd+F shortcut key to trigger the FindInPage function.
This commit is contained in:
@@ -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<string | null>(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<FindInPageResult>(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));
|
||||
}
|
||||
}
|
||||
8
packages/frontend/core/src/modules/find-in-page/index.ts
Normal file
8
packages/frontend/core/src/modules/find-in-page/index.ts
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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 (
|
||||
<Modal
|
||||
open={visible}
|
||||
onOpenChange={onChangeVisible}
|
||||
overlayOptions={{
|
||||
hidden: true,
|
||||
}}
|
||||
withoutCloseButton
|
||||
width={398}
|
||||
height={48}
|
||||
minHeight={48}
|
||||
contentOptions={{
|
||||
className: styles.container,
|
||||
style: panelWidth,
|
||||
}}
|
||||
>
|
||||
<div className={styles.leftContent}>
|
||||
<Input
|
||||
onChange={setValue}
|
||||
value={isSearching ? '' : value}
|
||||
onEnter={handleSearch}
|
||||
autoFocus
|
||||
preFix={<SearchIcon fontSize={20} />}
|
||||
endFix={
|
||||
<div className={styles.count}>
|
||||
{value.length > 0 && result && result.matches !== 0 ? (
|
||||
<>
|
||||
<span>{result?.activeMatchOrdinal || 0}</span>
|
||||
<span>/</span>
|
||||
<span>{result?.matches || 0}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>No matches</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
style={{
|
||||
width: 239,
|
||||
}}
|
||||
className={styles.input}
|
||||
inputStyle={{
|
||||
padding: '0',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className={clsx(styles.arrowButton, 'backward')}
|
||||
onClick={handleBackWard}
|
||||
>
|
||||
<ArrowUpSmallIcon />
|
||||
</Button>
|
||||
<Button
|
||||
className={clsx(styles.arrowButton, 'forward')}
|
||||
onClick={handleForward}
|
||||
>
|
||||
<ArrowDownSmallIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<Button type="primary" onClick={handleDone}>
|
||||
Done
|
||||
</Button>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user