Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .agents/skills/code-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
4 changes: 4 additions & 0 deletions packages/pluggableWidgets/document-viewer-web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CSSProperties, Fragment, PropsWithChildren, ReactElement, ReactNode, useCallback } from "react";
import { useZoomScale } from "../utils/useZoomScale";

Check warning on line 2 in packages/pluggableWidgets/document-viewer-web/src/components/BaseViewer.tsx

View workflow job for this annotation

GitHub Actions / Run code quality check

`../utils/useZoomScale` import should occur after import of `../utils/helpers`
import { DocumentViewerContainerProps } from "../../typings/DocumentViewerProps";
import { downloadFile } from "../utils/helpers";

Expand All @@ -19,10 +19,11 @@
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 (
<Fragment>
<div className="widget-document-viewer-controls">
Expand All @@ -31,6 +32,7 @@
</div>
<div className="widget-document-viewer-controls-icons">{CustomControl}</div>
</div>
{SecondaryControl}
<div className="widget-document-viewer-content">{children}</div>
</Fragment>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
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";

Check warning on line 16 in packages/pluggableWidgets/document-viewer-web/src/components/PDFViewer.tsx

View workflow job for this annotation

GitHub Actions / Run code quality check

`pdfjs-dist` type import should occur before import of `react`
import { usePDFHighlightPositions } from "../utils/usePDFHighlightPositions";
import { usePDFSearch } from "../utils/usePDFSearch";
import BaseViewer from "./BaseViewer";

Check warning on line 19 in packages/pluggableWidgets/document-viewer-web/src/components/PDFViewer.tsx

View workflow job for this annotation

GitHub Actions / Run code quality check

`./BaseViewer` import should occur before import of `../utils/usePDFHighlightPositions`
import { DocRendererElement, DocumentRendererProps, DocumentStatus } from "./documentRenderer";

Check warning on line 20 in packages/pluggableWidgets/document-viewer-web/src/components/PDFViewer.tsx

View workflow job for this annotation

GitHub Actions / Run code quality check

`./documentRenderer` import should occur before import of `../utils/usePDFHighlightPositions`
import { downloadFile } from "../utils/helpers";

Check warning on line 21 in packages/pluggableWidgets/document-viewer-web/src/components/PDFViewer.tsx

View workflow job for this annotation

GitHub Actions / Run code quality check

`../utils/helpers` import should occur before import of `../utils/usePDFHighlightPositions`
import { useZoomScale } from "../utils/useZoomScale";

const origin: string = (window.mx?.appUrl ?? window.location.origin).replace(/\/$/, "");
Expand Down Expand Up @@ -39,11 +52,40 @@
const [currentPage, setCurrentPage] = useState<number>(1);
const [pageInputValue, setPageInputValue] = useState<string>("1");
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [pdfDoc, setPdfDoc] = useState<PDFDocumentProxy | null>(null);
const [showSearch, setShowSearch] = useState<boolean>(false);
const [searchQuery, setSearchQuery] = useState<string>("");
const [debouncedQuery, setDebouncedQuery] = useState<string>("");
const searchInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
setSearchQuery(event.target.value);
}, []);

const handleSearchKeyDown = useCallback(
(event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Escape") {
event.preventDefault();
toggleSearch();
}
},
[toggleSearch]
);

const handlePageInputChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
// Allow only numbers and empty string
Expand Down Expand Up @@ -104,18 +146,51 @@
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 <div>No document selected</div>;
}
Expand All @@ -124,6 +199,39 @@
<BaseViewer
{...props}
fileName={file.value?.name || ""}
SecondaryControl={
showSearch ? (
<div className="widget-document-viewer-search-bar">
<input
ref={searchInputRef}
type="search"
value={searchQuery}
onChange={handleSearchInputChange}
onKeyDown={handleSearchKeyDown}
className="form-control widget-document-viewer-search-input"
aria-label="Search in document"
placeholder="Search…"
/>
<span className="widget-document-viewer-search-count" aria-live="polite">
{searchMatchLabel}
</span>
<button
onClick={goToPrevMatch}
disabled={matches.length === 0}
className="icons icon-Left btn btn-icon-only"
aria-label="Previous match"
title="Previous match"
></button>
<button
onClick={goToNextMatch}
disabled={matches.length === 0}
className="icons icon-Right btn btn-icon-only"
aria-label="Next match"
title="Next match"
></button>
</div>
) : null
}
CustomControl={
<Fragment>
<div className="widget-document-viewer-pagination">
Expand Down Expand Up @@ -160,6 +268,13 @@
title={"Go to next page"}
></button>
</div>
<button
onClick={toggleSearch}
className="icons icon-Search btn btn-icon-only widget-document-viewer-search-toggle"
aria-label={showSearch ? "Close search" : "Search in document"}
aria-pressed={showSearch}
title={showSearch ? "Close search" : "Search in document"}
></button>
<button
onClick={onDownloadClick}
className="icons icon-Download btn btn-icon-only"
Expand Down Expand Up @@ -204,7 +319,23 @@
})
}
>
<Page pageNumber={currentPage} scale={zoomLevel} />
<div className="widget-document-viewer-highlight-layer">
<Page pageNumber={currentPage} scale={zoomLevel} />
{highlightRects.map(rect => (
<div
key={rect.globalMatchIndex}
className={`widget-document-viewer-highlight${
rect.globalMatchIndex === currentMatchIndex ? " current" : ""
}`}
style={{
left: rect.x,
top: rect.y,
width: rect.width,
height: rect.height
}}
/>
))}
</div>
</Document>
</If>
</BaseViewer>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ $icons: (
Left: "\e902",
ZoomIn: "\e901",
ZoomOut: "\e900",
FitToWidth: "\e904"
FitToWidth: "\e904",
Search: "\e905"
);

.icons.btn {
Expand All @@ -21,24 +22,26 @@ $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;
}
}
}
Expand Down
Loading
Loading