@gmickel/gno 1.39.2 → 1.40.0
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/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v1.39.2.zip → gno-browser-clipper-v1.40.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.40.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/src/core/network-boundary-inventory.ts +10 -0
- package/src/serve/CLAUDE.md +22 -2
- package/src/serve/clipper-security.ts +3 -17
- package/src/serve/public/components/pdf/PdfViewer.tsx +97 -19
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/globals.css +4 -0
- package/src/serve/public/hooks/use-pdf-document.ts +101 -37
- package/src/serve/public/hooks/use-pdf-pages.ts +324 -92
- package/src/serve/public/lib/pdf-transport.ts +13 -0
- package/src/serve/public/lib/pdf.ts +77 -10
- package/src/serve/public/lib/server-capabilities.ts +19 -0
- package/src/serve/public/pages/Ask.tsx +8 -9
- package/src/serve/public/pages/DocView.tsx +80 -18
- package/src/serve/public/pages/Search.tsx +8 -9
- package/src/serve/request-locality.ts +84 -0
- package/src/serve/routes/api.ts +88 -3
- package/src/serve/server.ts +163 -29
- package/browser-extension/artifacts/gno-browser-clipper-v1.39.2.zip.sha256 +0 -1
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useLayoutEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
type RefObject,
|
|
8
|
+
} from "react";
|
|
2
9
|
|
|
3
10
|
import {
|
|
4
11
|
classifyPdfError,
|
|
@@ -41,6 +48,13 @@ export type PageSlotState = {
|
|
|
41
48
|
* mount canvas / TextLayer. Production PdfPageView `active` prop.
|
|
42
49
|
*/
|
|
43
50
|
active: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Nonfatal geometry failure carried on this slot (R2): a later page whose
|
|
53
|
+
* `getPage` failed while page 1 succeeded. The slot keeps its placeholder
|
|
54
|
+
* size so the scroll model holds; the viewer shows the page error state in
|
|
55
|
+
* place of the page and every other slot stays mounted.
|
|
56
|
+
*/
|
|
57
|
+
error: PdfFallbackReason | null;
|
|
44
58
|
};
|
|
45
59
|
|
|
46
60
|
export type UsePdfPagesOptions = {
|
|
@@ -54,6 +68,12 @@ export type UsePdfPagesOptions = {
|
|
|
54
68
|
containerHeight: number;
|
|
55
69
|
/** Bumped by viewer on every zoom/fit/scale commit. */
|
|
56
70
|
genId: number;
|
|
71
|
+
/**
|
|
72
|
+
* Scrolling element that holds the page column. When the full geometry pass
|
|
73
|
+
* corrects placeholder heights, its `scrollTop` is adjusted by the height
|
|
74
|
+
* delta of the pages above the page in view so that page's top edge stays put.
|
|
75
|
+
*/
|
|
76
|
+
scrollContainerRef?: RefObject<HTMLElement | null>;
|
|
57
77
|
devicePixelRatio?: number;
|
|
58
78
|
/**
|
|
59
79
|
* Optional test doubles only (not a product API). Production omits these.
|
|
@@ -67,7 +87,12 @@ export type UsePdfPagesOptions = {
|
|
|
67
87
|
|
|
68
88
|
export type UsePdfPagesResult = {
|
|
69
89
|
slots: PageSlotState[];
|
|
70
|
-
/**
|
|
90
|
+
/**
|
|
91
|
+
* Fatal failure classified for the viewer state model: page 1 geometry (the
|
|
92
|
+
* document behind it) or a page-1 render-path page acquisition failure. A later
|
|
93
|
+
* page's geometry or render-path acquisition failure is not fatal — it rides
|
|
94
|
+
* on `PageSlotState.error`.
|
|
95
|
+
*/
|
|
71
96
|
error: PdfFallbackReason | null;
|
|
72
97
|
liveCanvasCount: number;
|
|
73
98
|
observePage: (pageNumber: number, el: HTMLElement | null) => void;
|
|
@@ -110,6 +135,141 @@ type PageCache = {
|
|
|
110
135
|
|
|
111
136
|
type BasePageGeometry = { width: number; height: number };
|
|
112
137
|
|
|
138
|
+
type PageGeometryResult = {
|
|
139
|
+
geometry: BasePageGeometry | null;
|
|
140
|
+
/** Classified `getPage` failure for this page; `null` once measured. */
|
|
141
|
+
error: PdfFallbackReason | null;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* One geometry pass over a document. Page 1 resolves alone first so the viewer
|
|
146
|
+
* can publish placeholder slots (every page at page 1's size) and paint page 1
|
|
147
|
+
* before the rest is measured; the bounded worker pass then continues from
|
|
148
|
+
* page 2 and `full` carries every page's real size or its classified failure.
|
|
149
|
+
*/
|
|
150
|
+
type GeometryPass = {
|
|
151
|
+
firstPage: Promise<BasePageGeometry>;
|
|
152
|
+
full: Promise<PageGeometryResult[]>;
|
|
153
|
+
/** Result of `full` once it resolved; a later effect run skips the placeholder publish. */
|
|
154
|
+
settled: PageGeometryResult[] | null;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const GEOMETRY_WORKERS = 4;
|
|
158
|
+
|
|
159
|
+
function startGeometryPass(
|
|
160
|
+
doc: PDFDocumentProxy,
|
|
161
|
+
numPages: number
|
|
162
|
+
): GeometryPass {
|
|
163
|
+
const measure = async (pageNumber: number): Promise<BasePageGeometry> => {
|
|
164
|
+
const page = await doc.getPage(pageNumber);
|
|
165
|
+
const viewport = page.getViewport({ scale: 1 });
|
|
166
|
+
return { width: viewport.width, height: viewport.height };
|
|
167
|
+
};
|
|
168
|
+
const firstPage = measure(1);
|
|
169
|
+
const full = (async (): Promise<PageGeometryResult[]> => {
|
|
170
|
+
const results = Array.from(
|
|
171
|
+
{ length: numPages },
|
|
172
|
+
(): PageGeometryResult => ({ geometry: null, error: null })
|
|
173
|
+
);
|
|
174
|
+
// Page 1 is fatal: a rejection here rejects `full` as well.
|
|
175
|
+
results[0] = { geometry: await firstPage, error: null };
|
|
176
|
+
let nextPageNumber = 2;
|
|
177
|
+
const worker = async (): Promise<void> => {
|
|
178
|
+
while (nextPageNumber <= numPages) {
|
|
179
|
+
const pageNumber = nextPageNumber;
|
|
180
|
+
nextPageNumber += 1;
|
|
181
|
+
try {
|
|
182
|
+
results[pageNumber - 1] = {
|
|
183
|
+
geometry: await measure(pageNumber),
|
|
184
|
+
error: null,
|
|
185
|
+
};
|
|
186
|
+
} catch (error) {
|
|
187
|
+
// A later page's failure rides on its slot; the pass continues.
|
|
188
|
+
results[pageNumber - 1] = {
|
|
189
|
+
geometry: null,
|
|
190
|
+
error: classifyPdfError(error),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
await Promise.all(
|
|
196
|
+
Array.from(
|
|
197
|
+
{ length: Math.min(GEOMETRY_WORKERS, numPages - 1) },
|
|
198
|
+
async () => worker()
|
|
199
|
+
)
|
|
200
|
+
);
|
|
201
|
+
return results;
|
|
202
|
+
})();
|
|
203
|
+
const pass: GeometryPass = { firstPage, full, settled: null };
|
|
204
|
+
full.then(
|
|
205
|
+
(results) => {
|
|
206
|
+
pass.settled = results;
|
|
207
|
+
},
|
|
208
|
+
() => {
|
|
209
|
+
// Surfaced through `firstPage` by the effect that awaits it.
|
|
210
|
+
}
|
|
211
|
+
);
|
|
212
|
+
return pass;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function anchorPage(visible: ReadonlySet<number>): number | null {
|
|
216
|
+
let anchor: number | null = null;
|
|
217
|
+
for (const pageNumber of visible) {
|
|
218
|
+
if (anchor === null || pageNumber < anchor) {
|
|
219
|
+
anchor = pageNumber;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return anchor;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Page whose top edge straddles the viewport top, derived from laid-out boxes
|
|
227
|
+
* rather than the IntersectionObserver visible set (that set uses a 200px
|
|
228
|
+
* rootMargin and can include a page entirely above the viewport).
|
|
229
|
+
*
|
|
230
|
+
* Measures the DOM — including `.gno-pdf-page` 1rem margin and the column's
|
|
231
|
+
* padding — instead of reconstructing offsets from slot heights plus CSS
|
|
232
|
+
* constants. Returns null when the measurement is unusable: fewer than two
|
|
233
|
+
* registered pages, or offsets that are not strictly increasing (happy-dom
|
|
234
|
+
* and a not-yet-laid-out DOM return zero rects).
|
|
235
|
+
*/
|
|
236
|
+
function anchorPageFromLayout(
|
|
237
|
+
container: HTMLElement,
|
|
238
|
+
elements: ReadonlyMap<number, HTMLElement>,
|
|
239
|
+
numPages: number
|
|
240
|
+
): number | null {
|
|
241
|
+
const pages: number[] = [];
|
|
242
|
+
const offsets: number[] = [];
|
|
243
|
+
const containerTop = container.getBoundingClientRect().top;
|
|
244
|
+
for (let pageNumber = 1; pageNumber <= numPages; pageNumber += 1) {
|
|
245
|
+
const el = elements.get(pageNumber);
|
|
246
|
+
if (!el) {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
pages.push(pageNumber);
|
|
250
|
+
offsets.push(
|
|
251
|
+
el.getBoundingClientRect().top - containerTop + container.scrollTop
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
if (pages.length < 2) {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
for (let index = 1; index < offsets.length; index += 1) {
|
|
258
|
+
if (!(offsets[index]! > offsets[index - 1]!)) {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const edge = container.scrollTop + 0.5;
|
|
263
|
+
// No qualifying page means the reader is above page 1.
|
|
264
|
+
let anchor = 1;
|
|
265
|
+
for (let index = 0; index < pages.length; index += 1) {
|
|
266
|
+
if (offsets[index]! <= edge) {
|
|
267
|
+
anchor = pages[index]!;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return anchor;
|
|
271
|
+
}
|
|
272
|
+
|
|
113
273
|
function setsEqual(a: ReadonlySet<number>, b: ReadonlySet<number>): boolean {
|
|
114
274
|
if (a.size !== b.size) {
|
|
115
275
|
return false;
|
|
@@ -165,6 +325,7 @@ export function usePdfPages(options: UsePdfPagesOptions): UsePdfPagesResult {
|
|
|
165
325
|
containerWidth,
|
|
166
326
|
containerHeight,
|
|
167
327
|
genId,
|
|
328
|
+
scrollContainerRef,
|
|
168
329
|
devicePixelRatio = typeof window !== "undefined"
|
|
169
330
|
? window.devicePixelRatio || 1
|
|
170
331
|
: 1,
|
|
@@ -189,9 +350,12 @@ export function usePdfPages(options: UsePdfPagesOptions): UsePdfPagesResult {
|
|
|
189
350
|
const canvasRef = useRef<Map<number, HTMLCanvasElement>>(new Map());
|
|
190
351
|
const reservationsRef = useRef<Map<number, symbol>>(new Map());
|
|
191
352
|
const latestRequestRef = useRef<Map<number, symbol>>(new Map());
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
353
|
+
// Holds the full pass only: the placeholder slots derived from page 1 are a
|
|
354
|
+
// transient publish, never cached geometry. `firstPage` is that pass's own
|
|
355
|
+
// page-1 prefix, so a re-run during the pass can still publish early.
|
|
356
|
+
const geometryCacheRef = useRef<WeakMap<PDFDocumentProxy, GeometryPass>>(
|
|
357
|
+
new WeakMap()
|
|
358
|
+
);
|
|
195
359
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
|
196
360
|
const settledTaskIdsRef = useRef<Set<string>>(new Set());
|
|
197
361
|
const metrics = getPdfMetrics();
|
|
@@ -238,28 +402,37 @@ export function usePdfPages(options: UsePdfPagesOptions): UsePdfPagesResult {
|
|
|
238
402
|
}
|
|
239
403
|
}, []);
|
|
240
404
|
|
|
405
|
+
const openEpoch = (): void => {
|
|
406
|
+
epochSeqRef.current += 1;
|
|
407
|
+
epochBatchOpenRef.current = true;
|
|
408
|
+
epochAdmittedRef.current = 0;
|
|
409
|
+
pendingRef.current.clear();
|
|
410
|
+
reservationsRef.current.clear();
|
|
411
|
+
latestRequestRef.current.clear();
|
|
412
|
+
if (pendingTimerRef.current !== null) {
|
|
413
|
+
clearTimeout(pendingTimerRef.current);
|
|
414
|
+
pendingTimerRef.current = null;
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
|
|
241
418
|
// Synchronous epoch bump (same pattern as genIdRef above): a doc or gen change
|
|
242
419
|
// must open its exempt batch before any render path observes the new state.
|
|
243
420
|
{
|
|
244
421
|
const epochKey = `${docId ?? ""}:${genId}`;
|
|
245
422
|
if (epochKeyRef.current !== epochKey) {
|
|
246
423
|
epochKeyRef.current = epochKey;
|
|
247
|
-
|
|
248
|
-
epochBatchOpenRef.current = true;
|
|
249
|
-
epochAdmittedRef.current = 0;
|
|
250
|
-
pendingRef.current.clear();
|
|
251
|
-
reservationsRef.current.clear();
|
|
252
|
-
latestRequestRef.current.clear();
|
|
253
|
-
if (pendingTimerRef.current !== null) {
|
|
254
|
-
clearTimeout(pendingTimerRef.current);
|
|
255
|
-
pendingTimerRef.current = null;
|
|
256
|
-
}
|
|
424
|
+
openEpoch();
|
|
257
425
|
}
|
|
258
426
|
}
|
|
259
427
|
const scaleRef = useRef(scale);
|
|
260
428
|
scaleRef.current = scale;
|
|
261
429
|
const visibleRef = useRef(visiblePages);
|
|
262
430
|
visibleRef.current = visiblePages;
|
|
431
|
+
/** Slots as last rendered — the heights the DOM currently shows. */
|
|
432
|
+
const slotsRef = useRef(slots);
|
|
433
|
+
slotsRef.current = slots;
|
|
434
|
+
/** Scroll delta to apply once the corrected slot heights are in the DOM. */
|
|
435
|
+
const pendingScrollAdjustRef = useRef(0);
|
|
263
436
|
const disposedRef = useRef(false);
|
|
264
437
|
const disposePromiseRef = useRef<Promise<void> | null>(null);
|
|
265
438
|
|
|
@@ -287,9 +460,12 @@ export function usePdfPages(options: UsePdfPagesOptions): UsePdfPagesResult {
|
|
|
287
460
|
[numPages]
|
|
288
461
|
);
|
|
289
462
|
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
463
|
+
// Rotation-aware geometry drives the scroll model. Slots publish as soon as
|
|
464
|
+
// page 1 is measured (every page at page 1's size, fit scale from page 1) so
|
|
465
|
+
// the first paint does not wait for the rest of the document; the full pass
|
|
466
|
+
// then corrects sizes and the fit scale in one commit, anchored so the page
|
|
467
|
+
// in view does not jump. Copying page 1 is only ever the placeholder:
|
|
468
|
+
// mixed-size documents and fit modes settle on the real per-page sizes.
|
|
293
469
|
useEffect(() => {
|
|
294
470
|
let cancelled = false;
|
|
295
471
|
if (!doc || numPages === 0) {
|
|
@@ -299,75 +475,15 @@ export function usePdfPages(options: UsePdfPagesOptions): UsePdfPagesResult {
|
|
|
299
475
|
return;
|
|
300
476
|
}
|
|
301
477
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const worker = async (): Promise<void> => {
|
|
312
|
-
while (failure === null) {
|
|
313
|
-
const pageNumber = nextPageNumber;
|
|
314
|
-
nextPageNumber += 1;
|
|
315
|
-
if (pageNumber > numPages) {
|
|
316
|
-
return;
|
|
317
|
-
}
|
|
318
|
-
try {
|
|
319
|
-
const page = await doc.getPage(pageNumber);
|
|
320
|
-
if (failure !== null) {
|
|
321
|
-
return;
|
|
322
|
-
}
|
|
323
|
-
const viewport = page.getViewport({ scale: 1 });
|
|
324
|
-
geometry[pageNumber - 1] = {
|
|
325
|
-
width: viewport.width,
|
|
326
|
-
height: viewport.height,
|
|
327
|
-
};
|
|
328
|
-
} catch (error) {
|
|
329
|
-
failure = error;
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
};
|
|
333
|
-
await Promise.all(
|
|
334
|
-
Array.from({ length: Math.min(4, numPages) }, async () => worker())
|
|
335
|
-
);
|
|
336
|
-
const resolved = geometry.filter(
|
|
337
|
-
(entry): entry is BasePageGeometry => entry !== undefined
|
|
338
|
-
);
|
|
339
|
-
if (failure !== null) {
|
|
340
|
-
throw failure;
|
|
341
|
-
}
|
|
342
|
-
if (resolved.length !== numPages) {
|
|
343
|
-
throw new Error("PDF page geometry is incomplete");
|
|
344
|
-
}
|
|
345
|
-
return resolved;
|
|
346
|
-
})();
|
|
347
|
-
geometryCacheRef.current.set(doc, geometryPromise);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
let resolvedGeometry: BasePageGeometry[];
|
|
351
|
-
try {
|
|
352
|
-
resolvedGeometry = await geometryPromise;
|
|
353
|
-
} catch (error) {
|
|
354
|
-
if (cancelled) {
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
setSlots([]);
|
|
358
|
-
setPageError(classifyPdfError(error));
|
|
359
|
-
return;
|
|
360
|
-
}
|
|
361
|
-
if (cancelled) {
|
|
362
|
-
return;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
const maxWidth = Math.max(
|
|
366
|
-
...resolvedGeometry.map((entry) => entry.width)
|
|
367
|
-
);
|
|
368
|
-
const maxHeight = Math.max(
|
|
369
|
-
...resolvedGeometry.map((entry) => entry.height)
|
|
370
|
-
);
|
|
478
|
+
const commit = (
|
|
479
|
+
results: PageGeometryResult[],
|
|
480
|
+
placeholder: BasePageGeometry,
|
|
481
|
+
correction: boolean,
|
|
482
|
+
placeholderScale?: number
|
|
483
|
+
): number => {
|
|
484
|
+
const sizes = results.map((entry) => entry.geometry ?? placeholder);
|
|
485
|
+
const maxWidth = Math.max(...sizes.map((entry) => entry.width));
|
|
486
|
+
const maxHeight = Math.max(...sizes.map((entry) => entry.height));
|
|
371
487
|
let nextScale = zoom;
|
|
372
488
|
if (fitMode === "width" && containerWidth > 0) {
|
|
373
489
|
nextScale = containerWidth / maxWidth;
|
|
@@ -382,21 +498,114 @@ export function usePdfPages(options: UsePdfPagesOptions): UsePdfPagesResult {
|
|
|
382
498
|
);
|
|
383
499
|
}
|
|
384
500
|
nextScale = Math.max(0.25, Math.min(4, nextScale));
|
|
385
|
-
setScale(nextScale);
|
|
386
|
-
setPageError(null);
|
|
387
501
|
|
|
388
502
|
const visible = visibleRef.current;
|
|
389
503
|
const active = computeActiveSet(visible, numPages);
|
|
390
|
-
|
|
391
|
-
|
|
504
|
+
const next = sizes.map(
|
|
505
|
+
(entry, index): PageSlotState => ({
|
|
392
506
|
pageNumber: index + 1,
|
|
393
507
|
width: entry.width * nextScale,
|
|
394
508
|
height: entry.height * nextScale,
|
|
395
509
|
rendered: false,
|
|
396
510
|
visible: visible.has(index + 1),
|
|
397
511
|
active: active.has(index + 1),
|
|
398
|
-
|
|
512
|
+
error: results[index]?.error ?? null,
|
|
513
|
+
})
|
|
399
514
|
);
|
|
515
|
+
|
|
516
|
+
if (correction) {
|
|
517
|
+
// Keep the top edge of the page straddling the viewport top where it
|
|
518
|
+
// is: shift scrollTop by the height delta of pages strictly above that
|
|
519
|
+
// page, applied once the new heights are in the DOM. The anchor is
|
|
520
|
+
// read from laid-out boxes here only (DOM includes page margin and
|
|
521
|
+
// column padding); the observer set is the fallback when layout is
|
|
522
|
+
// unusable or the scroll container is missing.
|
|
523
|
+
const shown = slotsRef.current;
|
|
524
|
+
const container = scrollContainerRef?.current;
|
|
525
|
+
const layoutAnchor =
|
|
526
|
+
container == null
|
|
527
|
+
? null
|
|
528
|
+
: anchorPageFromLayout(container, elementsRef.current, numPages);
|
|
529
|
+
const anchor = layoutAnchor ?? anchorPage(visible);
|
|
530
|
+
if (anchor !== null && shown.length === next.length) {
|
|
531
|
+
let delta = 0;
|
|
532
|
+
for (let index = 0; index < anchor - 1; index += 1) {
|
|
533
|
+
delta += next[index]!.height - shown[index]!.height;
|
|
534
|
+
}
|
|
535
|
+
pendingScrollAdjustRef.current += delta;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Same scale as the placeholder publish: a page already drawn stays
|
|
540
|
+
// drawn. Compare against the scale that commit actually returned, not
|
|
541
|
+
// scaleRef — that ref is stale if the full pass settles before React
|
|
542
|
+
// re-rendered after the placeholder.
|
|
543
|
+
const keepRendered = nextScale === (placeholderScale ?? nextScale);
|
|
544
|
+
if (correction && !keepRendered) {
|
|
545
|
+
// The correction is a scale commit like a zoom, so it opens a new
|
|
546
|
+
// admission epoch and the exempt batch admits every active page immediately.
|
|
547
|
+
openEpoch();
|
|
548
|
+
}
|
|
549
|
+
setScale(nextScale);
|
|
550
|
+
if (!correction) {
|
|
551
|
+
// Geometry recovered: the placeholder commit clears any earlier
|
|
552
|
+
// geometry error. The correction never clears, so a fatal error raised
|
|
553
|
+
// by a page 1 render attempt during the placeholder window stays.
|
|
554
|
+
setPageError(null);
|
|
555
|
+
setSlots(next);
|
|
556
|
+
return nextScale;
|
|
557
|
+
}
|
|
558
|
+
setSlots((prev) =>
|
|
559
|
+
next.map((slot, index) =>
|
|
560
|
+
keepRendered && prev[index]?.pageNumber === slot.pageNumber
|
|
561
|
+
? { ...slot, rendered: prev[index].rendered }
|
|
562
|
+
: slot
|
|
563
|
+
)
|
|
564
|
+
);
|
|
565
|
+
return nextScale;
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
void (async () => {
|
|
569
|
+
let pass = geometryCacheRef.current.get(doc);
|
|
570
|
+
if (!pass) {
|
|
571
|
+
pass = startGeometryPass(doc, numPages);
|
|
572
|
+
geometryCacheRef.current.set(doc, pass);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
let firstPage: BasePageGeometry;
|
|
576
|
+
try {
|
|
577
|
+
firstPage = await pass.firstPage;
|
|
578
|
+
} catch (error) {
|
|
579
|
+
if (cancelled) {
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
// Page 1 (or the document behind it) failed: fatal, existing fallback.
|
|
583
|
+
setSlots([]);
|
|
584
|
+
setPageError(classifyPdfError(error));
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
if (cancelled) {
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const publishedPlaceholder = pass.settled === null;
|
|
592
|
+
let placeholderScale = 1;
|
|
593
|
+
if (publishedPlaceholder) {
|
|
594
|
+
placeholderScale = commit(
|
|
595
|
+
Array.from(
|
|
596
|
+
{ length: numPages },
|
|
597
|
+
(): PageGeometryResult => ({ geometry: null, error: null })
|
|
598
|
+
),
|
|
599
|
+
firstPage,
|
|
600
|
+
false
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const results = await pass.full;
|
|
605
|
+
if (cancelled) {
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
commit(results, firstPage, publishedPlaceholder, placeholderScale);
|
|
400
609
|
})();
|
|
401
610
|
|
|
402
611
|
return () => {
|
|
@@ -404,6 +613,19 @@ export function usePdfPages(options: UsePdfPagesOptions): UsePdfPagesResult {
|
|
|
404
613
|
};
|
|
405
614
|
}, [doc, numPages, zoom, fitMode, containerWidth, containerHeight]);
|
|
406
615
|
|
|
616
|
+
// Apply the anchored scroll shift in the same frame the corrected heights land.
|
|
617
|
+
useLayoutEffect(() => {
|
|
618
|
+
const delta = pendingScrollAdjustRef.current;
|
|
619
|
+
if (delta === 0) {
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
pendingScrollAdjustRef.current = 0;
|
|
623
|
+
const container = scrollContainerRef?.current;
|
|
624
|
+
if (container) {
|
|
625
|
+
container.scrollTop += delta;
|
|
626
|
+
}
|
|
627
|
+
}, [slots, scrollContainerRef]);
|
|
628
|
+
|
|
407
629
|
// IntersectionObserver drives visibility → slot.active/visible → PdfPageView.
|
|
408
630
|
useEffect(() => {
|
|
409
631
|
if (!IntersectionObserverImpl) {
|
|
@@ -837,6 +1059,16 @@ export function usePdfPages(options: UsePdfPagesOptions): UsePdfPagesResult {
|
|
|
837
1059
|
page = await doc.getPage(pageNumber);
|
|
838
1060
|
} catch (error) {
|
|
839
1061
|
releaseReservation();
|
|
1062
|
+
if (pageNumber > 1) {
|
|
1063
|
+
setSlots((prev) =>
|
|
1064
|
+
prev.map((s) =>
|
|
1065
|
+
s.pageNumber === pageNumber
|
|
1066
|
+
? { ...s, error: classifyPdfError(error), rendered: false }
|
|
1067
|
+
: s
|
|
1068
|
+
)
|
|
1069
|
+
);
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
840
1072
|
setPageError(classifyPdfError(error));
|
|
841
1073
|
return;
|
|
842
1074
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDF transport tier constants (fn-136 R1).
|
|
3
|
+
*
|
|
4
|
+
* Kept free of any pdfjs-dist import so tooling that runs under plain Bun
|
|
5
|
+
* (for example the Playwright smoke in scripts/pdf-viewer-smoke.ts) can read
|
|
6
|
+
* the product bounds without evaluating the browser-only pdf.js build.
|
|
7
|
+
* `./pdf` re-exports both names, so app code keeps importing from the facade.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Files whose HEAD Content-Length is under this load in one GET. */
|
|
11
|
+
export const PDF_WHOLE_FILE_MAX_BYTES = 8 * 1024 * 1024;
|
|
12
|
+
/** Range chunk size for files at or above the whole-file bound. */
|
|
13
|
+
export const PDF_RANGE_CHUNK_BYTES = 1024 * 1024;
|
|
@@ -16,6 +16,11 @@ import {
|
|
|
16
16
|
type PageViewport,
|
|
17
17
|
} from "pdfjs-dist";
|
|
18
18
|
|
|
19
|
+
import {
|
|
20
|
+
PDF_RANGE_CHUNK_BYTES,
|
|
21
|
+
PDF_WHOLE_FILE_MAX_BYTES,
|
|
22
|
+
} from "./pdf-transport";
|
|
23
|
+
|
|
19
24
|
// ── Worker / asset bootstrap ────────────────────────────────────────────────
|
|
20
25
|
//
|
|
21
26
|
// Browser (and Electrobun webview) production: same-origin /vendor/pdfjs/* routes.
|
|
@@ -124,12 +129,76 @@ export type PdfAnnotation = {
|
|
|
124
129
|
|
|
125
130
|
// ── Document load wrapper ───────────────────────────────────────────────────
|
|
126
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Transport tier for one document load (fn-136 R1).
|
|
134
|
+
* - `whole-file`: one GET, ranges disabled, body streamed as it arrives.
|
|
135
|
+
* - `ranged`: Range requests of PDF_RANGE_CHUNK_BYTES with background fetch.
|
|
136
|
+
*/
|
|
137
|
+
export type PdfTransportHint = "whole-file" | "ranged";
|
|
138
|
+
|
|
127
139
|
export type GnoGetDocumentParams = {
|
|
128
140
|
url: string;
|
|
141
|
+
/** Omitted → `ranged`: chunked transport, safe for an unknown or large file. */
|
|
142
|
+
transport?: PdfTransportHint;
|
|
129
143
|
// Intentionally NO caller-controlled document id — every load mints a fresh
|
|
130
144
|
// opaque instance id internally (I2-6 / Sol rereview).
|
|
131
145
|
};
|
|
132
146
|
|
|
147
|
+
/** pdf.js DocumentInitParameters subset that the transport tier controls. */
|
|
148
|
+
export type PdfTransportOptions = {
|
|
149
|
+
disableRange?: boolean;
|
|
150
|
+
rangeChunkSize?: number;
|
|
151
|
+
disableStream: boolean;
|
|
152
|
+
disableAutoFetch: boolean;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Map a transport hint to pdf.js transport options.
|
|
157
|
+
*
|
|
158
|
+
* whole-file: with `disableStream: true` pdf.js cancels the full-body reader
|
|
159
|
+
* as soon as the server advertises byte ranges, so the single-request tier
|
|
160
|
+
* must disable ranges explicitly or its first GET is thrown away.
|
|
161
|
+
*
|
|
162
|
+
* ranged: `disableStream: true` keeps the full-body GET from competing with
|
|
163
|
+
* Range requests; `disableAutoFetch: false` lets pdf.js pull the remaining
|
|
164
|
+
* chunks in the background instead of one round trip per parser miss. Range
|
|
165
|
+
* eligibility still needs Content-Length > 2×rangeChunkSize plus
|
|
166
|
+
* Accept-Ranges: bytes (emitted by GET /api/doc-asset).
|
|
167
|
+
*/
|
|
168
|
+
export function transportOptionsFor(
|
|
169
|
+
hint: PdfTransportHint
|
|
170
|
+
): PdfTransportOptions {
|
|
171
|
+
if (hint === "whole-file") {
|
|
172
|
+
return {
|
|
173
|
+
disableRange: true,
|
|
174
|
+
disableStream: false,
|
|
175
|
+
disableAutoFetch: false,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
rangeChunkSize: PDF_RANGE_CHUNK_BYTES,
|
|
180
|
+
disableStream: true,
|
|
181
|
+
disableAutoFetch: false,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Pick the transport tier from a HEAD-probed Content-Length.
|
|
187
|
+
* Unknown (null / non-finite / negative) sizes fall back to `ranged`.
|
|
188
|
+
*/
|
|
189
|
+
export function transportHintForContentLength(
|
|
190
|
+
contentLength: number | null
|
|
191
|
+
): PdfTransportHint {
|
|
192
|
+
if (
|
|
193
|
+
contentLength === null ||
|
|
194
|
+
!Number.isFinite(contentLength) ||
|
|
195
|
+
contentLength < 0
|
|
196
|
+
) {
|
|
197
|
+
return "ranged";
|
|
198
|
+
}
|
|
199
|
+
return contentLength < PDF_WHOLE_FILE_MAX_BYTES ? "whole-file" : "ranged";
|
|
200
|
+
}
|
|
201
|
+
|
|
133
202
|
/**
|
|
134
203
|
* Loading task augmented with the opaque per-load document instance id.
|
|
135
204
|
* `gnoDocId` is never derived from URL/path/URI/filename/title/content and is
|
|
@@ -180,16 +249,8 @@ export function getDocument(
|
|
|
180
249
|
// hits standardFontDataUrl, breaking the offline standard-font contract.
|
|
181
250
|
// Force pdfjs-dist standard_fonts/* over same-origin routes instead.
|
|
182
251
|
useSystemFonts: false,
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
// response headers arrive when ranges are supported, so every subsequent
|
|
186
|
-
// byte is fetched as a discrete Range request (pdf.mjs PDFFetchStreamReader).
|
|
187
|
-
// disableAutoFetch keeps pdf.js from eagerly pulling the remaining chunks —
|
|
188
|
-
// correct for a windowed/virtualized viewer that only paints a bounded page
|
|
189
|
-
// window. Range eligibility still requires Content-Length > 2×rangeChunkSize
|
|
190
|
-
// plus Accept-Ranges: bytes (already emitted by GET /api/doc-asset).
|
|
191
|
-
disableStream: true,
|
|
192
|
-
disableAutoFetch: true,
|
|
252
|
+
// Transport tier by file size (fn-136 R1); see transportOptionsFor.
|
|
253
|
+
...transportOptionsFor(params.transport ?? "ranged"),
|
|
193
254
|
// Never enable embedded PDF scripting.
|
|
194
255
|
// enableScripting is intentionally omitted (defaults false).
|
|
195
256
|
// pdfjs v5 removed the former eval-support flag; CSP enforces no unsafe-eval.
|
|
@@ -335,6 +396,12 @@ export function isRenderingCancelled(err: unknown): boolean {
|
|
|
335
396
|
return msg.includes("rendering cancelled") || msg.includes("cancelled");
|
|
336
397
|
}
|
|
337
398
|
|
|
399
|
+
// ── Transport tier constants (fn-136 R1) ────────────────────────────────────
|
|
400
|
+
// Defined in ./pdf-transport (no pdfjs-dist import) and re-exported here so
|
|
401
|
+
// the facade stays the single import surface for app code.
|
|
402
|
+
|
|
403
|
+
export { PDF_RANGE_CHUNK_BYTES, PDF_WHOLE_FILE_MAX_BYTES };
|
|
404
|
+
|
|
338
405
|
// ── Zoom / fit / canvas cap math ────────────────────────────────────────────
|
|
339
406
|
|
|
340
407
|
export const MIN_ZOOM = 0.25;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Shared client view of GET /api/capabilities. */
|
|
2
|
+
|
|
3
|
+
import { apiFetch } from "../hooks/use-api";
|
|
4
|
+
|
|
5
|
+
export interface ServerCapabilities {
|
|
6
|
+
bm25: boolean;
|
|
7
|
+
vector: boolean;
|
|
8
|
+
hybrid: boolean;
|
|
9
|
+
answer: boolean;
|
|
10
|
+
/** True only for a same-host client; a proxied or forwarded request is remote. */
|
|
11
|
+
localClient: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function fetchServerCapabilities(): Promise<{
|
|
15
|
+
data: ServerCapabilities | null;
|
|
16
|
+
error: string | null;
|
|
17
|
+
}> {
|
|
18
|
+
return apiFetch<ServerCapabilities>("/api/capabilities");
|
|
19
|
+
}
|