@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,384 @@
1
+ import {
2
+ ChevronLeft,
3
+ ChevronRight,
4
+ Download,
5
+ Maximize2,
6
+ Minus,
7
+ Plus,
8
+ StretchHorizontal,
9
+ } from "lucide-react";
10
+ import { useEffect, useId, useState, type ReactNode } from "react";
11
+
12
+ import type { FitMode } from "../../hooks/use-pdf-pages";
13
+
14
+ import { MAX_ZOOM, MIN_ZOOM } from "../../lib/pdf";
15
+ import { Button } from "../ui/button";
16
+ import { Input } from "../ui/input";
17
+ import {
18
+ Select,
19
+ SelectContent,
20
+ SelectItem,
21
+ SelectTrigger,
22
+ SelectValue,
23
+ } from "../ui/select";
24
+ import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
25
+
26
+ export type PdfToolbarProps = {
27
+ page: number;
28
+ numPages: number;
29
+ /** Logical zoom (1 = 100%). */
30
+ zoom: number;
31
+ fitMode: FitMode;
32
+ /** When true, all interactive controls are disabled (empty / zero-page). */
33
+ disabled?: boolean;
34
+ downloadUrl: string;
35
+ onPageChange: (page: number) => void;
36
+ onZoomIn: () => void;
37
+ onZoomOut: () => void;
38
+ /** Commit an exact zoom level (1 = 100%) from the zoom-level combobox. */
39
+ onZoomTo: (level: number) => void;
40
+ onFitMode: (mode: "width" | "page") => void;
41
+ };
42
+
43
+ /**
44
+ * Fixed zoom stops, all inside the viewer's existing MIN_ZOOM (0.25) /
45
+ * MAX_ZOOM (4) bounds — no new zoom math and no new bounds. 100% is the
46
+ * default level and is marked as such in its accessible name.
47
+ */
48
+ export const ZOOM_STOPS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4] as const;
49
+
50
+ function zoomStopLabel(level: number): string {
51
+ return `${Math.round(level * 100)}%`;
52
+ }
53
+
54
+ function clampPage(raw: number, numPages: number): number {
55
+ if (!Number.isFinite(raw) || numPages < 1) {
56
+ return 1;
57
+ }
58
+ return Math.min(numPages, Math.max(1, Math.trunc(raw)));
59
+ }
60
+
61
+ /**
62
+ * Digits-only finite values (including 0) are numeric and clamp to 1..N.
63
+ * Empty / non-numeric → null (caller reverts, no callback).
64
+ */
65
+ function parsePageDraft(draft: string, numPages: number): number | null {
66
+ const trimmed = draft.trim();
67
+ if (!trimmed || !/^\d+$/u.test(trimmed)) {
68
+ return null;
69
+ }
70
+ const n = Number(trimmed);
71
+ if (!Number.isFinite(n)) {
72
+ return null;
73
+ }
74
+ return clampPage(n, numPages);
75
+ }
76
+
77
+ function IconButton({
78
+ label,
79
+ disabled,
80
+ onClick,
81
+ children,
82
+ testId,
83
+ pressed,
84
+ }: {
85
+ label: string;
86
+ disabled?: boolean;
87
+ onClick?: () => void;
88
+ children: ReactNode;
89
+ testId?: string;
90
+ pressed?: boolean;
91
+ }) {
92
+ return (
93
+ <Tooltip>
94
+ <TooltipTrigger asChild>
95
+ <Button
96
+ aria-label={label}
97
+ aria-pressed={pressed}
98
+ className="cursor-pointer focus-visible:ring-primary/50"
99
+ data-testid={testId}
100
+ disabled={disabled}
101
+ onClick={onClick}
102
+ size="icon-sm"
103
+ type="button"
104
+ variant="ghost"
105
+ >
106
+ {children}
107
+ </Button>
108
+ </TooltipTrigger>
109
+ <TooltipContent side="bottom">{label}</TooltipContent>
110
+ </Tooltip>
111
+ );
112
+ }
113
+
114
+ /**
115
+ * Scholarly Dusk instrument-rail toolbar. Pure/controlled — no pdfjs imports.
116
+ * DocView owns the Pages/Text view toggle; this rail never renders it.
117
+ */
118
+ export function PdfToolbar({
119
+ page,
120
+ numPages,
121
+ zoom,
122
+ fitMode,
123
+ disabled = false,
124
+ downloadUrl,
125
+ onPageChange,
126
+ onZoomIn,
127
+ onZoomOut,
128
+ onZoomTo,
129
+ onFitMode,
130
+ }: PdfToolbarProps) {
131
+ const liveId = useId();
132
+ const [draft, setDraft] = useState(String(page));
133
+
134
+ useEffect(() => {
135
+ setDraft(String(page));
136
+ }, [page]);
137
+
138
+ const atStart = page <= 1 || numPages < 1;
139
+ const atEnd = page >= numPages || numPages < 1;
140
+ const controlsDisabled = disabled || numPages < 1;
141
+ const zoomPercent = Math.round(zoom * 100);
142
+ const atMinZoom = zoom <= MIN_ZOOM;
143
+ const atMaxZoom = zoom >= MAX_ZOOM;
144
+
145
+ const commitDraft = () => {
146
+ if (controlsDisabled) {
147
+ setDraft(String(page));
148
+ return;
149
+ }
150
+ const parsed = parsePageDraft(draft, numPages);
151
+ if (parsed === null) {
152
+ setDraft(String(page));
153
+ return;
154
+ }
155
+ setDraft(String(parsed));
156
+ if (parsed !== page) {
157
+ onPageChange(parsed);
158
+ }
159
+ };
160
+
161
+ return (
162
+ <div
163
+ className="gno-pdf-toolbar sticky top-0 z-10 flex flex-wrap items-center gap-x-2 gap-y-1.5 rounded-t-lg border-border/40 border-b bg-background/85 px-3 py-2 backdrop-blur-[20px]"
164
+ data-testid="pdf-toolbar"
165
+ role="toolbar"
166
+ aria-label="PDF toolbar"
167
+ >
168
+ {/* Group A — page navigation */}
169
+ <div
170
+ className="flex items-center gap-1"
171
+ data-testid="pdf-toolbar-group-nav"
172
+ >
173
+ <IconButton
174
+ disabled={controlsDisabled || atStart}
175
+ label="Previous page"
176
+ onClick={() => onPageChange(clampPage(page - 1, numPages))}
177
+ testId="pdf-toolbar-prev"
178
+ >
179
+ <ChevronLeft />
180
+ </IconButton>
181
+
182
+ <Input
183
+ aria-label="Page number"
184
+ className="hidden h-8 w-12 px-1 text-center font-mono text-xs tabular-nums lg:inline-flex"
185
+ data-testid="pdf-toolbar-page-input"
186
+ disabled={controlsDisabled}
187
+ inputMode="numeric"
188
+ onBlur={commitDraft}
189
+ onChange={(e) => setDraft(e.target.value)}
190
+ onKeyDown={(e) => {
191
+ if (e.key === "Enter") {
192
+ e.preventDefault();
193
+ commitDraft();
194
+ } else if (e.key === "Escape") {
195
+ e.preventDefault();
196
+ setDraft(String(page));
197
+ (e.target as HTMLInputElement).blur();
198
+ }
199
+ }}
200
+ value={draft}
201
+ />
202
+
203
+ <span
204
+ aria-atomic="true"
205
+ aria-live="polite"
206
+ className="min-w-[3.5rem] px-1 text-center font-mono text-muted-foreground text-xs tabular-nums"
207
+ data-testid="pdf-toolbar-page-indicator"
208
+ id={liveId}
209
+ >
210
+ <span className="text-primary">{Math.max(0, page)}</span>
211
+ <span className="text-muted-foreground/70">
212
+ {" "}
213
+ / {Math.max(0, numPages)}
214
+ </span>
215
+ </span>
216
+
217
+ <IconButton
218
+ disabled={controlsDisabled || atEnd}
219
+ label="Next page"
220
+ onClick={() => onPageChange(clampPage(page + 1, numPages))}
221
+ testId="pdf-toolbar-next"
222
+ >
223
+ <ChevronRight />
224
+ </IconButton>
225
+ </div>
226
+
227
+ {/* Group B — zoom */}
228
+ <div
229
+ className="flex items-center gap-1"
230
+ data-testid="pdf-toolbar-group-zoom"
231
+ >
232
+ <IconButton
233
+ disabled={controlsDisabled || atMinZoom}
234
+ label="Zoom out"
235
+ onClick={onZoomOut}
236
+ testId="pdf-toolbar-zoom-out"
237
+ >
238
+ <Minus />
239
+ </IconButton>
240
+
241
+ {/*
242
+ Zoom-level combobox. Deliberately inherits the previous readout's
243
+ exact type treatment (font-mono text-xs tabular-nums) and h-8 sizing,
244
+ with the primitive's border/shadow suppressed, so the toolbar's
245
+ resting rhythm is unchanged next to the ghost stepper buttons — the
246
+ control reads as the same numeral it always was, now operable.
247
+ */}
248
+ <Select
249
+ disabled={controlsDisabled}
250
+ onValueChange={(v) => onZoomTo(Number(v))}
251
+ // Always controlled: a zoom that is not a fixed stop simply has no
252
+ // matching item, and the trigger keeps showing the live percentage.
253
+ value={String(zoom)}
254
+ >
255
+ <SelectTrigger
256
+ aria-label={`Zoom level, ${zoomPercent} percent`}
257
+ className="h-8 min-w-[4.25rem] cursor-pointer gap-1 border-transparent bg-transparent px-2 font-mono text-xs tabular-nums shadow-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-primary/50 dark:bg-transparent dark:hover:bg-accent"
258
+ data-testid="pdf-toolbar-zoom-level"
259
+ size="sm"
260
+ >
261
+ <SelectValue placeholder={`${zoomPercent}%`}>
262
+ {zoomPercent}%
263
+ </SelectValue>
264
+ </SelectTrigger>
265
+ <SelectContent
266
+ className="min-w-[6rem]"
267
+ data-testid="pdf-toolbar-zoom-level-list"
268
+ >
269
+ {ZOOM_STOPS.map((level) => (
270
+ <SelectItem
271
+ aria-label={
272
+ level === 1
273
+ ? "100 percent, default level"
274
+ : `${Math.round(level * 100)} percent`
275
+ }
276
+ className="cursor-pointer font-mono text-xs tabular-nums"
277
+ data-testid={`pdf-toolbar-zoom-option-${Math.round(level * 100)}`}
278
+ key={level}
279
+ value={String(level)}
280
+ >
281
+ {zoomStopLabel(level)}
282
+ </SelectItem>
283
+ ))}
284
+ </SelectContent>
285
+ </Select>
286
+
287
+ <IconButton
288
+ disabled={controlsDisabled || atMaxZoom}
289
+ label="Zoom in"
290
+ onClick={onZoomIn}
291
+ testId="pdf-toolbar-zoom-in"
292
+ >
293
+ <Plus />
294
+ </IconButton>
295
+ </div>
296
+
297
+ {/*
298
+ Structural <lg two-row split: A+B on row 1, C+D on row 2.
299
+ Desktop (lg+) hides the break so all groups share one row.
300
+ Source order A→B→break→C→D preserved.
301
+ */}
302
+ <span
303
+ aria-hidden
304
+ className="h-0 w-full basis-full lg:hidden"
305
+ data-testid="pdf-toolbar-mobile-break"
306
+ />
307
+
308
+ {/* Group C — fit modes */}
309
+ <div
310
+ className="flex items-center gap-0.5 rounded-md border border-border/40 p-0.5"
311
+ data-testid="pdf-toolbar-fit-group"
312
+ role="group"
313
+ aria-label="Fit mode"
314
+ >
315
+ <Button
316
+ aria-label="Fit width"
317
+ aria-pressed={fitMode === "width"}
318
+ className="h-7 cursor-pointer gap-1 px-2 text-xs focus-visible:ring-primary/50 data-[pressed=true]:bg-primary/15 data-[pressed=true]:text-primary"
319
+ data-pressed={fitMode === "width" ? "true" : "false"}
320
+ data-testid="pdf-toolbar-fit-width"
321
+ disabled={controlsDisabled}
322
+ onClick={() => onFitMode("width")}
323
+ size="sm"
324
+ type="button"
325
+ variant="ghost"
326
+ >
327
+ <StretchHorizontal className="size-3.5" />
328
+ <span
329
+ className="hidden lg:inline"
330
+ data-testid="pdf-toolbar-fit-width-label"
331
+ >
332
+ Fit width
333
+ </span>
334
+ </Button>
335
+ <Button
336
+ aria-label="Fit page"
337
+ aria-pressed={fitMode === "page"}
338
+ className="h-7 cursor-pointer gap-1 px-2 text-xs focus-visible:ring-primary/50 data-[pressed=true]:bg-primary/15 data-[pressed=true]:text-primary"
339
+ data-pressed={fitMode === "page" ? "true" : "false"}
340
+ data-testid="pdf-toolbar-fit-page"
341
+ disabled={controlsDisabled}
342
+ onClick={() => onFitMode("page")}
343
+ size="sm"
344
+ type="button"
345
+ variant="ghost"
346
+ >
347
+ <Maximize2 className="size-3.5" />
348
+ <span
349
+ className="hidden lg:inline"
350
+ data-testid="pdf-toolbar-fit-page-label"
351
+ >
352
+ Fit page
353
+ </span>
354
+ </Button>
355
+ </div>
356
+
357
+ <span className="flex-1" data-testid="pdf-toolbar-spacer" />
358
+
359
+ {/* Group D — download */}
360
+ <div data-testid="pdf-toolbar-group-download">
361
+ <Tooltip>
362
+ <TooltipTrigger asChild>
363
+ <Button
364
+ asChild
365
+ className="cursor-pointer focus-visible:ring-primary/50"
366
+ data-testid="pdf-toolbar-download"
367
+ size="icon-sm"
368
+ variant="ghost"
369
+ >
370
+ <a
371
+ aria-label="Download original"
372
+ download
373
+ href={downloadUrl || undefined}
374
+ >
375
+ <Download />
376
+ </a>
377
+ </Button>
378
+ </TooltipTrigger>
379
+ <TooltipContent side="bottom">Download original</TooltipContent>
380
+ </Tooltip>
381
+ </div>
382
+ </div>
383
+ );
384
+ }