@rightkit/document-core 0.1.1 → 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.
Files changed (136) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +31 -7
  3. package/dist/app/chrome.js +1 -1
  4. package/dist/app/documentPrinting.d.ts +0 -6
  5. package/dist/app/documentPrinting.js +0 -18
  6. package/dist/app/shell/nativeMenu.d.ts +3 -2
  7. package/dist/app/shell/nativeMenu.js +8 -5
  8. package/dist/documents/perfEventCounters.d.ts +0 -8
  9. package/dist/documents/perfEventCounters.js +2 -1
  10. package/dist/documents/preview/previewFrame.d.ts +2 -1
  11. package/dist/documents/preview/previewFrame.js +9 -7
  12. package/dist/documents/searchUtils.js +1 -1
  13. package/dist/host.d.ts +36 -0
  14. package/dist/host.js +54 -0
  15. package/dist/index.d.ts +1 -50
  16. package/dist/index.js +1 -50
  17. package/dist/settings/themes.js +1 -1
  18. package/dist/state/bridgeEvents.d.ts +1 -1
  19. package/dist/state/bridgeEvents.js +7 -4
  20. package/dist/state/recentUrls.js +4 -3
  21. package/dist/state/tauriBridge.d.ts +3 -61
  22. package/dist/state/tauriBridge.js +8 -297
  23. package/dist/styles.css +2 -2
  24. package/package.json +25 -98
  25. package/dist/app/shortcutChords.d.ts +0 -6
  26. package/dist/app/shortcutChords.js +0 -14
  27. package/dist/app/useCloseFindOnSurfaceChange.d.ts +0 -14
  28. package/dist/app/useCloseFindOnSurfaceChange.js +0 -23
  29. package/dist/app/useEditText.d.ts +0 -6
  30. package/dist/app/useEditText.js +0 -10
  31. package/dist/appName.d.ts +0 -2
  32. package/dist/appName.js +0 -8
  33. package/dist/documents/FullscreenIcon.d.ts +0 -4
  34. package/dist/documents/FullscreenIcon.js +0 -7
  35. package/dist/documents/mdx/mdxRuntimeScript.d.ts +0 -2
  36. package/dist/documents/mdx/mdxRuntimeScript.js +0 -3
  37. package/dist/documents/mdx/mdxRuntimeScript.txt +0 -584
  38. package/dist/documents/pdf/MacPdfReadPane.d.ts +0 -23
  39. package/dist/documents/pdf/MacPdfReadPane.js +0 -708
  40. package/dist/documents/pdf/NativePdfDocument.d.ts +0 -9
  41. package/dist/documents/pdf/NativePdfDocument.js +0 -41
  42. package/dist/documents/pdf/PdfInlineTextEditor.d.ts +0 -53
  43. package/dist/documents/pdf/PdfInlineTextEditor.js +0 -225
  44. package/dist/documents/pdf/PdfOcrLayer.d.ts +0 -4
  45. package/dist/documents/pdf/PdfOcrLayer.js +0 -30
  46. package/dist/documents/pdf/PdfOutline.d.ts +0 -6
  47. package/dist/documents/pdf/PdfOutline.js +0 -12
  48. package/dist/documents/pdf/PdfPasswordPrompt.d.ts +0 -5
  49. package/dist/documents/pdf/PdfPasswordPrompt.js +0 -24
  50. package/dist/documents/pdf/PdfThumbnails.d.ts +0 -8
  51. package/dist/documents/pdf/PdfThumbnails.js +0 -201
  52. package/dist/documents/pdf/PdfToolbar.d.ts +0 -47
  53. package/dist/documents/pdf/PdfToolbar.js +0 -76
  54. package/dist/documents/pdf/PdfViewer.d.ts +0 -36
  55. package/dist/documents/pdf/PdfViewer.js +0 -1522
  56. package/dist/documents/pdf/PdfViewerBody.d.ts +0 -22
  57. package/dist/documents/pdf/PdfViewerBody.js +0 -42
  58. package/dist/documents/pdf/macPdfNativeBridge.d.ts +0 -118
  59. package/dist/documents/pdf/macPdfNativeBridge.js +0 -134
  60. package/dist/documents/pdf/nearestVisiblePdfPage.d.ts +0 -2
  61. package/dist/documents/pdf/nearestVisiblePdfPage.js +0 -22
  62. package/dist/documents/pdf/pdfBench.d.ts +0 -88
  63. package/dist/documents/pdf/pdfBench.js +0 -535
  64. package/dist/documents/pdf/pdfBenchAutopilot.d.ts +0 -48
  65. package/dist/documents/pdf/pdfBenchAutopilot.js +0 -154
  66. package/dist/documents/pdf/pdfBenchTransport.d.ts +0 -21
  67. package/dist/documents/pdf/pdfBenchTransport.js +0 -233
  68. package/dist/documents/pdf/pdfEditModel.d.ts +0 -294
  69. package/dist/documents/pdf/pdfEditModel.js +0 -105
  70. package/dist/documents/pdf/pdfEditor.d.ts +0 -100
  71. package/dist/documents/pdf/pdfEditor.js +0 -68
  72. package/dist/documents/pdf/pdfOcr.d.ts +0 -36
  73. package/dist/documents/pdf/pdfOcr.js +0 -88
  74. package/dist/documents/pdf/pdfOcrEdit.d.ts +0 -32
  75. package/dist/documents/pdf/pdfOcrEdit.js +0 -138
  76. package/dist/documents/pdf/pdfPrinting.d.ts +0 -28
  77. package/dist/documents/pdf/pdfPrinting.js +0 -110
  78. package/dist/documents/pdf/pdfSave.d.ts +0 -87
  79. package/dist/documents/pdf/pdfSave.js +0 -250
  80. package/dist/documents/pdf/pdfShared.d.ts +0 -16
  81. package/dist/documents/pdf/pdfShared.js +0 -20
  82. package/dist/documents/pdf/pdfToMarkdown.d.ts +0 -11
  83. package/dist/documents/pdf/pdfToMarkdown.js +0 -67
  84. package/dist/documents/pdf/pdfjsLibGlobal.d.ts +0 -1
  85. package/dist/documents/pdf/pdfjsLibGlobal.js +0 -12
  86. package/dist/documents/pdf/toolbarIcons.d.ts +0 -18
  87. package/dist/documents/pdf/toolbarIcons.js +0 -31
  88. package/dist/documents/pdf/usePdfDocumentLoader.d.ts +0 -40
  89. package/dist/documents/pdf/usePdfDocumentLoader.js +0 -243
  90. package/dist/documents/pdf/usePdfEditSession.d.ts +0 -88
  91. package/dist/documents/pdf/usePdfEditSession.js +0 -341
  92. package/dist/documents/pdf/usePdfEditor.d.ts +0 -59
  93. package/dist/documents/pdf/usePdfEditor.js +0 -317
  94. package/dist/documents/pdf/usePdfFit.d.ts +0 -19
  95. package/dist/documents/pdf/usePdfFit.js +0 -55
  96. package/dist/documents/pdf/usePdfOcr.d.ts +0 -38
  97. package/dist/documents/pdf/usePdfOcr.js +0 -337
  98. package/dist/documents/pdf/usePdfPreviewRecompose.d.ts +0 -28
  99. package/dist/documents/pdf/usePdfPreviewRecompose.js +0 -256
  100. package/dist/documents/pdf/usePdfSavePlan.d.ts +0 -61
  101. package/dist/documents/pdf/usePdfSavePlan.js +0 -303
  102. package/dist/documents/pdf/usePdfSearch.d.ts +0 -31
  103. package/dist/documents/pdf/usePdfSearch.js +0 -367
  104. package/dist/documents/pdf/usePdfViewControls.d.ts +0 -72
  105. package/dist/documents/pdf/usePdfViewControls.js +0 -152
  106. package/dist/documents/scopedSelectAll.d.ts +0 -5
  107. package/dist/documents/scopedSelectAll.js +0 -77
  108. package/dist/documents/txt/cmScroll.d.ts +0 -11
  109. package/dist/documents/txt/cmScroll.js +0 -52
  110. package/dist/documents/useMirroredEditText.d.ts +0 -15
  111. package/dist/documents/useMirroredEditText.js +0 -29
  112. package/dist/documents/useSplitResize.d.ts +0 -7
  113. package/dist/documents/useSplitResize.js +0 -37
  114. package/dist/settings/rk-settings.css +0 -38
  115. package/dist/settings/sections/EditorSection.d.ts +0 -1
  116. package/dist/settings/sections/EditorSection.js +0 -18
  117. package/dist/settings/sections/FileAssociationsSection.d.ts +0 -1
  118. package/dist/settings/sections/FileAssociationsSection.js +0 -5
  119. package/dist/settings/sections/MacFileAssociationsBlock.d.ts +0 -33
  120. package/dist/settings/sections/MacFileAssociationsBlock.js +0 -151
  121. package/dist/settings/sections/ThemeSection.d.ts +0 -1
  122. package/dist/settings/sections/ThemeSection.js +0 -31
  123. package/dist/settings/sections/TypographySection.d.ts +0 -1
  124. package/dist/settings/sections/TypographySection.js +0 -19
  125. package/dist/settings/sections/onOff.d.ts +0 -11
  126. package/dist/settings/sections/onOff.js +0 -8
  127. package/dist/state/browserQa.d.ts +0 -21
  128. package/dist/state/browserQa.js +0 -303
  129. package/dist/state/browserQaDocx.d.ts +0 -3
  130. package/dist/state/browserQaDocx.js +0 -35
  131. package/dist/state/browserQaLicense.d.ts +0 -8
  132. package/dist/state/browserQaLicense.js +0 -7
  133. package/dist/state/browserQaPdf.d.ts +0 -60
  134. package/dist/state/browserQaPdf.js +0 -302
  135. package/dist/state/browserQaText.d.ts +0 -6
  136. package/dist/state/browserQaText.js +0 -48
@@ -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
- }