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:
JimmFly
2024-05-28 06:19:48 +00:00
parent 5759c15de3
commit bd9c929d05
19 changed files with 440 additions and 4 deletions

View File

@@ -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));
}
}

View 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);
}

View File

@@ -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);
}

View File

@@ -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',
},
},
});

View File

@@ -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>
);
};

View File

@@ -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) {

View File

@@ -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;