From cd9365be1da147be79db53c8948eeb20947dc431 Mon Sep 17 00:00:00 2001 From: Kshitij Karandikar Date: Tue, 31 Mar 2026 16:27:07 +0200 Subject: [PATCH] feat(document-viewer): add PDF text search with highlight overlay --- .../src/assets/DocViewer.woff2 | Bin 1304 -> 1296 bytes .../src/components/BaseViewer.tsx | 4 +- .../src/components/PDFViewer.tsx | 137 +++++++++++++++++- .../src/ui/documentViewer.scss | 55 +++++++ .../src/ui/documentViewerIcons.scss | 44 ++++-- .../src/utils/usePDFHighlightPositions.ts | 137 ++++++++++++++++++ .../src/utils/usePDFSearch.ts | 106 ++++++++++++++ 7 files changed, 465 insertions(+), 18 deletions(-) create mode 100644 packages/pluggableWidgets/document-viewer-web/src/utils/usePDFHighlightPositions.ts create mode 100644 packages/pluggableWidgets/document-viewer-web/src/utils/usePDFSearch.ts diff --git a/packages/pluggableWidgets/document-viewer-web/src/assets/DocViewer.woff2 b/packages/pluggableWidgets/document-viewer-web/src/assets/DocViewer.woff2 index 16ebb6d15e99ae2d8b6d0f9e21e0433af1684cd4..b3d19dce0f5b361cbc9abf80fa1834ddb6afc128 100644 GIT binary patch delta 1283 zcmV+e1^oJ$3XlpIcTYw#00961000FL01E&B000a4000ESkr*F;1`35#g$@BW0we=) z3pxM+#y&ZRLd%?06{1~$1*}i=&eX{DUUA>^*>lwvq#i(C~}6l^qjt@Ct{oT zD1uoqnDXN4N?kI4m4y^#Lv!s0r0vo6PkPhrN}FIJ8!p+*+grjbnfzA=NXbo4|M`0r zvppX?C-Vtsk_7N(FAC1lu?9#1t83$!Af>gyLIn$02Ur2PAksxQle)n*LG)7!w2rzg zyZh5EL^g>UBqTs!yT%rI5_~cxNhqja;sd4S_B!nv#up)f3AI|I)v5IcA;~2xth5{l zX9|ZxPl7zU@aa&k4Lc{k6&-V6Tp+e3Oofo4a|53FyVQ0cmb>NB3itJ8ZToV zUd4#lu^exq!Mn0mEHDZd!ALAJ$XPIgMPigJ3dRP17Ug=B5e+fDhLH-^!oLRnGK`h6 zc$99o^*W>FG^_$Qw-KDhjGu`I03<*rgq&-`VR;|RGM*MaB>Di4HPUDyH5wGxVZlgH zYB+=d@(dn(FQ7m&v^m+jtdi^;O=eaBga8D<0ES>Fp%3%H5gvsj0`B)n=`!u?|wA zp+UW?Agsu4v`?*19NcMLah#Hu$DN&1{;=xf<2^fKB%7-s9AdjrXcs#+fQnS5rr?G? zl#p|C=#v(6Rh79nqhhF6_mJ5rGSX@dkT8=JF^W~)JPNG2)lSsbPDHcg$!Re$X-egP z!U%SJJPSbl0&hG>4pYL_|4Uvs3H-X@|F!Vj-wj`SUZZ@TkE51>Ab|wG;zR{IO3je*vGeMv3T*_~IPyu`7Do6xN*D30zJ%R4rtEKx3$ zI!EmvYiqyx3L>{r#M2*ZT{m%FG~2N%8sLm|S?&i79GwQ`J%y!VSx87{S!3~D6eP<} zyx+G+&L-}@Cg+&Lj!rdu(0XGmM$V&ta$fVst9Tvad0JOjQ@0cv|NKr%?Rn+#l%h}38*TBxy*u=rm$jvf- tXWXrqfO^5&=UyW9x)w>j-Ua>h^P=Y2>IOwxAx^|R>hDld7Ldr7i@jLYP;meN delta 1291 zcmV+m1@!um3YZEQcTYw#00961000FT01E&B000Zn000EZkr*F;CJKafgmD2j0we=$ z3pfA-AO(b82Z1vi(F#$NP0o?Xz9=35006*AP!!#U=XCmpaVa7Vsv_@HjN<#g4*vP} z``C|~p0^oA@6L;l*kx$W0@9AsNSZZk&ZhB}!-)~m5d869lDM2jn;}6lKNoOXBz7UtpB<1xH)0XR0#BCX7SL@w@ryvkp zXo4B6dFim#<`6ly#S!uayP=v>MzZ-!gp0VGgybo{+DyxTt&tcp3%PkH5=(?q8PVEu zTH5MEd&5&GRTrr*mDYMRRB6NmSw!3kilg0Eyb$}P9{=kGhEh541roWojy~t7c6cxw z^Trsj^qTsysF1Q}j}UILadn%pf5iM4U~l|71YK~w6nMabxxhh>==cw&%+k56m}))k z4KP&to-@yXDjiCu;*p`r$9-o1ht^05%cl=QQih^tY`XdDEeAb;zLa~iLAMNa?Lwj} z9yFRiOCufL#C(I|Gf;|PzQ;shSfA6hK3mo8^^G-1dU03}=ER)-zED2}XoB(~m?xtL zRv`de(Fcc+g2NbsqsYK9w7_w+!3mV$Br0$U1vra;1e`+`oJR~UAOsgN0GE-2tEj;Z zMBuing9al!G%~`WF~FiRLTDWD(Hw&TFaof=>vcYW`WysH)iy+|I1pGP%6vZ^Wp0q+ zeexSCJH7Mn)RH+^1PKp6%GFOl$z;iq38T*ewK+|o2qa*|tU$Ks86oUJ(0&OMC%tc8 zzTGZ=GRY%KD!`pc5N0Ha6~$?uuc=r%jkIOU`})#MH2y?if?|S-;EGzVg-0nl5q=`y z|9j$L?+1rmoGbhx6h{WaAXkk8a`GMKQRRJL5FAV@1XgDQu+$hyj!0+PwlbIpy4=u> zF(7EGxPE`%S!%0d3sOZc=)tZ)v>^vttFmEm?P?z~+e&aNi|H?<1 z!e_T`7f^IR@bU33?Xgs}1}NyxJDqtqJYz&77IFH|ZfjD0Ye~PJNe>L9rzN5Vrp+e4 z9E)_>ti*#xBt%%jO;&o7-G`uU#UlXBZE6hzbJv6RjP6KYE&Fr&8SXwO9}1e2|6$^P z0QfoT^}!@7UjxGV>DRx`rp{!qd%b-IkHyKqOY*2P-_o^EY(OkdfnPwfG}*kQ)AAI9LYt4xTs-ddrl#H{jIm@Ia)@vOaDXQf>hASc=Teb}m#(`el8J1fdvaNlL+uP(wLcNhR{iWR!{o(weB0&{kDaFaX5p BVov}7 diff --git a/packages/pluggableWidgets/document-viewer-web/src/components/BaseViewer.tsx b/packages/pluggableWidgets/document-viewer-web/src/components/BaseViewer.tsx index e395ca1968..fa69e79567 100644 --- a/packages/pluggableWidgets/document-viewer-web/src/components/BaseViewer.tsx +++ b/packages/pluggableWidgets/document-viewer-web/src/components/BaseViewer.tsx @@ -19,10 +19,11 @@ interface BaseControlViewerProps extends PropsWithChildren { interface BaseViewerProps extends PropsWithChildren { fileName: string; CustomControl?: ReactNode; + SecondaryControl?: ReactNode; } const BaseViewer = (props: BaseViewerProps): ReactElement => { - const { fileName, CustomControl, children } = props; + const { fileName, CustomControl, SecondaryControl, children } = props; return (
@@ -31,6 +32,7 @@ const BaseViewer = (props: BaseViewerProps): ReactElement => {
{CustomControl}
+ {SecondaryControl}
{children}
); diff --git a/packages/pluggableWidgets/document-viewer-web/src/components/PDFViewer.tsx b/packages/pluggableWidgets/document-viewer-web/src/components/PDFViewer.tsx index 1f3b7da0a6..dc63054fb2 100644 --- a/packages/pluggableWidgets/document-viewer-web/src/components/PDFViewer.tsx +++ b/packages/pluggableWidgets/document-viewer-web/src/components/PDFViewer.tsx @@ -1,8 +1,21 @@ -import { ChangeEvent, FormEvent, Fragment, KeyboardEvent, useCallback, useEffect, useMemo, useState } from "react"; +import { + ChangeEvent, + FormEvent, + Fragment, + KeyboardEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState +} from "react"; import { Document, Page, pdfjs } from "react-pdf"; import "react-pdf/dist/Page/AnnotationLayer.css"; import "react-pdf/dist/Page/TextLayer.css"; +import type { PDFDocumentProxy } from "pdfjs-dist"; import { downloadFile } from "../utils/helpers"; +import { usePDFHighlightPositions } from "../utils/usePDFHighlightPositions"; +import { usePDFSearch } from "../utils/usePDFSearch"; import { useZoomScale } from "../utils/useZoomScale"; import BaseViewer from "./BaseViewer"; import { DocRendererElement, DocumentRendererProps, DocumentStatus } from "./documentRenderer"; @@ -37,11 +50,40 @@ const PDFViewer: DocRendererElement = (props: DocumentRendererProps) => { const [currentPage, setCurrentPage] = useState(1); const [pageInputValue, setPageInputValue] = useState("1"); const [pdfUrl, setPdfUrl] = useState(null); + const [pdfDoc, setPdfDoc] = useState(null); + const [showSearch, setShowSearch] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [debouncedQuery, setDebouncedQuery] = useState(""); + const searchInputRef = useRef(null); const onDownloadClick = useCallback(() => { downloadFile(file.value?.uri); }, [file]); + const toggleSearch = useCallback(() => { + setShowSearch(prev => { + if (prev) { + setSearchQuery(""); + setDebouncedQuery(""); + } + return !prev; + }); + }, []); + + const handleSearchInputChange = useCallback((event: ChangeEvent) => { + setSearchQuery(event.target.value); + }, []); + + const handleSearchKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + toggleSearch(); + } + }, + [toggleSearch] + ); + const handlePageInputChange = useCallback((event: ChangeEvent) => { const value = event.target.value; // Allow only numbers and empty string @@ -102,18 +144,51 @@ const PDFViewer: DocRendererElement = (props: DocumentRendererProps) => { if (file.value?.uri) { setCurrentPage(1); setPageInputValue("1"); + setPdfDoc(null); + setSearchQuery(""); + setDebouncedQuery(""); } }, [file.value]); + // Debounce search query to avoid triggering search on every keystroke + useEffect(() => { + const timer = setTimeout(() => setDebouncedQuery(searchQuery), 300); + return () => clearTimeout(timer); + }, [searchQuery]); + + // Auto-focus search input when search bar opens + useEffect(() => { + if (showSearch) { + searchInputRef.current?.focus(); + } + }, [showSearch]); + // Sync page input value with current page useEffect(() => { setPageInputValue(currentPage.toString()); }, [currentPage]); - function onDocumentLoadSuccess({ numPages }: { numPages: number }): void { - setNumberOfPages(numPages); + function onDocumentLoadSuccess(pdf: PDFDocumentProxy): void { + setNumberOfPages(pdf.numPages); + setPdfDoc(pdf); } + const { matches, currentMatchIndex, goToNextMatch, goToPrevMatch, isSearching } = usePDFSearch( + pdfDoc, + debouncedQuery, + setCurrentPage + ); + + const highlightRects = usePDFHighlightPositions(pdfDoc, currentPage, zoomLevel, matches, currentMatchIndex); + + const searchMatchLabel = debouncedQuery.trim() + ? isSearching + ? "Searching…" + : matches.length === 0 + ? "No results" + : `${currentMatchIndex + 1} of ${matches.length}` + : ""; + if (!file.value?.uri) { return
No document selected
; } @@ -122,6 +197,39 @@ const PDFViewer: DocRendererElement = (props: DocumentRendererProps) => { + + + {searchMatchLabel} + + + + + ) : null + } CustomControl={
@@ -158,6 +266,13 @@ const PDFViewer: DocRendererElement = (props: DocumentRendererProps) => { title={"Go to next page"} >
+