Skip to content

Commit aa0fa98

Browse files
serpentbladeclaude
andcommitted
fix(ui-pdf): drive PdfViewer find via reactive :query prop (fixes Angular VR)
The find re-port (d581e01/ddd852ee) wired the PdfViewerDemo "Find" button to the imperative $expose handle (`$refs.viewer.find($data.query)`). On Angular a child-component `ref` inside a @click listener compiles to a template-local `ɵɵreference` that is out of scope in the listener closure, so clicking Find threw `TypeError: viewer_r2 is not a function` and find-readout stayed 0 — the exact non-uniformity the demo header already documented for page-nav (which is why `page` is driven by a two-way model, not the handle). PdfViewerDemo was the only demo driving a child handle from @click. Fix: add a controlled, reactive `query` prop to PdfViewer (the search analog of the `page` model). A lazy $watch runs find()/clearFind() on a consumer write, so find works uniformly across all six targets with no handle call. The demo's Find button now copies the input into `:query`; the imperative find/findNext/… verbs remain in $expose (structural + docs coverage). - PdfViewer.rozie: +query prop, +lazy $watch(query) → find/clearFind - PdfViewerDemo.rozie: Find/Clear drive :query, drop unused ref + Next-match handle button - regenerate 6 leaves + READMEs + docs props-table (11 props); surface gate 10→11 props - verified: pdf.spec.ts 6/6 green (angular + lit + react …), pdf-* leaf typecheck 10/10 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lxeam8c3XSttraCZPxufKM
1 parent 77effcc commit aa0fa98

17 files changed

Lines changed: 129 additions & 13 deletions

File tree

docs/components/pdf.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ el.addEventListener('load', (e) => console.log(e.detail.numPages));
181181
| `renderAllPages` | `Boolean` | `false` | || `false` = single page with nav (the two-way `page` drives it). `true` = continuous scroll of every page; the most-visible page reflects back into `page` and the `pagechange` event via an `IntersectionObserver`. |
182182
| `textLayer` | `Boolean` | `true` | || Render PDF.js's selectable / copyable text-layer spans over each page canvas (the differentiator vs a dumb canvas image). The required `.textLayer` CSS + `--scale-factor` var ship with the component — no extra import. |
183183
| `password` | `unknown` | `undefined` | || Password for an encrypted PDF. If the document is encrypted and no (or a wrong) password is set, the `passwordrequest` event fires with `{ reason }`. Changing it reloads the document. |
184+
| `query` | `unknown` | `undefined` | || A reactive search query — the **controlled** alternative to the imperative `find()` handle. Setting it to a non-empty string scans every page, navigates to + coarse-highlights the first match, and emits `findresult` with the total occurrence count; clearing it (empty string / `null`) clears the highlight. Reactive so it works uniformly across all six targets (an Angular child-component `ref` cannot reach the `$expose` handle from a template event handler — the same reason `page` is a two-way model rather than a handle call). |
184185
| `options` | `Object` | `{}` | || Raw [`getDocument` `DocumentInitParameters`](https://mozilla.github.io/pdf.js/api/draft/module-pdfjsLib.html) passthrough — spread **before** the curated keys (explicit `src` / `password` win). For `cMapUrl`, `httpHeaders`, `withCredentials`, etc. |
185186

186187
### Events

examples/demos/PdfViewerDemo.rozie

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@
2727
uniform across all 6 — an Angular child-component ref resolves to the host
2828
element, not the instance, so a handle-driven Next would no-op there. The
2929
$expose handle (nextPage/zoomIn/…) is covered structurally + in the docs.
30+
- Find is driven through the CONTROLLED `:query` prop (NOT the $expose handle):
31+
the "Find" button copies the input's text into $data.active, which flows into
32+
the wrapper's `:query` prop → its lazy $watch runs find() → emits `findresult`
33+
→ onFind mirrors the count into the readout. This mirrors the two-way `page`
34+
model: an Angular child-component `ref` can't reach the $expose handle from a
35+
template event handler (a handle-driven `$refs.viewer.find()` throws
36+
"viewer_r2 is not a function" there), so the demo stays uniform across all 6
37+
by driving reactive props, never the handle. The find/findNext/… handle verbs
38+
are covered structurally + in the docs.
3039
- standardFontDataUrl is left at its default; offline (Docker) it can't load,
3140
so glyphs may fall back — fine, the spec asserts a .textLayer span exists
3241
(selectable text present), not its exact glyphs.
@@ -47,6 +56,10 @@
4756
page: 1,
4857
total: 0,
4958
query: 'fox',
59+
// the CONTROLLED search query handed to the wrapper's `:query` prop. Starts
60+
// empty so the wrapper's lazy $watch does not run a find on mount; the "Find"
61+
// button copies $data.query into it to trigger the search.
62+
active: '',
5063
findCount: 0,
5164
}
5265
</data>
@@ -86,6 +99,12 @@ const onFind = (e) => {
8699
const m = e && e.detail && typeof e.detail.matches === 'number' ? e.detail.matches : e.matches
87100
$data.findCount = m
88101
}
102+
103+
// run the search by copying the query into the CONTROLLED `:query` prop (the
104+
// wrapper's lazy $watch runs find()) — NOT the imperative handle, so it works
105+
// uniformly across all six targets. Clearing it resets the highlight.
106+
const runFind = () => { $data.active = $data.query }
107+
const clearSearch = () => { $data.active = '' }
89108
</script>
90109

91110
<template>
@@ -99,17 +118,16 @@ const onFind = (e) => {
99118

100119
<div class="controls">
101120
<input type="text" data-testid="find-input" r-model="$data.query" />
102-
<button type="button" data-testid="find" @click="$refs.viewer && $refs.viewer.find($data.query)">Find</button>
103-
<button type="button" data-testid="find-next" @click="$refs.viewer && $refs.viewer.findNext()">Next match</button>
104-
<button type="button" data-testid="find-clear" @click="$refs.viewer && $refs.viewer.clearFind()">Clear</button>
121+
<button type="button" data-testid="find" @click="runFind">Find</button>
122+
<button type="button" data-testid="find-clear" @click="clearSearch">Clear</button>
105123
<span class="readout">matches <b data-testid="find-readout">{{ $data.findCount }}</b></span>
106124
</div>
107125

108126
<div class="pdf-wrap" style="height: 360px">
109127
<PdfViewer
110-
ref="viewer"
111128
:src="SAMPLE"
112129
:worker-src="workerSrc"
130+
:query="$data.active"
113131
r-model:page="$data.page"
114132
@load="onLoad"
115133
@findresult="onFind"

packages/ui/pdf/packages/angular/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export class DemoComponent {
5151
| `renderAllPages` | `Boolean` | `false` | | |
5252
| `textLayer` | `Boolean` | `true` | | |
5353
| `password` | `unknown` | `undefined` | | |
54+
| `query` | `unknown` | `undefined` | | |
5455
| `options` | `Object` | `{}` | | |
5556

5657
## Events

packages/ui/pdf/packages/angular/src/PdfViewer.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,10 @@ export class PdfViewer {
109109
* Password for an encrypted PDF. If the document is encrypted and no (or a wrong) password is set, the `passwordrequest` event fires with `{ reason }`. Changing it reloads the document.
110110
*/
111111
password = input<unknown>(undefined);
112+
/**
113+
* A reactive search query — the **controlled** alternative to the imperative `find()` handle. Setting it to a non-empty string scans every page, navigates to + coarse-highlights the first match, and emits `findresult` with the total occurrence count; clearing it (empty string / `null`) clears the highlight. Reactive so it works uniformly across all six targets (an Angular child-component `ref` cannot reach the `$expose` handle from a template event handler — the same reason `page` is a two-way model rather than a handle call).
114+
*/
115+
query = input<unknown>(undefined);
112116
/**
113117
* Raw `getDocument` `DocumentInitParameters` passthrough — spread **before** the curated keys (explicit `src` / `password` win). For `cMapUrl`, `httpHeaders`, `withCredentials`, etc.
114118
*/
@@ -138,6 +142,7 @@ export class PdfViewer {
138142
private __rozieWatchInitial_9 = true;
139143
private __rozieWatchInitial_10 = true;
140144
private __rozieWatchInitial_11 = true;
145+
private __rozieWatchInitial_12 = true;
141146

142147
constructor() {
143148
effect(() => { const __watchVal = (() => this.engineReady())(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } (() => this._load())(); }); });
@@ -168,6 +173,11 @@ export class PdfViewer {
168173
effect(() => { const __watchVal = (() => this.rot())(); untracked(() => { if (this.__rozieWatchInitial_9) { this.__rozieWatchInitial_9 = false; return; } (() => this.renderView())(); }); });
169174
effect(() => { const __watchVal = (() => this.renderAllPages())(); untracked(() => { if (this.__rozieWatchInitial_10) { this.__rozieWatchInitial_10 = false; return; } (() => this.renderView())(); }); });
170175
effect(() => { const __watchVal = (() => this.textLayer())(); untracked(() => { if (this.__rozieWatchInitial_11) { this.__rozieWatchInitial_11 = false; return; } (() => this.renderView())(); }); });
176+
effect(() => { const __watchVal = (() => this.query())(); untracked(() => { if (this.__rozieWatchInitial_12) { this.__rozieWatchInitial_12 = false; return; } ((v: any) => {
177+
if (v == null) return;
178+
const q = String(v);
179+
if (q) this.find(q);else this.clearFind();
180+
})(__watchVal); }); });
171181
}
172182

173183
ngAfterViewInit() {

packages/ui/pdf/packages/lit/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ el.addEventListener('load', (e) => console.log(e.detail.numPages));
3939
| `renderAllPages` | `Boolean` | `false` | | |
4040
| `textLayer` | `Boolean` | `true` | | |
4141
| `password` | `unknown` | `undefined` | | |
42+
| `query` | `unknown` | `undefined` | | |
4243
| `options` | `Object` | `{}` | | |
4344

4445
## Events

packages/ui/pdf/packages/lit/src/PdfViewer.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ export default class PdfViewer extends SignalWatcher(LitElement) {
9696
* Password for an encrypted PDF. If the document is encrypted and no (or a wrong) password is set, the `passwordrequest` event fires with `{ reason }`. Changing it reloads the document.
9797
*/
9898
@property({ type: Object }) password: unknown = undefined;
99+
/**
100+
* A reactive search query — the **controlled** alternative to the imperative `find()` handle. Setting it to a non-empty string scans every page, navigates to + coarse-highlights the first match, and emits `findresult` with the total occurrence count; clearing it (empty string / `null`) clears the highlight. Reactive so it works uniformly across all six targets (an Angular child-component `ref` cannot reach the `$expose` handle from a template event handler — the same reason `page` is a two-way model rather than a handle call).
101+
*/
102+
@property({ type: Object }) query: unknown = undefined;
99103
/**
100104
* Raw `getDocument` `DocumentInitParameters` passthrough — spread **before** the curated keys (explicit `src` / `password` win). For `cMapUrl`, `httpHeaders`, `withCredentials`, etc.
101105
*/
@@ -185,6 +189,11 @@ private __rozieFirstUpdateDone = false;
185189
})(__watchVal); }
186190
if (this.__rozieFirstUpdateDone && (changedProperties.has('renderAllPages'))) { const __watchVal = (() => this.renderAllPages)(); (() => this.renderView())(); }
187191
if (this.__rozieFirstUpdateDone && (changedProperties.has('textLayer'))) { const __watchVal = (() => this.textLayer)(); (() => this.renderView())(); }
192+
if (this.__rozieFirstUpdateDone && (changedProperties.has('query'))) { const __watchVal = (() => this.query)(); ((v: any) => {
193+
if (v == null) return;
194+
const q = String(v);
195+
if (q) this.find(q);else this.clearFind();
196+
})(__watchVal); }
188197
this.__rozieFirstUpdateDone = true;
189198
}
190199

@@ -648,7 +657,7 @@ private __rozieFirstUpdateDone = false;
648657
* (explicit `attribute:`) AND lowercased property name (Lit's default).
649658
*/
650659
private get $attrs(): Record<string, string> {
651-
const __skip = new Set<string>(['src', 'page', 'scale', 'rotation', 'worker-src', 'workersrc', 'standard-font-data-url', 'standardfontdataurl', 'render-all-pages', 'renderallpages', 'text-layer', 'textlayer', 'password', 'options']);
660+
const __skip = new Set<string>(['src', 'page', 'scale', 'rotation', 'worker-src', 'workersrc', 'standard-font-data-url', 'standardfontdataurl', 'render-all-pages', 'renderallpages', 'text-layer', 'textlayer', 'password', 'query', 'options']);
652661
const out: Record<string, string> = {};
653662
for (const a of Array.from(this.attributes)) {
654663
if (__skip.has(a.name)) continue;

packages/ui/pdf/packages/react/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export function Demo() {
4646
| `renderAllPages` | `Boolean` | `false` | | |
4747
| `textLayer` | `Boolean` | `true` | | |
4848
| `password` | `unknown` | `undefined` | | |
49+
| `query` | `unknown` | `undefined` | | |
4950
| `options` | `Object` | `{}` | | |
5051

5152
## Events

packages/ui/pdf/packages/react/src/PdfViewer.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ export interface PdfViewerProps {
4343
* Password for an encrypted PDF. If the document is encrypted and no (or a wrong) password is set, the `passwordrequest` event fires with `{ reason }`. Changing it reloads the document.
4444
*/
4545
password?: unknown;
46+
/**
47+
* A reactive search query — the **controlled** alternative to the imperative `find()` handle. Setting it to a non-empty string scans every page, navigates to + coarse-highlights the first match, and emits `findresult` with the total occurrence count; clearing it (empty string / `null`) clears the highlight. Reactive so it works uniformly across all six targets (an Angular child-component `ref` cannot reach the `$expose` handle from a template event handler — the same reason `page` is a two-way model rather than a handle call).
48+
*/
49+
query?: unknown;
4650
/**
4751
* Raw `getDocument` `DocumentInitParameters` passthrough — spread **before** the curated keys (explicit `src` / `password` win). For `cMapUrl`, `httpHeaders`, `withCredentials`, etc.
4852
*/

packages/ui/pdf/packages/react/src/PdfViewer.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ interface PdfViewerProps {
4444
* Password for an encrypted PDF. If the document is encrypted and no (or a wrong) password is set, the `passwordrequest` event fires with `{ reason }`. Changing it reloads the document.
4545
*/
4646
password?: unknown;
47+
/**
48+
* A reactive search query — the **controlled** alternative to the imperative `find()` handle. Setting it to a non-empty string scans every page, navigates to + coarse-highlights the first match, and emits `findresult` with the total occurrence count; clearing it (empty string / `null`) clears the highlight. Reactive so it works uniformly across all six targets (an Angular child-component `ref` cannot reach the `$expose` handle from a template event handler — the same reason `page` is a two-way model rather than a handle call).
49+
*/
50+
query?: unknown;
4751
/**
4852
* Raw `getDocument` `DocumentInitParameters` passthrough — spread **before** the curated keys (explicit `src` / `password` win). For `cMapUrl`, `httpHeaders`, `withCredentials`, etc.
4953
*/
@@ -81,7 +85,7 @@ export interface PdfViewerHandle {
8185

8286
const PdfViewer = forwardRef<PdfViewerHandle, PdfViewerProps>(function PdfViewer(_props: PdfViewerProps, ref): JSX.Element {
8387
const __defaultOptions = useState(() => (() => ({}))())[0];
84-
const props: Omit<PdfViewerProps, 'src' | 'scale' | 'rotation' | 'workerSrc' | 'standardFontDataUrl' | 'renderAllPages' | 'textLayer' | 'password' | 'options'> & { src: unknown; scale: number; rotation: number; workerSrc: string; standardFontDataUrl: string; renderAllPages: boolean; textLayer: boolean; password: unknown; options: Record<string, any> } = {
88+
const props: Omit<PdfViewerProps, 'src' | 'scale' | 'rotation' | 'workerSrc' | 'standardFontDataUrl' | 'renderAllPages' | 'textLayer' | 'password' | 'query' | 'options'> & { src: unknown; scale: number; rotation: number; workerSrc: string; standardFontDataUrl: string; renderAllPages: boolean; textLayer: boolean; password: unknown; query: unknown; options: Record<string, any> } = {
8589
..._props,
8690
src: _props.src ?? undefined,
8791
scale: _props.scale ?? 1,
@@ -91,11 +95,12 @@ const PdfViewer = forwardRef<PdfViewerHandle, PdfViewerProps>(function PdfViewer
9195
renderAllPages: _props.renderAllPages ?? false,
9296
textLayer: _props.textLayer ?? true,
9397
password: _props.password ?? undefined,
98+
query: _props.query ?? undefined,
9499
options: _props.options ?? __defaultOptions,
95100
};
96101
const attrs: Record<string, unknown> = (() => {
97-
const { src, page, scale, rotation, workerSrc, standardFontDataUrl, renderAllPages, textLayer, password, options, defaultValue, onPageChange, defaultPage, ...rest } = _props as PdfViewerProps & Record<string, unknown>;
98-
void src; void page; void scale; void rotation; void workerSrc; void standardFontDataUrl; void renderAllPages; void textLayer; void password; void options; void defaultValue; void onPageChange; void defaultPage;
102+
const { src, page, scale, rotation, workerSrc, standardFontDataUrl, renderAllPages, textLayer, password, query, options, defaultValue, onPageChange, defaultPage, ...rest } = _props as PdfViewerProps & Record<string, unknown>;
103+
void src; void page; void scale; void rotation; void workerSrc; void standardFontDataUrl; void renderAllPages; void textLayer; void password; void query; void options; void defaultValue; void onPageChange; void defaultPage;
99104
return rest;
100105
})();
101106
const cancelled = useRef(false);
@@ -139,6 +144,7 @@ const PdfViewer = forwardRef<PdfViewerHandle, PdfViewerProps>(function PdfViewer
139144
const _watch9First = useRef(true);
140145
const _watch10First = useRef(true);
141146
const _watch11First = useRef(true);
147+
const _watch12First = useRef(true);
142148

143149
function buildSource() {
144150
let cfg: any = null;
@@ -602,6 +608,13 @@ const PdfViewer = forwardRef<PdfViewerHandle, PdfViewerProps>(function PdfViewer
602608
if (_watch11First.current) { _watch11First.current = false; return; }
603609
renderView();
604610
}, [props.textLayer]); // eslint-disable-line react-hooks/exhaustive-deps
611+
useEffect(() => {
612+
if (_watch12First.current) { _watch12First.current = false; return; }
613+
const v = props.query;
614+
if (v == null) return;
615+
const q = String(v);
616+
if (q) find(q);else clearFind();
617+
}, [props.query]); // eslint-disable-line react-hooks/exhaustive-deps
605618

606619
const _rozieExposeRef = useRef({ getDocument, getPageCount, goToPage, nextPage, prevPage, setScale, zoomIn, zoomOut, fitWidth, fitPage, rotateCW, rotateCCW, download, getMetadata, getOutline, find, findNext, findPrev, clearFind });
607620
_rozieExposeRef.current = { getDocument, getPageCount, goToPage, nextPage, prevPage, setScale, zoomIn, zoomOut, fitWidth, fitPage, rotateCW, rotateCCW, download, getMetadata, getOutline, find, findNext, findPrev, clearFind };

packages/ui/pdf/packages/solid/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export function Demo() {
4646
| `renderAllPages` | `Boolean` | `false` | | |
4747
| `textLayer` | `Boolean` | `true` | | |
4848
| `password` | `unknown` | `undefined` | | |
49+
| `query` | `unknown` | `undefined` | | |
4950
| `options` | `Object` | `{}` | | |
5051

5152
## Events

0 commit comments

Comments
 (0)