@gmickel/gno 1.29.5 → 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.
- package/README.md +15 -5
- package/browser-extension/artifacts/{gno-browser-clipper-v1.29.5.zip → gno-browser-clipper-v1.30.1.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.30.1.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +4 -1
- package/src/core/network-boundary-inventory.ts +51 -0
- package/src/ingestion/index.ts +1 -1
- package/src/ingestion/walker.ts +45 -23
- 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/pdf/PdfPageView.tsx +427 -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 +1 -1
- package/src/serve/public/globals.css +113 -0
- package/src/serve/public/hooks/use-pdf-document.ts +227 -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/src/serve/watch-service.ts +219 -23
- package/browser-extension/artifacts/gno-browser-clipper-v1.29.5.zip.sha256 +0 -1
|
@@ -0,0 +1,1197 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
classifyPdfError,
|
|
5
|
+
computeEffectiveScale as defaultComputeEffectiveScale,
|
|
6
|
+
getPdfMetrics as defaultGetPdfMetrics,
|
|
7
|
+
isRenderingCancelled as defaultIsRenderingCancelled,
|
|
8
|
+
type PDFDocumentProxy,
|
|
9
|
+
type PDFPageProxy,
|
|
10
|
+
type PdfFallbackReason,
|
|
11
|
+
type RenderTask,
|
|
12
|
+
} from "../lib/pdf";
|
|
13
|
+
|
|
14
|
+
export const LIVE_CANVAS_CEILING = 10;
|
|
15
|
+
export const OVERSCAN_PAGES = 2;
|
|
16
|
+
/**
|
|
17
|
+
* Visible-set quiescence required before a page that entered the live window
|
|
18
|
+
* during scrolling is admitted to render. A **production behavior constant** —
|
|
19
|
+
* never harness-derived, injected, or tuned from the smoke.
|
|
20
|
+
*
|
|
21
|
+
* Without this, an N-page traversal issues N `renderStart`s because every page
|
|
22
|
+
* transiting the window starts a render (measured: 200 starts on the 200-page
|
|
23
|
+
* P-3 procedure). Pages that enter and leave before quiescence are never
|
|
24
|
+
* admitted and emit no metric events at all.
|
|
25
|
+
*/
|
|
26
|
+
export const SCROLL_QUIESCENCE_MS = 120;
|
|
27
|
+
|
|
28
|
+
export type FitMode = "width" | "page" | "custom";
|
|
29
|
+
|
|
30
|
+
export type PageSlotState = {
|
|
31
|
+
pageNumber: number;
|
|
32
|
+
/** CSS viewport width at current scale */
|
|
33
|
+
width: number;
|
|
34
|
+
/** CSS viewport height at current scale */
|
|
35
|
+
height: number;
|
|
36
|
+
rendered: boolean;
|
|
37
|
+
/** True when IntersectionObserver reports the page is intersecting. */
|
|
38
|
+
visible: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* True when the page is in the live window (visible ± overscan) and should
|
|
41
|
+
* mount canvas / TextLayer. Production PdfPageView `active` prop.
|
|
42
|
+
*/
|
|
43
|
+
active: boolean;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type UsePdfPagesOptions = {
|
|
47
|
+
doc: PDFDocumentProxy | null;
|
|
48
|
+
docId: string | null;
|
|
49
|
+
numPages: number;
|
|
50
|
+
/** Logical zoom (1 = 100%) when fitMode is custom; otherwise a hint. */
|
|
51
|
+
zoom: number;
|
|
52
|
+
fitMode: FitMode;
|
|
53
|
+
containerWidth: number;
|
|
54
|
+
containerHeight: number;
|
|
55
|
+
/** Bumped by viewer on every zoom/fit/scale commit. */
|
|
56
|
+
genId: number;
|
|
57
|
+
devicePixelRatio?: number;
|
|
58
|
+
/**
|
|
59
|
+
* Optional test doubles only (not a product API). Production omits these.
|
|
60
|
+
*/
|
|
61
|
+
getPdfMetrics?: typeof defaultGetPdfMetrics;
|
|
62
|
+
computeEffectiveScale?: typeof defaultComputeEffectiveScale;
|
|
63
|
+
isRenderingCancelled?: typeof defaultIsRenderingCancelled;
|
|
64
|
+
/** Injectable IntersectionObserver for controlled tests. */
|
|
65
|
+
IntersectionObserverImpl?: typeof IntersectionObserver;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type UsePdfPagesResult = {
|
|
69
|
+
slots: PageSlotState[];
|
|
70
|
+
/** Geometry/page acquisition failure classified for the viewer state model. */
|
|
71
|
+
error: PdfFallbackReason | null;
|
|
72
|
+
liveCanvasCount: number;
|
|
73
|
+
observePage: (pageNumber: number, el: HTMLElement | null) => void;
|
|
74
|
+
/**
|
|
75
|
+
* Register a canvas and render when the page is in the live window.
|
|
76
|
+
* Called by PdfPageView when `active` — not a manual test escape hatch for
|
|
77
|
+
* arbitrary off-window pages (those are no-ops).
|
|
78
|
+
*/
|
|
79
|
+
ensureRendered: (
|
|
80
|
+
pageNumber: number,
|
|
81
|
+
canvas: HTMLCanvasElement | null
|
|
82
|
+
) => Promise<void>;
|
|
83
|
+
scale: number;
|
|
84
|
+
/**
|
|
85
|
+
* Awaited disposal barrier: cancel → settle → page.cleanup → canvas zero for
|
|
86
|
+
* every live page. Effect cleanup triggers this but does not await; callers
|
|
87
|
+
* and tests that need completion before document teardown must await this.
|
|
88
|
+
*/
|
|
89
|
+
disposeAll: () => Promise<void>;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
type PageCache = {
|
|
93
|
+
page: PDFPageProxy;
|
|
94
|
+
task: RenderTask | null;
|
|
95
|
+
taskId: string | null;
|
|
96
|
+
startGenId: number | null;
|
|
97
|
+
/**
|
|
98
|
+
* Logical scale this entry was rendered at. A generation bump and the
|
|
99
|
+
* recomputed scale do NOT land in the same React pass (scale is derived from
|
|
100
|
+
* the ResizeObserver-fed container width), so generation identity alone
|
|
101
|
+
* cannot decide "already rendered" — see the same-generation scale change
|
|
102
|
+
* guarded below.
|
|
103
|
+
*/
|
|
104
|
+
startScale: number | null;
|
|
105
|
+
canvas: HTMLCanvasElement | null;
|
|
106
|
+
settled: boolean;
|
|
107
|
+
/** Single-owner cancel promise — concurrent callers share one cancel/settle. */
|
|
108
|
+
cancelClaim: Promise<void> | null;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
type BasePageGeometry = { width: number; height: number };
|
|
112
|
+
|
|
113
|
+
function setsEqual(a: ReadonlySet<number>, b: ReadonlySet<number>): boolean {
|
|
114
|
+
if (a.size !== b.size) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
for (const v of a) {
|
|
118
|
+
if (!b.has(v)) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function computeActiveSet(
|
|
126
|
+
visible: ReadonlySet<number>,
|
|
127
|
+
numPages: number
|
|
128
|
+
): Set<number> {
|
|
129
|
+
const active = new Set<number>();
|
|
130
|
+
for (const p of visible) {
|
|
131
|
+
for (
|
|
132
|
+
let i = Math.max(1, p - OVERSCAN_PAGES);
|
|
133
|
+
i <= Math.min(numPages, p + OVERSCAN_PAGES);
|
|
134
|
+
i++
|
|
135
|
+
) {
|
|
136
|
+
active.add(i);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return active;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Mark a canvas as carrying a live pdfjs backing store (not browser defaults). */
|
|
143
|
+
function markLiveBacking(canvas: HTMLCanvasElement): void {
|
|
144
|
+
canvas.dataset.gnoPdfBacking = "1";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Zero dimensions and clear the live-backing marker (eviction / rollback). */
|
|
148
|
+
function zeroCanvasBacking(canvas: HTMLCanvasElement): void {
|
|
149
|
+
canvas.width = 0;
|
|
150
|
+
canvas.height = 0;
|
|
151
|
+
delete canvas.dataset.gnoPdfBacking;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Virtualized page render scheduling driven by IntersectionObserver.
|
|
156
|
+
* Cancel → await settle → cleanup → zero-dims eviction, with metrics correlation.
|
|
157
|
+
*/
|
|
158
|
+
export function usePdfPages(options: UsePdfPagesOptions): UsePdfPagesResult {
|
|
159
|
+
const {
|
|
160
|
+
doc,
|
|
161
|
+
docId,
|
|
162
|
+
numPages,
|
|
163
|
+
zoom,
|
|
164
|
+
fitMode,
|
|
165
|
+
containerWidth,
|
|
166
|
+
containerHeight,
|
|
167
|
+
genId,
|
|
168
|
+
devicePixelRatio = typeof window !== "undefined"
|
|
169
|
+
? window.devicePixelRatio || 1
|
|
170
|
+
: 1,
|
|
171
|
+
getPdfMetrics = defaultGetPdfMetrics,
|
|
172
|
+
computeEffectiveScale = defaultComputeEffectiveScale,
|
|
173
|
+
isRenderingCancelled = defaultIsRenderingCancelled,
|
|
174
|
+
IntersectionObserverImpl = typeof IntersectionObserver !== "undefined"
|
|
175
|
+
? IntersectionObserver
|
|
176
|
+
: undefined,
|
|
177
|
+
} = options;
|
|
178
|
+
|
|
179
|
+
const [slots, setSlots] = useState<PageSlotState[]>([]);
|
|
180
|
+
const [scale, setScale] = useState(1);
|
|
181
|
+
const [visiblePages, setVisiblePages] = useState<Set<number>>(
|
|
182
|
+
() => new Set()
|
|
183
|
+
);
|
|
184
|
+
const [liveCanvasCount, setLiveCanvasCount] = useState(0);
|
|
185
|
+
const [pageError, setPageError] = useState<PdfFallbackReason | null>(null);
|
|
186
|
+
|
|
187
|
+
const cacheRef = useRef<Map<number, PageCache>>(new Map());
|
|
188
|
+
const elementsRef = useRef<Map<number, HTMLElement>>(new Map());
|
|
189
|
+
const canvasRef = useRef<Map<number, HTMLCanvasElement>>(new Map());
|
|
190
|
+
const reservationsRef = useRef<Map<number, symbol>>(new Map());
|
|
191
|
+
const latestRequestRef = useRef<Map<number, symbol>>(new Map());
|
|
192
|
+
const geometryCacheRef = useRef<
|
|
193
|
+
WeakMap<PDFDocumentProxy, Promise<BasePageGeometry[]>>
|
|
194
|
+
>(new WeakMap());
|
|
195
|
+
const observerRef = useRef<IntersectionObserver | null>(null);
|
|
196
|
+
const settledTaskIdsRef = useRef<Set<string>>(new Set());
|
|
197
|
+
const metrics = getPdfMetrics();
|
|
198
|
+
const genIdRef = useRef(genId);
|
|
199
|
+
genIdRef.current = genId;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Deferred-admission state.
|
|
203
|
+
*
|
|
204
|
+
* `epochSeqRef` is a monotonic counter bumped synchronously on every `docId`
|
|
205
|
+
* or `genId` change. Opening an epoch opens its *exempt batch*: while open,
|
|
206
|
+
* every page passing the ordinary active-set guard is admitted immediately —
|
|
207
|
+
* the whole initial window, and every active page after a zoom/fit commit.
|
|
208
|
+
* The batch closes at the first visible-set mutation occurring *after* it has
|
|
209
|
+
* admitted >= 1 page, and never reopens; only a new epoch opens the next.
|
|
210
|
+
*/
|
|
211
|
+
const epochSeqRef = useRef(0);
|
|
212
|
+
const epochKeyRef = useRef<string | null>(null);
|
|
213
|
+
const epochBatchOpenRef = useRef(true);
|
|
214
|
+
const epochAdmittedRef = useRef(0);
|
|
215
|
+
const pendingRef = useRef<
|
|
216
|
+
Map<
|
|
217
|
+
number,
|
|
218
|
+
{
|
|
219
|
+
docId: string | null;
|
|
220
|
+
genId: number;
|
|
221
|
+
epochSeq: number;
|
|
222
|
+
pageNumber: number;
|
|
223
|
+
canvas: HTMLCanvasElement;
|
|
224
|
+
scale: number;
|
|
225
|
+
}
|
|
226
|
+
>
|
|
227
|
+
>(new Map());
|
|
228
|
+
const pendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
229
|
+
const flushPendingRef = useRef<
|
|
230
|
+
((armedEpochSeq: number) => Promise<void>) | null
|
|
231
|
+
>(null);
|
|
232
|
+
|
|
233
|
+
const clearPending = useCallback((): void => {
|
|
234
|
+
pendingRef.current.clear();
|
|
235
|
+
if (pendingTimerRef.current !== null) {
|
|
236
|
+
clearTimeout(pendingTimerRef.current);
|
|
237
|
+
pendingTimerRef.current = null;
|
|
238
|
+
}
|
|
239
|
+
}, []);
|
|
240
|
+
|
|
241
|
+
// Synchronous epoch bump (same pattern as genIdRef above): a doc or gen change
|
|
242
|
+
// must open its exempt batch before any render path observes the new state.
|
|
243
|
+
{
|
|
244
|
+
const epochKey = `${docId ?? ""}:${genId}`;
|
|
245
|
+
if (epochKeyRef.current !== epochKey) {
|
|
246
|
+
epochKeyRef.current = epochKey;
|
|
247
|
+
epochSeqRef.current += 1;
|
|
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
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const scaleRef = useRef(scale);
|
|
260
|
+
scaleRef.current = scale;
|
|
261
|
+
const visibleRef = useRef(visiblePages);
|
|
262
|
+
visibleRef.current = visiblePages;
|
|
263
|
+
const disposedRef = useRef(false);
|
|
264
|
+
const disposePromiseRef = useRef<Promise<void> | null>(null);
|
|
265
|
+
|
|
266
|
+
const markSettled = useCallback((taskId: string | null | undefined) => {
|
|
267
|
+
if (taskId) {
|
|
268
|
+
settledTaskIdsRef.current.add(taskId);
|
|
269
|
+
}
|
|
270
|
+
}, []);
|
|
271
|
+
|
|
272
|
+
const hasSettled = useCallback((taskId: string | null | undefined) => {
|
|
273
|
+
return Boolean(taskId && settledTaskIdsRef.current.has(taskId));
|
|
274
|
+
}, []);
|
|
275
|
+
|
|
276
|
+
const syncSlotWindow = useCallback(
|
|
277
|
+
(visible: Set<number>) => {
|
|
278
|
+
const active = computeActiveSet(visible, numPages);
|
|
279
|
+
setSlots((prev) =>
|
|
280
|
+
prev.map((s) => ({
|
|
281
|
+
...s,
|
|
282
|
+
visible: visible.has(s.pageNumber),
|
|
283
|
+
active: active.has(s.pageNumber),
|
|
284
|
+
}))
|
|
285
|
+
);
|
|
286
|
+
},
|
|
287
|
+
[numPages]
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
// Resolve rotation-aware geometry with bounded concurrency before publishing
|
|
291
|
+
// slots. Correct placeholders are part of the scroll model: copying page 1
|
|
292
|
+
// makes mixed-size documents jump and makes fit modes overflow later pages.
|
|
293
|
+
useEffect(() => {
|
|
294
|
+
let cancelled = false;
|
|
295
|
+
if (!doc || numPages === 0) {
|
|
296
|
+
setSlots([]);
|
|
297
|
+
setScale(1);
|
|
298
|
+
setPageError(null);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
void (async () => {
|
|
303
|
+
let geometryPromise = geometryCacheRef.current.get(doc);
|
|
304
|
+
if (!geometryPromise) {
|
|
305
|
+
geometryPromise = (async (): Promise<BasePageGeometry[]> => {
|
|
306
|
+
const geometry = Array.from<BasePageGeometry | undefined>({
|
|
307
|
+
length: numPages,
|
|
308
|
+
});
|
|
309
|
+
let nextPageNumber = 1;
|
|
310
|
+
let failure: unknown = null;
|
|
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
|
+
);
|
|
371
|
+
let nextScale = zoom;
|
|
372
|
+
if (fitMode === "width" && containerWidth > 0) {
|
|
373
|
+
nextScale = containerWidth / maxWidth;
|
|
374
|
+
} else if (
|
|
375
|
+
fitMode === "page" &&
|
|
376
|
+
containerWidth > 0 &&
|
|
377
|
+
containerHeight > 0
|
|
378
|
+
) {
|
|
379
|
+
nextScale = Math.min(
|
|
380
|
+
containerWidth / maxWidth,
|
|
381
|
+
containerHeight / maxHeight
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
nextScale = Math.max(0.25, Math.min(4, nextScale));
|
|
385
|
+
setScale(nextScale);
|
|
386
|
+
setPageError(null);
|
|
387
|
+
|
|
388
|
+
const visible = visibleRef.current;
|
|
389
|
+
const active = computeActiveSet(visible, numPages);
|
|
390
|
+
setSlots(
|
|
391
|
+
resolvedGeometry.map((entry, index) => ({
|
|
392
|
+
pageNumber: index + 1,
|
|
393
|
+
width: entry.width * nextScale,
|
|
394
|
+
height: entry.height * nextScale,
|
|
395
|
+
rendered: false,
|
|
396
|
+
visible: visible.has(index + 1),
|
|
397
|
+
active: active.has(index + 1),
|
|
398
|
+
}))
|
|
399
|
+
);
|
|
400
|
+
})();
|
|
401
|
+
|
|
402
|
+
return () => {
|
|
403
|
+
cancelled = true;
|
|
404
|
+
};
|
|
405
|
+
}, [doc, numPages, zoom, fitMode, containerWidth, containerHeight]);
|
|
406
|
+
|
|
407
|
+
// IntersectionObserver drives visibility → slot.active/visible → PdfPageView.
|
|
408
|
+
useEffect(() => {
|
|
409
|
+
if (!IntersectionObserverImpl) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
observerRef.current?.disconnect();
|
|
413
|
+
const observer = new IntersectionObserverImpl(
|
|
414
|
+
(entries) => {
|
|
415
|
+
setVisiblePages((prev) => {
|
|
416
|
+
const next = new Set(prev);
|
|
417
|
+
for (const entry of entries) {
|
|
418
|
+
const pageNumber = Number(
|
|
419
|
+
(entry.target as HTMLElement).dataset.pageNumber
|
|
420
|
+
);
|
|
421
|
+
if (!Number.isFinite(pageNumber)) {
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
if (entry.isIntersecting) {
|
|
425
|
+
next.add(pageNumber);
|
|
426
|
+
} else {
|
|
427
|
+
next.delete(pageNumber);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
// The exempt batch closes at the first visible-set mutation *after* it
|
|
431
|
+
// has admitted >= 1 page. Requiring a prior admission keeps the cold
|
|
432
|
+
// start correct (there, this mutation is what makes the initial pages
|
|
433
|
+
// active); requiring a subsequent mutation keeps later scroll entries
|
|
434
|
+
// out of a batch that is already serving.
|
|
435
|
+
if (!setsEqual(prev, next)) {
|
|
436
|
+
if (epochBatchOpenRef.current && epochAdmittedRef.current > 0) {
|
|
437
|
+
epochBatchOpenRef.current = false;
|
|
438
|
+
}
|
|
439
|
+
// Genuine visible-set churn restarts the quiescence window.
|
|
440
|
+
if (pendingTimerRef.current !== null) {
|
|
441
|
+
clearTimeout(pendingTimerRef.current);
|
|
442
|
+
pendingTimerRef.current = setTimeout(() => {
|
|
443
|
+
void flushPendingRef.current?.(epochSeqRef.current);
|
|
444
|
+
}, SCROLL_QUIESCENCE_MS);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return next;
|
|
448
|
+
});
|
|
449
|
+
},
|
|
450
|
+
{ root: null, rootMargin: "200px 0px", threshold: 0.01 }
|
|
451
|
+
);
|
|
452
|
+
observerRef.current = observer;
|
|
453
|
+
for (const el of elementsRef.current.values()) {
|
|
454
|
+
observer.observe(el);
|
|
455
|
+
}
|
|
456
|
+
return () => {
|
|
457
|
+
observer.disconnect();
|
|
458
|
+
};
|
|
459
|
+
}, [numPages, IntersectionObserverImpl]);
|
|
460
|
+
|
|
461
|
+
// Propagate visible set into slot.visible / slot.active.
|
|
462
|
+
useEffect(() => {
|
|
463
|
+
syncSlotWindow(visiblePages);
|
|
464
|
+
}, [visiblePages, syncSlotWindow]);
|
|
465
|
+
|
|
466
|
+
const observePage = useCallback(
|
|
467
|
+
(pageNumber: number, el: HTMLElement | null) => {
|
|
468
|
+
const prev = elementsRef.current.get(pageNumber);
|
|
469
|
+
if (prev && observerRef.current) {
|
|
470
|
+
observerRef.current.unobserve(prev);
|
|
471
|
+
}
|
|
472
|
+
if (!el) {
|
|
473
|
+
elementsRef.current.delete(pageNumber);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
el.dataset.pageNumber = String(pageNumber);
|
|
477
|
+
elementsRef.current.set(pageNumber, el);
|
|
478
|
+
observerRef.current?.observe(el);
|
|
479
|
+
},
|
|
480
|
+
[]
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Single-owner cancel for a page's in-flight task.
|
|
485
|
+
* Concurrent callers share one cancelClaim promise; at most one renderCancel
|
|
486
|
+
* and one cancelled/failed settle per taskId.
|
|
487
|
+
*/
|
|
488
|
+
const claimCancelInFlight = useCallback(
|
|
489
|
+
async (pageNumber: number): Promise<void> => {
|
|
490
|
+
const cached = cacheRef.current.get(pageNumber);
|
|
491
|
+
if (!cached) {
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (!cached.task || cached.settled || !cached.taskId) {
|
|
495
|
+
cached.task = null;
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (cached.cancelClaim) {
|
|
499
|
+
await cached.cancelClaim;
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (hasSettled(cached.taskId)) {
|
|
503
|
+
cached.task = null;
|
|
504
|
+
cached.settled = true;
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const { task, taskId, startGenId } = cached;
|
|
509
|
+
if (startGenId == null || !docId || !task || !taskId) {
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
cached.cancelClaim = (async () => {
|
|
514
|
+
// Claim is exclusive — record cancel once.
|
|
515
|
+
if (!hasSettled(taskId)) {
|
|
516
|
+
metrics.recordRenderCancel({
|
|
517
|
+
docId,
|
|
518
|
+
pageNumber,
|
|
519
|
+
taskId,
|
|
520
|
+
genId: startGenId,
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
try {
|
|
524
|
+
task.cancel();
|
|
525
|
+
} catch {
|
|
526
|
+
// ignore
|
|
527
|
+
}
|
|
528
|
+
try {
|
|
529
|
+
await task.promise;
|
|
530
|
+
if (!hasSettled(taskId)) {
|
|
531
|
+
metrics.recordRenderSettle({
|
|
532
|
+
docId,
|
|
533
|
+
pageNumber,
|
|
534
|
+
taskId,
|
|
535
|
+
genId: startGenId,
|
|
536
|
+
outcome: "cancelled",
|
|
537
|
+
});
|
|
538
|
+
markSettled(taskId);
|
|
539
|
+
}
|
|
540
|
+
} catch (err) {
|
|
541
|
+
if (hasSettled(taskId)) {
|
|
542
|
+
// already recorded
|
|
543
|
+
} else if (isRenderingCancelled(err)) {
|
|
544
|
+
metrics.recordRenderSettle({
|
|
545
|
+
docId,
|
|
546
|
+
pageNumber,
|
|
547
|
+
taskId,
|
|
548
|
+
genId: startGenId,
|
|
549
|
+
outcome: "cancelled",
|
|
550
|
+
});
|
|
551
|
+
markSettled(taskId);
|
|
552
|
+
} else {
|
|
553
|
+
metrics.recordRenderSettle({
|
|
554
|
+
docId,
|
|
555
|
+
pageNumber,
|
|
556
|
+
taskId,
|
|
557
|
+
genId: startGenId,
|
|
558
|
+
outcome: "failed",
|
|
559
|
+
});
|
|
560
|
+
markSettled(taskId);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
const still = cacheRef.current.get(pageNumber);
|
|
564
|
+
if (still?.taskId === taskId) {
|
|
565
|
+
still.task = null;
|
|
566
|
+
still.settled = true;
|
|
567
|
+
still.cancelClaim = null;
|
|
568
|
+
}
|
|
569
|
+
})();
|
|
570
|
+
|
|
571
|
+
await cached.cancelClaim;
|
|
572
|
+
},
|
|
573
|
+
[docId, metrics, hasSettled, markSettled, isRenderingCancelled]
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
const cancelAndCleanup = useCallback(
|
|
577
|
+
async (pageNumber: number): Promise<void> => {
|
|
578
|
+
const cached = cacheRef.current.get(pageNumber);
|
|
579
|
+
if (!cached) {
|
|
580
|
+
canvasRef.current.delete(pageNumber);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
await claimCancelInFlight(pageNumber);
|
|
584
|
+
const still = cacheRef.current.get(pageNumber);
|
|
585
|
+
if (!still) {
|
|
586
|
+
canvasRef.current.delete(pageNumber);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
try {
|
|
590
|
+
still.page.cleanup();
|
|
591
|
+
} catch {
|
|
592
|
+
// ignore
|
|
593
|
+
}
|
|
594
|
+
if (docId) {
|
|
595
|
+
metrics.recordPageCleanup({ docId, pageNumber });
|
|
596
|
+
}
|
|
597
|
+
if (still.canvas) {
|
|
598
|
+
zeroCanvasBacking(still.canvas);
|
|
599
|
+
}
|
|
600
|
+
cacheRef.current.delete(pageNumber);
|
|
601
|
+
canvasRef.current.delete(pageNumber);
|
|
602
|
+
setLiveCanvasCount(cacheRef.current.size);
|
|
603
|
+
setSlots((prev) =>
|
|
604
|
+
prev.map((s) =>
|
|
605
|
+
s.pageNumber === pageNumber ? { ...s, rendered: false } : s
|
|
606
|
+
)
|
|
607
|
+
);
|
|
608
|
+
},
|
|
609
|
+
[claimCancelInFlight, docId, metrics]
|
|
610
|
+
);
|
|
611
|
+
|
|
612
|
+
// Evict pages outside the live window / over ceiling.
|
|
613
|
+
useEffect(() => {
|
|
614
|
+
if (!docId || disposedRef.current) {
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
const active = computeActiveSet(visiblePages, numPages);
|
|
618
|
+
const live = [...cacheRef.current.keys()];
|
|
619
|
+
const outside = live.filter((p) => !active.has(p));
|
|
620
|
+
const overCeiling = live.length > LIVE_CANVAS_CEILING;
|
|
621
|
+
|
|
622
|
+
if (outside.length === 0 && !overCeiling) {
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const dist = (p: number) => {
|
|
627
|
+
let min = Number.POSITIVE_INFINITY;
|
|
628
|
+
for (const v of visiblePages) {
|
|
629
|
+
min = Math.min(min, Math.abs(v - p));
|
|
630
|
+
}
|
|
631
|
+
if (!Number.isFinite(min) && active.size > 0) {
|
|
632
|
+
for (const v of active) {
|
|
633
|
+
min = Math.min(min, Math.abs(v - p));
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
return min;
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
const toEvict = [
|
|
640
|
+
...outside.sort((a, b) => dist(b) - dist(a)),
|
|
641
|
+
...live.filter((p) => active.has(p)).sort((a, b) => dist(b) - dist(a)),
|
|
642
|
+
];
|
|
643
|
+
|
|
644
|
+
let remaining = cacheRef.current.size;
|
|
645
|
+
for (const p of toEvict) {
|
|
646
|
+
if (!cacheRef.current.has(p)) {
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
const mustEvictOutside = !active.has(p);
|
|
650
|
+
const mustEvictCeiling = remaining > LIVE_CANVAS_CEILING;
|
|
651
|
+
if (!mustEvictOutside && !mustEvictCeiling) {
|
|
652
|
+
break;
|
|
653
|
+
}
|
|
654
|
+
void cancelAndCleanup(p);
|
|
655
|
+
remaining -= 1;
|
|
656
|
+
}
|
|
657
|
+
}, [visiblePages, numPages, docId, cancelAndCleanup]);
|
|
658
|
+
|
|
659
|
+
// Gen bump: cancel in-flight tasks for the old gen only (no synthetic cancel).
|
|
660
|
+
useEffect(() => {
|
|
661
|
+
if (!docId) {
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
for (const [pageNumber, cached] of cacheRef.current) {
|
|
665
|
+
if (
|
|
666
|
+
cached.task &&
|
|
667
|
+
!cached.settled &&
|
|
668
|
+
cached.startGenId != null &&
|
|
669
|
+
cached.startGenId !== genId
|
|
670
|
+
) {
|
|
671
|
+
void claimCancelInFlight(pageNumber);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}, [genId, docId, claimCancelInFlight]);
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Ungated render path — today's render body verbatim. Never consults the
|
|
678
|
+
* admission gate, so a pending flush can never re-defer recursively.
|
|
679
|
+
* `admittedEpochSeq` is the token admission was decided under; it is
|
|
680
|
+
* revalidated after every await alongside the existing dispose/gen/active
|
|
681
|
+
* re-checks, and a mismatch abandons the attempt without starting a render.
|
|
682
|
+
*/
|
|
683
|
+
const startRenderAdmitted = useCallback(
|
|
684
|
+
async (
|
|
685
|
+
pageNumber: number,
|
|
686
|
+
canvas: HTMLCanvasElement | null,
|
|
687
|
+
admittedEpochSeq: number
|
|
688
|
+
) => {
|
|
689
|
+
if (
|
|
690
|
+
!doc ||
|
|
691
|
+
!docId ||
|
|
692
|
+
!canvas ||
|
|
693
|
+
pageNumber < 1 ||
|
|
694
|
+
pageNumber > numPages ||
|
|
695
|
+
disposedRef.current
|
|
696
|
+
) {
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Only pages in the live window (visible ± overscan) may render.
|
|
701
|
+
const active = computeActiveSet(visibleRef.current, numPages);
|
|
702
|
+
if (!active.has(pageNumber)) {
|
|
703
|
+
canvasRef.current.delete(pageNumber);
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
canvasRef.current.set(pageNumber, canvas);
|
|
708
|
+
|
|
709
|
+
const currentGen = genIdRef.current;
|
|
710
|
+
const currentScale = scaleRef.current;
|
|
711
|
+
const requestToken = Symbol(`pdf-page-${pageNumber}`);
|
|
712
|
+
latestRequestRef.current.set(pageNumber, requestToken);
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Full admission identity: {docId, genId, epochSeq, pageNumber, canvas}
|
|
716
|
+
* plus live-window membership and DOM attachment. Re-checked after EVERY
|
|
717
|
+
* await — a canvas replaced or detached while `getPage` was pending must
|
|
718
|
+
* never receive a renderStart, a hidden metric event, or a backing-store
|
|
719
|
+
* allocation.
|
|
720
|
+
*/
|
|
721
|
+
const identityStillValid = (): boolean =>
|
|
722
|
+
!disposedRef.current &&
|
|
723
|
+
genIdRef.current === currentGen &&
|
|
724
|
+
scaleRef.current === currentScale &&
|
|
725
|
+
epochSeqRef.current === admittedEpochSeq &&
|
|
726
|
+
latestRequestRef.current.get(pageNumber) === requestToken &&
|
|
727
|
+
computeActiveSet(visibleRef.current, numPages).has(pageNumber) &&
|
|
728
|
+
canvasRef.current.get(pageNumber) === canvas &&
|
|
729
|
+
canvas.isConnected;
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Same identity, used after a cancel we ourselves initiated. Those paths
|
|
733
|
+
* intentionally drop the page's `canvasRef` entry, so "no entry" means
|
|
734
|
+
* "still ours"; only a *different* canvas claiming the page is a genuine
|
|
735
|
+
* replacement. Ownership is re-asserted so the strict check above holds
|
|
736
|
+
* at the post-`getPage` checkpoint.
|
|
737
|
+
*/
|
|
738
|
+
const identityValidAfterOwnCancel = (): boolean => {
|
|
739
|
+
if (
|
|
740
|
+
disposedRef.current ||
|
|
741
|
+
genIdRef.current !== currentGen ||
|
|
742
|
+
scaleRef.current !== currentScale ||
|
|
743
|
+
epochSeqRef.current !== admittedEpochSeq ||
|
|
744
|
+
latestRequestRef.current.get(pageNumber) !== requestToken ||
|
|
745
|
+
!computeActiveSet(visibleRef.current, numPages).has(pageNumber) ||
|
|
746
|
+
!canvas.isConnected
|
|
747
|
+
) {
|
|
748
|
+
return false;
|
|
749
|
+
}
|
|
750
|
+
const owner = canvasRef.current.get(pageNumber);
|
|
751
|
+
if (owner !== undefined && owner !== canvas) {
|
|
752
|
+
return false;
|
|
753
|
+
}
|
|
754
|
+
canvasRef.current.set(pageNumber, canvas);
|
|
755
|
+
return true;
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
// Evict farthest until this uncached page can reserve one live-canvas
|
|
759
|
+
// slot. Reservations happen before getPage(), so concurrent cold-window
|
|
760
|
+
// admissions cannot all observe the same under-ceiling cache size.
|
|
761
|
+
while (!cacheRef.current.has(pageNumber)) {
|
|
762
|
+
const alreadyReserved = reservationsRef.current.has(pageNumber);
|
|
763
|
+
const usedByOtherPages =
|
|
764
|
+
cacheRef.current.size +
|
|
765
|
+
reservationsRef.current.size -
|
|
766
|
+
(alreadyReserved ? 1 : 0);
|
|
767
|
+
if (usedByOtherPages < LIVE_CANVAS_CEILING) {
|
|
768
|
+
break;
|
|
769
|
+
}
|
|
770
|
+
let farthest = -1;
|
|
771
|
+
let farthestDist = -1;
|
|
772
|
+
for (const p of cacheRef.current.keys()) {
|
|
773
|
+
let min = Number.POSITIVE_INFINITY;
|
|
774
|
+
for (const v of visibleRef.current) {
|
|
775
|
+
min = Math.min(min, Math.abs(v - p));
|
|
776
|
+
}
|
|
777
|
+
if (min > farthestDist) {
|
|
778
|
+
farthestDist = min;
|
|
779
|
+
farthest = p;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
if (farthest < 1) {
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
await cancelAndCleanup(farthest);
|
|
786
|
+
if (!identityValidAfterOwnCancel()) {
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const existing = cacheRef.current.get(pageNumber);
|
|
792
|
+
// In-flight at a different gen → await single-owner cancel, then replace.
|
|
793
|
+
if (
|
|
794
|
+
existing?.task &&
|
|
795
|
+
!existing.settled &&
|
|
796
|
+
existing.startGenId != null &&
|
|
797
|
+
(existing.startGenId !== currentGen ||
|
|
798
|
+
existing.startScale !== currentScale)
|
|
799
|
+
) {
|
|
800
|
+
await claimCancelInFlight(pageNumber);
|
|
801
|
+
if (!identityValidAfterOwnCancel()) {
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const afterCancel = cacheRef.current.get(pageNumber);
|
|
807
|
+
if (
|
|
808
|
+
afterCancel &&
|
|
809
|
+
afterCancel.startGenId === currentGen &&
|
|
810
|
+
afterCancel.startScale === currentScale &&
|
|
811
|
+
afterCancel.canvas === canvas &&
|
|
812
|
+
(afterCancel.task || afterCancel.settled)
|
|
813
|
+
) {
|
|
814
|
+
if (afterCancel.settled && !afterCancel.task) {
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
if (afterCancel.task && !afterCancel.settled) {
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
if (!cacheRef.current.has(pageNumber)) {
|
|
823
|
+
reservationsRef.current.set(pageNumber, requestToken);
|
|
824
|
+
}
|
|
825
|
+
const releaseReservation = (): void => {
|
|
826
|
+
if (reservationsRef.current.get(pageNumber) === requestToken) {
|
|
827
|
+
reservationsRef.current.delete(pageNumber);
|
|
828
|
+
}
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
let page: PDFPageProxy;
|
|
832
|
+
const still = cacheRef.current.get(pageNumber);
|
|
833
|
+
if (still?.page) {
|
|
834
|
+
page = still.page;
|
|
835
|
+
} else {
|
|
836
|
+
try {
|
|
837
|
+
page = await doc.getPage(pageNumber);
|
|
838
|
+
} catch (error) {
|
|
839
|
+
releaseReservation();
|
|
840
|
+
setPageError(classifyPdfError(error));
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Full identity re-check after page acquisition, BEFORE any slot write,
|
|
846
|
+
// metric event, or backing-store allocation.
|
|
847
|
+
if (!identityStillValid()) {
|
|
848
|
+
releaseReservation();
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Release the admission on every pre-commit path. Geometry/DPR helpers
|
|
853
|
+
// are third-party boundaries too; an exception there must not consume a
|
|
854
|
+
// permanent canvas reservation and deadlock later pages at the ceiling.
|
|
855
|
+
const rollbackPreStart = () => {
|
|
856
|
+
releaseReservation();
|
|
857
|
+
zeroCanvasBacking(canvas);
|
|
858
|
+
try {
|
|
859
|
+
page.cleanup();
|
|
860
|
+
} catch {
|
|
861
|
+
// ignore
|
|
862
|
+
}
|
|
863
|
+
if (docId) {
|
|
864
|
+
metrics.recordPageCleanup({ docId, pageNumber });
|
|
865
|
+
}
|
|
866
|
+
cacheRef.current.delete(pageNumber);
|
|
867
|
+
setLiveCanvasCount(cacheRef.current.size);
|
|
868
|
+
};
|
|
869
|
+
|
|
870
|
+
let prepared: {
|
|
871
|
+
viewport: ReturnType<PDFPageProxy["getViewport"]>;
|
|
872
|
+
renderViewport: ReturnType<PDFPageProxy["getViewport"]>;
|
|
873
|
+
effective: ReturnType<typeof defaultComputeEffectiveScale>;
|
|
874
|
+
};
|
|
875
|
+
try {
|
|
876
|
+
const viewport = page.getViewport({ scale: currentScale });
|
|
877
|
+
const effective = computeEffectiveScale({
|
|
878
|
+
zoom: currentScale,
|
|
879
|
+
devicePixelRatio,
|
|
880
|
+
cssWidth: viewport.width,
|
|
881
|
+
cssHeight: viewport.height,
|
|
882
|
+
});
|
|
883
|
+
prepared = {
|
|
884
|
+
viewport,
|
|
885
|
+
effective,
|
|
886
|
+
renderViewport: page.getViewport({
|
|
887
|
+
scale: effective.renderScale,
|
|
888
|
+
}),
|
|
889
|
+
};
|
|
890
|
+
} catch (error) {
|
|
891
|
+
rollbackPreStart();
|
|
892
|
+
setPageError(classifyPdfError(error));
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
const { viewport, effective, renderViewport } = prepared;
|
|
896
|
+
setSlots((prev) =>
|
|
897
|
+
prev.map((s) =>
|
|
898
|
+
s.pageNumber === pageNumber
|
|
899
|
+
? { ...s, width: viewport.width, height: viewport.height }
|
|
900
|
+
: s
|
|
901
|
+
)
|
|
902
|
+
);
|
|
903
|
+
|
|
904
|
+
const taskId = metrics.mintTaskId();
|
|
905
|
+
const startGenId = currentGen;
|
|
906
|
+
|
|
907
|
+
canvas.width = effective.canvasWidth;
|
|
908
|
+
canvas.height = effective.canvasHeight;
|
|
909
|
+
canvas.style.width = `${viewport.width}px`;
|
|
910
|
+
canvas.style.height = `${viewport.height}px`;
|
|
911
|
+
markLiveBacking(canvas);
|
|
912
|
+
|
|
913
|
+
const ctx = canvas.getContext("2d");
|
|
914
|
+
if (!ctx) {
|
|
915
|
+
rollbackPreStart();
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
let task: RenderTask;
|
|
920
|
+
try {
|
|
921
|
+
task = page.render({
|
|
922
|
+
canvasContext: ctx,
|
|
923
|
+
viewport: renderViewport,
|
|
924
|
+
canvas,
|
|
925
|
+
} as never);
|
|
926
|
+
} catch (error) {
|
|
927
|
+
rollbackPreStart();
|
|
928
|
+
setPageError(classifyPdfError(error));
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// Logical committed scale (not DPR/effective render scale).
|
|
933
|
+
metrics.recordRenderStart({
|
|
934
|
+
docId,
|
|
935
|
+
pageNumber,
|
|
936
|
+
taskId,
|
|
937
|
+
genId: startGenId,
|
|
938
|
+
scale: currentScale,
|
|
939
|
+
canvasWidth: effective.canvasWidth,
|
|
940
|
+
canvasHeight: effective.canvasHeight,
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
cacheRef.current.set(pageNumber, {
|
|
944
|
+
page,
|
|
945
|
+
task,
|
|
946
|
+
taskId,
|
|
947
|
+
startGenId,
|
|
948
|
+
startScale: currentScale,
|
|
949
|
+
canvas,
|
|
950
|
+
settled: false,
|
|
951
|
+
cancelClaim: null,
|
|
952
|
+
});
|
|
953
|
+
releaseReservation();
|
|
954
|
+
setPageError(null);
|
|
955
|
+
setLiveCanvasCount(cacheRef.current.size);
|
|
956
|
+
|
|
957
|
+
try {
|
|
958
|
+
await task.promise;
|
|
959
|
+
if (hasSettled(taskId)) {
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
const entry = cacheRef.current.get(pageNumber);
|
|
963
|
+
if (entry?.taskId === taskId && !entry.settled) {
|
|
964
|
+
metrics.recordRenderSettle({
|
|
965
|
+
docId,
|
|
966
|
+
pageNumber,
|
|
967
|
+
taskId,
|
|
968
|
+
genId: startGenId,
|
|
969
|
+
outcome: "completed",
|
|
970
|
+
scale: currentScale,
|
|
971
|
+
});
|
|
972
|
+
markSettled(taskId);
|
|
973
|
+
entry.task = null;
|
|
974
|
+
entry.settled = true;
|
|
975
|
+
setSlots((prev) =>
|
|
976
|
+
prev.map((s) =>
|
|
977
|
+
s.pageNumber === pageNumber ? { ...s, rendered: true } : s
|
|
978
|
+
)
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
} catch (err) {
|
|
982
|
+
if (hasSettled(taskId)) {
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
if (isRenderingCancelled(err)) {
|
|
986
|
+
metrics.recordRenderSettle({
|
|
987
|
+
docId,
|
|
988
|
+
pageNumber,
|
|
989
|
+
taskId,
|
|
990
|
+
genId: startGenId,
|
|
991
|
+
outcome: "cancelled",
|
|
992
|
+
});
|
|
993
|
+
} else {
|
|
994
|
+
metrics.recordRenderSettle({
|
|
995
|
+
docId,
|
|
996
|
+
pageNumber,
|
|
997
|
+
taskId,
|
|
998
|
+
genId: startGenId,
|
|
999
|
+
outcome: "failed",
|
|
1000
|
+
});
|
|
1001
|
+
// Failed (non-cancel) renders: dispose page resources safely.
|
|
1002
|
+
const entry = cacheRef.current.get(pageNumber);
|
|
1003
|
+
if (entry?.taskId === taskId) {
|
|
1004
|
+
try {
|
|
1005
|
+
entry.page.cleanup();
|
|
1006
|
+
} catch {
|
|
1007
|
+
// ignore
|
|
1008
|
+
}
|
|
1009
|
+
if (entry.canvas) {
|
|
1010
|
+
zeroCanvasBacking(entry.canvas);
|
|
1011
|
+
}
|
|
1012
|
+
if (docId) {
|
|
1013
|
+
metrics.recordPageCleanup({ docId, pageNumber });
|
|
1014
|
+
}
|
|
1015
|
+
cacheRef.current.delete(pageNumber);
|
|
1016
|
+
setLiveCanvasCount(cacheRef.current.size);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
markSettled(taskId);
|
|
1020
|
+
const entry = cacheRef.current.get(pageNumber);
|
|
1021
|
+
if (entry?.taskId === taskId) {
|
|
1022
|
+
entry.task = null;
|
|
1023
|
+
entry.settled = true;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
},
|
|
1027
|
+
// genId must be a dependency: PdfPageView starts renders via an effect keyed
|
|
1028
|
+
// on onRender identity. Zoom/fit gen bumps cancel in-flight work in the gen
|
|
1029
|
+
// effect, but active stays true and scale alone is not a reliable re-entry
|
|
1030
|
+
// signal under all fit modes. Changing ensureRendered identity on gen commit
|
|
1031
|
+
// re-invokes onRender so a higher-gen replacement starts after cancel settles
|
|
1032
|
+
// (ordering: start < cancel < cancelled settle < higher-gen start).
|
|
1033
|
+
[
|
|
1034
|
+
doc,
|
|
1035
|
+
docId,
|
|
1036
|
+
numPages,
|
|
1037
|
+
genId,
|
|
1038
|
+
devicePixelRatio,
|
|
1039
|
+
metrics,
|
|
1040
|
+
cancelAndCleanup,
|
|
1041
|
+
claimCancelInFlight,
|
|
1042
|
+
hasSettled,
|
|
1043
|
+
markSettled,
|
|
1044
|
+
computeEffectiveScale,
|
|
1045
|
+
isRenderingCancelled,
|
|
1046
|
+
]
|
|
1047
|
+
);
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Flush the pending set. Captures the epoch it was armed under and no-ops when
|
|
1051
|
+
* stale; atomically claims the map (copy, then clear) before iterating; and
|
|
1052
|
+
* processes entries sequentially so ceiling eviction cannot race.
|
|
1053
|
+
*/
|
|
1054
|
+
const flushPending = useCallback(
|
|
1055
|
+
async (armedEpochSeq: number): Promise<void> => {
|
|
1056
|
+
pendingTimerRef.current = null;
|
|
1057
|
+
if (disposedRef.current || epochSeqRef.current !== armedEpochSeq) {
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
const claimed = [...pendingRef.current.values()];
|
|
1061
|
+
pendingRef.current.clear();
|
|
1062
|
+
for (const entry of claimed) {
|
|
1063
|
+
if (disposedRef.current || epochSeqRef.current !== armedEpochSeq) {
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
if (entry.docId !== docId || entry.genId !== genIdRef.current) {
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
1069
|
+
const active = computeActiveSet(visibleRef.current, numPages);
|
|
1070
|
+
if (!active.has(entry.pageNumber)) {
|
|
1071
|
+
continue;
|
|
1072
|
+
}
|
|
1073
|
+
if (
|
|
1074
|
+
canvasRef.current.get(entry.pageNumber) !== entry.canvas ||
|
|
1075
|
+
!entry.canvas.isConnected
|
|
1076
|
+
) {
|
|
1077
|
+
continue;
|
|
1078
|
+
}
|
|
1079
|
+
const cached = cacheRef.current.get(entry.pageNumber);
|
|
1080
|
+
if (
|
|
1081
|
+
cached?.task &&
|
|
1082
|
+
!cached.settled &&
|
|
1083
|
+
cached.startGenId === genIdRef.current &&
|
|
1084
|
+
cached.startScale === entry.scale
|
|
1085
|
+
) {
|
|
1086
|
+
continue;
|
|
1087
|
+
}
|
|
1088
|
+
if (entry.scale !== scaleRef.current) {
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
await startRenderAdmitted(
|
|
1092
|
+
entry.pageNumber,
|
|
1093
|
+
entry.canvas,
|
|
1094
|
+
armedEpochSeq
|
|
1095
|
+
);
|
|
1096
|
+
}
|
|
1097
|
+
},
|
|
1098
|
+
[docId, numPages, startRenderAdmitted]
|
|
1099
|
+
);
|
|
1100
|
+
|
|
1101
|
+
flushPendingRef.current = flushPending;
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* Guards + admission decision. Admission is decided synchronously at entry,
|
|
1105
|
+
* before any await, and carries an `epochSeq` token.
|
|
1106
|
+
*/
|
|
1107
|
+
const ensureRendered = useCallback(
|
|
1108
|
+
async (pageNumber: number, canvas: HTMLCanvasElement | null) => {
|
|
1109
|
+
if (
|
|
1110
|
+
!doc ||
|
|
1111
|
+
!docId ||
|
|
1112
|
+
!canvas ||
|
|
1113
|
+
pageNumber < 1 ||
|
|
1114
|
+
pageNumber > numPages ||
|
|
1115
|
+
disposedRef.current
|
|
1116
|
+
) {
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// Only pages in the live window (visible ± overscan) may render.
|
|
1121
|
+
const active = computeActiveSet(visibleRef.current, numPages);
|
|
1122
|
+
if (!active.has(pageNumber)) {
|
|
1123
|
+
canvasRef.current.delete(pageNumber);
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
canvasRef.current.set(pageNumber, canvas);
|
|
1127
|
+
|
|
1128
|
+
const admittedEpochSeq = epochSeqRef.current;
|
|
1129
|
+
if (epochBatchOpenRef.current) {
|
|
1130
|
+
epochAdmittedRef.current += 1;
|
|
1131
|
+
await startRenderAdmitted(pageNumber, canvas, admittedEpochSeq);
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// Deferred: schedule, and emit no metric event unless later admitted.
|
|
1136
|
+
pendingRef.current.set(pageNumber, {
|
|
1137
|
+
docId,
|
|
1138
|
+
genId: genIdRef.current,
|
|
1139
|
+
epochSeq: admittedEpochSeq,
|
|
1140
|
+
pageNumber,
|
|
1141
|
+
canvas,
|
|
1142
|
+
scale: scaleRef.current,
|
|
1143
|
+
});
|
|
1144
|
+
// Arm once per pending batch. The quiescence being waited on is that of
|
|
1145
|
+
// the VISIBLE SET (the observer re-arms below on genuine churn); resetting
|
|
1146
|
+
// the timer on every ensureRendered call would let a component that
|
|
1147
|
+
// re-invokes it starve the flush indefinitely.
|
|
1148
|
+
if (pendingTimerRef.current === null) {
|
|
1149
|
+
pendingTimerRef.current = setTimeout(() => {
|
|
1150
|
+
void flushPending(admittedEpochSeq);
|
|
1151
|
+
}, SCROLL_QUIESCENCE_MS);
|
|
1152
|
+
}
|
|
1153
|
+
},
|
|
1154
|
+
[doc, docId, numPages, startRenderAdmitted, flushPending]
|
|
1155
|
+
);
|
|
1156
|
+
|
|
1157
|
+
const disposeAll = useCallback(async (): Promise<void> => {
|
|
1158
|
+
clearPending();
|
|
1159
|
+
if (disposePromiseRef.current) {
|
|
1160
|
+
await disposePromiseRef.current;
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
disposePromiseRef.current = (async () => {
|
|
1164
|
+
disposedRef.current = true;
|
|
1165
|
+
const pages = [...cacheRef.current.keys()];
|
|
1166
|
+
await Promise.all(pages.map((p) => cancelAndCleanup(p)));
|
|
1167
|
+
canvasRef.current.clear();
|
|
1168
|
+
reservationsRef.current.clear();
|
|
1169
|
+
latestRequestRef.current.clear();
|
|
1170
|
+
setLiveCanvasCount(0);
|
|
1171
|
+
})();
|
|
1172
|
+
try {
|
|
1173
|
+
await disposePromiseRef.current;
|
|
1174
|
+
} finally {
|
|
1175
|
+
disposePromiseRef.current = null;
|
|
1176
|
+
// Allow re-use if doc is remounted with same hook instance.
|
|
1177
|
+
disposedRef.current = false;
|
|
1178
|
+
}
|
|
1179
|
+
}, [cancelAndCleanup, clearPending]);
|
|
1180
|
+
|
|
1181
|
+
// Trigger disposal on unmount / doc change (does not await — use disposeAll).
|
|
1182
|
+
useEffect(() => {
|
|
1183
|
+
return () => {
|
|
1184
|
+
void disposeAll();
|
|
1185
|
+
};
|
|
1186
|
+
}, [doc, disposeAll]);
|
|
1187
|
+
|
|
1188
|
+
return {
|
|
1189
|
+
slots,
|
|
1190
|
+
error: pageError,
|
|
1191
|
+
liveCanvasCount,
|
|
1192
|
+
observePage,
|
|
1193
|
+
ensureRendered,
|
|
1194
|
+
scale,
|
|
1195
|
+
disposeAll,
|
|
1196
|
+
};
|
|
1197
|
+
}
|