/* eslint-disable unicorn/consistent-destructuring */ import * as React from "react"; import { type CellArray, type GetCellsThunk, GridCellKind, type Item, type Rectangle, } from "../data-grid/data-grid-types.js"; import ScrollingDataGrid, { type ScrollingDataGridProps } from "../scrolling-data-grid/scrolling-data-grid.js"; import { SearchWrapper } from "./data-grid-search-style.js"; import { assert } from "../../common/support.js"; // icons const upArrow = ( ); const downArrow = ( ); const closeX = ( ); export interface DataGridSearchProps extends Omit { readonly getCellsForSelection?: (selection: Rectangle, abortSignal: AbortSignal) => GetCellsThunk | CellArray; /** * The search results to display. If not provided glide will use its own internal search provider. */ readonly searchResults?: readonly Item[]; /** * Emitted whenever the search results for the current search field changes. * @param results The new search results * @param navIndex The currents selected search result */ readonly onSearchResultsChanged?: (results: readonly Item[], navIndex: number) => void; /** * Controls the visibility of the search overlay. * @group Search */ readonly showSearch?: boolean; /** * Emitted when the search window close event is triggered. * @group Search */ readonly onSearchClose?: () => void; /** * The current search value. * @group Search */ readonly searchValue?: string; /** * Emitted when the search value changes. * @group Search * @param newVal The new search value */ readonly onSearchValueChange?: (newVal: string) => void; readonly searchInputRef: React.MutableRefObject; } const targetSearchTimeMS = 10; const DataGridSearch: React.FunctionComponent = p => { const { canvasRef, cellYOffset, rows, columns, searchInputRef, searchValue, searchResults: searchResultsIn, onSearchValueChange, getCellsForSelection, onSearchResultsChanged, showSearch = false, onSearchClose, } = p; const [searchID] = React.useState(() => "search-box-" + Math.round(Math.random() * 1000)); const [searchStringInner, setSearchStringInner] = React.useState(""); const searchString = searchValue ?? searchStringInner; // always emit both, this allows the user to spy on the search value without controlling it. const setSearchString = React.useCallback( (newVal: string) => { setSearchStringInner(newVal); onSearchValueChange?.(newVal); }, [onSearchValueChange] ); const [searchStatus, setSearchStatus] = React.useState<{ rowsSearched: number; results: number; selectedIndex: number; }>(); const searchStatusRef = React.useRef(searchStatus); searchStatusRef.current = searchStatus; React.useEffect(() => { if (searchResultsIn === undefined) return; if (searchResultsIn.length > 0) { setSearchStatus(cv => ({ rowsSearched: rows, results: searchResultsIn.length, selectedIndex: cv?.selectedIndex ?? -1, })); } else { setSearchStatus(undefined); } }, [rows, searchResultsIn]); const abortControllerRef = React.useRef() as React.MutableRefObject; if (abortControllerRef.current === undefined) abortControllerRef.current = new AbortController(); const searchHandle = React.useRef(); const [searchResultsInner, setSearchResultsInner] = React.useState([]); const searchResults = searchResultsIn ?? searchResultsInner; const cancelSearch = React.useCallback(() => { if (searchHandle.current !== undefined) { window.cancelAnimationFrame(searchHandle.current); searchHandle.current = undefined; abortControllerRef.current.abort(); } }, []); const cellYOffsetRef = React.useRef(cellYOffset); cellYOffsetRef.current = cellYOffset; const beginSearch = React.useCallback( (str: string) => { const regex = new RegExp(str.replace(/([$()*+.?[\\\]^{|}-])/g, "\\$1"), "i"); let startY = cellYOffsetRef.current; // Lets assume we can do 10 rows at a time // This is usually very safe and limits the damage for bad // performing sheets. let searchStride = Math.min(10, rows); let rowsSearched = 0; setSearchStatus(undefined); setSearchResultsInner([]); const runningResult: [number, number][] = []; const tick = async () => { if (getCellsForSelection === undefined) return; const tStart = performance.now(); const rowsLeft = rows - rowsSearched; let data = getCellsForSelection( { x: 0, y: startY, width: columns.length, height: Math.min(searchStride, rowsLeft, rows - startY), }, abortControllerRef.current.signal ); if (typeof data === "function") { data = await data(); } let added = false; for (const [row, d] of data.entries()) { for (const [col, cell] of d.entries()) { let testString: string | undefined; switch (cell.kind) { case GridCellKind.Text: case GridCellKind.Number: testString = cell.displayData; break; case GridCellKind.Uri: case GridCellKind.Markdown: testString = cell.data; break; case GridCellKind.Boolean: testString = typeof cell.data === "boolean" ? cell.data.toString() : undefined; break; case GridCellKind.Image: case GridCellKind.Bubble: // I know its lazy, but unless someone is actually // searching for the whale emoji, this is pretty side effect // free. And ya know... it's nice and easy to do... testString = cell.data.join("🐳"); break; case GridCellKind.Custom: testString = cell.copyData; break; } if (testString !== undefined && regex.test(testString)) { runningResult.push([col, row + startY]); added = true; } } } const tEnd = performance.now(); if (added) { setSearchResultsInner([...runningResult]); } rowsSearched += data.length; assert(rowsSearched <= rows); const selectedIndex = searchStatusRef.current?.selectedIndex ?? -1; setSearchStatus({ results: runningResult.length, rowsSearched, selectedIndex, }); onSearchResultsChanged?.(runningResult, selectedIndex); if (startY + searchStride >= rows) { startY = 0; } else { startY += searchStride; } const tElapsed = tEnd - tStart; const rounded = Math.max(tElapsed, 1); const scalar = targetSearchTimeMS / rounded; searchStride = Math.ceil(searchStride * scalar); if (rowsSearched < rows && runningResult.length < 1000) { searchHandle.current = window.requestAnimationFrame(tick); } }; cancelSearch(); searchHandle.current = window.requestAnimationFrame(tick); }, [cancelSearch, columns.length, getCellsForSelection, onSearchResultsChanged, rows] ); const onClose = React.useCallback(() => { onSearchClose?.(); setSearchStatus(undefined); setSearchResultsInner([]); onSearchResultsChanged?.([], -1); cancelSearch(); canvasRef?.current?.focus(); }, [cancelSearch, canvasRef, onSearchClose, onSearchResultsChanged]); const onSearchChange = React.useCallback( (event: React.ChangeEvent) => { setSearchString(event.target.value); if (searchResultsIn !== undefined) return; if (event.target.value === "") { setSearchStatus(undefined); setSearchResultsInner([]); cancelSearch(); } else { beginSearch(event.target.value); } }, [beginSearch, cancelSearch, setSearchString, searchResultsIn] ); React.useEffect(() => { if (showSearch && searchInputRef.current !== null) { setSearchString(""); searchInputRef.current.focus({ preventScroll: true }); } }, [showSearch, searchInputRef, setSearchString]); const onNext = React.useCallback( (ev?: React.MouseEvent) => { ev?.stopPropagation?.(); if (searchStatus === undefined) return; const newIndex = (searchStatus.selectedIndex + 1) % searchStatus.results; setSearchStatus({ ...searchStatus, selectedIndex: newIndex, }); onSearchResultsChanged?.(searchResults, newIndex); }, [searchStatus, onSearchResultsChanged, searchResults] ); const onPrev = React.useCallback( (ev?: React.MouseEvent) => { ev?.stopPropagation?.(); if (searchStatus === undefined) return; let newIndex = (searchStatus.selectedIndex - 1) % searchStatus.results; if (newIndex < 0) newIndex += searchStatus.results; setSearchStatus({ ...searchStatus, selectedIndex: newIndex, }); onSearchResultsChanged?.(searchResults, newIndex); }, [onSearchResultsChanged, searchResults, searchStatus] ); const onSearchKeyDown = React.useCallback( (event: React.KeyboardEvent) => { if (((event.ctrlKey || event.metaKey) && event.nativeEvent.code === "KeyF") || event.key === "Escape") { onClose(); event.stopPropagation(); event.preventDefault(); } else if (event.key === "Enter") { if (event.shiftKey) { onPrev(); } else { onNext(); } } }, [onClose, onNext, onPrev] ); // cancel search if the component is unmounted React.useEffect(() => { return () => { cancelSearch(); }; }, [cancelSearch]); const [isAnimatingOut, setIsAnimatingOut] = React.useState(false); React.useEffect(() => { if (showSearch) { setIsAnimatingOut(true); } else { const timeoutId = setTimeout(() => setIsAnimatingOut(false), 150); return () => clearTimeout(timeoutId); } }, [showSearch]); const searchbox = React.useMemo(() => { if (!showSearch && !isAnimatingOut) { return null; } let resultString: string | undefined; if (searchStatus !== undefined) { resultString = searchStatus.results >= 1000 ? `over 1000` : `${searchStatus.results} result${searchStatus.results !== 1 ? "s" : ""}`; if (searchStatus.selectedIndex >= 0) { resultString = `${searchStatus.selectedIndex + 1} of ${resultString}`; } } const cancelEvent = (ev: React.MouseEvent) => { ev.stopPropagation(); }; const rowsSearchedProgress = Math.floor(((searchStatus?.rowsSearched ?? 0) / rows) * 100); const progressStyle: React.CSSProperties = { width: `${rowsSearchedProgress}%`, }; return (
{onSearchClose !== undefined && ( )}
{searchStatus !== undefined ? ( <>
{resultString}
) : (
)} ); }, [ showSearch, isAnimatingOut, searchStatus, rows, searchID, searchInputRef, onSearchChange, searchString, onSearchKeyDown, onPrev, onNext, onSearchClose, onClose, ]); return ( <> {searchbox} ); }; export default DataGridSearch;