@gmickel/gno 1.29.6 → 1.30.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +15 -5
  2. package/browser-extension/artifacts/gno-browser-clipper-v1.30.4.zip +0 -0
  3. package/browser-extension/artifacts/gno-browser-clipper-v1.30.4.zip.sha256 +1 -0
  4. package/browser-extension/dist/chunk-627emwpj.js +75 -0
  5. package/browser-extension/dist/manifest.json +1 -1
  6. package/browser-extension/dist/preview.html +1 -1
  7. package/browser-extension/dist/service-worker.js +47 -22
  8. package/package.json +42 -39
  9. package/src/converters/adapters/officeparser/adapter.ts +7 -3
  10. package/src/core/network-boundary-inventory.ts +51 -0
  11. package/src/core/project-profile.ts +2 -2
  12. package/src/publish/encrypted-export.ts +1 -1
  13. package/src/serve/AGENTS.md +5 -1
  14. package/src/serve/CLAUDE.md +5 -1
  15. package/src/serve/fn112-routes.ts +232 -0
  16. package/src/serve/pdfjs-assets.ts +391 -0
  17. package/src/serve/public/components/ai-elements/code-block.tsx +28 -10
  18. package/src/serve/public/components/pdf/PdfPageView.tsx +428 -0
  19. package/src/serve/public/components/pdf/PdfToolbar.tsx +384 -0
  20. package/src/serve/public/components/pdf/PdfViewer.tsx +539 -0
  21. package/src/serve/public/components/pdf/pdf-viewer-deps.tsx +94 -0
  22. package/src/serve/public/globals.built.css +2 -2
  23. package/src/serve/public/globals.css +113 -0
  24. package/src/serve/public/hooks/use-pdf-document.ts +199 -0
  25. package/src/serve/public/hooks/use-pdf-pages.ts +1197 -0
  26. package/src/serve/public/lib/doc-asset-url.ts +57 -0
  27. package/src/serve/public/lib/math-sum-precise.ts +34 -0
  28. package/src/serve/public/lib/pdf.ts +772 -0
  29. package/src/serve/public/pages/DocView.tsx +295 -39
  30. package/src/serve/public/pages/doc-pdf-viewer.tsx +7 -0
  31. package/src/serve/routes/api.ts +154 -14
  32. package/src/serve/server.ts +190 -37
  33. package/src/serve/spa-bundle-source.ts +99 -0
  34. package/browser-extension/artifacts/gno-browser-clipper-v1.29.6.zip +0 -0
  35. package/browser-extension/artifacts/gno-browser-clipper-v1.29.6.zip.sha256 +0 -1
  36. package/browser-extension/dist/chunk-b2zm0jjd.js +0 -50
@@ -0,0 +1,539 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useLayoutEffect,
5
+ useRef,
6
+ useState,
7
+ type KeyboardEvent as ReactKeyboardEvent,
8
+ type ReactNode,
9
+ } from "react";
10
+
11
+ import type { FitMode } from "../../hooks/use-pdf-pages";
12
+
13
+ import {
14
+ clampZoom,
15
+ DEFAULT_ZOOM,
16
+ stepZoom,
17
+ type PdfFallbackReason,
18
+ } from "../../lib/pdf";
19
+ import { Button } from "../ui/button";
20
+ import { usePdfViewerInternalDeps } from "./pdf-viewer-deps";
21
+ import { PdfPageView } from "./PdfPageView";
22
+ import { PdfToolbar } from "./PdfToolbar";
23
+
24
+ /**
25
+ * Exact production contract (task .4 / R4).
26
+ * No hook-injection props — tests use pdf-viewer-deps TestDepsProvider.
27
+ */
28
+ export type PdfViewerProps = {
29
+ assetUrl: string | null;
30
+ downloadUrl: string;
31
+ /**
32
+ * Exact predicate computed by DocView:
33
+ * contentAvailable && non-empty trimmed content string.
34
+ * Viewer never re-derives this.
35
+ */
36
+ extractedTextAvailable: boolean;
37
+ onFallback: (reason: PdfFallbackReason) => void;
38
+ };
39
+
40
+ type StatePanelProps = {
41
+ testId: string;
42
+ eyebrow: string;
43
+ body: string;
44
+ downloadUrl: string;
45
+ /** loading/empty → status; error cards → alert */
46
+ role: "status" | "alert";
47
+ onRetry?: () => void;
48
+ actions?: ReactNode;
49
+ };
50
+
51
+ /**
52
+ * Flat state treatment inside the recessed well — no Card chrome
53
+ * (no rounded border / tinted surface / shadow box).
54
+ */
55
+ function StatePanel({
56
+ testId,
57
+ eyebrow,
58
+ body,
59
+ downloadUrl,
60
+ role,
61
+ onRetry,
62
+ }: StatePanelProps) {
63
+ return (
64
+ <div
65
+ className="mx-auto flex max-w-md flex-col items-center gap-3 py-10 text-center"
66
+ data-testid={testId}
67
+ role={role}
68
+ >
69
+ <p className="font-mono text-[10px] text-muted-foreground/60 uppercase tracking-[0.15em]">
70
+ {eyebrow}
71
+ </p>
72
+ <p className="text-[13px] text-foreground/90 leading-relaxed">{body}</p>
73
+ <div className="flex flex-wrap items-center justify-center gap-2 pt-1">
74
+ {onRetry ? (
75
+ <Button
76
+ className="cursor-pointer focus-visible:ring-primary/50"
77
+ data-testid="pdf-action-retry"
78
+ onClick={onRetry}
79
+ size="sm"
80
+ type="button"
81
+ variant="outline"
82
+ >
83
+ Try again
84
+ </Button>
85
+ ) : null}
86
+ <Button
87
+ asChild
88
+ className="cursor-pointer focus-visible:ring-primary/50"
89
+ data-testid="pdf-action-download"
90
+ size="sm"
91
+ variant="secondary"
92
+ >
93
+ <a download href={downloadUrl || undefined}>
94
+ Download original
95
+ </a>
96
+ </Button>
97
+ </div>
98
+ </div>
99
+ );
100
+ }
101
+
102
+ function prefersReducedMotion(): boolean {
103
+ if (
104
+ typeof window === "undefined" ||
105
+ typeof window.matchMedia !== "function"
106
+ ) {
107
+ return false;
108
+ }
109
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
110
+ }
111
+
112
+ /**
113
+ * User-facing PDF viewer: instrument rail + page column + designed states.
114
+ * DocView owns the Pages/Text toggle; this shell never renders it.
115
+ */
116
+ export function PdfViewer({
117
+ assetUrl,
118
+ downloadUrl,
119
+ extractedTextAvailable,
120
+ onFallback,
121
+ }: PdfViewerProps) {
122
+ const { usePdfDocument, usePdfPages } = usePdfViewerInternalDeps();
123
+ const docState = usePdfDocument(assetUrl);
124
+ const { status, doc, numPages, error, docId, retry } = docState;
125
+
126
+ const [page, setPage] = useState(1);
127
+ const [zoom, setZoom] = useState(DEFAULT_ZOOM);
128
+ const [fitMode, setFitMode] = useState<FitMode>("width");
129
+ const [genId, setGenId] = useState(1);
130
+ const [containerWidth, setContainerWidth] = useState(0);
131
+ const [containerHeight, setContainerHeight] = useState(0);
132
+
133
+ const columnRef = useRef<HTMLDivElement | null>(null);
134
+ const viewerRef = useRef<HTMLElement | null>(null);
135
+ const fallbackFiredForLoadRef = useRef<string | null>(null);
136
+ const onFallbackRef = useRef(onFallback);
137
+ onFallbackRef.current = onFallback;
138
+
139
+ const bumpGen = useCallback(() => {
140
+ setGenId((g) => g + 1);
141
+ }, []);
142
+
143
+ // Measure page column for fit modes
144
+ useLayoutEffect(() => {
145
+ const el = columnRef.current;
146
+ if (!el || typeof ResizeObserver === "undefined") {
147
+ return;
148
+ }
149
+ const ro = new ResizeObserver((entries) => {
150
+ const entry = entries[0];
151
+ if (!entry) {
152
+ return;
153
+ }
154
+ const { width, height } = entry.contentRect;
155
+ setContainerWidth(Math.max(0, Math.floor(width)));
156
+ setContainerHeight(Math.max(0, Math.floor(height)));
157
+ });
158
+ ro.observe(el);
159
+ setContainerWidth(Math.max(0, el.clientWidth));
160
+ setContainerHeight(Math.max(0, el.clientHeight));
161
+ return () => {
162
+ ro.disconnect();
163
+ };
164
+ }, [status, numPages]);
165
+
166
+ // Reset page/zoom when a new document loads
167
+ useEffect(() => {
168
+ if (status === "ready" && docId) {
169
+ setPage(1);
170
+ setZoom(DEFAULT_ZOOM);
171
+ setFitMode("width");
172
+ setGenId(1);
173
+ fallbackFiredForLoadRef.current = null;
174
+ }
175
+ }, [docId, status]);
176
+
177
+ // Real hook composition: genId from zoom/fit commits drives task .3 cancel path
178
+ const pages = usePdfPages({
179
+ doc: status === "ready" ? doc : null,
180
+ docId: status === "ready" ? docId : null,
181
+ numPages: status === "ready" ? numPages : 0,
182
+ zoom,
183
+ fitMode,
184
+ containerWidth,
185
+ containerHeight,
186
+ genId,
187
+ });
188
+ const viewerError = status === "error" ? error : pages.error;
189
+
190
+ // Fallback: exactly once per failed load/page attempt when extracted text exists.
191
+ useEffect(() => {
192
+ if (!viewerError || !extractedTextAvailable) {
193
+ return;
194
+ }
195
+ const key = `${docId ?? assetUrl ?? "err"}:${viewerError}`;
196
+ if (fallbackFiredForLoadRef.current === key) {
197
+ return;
198
+ }
199
+ fallbackFiredForLoadRef.current = key;
200
+ onFallbackRef.current(viewerError);
201
+ }, [viewerError, extractedTextAvailable, docId, assetUrl]);
202
+
203
+ const firstVisiblePage = pages.slots.find((slot) => slot.visible)?.pageNumber;
204
+
205
+ // Native scrolling is intentionally left to the browser. Keep toolbar and
206
+ // subsequent prev/next actions anchored to the first page currently visible
207
+ // in the column instead of the last explicitly requested page.
208
+ useEffect(() => {
209
+ if (firstVisiblePage === undefined || numPages < 1) {
210
+ return;
211
+ }
212
+ setPage(Math.min(numPages, Math.max(1, firstVisiblePage)));
213
+ }, [firstVisiblePage, numPages]);
214
+
215
+ // Keep page in range when numPages changes
216
+ useEffect(() => {
217
+ if (numPages < 1) {
218
+ return;
219
+ }
220
+ setPage((p) => Math.min(numPages, Math.max(1, p)));
221
+ }, [numPages]);
222
+
223
+ const scrollToPage = useCallback((target: number) => {
224
+ const el = columnRef.current?.querySelector(
225
+ `[data-testid="pdf-page-${target}"]`
226
+ );
227
+ if (!(el instanceof HTMLElement)) {
228
+ return;
229
+ }
230
+ el.scrollIntoView({
231
+ block: "start",
232
+ behavior: prefersReducedMotion() ? "auto" : "smooth",
233
+ });
234
+ }, []);
235
+
236
+ const goToPage = useCallback(
237
+ (next: number) => {
238
+ if (numPages < 1) {
239
+ return false;
240
+ }
241
+ const clamped = Math.min(numPages, Math.max(1, Math.trunc(next)));
242
+ if (clamped === page) {
243
+ return false;
244
+ }
245
+ setPage(clamped);
246
+ requestAnimationFrame(() => {
247
+ scrollToPage(clamped);
248
+ });
249
+ return true;
250
+ },
251
+ [numPages, page, scrollToPage]
252
+ );
253
+
254
+ // Boundary: when step cannot change zoom, no state/gen change (any fit mode)
255
+ const zoomIn = useCallback(() => {
256
+ const next = stepZoom(zoom, 1);
257
+ if (next === zoom) {
258
+ return false;
259
+ }
260
+ setZoom(next);
261
+ setFitMode("custom");
262
+ bumpGen();
263
+ return true;
264
+ }, [zoom, bumpGen]);
265
+
266
+ /**
267
+ * Commit an exact zoom level from the zoom-level combobox. Does exactly what
268
+ * zoomIn/zoomOut already do — no new zoom math. Matching the accepted
269
+ * boundary rule, an already-current level in `custom` fit mode makes no state
270
+ * or generation change.
271
+ */
272
+ const zoomTo = useCallback(
273
+ (level: number) => {
274
+ const next = clampZoom(level);
275
+ if (next === zoom && fitMode === "custom") {
276
+ return false;
277
+ }
278
+ setZoom(next);
279
+ setFitMode("custom");
280
+ bumpGen();
281
+ return true;
282
+ },
283
+ [zoom, fitMode, bumpGen]
284
+ );
285
+
286
+ const zoomOut = useCallback(() => {
287
+ const next = stepZoom(zoom, -1);
288
+ if (next === zoom) {
289
+ return false;
290
+ }
291
+ setZoom(next);
292
+ setFitMode("custom");
293
+ bumpGen();
294
+ return true;
295
+ }, [zoom, bumpGen]);
296
+
297
+ const zoomReset = useCallback(() => {
298
+ if (zoom === DEFAULT_ZOOM && fitMode === "custom") {
299
+ return false;
300
+ }
301
+ setZoom(DEFAULT_ZOOM);
302
+ setFitMode("custom");
303
+ bumpGen();
304
+ return true;
305
+ }, [zoom, fitMode, bumpGen]);
306
+
307
+ const setFit = useCallback(
308
+ (mode: "width" | "page") => {
309
+ if (fitMode === mode) {
310
+ return false;
311
+ }
312
+ setFitMode(mode);
313
+ bumpGen();
314
+ return true;
315
+ },
316
+ [fitMode, bumpGen]
317
+ );
318
+
319
+ const handleRetry = useCallback(() => {
320
+ fallbackFiredForLoadRef.current = null;
321
+ retry();
322
+ }, [retry]);
323
+
324
+ const handleKeyDown = useCallback(
325
+ (e: ReactKeyboardEvent<HTMLElement>) => {
326
+ const target = e.target as HTMLElement | null;
327
+ const tag = target?.tagName?.toLowerCase() ?? "";
328
+ const inInput =
329
+ tag === "input" ||
330
+ tag === "textarea" ||
331
+ target?.isContentEditable === true;
332
+
333
+ if (
334
+ e.key === "ArrowUp" ||
335
+ e.key === "ArrowDown" ||
336
+ e.key === "Home" ||
337
+ e.key === "End" ||
338
+ e.key === " " ||
339
+ e.key === "Spacebar"
340
+ ) {
341
+ return;
342
+ }
343
+
344
+ if (inInput) {
345
+ return;
346
+ }
347
+
348
+ const controlsLive = status === "ready" && numPages > 0;
349
+
350
+ let handled = false;
351
+
352
+ if (e.key === "PageDown" || e.key === "ArrowRight") {
353
+ if (controlsLive && page < numPages) {
354
+ handled = goToPage(page + 1);
355
+ }
356
+ } else if (e.key === "PageUp" || e.key === "ArrowLeft") {
357
+ if (controlsLive && page > 1) {
358
+ handled = goToPage(page - 1);
359
+ }
360
+ } else if (e.key === "+" || e.key === "=") {
361
+ if (controlsLive) {
362
+ handled = zoomIn();
363
+ }
364
+ } else if (e.key === "-" || e.key === "_") {
365
+ if (controlsLive) {
366
+ handled = zoomOut();
367
+ }
368
+ } else if (e.key === "0") {
369
+ if (controlsLive) {
370
+ handled = zoomReset();
371
+ }
372
+ }
373
+
374
+ if (handled) {
375
+ e.preventDefault();
376
+ }
377
+ },
378
+ [status, numPages, page, goToPage, zoomIn, zoomOut, zoomReset]
379
+ );
380
+
381
+ // ── Designed states (real usePdfDocument semantics) ──────────────────
382
+ // loading: only while status === "loading"
383
+ // empty: ready + zero pages (firstPageReady is false for zero-page docs)
384
+ // progressive: ready + numPages > 0
385
+ // error: status === "error"
386
+ const showLoading = status === "loading";
387
+ const showEmpty = status === "ready" && numPages === 0;
388
+ const showProgressive =
389
+ status === "ready" && numPages > 0 && viewerError === null;
390
+ const showError = viewerError !== null;
391
+ const showErrorPanel = showError && !extractedTextAvailable;
392
+
393
+ const errorPanel = (() => {
394
+ if (!showErrorPanel || !viewerError) {
395
+ return null;
396
+ }
397
+ switch (viewerError) {
398
+ case "corrupt":
399
+ return (
400
+ <StatePanel
401
+ body="This PDF could not be rendered. Download the original to read it."
402
+ downloadUrl={downloadUrl}
403
+ eyebrow="CANNOT RENDER"
404
+ onRetry={handleRetry}
405
+ role="alert"
406
+ testId="pdf-state-corrupt"
407
+ />
408
+ );
409
+ case "password":
410
+ return (
411
+ <StatePanel
412
+ body="This PDF is password protected. Download the original to open it in a PDF reader."
413
+ downloadUrl={downloadUrl}
414
+ eyebrow="PASSWORD PROTECTED"
415
+ role="alert"
416
+ testId="pdf-state-password"
417
+ />
418
+ );
419
+ case "network":
420
+ return (
421
+ <StatePanel
422
+ body="The document could not be loaded from this session. Try again, or download the original."
423
+ downloadUrl={downloadUrl}
424
+ eyebrow="COULD NOT LOAD"
425
+ onRetry={handleRetry}
426
+ role="alert"
427
+ testId="pdf-state-network"
428
+ />
429
+ );
430
+ case "bootstrap":
431
+ return (
432
+ <StatePanel
433
+ body="The PDF viewer could not start in this window. Download the original to read it."
434
+ downloadUrl={downloadUrl}
435
+ eyebrow="VIEWER UNAVAILABLE"
436
+ onRetry={handleRetry}
437
+ role="alert"
438
+ testId="pdf-state-bootstrap"
439
+ />
440
+ );
441
+ default:
442
+ return null;
443
+ }
444
+ })();
445
+
446
+ const toolbarDisabled = showEmpty || showLoading || showError;
447
+
448
+ return (
449
+ <section
450
+ aria-label="PDF viewer"
451
+ className="gno-pdf-viewer relative flex flex-col rounded-lg border border-border/40 bg-gradient-to-br from-background to-muted/10 shadow-inner outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
452
+ data-testid="pdf-viewer"
453
+ onKeyDown={handleKeyDown}
454
+ ref={viewerRef}
455
+ tabIndex={0}
456
+ >
457
+ <PdfToolbar
458
+ disabled={toolbarDisabled || numPages < 1}
459
+ downloadUrl={downloadUrl}
460
+ fitMode={fitMode}
461
+ numPages={showProgressive ? numPages : 0}
462
+ onFitMode={(m) => {
463
+ setFit(m);
464
+ }}
465
+ onPageChange={(p) => {
466
+ goToPage(p);
467
+ }}
468
+ onZoomIn={() => {
469
+ zoomIn();
470
+ }}
471
+ onZoomOut={() => {
472
+ zoomOut();
473
+ }}
474
+ onZoomTo={(level) => {
475
+ zoomTo(level);
476
+ }}
477
+ page={showProgressive ? page : 0}
478
+ zoom={zoom}
479
+ />
480
+
481
+ <div
482
+ className="gno-pdf-page-column h-[min(78dvh,1100px)] min-h-[420px] overflow-y-auto px-4 py-6"
483
+ data-testid="pdf-page-column"
484
+ ref={columnRef}
485
+ >
486
+ {showLoading ? (
487
+ <div
488
+ className="mx-auto flex max-w-md flex-col items-center gap-3 py-10 text-center"
489
+ data-testid="pdf-state-loading"
490
+ role="status"
491
+ >
492
+ <p className="font-mono text-[10px] text-muted-foreground/60 uppercase tracking-[0.15em]">
493
+ LOADING
494
+ </p>
495
+ <p className="text-[13px] text-foreground/90 leading-relaxed">
496
+ Preparing document…
497
+ </p>
498
+ </div>
499
+ ) : null}
500
+
501
+ {showEmpty ? (
502
+ <StatePanel
503
+ body="This PDF has no pages."
504
+ downloadUrl={downloadUrl}
505
+ eyebrow="EMPTY DOCUMENT"
506
+ role="status"
507
+ testId="pdf-state-empty"
508
+ />
509
+ ) : null}
510
+
511
+ {errorPanel}
512
+
513
+ {showProgressive ? (
514
+ <div className="mx-auto flex w-fit flex-col items-center gap-6">
515
+ {pages.slots.map((slot) => (
516
+ <PdfPageView
517
+ key={slot.pageNumber}
518
+ active={slot.active}
519
+ doc={doc}
520
+ height={slot.height}
521
+ onMount={pages.observePage}
522
+ onInternalNavigate={goToPage}
523
+ onRender={pages.ensureRendered}
524
+ pageNumber={slot.pageNumber}
525
+ rendered={slot.rendered}
526
+ scale={pages.scale}
527
+ width={slot.width}
528
+ />
529
+ ))}
530
+ </div>
531
+ ) : null}
532
+ </div>
533
+ </section>
534
+ );
535
+ }
536
+
537
+ // Re-export for tests / consumers
538
+ export type { FitMode };
539
+ export { clampZoom, stepZoom, DEFAULT_ZOOM };
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Internal dependency boundary for PdfViewer.
3
+ *
4
+ * Production always uses the real usePdfDocument / usePdfPages hooks.
5
+ * Tests may wrap the viewer in PdfViewerTestDepsProvider to substitute
6
+ * lower-level seams or controlled hook adapters — never via PdfViewer props.
7
+ *
8
+ * Not a product barrel export. Import only from tests or PdfViewer itself.
9
+ */
10
+
11
+ import { createContext, useContext, type ReactNode } from "react";
12
+
13
+ import {
14
+ usePdfDocument as defaultUsePdfDocument,
15
+ type UsePdfDocumentDeps,
16
+ type UsePdfDocumentResult,
17
+ } from "../../hooks/use-pdf-document";
18
+ import {
19
+ usePdfPages as defaultUsePdfPages,
20
+ type UsePdfPagesOptions,
21
+ type UsePdfPagesResult,
22
+ } from "../../hooks/use-pdf-pages";
23
+
24
+ export type PdfViewerDocumentHook = (
25
+ url: string | null
26
+ ) => UsePdfDocumentResult;
27
+
28
+ export type PdfViewerPagesHook = (
29
+ options: UsePdfPagesOptions
30
+ ) => UsePdfPagesResult;
31
+
32
+ export type PdfViewerInternalDeps = {
33
+ usePdfDocument: PdfViewerDocumentHook;
34
+ usePdfPages: PdfViewerPagesHook;
35
+ };
36
+
37
+ const productionDeps: PdfViewerInternalDeps = {
38
+ usePdfDocument: (url) => defaultUsePdfDocument(url),
39
+ usePdfPages: (options) => defaultUsePdfPages(options),
40
+ };
41
+
42
+ const PdfViewerDepsContext =
43
+ createContext<PdfViewerInternalDeps>(productionDeps);
44
+
45
+ /** Used only by PdfViewer — resolves production defaults or test overrides. */
46
+ export function usePdfViewerInternalDeps(): PdfViewerInternalDeps {
47
+ return useContext(PdfViewerDepsContext);
48
+ }
49
+
50
+ /**
51
+ * Test-only harness. Production call sites must not use this.
52
+ * Keeps PdfViewerProps to the exact four-prop contract.
53
+ */
54
+ export function PdfViewerTestDepsProvider({
55
+ deps,
56
+ children,
57
+ }: {
58
+ deps: Partial<PdfViewerInternalDeps>;
59
+ children: ReactNode;
60
+ }) {
61
+ const value: PdfViewerInternalDeps = {
62
+ usePdfDocument: deps.usePdfDocument ?? productionDeps.usePdfDocument,
63
+ usePdfPages: deps.usePdfPages ?? productionDeps.usePdfPages,
64
+ };
65
+ return (
66
+ <PdfViewerDepsContext.Provider value={value}>
67
+ {children}
68
+ </PdfViewerDepsContext.Provider>
69
+ );
70
+ }
71
+
72
+ /** Real document hook with lower-level facade deps (getDocument, metrics, …). */
73
+ export function createDocumentHookWithDeps(
74
+ documentDeps: UsePdfDocumentDeps
75
+ ): PdfViewerDocumentHook {
76
+ return (url: string | null) => defaultUsePdfDocument(url, documentDeps);
77
+ }
78
+
79
+ /** Real pages hook with optional lower-level IO/metrics seams. */
80
+ export function createPagesHookWithDeps(
81
+ pagesDeps: Partial<
82
+ Pick<
83
+ UsePdfPagesOptions,
84
+ | "getPdfMetrics"
85
+ | "computeEffectiveScale"
86
+ | "isRenderingCancelled"
87
+ | "IntersectionObserverImpl"
88
+ | "devicePixelRatio"
89
+ >
90
+ > = {}
91
+ ): PdfViewerPagesHook {
92
+ return (options: UsePdfPagesOptions) =>
93
+ defaultUsePdfPages({ ...options, ...pagesDeps });
94
+ }