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/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 search panel (Cmd/Ctrl+Shift+F) backed by the Host text route;
7
- * - "→ AI" insertion of a search hit into the chat composer;
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` and `inputActions` props; insertion
14
- * uses `captureInsertion()` + `insertText(text, span)`, so the user's draft is
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 Host snippet for the composer. */
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+Shift+F.
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 LIST_STYLE = {
584
- listStyle: 'none',
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
- padding: '2px 5px',
619
- border: '1px solid rgba(128,128,128,.45)',
620
- borderRadius: '5px',
621
- background: 'transparent',
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+Shift+F)'],
675
- en: ['Search', "Search this PDF's text layer (Cmd/Ctrl+Shift+F)"],
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
- resultJump: {
686
- zh: ['跳到这一页', '滚动到搜索结果所在的页'],
687
- en: ['Jump to page', 'Scroll to the page of this search result'],
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
- resultInsert: {
690
- zh: ['引用这条结果', '把这条结果的位置插入输入框'],
691
- en: ['Insert reference', "Insert this result's position into the chat"],
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 (focusTimerRef.current !== null) globalThis.clearTimeout(focusTimerRef.current)
892
- focusTimerRef.current = globalThis.setTimeout(() => {
893
- focusTimerRef.current = null
894
- searchInputRef.current?.focus()
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
- * Deliver a composer reference.
911
- * Primary path is the undoable insertion at the caret; when the composer
912
- * is busy or absent, copy instead of clobbering the draft.
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 deliverReference = (text, label) => {
915
- if (insertComposerText(inputActions, text)) {
916
- flashMessage(label, 'ok')
917
- return
918
- }
919
- void copyText(text).then((copied) => {
920
- if (hostRef.current === null) return
921
- flashMessage(copied ? `${label} (copied)` : 'Composer busy', copied ? 'ok' : 'warn')
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
- // Cmd/Ctrl+Shift+F opens search. Plain Cmd/Ctrl+F is deliberately left
1394
- // alone: dsh-pdf-search owns it and the browser find bar remains intact.
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 || !event.shiftKey) return
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 needle = query.trim()
1591
- const statusText = searching
1592
- ? 'Searching…'
1593
- : searchError !== null
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': 'Search this PDF',
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: 'Search this PDF',
1651
- 'aria-label': 'Search this PDF',
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' && hits !== null && hits.length > 0) {
2560
+ if (event.key === 'Enter') {
1662
2561
  event.preventDefault()
1663
- jumpTo(hits[0].pdf)
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
- 'aria-live': 'polite',
1676
- style: {
1677
- minHeight: '18px',
1678
- ...(searchError !== null ? WARN_STYLE : MUTED_STYLE),
1679
- },
1680
- }, statusText),
1681
- list,
1682
- h('div', { style: { ...MUTED_STYLE, fontSize: '10px' } },
1683
- 'Click a page to jump · → AI inserts a reference at the caret'))
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
- buildSearchReference,
2674
+ SEARCH_ACTIVE_HIGHLIGHT_NAME,
2675
+ SEARCH_HIGHLIGHT_NAME,
1747
2676
  cachedInfo,
1748
- copyText,
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
  },