diff --git a/packages/pluggableWidgets/document-viewer-web/src/assets/DocViewer.woff2 b/packages/pluggableWidgets/document-viewer-web/src/assets/DocViewer.woff2 index 16ebb6d15e..b3d19dce0f 100644 Binary files a/packages/pluggableWidgets/document-viewer-web/src/assets/DocViewer.woff2 and b/packages/pluggableWidgets/document-viewer-web/src/assets/DocViewer.woff2 differ 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"} > + { }) } > - + + + {highlightRects.map((rect, i) => ( + + ))} + diff --git a/packages/pluggableWidgets/document-viewer-web/src/ui/documentViewer.scss b/packages/pluggableWidgets/document-viewer-web/src/ui/documentViewer.scss index 2b7d6bd666..2bc1d2aa03 100644 --- a/packages/pluggableWidgets/document-viewer-web/src/ui/documentViewer.scss +++ b/packages/pluggableWidgets/document-viewer-web/src/ui/documentViewer.scss @@ -100,3 +100,58 @@ div.widget-document-viewer { margin-right: 5px; width: 5ch; } + +.widget-document-viewer-search-bar { + display: flex; + align-items: center; + gap: var(--spacing-small, 4px); + margin: 0 calc(-1 * var(--form-input-padding-x)); + padding: var(--spacing-small, 4px) var(--spacing-large, 16px); + background-color: var(--gray-lighter); + border-bottom: 1px solid var(--border-color-default, #ced0d3); + + .widget-document-viewer-search-input { + max-width: 200px; + padding: 3px 6px !important; + } + + .widget-document-viewer-search-count { + min-width: 80px; + font-size: var(--font-size-small, 12px); + color: var(--text-color-secondary, #6c757d); + white-space: nowrap; + } +} + +.react-pdf__Page__textContent { + .widget-document-viewer-search-match { + background-color: rgba(255, 210, 0, 0.45); + border-radius: 2px; + color: inherit; + + &--current { + background-color: rgba(255, 140, 0, 0.75); + outline: 1px solid rgba(200, 100, 0, 0.8); + } + } +} + +.widget-document-viewer-highlight-layer { + position: relative; + display: inline-block; + line-height: 0; + + .widget-document-viewer-highlight { + position: absolute; + background-color: rgba(255, 210, 0, 0.4); + mix-blend-mode: multiply; + border-radius: 2px; + pointer-events: none; + + &.current { + background-color: rgba(255, 140, 0, 0.6); + mix-blend-mode: multiply; + box-shadow: 0 0 0 1.5px rgba(200, 100, 0, 0.9); + } + } +} diff --git a/packages/pluggableWidgets/document-viewer-web/src/ui/documentViewerIcons.scss b/packages/pluggableWidgets/document-viewer-web/src/ui/documentViewerIcons.scss index 0dc5bc8921..96c7f3f30f 100644 --- a/packages/pluggableWidgets/document-viewer-web/src/ui/documentViewerIcons.scss +++ b/packages/pluggableWidgets/document-viewer-web/src/ui/documentViewerIcons.scss @@ -10,7 +10,8 @@ $icons: ( Left: "\e902", ZoomIn: "\e901", ZoomOut: "\e900", - FitToWidth: "\e904" + FitToWidth: "\e904", + Search: "\e905" ); .icons.btn { @@ -21,25 +22,42 @@ $icons: ( } } +// Apply DocViewer font to all .icons elements anywhere in the widget div.widget-document-viewer { &-controls { button { margin-left: var(--spacing-smaller, 4px); } - .icons { - font-family: "DocViewer" !important; - font-size: 16px; - font-style: normal; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - text-rendering: optimizeLegibility; - padding: var(--spacing-smallest) var(--spacing-small); + } + + .icons { + font-family: "DocViewer" !important; + font-size: 16px; + font-style: normal; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + padding: var(--spacing-smallest) var(--spacing-small); - @each $name, $code in $icons { - &.icon-#{$name}:before { - content: $code; - } + @each $name, $code in $icons { + &.icon-#{$name}:before { + content: $code; } } } } +// Search toggle button — pressed state via aria-pressed so Atlas hover/focus work normally +// .widget-document-viewer-search-toggle { +// &[aria-pressed="true"] { +// background-color: var(--brand-primary, #264ae5); +// color: #fff; +// border-radius: var(--border-radius-default); + +// &:hover, +// &:focus-visible { +// background-color: var(--brand-primary-dark, #1a36c4); +// color: #fff; +// } +// } +// } +// } diff --git a/packages/pluggableWidgets/document-viewer-web/src/utils/usePDFHighlightPositions.ts b/packages/pluggableWidgets/document-viewer-web/src/utils/usePDFHighlightPositions.ts new file mode 100644 index 0000000000..7d5691a42a --- /dev/null +++ b/packages/pluggableWidgets/document-viewer-web/src/utils/usePDFHighlightPositions.ts @@ -0,0 +1,137 @@ +import { useEffect, useState } from "react"; +import type { PDFDocumentProxy } from "pdfjs-dist"; +import type { SearchMatch } from "./usePDFSearch"; + +export interface HighlightRect { + x: number; + y: number; + width: number; + height: number; + isCurrent: boolean; +} + +/** Multiply two affine transforms stored as 6-element arrays [a, b, c, d, e, f]. */ +function multiplyTransform(m1: number[], m2: number[]): number[] { + return [ + m1[0] * m2[0] + m1[2] * m2[1], + m1[1] * m2[0] + m1[3] * m2[1], + m1[0] * m2[2] + m1[2] * m2[3], + m1[1] * m2[2] + m1[3] * m2[3], + m1[0] * m2[4] + m1[2] * m2[5] + m1[4], + m1[1] * m2[4] + m1[3] * m2[5] + m1[5] + ]; +} + +/** + * Computes absolutely-positioned highlight rectangles for all matches on the + * current page by converting PDF text-item coordinates to screen coordinates + * via the page viewport. Character offsets within an item are estimated using + * canvas text measurement (same font size, generic family) scaled to the + * actual rendered item width, which is more accurate than linear interpolation. + */ +export function usePDFHighlightPositions( + pdfDoc: PDFDocumentProxy | null, + currentPage: number, + zoomLevel: number, + matches: SearchMatch[], + currentMatchIndex: number +): HighlightRect[] { + const [rects, setRects] = useState([]); + + useEffect(() => { + const pageMatches = matches.filter(m => m.page === currentPage); + if (!pdfDoc || pageMatches.length === 0) { + setRects([]); + return; + } + + let active = true; + + (async () => { + const page = await pdfDoc.getPage(currentPage); + const viewport = page.getViewport({ scale: zoomLevel }); + const textContent = await page.getTextContent(); + + // Shared canvas for proportional character-width measurement. + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + + const newRects: HighlightRect[] = []; + let strItemIndex = 0; + const matchesBeforePage = matches.filter(m => m.page < currentPage).length; + + textContent.items.forEach(rawItem => { + if (!("str" in rawItem)) return; + + const itemIndex = strItemIndex++; + const item = rawItem as { str: string; transform: number[]; width: number }; + const itemMatches = pageMatches.filter(m => m.itemIndex === itemIndex); + if (itemMatches.length === 0) return; + + // Combined viewport+item transform → screen-space position. + const tx = multiplyTransform(viewport.transform, item.transform); + const screenX = tx[4]; // left edge of text baseline in CSS px + const screenY = tx[5]; // y of baseline (from page top) in CSS px + + // Font height in screen pixels = magnitude of the y-column. + const fontHeight = Math.sqrt(tx[2] ** 2 + tx[3] ** 2); + + // Item width in screen pixels (item.width is in PDF user space). + const itemScreenWidth = item.width * zoomLevel; + + // Build a function that converts a [start,end) substring range + // into an {x, w} pair in screen pixels. + let segmentToRect: (start: number, end: number) => { x: number; w: number }; + + if (ctx && item.str.length > 0 && itemScreenWidth > 0) { + // Approximate glyph-proportional widths using canvas measurement + // at the same font size as the PDF item, then scale to actual width. + const fontSize = Math.max(1, Math.round(Math.sqrt(tx[0] ** 2 + tx[1] ** 2))); + ctx.font = `${fontSize}px sans-serif`; + const measuredTotal = ctx.measureText(item.str).width; + if (measuredTotal > 0) { + const scaleFactor = itemScreenWidth / measuredTotal; + segmentToRect = (start, end) => ({ + x: screenX + ctx.measureText(item.str.slice(0, start)).width * scaleFactor, + w: ctx.measureText(item.str.slice(start, end)).width * scaleFactor + }); + } else { + const perChar = itemScreenWidth / item.str.length; + segmentToRect = (start, end) => ({ + x: screenX + start * perChar, + w: (end - start) * perChar + }); + } + } else { + const perChar = item.str.length > 0 ? itemScreenWidth / item.str.length : 0; + segmentToRect = (start, end) => ({ + x: screenX + start * perChar, + w: (end - start) * perChar + }); + } + + const matchesBeforeItem = matches.filter(m => m.page === currentPage && m.itemIndex < itemIndex).length; + + itemMatches.forEach((match, localIdx) => { + const globalIdx = matchesBeforePage + matchesBeforeItem + localIdx; + const { x, w } = segmentToRect(match.matchStart, match.matchEnd); + newRects.push({ + x, + y: screenY - fontHeight, + width: w, + height: fontHeight, + isCurrent: globalIdx === currentMatchIndex + }); + }); + }); + + if (active) setRects(newRects); + })(); + + return () => { + active = false; + }; + }, [pdfDoc, currentPage, zoomLevel, matches, currentMatchIndex]); + + return rects; +} diff --git a/packages/pluggableWidgets/document-viewer-web/src/utils/usePDFSearch.ts b/packages/pluggableWidgets/document-viewer-web/src/utils/usePDFSearch.ts new file mode 100644 index 0000000000..02f17cbab3 --- /dev/null +++ b/packages/pluggableWidgets/document-viewer-web/src/utils/usePDFSearch.ts @@ -0,0 +1,106 @@ +import { useCallback, useEffect, useState } from "react"; +import type { PDFDocumentProxy } from "pdfjs-dist"; + +export interface SearchMatch { + page: number; + itemIndex: number; + matchStart: number; + matchEnd: number; +} + +interface UsePDFSearchResult { + matches: SearchMatch[]; + currentMatchIndex: number; + goToNextMatch: () => void; + goToPrevMatch: () => void; + isSearching: boolean; +} + +export function usePDFSearch( + pdfDoc: PDFDocumentProxy | null, + searchQuery: string, + setCurrentPage: (page: number) => void +): UsePDFSearchResult { + const [matches, setMatches] = useState([]); + const [currentMatchIndex, setCurrentMatchIndex] = useState(-1); + const [isSearching, setIsSearching] = useState(false); + + useEffect(() => { + const trimmedQuery = searchQuery.trim(); + if (!pdfDoc || !trimmedQuery) { + setMatches([]); + setCurrentMatchIndex(-1); + setIsSearching(false); + return; + } + + let active = true; + setIsSearching(true); + + const queryLower = trimmedQuery.toLowerCase(); + + (async () => { + const allMatches: SearchMatch[] = []; + for (let pageNum = 1; pageNum <= pdfDoc.numPages; pageNum++) { + if (!active) { + break; + } + const page = await pdfDoc.getPage(pageNum); + const textContent = await page.getTextContent(); + + let strItemIndex = 0; + textContent.items.forEach(item => { + if (!("str" in item)) { + return; + } + const itemIndex = strItemIndex++; + const str = (item as { str: string }).str.toLowerCase(); + let start = 0; + let idx: number; + while ((idx = str.indexOf(queryLower, start)) !== -1) { + allMatches.push({ + page: pageNum, + itemIndex, + matchStart: idx, + matchEnd: idx + queryLower.length + }); + start = idx + queryLower.length; + } + }); + } + + if (active) { + setMatches(allMatches); + setCurrentMatchIndex(allMatches.length > 0 ? 0 : -1); + setIsSearching(false); + if (allMatches.length > 0) { + setCurrentPage(allMatches[0].page); + } + } + })(); + + return () => { + active = false; + }; + }, [pdfDoc, searchQuery, setCurrentPage]); + + const goToNextMatch = useCallback(() => { + if (matches.length === 0) { + return; + } + const nextIndex = (currentMatchIndex + 1) % matches.length; + setCurrentMatchIndex(nextIndex); + setCurrentPage(matches[nextIndex].page); + }, [matches, currentMatchIndex, setCurrentPage]); + + const goToPrevMatch = useCallback(() => { + if (matches.length === 0) { + return; + } + const prevIndex = (currentMatchIndex - 1 + matches.length) % matches.length; + setCurrentMatchIndex(prevIndex); + setCurrentPage(matches[prevIndex].page); + }, [matches, currentMatchIndex, setCurrentPage]); + + return { matches, currentMatchIndex, goToNextMatch, goToPrevMatch, isSearching }; +}