refactor: find in page (#7086)

- refactor rxjs data flow
- use canvas text to mitigate searchable search box input text issue
This commit is contained in:
pengx17
2024-05-28 06:19:53 +00:00
parent bd9c929d05
commit 2ca77d9170
12 changed files with 276 additions and 192 deletions
@@ -45,13 +45,21 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
autoFocus, autoFocus,
...otherProps ...otherProps
}: InputProps, }: InputProps,
ref: ForwardedRef<HTMLInputElement> upstreamRef: ForwardedRef<HTMLInputElement>
) { ) {
const handleAutoFocus = useCallback((ref: HTMLInputElement | null) => { const handleAutoFocus = useCallback(
if (ref) { (ref: HTMLInputElement | null) => {
window.setTimeout(() => ref.focus(), 0); if (ref) {
} window.setTimeout(() => ref.focus(), 0);
}, []); if (typeof upstreamRef === 'function') {
upstreamRef(ref);
} else if (upstreamRef) {
upstreamRef.current = ref;
}
}
},
[upstreamRef]
);
return ( return (
<div <div
@@ -78,7 +86,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
large: size === 'large', large: size === 'large',
'extra-large': size === 'extraLarge', 'extra-large': size === 'extraLarge',
})} })}
ref={autoFocus ? handleAutoFocus : ref} ref={autoFocus ? handleAutoFocus : upstreamRef}
disabled={disabled} disabled={disabled}
style={inputStyle} style={inputStyle}
onChange={useCallback( onChange={useCallback(
@@ -46,6 +46,7 @@ export const Modal = forwardRef<HTMLDivElement, ModalProps>(
title, title,
description, description,
withoutCloseButton = false, withoutCloseButton = false,
modal,
portalOptions, portalOptions,
contentOptions: { contentOptions: {
@@ -63,13 +64,13 @@ export const Modal = forwardRef<HTMLDivElement, ModalProps>(
}, },
ref ref
) => ( ) => (
<Dialog.Root {...props}> <Dialog.Root modal={modal} {...props}>
<Dialog.Portal {...portalOptions}> <Dialog.Portal {...portalOptions}>
<Dialog.Overlay <Dialog.Overlay
className={clsx(styles.modalOverlay, overlayClassName)} className={clsx(styles.modalOverlay, overlayClassName)}
{...otherOverlayOptions} {...otherOverlayOptions}
/> />
<div className={styles.modalContentWrapper}> <div data-modal={modal} className={clsx(styles.modalContentWrapper)}>
<Dialog.Content <Dialog.Content
className={clsx(styles.modalContent, contentClassName)} className={clsx(styles.modalContent, contentClassName)}
style={{ style={{
@@ -1,5 +1,5 @@
import { cssVar } from '@toeverything/theme'; import { cssVar } from '@toeverything/theme';
import { createVar, style } from '@vanilla-extract/css'; import { createVar, globalStyle, style } from '@vanilla-extract/css';
export const widthVar = createVar('widthVar'); export const widthVar = createVar('widthVar');
export const heightVar = createVar('heightVar'); export const heightVar = createVar('heightVar');
export const minHeightVar = createVar('minHeightVar'); export const minHeightVar = createVar('minHeightVar');
@@ -17,6 +17,7 @@ export const modalContentWrapper = style({
justifyContent: 'center', justifyContent: 'center',
zIndex: cssVar('zIndexModal'), zIndex: cssVar('zIndexModal'),
}); });
export const modalContent = style({ export const modalContent = style({
vars: { vars: {
[widthVar]: '', [widthVar]: '',
@@ -82,3 +83,11 @@ export const confirmModalContainer = style({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
}); });
globalStyle(`[data-modal="false"]${modalContentWrapper}`, {
pointerEvents: 'none',
});
globalStyle(`[data-modal="false"] ${modalContent}`, {
pointerEvents: 'auto',
});
@@ -5,7 +5,11 @@ import { useCallback, useEffect } from 'react';
export function useRegisterFindInPageCommands() { export function useRegisterFindInPageCommands() {
const findInPage = useService(FindInPageService).findInPage; const findInPage = useService(FindInPageService).findInPage;
const toggleVisible = useCallback(() => { const toggleVisible = useCallback(() => {
findInPage.toggleVisible(); // get the selected text in page
const selection = window.getSelection();
const selectedText = selection?.toString();
findInPage.toggleVisible(selectedText);
}, [findInPage]); }, [findInPage]);
useEffect(() => { useEffect(() => {
@@ -1,81 +1,98 @@
import { cmdFind } from '@affine/electron-api'; import { DebugLogger } from '@affine/debug';
import { apis } from '@affine/electron-api';
import { Entity, LiveData } from '@toeverything/infra'; import { Entity, LiveData } from '@toeverything/infra';
import { Observable, of, switchMap } from 'rxjs'; import {
debounceTime,
distinctUntilChanged,
of,
shareReplay,
switchMap,
tap,
} from 'rxjs';
const logger = new DebugLogger('affine:find-in-page');
type FindInPageResult = {
requestId: number;
activeMatchOrdinal: number;
matches: number;
finalUpdate: boolean;
};
export class FindInPage extends Entity { export class FindInPage extends Entity {
// modal open/close
readonly searchText$ = new LiveData<string | null>(null); readonly searchText$ = new LiveData<string | null>(null);
private readonly direction$ = new LiveData<'forward' | 'backward'>('forward');
readonly isSearching$ = new LiveData(false); readonly isSearching$ = new LiveData(false);
private readonly direction$ = new LiveData<'forward' | 'backward'>('forward');
readonly visible$ = new LiveData(false); readonly visible$ = new LiveData(false);
readonly result$ = LiveData.from( readonly result$ = LiveData.from(
this.searchText$.pipe( this.visible$.pipe(
switchMap(searchText => { distinctUntilChanged(),
if (!searchText) { switchMap(visible => {
if (!visible) {
return of(null); 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);
};
});
} }
let searchId = 0;
return this.searchText$.pipe(
tap(() => {
this.isSearching$.next(false);
}),
debounceTime(500),
switchMap(searchText => {
if (!searchText) {
return of(null);
} else {
let findNext = true;
return this.direction$.pipe(
switchMap(direction => {
if (apis?.findInPage) {
this.isSearching$.next(true);
const currentId = ++searchId;
return apis?.findInPage
.find(searchText, {
forward: direction === 'forward',
findNext,
})
.finally(() => {
if (currentId === searchId) {
this.isSearching$.next(false);
findNext = false;
}
});
} else {
return of(null);
}
})
);
}
})
);
}),
shareReplay({
bufferSize: 1,
refCount: true,
}) })
), ),
{ requestId: 0, activeMatchOrdinal: 0, matches: 0, finalUpdate: true } null
); );
constructor() { constructor() {
super(); super();
// todo: hide on navigation
} }
findInPage(searchText: string) { findInPage(searchText: string) {
this.onChangeVisible(true);
this.searchText$.next(searchText); this.searchText$.next(searchText);
} }
private updateResult(result: FindInPageResult) {
this.result$.next(result);
}
onChangeVisible(visible: boolean) { onChangeVisible(visible: boolean) {
this.visible$.next(visible); this.visible$.next(visible);
if (!visible) { if (!visible) {
this.stopFindInPage('clearSelection'); this.clear();
} }
} }
toggleVisible() { toggleVisible(text?: string) {
const nextVisible = !this.visible$.value; const nextVisible = !this.visible$.value;
this.visible$.next(nextVisible); this.visible$.next(nextVisible);
if (!nextVisible) { if (!nextVisible) {
this.stopFindInPage('clearSelection'); this.clear();
} else if (text) {
this.searchText$.next(text);
} }
} }
@@ -84,8 +101,6 @@ export class FindInPage extends Entity {
return; return;
} }
this.direction$.next('backward'); this.direction$.next('backward');
this.searchText$.next(this.searchText$.value);
cmdFind?.onFindInPageResult(result => this.updateResult(result));
} }
forward() { forward() {
@@ -93,16 +108,10 @@ export class FindInPage extends Entity {
return; return;
} }
this.direction$.next('forward'); this.direction$.next('forward');
this.searchText$.next(this.searchText$.value);
cmdFind?.onFindInPageResult(result => this.updateResult(result));
} }
stopFindInPage( clear() {
action: 'clearSelection' | 'keepSelection' | 'activateSelection' logger.debug('clear');
) { apis?.findInPage.clear().catch(logger.error);
if (action === 'clearSelection') {
this.searchText$.next(null);
}
cmdFind?.stopFindInPage(action).catch(e => console.error(e));
} }
} }
@@ -20,16 +20,40 @@ export const container = style({
export const leftContent = style({ export const leftContent = style({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
flex: 1,
});
export const inputContainer = style({
display: 'flex',
alignSelf: 'stretch',
alignItems: 'center',
gap: '8px',
flex: 1,
height: '32px',
position: 'relative',
margin: '0 8px',
}); });
export const input = style({ export const input = style({
padding: '0 10px', position: 'absolute',
height: '32px', padding: '0',
gap: '8px', inset: 0,
color: cssVar('iconColor'), height: '100%',
width: '100%',
color: 'transparent',
background: cssVar('white10'), background: cssVar('white10'),
}); });
export const inputHack = style([
input,
{
'::placeholder': {
color: cssVar('iconColor'),
},
pointerEvents: 'none',
},
]);
export const count = style({ export const count = style({
color: cssVar('textSecondaryColor'), color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontXs'), fontSize: cssVar('fontXs'),
@@ -41,6 +65,7 @@ export const arrowButton = style({
fontSize: '24px', fontSize: '24px',
width: '32px', width: '32px',
height: '32px', height: '32px',
flexShrink: 0,
border: '1px solid', border: '1px solid',
borderColor: cssVar('borderColor'), borderColor: cssVar('borderColor'),
alignItems: 'baseline', alignItems: 'baseline',
@@ -1,24 +1,68 @@
import { Button, Input, Modal } from '@affine/component'; import { Button, Modal } from '@affine/component';
import { rightSidebarWidthAtom } from '@affine/core/atoms'; import { rightSidebarWidthAtom } from '@affine/core/atoms';
import { import { ArrowDownSmallIcon, ArrowUpSmallIcon } from '@blocksuite/icons';
ArrowDownSmallIcon,
ArrowUpSmallIcon,
SearchIcon,
} from '@blocksuite/icons';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import { assignInlineVars } from '@vanilla-extract/dynamic'; import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx'; import clsx from 'clsx';
import { useDebouncedValue } from 'foxact/use-debounced-value';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { useCallback, useDeferredValue, useEffect, useState } from 'react'; import {
type KeyboardEventHandler,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { RightSidebarService } from '../../right-sidebar'; import { RightSidebarService } from '../../right-sidebar';
import { FindInPageService } from '../services/find-in-page'; import { FindInPageService } from '../services/find-in-page';
import * as styles from './find-in-page-modal.css'; import * as styles from './find-in-page-modal.css';
const drawText = (canvas: HTMLCanvasElement, text: string) => {
const ctx = canvas.getContext('2d');
if (!ctx) {
return;
}
const dpr = window.devicePixelRatio || 1;
canvas.width = canvas.getBoundingClientRect().width * dpr;
canvas.height = canvas.getBoundingClientRect().height * dpr;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.font = '15px Inter';
ctx.fillText(text, 0, 22);
ctx.textAlign = 'left';
ctx.textBaseline = 'ideographic';
};
const CanvasText = ({
text,
className,
}: {
text: string;
className: string;
}) => {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = ref.current;
if (!canvas) {
return;
}
drawText(canvas, text);
const resizeObserver = new ResizeObserver(() => {
drawText(canvas, text);
});
resizeObserver.observe(canvas);
return () => {
resizeObserver.disconnect();
};
}, [text]);
return <canvas className={className} ref={ref} />;
};
export const FindInPageModal = () => { export const FindInPageModal = () => {
const [value, setValue] = useState(''); const [value, setValue] = useState('');
const debouncedValue = useDebouncedValue(value, 300);
const deferredValue = useDeferredValue(debouncedValue);
const findInPage = useService(FindInPageService).findInPage; const findInPage = useService(FindInPageService).findInPage;
const visible = useLiveData(findInPage.visible$); const visible = useLiveData(findInPage.visible$);
@@ -29,10 +73,48 @@ export const FindInPageModal = () => {
const rightSidebar = useService(RightSidebarService).rightSidebar; const rightSidebar = useService(RightSidebarService).rightSidebar;
const frontView = useLiveData(rightSidebar.front$); const frontView = useLiveData(rightSidebar.front$);
const open = useLiveData(rightSidebar.isOpen$) && frontView !== undefined; const open = useLiveData(rightSidebar.isOpen$) && frontView !== undefined;
const inputRef = useRef<HTMLInputElement>(null);
const handleSearch = useCallback(() => { const handleValueChange = useCallback(
findInPage.findInPage(deferredValue); (v: string) => {
}, [deferredValue, findInPage]); setValue(v);
findInPage.findInPage(v);
if (v.length === 0) {
findInPage.clear();
}
inputRef.current?.focus();
},
[findInPage]
);
useEffect(() => {
if (visible) {
setValue(findInPage.searchText$.value || '');
const onEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
findInPage.onChangeVisible(false);
}
};
window.addEventListener('keydown', onEsc);
return () => {
window.removeEventListener('keydown', onEsc);
};
}
return () => {};
}, [findInPage, findInPage.searchText$.value, visible]);
useEffect(() => {
const unsub = findInPage.isSearching$.subscribe(() => {
inputRef.current?.focus();
setTimeout(() => {
inputRef.current?.focus();
});
});
return () => {
unsub.unsubscribe();
};
}, [findInPage.isSearching$]);
const handleBackWard = useCallback(() => { const handleBackWard = useCallback(() => {
findInPage.backward(); findInPage.backward();
@@ -45,7 +127,7 @@ export const FindInPageModal = () => {
const onChangeVisible = useCallback( const onChangeVisible = useCallback(
(visible: boolean) => { (visible: boolean) => {
if (!visible) { if (!visible) {
findInPage.stopFindInPage('clearSelection'); findInPage.clear();
} }
findInPage.onChangeVisible(visible); findInPage.onChangeVisible(visible);
}, },
@@ -55,53 +137,27 @@ export const FindInPageModal = () => {
onChangeVisible(false); onChangeVisible(false);
}, [onChangeVisible]); }, [onChangeVisible]);
useEffect(() => { const handleKeydown: KeyboardEventHandler = useCallback(
// add keyboard event listener for arrow up and down e => {
const keyArrowDown = (event: KeyboardEvent) => { if (e.key === 'Enter' || e.key === 'ArrowDown') {
if (event.key === 'ArrowDown') {
handleForward(); handleForward();
} }
}; if (e.key === 'ArrowUp') {
const keyArrowUp = (event: KeyboardEvent) => {
if (event.key === 'ArrowUp') {
handleBackWard(); handleBackWard();
} }
}; },
document.addEventListener('keydown', keyArrowDown); [handleBackWard, handleForward]
document.addEventListener('keydown', keyArrowUp); );
return () => {
document.removeEventListener('keydown', keyArrowDown);
document.removeEventListener('keydown', keyArrowUp);
};
}, [findInPage, handleBackWard, handleForward]);
const panelWidth = assignInlineVars({ const panelWidth = assignInlineVars({
[styles.panelWidthVar]: open ? `${rightSidebarWidth}px` : '0', [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 ( return (
<Modal <Modal
open={visible} open={visible}
onOpenChange={onChangeVisible} modal={false}
overlayOptions={{
hidden: true,
}}
withoutCloseButton withoutCloseButton
width={398} width={400}
height={48} height={48}
minHeight={48} minHeight={48}
contentOptions={{ contentOptions={{
@@ -110,33 +166,32 @@ export const FindInPageModal = () => {
}} }}
> >
<div className={styles.leftContent}> <div className={styles.leftContent}>
<Input <div className={styles.inputContainer}>
onChange={setValue} <input
value={isSearching ? '' : value} type="text"
onEnter={handleSearch} autoFocus
autoFocus value={value}
preFix={<SearchIcon fontSize={20} />} ref={inputRef}
endFix={ style={{
<div className={styles.count}> visibility: isSearching ? 'hidden' : 'visible',
{value.length > 0 && result && result.matches !== 0 ? ( }}
<> className={styles.input}
<span>{result?.activeMatchOrdinal || 0}</span> onKeyDown={handleKeydown}
<span>/</span> onChange={e => handleValueChange(e.target.value)}
<span>{result?.matches || 0}</span> />
</> <CanvasText className={styles.inputHack} text={value} />
) : ( </div>
<span>No matches</span> <div className={styles.count}>
)} {value.length > 0 && result && result.matches !== 0 ? (
</div> <>
} <span>{result?.activeMatchOrdinal || 0}</span>
style={{ <span>/</span>
width: 239, <span>{result?.matches || 0}</span>
}} </>
className={styles.input} ) : value.length ? (
inputStyle={{ <span>No matches</span>
padding: '0', ) : null}
}} </div>
/>
<Button <Button
className={clsx(styles.arrowButton, 'backward')} className={clsx(styles.arrowButton, 'backward')}
@@ -9,7 +9,6 @@ import type {
import type { import type {
affine as exposedAffineGlobal, affine as exposedAffineGlobal,
appInfo as exposedAppInfo, appInfo as exposedAppInfo,
cmdFind as exposedCmdFind,
} from '@affine/electron/preload/electron-api'; } from '@affine/electron/preload/electron-api';
type MainHandlers = typeof mainHandlers; type MainHandlers = typeof mainHandlers;
@@ -40,8 +39,5 @@ export const events = (globalThis as any).events as ClientEvents | null;
export const affine = (globalThis as any).affine as export const affine = (globalThis as any).affine as
| typeof exposedAffineGlobal | typeof exposedAffineGlobal
| null; | null;
export const cmdFind = (globalThis as any).cmdFind as
| typeof exposedCmdFind
| null;
export type { UpdateMeta } from '@affine/electron/main/updater/event'; export type { UpdateMeta } from '@affine/electron/main/updater/event';
@@ -1,17 +1,19 @@
import type { NamespaceHandlers } from '../type';
export const findInPageHandlers = { export const findInPageHandlers = {
findInPage: async ( find: async (event, text: string, options?: Electron.FindInPageOptions) => {
event: Electron.IpcMainInvokeEvent, const { promise, resolve } =
text: string, Promise.withResolvers<Electron.Result | null>();
options?: Electron.FindInPageOptions
) => {
const webContents = event.sender; const webContents = event.sender;
return webContents.findInPage(text, options); let requestId: number = -1;
webContents.once('found-in-page', (_, result) => {
resolve(result.requestId === requestId ? result : null);
});
requestId = webContents.findInPage(text, options);
return promise;
}, },
stopFindInPage: async ( clear: async event => {
event: Electron.IpcMainInvokeEvent,
action: 'clearSelection' | 'keepSelection' | 'activateSelection'
) => {
const webContents = event.sender; const webContents = event.sender;
return webContents.stopFindInPage(action); webContents.stopFindInPage('keepSelection');
}, },
}; } satisfies NamespaceHandlers;
@@ -1,7 +1,7 @@
import assert from 'node:assert'; import assert from 'node:assert';
import { join } from 'node:path'; import { join } from 'node:path';
import type { CookiesSetDetails } from 'electron'; import { type CookiesSetDetails } from 'electron';
import { BrowserWindow, nativeTheme } from 'electron'; import { BrowserWindow, nativeTheme } from 'electron';
import electronWindowState from 'electron-window-state'; import electronWindowState from 'electron-window-state';
@@ -169,16 +169,6 @@ async function createWindow(additionalArguments: string[]) {
uiSubjects.onFullScreen$.next(false); 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. * URL for main window.
*/ */
@@ -1,6 +1,6 @@
import { contextBridge } from 'electron'; import { contextBridge } from 'electron';
import { affine, appInfo, cmdFind, getElectronAPIs } from './electron-api'; import { affine, appInfo, getElectronAPIs } from './electron-api';
const { apis, events } = getElectronAPIs(); const { apis, events } = getElectronAPIs();
@@ -10,7 +10,6 @@ contextBridge.exposeInMainWorld('events', events);
try { try {
contextBridge.exposeInMainWorld('affine', affine); contextBridge.exposeInMainWorld('affine', affine);
contextBridge.exposeInMainWorld('cmdFind', cmdFind);
} catch (error) { } catch (error) {
console.error('Failed to expose affine APIs to window object!', error); console.error('Failed to expose affine APIs to window object!', error);
} }
@@ -47,20 +47,6 @@ 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() { export function getElectronAPIs() {
const mainAPIs = getMainAPIs(); const mainAPIs = getMainAPIs();
const helperAPIs = getHelperAPIs(); const helperAPIs = getHelperAPIs();