@rightkit/document-core 0.1.2 → 0.2.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/CHANGELOG.md +23 -0
- package/README.md +31 -7
- package/dist/app/chrome.js +1 -1
- package/dist/app/documentPrinting.d.ts +0 -6
- package/dist/app/documentPrinting.js +0 -18
- package/dist/app/shell/nativeMenu.d.ts +3 -2
- package/dist/app/shell/nativeMenu.js +8 -5
- package/dist/documents/perfEventCounters.d.ts +0 -8
- package/dist/documents/perfEventCounters.js +2 -1
- package/dist/documents/preview/previewFrame.d.ts +2 -1
- package/dist/documents/preview/previewFrame.js +9 -7
- package/dist/documents/searchUtils.js +1 -1
- package/dist/host.d.ts +36 -0
- package/dist/host.js +54 -0
- package/dist/index.d.ts +1 -46
- package/dist/index.js +1 -46
- package/dist/settings/themes.js +1 -1
- package/dist/state/bridgeEvents.d.ts +1 -1
- package/dist/state/bridgeEvents.js +7 -4
- package/dist/state/recentUrls.js +4 -3
- package/dist/state/tauriBridge.d.ts +3 -47
- package/dist/state/tauriBridge.js +6 -162
- package/dist/styles.css +2 -2
- package/package.json +16 -87
- package/dist/app/shortcutChords.d.ts +0 -6
- package/dist/app/shortcutChords.js +0 -14
- package/dist/app/useCloseFindOnSurfaceChange.d.ts +0 -14
- package/dist/app/useCloseFindOnSurfaceChange.js +0 -23
- package/dist/app/useEditText.d.ts +0 -6
- package/dist/app/useEditText.js +0 -10
- package/dist/appName.d.ts +0 -2
- package/dist/appName.js +0 -8
- package/dist/documents/FullscreenIcon.d.ts +0 -4
- package/dist/documents/FullscreenIcon.js +0 -7
- package/dist/documents/mdx/mdxRuntimeScript.d.ts +0 -2
- package/dist/documents/mdx/mdxRuntimeScript.js +0 -3
- package/dist/documents/mdx/mdxRuntimeScript.txt +0 -584
- package/dist/documents/pdf/MacPdfReadPane.d.ts +0 -23
- package/dist/documents/pdf/MacPdfReadPane.js +0 -708
- package/dist/documents/pdf/NativePdfDocument.d.ts +0 -9
- package/dist/documents/pdf/NativePdfDocument.js +0 -41
- package/dist/documents/pdf/PdfInlineTextEditor.d.ts +0 -53
- package/dist/documents/pdf/PdfInlineTextEditor.js +0 -225
- package/dist/documents/pdf/PdfOcrLayer.d.ts +0 -4
- package/dist/documents/pdf/PdfOcrLayer.js +0 -30
- package/dist/documents/pdf/PdfOutline.d.ts +0 -6
- package/dist/documents/pdf/PdfOutline.js +0 -12
- package/dist/documents/pdf/PdfPasswordPrompt.d.ts +0 -5
- package/dist/documents/pdf/PdfPasswordPrompt.js +0 -24
- package/dist/documents/pdf/PdfThumbnails.d.ts +0 -8
- package/dist/documents/pdf/PdfThumbnails.js +0 -201
- package/dist/documents/pdf/PdfToolbar.d.ts +0 -47
- package/dist/documents/pdf/PdfToolbar.js +0 -76
- package/dist/documents/pdf/PdfViewer.d.ts +0 -36
- package/dist/documents/pdf/PdfViewer.js +0 -1522
- package/dist/documents/pdf/PdfViewerBody.d.ts +0 -22
- package/dist/documents/pdf/PdfViewerBody.js +0 -42
- package/dist/documents/pdf/macPdfNativeBridge.d.ts +0 -118
- package/dist/documents/pdf/macPdfNativeBridge.js +0 -134
- package/dist/documents/pdf/nearestVisiblePdfPage.d.ts +0 -2
- package/dist/documents/pdf/nearestVisiblePdfPage.js +0 -22
- package/dist/documents/pdf/pdfBench.d.ts +0 -88
- package/dist/documents/pdf/pdfBench.js +0 -535
- package/dist/documents/pdf/pdfBenchAutopilot.d.ts +0 -48
- package/dist/documents/pdf/pdfBenchAutopilot.js +0 -154
- package/dist/documents/pdf/pdfBenchTransport.d.ts +0 -21
- package/dist/documents/pdf/pdfBenchTransport.js +0 -233
- package/dist/documents/pdf/pdfEditModel.d.ts +0 -294
- package/dist/documents/pdf/pdfEditModel.js +0 -105
- package/dist/documents/pdf/pdfEditor.d.ts +0 -100
- package/dist/documents/pdf/pdfEditor.js +0 -68
- package/dist/documents/pdf/pdfOcr.d.ts +0 -36
- package/dist/documents/pdf/pdfOcr.js +0 -88
- package/dist/documents/pdf/pdfOcrEdit.d.ts +0 -32
- package/dist/documents/pdf/pdfOcrEdit.js +0 -138
- package/dist/documents/pdf/pdfPrinting.d.ts +0 -28
- package/dist/documents/pdf/pdfPrinting.js +0 -110
- package/dist/documents/pdf/pdfSave.d.ts +0 -87
- package/dist/documents/pdf/pdfSave.js +0 -250
- package/dist/documents/pdf/pdfShared.d.ts +0 -16
- package/dist/documents/pdf/pdfShared.js +0 -20
- package/dist/documents/pdf/pdfToMarkdown.d.ts +0 -11
- package/dist/documents/pdf/pdfToMarkdown.js +0 -67
- package/dist/documents/pdf/pdfjsLibGlobal.d.ts +0 -1
- package/dist/documents/pdf/pdfjsLibGlobal.js +0 -12
- package/dist/documents/pdf/toolbarIcons.d.ts +0 -18
- package/dist/documents/pdf/toolbarIcons.js +0 -31
- package/dist/documents/pdf/usePdfDocumentLoader.d.ts +0 -40
- package/dist/documents/pdf/usePdfDocumentLoader.js +0 -243
- package/dist/documents/pdf/usePdfEditSession.d.ts +0 -88
- package/dist/documents/pdf/usePdfEditSession.js +0 -341
- package/dist/documents/pdf/usePdfEditor.d.ts +0 -59
- package/dist/documents/pdf/usePdfEditor.js +0 -317
- package/dist/documents/pdf/usePdfFit.d.ts +0 -19
- package/dist/documents/pdf/usePdfFit.js +0 -55
- package/dist/documents/pdf/usePdfOcr.d.ts +0 -38
- package/dist/documents/pdf/usePdfOcr.js +0 -337
- package/dist/documents/pdf/usePdfPreviewRecompose.d.ts +0 -28
- package/dist/documents/pdf/usePdfPreviewRecompose.js +0 -256
- package/dist/documents/pdf/usePdfSavePlan.d.ts +0 -61
- package/dist/documents/pdf/usePdfSavePlan.js +0 -287
- package/dist/documents/pdf/usePdfSearch.d.ts +0 -31
- package/dist/documents/pdf/usePdfSearch.js +0 -367
- package/dist/documents/pdf/usePdfViewControls.d.ts +0 -72
- package/dist/documents/pdf/usePdfViewControls.js +0 -152
- package/dist/documents/scopedSelectAll.d.ts +0 -5
- package/dist/documents/scopedSelectAll.js +0 -77
- package/dist/documents/txt/cmScroll.d.ts +0 -11
- package/dist/documents/txt/cmScroll.js +0 -52
- package/dist/documents/useMirroredEditText.d.ts +0 -15
- package/dist/documents/useMirroredEditText.js +0 -29
- package/dist/documents/useSplitResize.d.ts +0 -7
- package/dist/documents/useSplitResize.js +0 -37
- package/dist/settings/rk-settings.css +0 -38
- package/dist/settings/sections/EditorSection.d.ts +0 -1
- package/dist/settings/sections/EditorSection.js +0 -18
- package/dist/settings/sections/FileAssociationsSection.d.ts +0 -1
- package/dist/settings/sections/FileAssociationsSection.js +0 -5
- package/dist/settings/sections/MacFileAssociationsBlock.d.ts +0 -33
- package/dist/settings/sections/MacFileAssociationsBlock.js +0 -151
- package/dist/settings/sections/ThemeSection.d.ts +0 -1
- package/dist/settings/sections/ThemeSection.js +0 -31
- package/dist/settings/sections/TypographySection.d.ts +0 -1
- package/dist/settings/sections/TypographySection.js +0 -19
- package/dist/settings/sections/onOff.d.ts +0 -11
- package/dist/settings/sections/onOff.js +0 -8
|
@@ -1,1522 +0,0 @@
|
|
|
1
|
-
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
-
import { createPortal } from "react-dom";
|
|
4
|
-
import { createShortcutRegistry } from "@rightkit/react-shell";
|
|
5
|
-
import * as pdfjs from "pdfjs-dist";
|
|
6
|
-
import { boundedCanvasDpr, highlightSpan, viewportRotation } from "./pdfShared.js";
|
|
7
|
-
import { PdfToolbar } from "./PdfToolbar.js";
|
|
8
|
-
import { PdfViewerBody } from "./PdfViewerBody.js";
|
|
9
|
-
import { PdfPasswordPrompt } from "./PdfPasswordPrompt.js";
|
|
10
|
-
import { usePdfDocumentLoader } from "./usePdfDocumentLoader.js";
|
|
11
|
-
import { usePdfPreviewRecompose } from "./usePdfPreviewRecompose.js";
|
|
12
|
-
import { usePdfSavePlan } from "./usePdfSavePlan.js";
|
|
13
|
-
import { usePdfFit } from "./usePdfFit.js";
|
|
14
|
-
import { acquirePdfRenderKeepalive, createPdfLinkService, } from "@rightkit/pdf-ui";
|
|
15
|
-
import { openExternal } from "../../state/tauriBridge.js";
|
|
16
|
-
import { usePdfSearch } from "./usePdfSearch.js";
|
|
17
|
-
import { usePdfOcr } from "./usePdfOcr.js";
|
|
18
|
-
import { applyOcrCorrections, findOcrLineAt, sampleCanvasRectBackground } from "./pdfOcrEdit.js";
|
|
19
|
-
import { usePdfViewControls } from "./usePdfViewControls.js";
|
|
20
|
-
import { usePdfEditor } from "./usePdfEditor.js";
|
|
21
|
-
import { createPdfEditorTextLayerAdapter, SOURCE_TEXT_COMMAND } from "./pdfEditor.js";
|
|
22
|
-
import { usePdfEditSession } from "./usePdfEditSession.js";
|
|
23
|
-
import { PdfInlineTextEditor } from "./PdfInlineTextEditor.js";
|
|
24
|
-
// New PDF-edit backend commands (docs/PDF-EDITOR-NOW.md §5.2/§7.2/§9.4/§10.1/§10.3). These land in
|
|
25
|
-
// tauriBridge.ts at Dispatch D2 — this lane imports them by name against the frozen type contract
|
|
26
|
-
// in pdfEditModel.ts, per the dispatch brief ("D2 is responsible for making those exports real").
|
|
27
|
-
import { inspectPdfPage, listPdfFonts } from "../../state/tauriBridge.js";
|
|
28
|
-
// Worker-src setup lives in pdfPrinting.ts (module side effect), the one shared resolution point
|
|
29
|
-
// for both the viewer and the print path — see its comment for why the worker URL must never be
|
|
30
|
-
// resolved twice through different chunks.
|
|
31
|
-
import "./pdfPrinting.js";
|
|
32
|
-
import { printPdfDocument } from "./pdfPrinting.js";
|
|
33
|
-
import { acquireResource } from "../../app/resourceCensus.js";
|
|
34
|
-
import { logError, logPerf } from "../../state/diagnostics.js";
|
|
35
|
-
import { errorMessage } from "../../state/errors.js";
|
|
36
|
-
import { isPdfBenchEnabled, markPdfBenchError, markPdfBenchFirstCanvasPaint, registerPdfBenchLatePageNavigator, } from "./pdfBench.js";
|
|
37
|
-
import { nearestVisiblePdfPage } from "./nearestVisiblePdfPage.js";
|
|
38
|
-
// pdf.js's own bundled code (build/pdf.mjs) has at least one call site — converting an existing
|
|
39
|
-
// Stamp annotation into an editable `StampEditor` object, which `AnnotationEditorLayer`'s
|
|
40
|
-
// `#enableAll()`/`updateMode()` does for every existing annotation on entering an editor mode —
|
|
41
|
-
// that dereferences `structTreeLayer` (`parent._structTree.getAriaAttributes(id)`) with NO
|
|
42
|
-
// null-guard, unlike its sibling call sites (which correctly use `?.`). This app deliberately has
|
|
43
|
-
// no struct-tree implementation (Stage 1 doesn't build one — see the comment at its use below), so
|
|
44
|
-
// `null` — the value pdf.js's own `AnnotationEditorLayerBuilder` itself passes through at runtime,
|
|
45
|
-
// despite its generated types (wrongly) declaring the field non-nullable — throws
|
|
46
|
-
// `TypeError: Cannot read properties of null (reading 'getAriaAttributes')` from inside
|
|
47
|
-
// `AnnotationEditorUIManager.updateMode()`'s promise chain the instant a page whose annotations
|
|
48
|
-
// include a Stamp tries to enter ANY editor mode. Since `updateMode()` is invoked fire-and-forget
|
|
49
|
-
// (`void uiManager.updateMode(...)`, matching pdf.js's own viewer), that rejection was entirely
|
|
50
|
-
// invisible: the editor mode's own state (aria-pressed, etc.) still flipped, but every registered
|
|
51
|
-
// page's `AnnotationEditorLayer.updateMode()` — the call that un-hides its div — was never reached,
|
|
52
|
-
// leaving the layer permanently inert (a real DOM node, 0×0, `hidden`) with no visible error. This
|
|
53
|
-
// is why it only reproduces once a document HAS a Stamp annotation already on disk — i.e. after the
|
|
54
|
-
// very first save that includes one, never on a document's first, pre-save edit session. This
|
|
55
|
-
// minimal stub satisfies the one method pdf.js actually calls on it without depending on any deeper
|
|
56
|
-
// pdf.js internals or a real accessibility struct tree.
|
|
57
|
-
const NULL_STRUCT_TREE_LAYER = {
|
|
58
|
-
async getAriaAttributes() {
|
|
59
|
-
return undefined;
|
|
60
|
-
},
|
|
61
|
-
};
|
|
62
|
-
// Pages whose canvas render exceeds this are exempt from scroll-out unload. The
|
|
63
|
-
// unload-on-scroll-out strategy assumes re-rendering is ~20 ms; a page with a
|
|
64
|
-
// huge embedded image (measured: one 6352×3068 image = ~4.7 s per render,
|
|
65
|
-
// re-paid on EVERY re-entry because it exceeds pdf.js's decoded-image cache cap)
|
|
66
|
-
// breaks that assumption, so those keep their canvas alive across scroll-out.
|
|
67
|
-
const COSTLY_RENDER_MS = 250;
|
|
68
|
-
// Byte budget for expensive off-screen page canvases kept alive, evicted stalest-first (LRU by
|
|
69
|
-
// last render/scroll-out). Each page is costed at canvas.width * canvas.height * 4 bytes; canvas
|
|
70
|
-
// dimensions already include devicePixelRatio, so this is the device-pixel backing-store size.
|
|
71
|
-
// There is deliberately no per-count cap: the byte budget is the only bound.
|
|
72
|
-
const MAX_KEPT_CANVAS_BYTES = 32 * 1024 * 1024;
|
|
73
|
-
// Virtualization hysteresis (#138): pages render when within RENDER_ROOT_MARGIN of the viewport
|
|
74
|
-
// and unload only after leaving the wider UNLOAD_ROOT_MARGIN, so direction reversals near the
|
|
75
|
-
// render edge do not churn rasters.
|
|
76
|
-
const RENDER_ROOT_MARGIN = "50% 0px";
|
|
77
|
-
const UNLOAD_ROOT_MARGIN = "150% 0px";
|
|
78
|
-
/** Fallback mask behind the inline source-text editor when the canvas under it isn't uniform. */
|
|
79
|
-
const WHITE_COVER = { r: 1, g: 1, b: 1, alpha: 1 };
|
|
80
|
-
// ---------------------------------------------------------------------------------------------
|
|
81
|
-
// §4/§5.3 source-graph <-> viewport correlation helpers
|
|
82
|
-
// ---------------------------------------------------------------------------------------------
|
|
83
|
-
//
|
|
84
|
-
// The Rust inspector reports islands/quads in PDF user-space (pre-viewport-transform, per
|
|
85
|
-
// pdfEditModel.ts's PdfRect doc comment). pdf.js's DOM never identifies persistent source content
|
|
86
|
-
// (§4), so every hit-test and every on-screen placement goes through pdf.js's own PageViewport
|
|
87
|
-
// transform, computed once per page in renderPage below and cached in viewportsRef.
|
|
88
|
-
/** Point-in-polygon (ray casting) test against one glyph-run quad's 4 corner pairs. Quads may be
|
|
89
|
-
* non-axis-aligned (rotated pages, skewed text matrices), so a plain bounding-box test would
|
|
90
|
-
* over-select; this matches the exact painted quad instead. */
|
|
91
|
-
function quadContainsPoint(quad, x, y) {
|
|
92
|
-
const pts = [
|
|
93
|
-
[quad[0], quad[1]],
|
|
94
|
-
[quad[2], quad[3]],
|
|
95
|
-
[quad[4], quad[5]],
|
|
96
|
-
[quad[6], quad[7]],
|
|
97
|
-
];
|
|
98
|
-
let inside = false;
|
|
99
|
-
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
|
|
100
|
-
const [xi, yi] = pts[i];
|
|
101
|
-
const [xj, yj] = pts[j];
|
|
102
|
-
const intersects = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
|
|
103
|
-
if (intersects)
|
|
104
|
-
inside = !inside;
|
|
105
|
-
}
|
|
106
|
-
return inside;
|
|
107
|
-
}
|
|
108
|
-
/** Axis-aligned PDF-space bounding box over every quad in an island — used both as the hit target's
|
|
109
|
-
* selection rect and (conservatively, see judgment-call note on startSourceEditDraft below) as the
|
|
110
|
-
* draft's available-bounds until the backend's own layout validation runs at Save time (§8). */
|
|
111
|
-
function islandPdfBounds(island) {
|
|
112
|
-
let minX = Number.POSITIVE_INFINITY;
|
|
113
|
-
let minY = Number.POSITIVE_INFINITY;
|
|
114
|
-
let maxX = Number.NEGATIVE_INFINITY;
|
|
115
|
-
let maxY = Number.NEGATIVE_INFINITY;
|
|
116
|
-
for (const quad of island.quads) {
|
|
117
|
-
for (let i = 0; i < 8; i += 2) {
|
|
118
|
-
minX = Math.min(minX, quad[i]);
|
|
119
|
-
maxX = Math.max(maxX, quad[i]);
|
|
120
|
-
minY = Math.min(minY, quad[i + 1]);
|
|
121
|
-
maxY = Math.max(maxY, quad[i + 1]);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
if (!Number.isFinite(minX))
|
|
125
|
-
return { x: 0, y: 0, width: 0, height: 0 };
|
|
126
|
-
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
127
|
-
}
|
|
128
|
-
/** Converts a PDF-space rect to a screen-space (CSS px) rect via the page's current pdf.js
|
|
129
|
-
* viewport, per §6.1's `pdfRectToViewport(session.draft.rect, viewport)`. Converts all four
|
|
130
|
-
* corners (not just two) and takes the bounding box, so rotation is handled correctly. */
|
|
131
|
-
function pdfRectToViewport(rect, viewport) {
|
|
132
|
-
const corners = [
|
|
133
|
-
[rect.x, rect.y],
|
|
134
|
-
[rect.x + rect.width, rect.y],
|
|
135
|
-
[rect.x, rect.y + rect.height],
|
|
136
|
-
[rect.x + rect.width, rect.y + rect.height],
|
|
137
|
-
];
|
|
138
|
-
const converted = corners.map(([x, y]) => viewport.convertToViewportPoint(x, y));
|
|
139
|
-
const xs = converted.map((c) => c[0]);
|
|
140
|
-
const ys = converted.map((c) => c[1]);
|
|
141
|
-
const minX = Math.min(...xs);
|
|
142
|
-
const maxX = Math.max(...xs);
|
|
143
|
-
const minY = Math.min(...ys);
|
|
144
|
-
const maxY = Math.max(...ys);
|
|
145
|
-
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
146
|
-
}
|
|
147
|
-
/** Default appearance for a fresh Add Text draft. The contract's font/size/color toolbar (§3.2) is
|
|
148
|
-
* owned by the frozen PdfToolbar.tsx (Dispatch B), which does not yet expose those controls in
|
|
149
|
-
* this dispatch — see the file-level judgment-call note near `handlePageClick` below. */
|
|
150
|
-
const DEFAULT_TEXT_APPEARANCE = {
|
|
151
|
-
fontId: "source-serif-4",
|
|
152
|
-
family: "Source Serif 4",
|
|
153
|
-
sizePt: 12,
|
|
154
|
-
color: { r: 0, g: 0, b: 0, alpha: 1 },
|
|
155
|
-
};
|
|
156
|
-
export function PdfViewer({ documentPath, url, active = true, initialView, initialEditingAction, onViewState, registerPrint, registerSearchProvider, findBar, findMode, onRequestFind, onRequestReplace, registerPdfSave, registerPdfSavePlan, onDirtyChange, readOnly = false, }) {
|
|
157
|
-
const scrollRef = useRef(null);
|
|
158
|
-
const benchmarkEnabled = isPdfBenchEnabled();
|
|
159
|
-
const effectiveInitialView = benchmarkEnabled ? undefined : initialView;
|
|
160
|
-
const restoredRef = useRef(false); // applied initialView page once per document
|
|
161
|
-
const reportReadyRef = useRef(false); // gate reporting until after the initial restore
|
|
162
|
-
const slotRefs = useRef(new Map());
|
|
163
|
-
const renderedRef = useRef(new Map()); // page → cleanup
|
|
164
|
-
const rasterRenderRef = useRef(new Map());
|
|
165
|
-
const rasterDropRef = useRef(new Map());
|
|
166
|
-
const rasterPresentRef = useRef(new Set());
|
|
167
|
-
const rasterPendingRef = useRef(new Set());
|
|
168
|
-
const visibleRef = useRef(new Set());
|
|
169
|
-
const benchmarkRenderRef = useRef(new Set());
|
|
170
|
-
// Pages whose last canvas render exceeded COSTLY_RENDER_MS, → last-touched
|
|
171
|
-
// timestamp. Used to exempt them from scroll-out unload (see the observer).
|
|
172
|
-
const costlyRef = useRef(new Map());
|
|
173
|
-
const pdfRef = useRef(null);
|
|
174
|
-
// Pane activation is intentionally separate from PDF document lifetime. Pure-view panes may
|
|
175
|
-
// release page rasters while hidden, but an initialized editor keeps its layers/history alive.
|
|
176
|
-
const activeRef = useRef(active);
|
|
177
|
-
activeRef.current = active;
|
|
178
|
-
const previousActiveRef = useRef(active);
|
|
179
|
-
// Census attribution only; renderPage must not re-create itself when the URL changes.
|
|
180
|
-
const documentPathRef = useRef(documentPath);
|
|
181
|
-
documentPathRef.current = documentPath;
|
|
182
|
-
// §4/§9.4: per-page pdf.js viewport (for hit-testing + on-screen placement), the Rust source
|
|
183
|
-
// graph (cached per page so repeated clicks on an already-inspected page skip the round trip),
|
|
184
|
-
// and the in-memory candidate document/pages committed-but-unsaved edits render from.
|
|
185
|
-
const viewportsRef = useRef(new Map());
|
|
186
|
-
const pageGraphsRef = useRef(new Map());
|
|
187
|
-
// Bumped wherever pageGraphsRef is cleared, so an inspection requested before that clear cannot
|
|
188
|
-
// write a pre-save/pre-switch graph back into the cache or into a draft (handlePageClick).
|
|
189
|
-
const pageGraphsGenRef = useRef(0);
|
|
190
|
-
const previewDocRef = useRef(null);
|
|
191
|
-
// `PDFDocumentProxy` itself has no `destroy()` in this pdfjs-dist version — disposal goes through
|
|
192
|
-
// the `PDFDocumentLoadingTask` that produced it, same as `usePdfDocumentLoader`'s own task
|
|
193
|
-
// ownership (`replaceDocument`) and `pdfPrinting.ts`/`pdfBenchTransport.ts`. The preview document
|
|
194
|
-
// is created ad hoc via `pdfjs.getDocument()`, so its disposal likewise goes through this task.
|
|
195
|
-
const previewLoadingTaskRef = useRef(null);
|
|
196
|
-
const previewPagesRef = useRef(new Set());
|
|
197
|
-
// Bumped on every `handleRecomposePreview` call (same pattern as usePdfSearch.ts's
|
|
198
|
-
// `searchSeqRef`) — guards against concurrent publishes (rapid undo/redo) racing: whichever call
|
|
199
|
-
// is no longer the latest by the time its own compose/load finishes discards its own task instead
|
|
200
|
-
// of leaking it or overwriting the refs above with a stale candidate.
|
|
201
|
-
const previewSeqRef = useRef(0);
|
|
202
|
-
// Serialize page decoding within this pane so adjacent scanned pages do not allocate their
|
|
203
|
-
// full mask/image working sets simultaneously. Cancelled queue entries never start decoding.
|
|
204
|
-
const renderTailRef = useRef(Promise.resolve());
|
|
205
|
-
const canvasBytesRef = useRef(new Map());
|
|
206
|
-
const ocrSourceLinesRef = useRef(new Map());
|
|
207
|
-
// Best-known document_sha256 (§5.1) — populated from every inspected page graph, including an
|
|
208
|
-
// empty scanned page, then refreshed to the committed revision's sha after Save.
|
|
209
|
-
const sourceShaRef = useRef(null);
|
|
210
|
-
// Pages a post-commit reload still needs re-rendered, drained by the `[pdfEditor.ready, pdfDoc]`
|
|
211
|
-
// catch-up effect below rather than rendered synchronously right after `replaceDocument` — see
|
|
212
|
-
// that effect's doc comment for why: rendering synchronously there races React's own processing
|
|
213
|
-
// of the `pdfDoc` swap (which is what tears down the OLD AnnotationEditorUIManager and builds the
|
|
214
|
-
// new one), and can leave a page's editor layer registered against a soon-destroyed manager.
|
|
215
|
-
const pendingReloadRerenderPagesRef = useRef(null);
|
|
216
|
-
const saveLockedRef = useRef(false);
|
|
217
|
-
const [saveLocked, setSaveLockedState] = useState(false);
|
|
218
|
-
const setSaveLocked = useCallback((locked) => {
|
|
219
|
-
saveLockedRef.current = locked;
|
|
220
|
-
setSaveLockedState(locked);
|
|
221
|
-
}, []);
|
|
222
|
-
const [scale, setScale] = useState(1);
|
|
223
|
-
const [rotation, setRotation] = useState(0); // 0 | 90 | 180 | 270, applied to every page
|
|
224
|
-
const [current, setCurrent] = useState(1);
|
|
225
|
-
const currentRef = useRef(current);
|
|
226
|
-
currentRef.current = current;
|
|
227
|
-
const [pageDraft, setPageDraft] = useState(null); // in-progress page-box edit
|
|
228
|
-
const [pageShake, setPageShake] = useState(false); // brief shake on an invalid page-box entry
|
|
229
|
-
const [fitMode, setFitMode] = useState("width");
|
|
230
|
-
const [scaleDraft, setScaleDraft] = useState(() => String(Math.round(100 * 100)));
|
|
231
|
-
const [ocrCorrectionError, setOcrCorrectionError] = useState(null);
|
|
232
|
-
const currentRaf = useRef(null);
|
|
233
|
-
const [panel, setPanel] = useState(() => {
|
|
234
|
-
const saved = typeof localStorage !== "undefined" ? localStorage.getItem("vr.pdf.panel") : null;
|
|
235
|
-
return saved === "outline" || saved === "thumbs" ? saved : null;
|
|
236
|
-
});
|
|
237
|
-
useEffect(() => {
|
|
238
|
-
try {
|
|
239
|
-
if (panel)
|
|
240
|
-
localStorage.setItem("vr.pdf.panel", panel);
|
|
241
|
-
else
|
|
242
|
-
localStorage.removeItem("vr.pdf.panel");
|
|
243
|
-
}
|
|
244
|
-
catch {
|
|
245
|
-
/* ignore */
|
|
246
|
-
}
|
|
247
|
-
}, [panel]);
|
|
248
|
-
const { pdfDoc, numPages, base, pageSizes, setPageSizes, error, renderErrors, setRenderErrors, pwPrompt, outline, replaceDocument, } = usePdfDocumentLoader({
|
|
249
|
-
documentPath,
|
|
250
|
-
url,
|
|
251
|
-
initialView: effectiveInitialView,
|
|
252
|
-
pdfRef,
|
|
253
|
-
renderedRef,
|
|
254
|
-
slotRefs,
|
|
255
|
-
visibleRef,
|
|
256
|
-
restoredRef,
|
|
257
|
-
reportReadyRef,
|
|
258
|
-
setCurrent,
|
|
259
|
-
setScale,
|
|
260
|
-
setRotation,
|
|
261
|
-
setFitMode,
|
|
262
|
-
});
|
|
263
|
-
useEffect(() => {
|
|
264
|
-
if (!registerPrint)
|
|
265
|
-
return;
|
|
266
|
-
registerPrint(async () => {
|
|
267
|
-
const doc = pdfRef.current;
|
|
268
|
-
if (!doc)
|
|
269
|
-
return false;
|
|
270
|
-
return printPdfDocument(doc);
|
|
271
|
-
});
|
|
272
|
-
return () => registerPrint(null);
|
|
273
|
-
}, [registerPrint]);
|
|
274
|
-
// Stage 1 editing: FreeText / Ink / Highlight / Stamp via pdf.js's own AnnotationEditorLayer
|
|
275
|
-
// stack. `mode` defaults to "none" (view-first invariant) — see usePdfEditor.ts for the
|
|
276
|
-
// UIManager lifecycle and docs/ENGINEERING-FINDINGS-2026-08.md §4 for why this needs its own
|
|
277
|
-
// hook rather than a flag flip.
|
|
278
|
-
const pdfEditor = usePdfEditor({ pdfDoc, containerRef: scrollRef });
|
|
279
|
-
// §5-§10: source-text edit session (mode/tool/selection/draft/history, checkpointing). Bridge
|
|
280
|
-
// functions are the §5.2/§7.2 backend commands, imported by name from tauriBridge.ts per the
|
|
281
|
-
// dispatch brief (D2 lands the real implementations there). The bridge object is memoized with an
|
|
282
|
-
// empty dep array — `inspectPdfPage`/`listPdfFonts` are stable module-level imports, and
|
|
283
|
-
// usePdfEditSession recreates its own callbacks whenever this reference changes.
|
|
284
|
-
const editBridgeRef = useRef({ inspectPdfPage, listPdfFonts });
|
|
285
|
-
const editSession = usePdfEditSession({
|
|
286
|
-
bridge: editBridgeRef.current,
|
|
287
|
-
sourcePath: documentPath,
|
|
288
|
-
});
|
|
289
|
-
// Reset every per-document cache when the document identity changes (new tab/path). Intentionally
|
|
290
|
-
// keyed on `documentPath` only — this must reset once per document switch, not whenever
|
|
291
|
-
// `editSession.resetForDocument`'s identity happens to change; `editSession`'s own identity is
|
|
292
|
-
// stable across renders (its setters are useCallback-memoized) so it is safely omitted.
|
|
293
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: see comment above.
|
|
294
|
-
useEffect(() => {
|
|
295
|
-
viewportsRef.current = new Map();
|
|
296
|
-
pageGraphsRef.current = new Map();
|
|
297
|
-
pageGraphsGenRef.current++;
|
|
298
|
-
void previewLoadingTaskRef.current?.destroy();
|
|
299
|
-
previewLoadingTaskRef.current = null;
|
|
300
|
-
previewDocRef.current = null;
|
|
301
|
-
previewPagesRef.current = new Set();
|
|
302
|
-
// Invalidate any still-in-flight `handleRecomposePreview` call from the previous document —
|
|
303
|
-
// its `seq` capture can no longer match, so it discards its own task instead of writing a
|
|
304
|
-
// stale preview into the refs just reset above once it resolves after this switch.
|
|
305
|
-
previewSeqRef.current++;
|
|
306
|
-
sourceShaRef.current = null;
|
|
307
|
-
costlyRef.current = new Map();
|
|
308
|
-
editSession.resetForDocument();
|
|
309
|
-
}, [documentPath]);
|
|
310
|
-
// ---------------------------------------------------------------------------------------------
|
|
311
|
-
// §6.2 unified pdf.js command history for source-text commits
|
|
312
|
-
// ---------------------------------------------------------------------------------------------
|
|
313
|
-
//
|
|
314
|
-
// `usePdfEditSession.commitDraft()` already applies the resulting command to `sourceCommands`
|
|
315
|
-
// and calls `publish()` itself (so §9.4's preview recompose and dirty-state tracking work with
|
|
316
|
-
// zero extra wiring here) — see its doc comment. That means the "commit" has already happened by
|
|
317
|
-
// the time we get the command back; `pushSourceCommand`'s helper (pdfEditor.ts) assumes the
|
|
318
|
-
// opposite order (apply happens lazily, driven by `addCommands({ mustExec: true })`), so using it
|
|
319
|
-
// verbatim here would double-apply. Instead we register a *retroactive* history entry directly:
|
|
320
|
-
// `mustExec: false` skips the immediate `cmd()` call (state is already correct), and only
|
|
321
|
-
// undo/redo from here on route through `editSession.revert`/`editSession.apply` so source edits
|
|
322
|
-
// and annotation edits undo/redo in one chronological pdf.js stack (§6.2).
|
|
323
|
-
const registerCommittedSourceCommand = useCallback((command) => {
|
|
324
|
-
const uiManager = pdfEditor.uiManagerRef.current;
|
|
325
|
-
if (!uiManager)
|
|
326
|
-
return; // degrade to sourceDigest-only undo if the UI manager never built
|
|
327
|
-
uiManager.addCommands({
|
|
328
|
-
cmd: () => editSession.apply(command),
|
|
329
|
-
undo: () => editSession.revert(command),
|
|
330
|
-
post: () => editSession.publish(),
|
|
331
|
-
mustExec: false,
|
|
332
|
-
type: SOURCE_TEXT_COMMAND,
|
|
333
|
-
overwriteIfSameType: false,
|
|
334
|
-
keepUndo: false,
|
|
335
|
-
});
|
|
336
|
-
}, [pdfEditor.uiManagerRef, editSession]);
|
|
337
|
-
// Every explicit "commit the active draft" call site (blur/Enter from the inline editor, click
|
|
338
|
-
// another target, Done) routes through this single function so the history registration above
|
|
339
|
-
// and the no-op-on-overflow/no-op-on-empty behavior stay in one place. Selecting a new island or
|
|
340
|
-
// exiting edit mode both call this FIRST (before usePdfEditSession's own selectIsland/
|
|
341
|
-
// exitEditMode, which also commit internally) so that by the time those run, the draft is already
|
|
342
|
-
// null and their internal commit is a no-op — avoiding a double-commit of the same draft id.
|
|
343
|
-
const handleCommitDraft = useCallback(() => {
|
|
344
|
-
const command = editSession.commitDraft();
|
|
345
|
-
if (command)
|
|
346
|
-
registerCommittedSourceCommand(command);
|
|
347
|
-
return command;
|
|
348
|
-
}, [editSession, registerCommittedSourceCommand]);
|
|
349
|
-
// ---------------------------------------------------------------------------------------------
|
|
350
|
-
// §9.4 in-memory candidate preview: recompose + re-render affected pages on every source-history
|
|
351
|
-
// mutation (commit, undo, redo — anything that calls `editSession.publish()`).
|
|
352
|
-
// ---------------------------------------------------------------------------------------------
|
|
353
|
-
const renderPageRef = useRef(async () => { });
|
|
354
|
-
const { invalidatePreview } = usePdfPreviewRecompose({
|
|
355
|
-
documentPath,
|
|
356
|
-
editSession,
|
|
357
|
-
sourceShaRef,
|
|
358
|
-
previewSeqRef,
|
|
359
|
-
previewLoadingTaskRef,
|
|
360
|
-
previewDocRef,
|
|
361
|
-
previewPagesRef,
|
|
362
|
-
renderedRef,
|
|
363
|
-
renderPageRef,
|
|
364
|
-
});
|
|
365
|
-
// ---------------------------------------------------------------------------------------------
|
|
366
|
-
// §10 save orchestration — additive PdfSavePlan registration (see registerPdfSavePlan doc above)
|
|
367
|
-
// ---------------------------------------------------------------------------------------------
|
|
368
|
-
const savePlan = usePdfSavePlan({
|
|
369
|
-
documentPath,
|
|
370
|
-
url,
|
|
371
|
-
editSession,
|
|
372
|
-
pdfEditor,
|
|
373
|
-
handleCommitDraft,
|
|
374
|
-
pageGraphsRef,
|
|
375
|
-
pageGraphsGenRef,
|
|
376
|
-
sourceShaRef,
|
|
377
|
-
visibleRef,
|
|
378
|
-
renderedRef,
|
|
379
|
-
renderPageRef,
|
|
380
|
-
previewSeqRef,
|
|
381
|
-
invalidatePreview,
|
|
382
|
-
previewLoadingTaskRef,
|
|
383
|
-
previewDocRef,
|
|
384
|
-
previewPagesRef,
|
|
385
|
-
pendingReloadRerenderPagesRef,
|
|
386
|
-
replaceDocument,
|
|
387
|
-
registerPdfSavePlan,
|
|
388
|
-
setSaveLocked,
|
|
389
|
-
});
|
|
390
|
-
// ---------------------------------------------------------------------------------------------
|
|
391
|
-
// §3.2/§4 Edit-text mode: click-to-select-or-add over the whole scroll container. The frozen
|
|
392
|
-
// PdfToolbar.tsx (Dispatch B) exposes one "Edit existing PDF text" toggle (`textEditActive` /
|
|
393
|
-
// `onToggleTextEdit`) and no separate "Add text" tool button, so — judgment call — this single
|
|
394
|
-
// mode handles both: clicking an editable island opens a `sourceEdit` draft on it; clicking empty
|
|
395
|
-
// page space opens an `addText` draft anchored at the click (§1's "add text at a clicked page
|
|
396
|
-
// position"); clicking an uneditable island is a no-op (viewer stays usable, §15).
|
|
397
|
-
// ---------------------------------------------------------------------------------------------
|
|
398
|
-
const handlePageClick = useCallback(async (page, pdfX, pdfY) => {
|
|
399
|
-
setOcrCorrectionError(null);
|
|
400
|
-
handleCommitDraft(); // §3.2 "Clicking another page target commits active valid draft"
|
|
401
|
-
let graph = pageGraphsRef.current.get(page);
|
|
402
|
-
if (!graph) {
|
|
403
|
-
// A save commit or document switch may clear the cache while this inspect is in flight; its
|
|
404
|
-
// result then describes the old file. Re-request once against the current file in that case.
|
|
405
|
-
let generation = pageGraphsGenRef.current;
|
|
406
|
-
try {
|
|
407
|
-
graph = await editSession.inspectPage(page);
|
|
408
|
-
if (generation !== pageGraphsGenRef.current) {
|
|
409
|
-
generation = pageGraphsGenRef.current;
|
|
410
|
-
graph = await editSession.inspectPage(page);
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
catch (e) {
|
|
414
|
-
logError("pdf-editor:inspect");
|
|
415
|
-
setOcrCorrectionError(`Couldn't read this page for editing: ${errorMessage(e)}`);
|
|
416
|
-
return;
|
|
417
|
-
}
|
|
418
|
-
if (generation !== pageGraphsGenRef.current)
|
|
419
|
-
return; // cleared again: fail closed, no draft
|
|
420
|
-
pageGraphsRef.current.set(page, graph);
|
|
421
|
-
sourceShaRef.current = graph.documentSha256;
|
|
422
|
-
}
|
|
423
|
-
const viewport = viewportsRef.current.get(page);
|
|
424
|
-
const ocrLines = ocrSourceLinesRef.current.get(page);
|
|
425
|
-
if (viewport && ocrLines && ocrLines.length > 0) {
|
|
426
|
-
const [viewX, viewY] = viewport.convertToViewportPoint(pdfX, pdfY);
|
|
427
|
-
const hit = findOcrLineAt(ocrLines, viewX / viewport.width, viewY / viewport.height);
|
|
428
|
-
if (hit) {
|
|
429
|
-
setOcrCorrectionError("Recognized text is available for search & copy. Editing scanned text is not supported.");
|
|
430
|
-
return;
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
const hitEditable = graph.islands.find((island) => island.editable && island.quads.some((q) => quadContainsPoint(q, pdfX, pdfY)));
|
|
434
|
-
if (hitEditable) {
|
|
435
|
-
const bounds = islandPdfBounds(hitEditable);
|
|
436
|
-
editSession.selectIsland({
|
|
437
|
-
page,
|
|
438
|
-
islandId: hitEditable.id,
|
|
439
|
-
locator: hitEditable.locator,
|
|
440
|
-
rect: bounds,
|
|
441
|
-
});
|
|
442
|
-
editSession.startSourceEditDraft({
|
|
443
|
-
id: crypto.randomUUID(),
|
|
444
|
-
rect: bounds,
|
|
445
|
-
text: hitEditable.text,
|
|
446
|
-
appearance: hitEditable.appearance,
|
|
447
|
-
// Conservative default: no expansion beyond the island's own painted box. True available
|
|
448
|
-
// space (including verified empty neighboring page area, §8) is backend-validated at
|
|
449
|
-
// Save time regardless — this only affects the live overflow hint, not correctness.
|
|
450
|
-
availableBounds: bounds,
|
|
451
|
-
});
|
|
452
|
-
return;
|
|
453
|
-
}
|
|
454
|
-
const hitUneditable = graph.islands.some((island) => !island.editable && island.quads.some((q) => quadContainsPoint(q, pdfX, pdfY)));
|
|
455
|
-
if (hitUneditable)
|
|
456
|
-
return; // "Unsupported content remains viewable & unchanged" (§3.2)
|
|
457
|
-
editSession.startAddTextDraft({
|
|
458
|
-
id: crypto.randomUUID(),
|
|
459
|
-
page,
|
|
460
|
-
anchor: { x: pdfX, y: pdfY },
|
|
461
|
-
rect: {
|
|
462
|
-
x: pdfX,
|
|
463
|
-
y: pdfY,
|
|
464
|
-
width: 160,
|
|
465
|
-
height: DEFAULT_TEXT_APPEARANCE.sizePt * 1.4,
|
|
466
|
-
},
|
|
467
|
-
appearance: DEFAULT_TEXT_APPEARANCE,
|
|
468
|
-
});
|
|
469
|
-
}, [editSession, handleCommitDraft]);
|
|
470
|
-
useEffect(() => {
|
|
471
|
-
const root = scrollRef.current;
|
|
472
|
-
if (!root || editSession.session.mode !== "edit" || editSession.session.tool !== "selectText") {
|
|
473
|
-
return;
|
|
474
|
-
}
|
|
475
|
-
const onClick = (e) => {
|
|
476
|
-
const slot = e.target?.closest("[data-page]");
|
|
477
|
-
if (!slot)
|
|
478
|
-
return;
|
|
479
|
-
// The inline editor and toolbar controls sit "inside edit interaction scope" (§3.2) — a click
|
|
480
|
-
// on the editor's own contenteditable must not be reinterpreted as a page hit-test.
|
|
481
|
-
if (e.target?.closest("[data-pdf-inline-text-editor]"))
|
|
482
|
-
return;
|
|
483
|
-
const page = Number(slot.dataset.page);
|
|
484
|
-
const viewport = viewportsRef.current.get(page);
|
|
485
|
-
if (!viewport)
|
|
486
|
-
return;
|
|
487
|
-
const rect = slot.getBoundingClientRect();
|
|
488
|
-
const [pdfX, pdfY] = viewport.convertToPdfPoint(e.clientX - rect.left, e.clientY - rect.top);
|
|
489
|
-
e.preventDefault();
|
|
490
|
-
e.stopPropagation();
|
|
491
|
-
void handlePageClick(page, pdfX, pdfY);
|
|
492
|
-
};
|
|
493
|
-
root.addEventListener("click", onClick, true);
|
|
494
|
-
return () => root.removeEventListener("click", onClick, true);
|
|
495
|
-
}, [editSession.session.mode, editSession.session.tool, handlePageClick]);
|
|
496
|
-
const setTextEditActive = useCallback((next) => {
|
|
497
|
-
if (next) {
|
|
498
|
-
if (!pdfEditor.ensureReady())
|
|
499
|
-
return;
|
|
500
|
-
editSession.enterEditMode();
|
|
501
|
-
editSession.setTool("selectText");
|
|
502
|
-
// Mutually exclusive with the annotation-editor tools so a click unambiguously means
|
|
503
|
-
// "select/add text" rather than also placing a FreeText/Ink/Highlight/Stamp editor.
|
|
504
|
-
if (pdfEditor.mode !== "none")
|
|
505
|
-
pdfEditor.setMode("none");
|
|
506
|
-
}
|
|
507
|
-
else {
|
|
508
|
-
handleCommitDraft(); // §3.2 "Done": commit active valid draft, then return to view mode.
|
|
509
|
-
editSession.exitEditMode();
|
|
510
|
-
}
|
|
511
|
-
}, [editSession, handleCommitDraft, pdfEditor]);
|
|
512
|
-
// Leaving edit-text mode is the reverse exclusion: picking an annotation tool while mid source
|
|
513
|
-
// edit commits & exits, matching the old pdfTextEdit <-> pdfEditor mutual-exclusion contract.
|
|
514
|
-
const handleSetAnnotationMode = useCallback((mode) => {
|
|
515
|
-
if (mode !== "none" && editSession.session.mode === "edit") {
|
|
516
|
-
handleCommitDraft();
|
|
517
|
-
editSession.exitEditMode();
|
|
518
|
-
}
|
|
519
|
-
pdfEditor.setMode(mode);
|
|
520
|
-
}, [editSession, handleCommitDraft, pdfEditor]);
|
|
521
|
-
// Wave 2 Read mode: drop every active edit surface — annotation mode back to "none", and the
|
|
522
|
-
// source-text session exits through setTextEditActive(false) so an in-flight draft COMMITS
|
|
523
|
-
// rather than being silently discarded.
|
|
524
|
-
useEffect(() => {
|
|
525
|
-
if (!readOnly)
|
|
526
|
-
return;
|
|
527
|
-
if (pdfEditor.mode !== "none")
|
|
528
|
-
pdfEditor.setMode("none");
|
|
529
|
-
if (editSession.session.mode === "edit")
|
|
530
|
-
setTextEditActive(false);
|
|
531
|
-
else
|
|
532
|
-
editSession.cancelDraft();
|
|
533
|
-
}, [readOnly, pdfEditor, editSession, setTextEditActive]);
|
|
534
|
-
// §6.2: Cmd/Ctrl+Z outside text composition drives pdf.js's unified command history (annotations
|
|
535
|
-
// + source edits together); Cmd/Ctrl+Z while a native text input/contenteditable is focused keeps
|
|
536
|
-
// that element's own native undo (we simply don't intercept the key in that case).
|
|
537
|
-
useEffect(() => {
|
|
538
|
-
if (!active)
|
|
539
|
-
return;
|
|
540
|
-
// Matched by the shared registry (Ctrl or Meta + Z, Ctrl + Y; any extra modifiers, IME state and
|
|
541
|
-
// already-prevented events are accepted as before) and never prevents the default action.
|
|
542
|
-
const editableHistory = createShortcutRegistry();
|
|
543
|
-
editableHistory.register({
|
|
544
|
-
id: "pdf.editable-history",
|
|
545
|
-
label: "Keep native undo in editable targets",
|
|
546
|
-
chord: ["ctrlormeta+z", "ctrl+y"],
|
|
547
|
-
hidden: true,
|
|
548
|
-
preventDefault: false,
|
|
549
|
-
ignoreExtraModifiers: true,
|
|
550
|
-
allowComposing: true,
|
|
551
|
-
allowDefaultPrevented: true,
|
|
552
|
-
allowInInput: true,
|
|
553
|
-
run: (event) => {
|
|
554
|
-
// This listener runs after the editable target has handled its native undo, but before any
|
|
555
|
-
// window-bubble listener (including pdf.js's). Keep default action untouched while suppressing
|
|
556
|
-
// pdf.js's second history command for contenteditable/input targets.
|
|
557
|
-
const target = event.target;
|
|
558
|
-
if (!target?.closest?.("input, textarea, [contenteditable='true']"))
|
|
559
|
-
return false;
|
|
560
|
-
event.stopImmediatePropagation();
|
|
561
|
-
},
|
|
562
|
-
});
|
|
563
|
-
const onEditableHistoryKeyDown = (event) => {
|
|
564
|
-
editableHistory.handle(event);
|
|
565
|
-
};
|
|
566
|
-
const undo = () => {
|
|
567
|
-
if (readOnly)
|
|
568
|
-
return;
|
|
569
|
-
if (!saveLockedRef.current && !savePlan.isReloadRequired())
|
|
570
|
-
pdfEditor.uiManagerRef.current?.undo();
|
|
571
|
-
};
|
|
572
|
-
const redo = () => {
|
|
573
|
-
if (readOnly)
|
|
574
|
-
return;
|
|
575
|
-
if (!saveLockedRef.current && !savePlan.isReloadRequired())
|
|
576
|
-
pdfEditor.uiManagerRef.current?.redo();
|
|
577
|
-
};
|
|
578
|
-
// The chords are matched by a registry that this capture-phase listener delegates to, so the
|
|
579
|
-
// listener still runs before pdf.js's own window listener. The old handler accepted Ctrl, Meta
|
|
580
|
-
// or both with any Shift or Alt, skipped an already-prevented event and ignored IME state.
|
|
581
|
-
const historyShortcuts = createShortcutRegistry();
|
|
582
|
-
historyShortcuts.register({
|
|
583
|
-
id: "pdf.history",
|
|
584
|
-
label: "Undo or redo",
|
|
585
|
-
chord: ["ctrlormeta+z", "ctrl+y"],
|
|
586
|
-
hidden: true,
|
|
587
|
-
ignoreExtraModifiers: true,
|
|
588
|
-
allowComposing: true,
|
|
589
|
-
run: (event) => {
|
|
590
|
-
const target = event.target;
|
|
591
|
-
if (target?.closest?.("input, textarea, [contenteditable='true']"))
|
|
592
|
-
return false;
|
|
593
|
-
// Read mode: the UIManager from a prior Markup session is still mounted — swallow the
|
|
594
|
-
// shortcut here so neither this pane nor pdf.js's own window listener mutates history.
|
|
595
|
-
// pdf.js's AnnotationEditorUIManager also installs a window keydown listener. This pane
|
|
596
|
-
// owns history routing, so stop its listener after handling the shortcut; otherwise one
|
|
597
|
-
// keystroke can enter a stale/destroyed CommandManager during tab/document transitions.
|
|
598
|
-
event.stopImmediatePropagation();
|
|
599
|
-
if (readOnly)
|
|
600
|
-
return;
|
|
601
|
-
if (event.shiftKey || (event.ctrlKey && event.key.toLowerCase() === "y"))
|
|
602
|
-
redo();
|
|
603
|
-
else
|
|
604
|
-
undo();
|
|
605
|
-
},
|
|
606
|
-
});
|
|
607
|
-
const onKeyDown = (event) => {
|
|
608
|
-
if (event.defaultPrevented)
|
|
609
|
-
return;
|
|
610
|
-
if (saveLockedRef.current || savePlan.isReloadRequired()) {
|
|
611
|
-
event.preventDefault();
|
|
612
|
-
event.stopImmediatePropagation();
|
|
613
|
-
return;
|
|
614
|
-
}
|
|
615
|
-
historyShortcuts.handle(event);
|
|
616
|
-
};
|
|
617
|
-
window.addEventListener("viewright:undo", undo);
|
|
618
|
-
window.addEventListener("viewright:redo", redo);
|
|
619
|
-
window.addEventListener("keydown", onKeyDown, true);
|
|
620
|
-
document.addEventListener("keydown", onEditableHistoryKeyDown);
|
|
621
|
-
return () => {
|
|
622
|
-
window.removeEventListener("viewright:undo", undo);
|
|
623
|
-
window.removeEventListener("viewright:redo", redo);
|
|
624
|
-
window.removeEventListener("keydown", onKeyDown, true);
|
|
625
|
-
document.removeEventListener("keydown", onEditableHistoryKeyDown);
|
|
626
|
-
};
|
|
627
|
-
}, [active, readOnly, pdfEditor.uiManagerRef, savePlan]);
|
|
628
|
-
// A viewport transform rebuilds text-layer DOM & invalidates cached geometry. Discard (not
|
|
629
|
-
// commit — matches the prior pdfTextEdit behavior) any transient draft instead of leaving a
|
|
630
|
-
// detached inline editor over stale coordinates.
|
|
631
|
-
const textEditViewportRef = useRef({ scale, rotation });
|
|
632
|
-
useEffect(() => {
|
|
633
|
-
const previous = textEditViewportRef.current;
|
|
634
|
-
textEditViewportRef.current = { scale, rotation };
|
|
635
|
-
if (previous.scale !== scale || previous.rotation !== rotation) {
|
|
636
|
-
editSession.cancelDraft();
|
|
637
|
-
}
|
|
638
|
-
}, [scale, rotation, editSession]);
|
|
639
|
-
useEffect(() => {
|
|
640
|
-
onDirtyChange?.(pdfEditor.dirty || editSession.dirty);
|
|
641
|
-
}, [editSession.dirty, pdfEditor.dirty, onDirtyChange]);
|
|
642
|
-
useEffect(() => {
|
|
643
|
-
if (!registerPdfSave)
|
|
644
|
-
return;
|
|
645
|
-
registerPdfSave(() => pdfEditor.saveBytes());
|
|
646
|
-
return () => registerPdfSave(null);
|
|
647
|
-
}, [registerPdfSave, pdfEditor.saveBytes]);
|
|
648
|
-
// Keep the UI manager's viewParameters (used to place/scale new + existing editors) in step
|
|
649
|
-
// with the viewer's own scale/rotation/page state.
|
|
650
|
-
useEffect(() => {
|
|
651
|
-
pdfEditor.syncViewState({ scale, rotation, page: current });
|
|
652
|
-
}, [scale, rotation, current, pdfEditor.syncViewState]);
|
|
653
|
-
const goToPage = useCallback((n) => {
|
|
654
|
-
const p = Math.max(1, Math.min(numPages, n));
|
|
655
|
-
slotRefs.current.get(p)?.scrollIntoView({ block: "start" });
|
|
656
|
-
setCurrent(p);
|
|
657
|
-
}, [numPages]);
|
|
658
|
-
// Link service for the per-page annotation layer. The service object must stay stable (it is
|
|
659
|
-
// captured by renderPage, whose dependency list must not grow), so it delegates through a ref
|
|
660
|
-
// that an effect below keeps pointed at the live navigation callbacks.
|
|
661
|
-
const linkActionsRef = useRef({
|
|
662
|
-
goToDest: () => { },
|
|
663
|
-
goToPage: () => { },
|
|
664
|
-
currentPage: () => 1,
|
|
665
|
-
pagesCount: () => 1,
|
|
666
|
-
});
|
|
667
|
-
const linkServiceRef = useRef(null);
|
|
668
|
-
if (!linkServiceRef.current) {
|
|
669
|
-
linkServiceRef.current = createPdfLinkService({
|
|
670
|
-
goToDest: (dest) => linkActionsRef.current.goToDest(dest),
|
|
671
|
-
goToPage: (n) => linkActionsRef.current.goToPage(n),
|
|
672
|
-
currentPage: () => linkActionsRef.current.currentPage(),
|
|
673
|
-
pagesCount: () => linkActionsRef.current.pagesCount(),
|
|
674
|
-
openExternal,
|
|
675
|
-
});
|
|
676
|
-
}
|
|
677
|
-
// Find lives in its own hook (owns the match model + committed query). It returns the committed
|
|
678
|
-
// query (highlight re-render dependency) and a ref the per-page render reads to apply <mark>s.
|
|
679
|
-
const { queryRef, optionsRef, setPageText, invalidatePageText, rehighlight } = usePdfSearch({
|
|
680
|
-
url,
|
|
681
|
-
pdfRef,
|
|
682
|
-
slotRefs,
|
|
683
|
-
scrollRef,
|
|
684
|
-
goToPage,
|
|
685
|
-
registerSearchProvider,
|
|
686
|
-
});
|
|
687
|
-
const pdfOcr = usePdfOcr({
|
|
688
|
-
documentId: documentPath,
|
|
689
|
-
pdfDoc,
|
|
690
|
-
pdfRef,
|
|
691
|
-
current,
|
|
692
|
-
rotation,
|
|
693
|
-
setPageText,
|
|
694
|
-
invalidatePageText,
|
|
695
|
-
rehighlight,
|
|
696
|
-
});
|
|
697
|
-
const appliedInitialActionRef = useRef(false);
|
|
698
|
-
const [initialActionError, setInitialActionError] = useState(null);
|
|
699
|
-
useEffect(() => {
|
|
700
|
-
if (!initialEditingAction || appliedInitialActionRef.current || !active || !pdfDoc || !base) {
|
|
701
|
-
return;
|
|
702
|
-
}
|
|
703
|
-
// Restore the native reader's page before tools/OCR use the editor's current page.
|
|
704
|
-
const target = Math.max(1, Math.min(numPages, effectiveInitialView?.page ?? 1));
|
|
705
|
-
if (current !== target)
|
|
706
|
-
return;
|
|
707
|
-
if (initialEditingAction.kind !== "ocr" &&
|
|
708
|
-
initialEditingAction.kind !== "ocr-document" &&
|
|
709
|
-
!pdfEditor.ensureReady())
|
|
710
|
-
return;
|
|
711
|
-
appliedInitialActionRef.current = true;
|
|
712
|
-
switch (initialEditingAction.kind) {
|
|
713
|
-
case "annotation":
|
|
714
|
-
handleSetAnnotationMode(initialEditingAction.mode);
|
|
715
|
-
break;
|
|
716
|
-
case "text":
|
|
717
|
-
setTextEditActive(true);
|
|
718
|
-
break;
|
|
719
|
-
case "stamp":
|
|
720
|
-
// A cold editor may still be rendering this page when its image picker completes.
|
|
721
|
-
void pdfEditor
|
|
722
|
-
.addImageStamp(initialEditingAction.file, 15000)
|
|
723
|
-
.then((added) => {
|
|
724
|
-
if (!added) {
|
|
725
|
-
const message = "Couldn't add this image. Wait for the page to load & select the image again.";
|
|
726
|
-
logError("pdf-editor:initial-stamp");
|
|
727
|
-
setInitialActionError(message);
|
|
728
|
-
}
|
|
729
|
-
})
|
|
730
|
-
.catch((error) => {
|
|
731
|
-
logError("pdf-editor:initial-stamp");
|
|
732
|
-
setInitialActionError(`Couldn't add this image: ${errorMessage(error)}`);
|
|
733
|
-
});
|
|
734
|
-
break;
|
|
735
|
-
case "ocr":
|
|
736
|
-
// Explicit native request precedes the editor's text-only eligibility check.
|
|
737
|
-
void pdfOcr.recognizeCurrentPage(true);
|
|
738
|
-
break;
|
|
739
|
-
case "ocr-document":
|
|
740
|
-
void pdfOcr.recognizeDocument();
|
|
741
|
-
break;
|
|
742
|
-
}
|
|
743
|
-
}, [
|
|
744
|
-
active,
|
|
745
|
-
base,
|
|
746
|
-
current,
|
|
747
|
-
effectiveInitialView?.page,
|
|
748
|
-
handleSetAnnotationMode,
|
|
749
|
-
initialEditingAction,
|
|
750
|
-
numPages,
|
|
751
|
-
pdfDoc,
|
|
752
|
-
pdfEditor,
|
|
753
|
-
pdfOcr,
|
|
754
|
-
setTextEditActive,
|
|
755
|
-
]);
|
|
756
|
-
const effectiveOcrLinesByPage = useMemo(() => applyOcrCorrections(pdfOcr.linesByPage, editSession.session.sourceCommands), [pdfOcr.linesByPage, editSession.session.sourceCommands]);
|
|
757
|
-
ocrSourceLinesRef.current = pdfOcr.linesByPage;
|
|
758
|
-
useEffect(() => {
|
|
759
|
-
for (const [page, lines] of effectiveOcrLinesByPage) {
|
|
760
|
-
setPageText(page, lines.map((line) => line.text).join(" "));
|
|
761
|
-
}
|
|
762
|
-
}, [effectiveOcrLinesByPage, setPageText]);
|
|
763
|
-
const ocrErrorMessage = ocrCorrectionError ??
|
|
764
|
-
(pdfOcr.currentError
|
|
765
|
-
? pdfOcr.currentErrorIsBackendUnavailable
|
|
766
|
-
? "This machine cannot recognize text in PDFs."
|
|
767
|
-
: `Text recognition failed: ${pdfOcr.currentError}`
|
|
768
|
-
: undefined);
|
|
769
|
-
// Fit-to-width/page scale math + container-resize re-fit (rAF-coalesced).
|
|
770
|
-
usePdfFit({ scrollRef, base, rotation, fitMode, setScale });
|
|
771
|
-
// Measured page sizes are recorded at render time for the *current* rotation. When rotation
|
|
772
|
-
// changes, those are stale — drop them so every slot falls back to the rotation-aware base
|
|
773
|
-
// until its page re-renders. (Safe on initial load too: pages aren't rendered yet.)
|
|
774
|
-
useEffect(() => {
|
|
775
|
-
setPageSizes(new Map());
|
|
776
|
-
}, [setPageSizes]);
|
|
777
|
-
// --- Render a single page (canvas @ DPR + positioned text layer) ----------------
|
|
778
|
-
// `queryRef.current` / `optionsRef.current` are read fresh at call time and are deliberately NOT
|
|
779
|
-
// dependencies: listing them gave renderPage a new identity on every committed find query, which
|
|
780
|
-
// made the IntersectionObserver effect below tear down and re-rasterize every visible page
|
|
781
|
-
// (visible flicker + CPU spike on each search). usePdfSearch applies highlights in place instead.
|
|
782
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: see comment above.
|
|
783
|
-
const renderPage = useCallback(async (n) => {
|
|
784
|
-
if (!activeRef.current)
|
|
785
|
-
return;
|
|
786
|
-
// §9.4: pages in the committed-but-unsaved candidate set render from the in-memory preview
|
|
787
|
-
// document instead of the on-disk one, so unsaved source edits show as edited truth rather
|
|
788
|
-
// than a DOM overlay (§3.2's "Committed, unsaved source edits display as edited").
|
|
789
|
-
const pdf = (previewPagesRef.current.has(n) ? previewDocRef.current : null) ?? pdfRef.current;
|
|
790
|
-
const slot = slotRefs.current.get(n);
|
|
791
|
-
if (!pdf || !slot)
|
|
792
|
-
return;
|
|
793
|
-
if (renderedRef.current.has(n)) {
|
|
794
|
-
if (!rasterPresentRef.current.has(n) && !rasterPendingRef.current.has(n)) {
|
|
795
|
-
void rasterRenderRef.current.get(n)?.();
|
|
796
|
-
}
|
|
797
|
-
return;
|
|
798
|
-
}
|
|
799
|
-
let disposed = false;
|
|
800
|
-
let renderedPage = null;
|
|
801
|
-
let pageCanvas = null;
|
|
802
|
-
let renderTask = null;
|
|
803
|
-
let textLayer = null;
|
|
804
|
-
// Page rasters are the dominant PDF allocation, so the census tracks each live canvas
|
|
805
|
-
// and its estimated bytes rather than only counting rendered slots.
|
|
806
|
-
let releaseCanvasCensus = null;
|
|
807
|
-
let editorLayer = null;
|
|
808
|
-
let initialRasterGeneration = 0;
|
|
809
|
-
let initialRenderSettled = false;
|
|
810
|
-
// `slot` (PdfViewerBody.tsx's `<div data-page>`) can also hold React-owned children —
|
|
811
|
-
// `<PdfOcrLayer>` and the render-error `<div>`. EVERY node this render appends imperatively
|
|
812
|
-
// (canvas, text/annotation/editor layers, and any `DrawLayer` SVG pdf.js appends later during
|
|
813
|
-
// an active ink-drawing gesture, via `drawLayer.setParent` below) goes inside this one wrapper
|
|
814
|
-
// instead of directly into `slot`, and cleanup removes only the wrapper itself. Previously,
|
|
815
|
-
// cleanup called `slot.replaceChildren()` (wiping React's children too, then re-appending the
|
|
816
|
-
// OCR layer node by hand) — mutating a React-owned child's parent behind React's back, which
|
|
817
|
-
// made React's next reconciliation of that child throw `NotFoundError` from `removeChild`
|
|
818
|
-
// (crashing to ErrorBoundary; reproduced live on scanned PDFs, whose slots have an OCR layer).
|
|
819
|
-
// Appended once, up front (before any `await`), so cleanup can always safely remove it even if
|
|
820
|
-
// a page is scrolled out mid-render, before any pdf.js layer exists yet.
|
|
821
|
-
//
|
|
822
|
-
// Stacking is unaffected by this extra nesting level: `layerRoot` itself is `position: static`
|
|
823
|
-
// (no position/z-index/opacity/transform), so it establishes no stacking context of its own —
|
|
824
|
-
// its absolutely-positioned children (canvas/text/annotation/editor layers, all z-index:auto/0
|
|
825
|
-
// per pdf_viewer.css) paint exactly as if they were still direct children of `slot`. `.pdfOcrLayer`
|
|
826
|
-
// (PdfOcrLayer.tsx) sets `zIndex: 2` specifically so it paints above all of those regardless of
|
|
827
|
-
// DOM order/nesting, so no `insertBefore` is needed to keep it visually on top.
|
|
828
|
-
//
|
|
829
|
-
// `canvasWrapper`: pdf.js's Ink/Highlight `DrawLayer` only paints its `<svg class="draw">`/
|
|
830
|
-
// `<svg class="highlight">` strokes when styled by pdf_viewer.css's `.canvasWrapper svg{...}`
|
|
831
|
-
// rules (position:absolute + mix-blend-mode; unstyled, an svg defaults to `position: static`
|
|
832
|
-
// and paints nothing visible at the stroke's coordinates). That block is entirely
|
|
833
|
-
// `.canvasWrapper svg{...}` descendant rules — no box-model properties on `.canvasWrapper`
|
|
834
|
-
// itself here — so this class is safe to add with zero effect on this wrapper's own layout.
|
|
835
|
-
// (A second, `.pdfViewer .canvasWrapper{ overflow/width/height:100% }` rule exists in
|
|
836
|
-
// pdf_viewer.css, but only under a `.pdfViewer` ancestor class this app's DOM never uses, so
|
|
837
|
-
// it never applies here.)
|
|
838
|
-
const layerRoot = document.createElement("div");
|
|
839
|
-
layerRoot.className = "canvasWrapper";
|
|
840
|
-
slot.appendChild(layerRoot);
|
|
841
|
-
const rasterRoot = document.createElement("div");
|
|
842
|
-
rasterRoot.className = "pdfRasterLayer";
|
|
843
|
-
layerRoot.appendChild(rasterRoot);
|
|
844
|
-
let rasterPainted = false;
|
|
845
|
-
let rasterRepaint = null;
|
|
846
|
-
let rasterGeneration = 0;
|
|
847
|
-
const dropRaster = () => {
|
|
848
|
-
rasterGeneration += 1;
|
|
849
|
-
rasterPainted = false;
|
|
850
|
-
renderTask?.cancel();
|
|
851
|
-
if (pageCanvas) {
|
|
852
|
-
pageCanvas.width = pageCanvas.height = 0;
|
|
853
|
-
pageCanvas.remove();
|
|
854
|
-
pageCanvas = null;
|
|
855
|
-
}
|
|
856
|
-
releaseCanvasCensus?.();
|
|
857
|
-
releaseCanvasCensus = null;
|
|
858
|
-
canvasBytesRef.current.delete(n);
|
|
859
|
-
rasterPresentRef.current.delete(n);
|
|
860
|
-
renderedPage?.cleanup();
|
|
861
|
-
renderedPage = null;
|
|
862
|
-
};
|
|
863
|
-
const cleanup = () => {
|
|
864
|
-
if (disposed)
|
|
865
|
-
return;
|
|
866
|
-
disposed = true;
|
|
867
|
-
rasterPendingRef.current.delete(n);
|
|
868
|
-
rasterRenderRef.current.delete(n);
|
|
869
|
-
rasterDropRef.current.delete(n);
|
|
870
|
-
textLayer?.cancel();
|
|
871
|
-
editorLayer?.destroy();
|
|
872
|
-
editorLayer = null;
|
|
873
|
-
dropRaster();
|
|
874
|
-
layerRoot.remove();
|
|
875
|
-
if (renderedRef.current.get(n) === cleanup)
|
|
876
|
-
renderedRef.current.delete(n);
|
|
877
|
-
};
|
|
878
|
-
renderedRef.current.set(n, cleanup);
|
|
879
|
-
rasterPendingRef.current.add(n);
|
|
880
|
-
const requestRaster = async () => {
|
|
881
|
-
if (disposed || !activeRef.current || rasterPainted || !initialRenderSettled)
|
|
882
|
-
return;
|
|
883
|
-
if (rasterRepaint) {
|
|
884
|
-
const pending = rasterRepaint;
|
|
885
|
-
await pending;
|
|
886
|
-
if (!disposed && activeRef.current && !rasterPresentRef.current.has(n)) {
|
|
887
|
-
await requestRaster();
|
|
888
|
-
}
|
|
889
|
-
return;
|
|
890
|
-
}
|
|
891
|
-
rasterPendingRef.current.add(n);
|
|
892
|
-
const currentPdf = (previewPagesRef.current.has(n) ? previewDocRef.current : null) ?? pdfRef.current;
|
|
893
|
-
const viewport = viewportsRef.current.get(n);
|
|
894
|
-
if (!currentPdf || !viewport) {
|
|
895
|
-
rasterPendingRef.current.delete(n);
|
|
896
|
-
return;
|
|
897
|
-
}
|
|
898
|
-
const previousRender = renderTailRef.current;
|
|
899
|
-
let releaseRender;
|
|
900
|
-
renderTailRef.current = new Promise((resolve) => {
|
|
901
|
-
releaseRender = resolve;
|
|
902
|
-
});
|
|
903
|
-
const startedAt = performance.now();
|
|
904
|
-
const repaint = (async () => {
|
|
905
|
-
const generation = rasterGeneration;
|
|
906
|
-
let page = null;
|
|
907
|
-
let keepaliveRelease = null;
|
|
908
|
-
try {
|
|
909
|
-
await previousRender;
|
|
910
|
-
if (disposed || !activeRef.current || generation !== rasterGeneration)
|
|
911
|
-
return;
|
|
912
|
-
page = await currentPdf.getPage(n);
|
|
913
|
-
if (disposed || !activeRef.current || generation !== rasterGeneration)
|
|
914
|
-
return;
|
|
915
|
-
const dpr = boundedCanvasDpr(viewport, window.devicePixelRatio || 1);
|
|
916
|
-
const canvas = document.createElement("canvas");
|
|
917
|
-
pageCanvas = canvas;
|
|
918
|
-
canvas.width = Math.floor(viewport.width * dpr);
|
|
919
|
-
canvas.height = Math.floor(viewport.height * dpr);
|
|
920
|
-
canvas.style.width = `${viewport.width}px`;
|
|
921
|
-
canvas.style.height = `${viewport.height}px`;
|
|
922
|
-
canvas.className = "absolute left-0 top-0";
|
|
923
|
-
rasterRoot.appendChild(canvas);
|
|
924
|
-
canvasBytesRef.current.set(n, canvas.width * canvas.height * 4);
|
|
925
|
-
releaseCanvasCensus = acquireResource({
|
|
926
|
-
kind: "pdf-page-canvas",
|
|
927
|
-
documentPath: documentPathRef.current,
|
|
928
|
-
format: "pdf",
|
|
929
|
-
estimatedBytes: canvas.width * canvas.height * 4,
|
|
930
|
-
});
|
|
931
|
-
const ctx = canvas.getContext("2d");
|
|
932
|
-
if (!ctx)
|
|
933
|
-
return;
|
|
934
|
-
const renderIntent = isPdfBenchEnabled() ? "print" : "display";
|
|
935
|
-
keepaliveRelease = renderIntent === "display" ? acquirePdfRenderKeepalive() : null;
|
|
936
|
-
renderTask = page.render({
|
|
937
|
-
canvas,
|
|
938
|
-
canvasContext: ctx,
|
|
939
|
-
viewport,
|
|
940
|
-
transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined,
|
|
941
|
-
intent: renderIntent,
|
|
942
|
-
});
|
|
943
|
-
await renderTask.promise;
|
|
944
|
-
if (disposed || !activeRef.current || generation !== rasterGeneration)
|
|
945
|
-
return;
|
|
946
|
-
rasterPainted = true;
|
|
947
|
-
rasterPresentRef.current.add(n);
|
|
948
|
-
logPerf("pdf:page-render", startedAt);
|
|
949
|
-
}
|
|
950
|
-
catch (error) {
|
|
951
|
-
if (!disposed && generation === rasterGeneration) {
|
|
952
|
-
const message = String(error?.message ?? error);
|
|
953
|
-
setRenderErrors((prev) => {
|
|
954
|
-
const next = new Map(prev);
|
|
955
|
-
next.set(n, message);
|
|
956
|
-
return next;
|
|
957
|
-
});
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
|
-
finally {
|
|
961
|
-
rasterPendingRef.current.delete(n);
|
|
962
|
-
keepaliveRelease?.();
|
|
963
|
-
renderTask = null;
|
|
964
|
-
page?.cleanup();
|
|
965
|
-
releaseRender();
|
|
966
|
-
if (!rasterPainted && generation === rasterGeneration)
|
|
967
|
-
dropRaster();
|
|
968
|
-
}
|
|
969
|
-
})();
|
|
970
|
-
rasterRepaint = repaint;
|
|
971
|
-
try {
|
|
972
|
-
await repaint;
|
|
973
|
-
}
|
|
974
|
-
finally {
|
|
975
|
-
if (rasterRepaint === repaint)
|
|
976
|
-
rasterRepaint = null;
|
|
977
|
-
}
|
|
978
|
-
};
|
|
979
|
-
rasterRenderRef.current.set(n, requestRaster);
|
|
980
|
-
rasterDropRef.current.set(n, () => {
|
|
981
|
-
// A partially built page has no complete editor layer to preserve. Remove its queued
|
|
982
|
-
// work/DOM so activation builds every layer again; only settled pages keep editor state.
|
|
983
|
-
if (!initialRenderSettled)
|
|
984
|
-
cleanup();
|
|
985
|
-
else
|
|
986
|
-
dropRaster();
|
|
987
|
-
});
|
|
988
|
-
const previousRender = renderTailRef.current;
|
|
989
|
-
let releaseRender;
|
|
990
|
-
renderTailRef.current = new Promise((resolve) => {
|
|
991
|
-
releaseRender = resolve;
|
|
992
|
-
});
|
|
993
|
-
try {
|
|
994
|
-
await previousRender;
|
|
995
|
-
if (disposed)
|
|
996
|
-
return;
|
|
997
|
-
if (!activeRef.current) {
|
|
998
|
-
cleanup();
|
|
999
|
-
return;
|
|
1000
|
-
}
|
|
1001
|
-
initialRasterGeneration = rasterGeneration;
|
|
1002
|
-
const page = await pdf.getPage(n);
|
|
1003
|
-
renderedPage = page;
|
|
1004
|
-
if (disposed || rasterGeneration !== initialRasterGeneration) {
|
|
1005
|
-
page.cleanup();
|
|
1006
|
-
return;
|
|
1007
|
-
}
|
|
1008
|
-
if (!activeRef.current) {
|
|
1009
|
-
page.cleanup();
|
|
1010
|
-
cleanup();
|
|
1011
|
-
return;
|
|
1012
|
-
}
|
|
1013
|
-
const pageRotation = viewportRotation(page.rotate, rotation);
|
|
1014
|
-
const viewport = page.getViewport({ scale, rotation: pageRotation });
|
|
1015
|
-
viewportsRef.current.set(n, viewport);
|
|
1016
|
-
slot.style.width = `${viewport.width}px`;
|
|
1017
|
-
slot.style.height = `${viewport.height}px`;
|
|
1018
|
-
slot.style.setProperty("--scale-factor", String(scale));
|
|
1019
|
-
// pdf.js 6.x sizes the annotation layer with these three vars, normally defined on the
|
|
1020
|
-
// library's own `.pdfViewer .page` selector — our slots aren't inside one, so set them
|
|
1021
|
-
// here or the layer computes 0x0 and every link collapses to an unclickable point.
|
|
1022
|
-
slot.style.setProperty("--total-scale-factor", String(scale * (page.userUnit ?? 1)));
|
|
1023
|
-
slot.style.setProperty("--scale-round-x", "1px");
|
|
1024
|
-
slot.style.setProperty("--scale-round-y", "1px");
|
|
1025
|
-
// Record the page's intrinsic size so its slot reserves correct space even before render —
|
|
1026
|
-
// this is what stops mixed-orientation PDFs from jumping as you scroll (D1).
|
|
1027
|
-
const iw = viewport.width / scale;
|
|
1028
|
-
const ih = viewport.height / scale;
|
|
1029
|
-
setPageSizes((prev) => {
|
|
1030
|
-
const ex = prev.get(n);
|
|
1031
|
-
if (ex && Math.abs(ex.w - iw) < 0.5 && Math.abs(ex.h - ih) < 0.5)
|
|
1032
|
-
return prev;
|
|
1033
|
-
const next = new Map(prev);
|
|
1034
|
-
next.set(n, { w: iw, h: ih });
|
|
1035
|
-
return next;
|
|
1036
|
-
});
|
|
1037
|
-
const dpr = boundedCanvasDpr(viewport, window.devicePixelRatio || 1);
|
|
1038
|
-
const canvas = document.createElement("canvas");
|
|
1039
|
-
pageCanvas = canvas;
|
|
1040
|
-
canvas.width = Math.floor(viewport.width * dpr);
|
|
1041
|
-
canvas.height = Math.floor(viewport.height * dpr);
|
|
1042
|
-
canvas.style.width = `${viewport.width}px`;
|
|
1043
|
-
canvas.style.height = `${viewport.height}px`;
|
|
1044
|
-
canvas.className = "absolute left-0 top-0";
|
|
1045
|
-
rasterRoot.appendChild(canvas);
|
|
1046
|
-
canvasBytesRef.current.set(n, canvas.width * canvas.height * 4);
|
|
1047
|
-
releaseCanvasCensus = acquireResource({
|
|
1048
|
-
kind: "pdf-page-canvas",
|
|
1049
|
-
documentPath: documentPathRef.current,
|
|
1050
|
-
format: "pdf",
|
|
1051
|
-
estimatedBytes: canvas.width * canvas.height * 4,
|
|
1052
|
-
});
|
|
1053
|
-
const ctx = canvas.getContext("2d");
|
|
1054
|
-
if (!ctx)
|
|
1055
|
-
return;
|
|
1056
|
-
const renderStart = performance.now();
|
|
1057
|
-
const renderIntent = isPdfBenchEnabled() ? "print" : "display";
|
|
1058
|
-
const releaseKeepalive = renderIntent === "display" ? acquirePdfRenderKeepalive() : null;
|
|
1059
|
-
try {
|
|
1060
|
-
renderTask = page.render({
|
|
1061
|
-
canvas,
|
|
1062
|
-
canvasContext: ctx,
|
|
1063
|
-
viewport,
|
|
1064
|
-
transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined,
|
|
1065
|
-
// Bench renders use print intent for deterministic headless timing. Installed display
|
|
1066
|
-
// renders keep display semantics; scoped keepalive advances pdf.js only when WKWebView
|
|
1067
|
-
// pauses animation frames behind another app.
|
|
1068
|
-
intent: renderIntent,
|
|
1069
|
-
});
|
|
1070
|
-
await renderTask.promise;
|
|
1071
|
-
}
|
|
1072
|
-
finally {
|
|
1073
|
-
releaseKeepalive?.();
|
|
1074
|
-
}
|
|
1075
|
-
if (disposed || rasterGeneration !== initialRasterGeneration)
|
|
1076
|
-
return;
|
|
1077
|
-
rasterPainted = true;
|
|
1078
|
-
rasterPresentRef.current.add(n);
|
|
1079
|
-
rasterPendingRef.current.delete(n);
|
|
1080
|
-
markPdfBenchFirstCanvasPaint(documentPathRef.current, n);
|
|
1081
|
-
logPerf("pdf:page-render", renderStart);
|
|
1082
|
-
// An expensive render marks the page for scroll-out keep-alive; a page
|
|
1083
|
-
// that later renders fast again (e.g. after a zoom-out shrank the raster
|
|
1084
|
-
// work) demotes itself so the slot goes back to normal unloading.
|
|
1085
|
-
if (performance.now() - renderStart > COSTLY_RENDER_MS) {
|
|
1086
|
-
costlyRef.current.set(n, performance.now());
|
|
1087
|
-
}
|
|
1088
|
-
else {
|
|
1089
|
-
costlyRef.current.delete(n);
|
|
1090
|
-
}
|
|
1091
|
-
const textDiv = document.createElement("div");
|
|
1092
|
-
textDiv.className = "textLayer";
|
|
1093
|
-
layerRoot.appendChild(textDiv);
|
|
1094
|
-
textLayer = new pdfjs.TextLayer({
|
|
1095
|
-
textContentSource: page.streamTextContent(),
|
|
1096
|
-
container: textDiv,
|
|
1097
|
-
viewport,
|
|
1098
|
-
});
|
|
1099
|
-
await textLayer.render();
|
|
1100
|
-
if (disposed || rasterGeneration !== initialRasterGeneration)
|
|
1101
|
-
return;
|
|
1102
|
-
// Apply find highlights to this freshly-rendered text layer.
|
|
1103
|
-
const q = queryRef.current;
|
|
1104
|
-
if (q)
|
|
1105
|
-
textDiv
|
|
1106
|
-
.querySelectorAll("span")
|
|
1107
|
-
.forEach((s) => highlightSpan(s, q, optionsRef.current));
|
|
1108
|
-
// Annotation layer: interactive DOM (clickable links, note popups) on top of the canvas.
|
|
1109
|
-
// Visual appearances are already painted into the canvas by page.render; this layer only
|
|
1110
|
-
// adds the hit targets. `annotationEditorUIManager`/`annotationStorage` are wired (Stage 1)
|
|
1111
|
-
// so existing annotations interoperate with the editor layer below; `renderForms` stays off
|
|
1112
|
-
// — form-field editing is out of Stage 1's scope.
|
|
1113
|
-
const annotations = await page.getAnnotations({ intent: "display" });
|
|
1114
|
-
if (disposed || rasterGeneration !== initialRasterGeneration)
|
|
1115
|
-
return;
|
|
1116
|
-
const layerViewport = viewport.clone({ dontFlip: true });
|
|
1117
|
-
const uiManager = pdfEditor.uiManagerRef.current;
|
|
1118
|
-
let annotationLayer = null;
|
|
1119
|
-
if (annotations.length > 0) {
|
|
1120
|
-
const annotationDiv = document.createElement("div");
|
|
1121
|
-
annotationDiv.className = "annotationLayer";
|
|
1122
|
-
layerRoot.appendChild(annotationDiv);
|
|
1123
|
-
annotationLayer = new pdfjs.AnnotationLayer({
|
|
1124
|
-
div: annotationDiv,
|
|
1125
|
-
accessibilityManager: null,
|
|
1126
|
-
annotationCanvasMap: null,
|
|
1127
|
-
annotationEditorUIManager: uiManager,
|
|
1128
|
-
page,
|
|
1129
|
-
viewport: layerViewport,
|
|
1130
|
-
structTreeLayer: NULL_STRUCT_TREE_LAYER,
|
|
1131
|
-
commentManager: null,
|
|
1132
|
-
linkService: linkServiceRef.current,
|
|
1133
|
-
annotationStorage: pdf.annotationStorage,
|
|
1134
|
-
});
|
|
1135
|
-
await annotationLayer.render({
|
|
1136
|
-
annotations,
|
|
1137
|
-
div: annotationDiv,
|
|
1138
|
-
page,
|
|
1139
|
-
viewport: layerViewport,
|
|
1140
|
-
// The declared param type is pdf.js's own web-viewer class; the layer only calls the
|
|
1141
|
-
// IPDFLinkService surface our structural service implements (see @rightkit/pdf-ui linkService).
|
|
1142
|
-
linkService: linkServiceRef.current,
|
|
1143
|
-
renderForms: false,
|
|
1144
|
-
imageResourcesPath: "",
|
|
1145
|
-
});
|
|
1146
|
-
if (disposed || rasterGeneration !== initialRasterGeneration)
|
|
1147
|
-
return;
|
|
1148
|
-
}
|
|
1149
|
-
// Editor layer: the interactive surface for FreeText/Ink/Highlight/Stamp editors (Stage 1).
|
|
1150
|
-
// Built even on pages with zero existing annotations — a new editor can be placed on any
|
|
1151
|
-
// page. `uiManager` is null until usePdfEditor finishes constructing it for this document
|
|
1152
|
-
// (one render tick after pdfDoc loads), so an early page render just skips it; it re-renders
|
|
1153
|
-
// on the next scroll-driven visibility pass once the manager exists (editor layers are
|
|
1154
|
-
// otherwise cheap/idempotent to add — the IntersectionObserver re-invokes renderPage for
|
|
1155
|
-
// any page whose renderedRef entry was cleared, and a fresh document load always clears it).
|
|
1156
|
-
if (uiManager) {
|
|
1157
|
-
// The interactive editor layer is optional: it must never be able to blank the base page.
|
|
1158
|
-
// If its (viewer-internal, environment-sensitive) construction/render throws, degrade to a
|
|
1159
|
-
// page with no editor layer rather than failing the whole page render in the outer catch.
|
|
1160
|
-
try {
|
|
1161
|
-
const editorDiv = document.createElement("div");
|
|
1162
|
-
editorDiv.className = "annotationEditorLayer";
|
|
1163
|
-
layerRoot.appendChild(editorDiv);
|
|
1164
|
-
const drawLayer = new pdfjs.DrawLayer({ pageIndex: n - 1 });
|
|
1165
|
-
// Ink-drawing SVGs pdf.js appends later (mid-gesture, well after this render call
|
|
1166
|
-
// returns) must land inside `layerRoot` too, not directly in `slot` — see `layerRoot`'s
|
|
1167
|
-
// doc comment above.
|
|
1168
|
-
drawLayer.setParent(layerRoot);
|
|
1169
|
-
// `NULL_STRUCT_TREE_LAYER`, not `null` — see its own doc comment (top of file) for the
|
|
1170
|
-
// exact crash a bare `null` here causes the instant a page's annotations include a
|
|
1171
|
-
// Stamp. structTreeLayer is declared non-nullable in pdfjs-dist's generated types
|
|
1172
|
-
// regardless, so cast this one boundary rather than fight the (viewer-internal-only)
|
|
1173
|
-
// types.
|
|
1174
|
-
editorLayer = new pdfjs.AnnotationEditorLayer({
|
|
1175
|
-
uiManager,
|
|
1176
|
-
pageIndex: n - 1,
|
|
1177
|
-
div: editorDiv,
|
|
1178
|
-
structTreeLayer: NULL_STRUCT_TREE_LAYER,
|
|
1179
|
-
accessibilityManager: undefined,
|
|
1180
|
-
annotationLayer: annotationLayer ?? undefined,
|
|
1181
|
-
drawLayer,
|
|
1182
|
-
textLayer: createPdfEditorTextLayerAdapter(textDiv),
|
|
1183
|
-
viewport: layerViewport,
|
|
1184
|
-
l10n: uiManager.l10n,
|
|
1185
|
-
});
|
|
1186
|
-
await editorLayer.render({ viewport: layerViewport });
|
|
1187
|
-
if (disposed || rasterGeneration !== initialRasterGeneration)
|
|
1188
|
-
return;
|
|
1189
|
-
}
|
|
1190
|
-
catch (editorError) {
|
|
1191
|
-
if (benchmarkEnabled)
|
|
1192
|
-
markPdfBenchError(documentPathRef.current, editorError);
|
|
1193
|
-
// Previously silent (no console/diagnostics trail at all) — degrading to "no editor
|
|
1194
|
-
// layer" is the right recovery, but a page silently losing its editor layer (ink/
|
|
1195
|
-
// highlight/stamp become uninteractable, with no visible error) still needs to be
|
|
1196
|
-
// diagnosable after the fact.
|
|
1197
|
-
logError("pdf-editor:layer-init");
|
|
1198
|
-
editorLayer = null;
|
|
1199
|
-
}
|
|
1200
|
-
}
|
|
1201
|
-
}
|
|
1202
|
-
catch (e) {
|
|
1203
|
-
if (!disposed && rasterGeneration === initialRasterGeneration) {
|
|
1204
|
-
if (benchmarkEnabled)
|
|
1205
|
-
markPdfBenchError(documentPathRef.current, e);
|
|
1206
|
-
const message = String(e?.message ?? e);
|
|
1207
|
-
setRenderErrors((prev) => {
|
|
1208
|
-
const next = new Map(prev);
|
|
1209
|
-
next.set(n, message);
|
|
1210
|
-
return next;
|
|
1211
|
-
});
|
|
1212
|
-
}
|
|
1213
|
-
}
|
|
1214
|
-
finally {
|
|
1215
|
-
// Settled page rasters & editor DOM/history have separate ownership from pdf.js's
|
|
1216
|
-
// operator lists/decoded image objects. Release those caches after editing renders too;
|
|
1217
|
-
// cleanup defers until pending render tasks finish & leaves editor layers intact.
|
|
1218
|
-
rasterPendingRef.current.delete(n);
|
|
1219
|
-
renderedPage?.cleanup();
|
|
1220
|
-
renderedPage = null;
|
|
1221
|
-
releaseRender();
|
|
1222
|
-
if (!rasterPainted && rasterGeneration === initialRasterGeneration)
|
|
1223
|
-
dropRaster();
|
|
1224
|
-
initialRenderSettled = true;
|
|
1225
|
-
if (!disposed && activeRef.current && !rasterPresentRef.current.has(n)) {
|
|
1226
|
-
void requestRaster();
|
|
1227
|
-
}
|
|
1228
|
-
}
|
|
1229
|
-
}, [benchmarkEnabled, scale, rotation, setPageSizes, setRenderErrors, pdfEditor.uiManagerRef]);
|
|
1230
|
-
renderPageRef.current = renderPage;
|
|
1231
|
-
// Hidden pure-view panes keep their PDFDocumentProxy/worker/search/OCR state, but their page
|
|
1232
|
-
// rasters are disposable. Existing cleanup cancels queued/in-flight renders, zeroes detached
|
|
1233
|
-
// canvases, releases census bytes & calls page.cleanup(). Initialized editor panes retain their
|
|
1234
|
-
// layers/history/UIManager, but drop only base canvases and repaint them after activation.
|
|
1235
|
-
useEffect(() => {
|
|
1236
|
-
if (active) {
|
|
1237
|
-
if (pdfEditor.uiManagerRef.current) {
|
|
1238
|
-
renderedRef.current.forEach((_cleanup, n) => {
|
|
1239
|
-
if (!rasterPresentRef.current.has(n)) {
|
|
1240
|
-
void rasterRenderRef.current.get(n)?.();
|
|
1241
|
-
}
|
|
1242
|
-
});
|
|
1243
|
-
}
|
|
1244
|
-
return;
|
|
1245
|
-
}
|
|
1246
|
-
visibleRef.current.clear();
|
|
1247
|
-
costlyRef.current.clear();
|
|
1248
|
-
if (pdfEditor.uiManagerRef.current) {
|
|
1249
|
-
rasterDropRef.current.forEach((drop) => drop());
|
|
1250
|
-
}
|
|
1251
|
-
else {
|
|
1252
|
-
renderedRef.current.forEach((cleanup) => cleanup());
|
|
1253
|
-
}
|
|
1254
|
-
}, [active, pdfEditor.uiManagerRef]);
|
|
1255
|
-
// The UIManager finishes constructing one tick after pdfDoc loads (see usePdfEditor.ts), which
|
|
1256
|
-
// can land after the first visible pages already rendered without an editor layer. Re-render
|
|
1257
|
-
// whatever is currently on-screen once it's ready so those pages pick one up — pure pages
|
|
1258
|
-
// (already covered) are unaffected; this is a one-time catch-up per document load. Also drains
|
|
1259
|
-
// `pendingReloadRerenderPagesRef` (set by `buildSavePlan`'s `commit`, see its own comment) — a
|
|
1260
|
-
// post-commit reload's affected pages, deliberately NOT rendered synchronously right after
|
|
1261
|
-
// `replaceDocument`, precisely so their `AnnotationEditorLayer`s are only ever built here, after
|
|
1262
|
-
// `usePdfEditor.ts` has already rebuilt the UIManager for the fresh `pdfDoc`.
|
|
1263
|
-
//
|
|
1264
|
-
// Also keyed on `pdfDoc` itself (not just `pdfEditor.ready`): a post-commit reload
|
|
1265
|
-
// (`replaceDocument`) can swap `pdfDoc` while `ready` stays `true` the whole time (usePdfEditor.ts
|
|
1266
|
-
// batches `setReady(false)` then `setReady(true)` within its OWN single `[pdfDoc]` effect, so
|
|
1267
|
-
// `ready`'s rendered value never observably changes across the swap, and a boolean-only dependency
|
|
1268
|
-
// never re-fires this effect). This matters even with the synchronous render removed from
|
|
1269
|
-
// `commit`: without re-firing here too, a reload would leave affected pages permanently
|
|
1270
|
-
// unrendered whenever `ready` happens to already be `true` across the swap.
|
|
1271
|
-
//
|
|
1272
|
-
// History: an earlier version of this fix still called `renderPageRef.current(n)` synchronously
|
|
1273
|
-
// from `commit` right after `replaceDocument`, racing THIS effect. Both executions could complete
|
|
1274
|
-
// their own async `AnnotationEditorLayer` construction in either order, and whichever called the
|
|
1275
|
-
// UIManager's `addLayer()` LAST won that page's slot in the UIManager's registry — even when it
|
|
1276
|
-
// was the earlier (by-then-detached) attempt. The result: a real, correctly-styled `.disabled`/
|
|
1277
|
-
// `.nonEditing` class removal (from `AnnotationEditorLayer.enable()`, called by `addLayer()`
|
|
1278
|
-
// itself) with NO further update ever reaching the DOM-attached layer — a plausible-looking but
|
|
1279
|
-
// permanently `hidden` (0×0) editor layer, with no console error at all. `commit` no longer
|
|
1280
|
-
// renders synchronously (see its own comment), which removes this race by construction.
|
|
1281
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: catch-up runs only when manager becomes ready or the document identity changes; renderPage changes with ordinary view state.
|
|
1282
|
-
useEffect(() => {
|
|
1283
|
-
const pendingReloadPages = pendingReloadRerenderPagesRef.current;
|
|
1284
|
-
// React's ready value can still describe the old manager during a save reload. Check the
|
|
1285
|
-
// live ref, and initialize the replacement before consuming its pending pages.
|
|
1286
|
-
if (pendingReloadPages && !pdfEditor.ensureReady())
|
|
1287
|
-
return;
|
|
1288
|
-
if (!pdfEditor.uiManagerRef.current)
|
|
1289
|
-
return;
|
|
1290
|
-
pendingReloadRerenderPagesRef.current = null;
|
|
1291
|
-
if (pendingReloadPages) {
|
|
1292
|
-
for (const n of pendingReloadPages) {
|
|
1293
|
-
if (visibleRef.current.has(n))
|
|
1294
|
-
continue; // covered by the loop below already
|
|
1295
|
-
renderedRef.current.get(n)?.();
|
|
1296
|
-
void renderPage(n);
|
|
1297
|
-
}
|
|
1298
|
-
}
|
|
1299
|
-
for (const n of visibleRef.current) {
|
|
1300
|
-
renderedRef.current.get(n)?.();
|
|
1301
|
-
void renderPage(n);
|
|
1302
|
-
}
|
|
1303
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1304
|
-
}, [pdfEditor.ready, pdfDoc]);
|
|
1305
|
-
// Native transport measurements must not depend on WKWebView's occlusion-throttled observer.
|
|
1306
|
-
// Render page 1 explicitly; renderedRef keeps this idempotent with normal visible-page delivery.
|
|
1307
|
-
useEffect(() => {
|
|
1308
|
-
if (!benchmarkEnabled || !base || numPages <= 0)
|
|
1309
|
-
return;
|
|
1310
|
-
benchmarkRenderRef.current.add(1);
|
|
1311
|
-
void renderPage(1).finally(() => benchmarkRenderRef.current.delete(1));
|
|
1312
|
-
}, [base, benchmarkEnabled, numPages, renderPage]);
|
|
1313
|
-
// The dev-only PDF benchmark asks the live viewer to visit a late page after first paint. Force
|
|
1314
|
-
// that exact page through the same renderer as a visible intersection: WKWebView may throttle
|
|
1315
|
-
// IntersectionObserver while an unattended benchmark window is occluded, which otherwise turns
|
|
1316
|
-
// transport timing into a foreground-focus measurement. Keep ownership on this mounted document
|
|
1317
|
-
// so a stale pane cannot navigate or render a later tab.
|
|
1318
|
-
useEffect(() => {
|
|
1319
|
-
registerPdfBenchLatePageNavigator((requested) => {
|
|
1320
|
-
if (numPages <= 0 || !Number.isFinite(requested))
|
|
1321
|
-
return null;
|
|
1322
|
-
const page = Math.max(1, Math.min(numPages, Math.trunc(requested)));
|
|
1323
|
-
if (page !== current)
|
|
1324
|
-
goToPage(page);
|
|
1325
|
-
benchmarkRenderRef.current.add(page);
|
|
1326
|
-
void renderPage(page).finally(() => benchmarkRenderRef.current.delete(page));
|
|
1327
|
-
return page;
|
|
1328
|
-
});
|
|
1329
|
-
return () => registerPdfBenchLatePageNavigator(null);
|
|
1330
|
-
}, [current, goToPage, numPages, renderPage]);
|
|
1331
|
-
// --- Virtualization: render pages near the viewport, drop the rest --------------
|
|
1332
|
-
// NB: committedQuery is intentionally NOT a dependency. Tearing down + re-rasterizing every
|
|
1333
|
-
// rendered page on each query change caused a visible page jitter while typing in find. Highlights
|
|
1334
|
-
// are now applied in place by usePdfSearch (and by renderPage for pages that scroll in fresh).
|
|
1335
|
-
useEffect(() => {
|
|
1336
|
-
const reactivated = active && !previousActiveRef.current;
|
|
1337
|
-
previousActiveRef.current = active;
|
|
1338
|
-
const root = scrollRef.current;
|
|
1339
|
-
if (!root || !base)
|
|
1340
|
-
return;
|
|
1341
|
-
if (!active)
|
|
1342
|
-
return;
|
|
1343
|
-
// Activation must not tear down initialized editor layers: their DOM owns live annotation
|
|
1344
|
-
// interaction while UIManager owns pending edits/history. Pure-view panes were already cleaned
|
|
1345
|
-
// by the inactive effect above, so only those need a fresh raster pass here.
|
|
1346
|
-
const preserveEditorLayers = reactivated && pdfEditor.ready;
|
|
1347
|
-
if (!preserveEditorLayers)
|
|
1348
|
-
renderedRef.current.forEach((cleanup) => cleanup());
|
|
1349
|
-
// Render zone: drives visibility and renders pages entering the near-viewport band.
|
|
1350
|
-
const renderObs = new IntersectionObserver((entries) => {
|
|
1351
|
-
for (const e of entries) {
|
|
1352
|
-
const n = Number(e.target.dataset.page);
|
|
1353
|
-
if (e.isIntersecting) {
|
|
1354
|
-
visibleRef.current.add(n);
|
|
1355
|
-
void renderPage(n);
|
|
1356
|
-
}
|
|
1357
|
-
else {
|
|
1358
|
-
visibleRef.current.delete(n);
|
|
1359
|
-
}
|
|
1360
|
-
}
|
|
1361
|
-
}, { root, rootMargin: RENDER_ROOT_MARGIN });
|
|
1362
|
-
// Unload zone (wider than the render zone): a page is only unloaded once it leaves this band.
|
|
1363
|
-
const unloadObs = new IntersectionObserver((entries) => {
|
|
1364
|
-
for (const e of entries) {
|
|
1365
|
-
if (e.isIntersecting)
|
|
1366
|
-
continue;
|
|
1367
|
-
const n = Number(e.target.dataset.page);
|
|
1368
|
-
if (benchmarkRenderRef.current.has(n))
|
|
1369
|
-
continue;
|
|
1370
|
-
if (costlyRef.current.has(n)) {
|
|
1371
|
-
// Keep expensive pages rendered across scroll-out — re-rendering
|
|
1372
|
-
// one costs seconds, not the ~20 ms the unload strategy assumes.
|
|
1373
|
-
// Bound memory by evicting the stalest kept pages beyond the byte budget.
|
|
1374
|
-
costlyRef.current.set(n, performance.now());
|
|
1375
|
-
const kept = [...costlyRef.current.keys()].filter((p) => renderedRef.current.has(p) && !visibleRef.current.has(p));
|
|
1376
|
-
kept.sort((a, b) => (costlyRef.current.get(a) ?? 0) - (costlyRef.current.get(b) ?? 0));
|
|
1377
|
-
let bytes = kept.reduce((total, p) => total + (canvasBytesRef.current.get(p) ?? 0), 0);
|
|
1378
|
-
while (bytes > MAX_KEPT_CANVAS_BYTES) {
|
|
1379
|
-
const oldest = kept.shift();
|
|
1380
|
-
if (oldest === undefined)
|
|
1381
|
-
break;
|
|
1382
|
-
bytes -= canvasBytesRef.current.get(oldest) ?? 0;
|
|
1383
|
-
renderedRef.current.get(oldest)?.();
|
|
1384
|
-
}
|
|
1385
|
-
}
|
|
1386
|
-
else {
|
|
1387
|
-
renderedRef.current.get(n)?.(); // unload off-screen page
|
|
1388
|
-
}
|
|
1389
|
-
}
|
|
1390
|
-
}, { root, rootMargin: UNLOAD_ROOT_MARGIN });
|
|
1391
|
-
slotRefs.current.forEach((el) => {
|
|
1392
|
-
renderObs.observe(el);
|
|
1393
|
-
unloadObs.observe(el);
|
|
1394
|
-
});
|
|
1395
|
-
if (reactivated)
|
|
1396
|
-
void renderPage(currentRef.current);
|
|
1397
|
-
return () => {
|
|
1398
|
-
renderObs.disconnect();
|
|
1399
|
-
unloadObs.disconnect();
|
|
1400
|
-
};
|
|
1401
|
-
}, [active, base, pdfEditor.ready, renderPage]);
|
|
1402
|
-
const updateCurrentPage = useCallback(() => {
|
|
1403
|
-
const root = scrollRef.current;
|
|
1404
|
-
if (!root || slotRefs.current.size === 0)
|
|
1405
|
-
return;
|
|
1406
|
-
const bestPage = nearestVisiblePdfPage(root, slotRefs.current, visibleRef.current, currentRef.current);
|
|
1407
|
-
setCurrent((prev) => (prev === bestPage ? prev : bestPage));
|
|
1408
|
-
}, []);
|
|
1409
|
-
useEffect(() => {
|
|
1410
|
-
const root = scrollRef.current;
|
|
1411
|
-
if (!root || !base)
|
|
1412
|
-
return;
|
|
1413
|
-
const onScroll = () => {
|
|
1414
|
-
if (currentRaf.current != null)
|
|
1415
|
-
return;
|
|
1416
|
-
currentRaf.current = requestAnimationFrame(() => {
|
|
1417
|
-
currentRaf.current = null;
|
|
1418
|
-
updateCurrentPage();
|
|
1419
|
-
});
|
|
1420
|
-
};
|
|
1421
|
-
root.addEventListener("scroll", onScroll, { passive: true });
|
|
1422
|
-
updateCurrentPage();
|
|
1423
|
-
return () => {
|
|
1424
|
-
root.removeEventListener("scroll", onScroll);
|
|
1425
|
-
if (currentRaf.current != null) {
|
|
1426
|
-
cancelAnimationFrame(currentRaf.current);
|
|
1427
|
-
currentRaf.current = null;
|
|
1428
|
-
}
|
|
1429
|
-
};
|
|
1430
|
-
}, [base, updateCurrentPage]);
|
|
1431
|
-
const { goToDest, commitPageDraft, zoom, commitScaleDraft, onWheelZoom, rotate } = usePdfViewControls({
|
|
1432
|
-
active,
|
|
1433
|
-
numPages,
|
|
1434
|
-
pageDraft,
|
|
1435
|
-
scale,
|
|
1436
|
-
scaleDraft,
|
|
1437
|
-
scrollRef,
|
|
1438
|
-
pdfRef,
|
|
1439
|
-
goToPage,
|
|
1440
|
-
setPageDraft,
|
|
1441
|
-
setPageShake,
|
|
1442
|
-
setScale,
|
|
1443
|
-
setScaleDraft,
|
|
1444
|
-
setFitMode,
|
|
1445
|
-
setRotation,
|
|
1446
|
-
});
|
|
1447
|
-
// Keep the link service's delegate pointed at the live navigation callbacks and page state.
|
|
1448
|
-
useEffect(() => {
|
|
1449
|
-
linkActionsRef.current = {
|
|
1450
|
-
goToDest,
|
|
1451
|
-
goToPage,
|
|
1452
|
-
currentPage: () => current,
|
|
1453
|
-
pagesCount: () => numPages,
|
|
1454
|
-
};
|
|
1455
|
-
}, [goToDest, goToPage, current, numPages]);
|
|
1456
|
-
// Restore the saved page once the document is loaded and its slots exist (run once per doc).
|
|
1457
|
-
useEffect(() => {
|
|
1458
|
-
if (restoredRef.current || !base || numPages === 0)
|
|
1459
|
-
return;
|
|
1460
|
-
restoredRef.current = true;
|
|
1461
|
-
const page = effectiveInitialView?.page;
|
|
1462
|
-
if (!page || page <= 1) {
|
|
1463
|
-
reportReadyRef.current = true;
|
|
1464
|
-
return;
|
|
1465
|
-
}
|
|
1466
|
-
let raf = 0;
|
|
1467
|
-
let tries = 0;
|
|
1468
|
-
const target = Math.min(page, numPages);
|
|
1469
|
-
const tick = () => {
|
|
1470
|
-
const slot = slotRefs.current.get(target);
|
|
1471
|
-
if (slot) {
|
|
1472
|
-
slot.scrollIntoView({ block: "start" });
|
|
1473
|
-
setCurrent(target);
|
|
1474
|
-
reportReadyRef.current = true;
|
|
1475
|
-
return;
|
|
1476
|
-
}
|
|
1477
|
-
if (tries++ < 30)
|
|
1478
|
-
raf = requestAnimationFrame(tick);
|
|
1479
|
-
else
|
|
1480
|
-
reportReadyRef.current = true;
|
|
1481
|
-
};
|
|
1482
|
-
raf = requestAnimationFrame(tick);
|
|
1483
|
-
return () => cancelAnimationFrame(raf);
|
|
1484
|
-
}, [base, numPages, effectiveInitialView?.page]);
|
|
1485
|
-
// Report page/zoom/fit/rotation upward so the app can persist it (debounced there). Gated until
|
|
1486
|
-
// the initial restore ran so we don't immediately overwrite the saved position with the default.
|
|
1487
|
-
useEffect(() => {
|
|
1488
|
-
if (benchmarkEnabled || !reportReadyRef.current || !onViewState)
|
|
1489
|
-
return;
|
|
1490
|
-
onViewState({ page: current, scale, fit: fitMode, rotation });
|
|
1491
|
-
}, [benchmarkEnabled, current, scale, fitMode, rotation, onViewState]);
|
|
1492
|
-
if (!url) {
|
|
1493
|
-
return (_jsx("div", { className: "flex h-full items-center justify-center text-sm text-muted-2", children: "Open via the desktop app." }));
|
|
1494
|
-
}
|
|
1495
|
-
if (error)
|
|
1496
|
-
return _jsxs("div", { className: "m-4 text-sm text-copper-hi", children: ["PDF error: ", error] });
|
|
1497
|
-
const draft = editSession.session.draft;
|
|
1498
|
-
const draftSlot = draft ? slotRefs.current.get(draft.page) : null;
|
|
1499
|
-
const draftViewport = draft ? viewportsRef.current.get(draft.page) : null;
|
|
1500
|
-
const draftScreenRect = draft && draftViewport ? pdfRectToViewport(draft.rect, draftViewport) : null;
|
|
1501
|
-
// Existing-text edits type over glyphs that are still painted on the canvas underneath, so the
|
|
1502
|
-
// editor paints a background sampled from that canvas (white when the area isn't uniform) to hide
|
|
1503
|
-
// them; fresh add-text drafts sit on empty page space and stay transparent.
|
|
1504
|
-
let draftCoverColor = null;
|
|
1505
|
-
if (draft?.kind === "sourceEdit" && draftSlot && draftScreenRect) {
|
|
1506
|
-
const canvas = draftSlot.querySelector("canvas");
|
|
1507
|
-
draftCoverColor =
|
|
1508
|
-
(canvas && sampleCanvasRectBackground(canvas, draftScreenRect, draftSlot.clientWidth)) ??
|
|
1509
|
-
WHITE_COVER;
|
|
1510
|
-
}
|
|
1511
|
-
return (_jsxs("div", { className: "relative flex h-full flex-col", children: [saveLocked && (_jsx("div", { role: "status", className: "px-3 py-2 text-sm", children: "Saving PDF\u2026" })), savePlan.isReloadRequired() && (_jsxs("div", { role: "alert", className: "px-3 py-2 text-sm", children: [savePlan.hasPreservedEdits()
|
|
1512
|
-
? "PDF saved. Newer edits remain unsaved; automatic reload paused."
|
|
1513
|
-
: "PDF saved. Reload saved revision before editing.", _jsx("button", { type: "button", disabled: saveLocked || savePlan.hasPreservedEdits(), onClick: () => void savePlan.retryReload(), children: "Retry reload" })] })), _jsxs("div", { className: "contents", inert: saveLocked || savePlan.isReloadRequired(), children: [initialActionError && (_jsx("div", { role: "alert", className: "px-3 py-2 text-sm text-copper-hi", children: initialActionError })), _jsx(PdfToolbar, { panel: panel, hasOutline: !!outline && outline.length > 0, current: current, numPages: numPages, pageDraft: pageDraft, pageShake: pageShake, scaleDraft: scaleDraft, fitMode: fitMode, onTogglePanel: (p) => setPanel((cur) => (cur === p ? null : p)), onPageDraftChange: setPageDraft, onCommitPageDraft: commitPageDraft, onZoom: zoom, onScaleDraftChange: setScaleDraft, onCommitScaleDraft: commitScaleDraft, onSetFit: setFitMode, onRotate: rotate, ocrAvailable: pdfOcr.ocrAvailable, currentPageScanned: pdfOcr.currentPageScanned, ocrState: pdfOcr.currentPageState.status, ocrErrorMessage: ocrErrorMessage,
|
|
1514
|
-
// OCR adds searchable overlays without changing the file, so Live Text stays in Read.
|
|
1515
|
-
onRecognize: () => void pdfOcr.recognizeCurrentPage(), documentOcrState: pdfOcr.documentOcrState, onRecognizeDocument: () => void pdfOcr.recognizeDocument(), findBar: findBar, findMode: findMode, onRequestFind: onRequestFind, onRequestReplace: onRequestReplace, editorMode: pdfEditor.mode, editorReady: !!pdfDoc, editorDirty: pdfEditor.dirty, onSetEditorMode: readOnly ? undefined : handleSetAnnotationMode, freeTextSize: pdfEditor.freeTextSize, onFreeTextSizeChange: pdfEditor.setFreeTextSize, onAddImageStamp: pdfEditor.addImageStamp, textEditActive: editSession.session.mode === "edit", onToggleTextEdit: readOnly ? undefined : () => setTextEditActive(editSession.session.mode !== "edit"), readOnly: readOnly }), _jsx(PdfViewerBody, { panel: panel, outline: outline, pdfDoc: pdfDoc, numPages: numPages, current: current, rotation: rotation, base: base, pageSizes: pageSizes, scale: scale, renderErrors: renderErrors, ocrLinesByPage: effectiveOcrLinesByPage, scrollRef: scrollRef, slotRefs: slotRefs, onSelectDestination: goToDest, onSelectPage: goToPage, onWheel: onWheelZoom }), editSession.session.mode === "edit" && !draft && (_jsx("div", { className: "no-print pointer-events-none absolute bottom-4 left-1/2 z-30 -translate-x-1/2 rounded-full border border-line bg-panel/95 px-3 py-1.5 text-xs text-paper shadow-lg", children: "Click text to edit it, or click empty space to add text." })), draft &&
|
|
1516
|
-
draftSlot &&
|
|
1517
|
-
draftScreenRect &&
|
|
1518
|
-
createPortal(_jsxs(_Fragment, { children: [_jsx(PdfInlineTextEditor, { draft: draft, rect: draftScreenRect, cssPerPdfUnit: draftViewport?.scale, coverColor: draftCoverColor, onTextChange: editSession.updateDraftText, onCommit: handleCommitDraft, onCancel: editSession.cancelDraft, onOverflowChange: editSession.setDraftOverflow }, draft.id), draft.overflow && (_jsx("div", { className: "no-print pointer-events-none absolute rounded bg-panel/95 px-1.5 py-0.5 text-chrome-xs text-danger shadow", style: {
|
|
1519
|
-
left: draftScreenRect.x,
|
|
1520
|
-
top: draftScreenRect.y + draftScreenRect.height + 2,
|
|
1521
|
-
}, children: "Text exceeds available space \u2014 Save is disabled for this edit" }))] }), draftSlot), pwPrompt && (_jsx(PdfPasswordPrompt, { retry: pwPrompt.retry, onSubmit: pwPrompt.onSubmit, onCancel: pwPrompt.onCancel }))] })] }));
|
|
1522
|
-
}
|