dsh-book-reader 0.1.1 → 0.3.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 +47 -0
- package/README.md +58 -68
- package/README.zh-CN.md +91 -0
- package/SKILL.md +22 -4
- package/cordis.patch.yml +5 -3
- package/lib/client.js +1140 -209
- package/lib/core/README.md +47 -22
- package/lib/core/pdf.js +564 -35
- package/lib/index.js +550 -20
- package/package.json +12 -3
package/lib/client.js
CHANGED
|
@@ -3,16 +3,15 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Decorates DSH's official PDF preview; it never replaces the renderer:
|
|
5
5
|
* - current-page readout, prev/next page, and a page-jump box;
|
|
6
|
-
* - a
|
|
7
|
-
*
|
|
6
|
+
* - a find bar (Cmd/Ctrl+F) backed by the Host text route, with in-page
|
|
7
|
+
* highlights and sequential previous/next navigation between occurrences;
|
|
8
8
|
* - debounced current-page reporting for AI context, with an on/off toggle.
|
|
9
9
|
*
|
|
10
10
|
* The contribution mounts into the preview's own header slot
|
|
11
11
|
* `sidebar.right.tab.document.actions`, which the preview declares as a
|
|
12
12
|
* Session-scoped list and passes `{ absolutePath }` to. Session-scoped slots
|
|
13
|
-
* also receive the standard `sessionId
|
|
14
|
-
*
|
|
15
|
-
* preserved and the edit is undoable. It never calls `setDraft`.
|
|
13
|
+
* also receive the standard `sessionId`, used only to tag the current-page
|
|
14
|
+
* reports; the plugin never writes to the chat composer.
|
|
16
15
|
*
|
|
17
16
|
* The bundle is the module-loader factory shape used by the profile:
|
|
18
17
|
* `window.__ModuleLoader__.load({ id, factory })`, React is the only external.
|
|
@@ -31,12 +30,18 @@ window.__ModuleLoader__.load({
|
|
|
31
30
|
const INFO_ROUTE = '/dsh-pdf-reader/info'
|
|
32
31
|
const SEARCH_ROUTE = '/dsh-pdf-reader/search'
|
|
33
32
|
const CURRENT_ROUTE = '/dsh-pdf-reader/current'
|
|
33
|
+
const VIEW_ROUTE = '/dsh-pdf-reader/view'
|
|
34
34
|
|
|
35
35
|
/** DOM markers owned by the official preview (same ones dsh-pdf-search uses). */
|
|
36
36
|
const PANE_ATTR = 'data-textpreview-body'
|
|
37
37
|
const SCROLL_ATTR = 'data-document-zoom-scrollport'
|
|
38
|
+
const ZOOM_FRAME_ATTR = 'data-document-zoom-frame'
|
|
39
|
+
const ZOOM_MODE_ATTR = 'data-document-zoom-mode'
|
|
40
|
+
const ZOOM_VAR = '--document-zoom'
|
|
38
41
|
const PAGE_ATTR = 'data-pdf-page'
|
|
39
42
|
const PDF_SECTION_ATTR = 'data-pdf-preview'
|
|
43
|
+
/** Text-layer container created by pdf.js TextLayerBuilder for one page. */
|
|
44
|
+
const TEXT_LAYER_ATTR = 'data-pdf-text'
|
|
40
45
|
|
|
41
46
|
/** Timing. Reporting is the only debounced write; scrolling uses one rAF. */
|
|
42
47
|
const REPORT_DELAY_MS = 400
|
|
@@ -48,6 +53,45 @@ window.__ModuleLoader__.load({
|
|
|
48
53
|
const SEARCH_LIMIT = 100
|
|
49
54
|
const INFO_CACHE_LIMIT = 16
|
|
50
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Search highlighting.
|
|
58
|
+
*
|
|
59
|
+
* The panel owns two document-wide CSS Custom Highlights: one for every
|
|
60
|
+
* match on the pages the Host reported, and one for the occurrence the
|
|
61
|
+
* counter is on. Pages render lazily and re-render on zoom, so the text
|
|
62
|
+
* layer is retried for about three seconds and then observed for replacement.
|
|
63
|
+
*/
|
|
64
|
+
const SEARCH_HIGHLIGHT_NAME = 'dsh-book-reader-search'
|
|
65
|
+
const SEARCH_ACTIVE_HIGHLIGHT_NAME = 'dsh-book-reader-search-active'
|
|
66
|
+
const SEARCH_HIT_CLASS = 'dsh-book-reader-search-hit'
|
|
67
|
+
const SEARCH_ACTIVE_HIT_CLASS = 'dsh-book-reader-search-active-hit'
|
|
68
|
+
const SEARCH_HIGHLIGHT_STYLE_ID = 'dsh-book-reader-search-highlight-style'
|
|
69
|
+
const SEARCH_HIGHLIGHT_RETRY_MS = 150
|
|
70
|
+
const SEARCH_HIGHLIGHT_RETRY_LIMIT = 20
|
|
71
|
+
const SEARCH_HIGHLIGHT_REBUILD_MS = 60
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Reading-position memory timing.
|
|
75
|
+
*
|
|
76
|
+
* Restoring is best effort and must not block the preview: the saved view is
|
|
77
|
+
* fetched in parallel, then the structure/page markers are retried for about
|
|
78
|
+
* eight seconds. Saving starts only after restore finished (success or
|
|
79
|
+
* failure) so opening a book cannot overwrite its saved page with page 1.
|
|
80
|
+
* The save debounce is deliberately longer than the report debounce so a
|
|
81
|
+
* scroll settles before either write happens.
|
|
82
|
+
*/
|
|
83
|
+
const VIEW_TIMEOUT_MS = 6_000
|
|
84
|
+
const VIEW_SAVE_DELAY_MS = 500
|
|
85
|
+
const VIEW_RETRY_MS = 200
|
|
86
|
+
const VIEW_RETRY_LIMIT = 40
|
|
87
|
+
const VIEW_REAPPLY_MS = 300
|
|
88
|
+
const VIEW_ZOOM_STEP_MS = 50
|
|
89
|
+
const VIEW_ZOOM_SETTLE_MS = 160
|
|
90
|
+
const VIEW_ZOOM_EVENT_LIMIT = 20
|
|
91
|
+
const VIEW_ZOOM_TOLERANCE = 0.02
|
|
92
|
+
const VIEW_ZOOM_MIN = 0.25
|
|
93
|
+
const VIEW_ZOOM_MAX = 4
|
|
94
|
+
|
|
51
95
|
/** Non-PDF files are left entirely alone (Office previews have .docx paths). */
|
|
52
96
|
function isPdfPath(value) {
|
|
53
97
|
return typeof value === 'string' && /\.pdf$/iu.test(value)
|
|
@@ -112,7 +156,7 @@ window.__ModuleLoader__.load({
|
|
|
112
156
|
return page
|
|
113
157
|
}
|
|
114
158
|
|
|
115
|
-
/** Collapse whitespace and bound a
|
|
159
|
+
/** Collapse whitespace and bound a diagnostic detail for the quiet panel. */
|
|
116
160
|
function normalizeSnippet(value, limit = 160) {
|
|
117
161
|
const source = typeof value === 'string' ? value : ''
|
|
118
162
|
const clean = source.replace(/\s+/gu, ' ').trim()
|
|
@@ -121,51 +165,6 @@ window.__ModuleLoader__.load({
|
|
|
121
165
|
return `${clean.slice(0, max - 1).trimEnd()}…`
|
|
122
166
|
}
|
|
123
167
|
|
|
124
|
-
/** @returns the `p.N` label for one search hit. */
|
|
125
|
-
function searchResultPage(hit) {
|
|
126
|
-
if (positiveInteger(hit?.printed)) return hit.printed
|
|
127
|
-
if (positiveInteger(hit?.pdf)) return hit.pdf
|
|
128
|
-
return undefined
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** @returns the short list label `p.132`, or `p.?` for a malformed hit. */
|
|
132
|
-
function searchResultLabel(hit) {
|
|
133
|
-
const page = searchResultPage(hit)
|
|
134
|
-
return page === undefined ? 'p.?' : `p.${page}`
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** @returns `PDF p.132: <snippet>` (or `[PDF p.132]` when there is no snippet). */
|
|
138
|
-
function buildSearchReference(hit) {
|
|
139
|
-
const page = searchResultPage(hit)
|
|
140
|
-
const label = page === undefined ? '?' : String(page)
|
|
141
|
-
const snippet = normalizeSnippet(hit?.snippet)
|
|
142
|
-
return snippet === '' ? `[PDF p.${label}]` : `PDF p.${label}: ${snippet}`
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Insert into the composer through the Session standard props.
|
|
147
|
-
*
|
|
148
|
-
* `captureInsertion()` snapshots the selection and draft revision;
|
|
149
|
-
* `insertText()` applies one undoable plain-text edit only while that
|
|
150
|
-
* revision is current. Rejected insertions are reported as `false` so the
|
|
151
|
-
* caller can copy the reference instead; the draft is never replaced.
|
|
152
|
-
* @param inputActions - standard slot prop.
|
|
153
|
-
* @param text - plain text to insert.
|
|
154
|
-
* @returns whether the edit applied.
|
|
155
|
-
*/
|
|
156
|
-
function insertComposerText(inputActions, text) {
|
|
157
|
-
if (inputActions === null || typeof inputActions !== 'object') return false
|
|
158
|
-
if (typeof inputActions.captureInsertion !== 'function') return false
|
|
159
|
-
if (typeof inputActions.insertText !== 'function') return false
|
|
160
|
-
try {
|
|
161
|
-
const span = inputActions.captureInsertion()
|
|
162
|
-
if (span === null || typeof span !== 'object') return false
|
|
163
|
-
return inputActions.insertText(String(text), span) === true
|
|
164
|
-
} catch (_error) {
|
|
165
|
-
return false
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
168
|
/**
|
|
170
169
|
* Pick the page occupying the reading anchor near the top of the viewport.
|
|
171
170
|
*
|
|
@@ -223,6 +222,348 @@ window.__ModuleLoader__.load({
|
|
|
223
222
|
return out
|
|
224
223
|
}
|
|
225
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Case-fold text one UTF-16 code unit at a time.
|
|
227
|
+
*
|
|
228
|
+
* Search offsets have to stay 1:1 with the text layer, so characters whose
|
|
229
|
+
* lowercase form changes length (such as `İ`) keep their original form. The
|
|
230
|
+
* query is folded with the same function, which mirrors the Host's
|
|
231
|
+
* case-insensitive literal search closely enough to number occurrences.
|
|
232
|
+
* @param value - text to fold.
|
|
233
|
+
* @returns the folded text, same length as the input.
|
|
234
|
+
*/
|
|
235
|
+
function foldSearchText(value) {
|
|
236
|
+
const text = typeof value === 'string' ? value : String(value ?? '')
|
|
237
|
+
let folded = ''
|
|
238
|
+
for (let at = 0; at < text.length; at += 1) {
|
|
239
|
+
const char = text[at]
|
|
240
|
+
let lower = char
|
|
241
|
+
try {
|
|
242
|
+
lower = char.toLowerCase()
|
|
243
|
+
} catch (_error) {
|
|
244
|
+
lower = char
|
|
245
|
+
}
|
|
246
|
+
folded += typeof lower === 'string' && lower.length === 1 ? lower : char
|
|
247
|
+
}
|
|
248
|
+
return folded
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Every non-empty text node under one element, in document order.
|
|
253
|
+
* @param root - text-layer container.
|
|
254
|
+
* @returns text nodes, or an empty array when the DOM cannot be walked.
|
|
255
|
+
*/
|
|
256
|
+
function collectTextNodes(root) {
|
|
257
|
+
const nodes = []
|
|
258
|
+
if (root === null || root === undefined) return nodes
|
|
259
|
+
const documentRef = root.ownerDocument
|
|
260
|
+
if (documentRef === null || documentRef === undefined) return nodes
|
|
261
|
+
if (typeof documentRef.createTreeWalker !== 'function') return nodes
|
|
262
|
+
try {
|
|
263
|
+
// 4 is NodeFilter.SHOW_TEXT; pdf.js text layers contain no other nodes
|
|
264
|
+
// that matter, and this avoids depending on the global NodeFilter.
|
|
265
|
+
const walker = documentRef.createTreeWalker(root, 4)
|
|
266
|
+
let node = walker.nextNode()
|
|
267
|
+
while (node !== null) {
|
|
268
|
+
const text = node.nodeValue
|
|
269
|
+
if (typeof text === 'string' && text !== '') nodes.push(node)
|
|
270
|
+
node = walker.nextNode()
|
|
271
|
+
}
|
|
272
|
+
} catch (_error) {
|
|
273
|
+
return []
|
|
274
|
+
}
|
|
275
|
+
return nodes
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Locate literal case-insensitive occurrences across a run of text nodes.
|
|
280
|
+
*
|
|
281
|
+
* The text layer is split into arbitrarily many spans, so concatenate the
|
|
282
|
+
* node values and keep each node's start offset. A match may therefore span
|
|
283
|
+
* multiple nodes; `Range` creation maps both ends back to the right node.
|
|
284
|
+
* @param nodes - text nodes in document order.
|
|
285
|
+
* @param needle - literal query (already trimmed).
|
|
286
|
+
* @returns `{ nodes, starts, matches }`, or `undefined` when there is no text.
|
|
287
|
+
*/
|
|
288
|
+
function matchTextNodes(nodes, needle) {
|
|
289
|
+
const foldedNeedle = foldSearchText(needle)
|
|
290
|
+
if (nodes.length === 0 || foldedNeedle === '') return undefined
|
|
291
|
+
const starts = []
|
|
292
|
+
let text = ''
|
|
293
|
+
for (const node of nodes) {
|
|
294
|
+
starts.push(text.length)
|
|
295
|
+
text += typeof node.nodeValue === 'string' ? node.nodeValue : ''
|
|
296
|
+
}
|
|
297
|
+
const folded = foldSearchText(text)
|
|
298
|
+
const matches = []
|
|
299
|
+
let from = 0
|
|
300
|
+
for (;;) {
|
|
301
|
+
const at = folded.indexOf(foldedNeedle, from)
|
|
302
|
+
if (at < 0) break
|
|
303
|
+
matches.push({ start: at, end: at + foldedNeedle.length })
|
|
304
|
+
from = at + Math.max(1, foldedNeedle.length)
|
|
305
|
+
}
|
|
306
|
+
if (matches.length === 0) return undefined
|
|
307
|
+
return { nodes, starts, matches }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* @returns the text node and offset containing one global text offset.
|
|
312
|
+
*/
|
|
313
|
+
function locateTextOffset(nodes, starts, offset) {
|
|
314
|
+
let low = 0
|
|
315
|
+
let high = starts.length - 1
|
|
316
|
+
let found = 0
|
|
317
|
+
while (low <= high) {
|
|
318
|
+
const mid = (low + high) >> 1
|
|
319
|
+
if (starts[mid] <= offset) {
|
|
320
|
+
found = mid
|
|
321
|
+
low = mid + 1
|
|
322
|
+
} else {
|
|
323
|
+
high = mid - 1
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const node = nodes[found]
|
|
327
|
+
const length = typeof node.nodeValue === 'string' ? node.nodeValue.length : 0
|
|
328
|
+
return { node, offset: Math.max(0, Math.min(offset - starts[found], length)) }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Build a DOM Range for one match, or `undefined` when it cannot be made. */
|
|
332
|
+
function createTextRange(documentRef, nodes, starts, match) {
|
|
333
|
+
if (documentRef === null || documentRef === undefined || typeof documentRef.createRange !== 'function') {
|
|
334
|
+
return undefined
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
const first = locateTextOffset(nodes, starts, match.start)
|
|
338
|
+
const last = locateTextOffset(nodes, starts, match.end)
|
|
339
|
+
const range = documentRef.createRange()
|
|
340
|
+
range.setStart(first.node, first.offset)
|
|
341
|
+
range.setEnd(last.node, last.offset)
|
|
342
|
+
return range
|
|
343
|
+
} catch (_error) {
|
|
344
|
+
return undefined
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Add the highlight stylesheet once per document.
|
|
350
|
+
*
|
|
351
|
+
* Both APIs are styled from the same palette: translucent yellow for every
|
|
352
|
+
* match, a stronger orange for the current one. `rgba` keeps them readable
|
|
353
|
+
* over light and dark themes and avoids depending on theme variables a
|
|
354
|
+
* browser might not resolve inside `::highlight`.
|
|
355
|
+
*/
|
|
356
|
+
function ensureSearchHighlightStyle(documentRef) {
|
|
357
|
+
if (documentRef === null || documentRef === undefined) return
|
|
358
|
+
if (typeof documentRef.createElement !== 'function') return
|
|
359
|
+
try {
|
|
360
|
+
if (typeof documentRef.getElementById === 'function'
|
|
361
|
+
&& documentRef.getElementById(SEARCH_HIGHLIGHT_STYLE_ID) !== null) {
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
const style = documentRef.createElement('style')
|
|
365
|
+
style.id = SEARCH_HIGHLIGHT_STYLE_ID
|
|
366
|
+
style.textContent = [
|
|
367
|
+
`::highlight(${SEARCH_HIGHLIGHT_NAME}) { background-color: rgba(255, 213, 0, 0.42); color: inherit; }`,
|
|
368
|
+
`::highlight(${SEARCH_ACTIVE_HIGHLIGHT_NAME}) { background-color: rgba(255, 138, 0, 0.68); color: inherit; }`,
|
|
369
|
+
`.${SEARCH_HIT_CLASS} { background-color: rgba(255, 213, 0, 0.42); color: inherit; }`,
|
|
370
|
+
`.${SEARCH_ACTIVE_HIT_CLASS} { background-color: rgba(255, 138, 0, 0.68); color: inherit; }`,
|
|
371
|
+
].join('\n')
|
|
372
|
+
const parent = documentRef.head ?? documentRef.documentElement
|
|
373
|
+
if (parent !== null && parent !== undefined && typeof parent.appendChild === 'function') {
|
|
374
|
+
parent.appendChild(style)
|
|
375
|
+
}
|
|
376
|
+
} catch (_error) {
|
|
377
|
+
// Highlight color is decorative: a missing <style> must never break search.
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Detect the CSS Custom Highlight API.
|
|
383
|
+
* @returns `{ registry, Highlight }`, or `undefined` when unavailable.
|
|
384
|
+
*/
|
|
385
|
+
function searchHighlightSupport() {
|
|
386
|
+
const candidates = []
|
|
387
|
+
try {
|
|
388
|
+
if (typeof window !== 'undefined' && window !== null) candidates.push(window)
|
|
389
|
+
} catch (_error) {
|
|
390
|
+
// No window in some headless environments; globalThis is enough.
|
|
391
|
+
}
|
|
392
|
+
candidates.push(globalThis)
|
|
393
|
+
for (const candidate of candidates) {
|
|
394
|
+
try {
|
|
395
|
+
const registry = candidate?.CSS?.highlights
|
|
396
|
+
const HighlightClass = candidate?.Highlight
|
|
397
|
+
if (registry !== null && registry !== undefined
|
|
398
|
+
&& typeof registry.set === 'function'
|
|
399
|
+
&& typeof registry.delete === 'function'
|
|
400
|
+
&& typeof HighlightClass === 'function') {
|
|
401
|
+
return { registry, Highlight: HighlightClass }
|
|
402
|
+
}
|
|
403
|
+
} catch (_error) {
|
|
404
|
+
// Try the next global.
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return undefined
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Remove both highlight layers and any fallback span classes.
|
|
412
|
+
* @param pane - preview body, when still known.
|
|
413
|
+
*/
|
|
414
|
+
function clearSearchHighlights(pane) {
|
|
415
|
+
try {
|
|
416
|
+
const support = searchHighlightSupport()
|
|
417
|
+
if (support !== undefined) {
|
|
418
|
+
support.registry.delete(SEARCH_HIGHLIGHT_NAME)
|
|
419
|
+
support.registry.delete(SEARCH_ACTIVE_HIGHLIGHT_NAME)
|
|
420
|
+
}
|
|
421
|
+
} catch (_error) {
|
|
422
|
+
// Clearing is best effort.
|
|
423
|
+
}
|
|
424
|
+
try {
|
|
425
|
+
const documentRef = pane?.ownerDocument
|
|
426
|
+
?? (typeof document === 'undefined' ? undefined : document)
|
|
427
|
+
if (documentRef === undefined) return
|
|
428
|
+
const roots = pane !== null && pane !== undefined ? [pane] : [documentRef]
|
|
429
|
+
for (const root of roots) {
|
|
430
|
+
if (typeof root.querySelectorAll !== 'function') continue
|
|
431
|
+
for (const element of root.querySelectorAll(`.${SEARCH_HIT_CLASS}, .${SEARCH_ACTIVE_HIT_CLASS}`)) {
|
|
432
|
+
if (typeof element.classList?.remove === 'function') {
|
|
433
|
+
element.classList.remove(SEARCH_HIT_CLASS, SEARCH_ACTIVE_HIT_CLASS)
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
} catch (_error) {
|
|
438
|
+
// Clearing is best effort.
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Highlight the query on every hit page, with the current occurrence stronger.
|
|
444
|
+
*
|
|
445
|
+
* The active occurrence is chosen by its per-page ordinal: count the hits
|
|
446
|
+
* before `active` that share the page. That ordinal indexes the client-side
|
|
447
|
+
* match list for that page. When the client has fewer matches than the Host
|
|
448
|
+
* claimed (extraction and the text layer can disagree), every match is still
|
|
449
|
+
* highlighted and the first one becomes active.
|
|
450
|
+
* @param pane - preview body.
|
|
451
|
+
* @param needle - literal query.
|
|
452
|
+
* @param hits - ordered Host hits (`{ pdf }` is the only field used here).
|
|
453
|
+
* @param active - index into `hits`.
|
|
454
|
+
*/
|
|
455
|
+
function applySearchHighlights(pane, needle, hits, active) {
|
|
456
|
+
if (pane === null || pane === undefined || !Array.isArray(hits) || hits.length === 0) {
|
|
457
|
+
clearSearchHighlights(pane)
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
const queryText = String(needle ?? '').trim()
|
|
461
|
+
if (queryText === '') {
|
|
462
|
+
clearSearchHighlights(pane)
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
const documentRef = pane.ownerDocument
|
|
466
|
+
ensureSearchHighlightStyle(documentRef)
|
|
467
|
+
clearSearchHighlights(pane)
|
|
468
|
+
|
|
469
|
+
const elements = new Map()
|
|
470
|
+
for (const entry of pageElements(pane)) elements.set(entry.number, entry.element)
|
|
471
|
+
const pageNumbers = []
|
|
472
|
+
const seenPages = new Set()
|
|
473
|
+
for (const hit of hits) {
|
|
474
|
+
const page = hit?.pdf
|
|
475
|
+
if (!positiveInteger(page) || seenPages.has(page)) continue
|
|
476
|
+
seenPages.add(page)
|
|
477
|
+
pageNumbers.push(page)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const safeActive = Number.isInteger(active) && active >= 0 && active < hits.length ? active : 0
|
|
481
|
+
const activePage = positiveInteger(hits[safeActive]?.pdf) ? hits[safeActive].pdf : undefined
|
|
482
|
+
let activeOrdinal = 0
|
|
483
|
+
if (activePage !== undefined) {
|
|
484
|
+
for (let at = 0; at < safeActive; at += 1) {
|
|
485
|
+
if (hits[at]?.pdf === activePage) activeOrdinal += 1
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const pageMatches = []
|
|
490
|
+
for (const page of pageNumbers) {
|
|
491
|
+
const element = elements.get(page)
|
|
492
|
+
if (element === undefined) continue
|
|
493
|
+
const nodes = []
|
|
494
|
+
for (const container of element.querySelectorAll(`[${TEXT_LAYER_ATTR}]`)) {
|
|
495
|
+
for (const node of collectTextNodes(container)) nodes.push(node)
|
|
496
|
+
}
|
|
497
|
+
const matched = matchTextNodes(nodes, queryText)
|
|
498
|
+
if (matched !== undefined) pageMatches.push({ page, ...matched })
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const support = searchHighlightSupport()
|
|
502
|
+
if (support !== undefined) {
|
|
503
|
+
try {
|
|
504
|
+
const allRanges = []
|
|
505
|
+
let activeRange
|
|
506
|
+
for (const page of pageMatches) {
|
|
507
|
+
const ranges = []
|
|
508
|
+
for (const match of page.matches) {
|
|
509
|
+
const range = createTextRange(documentRef, page.nodes, page.starts, match)
|
|
510
|
+
if (range !== undefined) {
|
|
511
|
+
ranges.push(range)
|
|
512
|
+
allRanges.push(range)
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if (page.page === activePage && ranges.length > 0) {
|
|
516
|
+
activeRange = activeOrdinal < ranges.length ? ranges[activeOrdinal] : ranges[0]
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if (allRanges.length > 0) {
|
|
520
|
+
support.registry.set(SEARCH_HIGHLIGHT_NAME, new support.Highlight(...allRanges))
|
|
521
|
+
} else {
|
|
522
|
+
support.registry.delete(SEARCH_HIGHLIGHT_NAME)
|
|
523
|
+
}
|
|
524
|
+
if (activeRange !== undefined) {
|
|
525
|
+
support.registry.set(SEARCH_ACTIVE_HIGHLIGHT_NAME, new support.Highlight(activeRange))
|
|
526
|
+
} else {
|
|
527
|
+
support.registry.delete(SEARCH_ACTIVE_HIGHLIGHT_NAME)
|
|
528
|
+
}
|
|
529
|
+
return
|
|
530
|
+
} catch (_error) {
|
|
531
|
+
try {
|
|
532
|
+
support.registry.delete(SEARCH_HIGHLIGHT_NAME)
|
|
533
|
+
support.registry.delete(SEARCH_ACTIVE_HIGHLIGHT_NAME)
|
|
534
|
+
} catch (_ignored) {
|
|
535
|
+
// The span fallback below is still available.
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Fallback for older Chromium: tint the parent spans overlapping a match.
|
|
541
|
+
for (const page of pageMatches) {
|
|
542
|
+
const activeIndex = page.page === activePage
|
|
543
|
+
? (activeOrdinal < page.matches.length ? activeOrdinal : 0)
|
|
544
|
+
: -1
|
|
545
|
+
for (let index = 0; index < page.matches.length; index += 1) {
|
|
546
|
+
const match = page.matches[index]
|
|
547
|
+
for (let at = 0; at < page.nodes.length; at += 1) {
|
|
548
|
+
const node = page.nodes[at]
|
|
549
|
+
const start = page.starts[at]
|
|
550
|
+
const length = typeof node.nodeValue === 'string' ? node.nodeValue.length : 0
|
|
551
|
+
if (start + length <= match.start || start >= match.end) continue
|
|
552
|
+
const parent = node.parentElement
|
|
553
|
+
if (parent === null || parent === undefined) continue
|
|
554
|
+
if (typeof parent.classList?.add !== 'function') continue
|
|
555
|
+
try {
|
|
556
|
+
parent.classList.add(SEARCH_HIT_CLASS)
|
|
557
|
+
if (index === activeIndex) parent.classList.add(SEARCH_ACTIVE_HIT_CLASS)
|
|
558
|
+
} catch (_error) {
|
|
559
|
+
// The fallback is best effort.
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
|
|
226
567
|
/**
|
|
227
568
|
* Find the preview body that owns one header contribution.
|
|
228
569
|
*
|
|
@@ -267,6 +608,29 @@ window.__ModuleLoader__.load({
|
|
|
267
608
|
return undefined
|
|
268
609
|
}
|
|
269
610
|
|
|
611
|
+
/**
|
|
612
|
+
* Find the zoom wrapper that owns the preview scrollport.
|
|
613
|
+
*
|
|
614
|
+
* The PDF renderer nests the frame inside the preview body, so the frame is
|
|
615
|
+
* usually the scrollport's closest ancestor. A pane descendant is also
|
|
616
|
+
* checked so a renderer reshuffle degrades to "no zoom restore" instead of
|
|
617
|
+
* failing the page restore.
|
|
618
|
+
* @param pane - preview body.
|
|
619
|
+
* @param scrollport - inner scrolling element, when one exists.
|
|
620
|
+
* @returns the zoom frame, or `undefined` for a renderer without zoom.
|
|
621
|
+
*/
|
|
622
|
+
function resolveZoomFrame(pane, scrollport) {
|
|
623
|
+
if (scrollport !== null && scrollport !== undefined && typeof scrollport.closest === 'function') {
|
|
624
|
+
const nearest = scrollport.closest(`[${ZOOM_FRAME_ATTR}]`)
|
|
625
|
+
if (isElement(nearest)) return nearest
|
|
626
|
+
}
|
|
627
|
+
if (pane !== null && pane !== undefined && typeof pane.querySelector === 'function') {
|
|
628
|
+
const nested = pane.querySelector(`[${ZOOM_FRAME_ATTR}]`)
|
|
629
|
+
if (isElement(nested)) return nested
|
|
630
|
+
}
|
|
631
|
+
return undefined
|
|
632
|
+
}
|
|
633
|
+
|
|
270
634
|
/**
|
|
271
635
|
* Scroll one page near the top of the preview.
|
|
272
636
|
*
|
|
@@ -300,6 +664,118 @@ window.__ModuleLoader__.load({
|
|
|
300
664
|
return false
|
|
301
665
|
}
|
|
302
666
|
|
|
667
|
+
/**
|
|
668
|
+
* Ask the official preview to change zoom through its own gesture path.
|
|
669
|
+
*
|
|
670
|
+
* The preview implements zoom as a native `wheel` listener on the scrollport:
|
|
671
|
+
* it only reacts to `ctrlKey`, multiplies the current scale by
|
|
672
|
+
* `exp(-deltaY * 0.01)`, clamps `deltaY` to ±40, and commits the fixed
|
|
673
|
+
* preference 120 ms after the last event. Dispatching that synthetic event
|
|
674
|
+
* is therefore the only supported way for a plugin to set an arbitrary
|
|
675
|
+
* scale (0.25..4) without writing React state it does not own.
|
|
676
|
+
* @param scrollport - the preview's inner scrolling element.
|
|
677
|
+
* @param deltaY - wheel delta; negative zooms in.
|
|
678
|
+
* @returns whether an event was dispatched.
|
|
679
|
+
*/
|
|
680
|
+
function dispatchZoomWheel(scrollport, deltaY) {
|
|
681
|
+
if (scrollport === null || scrollport === undefined || typeof scrollport.dispatchEvent !== 'function') {
|
|
682
|
+
return false
|
|
683
|
+
}
|
|
684
|
+
const view = scrollport.ownerDocument?.defaultView
|
|
685
|
+
const Wheel = view?.WheelEvent ?? (typeof WheelEvent === 'function' ? WheelEvent : undefined)
|
|
686
|
+
if (typeof Wheel !== 'function') return false
|
|
687
|
+
let event
|
|
688
|
+
try {
|
|
689
|
+
event = new Wheel('wheel', {
|
|
690
|
+
ctrlKey: true,
|
|
691
|
+
deltaY,
|
|
692
|
+
deltaMode: 0,
|
|
693
|
+
clientX: 0,
|
|
694
|
+
clientY: 0,
|
|
695
|
+
bubbles: true,
|
|
696
|
+
cancelable: true,
|
|
697
|
+
})
|
|
698
|
+
} catch (_error) {
|
|
699
|
+
return false
|
|
700
|
+
}
|
|
701
|
+
try {
|
|
702
|
+
scrollport.dispatchEvent(event)
|
|
703
|
+
} catch (_error) {
|
|
704
|
+
return false
|
|
705
|
+
}
|
|
706
|
+
return true
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Clamp one zoom ratio into the range the preview and the Host agree on.
|
|
711
|
+
* @param value - number or CSS custom-property string.
|
|
712
|
+
* @returns a 3-decimal scale in 0.25..4, or `undefined` when unreadable.
|
|
713
|
+
*/
|
|
714
|
+
function clampZoomScale(value) {
|
|
715
|
+
const numeric = typeof value === 'number' ? value : Number.parseFloat(String(value ?? ''))
|
|
716
|
+
if (!Number.isFinite(numeric)) return undefined
|
|
717
|
+
return Math.round(Math.min(VIEW_ZOOM_MAX, Math.max(VIEW_ZOOM_MIN, numeric)) * 1000) / 1000
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Normalize one `GET /dsh-pdf-reader/view` payload.
|
|
722
|
+
*
|
|
723
|
+
* A missing row, a 404, and a malformed row all mean "nothing to restore";
|
|
724
|
+
* the Host owns validation, so the client only keeps values it can act on.
|
|
725
|
+
* @param body - parsed response body.
|
|
726
|
+
* @returns `{ pdfPage, zoomMode, zoomScale }`, or `undefined`.
|
|
727
|
+
*/
|
|
728
|
+
function normalizeView(body) {
|
|
729
|
+
if (body === null || typeof body !== 'object' || body.ok !== true) return undefined
|
|
730
|
+
const view = body.view
|
|
731
|
+
if (view === null || typeof view !== 'object') return undefined
|
|
732
|
+
if (!positiveInteger(view.pdfPage)) return undefined
|
|
733
|
+
return {
|
|
734
|
+
pdfPage: view.pdfPage,
|
|
735
|
+
zoomMode: view.zoomMode === 'fixed' ? 'fixed' : 'fit-width',
|
|
736
|
+
zoomScale: clampZoomScale(view.zoomScale) ?? 1,
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** @returns the zoom mode the preview frame currently reports. */
|
|
741
|
+
function frameZoomMode(frame) {
|
|
742
|
+
if (frame === null || frame === undefined || typeof frame.getAttribute !== 'function') return 'fit-width'
|
|
743
|
+
return frame.getAttribute(ZOOM_MODE_ATTR) === 'fixed' ? 'fixed' : 'fit-width'
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Read the effective zoom the preview frame currently shows.
|
|
748
|
+
*
|
|
749
|
+
* The renderer writes the resolved scale to the frame's inline
|
|
750
|
+
* `--document-zoom` (fit-width included), so the inline value is read first
|
|
751
|
+
* and the computed value only serves a frame whose style was not inlined.
|
|
752
|
+
* @param frame - zoom frame, when one exists.
|
|
753
|
+
* @returns a clamped, 3-decimal scale.
|
|
754
|
+
*/
|
|
755
|
+
function frameZoomScale(frame) {
|
|
756
|
+
if (frame === null || frame === undefined || typeof frame.getAttribute !== 'function') return 1
|
|
757
|
+
let raw
|
|
758
|
+
try {
|
|
759
|
+
raw = typeof frame.style?.getPropertyValue === 'function'
|
|
760
|
+
? frame.style.getPropertyValue(ZOOM_VAR)
|
|
761
|
+
: undefined
|
|
762
|
+
if (raw === undefined || raw === null || String(raw).trim() === '') {
|
|
763
|
+
const view = frame.ownerDocument?.defaultView
|
|
764
|
+
if (view !== null && view !== undefined && typeof view.getComputedStyle === 'function') {
|
|
765
|
+
raw = view.getComputedStyle(frame).getPropertyValue(ZOOM_VAR)
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
} catch (_error) {
|
|
769
|
+
raw = undefined
|
|
770
|
+
}
|
|
771
|
+
return clampZoomScale(raw) ?? 1
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** @returns `{ zoomMode, zoomScale }` as the frame currently shows them. */
|
|
775
|
+
function readFrameZoom(frame) {
|
|
776
|
+
return { zoomMode: frameZoomMode(frame), zoomScale: frameZoomScale(frame) }
|
|
777
|
+
}
|
|
778
|
+
|
|
303
779
|
/**
|
|
304
780
|
* A cancellable trailing-edge debouncer.
|
|
305
781
|
* @param delay - milliseconds.
|
|
@@ -479,7 +955,7 @@ window.__ModuleLoader__.load({
|
|
|
479
955
|
}
|
|
480
956
|
|
|
481
957
|
/**
|
|
482
|
-
* Which mounted contribution answers the next Cmd/Ctrl+
|
|
958
|
+
* Which mounted contribution answers the next Cmd/Ctrl+F.
|
|
483
959
|
* Pointing at a preview claims it, exactly like dsh-pdf-search's Ctrl+F.
|
|
484
960
|
*/
|
|
485
961
|
let activeOwner
|
|
@@ -580,51 +1056,17 @@ window.__ModuleLoader__.load({
|
|
|
580
1056
|
userSelect: 'text',
|
|
581
1057
|
}
|
|
582
1058
|
|
|
583
|
-
const
|
|
584
|
-
|
|
585
|
-
margin: '0',
|
|
586
|
-
padding: '0',
|
|
587
|
-
maxHeight: '46vh',
|
|
588
|
-
overflowY: 'auto',
|
|
589
|
-
overscrollBehavior: 'contain',
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
const RESULT_ROW_STYLE = {
|
|
593
|
-
display: 'flex',
|
|
594
|
-
alignItems: 'center',
|
|
595
|
-
gap: '4px',
|
|
596
|
-
borderTop: '1px solid rgba(128,128,128,.18)',
|
|
597
|
-
padding: '2px 0',
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
const RESULT_JUMP_STYLE = {
|
|
601
|
-
flex: '1 1 auto',
|
|
602
|
-
minWidth: '0',
|
|
603
|
-
padding: '3px 2px',
|
|
604
|
-
border: 'none',
|
|
605
|
-
background: 'transparent',
|
|
606
|
-
color: 'inherit',
|
|
607
|
-
font: 'inherit',
|
|
608
|
-
textAlign: 'left',
|
|
609
|
-
cursor: 'pointer',
|
|
610
|
-
overflow: 'hidden',
|
|
611
|
-
whiteSpace: 'nowrap',
|
|
612
|
-
textOverflow: 'ellipsis',
|
|
613
|
-
borderRadius: '4px',
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
const AI_BUTTON_STYLE = {
|
|
1059
|
+
const SEARCH_COUNT_STYLE = {
|
|
1060
|
+
minWidth: '42px',
|
|
617
1061
|
flex: '0 0 auto',
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
color: 'inherit',
|
|
623
|
-
font: 'inherit',
|
|
624
|
-
cursor: 'pointer',
|
|
625
|
-
opacity: 0.9,
|
|
1062
|
+
textAlign: 'center',
|
|
1063
|
+
fontVariantNumeric: 'tabular-nums',
|
|
1064
|
+
opacity: 0.8,
|
|
1065
|
+
fontSize: '11px',
|
|
626
1066
|
}
|
|
627
1067
|
|
|
1068
|
+
const SEARCH_HINT_STYLE = { ...MUTED_STYLE, fontSize: '10px' }
|
|
1069
|
+
|
|
628
1070
|
/**
|
|
629
1071
|
* The Client locale service, attached by `apply`. Absent in headless tests,
|
|
630
1072
|
* where the language then falls back to the browser's own preference.
|
|
@@ -671,8 +1113,8 @@ window.__ModuleLoader__.load({
|
|
|
671
1113
|
en: ['Page number', 'Type a page and press Enter; printed page by default, prefix p for a PDF page'],
|
|
672
1114
|
},
|
|
673
1115
|
search: {
|
|
674
|
-
zh: ['搜索', '搜索这个 PDF 的文字层(Cmd/Ctrl+
|
|
675
|
-
en: ['Search', "Search this PDF's text layer (Cmd/Ctrl+
|
|
1116
|
+
zh: ['搜索', '搜索这个 PDF 的文字层(Cmd/Ctrl+F)'],
|
|
1117
|
+
en: ['Search', "Search this PDF's text layer (Cmd/Ctrl+F)"],
|
|
676
1118
|
},
|
|
677
1119
|
follow: {
|
|
678
1120
|
zh: ['AI 上下文', '把当前阅读页报告给模型'],
|
|
@@ -682,14 +1124,32 @@ window.__ModuleLoader__.load({
|
|
|
682
1124
|
zh: ['关闭搜索', '收起搜索面板(Esc)'],
|
|
683
1125
|
en: ['Close search', 'Close the search panel (Esc)'],
|
|
684
1126
|
},
|
|
685
|
-
|
|
686
|
-
zh: ['
|
|
687
|
-
en: ['
|
|
1127
|
+
prevHit: {
|
|
1128
|
+
zh: ['上一个匹配', '跳到上一个命中(Shift+Enter)'],
|
|
1129
|
+
en: ['Previous match', 'Jump to the previous occurrence (Shift+Enter)'],
|
|
1130
|
+
},
|
|
1131
|
+
nextHit: {
|
|
1132
|
+
zh: ['下一个匹配', '跳到下一个命中(Enter)'],
|
|
1133
|
+
en: ['Next match', 'Jump to the next occurrence (Enter)'],
|
|
688
1134
|
},
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/** Find-bar copy, in both UI languages. */
|
|
1138
|
+
const SEARCH_COPY = {
|
|
1139
|
+
label: { zh: '搜索这个 PDF', en: 'Search this PDF' },
|
|
1140
|
+
hint: {
|
|
1141
|
+
zh: 'Enter 下一个 · Shift+Enter 上一个 · Esc 关闭',
|
|
1142
|
+
en: 'Enter next · Shift+Enter previous · Esc close',
|
|
692
1143
|
},
|
|
1144
|
+
prev: { zh: '上一个匹配', en: 'Previous match' },
|
|
1145
|
+
next: { zh: '下一个匹配', en: 'Next match' },
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/** @returns one find-bar string in the active UI language. */
|
|
1149
|
+
function searchCopy(key) {
|
|
1150
|
+
const entry = SEARCH_COPY[key]
|
|
1151
|
+
if (entry === undefined) return ''
|
|
1152
|
+
return entry[activeLanguage()] ?? entry.en
|
|
693
1153
|
}
|
|
694
1154
|
|
|
695
1155
|
/** @returns the localized explanation body for the follow control. */
|
|
@@ -762,21 +1222,6 @@ window.__ModuleLoader__.load({
|
|
|
762
1222
|
h('circle', { cx: 7, cy: 7, r: 4.4, stroke: 'currentColor', strokeWidth: 1.6 }),
|
|
763
1223
|
h('path', { d: 'M10.4 10.4 14.2 14.2', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round' }))
|
|
764
1224
|
|
|
765
|
-
/**
|
|
766
|
-
* Copy plain text, used only when the composer insertion is unavailable.
|
|
767
|
-
* @returns whether the clipboard write succeeded.
|
|
768
|
-
*/
|
|
769
|
-
async function copyText(text) {
|
|
770
|
-
try {
|
|
771
|
-
if (typeof navigator === 'undefined' || navigator.clipboard === undefined) return false
|
|
772
|
-
if (typeof navigator.clipboard.writeText !== 'function') return false
|
|
773
|
-
await navigator.clipboard.writeText(text)
|
|
774
|
-
return true
|
|
775
|
-
} catch (_error) {
|
|
776
|
-
return false
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
|
|
780
1225
|
/**
|
|
781
1226
|
* The header contribution for one PDF preview.
|
|
782
1227
|
* @param props - Session-scope standard props plus the preview's `absolutePath`.
|
|
@@ -784,7 +1229,6 @@ window.__ModuleLoader__.load({
|
|
|
784
1229
|
function PdfReaderAction(props) {
|
|
785
1230
|
const absolutePath = typeof props?.absolutePath === 'string' ? props.absolutePath : undefined
|
|
786
1231
|
const isPdf = isPdfPath(absolutePath)
|
|
787
|
-
const inputActions = props?.inputActions
|
|
788
1232
|
const sessionId = typeof props?.sessionId === 'string' ? props.sessionId : undefined
|
|
789
1233
|
|
|
790
1234
|
const hostRef = useRef(null)
|
|
@@ -792,7 +1236,6 @@ window.__ModuleLoader__.load({
|
|
|
792
1236
|
const searchInputRef = useRef(null)
|
|
793
1237
|
const ownerRef = useRef(null)
|
|
794
1238
|
if (ownerRef.current === null) ownerRef.current = Symbol('dsh-pdf-reader')
|
|
795
|
-
const focusTimerRef = useRef(null)
|
|
796
1239
|
const flashTimerRef = useRef(null)
|
|
797
1240
|
const reportAbortRef = useRef(null)
|
|
798
1241
|
const reportSeqRef = useRef(0)
|
|
@@ -805,6 +1248,10 @@ window.__ModuleLoader__.load({
|
|
|
805
1248
|
(id) => globalThis.clearTimeout(id),
|
|
806
1249
|
)
|
|
807
1250
|
}
|
|
1251
|
+
/** Latest sampled page, readable from the debounced view save. */
|
|
1252
|
+
const pageRef = useRef(1)
|
|
1253
|
+
/** Installed by the view effect; `null` when this preview keeps no view. */
|
|
1254
|
+
const requestViewSaveRef = useRef(null)
|
|
808
1255
|
|
|
809
1256
|
const [page, setPage] = useState(1)
|
|
810
1257
|
const [sampled, setSampled] = useState(false)
|
|
@@ -818,6 +1265,7 @@ window.__ModuleLoader__.load({
|
|
|
818
1265
|
const [open, setOpen] = useState(false)
|
|
819
1266
|
const [query, setQuery] = useState('')
|
|
820
1267
|
const [hits, setHits] = useState(null)
|
|
1268
|
+
const [active, setActive] = useState(0)
|
|
821
1269
|
const [searching, setSearching] = useState(false)
|
|
822
1270
|
const [searchError, setSearchError] = useState(null)
|
|
823
1271
|
const [tip, setTip] = useState(null)
|
|
@@ -887,41 +1335,51 @@ window.__ModuleLoader__.load({
|
|
|
887
1335
|
|
|
888
1336
|
/** Focus the search input; keep the query highlighted for quick replacement. */
|
|
889
1337
|
const openSearch = () => {
|
|
1338
|
+
const input = searchInputRef.current
|
|
1339
|
+
if (input === null) {
|
|
1340
|
+
// The panel is not mounted yet, so this is a fresh open: start clean
|
|
1341
|
+
// and let the search effect re-query the kept query.
|
|
1342
|
+
setHits(null)
|
|
1343
|
+
setActive(0)
|
|
1344
|
+
setSearching(false)
|
|
1345
|
+
setSearchError(null)
|
|
1346
|
+
}
|
|
890
1347
|
setOpen(true)
|
|
891
|
-
if (
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
searchInputRef.current?.select()
|
|
896
|
-
}, 0)
|
|
1348
|
+
if (input !== null) {
|
|
1349
|
+
input.focus()
|
|
1350
|
+
input.select()
|
|
1351
|
+
}
|
|
897
1352
|
}
|
|
898
1353
|
|
|
899
1354
|
/** Close the panel and give focus back to the header button. */
|
|
900
1355
|
const closeSearch = () => {
|
|
901
1356
|
setOpen(false)
|
|
902
|
-
if (focusTimerRef.current !== null) {
|
|
903
|
-
globalThis.clearTimeout(focusTimerRef.current)
|
|
904
|
-
focusTimerRef.current = null
|
|
905
|
-
}
|
|
906
1357
|
searchButtonRef.current?.focus()
|
|
907
1358
|
}
|
|
908
1359
|
|
|
909
1360
|
/**
|
|
910
|
-
*
|
|
911
|
-
*
|
|
912
|
-
* is
|
|
1361
|
+
* Highlighted navigation helpers.
|
|
1362
|
+
*
|
|
1363
|
+
* Search is deliberately the most basic kind: one query, a list of Host
|
|
1364
|
+
* hits, and previous/next stepping that wraps at both ends. The counter
|
|
1365
|
+
* updates immediately; the in-page highlight effect waits for the page's
|
|
1366
|
+
* lazily rendered text layer.
|
|
913
1367
|
*/
|
|
914
|
-
const
|
|
915
|
-
if (
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
1368
|
+
const navigateHits = (list, index) => {
|
|
1369
|
+
if (!Array.isArray(list) || list.length === 0) return false
|
|
1370
|
+
const count = list.length
|
|
1371
|
+
const requested = Number.isFinite(index) ? Math.trunc(index) : 0
|
|
1372
|
+
const next = ((requested % count) + count) % count
|
|
1373
|
+
const hit = list[next]
|
|
1374
|
+
if (hit === undefined) return false
|
|
1375
|
+
jumpTo(hit.pdf)
|
|
1376
|
+
setActive(next)
|
|
1377
|
+
return true
|
|
923
1378
|
}
|
|
924
1379
|
|
|
1380
|
+
/** Move to an absolute hit index (the caller adds or subtracts one). */
|
|
1381
|
+
const navigate = (index) => navigateHits(hits, index)
|
|
1382
|
+
|
|
925
1383
|
/** Scroll a physical page into view and update the readout immediately. */
|
|
926
1384
|
const jumpTo = (target) => {
|
|
927
1385
|
if (!positiveInteger(target)) return false
|
|
@@ -980,14 +1438,12 @@ window.__ModuleLoader__.load({
|
|
|
980
1438
|
setOpen(false)
|
|
981
1439
|
setQuery('')
|
|
982
1440
|
setHits(null)
|
|
1441
|
+
setActive(0)
|
|
983
1442
|
setSearching(false)
|
|
984
1443
|
setSearchError(null)
|
|
985
1444
|
lastReportedKeyRef.current = undefined
|
|
1445
|
+
pageRef.current = 1
|
|
986
1446
|
reportAbortRef.current?.abort()
|
|
987
|
-
if (focusTimerRef.current !== null) {
|
|
988
|
-
globalThis.clearTimeout(focusTimerRef.current)
|
|
989
|
-
focusTimerRef.current = null
|
|
990
|
-
}
|
|
991
1447
|
if (flashTimerRef.current !== null) {
|
|
992
1448
|
globalThis.clearTimeout(flashTimerRef.current)
|
|
993
1449
|
flashTimerRef.current = null
|
|
@@ -1254,6 +1710,360 @@ window.__ModuleLoader__.load({
|
|
|
1254
1710
|
}
|
|
1255
1711
|
}, [isPdf, absolutePath])
|
|
1256
1712
|
|
|
1713
|
+
// Restore the last page/zoom for this book, then persist both best-effort.
|
|
1714
|
+
//
|
|
1715
|
+
// The official preview keeps neither across tabs, so this owns one restore
|
|
1716
|
+
// per absolutePath: fetch the saved view in parallel with rendering, wait
|
|
1717
|
+
// (up to ~8 s) for the preview structure and the target page, drive the
|
|
1718
|
+
// renderer's own Ctrl+wheel path for a fixed zoom, and only then allow
|
|
1719
|
+
// saving. The gate matters: saving before the restore lands would replace
|
|
1720
|
+
// the saved page with page 1 every time a book is opened.
|
|
1721
|
+
useEffect(() => {
|
|
1722
|
+
if (!isPdf || absolutePath === undefined) return undefined
|
|
1723
|
+
const host = hostRef.current
|
|
1724
|
+
if (host === null) return undefined
|
|
1725
|
+
const pdfPath = absolutePath
|
|
1726
|
+
|
|
1727
|
+
let disposed = false
|
|
1728
|
+
let finished = false
|
|
1729
|
+
let pane
|
|
1730
|
+
let scrollport
|
|
1731
|
+
let frame
|
|
1732
|
+
let attributeObserver
|
|
1733
|
+
let structureObserver
|
|
1734
|
+
let retryTimer
|
|
1735
|
+
let retryCount = 0
|
|
1736
|
+
let reapplyTimer
|
|
1737
|
+
let zoomTimer
|
|
1738
|
+
let zoomEvents = 0
|
|
1739
|
+
let restoreTarget
|
|
1740
|
+
let lastAttemptKey = ''
|
|
1741
|
+
const restoreController = new AbortController()
|
|
1742
|
+
const saveController = new AbortController()
|
|
1743
|
+
|
|
1744
|
+
const saveDebouncer = createDebouncer(
|
|
1745
|
+
VIEW_SAVE_DELAY_MS,
|
|
1746
|
+
(fn, ms) => globalThis.setTimeout(fn, ms),
|
|
1747
|
+
(id) => globalThis.clearTimeout(id),
|
|
1748
|
+
)
|
|
1749
|
+
const structureDebouncer = createDebouncer(
|
|
1750
|
+
250,
|
|
1751
|
+
(fn, ms) => globalThis.setTimeout(fn, ms),
|
|
1752
|
+
(id) => globalThis.clearTimeout(id),
|
|
1753
|
+
)
|
|
1754
|
+
|
|
1755
|
+
/**
|
|
1756
|
+
* Persist the current page and zoom. The four client fields are the whole
|
|
1757
|
+
* contract; the Host stamps `updatedAt`. Identical consecutive payloads
|
|
1758
|
+
* are suppressed, and a failed write is retried by the next trigger.
|
|
1759
|
+
*/
|
|
1760
|
+
const saveNow = () => {
|
|
1761
|
+
if (disposed || !finished) return
|
|
1762
|
+
if (typeof fetch !== 'function') return
|
|
1763
|
+
// A debounced save is also a cheap reconnect point: if the preview
|
|
1764
|
+
// replaced its structure, resolve it again before reading the frame.
|
|
1765
|
+
refresh()
|
|
1766
|
+
const zoom = readFrameZoom(frame)
|
|
1767
|
+
const payload = {
|
|
1768
|
+
path: pdfPath,
|
|
1769
|
+
pdfPage: positiveInteger(pageRef.current) ? pageRef.current : 1,
|
|
1770
|
+
zoomMode: zoom.zoomMode,
|
|
1771
|
+
zoomScale: zoom.zoomScale,
|
|
1772
|
+
}
|
|
1773
|
+
const key = JSON.stringify(payload)
|
|
1774
|
+
if (key === lastAttemptKey) return
|
|
1775
|
+
lastAttemptKey = key
|
|
1776
|
+
let request
|
|
1777
|
+
try {
|
|
1778
|
+
request = fetch(VIEW_ROUTE, {
|
|
1779
|
+
method: 'POST',
|
|
1780
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
1781
|
+
body: JSON.stringify(payload),
|
|
1782
|
+
signal: saveController.signal,
|
|
1783
|
+
keepalive: true,
|
|
1784
|
+
})
|
|
1785
|
+
} catch (_error) {
|
|
1786
|
+
lastAttemptKey = ''
|
|
1787
|
+
return
|
|
1788
|
+
}
|
|
1789
|
+
Promise.resolve(request)
|
|
1790
|
+
.then((response) => {
|
|
1791
|
+
if (disposed) return
|
|
1792
|
+
if (response !== null && response !== undefined && response.ok === true) return
|
|
1793
|
+
if (lastAttemptKey === key) lastAttemptKey = ''
|
|
1794
|
+
})
|
|
1795
|
+
.catch(() => {
|
|
1796
|
+
if (disposed) return
|
|
1797
|
+
if (lastAttemptKey === key) lastAttemptKey = ''
|
|
1798
|
+
})
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
const requestSave = () => {
|
|
1802
|
+
if (disposed) return
|
|
1803
|
+
saveDebouncer.schedule(saveNow)
|
|
1804
|
+
}
|
|
1805
|
+
requestViewSaveRef.current = requestSave
|
|
1806
|
+
|
|
1807
|
+
/** Allow saving once, including when the restore failed or timed out. */
|
|
1808
|
+
const finish = () => {
|
|
1809
|
+
if (disposed || finished) return
|
|
1810
|
+
finished = true
|
|
1811
|
+
requestSave()
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
const detach = () => {
|
|
1815
|
+
attributeObserver?.disconnect()
|
|
1816
|
+
attributeObserver = undefined
|
|
1817
|
+
structureObserver?.disconnect()
|
|
1818
|
+
structureObserver = undefined
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
const observeStructure = () => {
|
|
1822
|
+
structureObserver?.disconnect()
|
|
1823
|
+
structureObserver = undefined
|
|
1824
|
+
if (typeof MutationObserver !== 'function') return
|
|
1825
|
+
const root = pane?.parentElement ?? host.parentElement ?? pane ?? host
|
|
1826
|
+
if (root === null || root === undefined) return
|
|
1827
|
+
structureObserver = new MutationObserver(() => {
|
|
1828
|
+
if (disposed) return
|
|
1829
|
+
structureDebouncer.schedule(syncStructure)
|
|
1830
|
+
})
|
|
1831
|
+
structureObserver.observe(root, { childList: true, subtree: true })
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
const observeFrame = (target) => {
|
|
1835
|
+
attributeObserver?.disconnect()
|
|
1836
|
+
attributeObserver = undefined
|
|
1837
|
+
if (target === null || target === undefined) return
|
|
1838
|
+
if (typeof MutationObserver !== 'function') return
|
|
1839
|
+
attributeObserver = new MutationObserver(() => {
|
|
1840
|
+
if (disposed) return
|
|
1841
|
+
requestSave()
|
|
1842
|
+
})
|
|
1843
|
+
attributeObserver.observe(target, {
|
|
1844
|
+
attributes: true,
|
|
1845
|
+
attributeFilter: [ZOOM_MODE_ATTR, 'style'],
|
|
1846
|
+
subtree: false,
|
|
1847
|
+
})
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
const bind = (nextPane, nextScrollport, nextFrame) => {
|
|
1851
|
+
attributeObserver?.disconnect()
|
|
1852
|
+
attributeObserver = undefined
|
|
1853
|
+
structureObserver?.disconnect()
|
|
1854
|
+
structureObserver = undefined
|
|
1855
|
+
pane = nextPane
|
|
1856
|
+
scrollport = nextScrollport
|
|
1857
|
+
frame = nextFrame
|
|
1858
|
+
observeFrame(frame)
|
|
1859
|
+
observeStructure()
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
const syncStructure = () => {
|
|
1863
|
+
if (disposed) return
|
|
1864
|
+
const nextPane = resolvePane(host)
|
|
1865
|
+
if (nextPane === undefined) return
|
|
1866
|
+
const nextScrollport = resolveScrollport(nextPane)
|
|
1867
|
+
const nextFrame = resolveZoomFrame(nextPane, nextScrollport)
|
|
1868
|
+
if (nextPane !== pane || nextScrollport !== scrollport || nextFrame !== frame) {
|
|
1869
|
+
bind(nextPane, nextScrollport, nextFrame)
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
/** Re-resolve the preview structure; true when a body exists. */
|
|
1874
|
+
const refresh = () => {
|
|
1875
|
+
const nextPane = resolvePane(host)
|
|
1876
|
+
if (nextPane === undefined) return false
|
|
1877
|
+
const nextScrollport = resolveScrollport(nextPane)
|
|
1878
|
+
const nextFrame = resolveZoomFrame(nextPane, nextScrollport)
|
|
1879
|
+
if (nextPane !== pane || nextScrollport !== scrollport || nextFrame !== frame) {
|
|
1880
|
+
bind(nextPane, nextScrollport, nextFrame)
|
|
1881
|
+
}
|
|
1882
|
+
return true
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
const retryRestore = () => {
|
|
1886
|
+
if (disposed || finished || retryTimer !== undefined) return
|
|
1887
|
+
if (retryCount >= VIEW_RETRY_LIMIT) {
|
|
1888
|
+
finish()
|
|
1889
|
+
return
|
|
1890
|
+
}
|
|
1891
|
+
retryCount += 1
|
|
1892
|
+
retryTimer = globalThis.setTimeout(() => {
|
|
1893
|
+
retryTimer = undefined
|
|
1894
|
+
if (!disposed) stepRestore()
|
|
1895
|
+
}, VIEW_RETRY_MS)
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
/**
|
|
1899
|
+
* Scroll the saved page into view, then re-apply once. The second scroll
|
|
1900
|
+
* absorbs pages that were 3:4 placeholders and grew when lazily rendered.
|
|
1901
|
+
*
|
|
1902
|
+
* With `finishGate: false` the page jump happens immediately but the save
|
|
1903
|
+
* gate stays closed — the caller restores zoom afterwards and then
|
|
1904
|
+
* calls `finish()`. That keeps the viewer from showing page 1 while a
|
|
1905
|
+
* fixed zoom is being replayed gesture by gesture.
|
|
1906
|
+
* @param options - `{ finishGate }`, default true.
|
|
1907
|
+
* @returns whether the scroll was applied.
|
|
1908
|
+
*/
|
|
1909
|
+
const placePage = ({ finishGate = true } = {}) => {
|
|
1910
|
+
if (disposed || restoreTarget === undefined) return false
|
|
1911
|
+
const target = restoreTarget.pdfPage
|
|
1912
|
+
if (!refresh() || !scrollPageIntoView(pane, scrollport, target)) {
|
|
1913
|
+
retryRestore()
|
|
1914
|
+
return false
|
|
1915
|
+
}
|
|
1916
|
+
// The save debounce reads this ref, and the restore runs from a timer
|
|
1917
|
+
// (not a React event), so publish the page before state can render.
|
|
1918
|
+
pageRef.current = target
|
|
1919
|
+
setPage(target)
|
|
1920
|
+
setSampled(true)
|
|
1921
|
+
if (finishGate) finish()
|
|
1922
|
+
if (reapplyTimer !== undefined) globalThis.clearTimeout(reapplyTimer)
|
|
1923
|
+
reapplyTimer = globalThis.setTimeout(() => {
|
|
1924
|
+
reapplyTimer = undefined
|
|
1925
|
+
if (disposed || !refresh()) return
|
|
1926
|
+
scrollPageIntoView(pane, scrollport, target)
|
|
1927
|
+
}, VIEW_REAPPLY_MS)
|
|
1928
|
+
return true
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
/**
|
|
1932
|
+
* Let the renderer's 120 ms zoom settlement commit, then scroll again so
|
|
1933
|
+
* the saved page is aligned after the zoom reflowed every page.
|
|
1934
|
+
*/
|
|
1935
|
+
const settleZoom = () => {
|
|
1936
|
+
if (zoomTimer !== undefined) globalThis.clearTimeout(zoomTimer)
|
|
1937
|
+
zoomTimer = globalThis.setTimeout(() => {
|
|
1938
|
+
zoomTimer = undefined
|
|
1939
|
+
if (!disposed) placePage()
|
|
1940
|
+
}, VIEW_ZOOM_SETTLE_MS)
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
/**
|
|
1944
|
+
* Drive Ctrl+wheel events until the frame scale is within 2% of the saved
|
|
1945
|
+
* fixed scale. Each event can change the ratio by at most ~1.49x, so a
|
|
1946
|
+
* large jump needs a few events; the event budget bounds a missing or
|
|
1947
|
+
* non-responsive handler.
|
|
1948
|
+
*/
|
|
1949
|
+
const stepZoom = () => {
|
|
1950
|
+
if (disposed || restoreTarget === undefined) return
|
|
1951
|
+
const target = restoreTarget.zoomScale
|
|
1952
|
+
if (!refresh() || frame === undefined || scrollport === undefined) {
|
|
1953
|
+
finish()
|
|
1954
|
+
return
|
|
1955
|
+
}
|
|
1956
|
+
const current = frameZoomScale(frame)
|
|
1957
|
+
if (Math.abs(current / target - 1) <= VIEW_ZOOM_TOLERANCE) {
|
|
1958
|
+
if (frameZoomMode(frame) !== 'fixed') {
|
|
1959
|
+
// Same scale, fit-width mode: a zero-delta gesture only asks the
|
|
1960
|
+
// renderer to commit `{kind:'fixed'}` at the current scale.
|
|
1961
|
+
if (!dispatchZoomWheel(scrollport, 0)) {
|
|
1962
|
+
finish()
|
|
1963
|
+
return
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
// The renderer commits its fixed preference 120 ms after the last
|
|
1967
|
+
// gesture and only then re-renders pages at the new scale.
|
|
1968
|
+
settleZoom()
|
|
1969
|
+
return
|
|
1970
|
+
}
|
|
1971
|
+
if (zoomEvents >= VIEW_ZOOM_EVENT_LIMIT) {
|
|
1972
|
+
settleZoom()
|
|
1973
|
+
return
|
|
1974
|
+
}
|
|
1975
|
+
const deltaY = Math.max(-40, Math.min(40, -100 * Math.log(target / current)))
|
|
1976
|
+
if (!Number.isFinite(deltaY) || Math.abs(deltaY) < 0.05) {
|
|
1977
|
+
settleZoom()
|
|
1978
|
+
return
|
|
1979
|
+
}
|
|
1980
|
+
zoomEvents += 1
|
|
1981
|
+
if (!dispatchZoomWheel(scrollport, deltaY)) {
|
|
1982
|
+
settleZoom()
|
|
1983
|
+
return
|
|
1984
|
+
}
|
|
1985
|
+
zoomTimer = globalThis.setTimeout(() => {
|
|
1986
|
+
zoomTimer = undefined
|
|
1987
|
+
if (!disposed) stepZoom()
|
|
1988
|
+
}, VIEW_ZOOM_STEP_MS)
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
const stepRestore = () => {
|
|
1992
|
+
if (disposed || finished || restoreTarget === undefined) return
|
|
1993
|
+
const target = restoreTarget
|
|
1994
|
+
if (!refresh()) {
|
|
1995
|
+
retryRestore()
|
|
1996
|
+
return
|
|
1997
|
+
}
|
|
1998
|
+
const page = pane.querySelector(`[${PAGE_ATTR}="${target.pdfPage}"]`)
|
|
1999
|
+
if (!isElement(page)) {
|
|
2000
|
+
retryRestore()
|
|
2001
|
+
return
|
|
2002
|
+
}
|
|
2003
|
+
// Jump first so the cover never flashes while a fixed zoom is replayed.
|
|
2004
|
+
if (!placePage({ finishGate: false })) return
|
|
2005
|
+
if (target.zoomMode === 'fixed' && frame !== undefined && scrollport !== undefined) {
|
|
2006
|
+
if (Math.abs(frameZoomScale(frame) / target.zoomScale - 1) > VIEW_ZOOM_TOLERANCE
|
|
2007
|
+
|| frameZoomMode(frame) !== 'fixed') {
|
|
2008
|
+
stepZoom()
|
|
2009
|
+
return
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
finish()
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
const loadSavedView = async () => {
|
|
2016
|
+
const timer = globalThis.setTimeout(() => {
|
|
2017
|
+
restoreController.abort()
|
|
2018
|
+
}, VIEW_TIMEOUT_MS)
|
|
2019
|
+
try {
|
|
2020
|
+
const { body } = await fetchJson(
|
|
2021
|
+
`${VIEW_ROUTE}?path=${encodeURIComponent(pdfPath)}`,
|
|
2022
|
+
{ headers: { accept: 'application/json' }, signal: restoreController.signal },
|
|
2023
|
+
)
|
|
2024
|
+
if (disposed) return
|
|
2025
|
+
const saved = normalizeView(body)
|
|
2026
|
+
if (saved === undefined) {
|
|
2027
|
+
finish()
|
|
2028
|
+
return
|
|
2029
|
+
}
|
|
2030
|
+
restoreTarget = saved
|
|
2031
|
+
stepRestore()
|
|
2032
|
+
} catch (_error) {
|
|
2033
|
+
if (disposed) return
|
|
2034
|
+
// A missing route, a network failure, or a timeout must not block
|
|
2035
|
+
// saving forever: the restore is best effort.
|
|
2036
|
+
finish()
|
|
2037
|
+
} finally {
|
|
2038
|
+
globalThis.clearTimeout(timer)
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
// Observe the current structure while the GET is in flight, so a zoom the
|
|
2043
|
+
// reader performs before the response still gets saved once the gate opens.
|
|
2044
|
+
refresh()
|
|
2045
|
+
void loadSavedView()
|
|
2046
|
+
|
|
2047
|
+
return () => {
|
|
2048
|
+
disposed = true
|
|
2049
|
+
restoreController.abort()
|
|
2050
|
+
saveController.abort()
|
|
2051
|
+
saveDebouncer.cancel()
|
|
2052
|
+
structureDebouncer.cancel()
|
|
2053
|
+
if (retryTimer !== undefined) globalThis.clearTimeout(retryTimer)
|
|
2054
|
+
if (reapplyTimer !== undefined) globalThis.clearTimeout(reapplyTimer)
|
|
2055
|
+
if (zoomTimer !== undefined) globalThis.clearTimeout(zoomTimer)
|
|
2056
|
+
detach()
|
|
2057
|
+
if (requestViewSaveRef.current === requestSave) requestViewSaveRef.current = null
|
|
2058
|
+
}
|
|
2059
|
+
}, [isPdf, absolutePath])
|
|
2060
|
+
|
|
2061
|
+
// Save the sampled page through the same 500 ms gate as zoom changes.
|
|
2062
|
+
useEffect(() => {
|
|
2063
|
+
pageRef.current = page
|
|
2064
|
+
if (sampled) requestViewSaveRef.current?.()
|
|
2065
|
+
}, [page, sampled])
|
|
2066
|
+
|
|
1257
2067
|
// Debounced AI-context reporting. One timer, one in-flight POST, abort on
|
|
1258
2068
|
// page change, toggle-off, path change, or unmount.
|
|
1259
2069
|
useEffect(() => {
|
|
@@ -1334,17 +2144,21 @@ window.__ModuleLoader__.load({
|
|
|
1334
2144
|
}, [isPdf, absolutePath, sampled, page, printed, sessionId, follow])
|
|
1335
2145
|
|
|
1336
2146
|
// Search: type, wait 220 ms, query the Host route, cancel the stale query.
|
|
2147
|
+
// A new query starts from a clean slate: no stale highlights or counter.
|
|
1337
2148
|
useEffect(() => {
|
|
1338
2149
|
if (!open || !isPdf || absolutePath === undefined) return undefined
|
|
1339
2150
|
const needle = query.trim()
|
|
1340
2151
|
if (needle === '') {
|
|
1341
2152
|
setHits(null)
|
|
2153
|
+
setActive(0)
|
|
1342
2154
|
setSearchError(null)
|
|
1343
2155
|
setSearching(false)
|
|
1344
2156
|
return undefined
|
|
1345
2157
|
}
|
|
1346
2158
|
setSearching(true)
|
|
1347
2159
|
setSearchError(null)
|
|
2160
|
+
setHits(null)
|
|
2161
|
+
setActive(0)
|
|
1348
2162
|
let disposed = false
|
|
1349
2163
|
let timedOut = false
|
|
1350
2164
|
let timer
|
|
@@ -1362,6 +2176,7 @@ window.__ModuleLoader__.load({
|
|
|
1362
2176
|
if (disposed) return
|
|
1363
2177
|
if (body === null || typeof body !== 'object' || body.ok !== true) {
|
|
1364
2178
|
setHits([])
|
|
2179
|
+
setActive(0)
|
|
1365
2180
|
setSearchError(describeSearchFailure(body, status))
|
|
1366
2181
|
return
|
|
1367
2182
|
}
|
|
@@ -1370,10 +2185,18 @@ window.__ModuleLoader__.load({
|
|
|
1370
2185
|
: []
|
|
1371
2186
|
setHits(list)
|
|
1372
2187
|
setSearchError(null)
|
|
2188
|
+
if (list.length > 0) {
|
|
2189
|
+
// Basic find behavior: the first occurrence becomes current and
|
|
2190
|
+
// the preview immediately jumps to its page.
|
|
2191
|
+
navigateHits(list, 0)
|
|
2192
|
+
} else {
|
|
2193
|
+
setActive(0)
|
|
2194
|
+
}
|
|
1373
2195
|
})
|
|
1374
2196
|
.catch((error) => {
|
|
1375
2197
|
if (disposed || (isAbortError(error) && !timedOut)) return
|
|
1376
2198
|
setHits([])
|
|
2199
|
+
setActive(0)
|
|
1377
2200
|
setSearchError(timedOut ? 'Search timed out.' : 'Search unavailable.')
|
|
1378
2201
|
})
|
|
1379
2202
|
.finally(() => {
|
|
@@ -1390,14 +2213,106 @@ window.__ModuleLoader__.load({
|
|
|
1390
2213
|
}
|
|
1391
2214
|
}, [open, isPdf, absolutePath, query])
|
|
1392
2215
|
|
|
1393
|
-
//
|
|
1394
|
-
//
|
|
2216
|
+
// In-page highlights: locate each hit page's text layer, map the query to
|
|
2217
|
+
// Ranges, and rebuild when pdf.js renders or re-renders a page (zoom).
|
|
2218
|
+
//
|
|
2219
|
+
// The text layer arrives lazily, so the first attempt retries for about
|
|
2220
|
+
// three seconds. Once the active page has a text layer, the timers stop
|
|
2221
|
+
// and the MutationObserver keeps the highlights in sync. Every path is
|
|
2222
|
+
// guarded: highlighting must never break reading or search itself.
|
|
2223
|
+
useEffect(() => {
|
|
2224
|
+
if (!open || !isPdf || absolutePath === undefined) return undefined
|
|
2225
|
+
const needle = query.trim()
|
|
2226
|
+
if (searching || searchError !== null || hits === null || hits.length === 0 || needle === '') {
|
|
2227
|
+
clearSearchHighlights(resolvePane(hostRef.current))
|
|
2228
|
+
return undefined
|
|
2229
|
+
}
|
|
2230
|
+
const host = hostRef.current
|
|
2231
|
+
if (host === null) return undefined
|
|
2232
|
+
const list = hits
|
|
2233
|
+
const current = Number.isInteger(active) ? active : 0
|
|
2234
|
+
const activePage = list[Math.min(Math.max(current, 0), list.length - 1)]?.pdf
|
|
2235
|
+
|
|
2236
|
+
let disposed = false
|
|
2237
|
+
let observer
|
|
2238
|
+
let observedPane
|
|
2239
|
+
let retryTimer
|
|
2240
|
+
let retryCount = 0
|
|
2241
|
+
let rebuildTimer
|
|
2242
|
+
|
|
2243
|
+
const activeTextReady = () => {
|
|
2244
|
+
if (activePage === undefined) return true
|
|
2245
|
+
const pane = resolvePane(host)
|
|
2246
|
+
if (pane === undefined) return false
|
|
2247
|
+
const page = pane.querySelector(`[${PAGE_ATTR}="${activePage}"]`)
|
|
2248
|
+
if (page === null || page === undefined) return false
|
|
2249
|
+
return page.querySelector(`[${TEXT_LAYER_ATTR}]`) !== null
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
const observe = (pane) => {
|
|
2253
|
+
if (pane === observedPane) return
|
|
2254
|
+
observer?.disconnect()
|
|
2255
|
+
observer = undefined
|
|
2256
|
+
observedPane = pane
|
|
2257
|
+
if (typeof MutationObserver !== 'function') return
|
|
2258
|
+
try {
|
|
2259
|
+
observer = new MutationObserver(() => {
|
|
2260
|
+
scheduleRebuild()
|
|
2261
|
+
})
|
|
2262
|
+
observer.observe(pane, { childList: true, subtree: true, characterData: true })
|
|
2263
|
+
} catch (_error) {
|
|
2264
|
+
observer = undefined
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
const rebuild = () => {
|
|
2269
|
+
if (disposed) return
|
|
2270
|
+
const pane = resolvePane(host)
|
|
2271
|
+
if (pane === undefined) return
|
|
2272
|
+
observe(pane)
|
|
2273
|
+
applySearchHighlights(pane, needle, list, current)
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
const scheduleRebuild = () => {
|
|
2277
|
+
if (disposed || rebuildTimer !== undefined) return
|
|
2278
|
+
rebuildTimer = globalThis.setTimeout(() => {
|
|
2279
|
+
rebuildTimer = undefined
|
|
2280
|
+
rebuild()
|
|
2281
|
+
}, SEARCH_HIGHLIGHT_REBUILD_MS)
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
const retry = () => {
|
|
2285
|
+
if (disposed || retryTimer !== undefined || retryCount >= SEARCH_HIGHLIGHT_RETRY_LIMIT) return
|
|
2286
|
+
retryCount += 1
|
|
2287
|
+
retryTimer = globalThis.setTimeout(() => {
|
|
2288
|
+
retryTimer = undefined
|
|
2289
|
+
if (disposed) return
|
|
2290
|
+
rebuild()
|
|
2291
|
+
if (!activeTextReady()) retry()
|
|
2292
|
+
}, SEARCH_HIGHLIGHT_RETRY_MS)
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
rebuild()
|
|
2296
|
+
if (!activeTextReady()) retry()
|
|
2297
|
+
|
|
2298
|
+
return () => {
|
|
2299
|
+
disposed = true
|
|
2300
|
+
observer?.disconnect()
|
|
2301
|
+
observer = undefined
|
|
2302
|
+
if (retryTimer !== undefined) globalThis.clearTimeout(retryTimer)
|
|
2303
|
+
if (rebuildTimer !== undefined) globalThis.clearTimeout(rebuildTimer)
|
|
2304
|
+
clearSearchHighlights(resolvePane(host))
|
|
2305
|
+
}
|
|
2306
|
+
}, [open, isPdf, absolutePath, query, hits, active, searching, searchError])
|
|
2307
|
+
|
|
2308
|
+
// Cmd/Ctrl+F (with or without Shift) opens our find bar. The first panel
|
|
2309
|
+
// claims the shortcut; pointing at another preview claims it for that one.
|
|
1395
2310
|
useEffect(() => {
|
|
1396
2311
|
if (!isPdf) return undefined
|
|
1397
2312
|
const owner = ownerRef.current
|
|
1398
2313
|
const onKeyDown = (event) => {
|
|
1399
2314
|
if (event.defaultPrevented || event.repeat || event.isComposing) return
|
|
1400
|
-
if (!(event.metaKey || event.ctrlKey) || event.altKey
|
|
2315
|
+
if (!(event.metaKey || event.ctrlKey) || event.altKey) return
|
|
1401
2316
|
if (String(event.key).toLowerCase() !== 'f') return
|
|
1402
2317
|
if (activeOwner === undefined) activeOwner = owner
|
|
1403
2318
|
if (activeOwner !== owner) return
|
|
@@ -1420,9 +2335,24 @@ window.__ModuleLoader__.load({
|
|
|
1420
2335
|
}
|
|
1421
2336
|
}, [isPdf])
|
|
1422
2337
|
|
|
2338
|
+
// Focus and select the find input once the panel has actually mounted.
|
|
2339
|
+
// Doing this in an effect (instead of the click handler's timer) also
|
|
2340
|
+
// covers the very first open, when the input ref does not exist yet.
|
|
2341
|
+
useEffect(() => {
|
|
2342
|
+
if (!open) return undefined
|
|
2343
|
+
const input = searchInputRef.current
|
|
2344
|
+
if (input === null) return undefined
|
|
2345
|
+
try {
|
|
2346
|
+
input.focus()
|
|
2347
|
+
input.select()
|
|
2348
|
+
} catch (_error) {
|
|
2349
|
+
// Focus is best effort (for example while the tab is hidden).
|
|
2350
|
+
}
|
|
2351
|
+
return undefined
|
|
2352
|
+
}, [open])
|
|
2353
|
+
|
|
1423
2354
|
// Timers that outlive a render and must not outlive the component.
|
|
1424
2355
|
useEffect(() => () => {
|
|
1425
|
-
if (focusTimerRef.current !== null) globalThis.clearTimeout(focusTimerRef.current)
|
|
1426
2356
|
if (flashTimerRef.current !== null) globalThis.clearTimeout(flashTimerRef.current)
|
|
1427
2357
|
if (tipTimerRef.current !== null) globalThis.clearTimeout(tipTimerRef.current)
|
|
1428
2358
|
}, [])
|
|
@@ -1587,51 +2517,16 @@ window.__ModuleLoader__.load({
|
|
|
1587
2517
|
|
|
1588
2518
|
let panel = null
|
|
1589
2519
|
if (open) {
|
|
1590
|
-
const
|
|
1591
|
-
const
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
? searchError
|
|
1595
|
-
: needle === ''
|
|
1596
|
-
? `${total > 0 ? total : '?'} pages · type to search`
|
|
1597
|
-
: hits === null
|
|
1598
|
-
? ''
|
|
1599
|
-
: hits.length === 0
|
|
1600
|
-
? 'No matches — scanned PDFs have no text layer.'
|
|
1601
|
-
: `${hits.length}${hits.length >= SEARCH_LIMIT ? '+' : ''} result${hits.length === 1 ? '' : 's'}`
|
|
1602
|
-
const list = hits !== null && hits.length > 0
|
|
1603
|
-
? h('ul', { style: LIST_STYLE }, hits.map((hit, index) => {
|
|
1604
|
-
const label = searchResultLabel(hit)
|
|
1605
|
-
const snippet = normalizeSnippet(hit.snippet, 140)
|
|
1606
|
-
return h('li', {
|
|
1607
|
-
key: `${hit.pdf}:${index}`,
|
|
1608
|
-
style: RESULT_ROW_STYLE,
|
|
1609
|
-
},
|
|
1610
|
-
h('button', {
|
|
1611
|
-
type: 'button',
|
|
1612
|
-
'data-tip': 'resultJump',
|
|
1613
|
-
'data-dsh-pdf-reader': 'result',
|
|
1614
|
-
onClick: () => {
|
|
1615
|
-
jumpTo(hit.pdf)
|
|
1616
|
-
},
|
|
1617
|
-
style: RESULT_JUMP_STYLE,
|
|
1618
|
-
}, snippet === '' ? label : `${label} — ${snippet}`),
|
|
1619
|
-
h('button', {
|
|
1620
|
-
type: 'button',
|
|
1621
|
-
'data-tip': 'resultInsert',
|
|
1622
|
-
'data-dsh-pdf-reader': 'result-ai',
|
|
1623
|
-
onClick: () => {
|
|
1624
|
-
deliverReference(buildSearchReference(hit), 'Inserted')
|
|
1625
|
-
},
|
|
1626
|
-
style: AI_BUTTON_STYLE,
|
|
1627
|
-
}, '→ AI'))
|
|
1628
|
-
}))
|
|
1629
|
-
: null
|
|
2520
|
+
const hitCount = hits === null ? 0 : hits.length
|
|
2521
|
+
const shownHit = hitCount === 0 ? 0 : Math.min(Math.max(active, 0), hitCount - 1) + 1
|
|
2522
|
+
const noHits = hitCount === 0
|
|
2523
|
+
const searchDisabledStyle = noHits ? { opacity: 0.35 } : null
|
|
1630
2524
|
panel = h('div', {
|
|
1631
2525
|
key: 'panel',
|
|
1632
2526
|
style: PANEL_STYLE,
|
|
1633
2527
|
role: 'dialog',
|
|
1634
|
-
'aria-label': '
|
|
2528
|
+
'aria-label': searchCopy('label'),
|
|
2529
|
+
'aria-busy': searching,
|
|
1635
2530
|
'data-dsh-pdf-reader': 'panel',
|
|
1636
2531
|
onKeyDown: (event) => {
|
|
1637
2532
|
if (event.isComposing) return
|
|
@@ -1647,40 +2542,74 @@ window.__ModuleLoader__.load({
|
|
|
1647
2542
|
ref: searchInputRef,
|
|
1648
2543
|
type: 'search',
|
|
1649
2544
|
value: query,
|
|
1650
|
-
placeholder: '
|
|
1651
|
-
'aria-label': '
|
|
2545
|
+
placeholder: searchCopy('label'),
|
|
2546
|
+
'aria-label': searchCopy('label'),
|
|
2547
|
+
'data-dsh-pdf-reader': 'search-input',
|
|
1652
2548
|
spellCheck: false,
|
|
1653
2549
|
autoComplete: 'off',
|
|
1654
2550
|
maxLength: 1000,
|
|
1655
2551
|
style: PANEL_INPUT_STYLE,
|
|
1656
2552
|
onChange: (event) => {
|
|
1657
2553
|
setQuery(String(event.target.value))
|
|
2554
|
+
setHits(null)
|
|
2555
|
+
setActive(0)
|
|
2556
|
+
setSearchError(null)
|
|
1658
2557
|
},
|
|
1659
2558
|
onKeyDown: (event) => {
|
|
1660
2559
|
if (event.isComposing) return
|
|
1661
|
-
if (event.key === 'Enter'
|
|
2560
|
+
if (event.key === 'Enter') {
|
|
1662
2561
|
event.preventDefault()
|
|
1663
|
-
|
|
2562
|
+
event.stopPropagation()
|
|
2563
|
+
navigate(active + (event.shiftKey ? -1 : 1))
|
|
1664
2564
|
}
|
|
1665
2565
|
},
|
|
1666
2566
|
}),
|
|
2567
|
+
h('button', {
|
|
2568
|
+
type: 'button',
|
|
2569
|
+
'aria-label': searchCopy('prev'),
|
|
2570
|
+
'data-tip': 'prevHit',
|
|
2571
|
+
'data-dsh-pdf-reader': 'search-prev',
|
|
2572
|
+
disabled: noHits,
|
|
2573
|
+
onClick: () => {
|
|
2574
|
+
navigate(active - 1)
|
|
2575
|
+
},
|
|
2576
|
+
style: { ...BUTTON_STYLE, ...searchDisabledStyle },
|
|
2577
|
+
}, '‹'),
|
|
2578
|
+
h('span', {
|
|
2579
|
+
'data-dsh-pdf-reader': 'search-count',
|
|
2580
|
+
'aria-live': 'polite',
|
|
2581
|
+
style: SEARCH_COUNT_STYLE,
|
|
2582
|
+
}, `${shownHit} / ${hitCount}`),
|
|
2583
|
+
h('button', {
|
|
2584
|
+
type: 'button',
|
|
2585
|
+
'aria-label': searchCopy('next'),
|
|
2586
|
+
'data-tip': 'nextHit',
|
|
2587
|
+
'data-dsh-pdf-reader': 'search-next',
|
|
2588
|
+
disabled: noHits,
|
|
2589
|
+
onClick: () => {
|
|
2590
|
+
navigate(active + 1)
|
|
2591
|
+
},
|
|
2592
|
+
style: { ...BUTTON_STYLE, ...searchDisabledStyle },
|
|
2593
|
+
}, '›'),
|
|
1667
2594
|
h('button', {
|
|
1668
2595
|
type: 'button',
|
|
1669
2596
|
'aria-label': 'Close search',
|
|
1670
2597
|
'data-tip': 'closeSearch',
|
|
2598
|
+
'data-dsh-pdf-reader': 'search-close',
|
|
1671
2599
|
onClick: closeSearch,
|
|
1672
2600
|
style: BUTTON_STYLE,
|
|
1673
2601
|
}, '✕')),
|
|
1674
2602
|
h('div', {
|
|
1675
|
-
'
|
|
1676
|
-
style:
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
2603
|
+
'data-dsh-pdf-reader': 'search-hint',
|
|
2604
|
+
style: SEARCH_HINT_STYLE,
|
|
2605
|
+
}, searchCopy('hint')),
|
|
2606
|
+
searchError === null
|
|
2607
|
+
? null
|
|
2608
|
+
: h('div', {
|
|
2609
|
+
'data-dsh-pdf-reader': 'search-error',
|
|
2610
|
+
role: 'status',
|
|
2611
|
+
style: { ...ERROR_STYLE, fontSize: '11px' },
|
|
2612
|
+
}, searchError))
|
|
1684
2613
|
}
|
|
1685
2614
|
|
|
1686
2615
|
controls.push(panel)
|
|
@@ -1741,32 +2670,34 @@ window.__ModuleLoader__.load({
|
|
|
1741
2670
|
exports.PdfReaderAction = PdfReaderAction
|
|
1742
2671
|
// Pure helpers exposed for the headless tests only; the loader ignores them.
|
|
1743
2672
|
exports.__test = {
|
|
1744
|
-
AI_BUTTON_STYLE,
|
|
1745
2673
|
PANEL_STYLE,
|
|
1746
|
-
|
|
2674
|
+
SEARCH_ACTIVE_HIGHLIGHT_NAME,
|
|
2675
|
+
SEARCH_HIGHLIGHT_NAME,
|
|
1747
2676
|
cachedInfo,
|
|
1748
|
-
|
|
2677
|
+
clampZoomScale,
|
|
1749
2678
|
createDebouncer,
|
|
1750
2679
|
createRafThrottle,
|
|
1751
2680
|
describeSearchFailure,
|
|
1752
2681
|
displayPage,
|
|
2682
|
+
dispatchZoomWheel,
|
|
1753
2683
|
fetchJson,
|
|
2684
|
+
foldSearchText,
|
|
1754
2685
|
infoCache,
|
|
1755
|
-
insertComposerText,
|
|
1756
2686
|
isPdfPath,
|
|
1757
2687
|
normalizeInfo,
|
|
1758
2688
|
normalizeSnippet,
|
|
2689
|
+
normalizeView,
|
|
1759
2690
|
pageElements,
|
|
1760
2691
|
parsePageInput,
|
|
1761
2692
|
pickPage,
|
|
1762
2693
|
positiveInteger,
|
|
1763
2694
|
printedPageOf,
|
|
2695
|
+
readFrameZoom,
|
|
1764
2696
|
rememberInfo,
|
|
1765
2697
|
resolvePane,
|
|
1766
2698
|
resolveScrollport,
|
|
2699
|
+
resolveZoomFrame,
|
|
1767
2700
|
scrollPageIntoView,
|
|
1768
|
-
searchResultLabel,
|
|
1769
|
-
searchResultPage,
|
|
1770
2701
|
}
|
|
1771
2702
|
return module.exports
|
|
1772
2703
|
},
|