@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.
- package/README.md +15 -5
- package/browser-extension/artifacts/gno-browser-clipper-v1.30.4.zip +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.30.4.zip.sha256 +1 -0
- package/browser-extension/dist/chunk-627emwpj.js +75 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/browser-extension/dist/preview.html +1 -1
- package/browser-extension/dist/service-worker.js +47 -22
- package/package.json +42 -39
- package/src/converters/adapters/officeparser/adapter.ts +7 -3
- package/src/core/network-boundary-inventory.ts +51 -0
- package/src/core/project-profile.ts +2 -2
- package/src/publish/encrypted-export.ts +1 -1
- package/src/serve/AGENTS.md +5 -1
- package/src/serve/CLAUDE.md +5 -1
- package/src/serve/fn112-routes.ts +232 -0
- package/src/serve/pdfjs-assets.ts +391 -0
- package/src/serve/public/components/ai-elements/code-block.tsx +28 -10
- package/src/serve/public/components/pdf/PdfPageView.tsx +428 -0
- package/src/serve/public/components/pdf/PdfToolbar.tsx +384 -0
- package/src/serve/public/components/pdf/PdfViewer.tsx +539 -0
- package/src/serve/public/components/pdf/pdf-viewer-deps.tsx +94 -0
- package/src/serve/public/globals.built.css +2 -2
- package/src/serve/public/globals.css +113 -0
- package/src/serve/public/hooks/use-pdf-document.ts +199 -0
- package/src/serve/public/hooks/use-pdf-pages.ts +1197 -0
- package/src/serve/public/lib/doc-asset-url.ts +57 -0
- package/src/serve/public/lib/math-sum-precise.ts +34 -0
- package/src/serve/public/lib/pdf.ts +772 -0
- package/src/serve/public/pages/DocView.tsx +295 -39
- package/src/serve/public/pages/doc-pdf-viewer.tsx +7 -0
- package/src/serve/routes/api.ts +154 -14
- package/src/serve/server.ts +190 -37
- package/src/serve/spa-bundle-source.ts +99 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.29.6.zip +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.29.6.zip.sha256 +0 -1
- package/browser-extension/dist/chunk-b2zm0jjd.js +0 -50
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
sanitizeAnnotationUrl as defaultSanitizeAnnotationUrl,
|
|
5
|
+
TextLayer as DefaultTextLayer,
|
|
6
|
+
type PDFDocumentProxy,
|
|
7
|
+
type PDFPageProxy,
|
|
8
|
+
type PdfAnnotation,
|
|
9
|
+
type PageViewport,
|
|
10
|
+
} from "../../lib/pdf";
|
|
11
|
+
|
|
12
|
+
export type PdfPageViewProps = {
|
|
13
|
+
doc: PDFDocumentProxy | null;
|
|
14
|
+
pageNumber: number;
|
|
15
|
+
width: number;
|
|
16
|
+
height: number;
|
|
17
|
+
scale: number;
|
|
18
|
+
rendered: boolean;
|
|
19
|
+
onMount: (pageNumber: number, el: HTMLElement | null) => void;
|
|
20
|
+
onRender: (pageNumber: number, canvas: HTMLCanvasElement | null) => void;
|
|
21
|
+
onInternalNavigate?: (pageNumber: number) => void;
|
|
22
|
+
active: boolean;
|
|
23
|
+
/** Optional test doubles only — production uses facade defaults. */
|
|
24
|
+
TextLayerImpl?: typeof DefaultTextLayer;
|
|
25
|
+
sanitizeAnnotationUrl?: typeof defaultSanitizeAnnotationUrl;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Exact pdfjs v5 scale CSS contract (wrapper + layers). */
|
|
29
|
+
export function applyScaleCssVars(
|
|
30
|
+
el: HTMLElement,
|
|
31
|
+
viewportScale: number
|
|
32
|
+
): void {
|
|
33
|
+
el.style.setProperty("--scale-factor", String(viewportScale));
|
|
34
|
+
el.style.setProperty(
|
|
35
|
+
"--total-scale-factor",
|
|
36
|
+
"calc(var(--scale-factor) * var(--user-unit, 1))"
|
|
37
|
+
);
|
|
38
|
+
el.style.setProperty("--scale-round-x", "1px");
|
|
39
|
+
el.style.setProperty("--scale-round-y", "1px");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function applyTextLayerBox(el: HTMLElement, viewport: PageViewport): void {
|
|
43
|
+
applyScaleCssVars(el, viewport.scale);
|
|
44
|
+
el.style.width = `${viewport.width}px`;
|
|
45
|
+
el.style.height = `${viewport.height}px`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Apply the current viewport scale contract to the page wrapper and layers.
|
|
50
|
+
* Wrapper receives the CSS vars only (slot width/height stay from props).
|
|
51
|
+
*/
|
|
52
|
+
function applyViewportScaleContract(
|
|
53
|
+
root: HTMLElement | null,
|
|
54
|
+
textContainer: HTMLElement | null,
|
|
55
|
+
inner: HTMLElement | null,
|
|
56
|
+
viewport: PageViewport
|
|
57
|
+
): void {
|
|
58
|
+
if (root) {
|
|
59
|
+
applyScaleCssVars(root, viewport.scale);
|
|
60
|
+
}
|
|
61
|
+
if (textContainer) {
|
|
62
|
+
applyTextLayerBox(textContainer, viewport);
|
|
63
|
+
}
|
|
64
|
+
if (inner) {
|
|
65
|
+
applyTextLayerBox(inner, viewport);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One page: rotation-aware placeholder, canvas, retained TextLayer (v5
|
|
71
|
+
* render / update / cancel), safe links.
|
|
72
|
+
*/
|
|
73
|
+
export function PdfPageView({
|
|
74
|
+
doc,
|
|
75
|
+
pageNumber,
|
|
76
|
+
width,
|
|
77
|
+
height,
|
|
78
|
+
scale,
|
|
79
|
+
rendered,
|
|
80
|
+
onMount,
|
|
81
|
+
onRender,
|
|
82
|
+
onInternalNavigate,
|
|
83
|
+
active,
|
|
84
|
+
TextLayerImpl = DefaultTextLayer,
|
|
85
|
+
sanitizeAnnotationUrl = defaultSanitizeAnnotationUrl,
|
|
86
|
+
}: PdfPageViewProps) {
|
|
87
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
88
|
+
const innerRef = useRef<HTMLDivElement | null>(null);
|
|
89
|
+
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
90
|
+
const textLayerRef = useRef<HTMLDivElement | null>(null);
|
|
91
|
+
const linkLayerRef = useRef<HTMLDivElement | null>(null);
|
|
92
|
+
const textLayerInstanceRef = useRef<InstanceType<
|
|
93
|
+
typeof DefaultTextLayer
|
|
94
|
+
> | null>(null);
|
|
95
|
+
/** Bumped on page/doc/active identity changes (rebuild path). */
|
|
96
|
+
const layerBuildGenRef = useRef(0);
|
|
97
|
+
const annotGenRef = useRef(0);
|
|
98
|
+
const pageProxyRef = useRef<PDFPageProxy | null>(null);
|
|
99
|
+
const scaleRef = useRef(scale);
|
|
100
|
+
scaleRef.current = scale;
|
|
101
|
+
const onInternalNavigateRef = useRef(onInternalNavigate);
|
|
102
|
+
onInternalNavigateRef.current = onInternalNavigate;
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
onMount(pageNumber, rootRef.current);
|
|
106
|
+
return () => {
|
|
107
|
+
onMount(pageNumber, null);
|
|
108
|
+
};
|
|
109
|
+
}, [pageNumber, onMount]);
|
|
110
|
+
|
|
111
|
+
// Start with zero backing store so default 300×150 never masquerades as live.
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
const canvas = canvasRef.current;
|
|
114
|
+
if (canvas && canvas.dataset.gnoPdfBacking !== "1") {
|
|
115
|
+
canvas.width = 0;
|
|
116
|
+
canvas.height = 0;
|
|
117
|
+
}
|
|
118
|
+
}, []);
|
|
119
|
+
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (!active) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
onRender(pageNumber, canvasRef.current);
|
|
125
|
+
}, [active, pageNumber, onRender, scale]);
|
|
126
|
+
|
|
127
|
+
// Build TextLayer once per page/doc/container identity (I3-03).
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
const textContainer = textLayerRef.current;
|
|
130
|
+
const inner = innerRef.current;
|
|
131
|
+
const root = rootRef.current;
|
|
132
|
+
if (!doc || !active || !textContainer) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
layerBuildGenRef.current += 1;
|
|
137
|
+
const buildGen = layerBuildGenRef.current;
|
|
138
|
+
let cancelled = false;
|
|
139
|
+
const isStale = () => cancelled || buildGen !== layerBuildGenRef.current;
|
|
140
|
+
|
|
141
|
+
void (async () => {
|
|
142
|
+
let page: PDFPageProxy;
|
|
143
|
+
try {
|
|
144
|
+
page = await doc.getPage(pageNumber);
|
|
145
|
+
} catch {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (isStale()) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
pageProxyRef.current = page;
|
|
152
|
+
|
|
153
|
+
let textContent: Awaited<ReturnType<PDFPageProxy["getTextContent"]>>;
|
|
154
|
+
try {
|
|
155
|
+
textContent = await page.getTextContent();
|
|
156
|
+
} catch {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// Guard immediately before any DOM mutation (stale after every await).
|
|
160
|
+
if (isStale()) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Cancel any prior retained layer before replace.
|
|
165
|
+
textLayerInstanceRef.current?.cancel();
|
|
166
|
+
textLayerInstanceRef.current = null;
|
|
167
|
+
if (isStale()) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
textContainer.replaceChildren();
|
|
171
|
+
|
|
172
|
+
const logicalScale = scaleRef.current;
|
|
173
|
+
const viewport = page.getViewport({
|
|
174
|
+
scale: logicalScale,
|
|
175
|
+
}) as PageViewport;
|
|
176
|
+
if (isStale()) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
applyViewportScaleContract(root, textContainer, inner, viewport);
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
if (isStale()) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const layer = new TextLayerImpl({
|
|
186
|
+
textContentSource: textContent,
|
|
187
|
+
container: textContainer,
|
|
188
|
+
viewport,
|
|
189
|
+
});
|
|
190
|
+
if (isStale()) {
|
|
191
|
+
layer.cancel();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
textLayerInstanceRef.current = layer;
|
|
195
|
+
await layer.render();
|
|
196
|
+
if (isStale()) {
|
|
197
|
+
layer.cancel();
|
|
198
|
+
if (textLayerInstanceRef.current === layer) {
|
|
199
|
+
textLayerInstanceRef.current = null;
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
// If scale changed during build, apply retained update with latest scale.
|
|
204
|
+
if (scaleRef.current !== logicalScale) {
|
|
205
|
+
if (isStale()) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const latest = page.getViewport({
|
|
209
|
+
scale: scaleRef.current,
|
|
210
|
+
}) as PageViewport;
|
|
211
|
+
applyViewportScaleContract(root, textContainer, inner, latest);
|
|
212
|
+
try {
|
|
213
|
+
layer.update({ viewport: latest });
|
|
214
|
+
} catch {
|
|
215
|
+
// ignore
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
// empty text layer ok
|
|
220
|
+
}
|
|
221
|
+
})();
|
|
222
|
+
|
|
223
|
+
return () => {
|
|
224
|
+
cancelled = true;
|
|
225
|
+
// Identity teardown: cancel retained layer.
|
|
226
|
+
if (buildGen === layerBuildGenRef.current) {
|
|
227
|
+
textLayerInstanceRef.current?.cancel();
|
|
228
|
+
textLayerInstanceRef.current = null;
|
|
229
|
+
pageProxyRef.current = null;
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
// scale intentionally excluded — zoom uses update path below.
|
|
233
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- scale via update effect
|
|
234
|
+
}, [doc, pageNumber, active, TextLayerImpl]);
|
|
235
|
+
|
|
236
|
+
// Viewport zoom/rotation: retained TextLayer.update + wrapper scale (I3-03/06).
|
|
237
|
+
useEffect(() => {
|
|
238
|
+
const textContainer = textLayerRef.current;
|
|
239
|
+
const inner = innerRef.current;
|
|
240
|
+
const root = rootRef.current;
|
|
241
|
+
const layer = textLayerInstanceRef.current;
|
|
242
|
+
const page = pageProxyRef.current;
|
|
243
|
+
if (!doc || !active || !textContainer || !layer || !page) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const viewport = page.getViewport({ scale }) as PageViewport;
|
|
248
|
+
applyViewportScaleContract(root, textContainer, inner, viewport);
|
|
249
|
+
try {
|
|
250
|
+
layer.update({ viewport });
|
|
251
|
+
} catch {
|
|
252
|
+
// If update fails, leave layer as-is; next identity rebuild will replace.
|
|
253
|
+
}
|
|
254
|
+
}, [doc, active, scale]);
|
|
255
|
+
|
|
256
|
+
// Link annotations with stale guards after every await.
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
const linkContainer = linkLayerRef.current;
|
|
259
|
+
if (!doc || !active || !linkContainer) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
annotGenRef.current += 1;
|
|
264
|
+
const gen = annotGenRef.current;
|
|
265
|
+
let cancelled = false;
|
|
266
|
+
const isStale = () => cancelled || gen !== annotGenRef.current;
|
|
267
|
+
|
|
268
|
+
void (async () => {
|
|
269
|
+
let page: PDFPageProxy;
|
|
270
|
+
try {
|
|
271
|
+
page = await doc.getPage(pageNumber);
|
|
272
|
+
} catch {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (isStale()) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const viewport = page.getViewport({ scale }) as PageViewport;
|
|
279
|
+
// Guard again immediately before DOM writes.
|
|
280
|
+
if (isStale()) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
linkContainer.replaceChildren();
|
|
284
|
+
linkContainer.style.width = `${viewport.width}px`;
|
|
285
|
+
linkContainer.style.height = `${viewport.height}px`;
|
|
286
|
+
|
|
287
|
+
let annots: PdfAnnotation[];
|
|
288
|
+
try {
|
|
289
|
+
annots = (await page.getAnnotations({
|
|
290
|
+
intent: "display",
|
|
291
|
+
})) as PdfAnnotation[];
|
|
292
|
+
} catch {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (isStale()) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
for (const annot of annots) {
|
|
300
|
+
if ((annot.subtype ?? "").toLowerCase() !== "link") {
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
const rect = annot.rect;
|
|
304
|
+
if (!rect || rect.length < 4) {
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
const [x1, y1] = viewport.convertToViewportPoint(rect[0]!, rect[1]!);
|
|
308
|
+
const [x2, y2] = viewport.convertToViewportPoint(rect[2]!, rect[3]!);
|
|
309
|
+
const left = Math.min(x1, x2);
|
|
310
|
+
const top = Math.min(y1, y2);
|
|
311
|
+
const w = Math.abs(x1 - x2);
|
|
312
|
+
const h = Math.abs(y1 - y2);
|
|
313
|
+
|
|
314
|
+
const rawUrl = annot.url ?? annot.unsafeUrl;
|
|
315
|
+
if (rawUrl) {
|
|
316
|
+
const safe = sanitizeAnnotationUrl(rawUrl);
|
|
317
|
+
if (safe) {
|
|
318
|
+
const anchor = document.createElement("a");
|
|
319
|
+
anchor.className = "gno-pdf-annotation-link";
|
|
320
|
+
anchor.style.left = `${left}px`;
|
|
321
|
+
anchor.style.top = `${top}px`;
|
|
322
|
+
anchor.style.width = `${w}px`;
|
|
323
|
+
anchor.style.height = `${h}px`;
|
|
324
|
+
anchor.href = safe;
|
|
325
|
+
anchor.target = "_blank";
|
|
326
|
+
anchor.rel = "noopener noreferrer";
|
|
327
|
+
anchor.setAttribute("data-annotation", "external");
|
|
328
|
+
if (!isStale()) {
|
|
329
|
+
linkContainer.appendChild(anchor);
|
|
330
|
+
}
|
|
331
|
+
} else {
|
|
332
|
+
const span = document.createElement("span");
|
|
333
|
+
span.className = "gno-pdf-annotation-inert";
|
|
334
|
+
span.style.left = `${left}px`;
|
|
335
|
+
span.style.top = `${top}px`;
|
|
336
|
+
span.style.width = `${w}px`;
|
|
337
|
+
span.style.height = `${h}px`;
|
|
338
|
+
span.setAttribute("data-annotation", "inert");
|
|
339
|
+
span.setAttribute("aria-hidden", "true");
|
|
340
|
+
if (!isStale()) {
|
|
341
|
+
linkContainer.appendChild(span);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (annot.dest) {
|
|
348
|
+
const btn = document.createElement("button");
|
|
349
|
+
btn.type = "button";
|
|
350
|
+
btn.className = "gno-pdf-annotation-link";
|
|
351
|
+
btn.style.left = `${left}px`;
|
|
352
|
+
btn.style.top = `${top}px`;
|
|
353
|
+
btn.style.width = `${w}px`;
|
|
354
|
+
btn.style.height = `${h}px`;
|
|
355
|
+
btn.setAttribute("data-annotation", "internal");
|
|
356
|
+
const destSnapshot = annot.dest;
|
|
357
|
+
// Capture generation for post-await destination/index resolution.
|
|
358
|
+
const clickGen = gen;
|
|
359
|
+
btn.addEventListener("click", (ev) => {
|
|
360
|
+
ev.preventDefault();
|
|
361
|
+
void (async () => {
|
|
362
|
+
try {
|
|
363
|
+
if (cancelled || clickGen !== annotGenRef.current) {
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const dest =
|
|
367
|
+
typeof destSnapshot === "string"
|
|
368
|
+
? await doc.getDestination(destSnapshot)
|
|
369
|
+
: destSnapshot;
|
|
370
|
+
if (cancelled || clickGen !== annotGenRef.current) {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (!Array.isArray(dest) || !dest[0]) {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const idx = await doc.getPageIndex(dest[0] as never);
|
|
377
|
+
if (cancelled || clickGen !== annotGenRef.current) {
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
onInternalNavigateRef.current?.(idx + 1);
|
|
381
|
+
} catch {
|
|
382
|
+
// ignore
|
|
383
|
+
}
|
|
384
|
+
})();
|
|
385
|
+
});
|
|
386
|
+
if (!isStale()) {
|
|
387
|
+
linkContainer.appendChild(btn);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
})();
|
|
392
|
+
|
|
393
|
+
return () => {
|
|
394
|
+
cancelled = true;
|
|
395
|
+
};
|
|
396
|
+
}, [doc, pageNumber, scale, active, sanitizeAnnotationUrl]);
|
|
397
|
+
|
|
398
|
+
useEffect(() => {
|
|
399
|
+
if (active) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
textLayerInstanceRef.current?.cancel();
|
|
403
|
+
textLayerInstanceRef.current = null;
|
|
404
|
+
pageProxyRef.current = null;
|
|
405
|
+
linkLayerRef.current?.replaceChildren();
|
|
406
|
+
}, [active]);
|
|
407
|
+
|
|
408
|
+
return (
|
|
409
|
+
<div
|
|
410
|
+
ref={rootRef}
|
|
411
|
+
className="gno-pdf-page"
|
|
412
|
+
data-page-number={pageNumber}
|
|
413
|
+
data-rendered={rendered ? "true" : "false"}
|
|
414
|
+
data-testid={`pdf-page-${pageNumber}`}
|
|
415
|
+
style={{
|
|
416
|
+
width: width || undefined,
|
|
417
|
+
height: height || undefined,
|
|
418
|
+
aspectRatio: width && height ? `${width} / ${height}` : undefined,
|
|
419
|
+
}}
|
|
420
|
+
>
|
|
421
|
+
<div ref={innerRef} className="gno-pdf-page-inner">
|
|
422
|
+
<canvas ref={canvasRef} className="gno-pdf-canvas" />
|
|
423
|
+
<div ref={textLayerRef} className="textLayer gno-pdf-text-layer" />
|
|
424
|
+
<div ref={linkLayerRef} className="gno-pdf-annotation-layer" />
|
|
425
|
+
</div>
|
|
426
|
+
</div>
|
|
427
|
+
);
|
|
428
|
+
}
|