From 2ca77d9170e9cb96ccd0ec74b60c17f069c9394c Mon Sep 17 00:00:00 2001 From: pengx17 Date: Tue, 28 May 2024 06:19:53 +0000 Subject: [PATCH] refactor: find in page (#7086) - refactor rxjs data flow - use canvas text to mitigate searchable search box input text issue --- .../frontend/component/src/ui/input/input.tsx | 22 +- .../frontend/component/src/ui/modal/modal.tsx | 5 +- .../component/src/ui/modal/styles.css.ts | 11 +- .../use-register-find-in-page-commands.ts | 6 +- .../find-in-page/entities/find-in-page.ts | 125 ++++++----- .../view/find-in-page-modal.css.ts | 33 ++- .../find-in-page/view/find-in-page-modal.tsx | 207 +++++++++++------- packages/frontend/electron-api/src/index.ts | 4 - .../src/main/find-in-page/handlers.ts | 26 ++- .../frontend/electron/src/main/main-window.ts | 12 +- .../electron/src/preload/bootstrap.ts | 3 +- .../electron/src/preload/electron-api.ts | 14 -- 12 files changed, 276 insertions(+), 192 deletions(-) diff --git a/packages/frontend/component/src/ui/input/input.tsx b/packages/frontend/component/src/ui/input/input.tsx index bb8ec5b0ecc0d..b55c3138d8f81 100644 --- a/packages/frontend/component/src/ui/input/input.tsx +++ b/packages/frontend/component/src/ui/input/input.tsx @@ -45,13 +45,21 @@ export const Input = forwardRef(function Input( autoFocus, ...otherProps }: InputProps, - ref: ForwardedRef + upstreamRef: ForwardedRef ) { - const handleAutoFocus = useCallback((ref: HTMLInputElement | null) => { - if (ref) { - window.setTimeout(() => ref.focus(), 0); - } - }, []); + const handleAutoFocus = useCallback( + (ref: HTMLInputElement | null) => { + if (ref) { + window.setTimeout(() => ref.focus(), 0); + if (typeof upstreamRef === 'function') { + upstreamRef(ref); + } else if (upstreamRef) { + upstreamRef.current = ref; + } + } + }, + [upstreamRef] + ); return (
(function Input( large: size === 'large', 'extra-large': size === 'extraLarge', })} - ref={autoFocus ? handleAutoFocus : ref} + ref={autoFocus ? handleAutoFocus : upstreamRef} disabled={disabled} style={inputStyle} onChange={useCallback( diff --git a/packages/frontend/component/src/ui/modal/modal.tsx b/packages/frontend/component/src/ui/modal/modal.tsx index ba258f134a730..c2d9856141bb9 100644 --- a/packages/frontend/component/src/ui/modal/modal.tsx +++ b/packages/frontend/component/src/ui/modal/modal.tsx @@ -46,6 +46,7 @@ export const Modal = forwardRef( title, description, withoutCloseButton = false, + modal, portalOptions, contentOptions: { @@ -63,13 +64,13 @@ export const Modal = forwardRef( }, ref ) => ( - + -
+
{ - findInPage.toggleVisible(); + // get the selected text in page + const selection = window.getSelection(); + const selectedText = selection?.toString(); + + findInPage.toggleVisible(selectedText); }, [findInPage]); useEffect(() => { 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 index 86619fab6805c..5ef1a32216285 100644 --- 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 @@ -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 { Observable, of, switchMap } from 'rxjs'; +import { + debounceTime, + distinctUntilChanged, + of, + shareReplay, + switchMap, + tap, +} from 'rxjs'; -type FindInPageResult = { - requestId: number; - activeMatchOrdinal: number; - matches: number; - finalUpdate: boolean; -}; -export class FindInPage extends Entity { - // modal open/close +const logger = new DebugLogger('affine:find-in-page'); +export class FindInPage extends Entity { readonly searchText$ = new LiveData(null); - private readonly direction$ = new LiveData<'forward' | 'backward'>('forward'); readonly isSearching$ = new LiveData(false); - + private readonly direction$ = new LiveData<'forward' | 'backward'>('forward'); readonly visible$ = new LiveData(false); readonly result$ = LiveData.from( - this.searchText$.pipe( - switchMap(searchText => { - if (!searchText) { + this.visible$.pipe( + distinctUntilChanged(), + switchMap(visible => { + if (!visible) { 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); - }; - }); } + 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() { super(); + // todo: hide on navigation } findInPage(searchText: string) { + this.onChangeVisible(true); this.searchText$.next(searchText); } - private updateResult(result: FindInPageResult) { - this.result$.next(result); - } - onChangeVisible(visible: boolean) { this.visible$.next(visible); if (!visible) { - this.stopFindInPage('clearSelection'); + this.clear(); } } - toggleVisible() { + toggleVisible(text?: string) { const nextVisible = !this.visible$.value; this.visible$.next(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; } this.direction$.next('backward'); - this.searchText$.next(this.searchText$.value); - cmdFind?.onFindInPageResult(result => this.updateResult(result)); } forward() { @@ -93,16 +108,10 @@ export class FindInPage extends Entity { 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)); + clear() { + logger.debug('clear'); + apis?.findInPage.clear().catch(logger.error); } } 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 index 63c4e82a2ee74..5d8da4f34f377 100644 --- 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 @@ -20,16 +20,40 @@ export const container = style({ export const leftContent = style({ display: 'flex', alignItems: 'center', + flex: 1, }); -export const input = style({ - padding: '0 10px', - height: '32px', +export const inputContainer = style({ + display: 'flex', + alignSelf: 'stretch', + alignItems: 'center', gap: '8px', - color: cssVar('iconColor'), + flex: 1, + height: '32px', + position: 'relative', + margin: '0 8px', +}); + +export const input = style({ + position: 'absolute', + padding: '0', + inset: 0, + height: '100%', + width: '100%', + color: 'transparent', background: cssVar('white10'), }); +export const inputHack = style([ + input, + { + '::placeholder': { + color: cssVar('iconColor'), + }, + pointerEvents: 'none', + }, +]); + export const count = style({ color: cssVar('textSecondaryColor'), fontSize: cssVar('fontXs'), @@ -41,6 +65,7 @@ export const arrowButton = style({ fontSize: '24px', width: '32px', height: '32px', + flexShrink: 0, border: '1px solid', borderColor: cssVar('borderColor'), alignItems: 'baseline', 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 index f3b123b06b693..0b544b02926da 100644 --- 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 @@ -1,24 +1,68 @@ -import { Button, Input, Modal } from '@affine/component'; +import { Button, Modal } from '@affine/component'; import { rightSidebarWidthAtom } from '@affine/core/atoms'; -import { - ArrowDownSmallIcon, - ArrowUpSmallIcon, - SearchIcon, -} from '@blocksuite/icons'; +import { ArrowDownSmallIcon, ArrowUpSmallIcon } 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 { + type KeyboardEventHandler, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; import { RightSidebarService } from '../../right-sidebar'; import { FindInPageService } from '../services/find-in-page'; 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(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 ; +}; + 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$); @@ -29,10 +73,48 @@ export const FindInPageModal = () => { const rightSidebar = useService(RightSidebarService).rightSidebar; const frontView = useLiveData(rightSidebar.front$); const open = useLiveData(rightSidebar.isOpen$) && frontView !== undefined; + const inputRef = useRef(null); + + const handleValueChange = useCallback( + (v: string) => { + setValue(v); + findInPage.findInPage(v); + if (v.length === 0) { + findInPage.clear(); + } + inputRef.current?.focus(); + }, + [findInPage] + ); - const handleSearch = useCallback(() => { - findInPage.findInPage(deferredValue); - }, [deferredValue, 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(() => { findInPage.backward(); @@ -45,7 +127,7 @@ export const FindInPageModal = () => { const onChangeVisible = useCallback( (visible: boolean) => { if (!visible) { - findInPage.stopFindInPage('clearSelection'); + findInPage.clear(); } findInPage.onChangeVisible(visible); }, @@ -55,53 +137,27 @@ export const FindInPageModal = () => { onChangeVisible(false); }, [onChangeVisible]); - useEffect(() => { - // add keyboard event listener for arrow up and down - const keyArrowDown = (event: KeyboardEvent) => { - if (event.key === 'ArrowDown') { + const handleKeydown: KeyboardEventHandler = useCallback( + e => { + if (e.key === 'Enter' || e.key === 'ArrowDown') { handleForward(); } - }; - const keyArrowUp = (event: KeyboardEvent) => { - if (event.key === 'ArrowUp') { + if (e.key === 'ArrowUp') { handleBackWard(); } - }; - document.addEventListener('keydown', keyArrowDown); - document.addEventListener('keydown', keyArrowUp); - return () => { - document.removeEventListener('keydown', keyArrowDown); - document.removeEventListener('keydown', keyArrowUp); - }; - }, [findInPage, handleBackWard, handleForward]); - + }, + [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', - }} - /> +
+ handleValueChange(e.target.value)} + /> + +
+
+ {value.length > 0 && result && result.matches !== 0 ? ( + <> + {result?.activeMatchOrdinal || 0} + / + {result?.matches || 0} + + ) : value.length ? ( + No matches + ) : null} +