dsh-book-reader 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.
@@ -41,6 +40,8 @@ window.__ModuleLoader__.load({
41
40
  const ZOOM_VAR = '--document-zoom'
42
41
  const PAGE_ATTR = 'data-pdf-page'
43
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'
44
45
 
45
46
  /** Timing. Reporting is the only debounced write; scrolling uses one rAF. */
46
47
  const REPORT_DELAY_MS = 400
@@ -52,6 +53,23 @@ window.__ModuleLoader__.load({
52
53
  const SEARCH_LIMIT = 100
53
54
  const INFO_CACHE_LIMIT = 16
54
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
+
55
73
  /**
56
74
  * Reading-position memory timing.
57
75
  *
@@ -138,7 +156,7 @@ window.__ModuleLoader__.load({
138
156
  return page
139
157
  }
140
158
 
141
- /** Collapse whitespace and bound a Host snippet for the composer. */
159
+ /** Collapse whitespace and bound a diagnostic detail for the quiet panel. */
142
160
  function normalizeSnippet(value, limit = 160) {
143
161
  const source = typeof value === 'string' ? value : ''
144
162
  const clean = source.replace(/\s+/gu, ' ').trim()
@@ -147,51 +165,6 @@ window.__ModuleLoader__.load({
147
165
  return `${clean.slice(0, max - 1).trimEnd()}…`
148
166
  }
149
167
 
150
- /** @returns the `p.N` label for one search hit. */
151
- function searchResultPage(hit) {
152
- if (positiveInteger(hit?.printed)) return hit.printed
153
- if (positiveInteger(hit?.pdf)) return hit.pdf
154
- return undefined
155
- }
156
-
157
- /** @returns the short list label `p.132`, or `p.?` for a malformed hit. */
158
- function searchResultLabel(hit) {
159
- const page = searchResultPage(hit)
160
- return page === undefined ? 'p.?' : `p.${page}`
161
- }
162
-
163
- /** @returns `PDF p.132: <snippet>` (or `[PDF p.132]` when there is no snippet). */
164
- function buildSearchReference(hit) {
165
- const page = searchResultPage(hit)
166
- const label = page === undefined ? '?' : String(page)
167
- const snippet = normalizeSnippet(hit?.snippet)
168
- return snippet === '' ? `[PDF p.${label}]` : `PDF p.${label}: ${snippet}`
169
- }
170
-
171
- /**
172
- * Insert into the composer through the Session standard props.
173
- *
174
- * `captureInsertion()` snapshots the selection and draft revision;
175
- * `insertText()` applies one undoable plain-text edit only while that
176
- * revision is current. Rejected insertions are reported as `false` so the
177
- * caller can copy the reference instead; the draft is never replaced.
178
- * @param inputActions - standard slot prop.
179
- * @param text - plain text to insert.
180
- * @returns whether the edit applied.
181
- */
182
- function insertComposerText(inputActions, text) {
183
- if (inputActions === null || typeof inputActions !== 'object') return false
184
- if (typeof inputActions.captureInsertion !== 'function') return false
185
- if (typeof inputActions.insertText !== 'function') return false
186
- try {
187
- const span = inputActions.captureInsertion()
188
- if (span === null || typeof span !== 'object') return false
189
- return inputActions.insertText(String(text), span) === true
190
- } catch (_error) {
191
- return false
192
- }
193
- }
194
-
195
168
  /**
196
169
  * Pick the page occupying the reading anchor near the top of the viewport.
197
170
  *
@@ -249,6 +222,348 @@ window.__ModuleLoader__.load({
249
222
  return out
250
223
  }
251
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
+
252
567
  /**
253
568
  * Find the preview body that owns one header contribution.
254
569
  *
@@ -640,7 +955,7 @@ window.__ModuleLoader__.load({
640
955
  }
641
956
 
642
957
  /**
643
- * Which mounted contribution answers the next Cmd/Ctrl+Shift+F.
958
+ * Which mounted contribution answers the next Cmd/Ctrl+F.
644
959
  * Pointing at a preview claims it, exactly like dsh-pdf-search's Ctrl+F.
645
960
  */
646
961
  let activeOwner
@@ -741,51 +1056,17 @@ window.__ModuleLoader__.load({
741
1056
  userSelect: 'text',
742
1057
  }
743
1058
 
744
- const LIST_STYLE = {
745
- listStyle: 'none',
746
- margin: '0',
747
- padding: '0',
748
- maxHeight: '46vh',
749
- overflowY: 'auto',
750
- overscrollBehavior: 'contain',
751
- }
752
-
753
- const RESULT_ROW_STYLE = {
754
- display: 'flex',
755
- alignItems: 'center',
756
- gap: '4px',
757
- borderTop: '1px solid rgba(128,128,128,.18)',
758
- padding: '2px 0',
759
- }
760
-
761
- const RESULT_JUMP_STYLE = {
762
- flex: '1 1 auto',
763
- minWidth: '0',
764
- padding: '3px 2px',
765
- border: 'none',
766
- background: 'transparent',
767
- color: 'inherit',
768
- font: 'inherit',
769
- textAlign: 'left',
770
- cursor: 'pointer',
771
- overflow: 'hidden',
772
- whiteSpace: 'nowrap',
773
- textOverflow: 'ellipsis',
774
- borderRadius: '4px',
775
- }
776
-
777
- const AI_BUTTON_STYLE = {
1059
+ const SEARCH_COUNT_STYLE = {
1060
+ minWidth: '42px',
778
1061
  flex: '0 0 auto',
779
- padding: '2px 5px',
780
- border: '1px solid rgba(128,128,128,.45)',
781
- borderRadius: '5px',
782
- background: 'transparent',
783
- color: 'inherit',
784
- font: 'inherit',
785
- cursor: 'pointer',
786
- opacity: 0.9,
1062
+ textAlign: 'center',
1063
+ fontVariantNumeric: 'tabular-nums',
1064
+ opacity: 0.8,
1065
+ fontSize: '11px',
787
1066
  }
788
1067
 
1068
+ const SEARCH_HINT_STYLE = { ...MUTED_STYLE, fontSize: '10px' }
1069
+
789
1070
  /**
790
1071
  * The Client locale service, attached by `apply`. Absent in headless tests,
791
1072
  * where the language then falls back to the browser's own preference.
@@ -832,8 +1113,8 @@ window.__ModuleLoader__.load({
832
1113
  en: ['Page number', 'Type a page and press Enter; printed page by default, prefix p for a PDF page'],
833
1114
  },
834
1115
  search: {
835
- zh: ['搜索', '搜索这个 PDF 的文字层(Cmd/Ctrl+Shift+F)'],
836
- 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)"],
837
1118
  },
838
1119
  follow: {
839
1120
  zh: ['AI 上下文', '把当前阅读页报告给模型'],
@@ -843,14 +1124,32 @@ window.__ModuleLoader__.load({
843
1124
  zh: ['关闭搜索', '收起搜索面板(Esc)'],
844
1125
  en: ['Close search', 'Close the search panel (Esc)'],
845
1126
  },
846
- resultJump: {
847
- zh: ['跳到这一页', '滚动到搜索结果所在的页'],
848
- 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)'],
849
1134
  },
850
- resultInsert: {
851
- zh: ['引用这条结果', '把这条结果的位置插入输入框'],
852
- 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',
853
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
854
1153
  }
855
1154
 
856
1155
  /** @returns the localized explanation body for the follow control. */
@@ -923,21 +1222,6 @@ window.__ModuleLoader__.load({
923
1222
  h('circle', { cx: 7, cy: 7, r: 4.4, stroke: 'currentColor', strokeWidth: 1.6 }),
924
1223
  h('path', { d: 'M10.4 10.4 14.2 14.2', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round' }))
925
1224
 
926
- /**
927
- * Copy plain text, used only when the composer insertion is unavailable.
928
- * @returns whether the clipboard write succeeded.
929
- */
930
- async function copyText(text) {
931
- try {
932
- if (typeof navigator === 'undefined' || navigator.clipboard === undefined) return false
933
- if (typeof navigator.clipboard.writeText !== 'function') return false
934
- await navigator.clipboard.writeText(text)
935
- return true
936
- } catch (_error) {
937
- return false
938
- }
939
- }
940
-
941
1225
  /**
942
1226
  * The header contribution for one PDF preview.
943
1227
  * @param props - Session-scope standard props plus the preview's `absolutePath`.
@@ -945,7 +1229,6 @@ window.__ModuleLoader__.load({
945
1229
  function PdfReaderAction(props) {
946
1230
  const absolutePath = typeof props?.absolutePath === 'string' ? props.absolutePath : undefined
947
1231
  const isPdf = isPdfPath(absolutePath)
948
- const inputActions = props?.inputActions
949
1232
  const sessionId = typeof props?.sessionId === 'string' ? props.sessionId : undefined
950
1233
 
951
1234
  const hostRef = useRef(null)
@@ -953,7 +1236,6 @@ window.__ModuleLoader__.load({
953
1236
  const searchInputRef = useRef(null)
954
1237
  const ownerRef = useRef(null)
955
1238
  if (ownerRef.current === null) ownerRef.current = Symbol('dsh-pdf-reader')
956
- const focusTimerRef = useRef(null)
957
1239
  const flashTimerRef = useRef(null)
958
1240
  const reportAbortRef = useRef(null)
959
1241
  const reportSeqRef = useRef(0)
@@ -983,6 +1265,7 @@ window.__ModuleLoader__.load({
983
1265
  const [open, setOpen] = useState(false)
984
1266
  const [query, setQuery] = useState('')
985
1267
  const [hits, setHits] = useState(null)
1268
+ const [active, setActive] = useState(0)
986
1269
  const [searching, setSearching] = useState(false)
987
1270
  const [searchError, setSearchError] = useState(null)
988
1271
  const [tip, setTip] = useState(null)
@@ -1052,41 +1335,51 @@ window.__ModuleLoader__.load({
1052
1335
 
1053
1336
  /** Focus the search input; keep the query highlighted for quick replacement. */
1054
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
+ }
1055
1347
  setOpen(true)
1056
- if (focusTimerRef.current !== null) globalThis.clearTimeout(focusTimerRef.current)
1057
- focusTimerRef.current = globalThis.setTimeout(() => {
1058
- focusTimerRef.current = null
1059
- searchInputRef.current?.focus()
1060
- searchInputRef.current?.select()
1061
- }, 0)
1348
+ if (input !== null) {
1349
+ input.focus()
1350
+ input.select()
1351
+ }
1062
1352
  }
1063
1353
 
1064
1354
  /** Close the panel and give focus back to the header button. */
1065
1355
  const closeSearch = () => {
1066
1356
  setOpen(false)
1067
- if (focusTimerRef.current !== null) {
1068
- globalThis.clearTimeout(focusTimerRef.current)
1069
- focusTimerRef.current = null
1070
- }
1071
1357
  searchButtonRef.current?.focus()
1072
1358
  }
1073
1359
 
1074
1360
  /**
1075
- * Deliver a composer reference.
1076
- * Primary path is the undoable insertion at the caret; when the composer
1077
- * 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.
1078
1367
  */
1079
- const deliverReference = (text, label) => {
1080
- if (insertComposerText(inputActions, text)) {
1081
- flashMessage(label, 'ok')
1082
- return
1083
- }
1084
- void copyText(text).then((copied) => {
1085
- if (hostRef.current === null) return
1086
- flashMessage(copied ? `${label} (copied)` : 'Composer busy', copied ? 'ok' : 'warn')
1087
- })
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
1088
1378
  }
1089
1379
 
1380
+ /** Move to an absolute hit index (the caller adds or subtracts one). */
1381
+ const navigate = (index) => navigateHits(hits, index)
1382
+
1090
1383
  /** Scroll a physical page into view and update the readout immediately. */
1091
1384
  const jumpTo = (target) => {
1092
1385
  if (!positiveInteger(target)) return false
@@ -1145,15 +1438,12 @@ window.__ModuleLoader__.load({
1145
1438
  setOpen(false)
1146
1439
  setQuery('')
1147
1440
  setHits(null)
1441
+ setActive(0)
1148
1442
  setSearching(false)
1149
1443
  setSearchError(null)
1150
1444
  lastReportedKeyRef.current = undefined
1151
1445
  pageRef.current = 1
1152
1446
  reportAbortRef.current?.abort()
1153
- if (focusTimerRef.current !== null) {
1154
- globalThis.clearTimeout(focusTimerRef.current)
1155
- focusTimerRef.current = null
1156
- }
1157
1447
  if (flashTimerRef.current !== null) {
1158
1448
  globalThis.clearTimeout(flashTimerRef.current)
1159
1449
  flashTimerRef.current = null
@@ -1608,29 +1898,40 @@ window.__ModuleLoader__.load({
1608
1898
  /**
1609
1899
  * Scroll the saved page into view, then re-apply once. The second scroll
1610
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.
1611
1908
  */
1612
- const placePage = () => {
1613
- if (disposed || restoreTarget === undefined) return
1909
+ const placePage = ({ finishGate = true } = {}) => {
1910
+ if (disposed || restoreTarget === undefined) return false
1614
1911
  const target = restoreTarget.pdfPage
1615
1912
  if (!refresh() || !scrollPageIntoView(pane, scrollport, target)) {
1616
1913
  retryRestore()
1617
- return
1914
+ return false
1618
1915
  }
1619
1916
  // The save debounce reads this ref, and the restore runs from a timer
1620
1917
  // (not a React event), so publish the page before state can render.
1621
1918
  pageRef.current = target
1622
1919
  setPage(target)
1623
1920
  setSampled(true)
1624
- finish()
1921
+ if (finishGate) finish()
1625
1922
  if (reapplyTimer !== undefined) globalThis.clearTimeout(reapplyTimer)
1626
1923
  reapplyTimer = globalThis.setTimeout(() => {
1627
1924
  reapplyTimer = undefined
1628
1925
  if (disposed || !refresh()) return
1629
1926
  scrollPageIntoView(pane, scrollport, target)
1630
1927
  }, VIEW_REAPPLY_MS)
1928
+ return true
1631
1929
  }
1632
1930
 
1633
- /** Let the renderer's 120 ms zoom settlement commit before scrolling. */
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
+ */
1634
1935
  const settleZoom = () => {
1635
1936
  if (zoomTimer !== undefined) globalThis.clearTimeout(zoomTimer)
1636
1937
  zoomTimer = globalThis.setTimeout(() => {
@@ -1649,7 +1950,7 @@ window.__ModuleLoader__.load({
1649
1950
  if (disposed || restoreTarget === undefined) return
1650
1951
  const target = restoreTarget.zoomScale
1651
1952
  if (!refresh() || frame === undefined || scrollport === undefined) {
1652
- placePage()
1953
+ finish()
1653
1954
  return
1654
1955
  }
1655
1956
  const current = frameZoomScale(frame)
@@ -1658,7 +1959,7 @@ window.__ModuleLoader__.load({
1658
1959
  // Same scale, fit-width mode: a zero-delta gesture only asks the
1659
1960
  // renderer to commit `{kind:'fixed'}` at the current scale.
1660
1961
  if (!dispatchZoomWheel(scrollport, 0)) {
1661
- placePage()
1962
+ finish()
1662
1963
  return
1663
1964
  }
1664
1965
  }
@@ -1699,6 +2000,8 @@ window.__ModuleLoader__.load({
1699
2000
  retryRestore()
1700
2001
  return
1701
2002
  }
2003
+ // Jump first so the cover never flashes while a fixed zoom is replayed.
2004
+ if (!placePage({ finishGate: false })) return
1702
2005
  if (target.zoomMode === 'fixed' && frame !== undefined && scrollport !== undefined) {
1703
2006
  if (Math.abs(frameZoomScale(frame) / target.zoomScale - 1) > VIEW_ZOOM_TOLERANCE
1704
2007
  || frameZoomMode(frame) !== 'fixed') {
@@ -1706,7 +2009,7 @@ window.__ModuleLoader__.load({
1706
2009
  return
1707
2010
  }
1708
2011
  }
1709
- placePage()
2012
+ finish()
1710
2013
  }
1711
2014
 
1712
2015
  const loadSavedView = async () => {
@@ -1841,17 +2144,21 @@ window.__ModuleLoader__.load({
1841
2144
  }, [isPdf, absolutePath, sampled, page, printed, sessionId, follow])
1842
2145
 
1843
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.
1844
2148
  useEffect(() => {
1845
2149
  if (!open || !isPdf || absolutePath === undefined) return undefined
1846
2150
  const needle = query.trim()
1847
2151
  if (needle === '') {
1848
2152
  setHits(null)
2153
+ setActive(0)
1849
2154
  setSearchError(null)
1850
2155
  setSearching(false)
1851
2156
  return undefined
1852
2157
  }
1853
2158
  setSearching(true)
1854
2159
  setSearchError(null)
2160
+ setHits(null)
2161
+ setActive(0)
1855
2162
  let disposed = false
1856
2163
  let timedOut = false
1857
2164
  let timer
@@ -1869,6 +2176,7 @@ window.__ModuleLoader__.load({
1869
2176
  if (disposed) return
1870
2177
  if (body === null || typeof body !== 'object' || body.ok !== true) {
1871
2178
  setHits([])
2179
+ setActive(0)
1872
2180
  setSearchError(describeSearchFailure(body, status))
1873
2181
  return
1874
2182
  }
@@ -1877,10 +2185,18 @@ window.__ModuleLoader__.load({
1877
2185
  : []
1878
2186
  setHits(list)
1879
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
+ }
1880
2195
  })
1881
2196
  .catch((error) => {
1882
2197
  if (disposed || (isAbortError(error) && !timedOut)) return
1883
2198
  setHits([])
2199
+ setActive(0)
1884
2200
  setSearchError(timedOut ? 'Search timed out.' : 'Search unavailable.')
1885
2201
  })
1886
2202
  .finally(() => {
@@ -1897,14 +2213,106 @@ window.__ModuleLoader__.load({
1897
2213
  }
1898
2214
  }, [open, isPdf, absolutePath, query])
1899
2215
 
1900
- // Cmd/Ctrl+Shift+F opens search. Plain Cmd/Ctrl+F is deliberately left
1901
- // 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.
1902
2310
  useEffect(() => {
1903
2311
  if (!isPdf) return undefined
1904
2312
  const owner = ownerRef.current
1905
2313
  const onKeyDown = (event) => {
1906
2314
  if (event.defaultPrevented || event.repeat || event.isComposing) return
1907
- if (!(event.metaKey || event.ctrlKey) || event.altKey || !event.shiftKey) return
2315
+ if (!(event.metaKey || event.ctrlKey) || event.altKey) return
1908
2316
  if (String(event.key).toLowerCase() !== 'f') return
1909
2317
  if (activeOwner === undefined) activeOwner = owner
1910
2318
  if (activeOwner !== owner) return
@@ -1927,9 +2335,24 @@ window.__ModuleLoader__.load({
1927
2335
  }
1928
2336
  }, [isPdf])
1929
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
+
1930
2354
  // Timers that outlive a render and must not outlive the component.
1931
2355
  useEffect(() => () => {
1932
- if (focusTimerRef.current !== null) globalThis.clearTimeout(focusTimerRef.current)
1933
2356
  if (flashTimerRef.current !== null) globalThis.clearTimeout(flashTimerRef.current)
1934
2357
  if (tipTimerRef.current !== null) globalThis.clearTimeout(tipTimerRef.current)
1935
2358
  }, [])
@@ -2094,51 +2517,16 @@ window.__ModuleLoader__.load({
2094
2517
 
2095
2518
  let panel = null
2096
2519
  if (open) {
2097
- const needle = query.trim()
2098
- const statusText = searching
2099
- ? 'Searching…'
2100
- : searchError !== null
2101
- ? searchError
2102
- : needle === ''
2103
- ? `${total > 0 ? total : '?'} pages · type to search`
2104
- : hits === null
2105
- ? ''
2106
- : hits.length === 0
2107
- ? 'No matches — scanned PDFs have no text layer.'
2108
- : `${hits.length}${hits.length >= SEARCH_LIMIT ? '+' : ''} result${hits.length === 1 ? '' : 's'}`
2109
- const list = hits !== null && hits.length > 0
2110
- ? h('ul', { style: LIST_STYLE }, hits.map((hit, index) => {
2111
- const label = searchResultLabel(hit)
2112
- const snippet = normalizeSnippet(hit.snippet, 140)
2113
- return h('li', {
2114
- key: `${hit.pdf}:${index}`,
2115
- style: RESULT_ROW_STYLE,
2116
- },
2117
- h('button', {
2118
- type: 'button',
2119
- 'data-tip': 'resultJump',
2120
- 'data-dsh-pdf-reader': 'result',
2121
- onClick: () => {
2122
- jumpTo(hit.pdf)
2123
- },
2124
- style: RESULT_JUMP_STYLE,
2125
- }, snippet === '' ? label : `${label} — ${snippet}`),
2126
- h('button', {
2127
- type: 'button',
2128
- 'data-tip': 'resultInsert',
2129
- 'data-dsh-pdf-reader': 'result-ai',
2130
- onClick: () => {
2131
- deliverReference(buildSearchReference(hit), 'Inserted')
2132
- },
2133
- style: AI_BUTTON_STYLE,
2134
- }, '→ AI'))
2135
- }))
2136
- : 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
2137
2524
  panel = h('div', {
2138
2525
  key: 'panel',
2139
2526
  style: PANEL_STYLE,
2140
2527
  role: 'dialog',
2141
- 'aria-label': 'Search this PDF',
2528
+ 'aria-label': searchCopy('label'),
2529
+ 'aria-busy': searching,
2142
2530
  'data-dsh-pdf-reader': 'panel',
2143
2531
  onKeyDown: (event) => {
2144
2532
  if (event.isComposing) return
@@ -2154,40 +2542,74 @@ window.__ModuleLoader__.load({
2154
2542
  ref: searchInputRef,
2155
2543
  type: 'search',
2156
2544
  value: query,
2157
- placeholder: 'Search this PDF',
2158
- 'aria-label': 'Search this PDF',
2545
+ placeholder: searchCopy('label'),
2546
+ 'aria-label': searchCopy('label'),
2547
+ 'data-dsh-pdf-reader': 'search-input',
2159
2548
  spellCheck: false,
2160
2549
  autoComplete: 'off',
2161
2550
  maxLength: 1000,
2162
2551
  style: PANEL_INPUT_STYLE,
2163
2552
  onChange: (event) => {
2164
2553
  setQuery(String(event.target.value))
2554
+ setHits(null)
2555
+ setActive(0)
2556
+ setSearchError(null)
2165
2557
  },
2166
2558
  onKeyDown: (event) => {
2167
2559
  if (event.isComposing) return
2168
- if (event.key === 'Enter' && hits !== null && hits.length > 0) {
2560
+ if (event.key === 'Enter') {
2169
2561
  event.preventDefault()
2170
- jumpTo(hits[0].pdf)
2562
+ event.stopPropagation()
2563
+ navigate(active + (event.shiftKey ? -1 : 1))
2171
2564
  }
2172
2565
  },
2173
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
+ }, '›'),
2174
2594
  h('button', {
2175
2595
  type: 'button',
2176
2596
  'aria-label': 'Close search',
2177
2597
  'data-tip': 'closeSearch',
2598
+ 'data-dsh-pdf-reader': 'search-close',
2178
2599
  onClick: closeSearch,
2179
2600
  style: BUTTON_STYLE,
2180
2601
  }, '✕')),
2181
2602
  h('div', {
2182
- 'aria-live': 'polite',
2183
- style: {
2184
- minHeight: '18px',
2185
- ...(searchError !== null ? WARN_STYLE : MUTED_STYLE),
2186
- },
2187
- }, statusText),
2188
- list,
2189
- h('div', { style: { ...MUTED_STYLE, fontSize: '10px' } },
2190
- '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))
2191
2613
  }
2192
2614
 
2193
2615
  controls.push(panel)
@@ -2248,20 +2670,19 @@ window.__ModuleLoader__.load({
2248
2670
  exports.PdfReaderAction = PdfReaderAction
2249
2671
  // Pure helpers exposed for the headless tests only; the loader ignores them.
2250
2672
  exports.__test = {
2251
- AI_BUTTON_STYLE,
2252
2673
  PANEL_STYLE,
2253
- buildSearchReference,
2674
+ SEARCH_ACTIVE_HIGHLIGHT_NAME,
2675
+ SEARCH_HIGHLIGHT_NAME,
2254
2676
  cachedInfo,
2255
2677
  clampZoomScale,
2256
- copyText,
2257
2678
  createDebouncer,
2258
2679
  createRafThrottle,
2259
2680
  describeSearchFailure,
2260
2681
  displayPage,
2261
2682
  dispatchZoomWheel,
2262
2683
  fetchJson,
2684
+ foldSearchText,
2263
2685
  infoCache,
2264
- insertComposerText,
2265
2686
  isPdfPath,
2266
2687
  normalizeInfo,
2267
2688
  normalizeSnippet,
@@ -2277,8 +2698,6 @@ window.__ModuleLoader__.load({
2277
2698
  resolveScrollport,
2278
2699
  resolveZoomFrame,
2279
2700
  scrollPageIntoView,
2280
- searchResultLabel,
2281
- searchResultPage,
2282
2701
  }
2283
2702
  return module.exports
2284
2703
  },