diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md index ca62983297..9df96baf77 100644 --- a/.agents/skills/code-review/SKILL.md +++ b/.agents/skills/code-review/SKILL.md @@ -141,10 +141,11 @@ Full rules, waiting strategies, locator patterns, and spec template are in `docs - Manual `afterEach` session logout via `window.mx.session.logout()` — no longer needed; the fixture handles it - `page.waitForTimeout()` / hardcoded `sleep` — replace with a web-first locator assertion - `page.waitForLoadState("networkidle")` — replace with `waitForMendixApp(page)` from helpers, or prefer web-first assertions. Correct pattern: - ```js - test.beforeEach(async ({ page }) => { - await waitForMendixApp(page); // or: await expect(page.locator(".mx-name-...")).toBeVisible(); - }); + ```js + test.beforeEach(async ({ page }) => { + await waitForMendixApp(page); // or: await expect(page.locator(".mx-name-...")).toBeVisible(); + }); + ``` - Selectors that don't use `.mx-name-*` when a Mendix widget name is available - Screenshot baselines not committed — `toHaveScreenshot` requires a baseline PNG in the repo - E2E file not following `WidgetName.spec.js` naming convention diff --git a/packages/modules/data-widgets/src/themesource/datawidgets/web/_date-picker.scss b/packages/modules/data-widgets/src/themesource/datawidgets/web/_date-picker.scss index e0f8f4a826..5e17e58a3e 100644 --- a/packages/modules/data-widgets/src/themesource/datawidgets/web/_date-picker.scss +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/_date-picker.scss @@ -103,12 +103,12 @@ $datepicker-border-color: #ccc !default; .react-datepicker__day--in-range:not(.react-datepicker__day--range-start, .react-datepicker__day--range-end), .react-datepicker__day--in-selecting-range:not( - .react-datepicker__day--in-range, - .react-datepicker__month-text--in-range, - .react-datepicker__quarter-text--in-range, - .react-datepicker__year-text--in-range, - .react-datepicker__day--selecting-range-start - ) { + .react-datepicker__day--in-range, + .react-datepicker__month-text--in-range, + .react-datepicker__quarter-text--in-range, + .react-datepicker__year-text--in-range, + .react-datepicker__day--selecting-range-start +) { background-color: var(--dg-day-range-background, $dg-day-range-background); color: var(--dg-day-range-color, $dg-day-range-color); diff --git a/packages/pluggableWidgets/document-viewer-web/CHANGELOG.md b/packages/pluggableWidgets/document-viewer-web/CHANGELOG.md index 712e4c27b2..223a42251c 100644 --- a/packages/pluggableWidgets/document-viewer-web/CHANGELOG.md +++ b/packages/pluggableWidgets/document-viewer-web/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Added + +- We added in-document text search for PDF files, with match highlighting and previous/next navigation across pages. + ### Changed - We changed the internal structure of the widget 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 e7b93313a2..b52ebcc4e9 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 { If } from "@mendix/widget-plugin-component-kit/If"; import "react-pdf/dist/Page/AnnotationLayer.css"; import "react-pdf/dist/Page/TextLayer.css"; +import type { PDFDocumentProxy } from "pdfjs-dist"; +import { usePDFHighlightPositions } from "../utils/usePDFHighlightPositions"; +import { usePDFSearch } from "../utils/usePDFSearch"; import BaseViewer from "./BaseViewer"; import { DocRendererElement, DocumentRendererProps, DocumentStatus } from "./documentRenderer"; import { downloadFile } from "../utils/helpers"; @@ -39,11 +52,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 @@ -104,18 +146,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); + + const searchMatchLabel = debouncedQuery.trim() + ? isSearching + ? "Searching…" + : matches.length === 0 + ? "No results" + : `${currentMatchIndex + 1} of ${matches.length}` + : ""; + if (!file.value?.uri) { return
No document selected
; } @@ -124,6 +199,39 @@ const PDFViewer: DocRendererElement = (props: DocumentRendererProps) => { + + + {searchMatchLabel} + + + + + ) : null + } CustomControl={
@@ -160,6 +268,13 @@ const PDFViewer: DocRendererElement = (props: DocumentRendererProps) => { title={"Go to next page"} >
+