@gmickel/gno 1.29.6 → 1.30.1

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.
@@ -451,3 +451,116 @@ mark {
451
451
  scroll-behavior: auto !important;
452
452
  }
453
453
  }
454
+
455
+ /* ── Native PDF viewer (fn-112) ───────────────────────────────────────────── */
456
+
457
+ .gno-pdf-viewer {
458
+ isolation: isolate;
459
+ }
460
+
461
+ .gno-pdf-page-column {
462
+ scrollbar-gutter: stable;
463
+ }
464
+
465
+ .gno-pdf-page {
466
+ position: relative;
467
+ margin: 0 auto 1rem;
468
+ max-width: 100%;
469
+ background: hsl(var(--muted) / 0.2);
470
+ border: 1px solid hsl(var(--border) / 0.4);
471
+ border-radius: 0.25rem;
472
+ box-shadow:
473
+ 0 1px 2px hsl(var(--background) / 0.4),
474
+ 0 8px 24px -12px hsl(var(--foreground) / 0.18);
475
+ /* pdfjs v5 TextLayer CSS contract on the page wrapper (inherits to layers) */
476
+ --scale-factor: 1;
477
+ --user-unit: 1;
478
+ --total-scale-factor: calc(var(--scale-factor) * var(--user-unit, 1));
479
+ --scale-round-x: 1px;
480
+ --scale-round-y: 1px;
481
+ }
482
+
483
+ .gno-pdf-page-inner {
484
+ position: relative;
485
+ /* Existing Scholarly Dusk semantic surface token — no invented paper color */
486
+ background: hsl(var(--card));
487
+ overflow: hidden;
488
+ }
489
+
490
+ .gno-pdf-canvas {
491
+ display: block;
492
+ width: 100%;
493
+ height: auto;
494
+ vertical-align: top;
495
+ }
496
+
497
+ /* Text layer: transparent text over canvas, Scholarly Dusk selection */
498
+ .gno-pdf-text-layer,
499
+ .textLayer {
500
+ position: absolute;
501
+ inset: 0;
502
+ overflow: clip;
503
+ opacity: 1;
504
+ line-height: 1;
505
+ text-size-adjust: none;
506
+ forced-color-adjust: none;
507
+ transform-origin: 0 0;
508
+ z-index: 2;
509
+ }
510
+
511
+ .gno-pdf-text-layer :is(span, br),
512
+ .textLayer :is(span, br) {
513
+ color: transparent;
514
+ position: absolute;
515
+ white-space: pre;
516
+ cursor: text;
517
+ transform-origin: 0% 0%;
518
+ }
519
+
520
+ .gno-pdf-text-layer ::selection,
521
+ .textLayer ::selection {
522
+ background: hsl(var(--primary) / 0.35);
523
+ color: transparent;
524
+ }
525
+
526
+ .gno-pdf-annotation-layer {
527
+ position: absolute;
528
+ inset: 0;
529
+ z-index: 3;
530
+ pointer-events: none;
531
+ }
532
+
533
+ .gno-pdf-annotation-link,
534
+ .gno-pdf-annotation-inert {
535
+ position: absolute;
536
+ pointer-events: auto;
537
+ background: transparent;
538
+ border: none;
539
+ padding: 0;
540
+ cursor: pointer;
541
+ }
542
+
543
+ .gno-pdf-annotation-inert {
544
+ cursor: default;
545
+ pointer-events: none;
546
+ }
547
+
548
+ .gno-pdf-page[data-rendered="false"] .gno-pdf-page-inner {
549
+ animation: gno-pdf-pulse 1.6s ease-in-out infinite;
550
+ }
551
+
552
+ @keyframes gno-pdf-pulse {
553
+ 0%,
554
+ 100% {
555
+ opacity: 1;
556
+ }
557
+ 50% {
558
+ opacity: 0.85;
559
+ }
560
+ }
561
+
562
+ @media (prefers-reduced-motion: reduce) {
563
+ .gno-pdf-page[data-rendered="false"] .gno-pdf-page-inner {
564
+ animation: none;
565
+ }
566
+ }
@@ -0,0 +1,227 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+
3
+ import {
4
+ classifyPdfError as defaultClassifyPdfError,
5
+ getDocument as defaultGetDocument,
6
+ getPdfMetrics as defaultGetPdfMetrics,
7
+ type GnoDocumentLoadingTask,
8
+ type PdfFallbackReason,
9
+ type PDFDocumentProxy,
10
+ } from "../lib/pdf";
11
+
12
+ export type PdfDocumentStatus = "loading" | "ready" | "error";
13
+
14
+ export type UsePdfDocumentResult = {
15
+ status: PdfDocumentStatus;
16
+ doc: PDFDocumentProxy | null;
17
+ numPages: number;
18
+ firstPageReady: boolean;
19
+ error: PdfFallbackReason | null;
20
+ errorMessage: string | null;
21
+ docId: string | null;
22
+ retry: () => void;
23
+ };
24
+
25
+ /**
26
+ * Optional test doubles only. Production call sites pass nothing and use the
27
+ * facade defaults. Not a public product API surface.
28
+ */
29
+ export type UsePdfDocumentDeps = {
30
+ getDocument?: typeof defaultGetDocument;
31
+ classifyPdfError?: typeof defaultClassifyPdfError;
32
+ getPdfMetrics?: typeof defaultGetPdfMetrics;
33
+ };
34
+
35
+ type LoadOwnership = {
36
+ /** Minted opaque id for this load attempt. */
37
+ docId: string;
38
+ task: GnoDocumentLoadingTask;
39
+ /** Set only after promise resolves into viewer ownership. */
40
+ viewerDoc: PDFDocumentProxy | null;
41
+ /** True once teardown for this load has run (idempotent). */
42
+ tornDown: boolean;
43
+ /** True once documentDestroy was emitted (success path only). */
44
+ destroyMetricEmitted: boolean;
45
+ };
46
+
47
+ /**
48
+ * Load a PDF document from a same-origin asset URL.
49
+ *
50
+ * Teardown ownership (I3-04): loadingTask.destroy() owns the transport for a
51
+ * load that never handed a proxy to the viewer. Once the proxy is viewer-owned,
52
+ * we destroy the proxy exactly once and do not also call loadingTask.destroy.
53
+ * documentDestroy is emitted exactly once per successfully loaded viewer
54
+ * instance, never for rejected/never-loaded attempts.
55
+ */
56
+ export function usePdfDocument(
57
+ url: string | null,
58
+ deps: UsePdfDocumentDeps = {}
59
+ ): UsePdfDocumentResult {
60
+ const getDocument = deps.getDocument ?? defaultGetDocument;
61
+ const classifyPdfError = deps.classifyPdfError ?? defaultClassifyPdfError;
62
+ const getPdfMetrics = deps.getPdfMetrics ?? defaultGetPdfMetrics;
63
+
64
+ const [status, setStatus] = useState<PdfDocumentStatus>("loading");
65
+ const [doc, setDoc] = useState<PDFDocumentProxy | null>(null);
66
+ const [numPages, setNumPages] = useState(0);
67
+ const [firstPageReady, setFirstPageReady] = useState(false);
68
+ const [error, setError] = useState<PdfFallbackReason | null>(null);
69
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
70
+ const [docId, setDocId] = useState<string | null>(null);
71
+ const [retryToken, setRetryToken] = useState(0);
72
+
73
+ const generationRef = useRef(0);
74
+ const ownershipRef = useRef<LoadOwnership | null>(null);
75
+
76
+ const retry = useCallback(() => {
77
+ setRetryToken((t) => t + 1);
78
+ }, []);
79
+
80
+ useEffect(() => {
81
+ if (!url) {
82
+ setStatus("error");
83
+ setError("network");
84
+ setErrorMessage("No document URL");
85
+ setDoc(null);
86
+ setNumPages(0);
87
+ setFirstPageReady(false);
88
+ setDocId(null);
89
+ return;
90
+ }
91
+
92
+ generationRef.current += 1;
93
+ const generation = generationRef.current;
94
+ const metrics = getPdfMetrics();
95
+
96
+ setStatus("loading");
97
+ setDoc(null);
98
+ setNumPages(0);
99
+ setFirstPageReady(false);
100
+ setError(null);
101
+ setErrorMessage(null);
102
+
103
+ const loadingTask = getDocument({ url });
104
+ const instanceDocId = loadingTask.gnoDocId;
105
+ setDocId(instanceDocId);
106
+
107
+ const ownership: LoadOwnership = {
108
+ docId: instanceDocId,
109
+ task: loadingTask,
110
+ viewerDoc: null,
111
+ tornDown: false,
112
+ destroyMetricEmitted: false,
113
+ };
114
+ ownershipRef.current = ownership;
115
+
116
+ const isStale = (): boolean => {
117
+ if (generation !== generationRef.current) {
118
+ return true;
119
+ }
120
+ if (ownership.tornDown) {
121
+ return true;
122
+ }
123
+ return Boolean((loadingTask as { destroyed?: boolean }).destroyed);
124
+ };
125
+
126
+ /**
127
+ * Idempotent teardown for this load.
128
+ * - Never-loaded / rejected: destroy loading task only; no documentDestroy.
129
+ * - Viewer-owned success: destroy proxy once + documentDestroy once.
130
+ * - Stale late-resolved orphan (never viewer-owned): destroy proxy only.
131
+ */
132
+ const teardown = (opts?: {
133
+ orphanProxy?: PDFDocumentProxy | null;
134
+ reason: "cleanup" | "stale-orphan";
135
+ }): void => {
136
+ if (ownership.tornDown && !opts?.orphanProxy) {
137
+ return;
138
+ }
139
+
140
+ if (opts?.orphanProxy && opts.reason === "stale-orphan") {
141
+ // Resolved after we already tore down the load attempt — destroy the
142
+ // orphan proxy only; never emit documentDestroy (never viewer-owned).
143
+ try {
144
+ void opts.orphanProxy.destroy();
145
+ } catch {
146
+ // ignore
147
+ }
148
+ return;
149
+ }
150
+
151
+ if (ownership.tornDown) {
152
+ return;
153
+ }
154
+ ownership.tornDown = true;
155
+
156
+ const viewerDoc = ownership.viewerDoc;
157
+ ownership.viewerDoc = null;
158
+
159
+ if (viewerDoc) {
160
+ // Viewer owned the proxy: destroy it once. Do not also destroy the
161
+ // loading task (that would double-destroy the same transport).
162
+ try {
163
+ void viewerDoc.destroy();
164
+ } catch {
165
+ // ignore
166
+ }
167
+ if (!ownership.destroyMetricEmitted) {
168
+ ownership.destroyMetricEmitted = true;
169
+ metrics.recordDocumentDestroy({ docId: ownership.docId });
170
+ }
171
+ return;
172
+ }
173
+
174
+ // Never handed a proxy to the viewer — loading task owns the transport.
175
+ try {
176
+ void ownership.task.destroy();
177
+ } catch {
178
+ // ignore
179
+ }
180
+ // No documentDestroy for never-loaded / rejected attempts.
181
+ };
182
+
183
+ loadingTask.promise
184
+ .then(async (pdf) => {
185
+ if (isStale()) {
186
+ teardown({ orphanProxy: pdf, reason: "stale-orphan" });
187
+ return;
188
+ }
189
+ ownership.viewerDoc = pdf;
190
+ setDoc(pdf);
191
+ setNumPages(pdf.numPages);
192
+ setStatus("ready");
193
+ setFirstPageReady(pdf.numPages > 0);
194
+ })
195
+ .catch((err: unknown) => {
196
+ if (isStale()) {
197
+ return;
198
+ }
199
+ const reason = classifyPdfError(err);
200
+ setStatus("error");
201
+ setError(reason);
202
+ setErrorMessage(err instanceof Error ? err.message : String(err));
203
+ setDoc(null);
204
+ setNumPages(0);
205
+ setFirstPageReady(false);
206
+ teardown({ reason: "cleanup" });
207
+ });
208
+
209
+ return () => {
210
+ if (ownershipRef.current === ownership) {
211
+ ownershipRef.current = null;
212
+ }
213
+ teardown({ reason: "cleanup" });
214
+ };
215
+ }, [url, retryToken, getDocument, classifyPdfError, getPdfMetrics]);
216
+
217
+ return {
218
+ status,
219
+ doc,
220
+ numPages,
221
+ firstPageReady,
222
+ error,
223
+ errorMessage,
224
+ docId,
225
+ retry,
226
+ };
227
+ }