dsh-book-reader 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,1773 @@
1
+ /**
2
+ * dsh-book-reader — browser half.
3
+ *
4
+ * Decorates DSH's official PDF preview; it never replaces the renderer:
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;
8
+ * - debounced current-page reporting for AI context, with an on/off toggle.
9
+ *
10
+ * The contribution mounts into the preview's own header slot
11
+ * `sidebar.right.tab.document.actions`, which the preview declares as a
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`.
16
+ *
17
+ * The bundle is the module-loader factory shape used by the profile:
18
+ * `window.__ModuleLoader__.load({ id, factory })`, React is the only external.
19
+ */
20
+ window.__ModuleLoader__.load({
21
+ id: 'dsh-book-reader',
22
+ factory: (require) => {
23
+ var module = { exports: {} }
24
+ var exports = module.exports
25
+
26
+ const React = require('react')
27
+ const { useEffect, useRef, useState } = React
28
+ const h = React.createElement
29
+
30
+ /** Host routes implemented by the Host half. */
31
+ const INFO_ROUTE = '/dsh-pdf-reader/info'
32
+ const SEARCH_ROUTE = '/dsh-pdf-reader/search'
33
+ const CURRENT_ROUTE = '/dsh-pdf-reader/current'
34
+
35
+ /** DOM markers owned by the official preview (same ones dsh-pdf-search uses). */
36
+ const PANE_ATTR = 'data-textpreview-body'
37
+ const SCROLL_ATTR = 'data-document-zoom-scrollport'
38
+ const PAGE_ATTR = 'data-pdf-page'
39
+ const PDF_SECTION_ATTR = 'data-pdf-preview'
40
+
41
+ /** Timing. Reporting is the only debounced write; scrolling uses one rAF. */
42
+ const REPORT_DELAY_MS = 400
43
+ const SEARCH_DELAY_MS = 220
44
+ const INFO_TIMEOUT_MS = 30_000
45
+ const SEARCH_TIMEOUT_MS = 60_000
46
+ const REPORT_TIMEOUT_MS = 6_000
47
+ const FLASH_MS = 1_600
48
+ const SEARCH_LIMIT = 100
49
+ const INFO_CACHE_LIMIT = 16
50
+
51
+ /** Non-PDF files are left entirely alone (Office previews have .docx paths). */
52
+ function isPdfPath(value) {
53
+ return typeof value === 'string' && /\.pdf$/iu.test(value)
54
+ }
55
+
56
+ /** @returns whether value is a positive integer. */
57
+ function positiveInteger(value) {
58
+ return typeof value === 'number' && Number.isInteger(value) && value > 0
59
+ }
60
+
61
+ /**
62
+ * Printed page for a physical PDF page.
63
+ *
64
+ * The Host contract defines `printedOffset` so that
65
+ * `physical = printed + printedOffset` (LADR: printed 132 -> PDF 152), which
66
+ * is the same convention `pdfkit info` prints. Front matter has no numeric
67
+ * printed label; page numbers at or below the offset yield `undefined`.
68
+ * @param pdfPage - 1-based physical page.
69
+ * @param printedOffset - Host-reported offset.
70
+ * @returns the 1-based printed page, or `undefined` when not derivable.
71
+ */
72
+ function printedPageOf(pdfPage, printedOffset) {
73
+ if (!positiveInteger(pdfPage)) return undefined
74
+ if (typeof printedOffset !== 'number' || !Number.isInteger(printedOffset)) return undefined
75
+ const printed = pdfPage - printedOffset
76
+ return printed >= 1 ? printed : undefined
77
+ }
78
+
79
+ /** @returns the number to show in the readout: printed when known, else physical. */
80
+ function displayPage(pdfPage, printedOffset) {
81
+ const printed = printedPageOf(pdfPage, printedOffset)
82
+ return printed === undefined ? pdfPage : printed
83
+ }
84
+
85
+ /**
86
+ * Parse the page-jump box.
87
+ *
88
+ * Accepted forms:
89
+ * - `132` — a printed page when an offset is known, otherwise physical;
90
+ * - `p152`, `pdf 152`, `#152` — always a physical PDF page;
91
+ * - surrounding whitespace and a dot after the prefix (`p.152`).
92
+ * Values outside `1..pageCount` are rejected rather than clamped, so a
93
+ * mistyped printed page cannot silently land on the last page.
94
+ * @param raw - input value.
95
+ * @param options - `{ printedOffset, pageCount }`.
96
+ * @returns a 1-based physical PDF page, or `undefined`.
97
+ */
98
+ function parsePageInput(raw, options) {
99
+ const text = String(raw ?? '').trim()
100
+ if (text === '') return undefined
101
+ const match = /^(?:(p|pdf|#)\.?\s*)?(\d+)$/iu.exec(text)
102
+ if (match === null) return undefined
103
+ const explicitPhysical = match[1] !== undefined
104
+ const value = Number(match[2])
105
+ if (!Number.isSafeInteger(value) || value < 1) return undefined
106
+ const printedOffset = options?.printedOffset
107
+ const knownOffset = typeof printedOffset === 'number' && Number.isInteger(printedOffset)
108
+ const page = explicitPhysical || !knownOffset ? value : value + printedOffset
109
+ if (!Number.isSafeInteger(page) || page < 1) return undefined
110
+ const pageCount = options?.pageCount
111
+ if (positiveInteger(pageCount) && page > pageCount) return undefined
112
+ return page
113
+ }
114
+
115
+ /** Collapse whitespace and bound a Host snippet for the composer. */
116
+ function normalizeSnippet(value, limit = 160) {
117
+ const source = typeof value === 'string' ? value : ''
118
+ const clean = source.replace(/\s+/gu, ' ').trim()
119
+ const max = positiveInteger(limit) ? limit : 160
120
+ if (clean.length <= max) return clean
121
+ return `${clean.slice(0, max - 1).trimEnd()}…`
122
+ }
123
+
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
+ /**
170
+ * Pick the page occupying the reading anchor near the top of the viewport.
171
+ *
172
+ * Pages are sorted by `top` (content coordinates when fed cached geometry,
173
+ * viewport coordinates for the pure tests). Binary search keeps a 410-page
174
+ * document at O(log n) per scroll; no DOM work happens here.
175
+ * @param pages - `[{ number, top }]`.
176
+ * @param viewport - `{ top, height }`.
177
+ * @returns the page number, or `undefined` for an empty sequence.
178
+ */
179
+ function pickPage(pages, viewport) {
180
+ if (!Array.isArray(pages) || pages.length === 0) return undefined
181
+ const top = Number.isFinite(viewport?.top) ? viewport.top : 0
182
+ const height = Number.isFinite(viewport?.height) ? Math.max(0, viewport.height) : 0
183
+ const anchor = top + Math.min(180, Math.max(40, height * 0.3))
184
+ let low = 0
185
+ let high = pages.length - 1
186
+ let found = 0
187
+ while (low <= high) {
188
+ const mid = (low + high) >> 1
189
+ const pageTop = pages[mid]?.top
190
+ if (typeof pageTop === 'number' && Number.isFinite(pageTop) && pageTop <= anchor) {
191
+ found = mid
192
+ low = mid + 1
193
+ } else {
194
+ high = mid - 1
195
+ }
196
+ }
197
+ return pages[found]?.number
198
+ }
199
+
200
+ /** @returns whether value is an Element in the current document. */
201
+ function isElement(value) {
202
+ return typeof HTMLElement === 'function' && value instanceof HTMLElement
203
+ }
204
+
205
+ /**
206
+ * Every PDF page placeholder currently in the pane, in page order.
207
+ *
208
+ * The official PDF body renders one `[data-pdf-page]` wrapper per page up
209
+ * front; unrendered pages are 3:4 placeholders, so this is stable before
210
+ * lazy rendering completes.
211
+ * @param pane - preview body.
212
+ * @returns `[{ number, element }]` sorted by page number.
213
+ */
214
+ function pageElements(pane) {
215
+ if (pane === null || pane === undefined || typeof pane.querySelectorAll !== 'function') return []
216
+ const out = []
217
+ for (const element of pane.querySelectorAll(`[${PAGE_ATTR}]`)) {
218
+ if (!isElement(element)) continue
219
+ const number = Number(element.getAttribute(PAGE_ATTR))
220
+ if (positiveInteger(number)) out.push({ number, element })
221
+ }
222
+ out.sort((left, right) => left.number - right.number)
223
+ return out
224
+ }
225
+
226
+ /**
227
+ * Find the preview body that owns one header contribution.
228
+ *
229
+ * The contribution renders in the header; the body is a sibling inside the
230
+ * preview wrapper. Walk a few ancestors looking for the body marker
231
+ * (mirrors dsh-pdf-search/src/client/action.ts:162-170).
232
+ * @param node - the contribution's own element.
233
+ * @returns the preview body, or `undefined` while the preview is not composed.
234
+ */
235
+ function resolvePane(node) {
236
+ let parent = node?.parentElement ?? null
237
+ for (let depth = 0; parent !== null && depth < 4; depth += 1, parent = parent.parentElement) {
238
+ if (typeof parent.hasAttribute === 'function' && parent.hasAttribute(PANE_ATTR)) return parent
239
+ if (typeof parent.querySelector === 'function') {
240
+ const body = parent.querySelector(`[${PANE_ATTR}]`)
241
+ if (isElement(body)) return body
242
+ }
243
+ }
244
+ return undefined
245
+ }
246
+
247
+ /**
248
+ * Find the element that actually scrolls the preview.
249
+ *
250
+ * The preview body is not the scrollport: the PDF renderer owns an inner
251
+ * one marked `data-document-zoom-scrollport`. The overflow walk-up is only
252
+ * a fallback for a future renderer (mirrors
253
+ * dsh-pdf-search/src/client/action.ts:172-192).
254
+ * @param pane - preview body.
255
+ * @returns the scrolling element, or `undefined` when nothing scrolls yet.
256
+ */
257
+ function resolveScrollport(pane) {
258
+ if (pane === null || pane === undefined || typeof pane.querySelector !== 'function') return undefined
259
+ const marked = pane.querySelector(`[${SCROLL_ATTR}]`)
260
+ if (isElement(marked)) return marked
261
+ for (let node = pane; node !== null; node = node.parentElement) {
262
+ if (typeof node.ownerDocument?.defaultView?.getComputedStyle !== 'function') continue
263
+ const style = node.ownerDocument.defaultView.getComputedStyle(node)
264
+ if (!/(auto|scroll|overlay)/u.test(style.overflowY)) continue
265
+ if (node.scrollHeight > node.clientHeight + 1) return node
266
+ }
267
+ return undefined
268
+ }
269
+
270
+ /**
271
+ * Scroll one page near the top of the preview.
272
+ *
273
+ * This is the mechanism dsh-pdf-search proved: write the inner scrollport,
274
+ * never the preview body. Bringing the page into the viewport is also what
275
+ * makes the renderer's IntersectionObserver draw a lazily rendered page.
276
+ * @param pane - preview body.
277
+ * @param scrollport - inner scrolling element.
278
+ * @param page - 1-based physical page.
279
+ * @returns whether a target was found and a scroll was attempted.
280
+ */
281
+ function scrollPageIntoView(pane, scrollport, page) {
282
+ if (!positiveInteger(page) || pane === null || pane === undefined || typeof pane.querySelector !== 'function') {
283
+ return false
284
+ }
285
+ const target = pane.querySelector(`[${PAGE_ATTR}="${page}"]`)
286
+ if (target === null || target === undefined) return false
287
+ if (scrollport !== null && scrollport !== undefined
288
+ && typeof scrollport.scrollTop === 'number'
289
+ && typeof scrollport.getBoundingClientRect === 'function'
290
+ && typeof target.getBoundingClientRect === 'function') {
291
+ const targetRect = target.getBoundingClientRect()
292
+ const portRect = scrollport.getBoundingClientRect()
293
+ scrollport.scrollTop += targetRect.top - portRect.top - 8
294
+ return true
295
+ }
296
+ if (typeof target.scrollIntoView === 'function') {
297
+ target.scrollIntoView({ block: 'start' })
298
+ return true
299
+ }
300
+ return false
301
+ }
302
+
303
+ /**
304
+ * A cancellable trailing-edge debouncer.
305
+ * @param delay - milliseconds.
306
+ * @param setTimer - scheduler (injectable for tests).
307
+ * @param clearTimer - canceller (injectable for tests).
308
+ */
309
+ function createDebouncer(delay, setTimer, clearTimer) {
310
+ const schedule = typeof setTimer === 'function' ? setTimer : (fn, ms) => globalThis.setTimeout(fn, ms)
311
+ const cancel = typeof clearTimer === 'function' ? clearTimer : (id) => globalThis.clearTimeout(id)
312
+ const wait = Number.isFinite(delay) ? Math.max(0, delay) : 0
313
+ let timer
314
+ return {
315
+ schedule(run) {
316
+ if (typeof run !== 'function') return
317
+ if (timer !== undefined) cancel(timer)
318
+ timer = schedule(() => {
319
+ timer = undefined
320
+ run()
321
+ }, wait)
322
+ },
323
+ cancel() {
324
+ if (timer === undefined) return
325
+ cancel(timer)
326
+ timer = undefined
327
+ },
328
+ get pending() {
329
+ return timer !== undefined
330
+ },
331
+ }
332
+ }
333
+
334
+ /**
335
+ * A one-frame leading/trailing throttle: many scroll events, one read.
336
+ * @param requestFrame - scheduler (injectable for tests).
337
+ * @param cancelFrame - canceller (injectable for tests).
338
+ */
339
+ function createRafThrottle(requestFrame, cancelFrame) {
340
+ const request = typeof requestFrame === 'function'
341
+ ? requestFrame
342
+ : (fn) => globalThis.requestAnimationFrame(fn)
343
+ const cancel = typeof cancelFrame === 'function'
344
+ ? cancelFrame
345
+ : (id) => globalThis.cancelAnimationFrame(id)
346
+ let handle
347
+ let pending
348
+ return {
349
+ schedule(run) {
350
+ if (typeof run !== 'function') return
351
+ pending = run
352
+ if (handle !== undefined) return
353
+ handle = request(() => {
354
+ handle = undefined
355
+ const next = pending
356
+ pending = undefined
357
+ if (next !== undefined) next()
358
+ })
359
+ },
360
+ cancel() {
361
+ if (handle !== undefined) {
362
+ cancel(handle)
363
+ handle = undefined
364
+ }
365
+ pending = undefined
366
+ },
367
+ get pending() {
368
+ return pending !== undefined
369
+ },
370
+ }
371
+ }
372
+
373
+ /** @returns whether an error is the expected AbortController rejection. */
374
+ function isAbortError(error) {
375
+ return error !== null && typeof error === 'object' && error.name === 'AbortError'
376
+ }
377
+
378
+ /** Read a JSON response without letting a non-JSON body crash the preview. */
379
+ async function fetchJson(url, options) {
380
+ const response = await fetch(url, options)
381
+ let body = null
382
+ try {
383
+ body = await response.json()
384
+ } catch (_error) {
385
+ body = null
386
+ }
387
+ return { status: response.status, ok: response.ok, body }
388
+ }
389
+
390
+ /** Normalize the Host `/info` payload. */
391
+ function normalizeInfo(body) {
392
+ if (body === null || typeof body !== 'object' || body.ok !== true) return undefined
393
+ return {
394
+ pageCount: positiveInteger(body.pageCount) ? body.pageCount : undefined,
395
+ printedOffset: typeof body.printedOffset === 'number' && Number.isInteger(body.printedOffset)
396
+ ? body.printedOffset
397
+ : undefined,
398
+ outline: Array.isArray(body.outline) ? body.outline : [],
399
+ }
400
+ }
401
+
402
+ /** Validate one search hit from the Host. */
403
+ function normalizeHit(value) {
404
+ if (value === null || typeof value !== 'object') return undefined
405
+ if (!positiveInteger(value.pdf)) return undefined
406
+ return {
407
+ pdf: value.pdf,
408
+ printed: positiveInteger(value.printed) ? value.printed : undefined,
409
+ snippet: typeof value.snippet === 'string' ? value.snippet : '',
410
+ }
411
+ }
412
+
413
+ /** Turn a Host failure payload into one quiet line. */
414
+ function describeSearchFailure(body, status) {
415
+ const code = typeof body?.error === 'string' ? body.error : undefined
416
+ switch (code) {
417
+ case 'invalid-path': return 'Only PDF files can be searched.'
418
+ case 'not-found': case 'missing': return 'The host cannot find this PDF.'
419
+ case 'not-a-file': return 'The host path is not a regular file.'
420
+ case 'file-too-large': case 'too-large': return 'This PDF is too large to search.'
421
+ case 'too-many-pages': return 'This PDF has too many pages to search.'
422
+ case 'no-text-layer': case 'scanned': case 'no-text': return 'This PDF has no text layer (scanned image).'
423
+ case 'extract-failed': return 'The host could not extract text from this PDF.'
424
+ case 'forbidden-host': return 'The host rejected this request.'
425
+ case 'missing-query': return 'Type something to search.'
426
+ case 'query-too-long': return 'Search query is too long (1000 characters max).'
427
+ case 'invalid-limit': return 'The host rejected the search request.'
428
+ default:
429
+ return typeof body?.detail === 'string' && body.detail.trim() !== ''
430
+ ? normalizeSnippet(body.detail, 180)
431
+ : `Search unavailable${status >= 400 ? ` (HTTP ${status})` : ''}.`
432
+ }
433
+ }
434
+
435
+ /** Page-lifetime info cache: one `/info` per PDF in ordinary use. */
436
+ const infoCache = new Map()
437
+
438
+ /** @returns the cached info, or `undefined`. */
439
+ function cachedInfo(path) {
440
+ return infoCache.get(path)
441
+ }
442
+
443
+ /** Remember one info payload, bounding the cache. */
444
+ function rememberInfo(path, info) {
445
+ infoCache.delete(path)
446
+ infoCache.set(path, info)
447
+ while (infoCache.size > INFO_CACHE_LIMIT) {
448
+ const oldest = infoCache.keys().next().value
449
+ infoCache.delete(oldest)
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Plugin-wide follow preference.
455
+ *
456
+ * Reporting is a property of the plugin, not of one preview tab: turning it
457
+ * off in one open PDF stops it everywhere. No storage is written.
458
+ */
459
+ let followEnabled = true
460
+ const followSubscribers = new Set()
461
+
462
+ /** Publish a new follow preference to every mounted contribution. */
463
+ function publishFollow(next) {
464
+ followEnabled = next === true
465
+ for (const subscriber of followSubscribers) subscriber(followEnabled)
466
+ }
467
+
468
+ /** Read the shared follow preference (Session-scope standard React state). */
469
+ function useFollow() {
470
+ const [value, setValue] = useState(followEnabled)
471
+ useEffect(() => {
472
+ followSubscribers.add(setValue)
473
+ setValue(followEnabled)
474
+ return () => {
475
+ followSubscribers.delete(setValue)
476
+ }
477
+ }, [])
478
+ return value
479
+ }
480
+
481
+ /**
482
+ * Which mounted contribution answers the next Cmd/Ctrl+Shift+F.
483
+ * Pointing at a preview claims it, exactly like dsh-pdf-search's Ctrl+F.
484
+ */
485
+ let activeOwner
486
+
487
+ const CONTROL_STYLE = {
488
+ position: 'relative',
489
+ display: 'inline-flex',
490
+ alignItems: 'center',
491
+ gap: '3px',
492
+ height: '24px',
493
+ fontSize: '10px',
494
+ lineHeight: '22px',
495
+ color: 'inherit',
496
+ userSelect: 'none',
497
+ whiteSpace: 'nowrap',
498
+ // Visually separate this group from the host's own header controls: a
499
+ // divider plus a faint pill, so the plugin's five controls read as one
500
+ // cluster instead of blending into the built-in toolbar.
501
+ marginLeft: '10px',
502
+ paddingLeft: '10px',
503
+ paddingRight: '5px',
504
+ borderLeft: '1px solid rgba(128,128,128,0.45)',
505
+ borderRadius: '7px',
506
+ background: 'rgba(128,128,128,0.10)',
507
+ }
508
+
509
+ const BUTTON_STYLE = {
510
+ display: 'inline-flex',
511
+ alignItems: 'center',
512
+ justifyContent: 'center',
513
+ width: '20px',
514
+ height: '20px',
515
+ padding: '0',
516
+ border: 'none',
517
+ borderRadius: '5px',
518
+ background: 'transparent',
519
+ color: 'inherit',
520
+ cursor: 'pointer',
521
+ font: 'inherit',
522
+ flex: '0 0 auto',
523
+ }
524
+
525
+ const INPUT_STYLE = {
526
+ width: '32px',
527
+ height: '20px',
528
+ padding: '0 1px',
529
+ border: '1px solid rgba(128,128,128,.45)',
530
+ borderRadius: '5px',
531
+ background: 'transparent',
532
+ color: 'inherit',
533
+ font: 'inherit',
534
+ textAlign: 'center',
535
+ outline: 'none',
536
+ boxSizing: 'border-box',
537
+ colorScheme: 'light dark',
538
+ userSelect: 'text',
539
+ }
540
+
541
+ const BRACKET_STYLE = { opacity: 0.55, fontSize: '10px' }
542
+
543
+ const PANEL_STYLE = {
544
+ position: 'fixed',
545
+ top: '56px',
546
+ right: '16px',
547
+ zIndex: 2147483000,
548
+ display: 'flex',
549
+ flexDirection: 'column',
550
+ gap: '6px',
551
+ width: 'min(360px, calc(100vw - 24px))',
552
+ padding: '8px',
553
+ border: '1px solid rgba(128,128,128,.45)',
554
+ borderRadius: '10px',
555
+ background: 'Canvas',
556
+ color: 'CanvasText',
557
+ boxShadow: '0 10px 32px rgba(0,0,0,.30)',
558
+ font: '12px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif',
559
+ colorScheme: 'light dark',
560
+ boxSizing: 'border-box',
561
+ }
562
+
563
+ const ROW_STYLE = { display: 'flex', alignItems: 'center', gap: '6px' }
564
+ const MUTED_STYLE = { opacity: 0.7 }
565
+ const WARN_STYLE = { color: '#c69026' }
566
+ const ERROR_STYLE = { color: '#e5534b' }
567
+
568
+ const PANEL_INPUT_STYLE = {
569
+ flex: '1 1 auto',
570
+ minWidth: '0',
571
+ height: '24px',
572
+ padding: '0 6px',
573
+ border: '1px solid rgba(128,128,128,.45)',
574
+ borderRadius: '6px',
575
+ background: 'Field',
576
+ color: 'FieldText',
577
+ font: 'inherit',
578
+ colorScheme: 'light dark',
579
+ boxSizing: 'border-box',
580
+ userSelect: 'text',
581
+ }
582
+
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 = {
617
+ 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,
626
+ }
627
+
628
+ /**
629
+ * The Client locale service, attached by `apply`. Absent in headless tests,
630
+ * where the language then falls back to the browser's own preference.
631
+ */
632
+ let localeService
633
+
634
+ /** @returns the active UI language id, e.g. `zh` or `en`. */
635
+ function activeLanguage() {
636
+ let id
637
+ try {
638
+ id = localeService?.getSnapshot?.().active
639
+ } catch (_error) {
640
+ id = undefined
641
+ }
642
+ if (typeof id !== 'string' || id === '') {
643
+ try {
644
+ id = typeof navigator === 'undefined' ? 'en' : navigator.language
645
+ } catch (_error) {
646
+ id = 'en'
647
+ }
648
+ }
649
+ return /^zh/iu.test(String(id)) ? 'zh' : 'en'
650
+ }
651
+
652
+ /** Delay before a hover explanation appears, in milliseconds. */
653
+ const TIP_DELAY_MS = 200
654
+
655
+ /**
656
+ * Hover explanations for the preview toolbar controls, in both UI languages.
657
+ * Each entry is `[title, one-line explanation]`; the follow control appends
658
+ * its live reporting state through `followTipBody`.
659
+ */
660
+ const TOOLTIPS = {
661
+ prev: {
662
+ zh: ['上一页', '翻到上一页'],
663
+ en: ['Previous page', 'Go to the previous page'],
664
+ },
665
+ next: {
666
+ zh: ['下一页', '翻到下一页'],
667
+ en: ['Next page', 'Go to the next page'],
668
+ },
669
+ page: {
670
+ zh: ['页码', '输入页码后回车跳转;默认按印刷页码,前缀 p 表示 PDF 页码'],
671
+ en: ['Page number', 'Type a page and press Enter; printed page by default, prefix p for a PDF page'],
672
+ },
673
+ search: {
674
+ zh: ['搜索', '搜索这个 PDF 的文字层(Cmd/Ctrl+Shift+F)'],
675
+ en: ['Search', "Search this PDF's text layer (Cmd/Ctrl+Shift+F)"],
676
+ },
677
+ follow: {
678
+ zh: ['AI 上下文', '把当前阅读页报告给模型'],
679
+ en: ['AI context', 'Report the page you are reading to the model'],
680
+ },
681
+ closeSearch: {
682
+ zh: ['关闭搜索', '收起搜索面板(Esc)'],
683
+ en: ['Close search', 'Close the search panel (Esc)'],
684
+ },
685
+ resultJump: {
686
+ zh: ['跳到这一页', '滚动到搜索结果所在的页'],
687
+ en: ['Jump to page', 'Scroll to the page of this search result'],
688
+ },
689
+ resultInsert: {
690
+ zh: ['引用这条结果', '把这条结果的位置插入输入框'],
691
+ en: ['Insert reference', "Insert this result's position into the chat"],
692
+ },
693
+ }
694
+
695
+ /** @returns the localized explanation body for the follow control. */
696
+ function followTipBody(lang, state) {
697
+ const display = state?.display
698
+ const reportState = state?.reportState
699
+ const follow = state?.follow
700
+ if (lang === 'zh') {
701
+ if (!follow) return '关闭:不向模型报告当前页'
702
+ if (reportState === 'sending') return `开启:正在报告第 ${display} 页…`
703
+ if (reportState === 'ok') return `开启:模型已知当前第 ${display} 页`
704
+ if (reportState === 'error') return '开启:Host 路由不可用,报告失败'
705
+ return `开启:将把当前页(第 ${display} 页)告诉模型`
706
+ }
707
+ if (!follow) return 'Off — the host is not told which page you are on'
708
+ if (reportState === 'sending') return `On — reporting page ${display}…`
709
+ if (reportState === 'ok') return `On — the host knows page ${display}`
710
+ if (reportState === 'error') return 'On — the host route is unavailable'
711
+ return `On — the current page (${display}) will be reported`
712
+ }
713
+
714
+ /**
715
+ * @param key - a `TOOLTIPS` key.
716
+ * @param state - the component's current follow/report/page facts.
717
+ * @returns `{ title, body }` in the active UI language, or undefined.
718
+ */
719
+ function tipContent(key, state) {
720
+ const entry = TOOLTIPS[key]
721
+ if (entry === undefined) return undefined
722
+ const lang = activeLanguage()
723
+ const text = entry[lang] ?? entry.en
724
+ return {
725
+ title: text[0],
726
+ body: key === 'follow' ? followTipBody(lang, state) : text[1],
727
+ }
728
+ }
729
+
730
+ const TIP_STYLE = {
731
+ position: 'fixed',
732
+ zIndex: 2147483001,
733
+ maxWidth: '260px',
734
+ padding: '6px 8px',
735
+ border: '1px solid var(--dsw-alias-border-l1, rgba(128,128,128,.45))',
736
+ borderRadius: '6px',
737
+ background: 'var(--dsw-alias-bg-overlay, Canvas)',
738
+ color: 'var(--dsw-alias-label-primary, CanvasText)',
739
+ boxShadow: '0 6px 20px rgba(0,0,0,.22)',
740
+ font: '11px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif',
741
+ pointerEvents: 'none',
742
+ whiteSpace: 'normal',
743
+ textAlign: 'left',
744
+ colorScheme: 'light dark',
745
+ boxSizing: 'border-box',
746
+ }
747
+
748
+ const TIP_TITLE_STYLE = { fontWeight: 600, fontSize: '11px' }
749
+ const TIP_BODY_STYLE = {
750
+ marginTop: '2px',
751
+ color: 'var(--dsw-alias-label-secondary, CanvasText)',
752
+ opacity: 0.9,
753
+ }
754
+
755
+ const SEARCH_ICON = h('svg', {
756
+ width: 14,
757
+ height: 14,
758
+ viewBox: '0 0 16 16',
759
+ fill: 'none',
760
+ 'aria-hidden': 'true',
761
+ },
762
+ h('circle', { cx: 7, cy: 7, r: 4.4, stroke: 'currentColor', strokeWidth: 1.6 }),
763
+ h('path', { d: 'M10.4 10.4 14.2 14.2', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round' }))
764
+
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
+ /**
781
+ * The header contribution for one PDF preview.
782
+ * @param props - Session-scope standard props plus the preview's `absolutePath`.
783
+ */
784
+ function PdfReaderAction(props) {
785
+ const absolutePath = typeof props?.absolutePath === 'string' ? props.absolutePath : undefined
786
+ const isPdf = isPdfPath(absolutePath)
787
+ const inputActions = props?.inputActions
788
+ const sessionId = typeof props?.sessionId === 'string' ? props.sessionId : undefined
789
+
790
+ const hostRef = useRef(null)
791
+ const searchButtonRef = useRef(null)
792
+ const searchInputRef = useRef(null)
793
+ const ownerRef = useRef(null)
794
+ if (ownerRef.current === null) ownerRef.current = Symbol('dsh-pdf-reader')
795
+ const focusTimerRef = useRef(null)
796
+ const flashTimerRef = useRef(null)
797
+ const reportAbortRef = useRef(null)
798
+ const reportSeqRef = useRef(0)
799
+ const lastReportedKeyRef = useRef(undefined)
800
+ const reportDebouncerRef = useRef(null)
801
+ if (reportDebouncerRef.current === null) {
802
+ reportDebouncerRef.current = createDebouncer(
803
+ REPORT_DELAY_MS,
804
+ (fn, ms) => globalThis.setTimeout(fn, ms),
805
+ (id) => globalThis.clearTimeout(id),
806
+ )
807
+ }
808
+
809
+ const [page, setPage] = useState(1)
810
+ const [sampled, setSampled] = useState(false)
811
+ const [domTotal, setDomTotal] = useState(0)
812
+ const [info, setInfo] = useState(null)
813
+ const [infoError, setInfoError] = useState(null)
814
+ const [editing, setEditing] = useState(false)
815
+ const [typed, setTyped] = useState('')
816
+ const [flash, setFlash] = useState(null)
817
+ const [reportState, setReportState] = useState('idle')
818
+ const [open, setOpen] = useState(false)
819
+ const [query, setQuery] = useState('')
820
+ const [hits, setHits] = useState(null)
821
+ const [searching, setSearching] = useState(false)
822
+ const [searchError, setSearchError] = useState(null)
823
+ const [tip, setTip] = useState(null)
824
+ const follow = useFollow()
825
+ const tipTimerRef = useRef(null)
826
+ const tipKeyRef = useRef(null)
827
+
828
+ const printedOffset = info?.printedOffset
829
+ const printed = printedPageOf(page, printedOffset)
830
+ const display = printed === undefined ? page : printed
831
+ const total = positiveInteger(info?.pageCount) ? info.pageCount : domTotal
832
+
833
+ /** Show a short, quiet status in the header. */
834
+ const flashMessage = (text, tone) => {
835
+ if (flashTimerRef.current !== null) {
836
+ globalThis.clearTimeout(flashTimerRef.current)
837
+ flashTimerRef.current = null
838
+ }
839
+ setFlash({ text, tone: tone ?? 'info' })
840
+ flashTimerRef.current = globalThis.setTimeout(() => {
841
+ flashTimerRef.current = null
842
+ setFlash(null)
843
+ }, FLASH_MS)
844
+ }
845
+
846
+ /** Hide the hover explanation and cancel a pending show. */
847
+ const hideTip = () => {
848
+ if (tipTimerRef.current !== null) {
849
+ globalThis.clearTimeout(tipTimerRef.current)
850
+ tipTimerRef.current = null
851
+ }
852
+ if (tipKeyRef.current !== null) {
853
+ tipKeyRef.current = null
854
+ setTip(null)
855
+ }
856
+ }
857
+
858
+ /**
859
+ * Arm the hover explanation for the closest control carrying `data-tip`.
860
+ * The explanation waits `TIP_DELAY_MS` so passing over the toolbar stays quiet.
861
+ */
862
+ const armTip = (event) => {
863
+ const target = event?.target
864
+ const element = target !== null && target !== undefined && typeof target.closest === 'function'
865
+ ? target.closest('[data-tip]')
866
+ : null
867
+ if (element === null || element === undefined) return
868
+ const key = element.getAttribute('data-tip')
869
+ if (typeof key !== 'string' || key === '' || tipKeyRef.current === key) return
870
+ if (tipTimerRef.current !== null) globalThis.clearTimeout(tipTimerRef.current)
871
+ tipKeyRef.current = key
872
+ tipTimerRef.current = globalThis.setTimeout(() => {
873
+ tipTimerRef.current = null
874
+ if (!element.isConnected) return
875
+ const rect = element.getBoundingClientRect()
876
+ setTip({ key, left: rect.left + rect.width / 2, top: rect.bottom })
877
+ }, TIP_DELAY_MS)
878
+ }
879
+
880
+ /** Hide unless the pointer or focus moved to another control of this group. */
881
+ const leaveTip = (event) => {
882
+ const related = event?.relatedTarget
883
+ const host = hostRef.current
884
+ if (host !== null && related !== null && related !== undefined && host.contains(related)) return
885
+ hideTip()
886
+ }
887
+
888
+ /** Focus the search input; keep the query highlighted for quick replacement. */
889
+ const openSearch = () => {
890
+ 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)
897
+ }
898
+
899
+ /** Close the panel and give focus back to the header button. */
900
+ const closeSearch = () => {
901
+ setOpen(false)
902
+ if (focusTimerRef.current !== null) {
903
+ globalThis.clearTimeout(focusTimerRef.current)
904
+ focusTimerRef.current = null
905
+ }
906
+ searchButtonRef.current?.focus()
907
+ }
908
+
909
+ /**
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.
913
+ */
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
+ })
923
+ }
924
+
925
+ /** Scroll a physical page into view and update the readout immediately. */
926
+ const jumpTo = (target) => {
927
+ if (!positiveInteger(target)) return false
928
+ if (total > 0 && target > total) {
929
+ flashMessage(`No page ${target}`, 'warn')
930
+ return false
931
+ }
932
+ const pane = resolvePane(hostRef.current)
933
+ if (pane === undefined) {
934
+ flashMessage('Preview not ready', 'warn')
935
+ return false
936
+ }
937
+ const scrollport = resolveScrollport(pane)
938
+ if (!scrollPageIntoView(pane, scrollport, target)) {
939
+ flashMessage(`Page ${displayPage(target, printedOffset)} is not rendered`, 'warn')
940
+ return false
941
+ }
942
+ setPage(target)
943
+ setSampled(true)
944
+ flashMessage(`p.${displayPage(target, printedOffset)}`, 'ok')
945
+ return true
946
+ }
947
+
948
+ /** Commit the page box; plain numbers mean printed pages when known. */
949
+ const commitJump = () => {
950
+ const target = parsePageInput(typed, { printedOffset, pageCount: total })
951
+ if (target === undefined) {
952
+ const shown = typed.trim() === '' ? 'No page' : `No page ${typed.trim()}`
953
+ flashMessage(shown, 'warn')
954
+ return false
955
+ }
956
+ if (!jumpTo(target)) return false
957
+ setEditing(false)
958
+ return true
959
+ }
960
+
961
+ /** Step one physical page; the readout still shows the printed label. */
962
+ const step = (delta) => {
963
+ const next = page + delta
964
+ if (next < 1) return
965
+ if (total > 0 && next > total) return
966
+ jumpTo(next)
967
+ }
968
+
969
+ // Reset every path-scoped piece of state when the preview switches files.
970
+ useEffect(() => {
971
+ setPage(1)
972
+ setSampled(false)
973
+ setDomTotal(0)
974
+ setInfo(null)
975
+ setInfoError(null)
976
+ setEditing(false)
977
+ setTyped('')
978
+ setFlash(null)
979
+ setReportState('idle')
980
+ setOpen(false)
981
+ setQuery('')
982
+ setHits(null)
983
+ setSearching(false)
984
+ setSearchError(null)
985
+ lastReportedKeyRef.current = undefined
986
+ reportAbortRef.current?.abort()
987
+ if (focusTimerRef.current !== null) {
988
+ globalThis.clearTimeout(focusTimerRef.current)
989
+ focusTimerRef.current = null
990
+ }
991
+ if (flashTimerRef.current !== null) {
992
+ globalThis.clearTimeout(flashTimerRef.current)
993
+ flashTimerRef.current = null
994
+ }
995
+ }, [absolutePath])
996
+
997
+ // One `/info` per PDF, cached for the page lifetime.
998
+ useEffect(() => {
999
+ if (!isPdf || absolutePath === undefined) {
1000
+ setInfo(null)
1001
+ setInfoError(null)
1002
+ return undefined
1003
+ }
1004
+ const cached = cachedInfo(absolutePath)
1005
+ if (cached !== undefined) {
1006
+ setInfo(cached)
1007
+ setInfoError(null)
1008
+ return undefined
1009
+ }
1010
+ setInfo(null)
1011
+ setInfoError(null)
1012
+ const controller = new AbortController()
1013
+ let disposed = false
1014
+ let timedOut = false
1015
+ const timer = globalThis.setTimeout(() => {
1016
+ timedOut = true
1017
+ controller.abort()
1018
+ }, INFO_TIMEOUT_MS)
1019
+ const load = async () => {
1020
+ try {
1021
+ const { status, body } = await fetchJson(
1022
+ `${INFO_ROUTE}?path=${encodeURIComponent(absolutePath)}`,
1023
+ { headers: { accept: 'application/json' }, signal: controller.signal },
1024
+ )
1025
+ if (disposed) return
1026
+ const parsed = normalizeInfo(body)
1027
+ if (parsed === undefined) {
1028
+ setInfoError(`info unavailable${status >= 400 ? ` (HTTP ${status})` : ''}`)
1029
+ return
1030
+ }
1031
+ rememberInfo(absolutePath, parsed)
1032
+ setInfo(parsed)
1033
+ setInfoError(null)
1034
+ } catch (error) {
1035
+ if (disposed || isAbortError(error)) return
1036
+ setInfoError(timedOut ? 'info timed out' : 'info unavailable')
1037
+ } finally {
1038
+ globalThis.clearTimeout(timer)
1039
+ }
1040
+ }
1041
+ void load()
1042
+ return () => {
1043
+ disposed = true
1044
+ globalThis.clearTimeout(timer)
1045
+ controller.abort()
1046
+ }
1047
+ }, [isPdf, absolutePath])
1048
+
1049
+ // Bind the header to its preview body, scrollport, and page geometry.
1050
+ //
1051
+ // Geometry is measured once per layout change (ResizeObserver / resize /
1052
+ // visibility), never per scroll: scroll handling is one rAF and a binary
1053
+ // search over cached page tops, so a 410-page book stays cheap.
1054
+ useEffect(() => {
1055
+ if (!isPdf) return undefined
1056
+ const host = hostRef.current
1057
+ if (host === null) return undefined
1058
+
1059
+ let disposed = false
1060
+ let pane
1061
+ let scrollport
1062
+ let section
1063
+ let rootObserver
1064
+ let resizeObserver
1065
+ let retryTimer
1066
+ let retryCount = 0
1067
+ let geometry = []
1068
+ let lastPage
1069
+ const view = typeof window !== 'undefined' ? window : globalThis
1070
+
1071
+ const requestFrame = typeof view.requestAnimationFrame === 'function'
1072
+ ? (fn) => view.requestAnimationFrame(fn)
1073
+ : (fn) => globalThis.setTimeout(fn, 16)
1074
+ const cancelFrame = typeof view.cancelAnimationFrame === 'function'
1075
+ ? (id) => view.cancelAnimationFrame(id)
1076
+ : (id) => globalThis.clearTimeout(id)
1077
+ const throttle = createRafThrottle(requestFrame, cancelFrame)
1078
+ const measureDebouncer = createDebouncer(
1079
+ 120,
1080
+ (fn, ms) => globalThis.setTimeout(fn, ms),
1081
+ (id) => globalThis.clearTimeout(id),
1082
+ )
1083
+ const structureDebouncer = createDebouncer(
1084
+ 250,
1085
+ (fn, ms) => globalThis.setTimeout(fn, ms),
1086
+ (id) => globalThis.clearTimeout(id),
1087
+ )
1088
+
1089
+ const sample = () => {
1090
+ if (disposed || scrollport === undefined) return
1091
+ if (geometry.length === 0) {
1092
+ measureDebouncer.schedule(measure)
1093
+ return
1094
+ }
1095
+ let height = scrollport.clientHeight
1096
+ if (!(typeof height === 'number' && height > 0)) {
1097
+ try {
1098
+ height = scrollport.getBoundingClientRect().height
1099
+ } catch (_error) {
1100
+ height = 0
1101
+ }
1102
+ }
1103
+ let top = scrollport.scrollTop
1104
+ if (typeof top !== 'number' || !Number.isFinite(top)) top = 0
1105
+ const next = pickPage(geometry, { top, height })
1106
+ if (next === undefined || next === lastPage) return
1107
+ lastPage = next
1108
+ setPage(next)
1109
+ setSampled(true)
1110
+ }
1111
+
1112
+ const measure = () => {
1113
+ if (disposed) return
1114
+ const currentPane = resolvePane(host)
1115
+ if (currentPane === undefined) {
1116
+ scheduleRetry()
1117
+ return
1118
+ }
1119
+ const currentScrollport = resolveScrollport(currentPane)
1120
+ const currentSection = currentPane.querySelector(`[${PDF_SECTION_ATTR}]`)
1121
+ if (currentPane !== pane || currentScrollport !== scrollport || currentSection !== section) {
1122
+ bind(currentPane, currentScrollport, currentSection)
1123
+ return
1124
+ }
1125
+ const pages = pageElements(currentPane)
1126
+ const nextGeometry = []
1127
+ try {
1128
+ const portRect = currentScrollport === undefined ? { top: 0 } : currentScrollport.getBoundingClientRect()
1129
+ const base = currentScrollport === undefined ? 0 : currentScrollport.scrollTop
1130
+ for (const entry of pages) {
1131
+ const rect = entry.element.getBoundingClientRect()
1132
+ nextGeometry.push({ number: entry.number, top: rect.top - portRect.top + base })
1133
+ }
1134
+ } catch (_error) {
1135
+ // A renderer mid-teardown leaves the previous cache in place; the
1136
+ // ResizeObserver retries once layout is available again.
1137
+ return
1138
+ }
1139
+ geometry = nextGeometry
1140
+ const last = nextGeometry.length === 0 ? 0 : nextGeometry[nextGeometry.length - 1].number
1141
+ setDomTotal(last)
1142
+ sample()
1143
+ }
1144
+
1145
+ const onScroll = () => {
1146
+ throttle.schedule(sample)
1147
+ }
1148
+
1149
+ const onResize = () => {
1150
+ if (disposed) return
1151
+ measureDebouncer.schedule(() => {
1152
+ measure()
1153
+ sample()
1154
+ })
1155
+ }
1156
+
1157
+ const onVisibility = () => {
1158
+ if (!disposed && document.visibilityState === 'visible') onResize()
1159
+ }
1160
+
1161
+ /** Re-resolve structure after a DOM mutation; page renders alone are ignored. */
1162
+ const syncStructure = () => {
1163
+ if (disposed) return
1164
+ const nextPane = resolvePane(host)
1165
+ if (nextPane === undefined) return
1166
+ const nextScrollport = resolveScrollport(nextPane)
1167
+ const nextSection = nextPane.querySelector(`[${PDF_SECTION_ATTR}]`)
1168
+ if (nextPane !== pane || nextScrollport !== scrollport || nextSection !== section) {
1169
+ bind(nextPane, nextScrollport, nextSection)
1170
+ }
1171
+ }
1172
+
1173
+ const observeStructure = (target) => {
1174
+ rootObserver?.disconnect()
1175
+ rootObserver = undefined
1176
+ if (typeof MutationObserver !== 'function' || target === null || target === undefined) return
1177
+ rootObserver = new MutationObserver(() => {
1178
+ structureDebouncer.schedule(syncStructure)
1179
+ })
1180
+ rootObserver.observe(target, { childList: true, subtree: true })
1181
+ }
1182
+
1183
+ const observeSize = (target) => {
1184
+ resizeObserver?.disconnect()
1185
+ resizeObserver = undefined
1186
+ if (typeof ResizeObserver !== 'function') return
1187
+ resizeObserver = new ResizeObserver(onResize)
1188
+ if (target !== null && target !== undefined) resizeObserver.observe(target)
1189
+ }
1190
+
1191
+ const detach = () => {
1192
+ scrollport?.removeEventListener('scroll', onScroll)
1193
+ pane?.removeEventListener('scroll', onScroll, true)
1194
+ view.removeEventListener('resize', onResize)
1195
+ document.removeEventListener('visibilitychange', onVisibility)
1196
+ rootObserver?.disconnect()
1197
+ rootObserver = undefined
1198
+ resizeObserver?.disconnect()
1199
+ resizeObserver = undefined
1200
+ }
1201
+
1202
+ const bind = (nextPane, nextScrollport, nextSection) => {
1203
+ detach()
1204
+ pane = nextPane
1205
+ scrollport = nextScrollport
1206
+ section = nextSection
1207
+ geometry = []
1208
+ lastPage = undefined
1209
+ if (pane !== undefined) {
1210
+ pane.addEventListener('scroll', onScroll, true)
1211
+ view.addEventListener('resize', onResize)
1212
+ document.addEventListener('visibilitychange', onVisibility)
1213
+ observeStructure(pane.parentElement ?? host.parentElement ?? pane)
1214
+ observeSize(nextScrollport ?? pane)
1215
+ if (typeof ResizeObserver === 'function' && nextSection !== null && nextSection !== undefined) {
1216
+ resizeObserver.observe(nextSection)
1217
+ }
1218
+ }
1219
+ if (nextScrollport !== undefined) {
1220
+ nextScrollport.addEventListener('scroll', onScroll, { passive: true })
1221
+ }
1222
+ measure()
1223
+ }
1224
+
1225
+ const scheduleRetry = () => {
1226
+ if (disposed || retryTimer !== undefined || retryCount >= 40) return
1227
+ retryCount += 1
1228
+ retryTimer = globalThis.setTimeout(() => {
1229
+ retryTimer = undefined
1230
+ attempt()
1231
+ }, 200)
1232
+ }
1233
+
1234
+ const attempt = () => {
1235
+ if (disposed) return
1236
+ const nextPane = resolvePane(host)
1237
+ if (nextPane === undefined) {
1238
+ scheduleRetry()
1239
+ return
1240
+ }
1241
+ const nextScrollport = resolveScrollport(nextPane)
1242
+ const nextSection = nextPane.querySelector(`[${PDF_SECTION_ATTR}]`)
1243
+ bind(nextPane, nextScrollport, nextSection)
1244
+ }
1245
+
1246
+ attempt()
1247
+ return () => {
1248
+ disposed = true
1249
+ throttle.cancel()
1250
+ measureDebouncer.cancel()
1251
+ structureDebouncer.cancel()
1252
+ if (retryTimer !== undefined) globalThis.clearTimeout(retryTimer)
1253
+ detach()
1254
+ }
1255
+ }, [isPdf, absolutePath])
1256
+
1257
+ // Debounced AI-context reporting. One timer, one in-flight POST, abort on
1258
+ // page change, toggle-off, path change, or unmount.
1259
+ useEffect(() => {
1260
+ if (!isPdf || absolutePath === undefined || !sampled) return undefined
1261
+ const key = `${absolutePath}|${page}|${printed === undefined ? '' : printed}|${sessionId ?? ''}`
1262
+ if (!follow) {
1263
+ reportDebouncerRef.current.cancel()
1264
+ reportAbortRef.current?.abort()
1265
+ lastReportedKeyRef.current = undefined
1266
+ setReportState('off')
1267
+ return undefined
1268
+ }
1269
+ if (lastReportedKeyRef.current === key) {
1270
+ // The host already has this exact page. This also repairs a state
1271
+ // left at "sending" when a rapid detour aborted a newer request.
1272
+ setReportState('ok')
1273
+ return undefined
1274
+ }
1275
+ let disposed = false
1276
+ reportDebouncerRef.current.schedule(() => {
1277
+ if (disposed) return
1278
+ if (typeof fetch !== 'function') {
1279
+ setReportState('error')
1280
+ return
1281
+ }
1282
+ const controller = new AbortController()
1283
+ reportAbortRef.current?.abort()
1284
+ reportAbortRef.current = controller
1285
+ const seq = reportSeqRef.current + 1
1286
+ reportSeqRef.current = seq
1287
+ let timedOut = false
1288
+ const timer = globalThis.setTimeout(() => {
1289
+ timedOut = true
1290
+ controller.abort()
1291
+ }, REPORT_TIMEOUT_MS)
1292
+ const body = { path: absolutePath, pdfPage: page }
1293
+ if (printed !== undefined) body.printedPage = printed
1294
+ if (sessionId !== undefined) body.sessionId = sessionId
1295
+ setReportState('sending')
1296
+ fetch(CURRENT_ROUTE, {
1297
+ method: 'POST',
1298
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
1299
+ body: JSON.stringify(body),
1300
+ signal: controller.signal,
1301
+ keepalive: true,
1302
+ })
1303
+ .then(async (response) => {
1304
+ const payload = await response.json().catch(() => null)
1305
+ if (!response.ok || payload?.ok !== true) throw new Error(`HTTP ${response.status}`)
1306
+ return payload
1307
+ })
1308
+ .then(() => {
1309
+ if (disposed || seq !== reportSeqRef.current) return
1310
+ lastReportedKeyRef.current = key
1311
+ setReportState('ok')
1312
+ })
1313
+ .catch((error) => {
1314
+ if (disposed || seq !== reportSeqRef.current) return
1315
+ if (isAbortError(error) && !timedOut) return
1316
+ setReportState('error')
1317
+ })
1318
+ .finally(() => {
1319
+ globalThis.clearTimeout(timer)
1320
+ if (reportAbortRef.current === controller) reportAbortRef.current = null
1321
+ })
1322
+ })
1323
+ return () => {
1324
+ disposed = true
1325
+ reportDebouncerRef.current.cancel()
1326
+ if (reportAbortRef.current !== null) {
1327
+ // The Host may already have received the aborted page. Forget the
1328
+ // acknowledged key so a detour back to the old page re-reports it.
1329
+ reportAbortRef.current.abort()
1330
+ reportAbortRef.current = null
1331
+ lastReportedKeyRef.current = undefined
1332
+ }
1333
+ }
1334
+ }, [isPdf, absolutePath, sampled, page, printed, sessionId, follow])
1335
+
1336
+ // Search: type, wait 220 ms, query the Host route, cancel the stale query.
1337
+ useEffect(() => {
1338
+ if (!open || !isPdf || absolutePath === undefined) return undefined
1339
+ const needle = query.trim()
1340
+ if (needle === '') {
1341
+ setHits(null)
1342
+ setSearchError(null)
1343
+ setSearching(false)
1344
+ return undefined
1345
+ }
1346
+ setSearching(true)
1347
+ setSearchError(null)
1348
+ let disposed = false
1349
+ let timedOut = false
1350
+ let timer
1351
+ const controller = new AbortController()
1352
+ const start = () => {
1353
+ if (disposed) return
1354
+ timer = globalThis.setTimeout(() => {
1355
+ timedOut = true
1356
+ controller.abort()
1357
+ }, SEARCH_TIMEOUT_MS)
1358
+ const url = `${SEARCH_ROUTE}?path=${encodeURIComponent(absolutePath)}`
1359
+ + `&q=${encodeURIComponent(needle)}&ignoreCase=1&limit=${SEARCH_LIMIT}`
1360
+ fetchJson(url, { headers: { accept: 'application/json' }, signal: controller.signal })
1361
+ .then(({ status, body }) => {
1362
+ if (disposed) return
1363
+ if (body === null || typeof body !== 'object' || body.ok !== true) {
1364
+ setHits([])
1365
+ setSearchError(describeSearchFailure(body, status))
1366
+ return
1367
+ }
1368
+ const list = Array.isArray(body.hits)
1369
+ ? body.hits.map(normalizeHit).filter((hit) => hit !== undefined)
1370
+ : []
1371
+ setHits(list)
1372
+ setSearchError(null)
1373
+ })
1374
+ .catch((error) => {
1375
+ if (disposed || (isAbortError(error) && !timedOut)) return
1376
+ setHits([])
1377
+ setSearchError(timedOut ? 'Search timed out.' : 'Search unavailable.')
1378
+ })
1379
+ .finally(() => {
1380
+ globalThis.clearTimeout(timer)
1381
+ if (!disposed) setSearching(false)
1382
+ })
1383
+ }
1384
+ const debounce = globalThis.setTimeout(start, SEARCH_DELAY_MS)
1385
+ return () => {
1386
+ disposed = true
1387
+ globalThis.clearTimeout(debounce)
1388
+ if (timer !== undefined) globalThis.clearTimeout(timer)
1389
+ controller.abort()
1390
+ }
1391
+ }, [open, isPdf, absolutePath, query])
1392
+
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.
1395
+ useEffect(() => {
1396
+ if (!isPdf) return undefined
1397
+ const owner = ownerRef.current
1398
+ const onKeyDown = (event) => {
1399
+ if (event.defaultPrevented || event.repeat || event.isComposing) return
1400
+ if (!(event.metaKey || event.ctrlKey) || event.altKey || !event.shiftKey) return
1401
+ if (String(event.key).toLowerCase() !== 'f') return
1402
+ if (activeOwner === undefined) activeOwner = owner
1403
+ if (activeOwner !== owner) return
1404
+ event.preventDefault()
1405
+ event.stopPropagation()
1406
+ openSearch()
1407
+ }
1408
+ const claim = () => {
1409
+ activeOwner = owner
1410
+ }
1411
+ const host = hostRef.current
1412
+ host?.addEventListener('pointerdown', claim)
1413
+ host?.addEventListener('focusin', claim)
1414
+ document.addEventListener('keydown', onKeyDown, true)
1415
+ return () => {
1416
+ document.removeEventListener('keydown', onKeyDown, true)
1417
+ host?.removeEventListener('pointerdown', claim)
1418
+ host?.removeEventListener('focusin', claim)
1419
+ if (activeOwner === owner) activeOwner = undefined
1420
+ }
1421
+ }, [isPdf])
1422
+
1423
+ // Timers that outlive a render and must not outlive the component.
1424
+ useEffect(() => () => {
1425
+ if (focusTimerRef.current !== null) globalThis.clearTimeout(focusTimerRef.current)
1426
+ if (flashTimerRef.current !== null) globalThis.clearTimeout(flashTimerRef.current)
1427
+ if (tipTimerRef.current !== null) globalThis.clearTimeout(tipTimerRef.current)
1428
+ }, [])
1429
+
1430
+ if (!isPdf) return null
1431
+
1432
+ const dotColor = !follow
1433
+ ? 'currentColor'
1434
+ : reportState === 'error'
1435
+ ? '#e5534b'
1436
+ : reportState === 'ok'
1437
+ ? '#3fb950'
1438
+ : reportState === 'sending'
1439
+ ? '#c69026'
1440
+ : 'currentColor'
1441
+
1442
+ const controls = [
1443
+ h('button', {
1444
+ key: 'prev',
1445
+ type: 'button',
1446
+ 'aria-label': 'Previous page',
1447
+ 'data-tip': 'prev',
1448
+ 'data-dsh-pdf-reader': 'prev',
1449
+ disabled: page <= 1,
1450
+ onClick: () => {
1451
+ step(-1)
1452
+ },
1453
+ style: { ...BUTTON_STYLE, opacity: page <= 1 ? 0.35 : 1 },
1454
+ }, '‹'),
1455
+ h('span', {
1456
+ key: 'readout',
1457
+ style: { display: 'inline-flex', alignItems: 'center', gap: '1px', flex: '0 0 auto' },
1458
+ },
1459
+ h('span', { style: BRACKET_STYLE, 'aria-hidden': 'true' }, '['),
1460
+ h('input', {
1461
+ type: 'text',
1462
+ inputMode: 'numeric',
1463
+ autoComplete: 'off',
1464
+ spellCheck: false,
1465
+ maxLength: 12,
1466
+ value: editing ? typed : String(display),
1467
+ 'aria-label': printedOffset === undefined
1468
+ ? `PDF page (${page} of ${total > 0 ? total : 'unknown'})`
1469
+ : `Printed page (PDF page ${page} of ${total > 0 ? total : 'unknown'})`,
1470
+ 'data-tip': 'page',
1471
+ 'data-dsh-pdf-reader': 'page',
1472
+ style: INPUT_STYLE,
1473
+ onFocus: (event) => {
1474
+ setEditing(true)
1475
+ setTyped(String(display))
1476
+ event.currentTarget.select()
1477
+ },
1478
+ onChange: (event) => {
1479
+ setTyped(String(event.target.value))
1480
+ },
1481
+ onBlur: () => {
1482
+ setEditing(false)
1483
+ },
1484
+ onKeyDown: (event) => {
1485
+ if (event.isComposing) return
1486
+ if (event.key === 'Enter') {
1487
+ event.preventDefault()
1488
+ if (commitJump()) event.currentTarget.blur()
1489
+ return
1490
+ }
1491
+ if (event.key === 'Escape') {
1492
+ event.preventDefault()
1493
+ setEditing(false)
1494
+ setTyped('')
1495
+ event.currentTarget.blur()
1496
+ }
1497
+ },
1498
+ }),
1499
+ h('span', { style: BRACKET_STYLE, 'aria-hidden': 'true' }, ']'),
1500
+ h('span', {
1501
+ style: { opacity: 0.55, marginLeft: '1px' },
1502
+ title: infoError ?? undefined,
1503
+ }, `/ ${total > 0 ? total : '?'}`)),
1504
+ h('button', {
1505
+ key: 'next',
1506
+ type: 'button',
1507
+ 'aria-label': 'Next page',
1508
+ 'data-tip': 'next',
1509
+ 'data-dsh-pdf-reader': 'next',
1510
+ disabled: total > 0 && page >= total,
1511
+ onClick: () => {
1512
+ step(1)
1513
+ },
1514
+ style: { ...BUTTON_STYLE, opacity: total > 0 && page >= total ? 0.35 : 1 },
1515
+ }, '›'),
1516
+ h('button', {
1517
+ key: 'search',
1518
+ ref: searchButtonRef,
1519
+ type: 'button',
1520
+ 'aria-label': 'Search this PDF',
1521
+ 'aria-expanded': open,
1522
+ 'data-tip': 'search',
1523
+ 'data-dsh-pdf-reader': 'search',
1524
+ onClick: () => {
1525
+ if (open) closeSearch()
1526
+ else openSearch()
1527
+ },
1528
+ style: BUTTON_STYLE,
1529
+ }, SEARCH_ICON),
1530
+ h('button', {
1531
+ key: 'follow',
1532
+ type: 'button',
1533
+ 'aria-label': 'AI context reporting',
1534
+ 'aria-pressed': follow,
1535
+ 'data-tip': 'follow',
1536
+ 'data-dsh-pdf-reader': 'follow',
1537
+ onClick: () => {
1538
+ publishFollow(!follow)
1539
+ },
1540
+ style: {
1541
+ ...BUTTON_STYLE,
1542
+ width: '24px',
1543
+ gap: '1px',
1544
+ opacity: follow ? 1 : 0.45,
1545
+ },
1546
+ },
1547
+ h('span', { 'aria-hidden': 'true', style: { fontSize: '8px', letterSpacing: '-.02em' } }, 'AI'),
1548
+ h('span', {
1549
+ 'aria-hidden': 'true',
1550
+ style: {
1551
+ width: '5px',
1552
+ height: '5px',
1553
+ borderRadius: '50%',
1554
+ background: dotColor,
1555
+ display: 'inline-block',
1556
+ flex: '0 0 auto',
1557
+ },
1558
+ })),
1559
+ ]
1560
+
1561
+ if (flash !== null) {
1562
+ controls.push(h('span', {
1563
+ key: 'flash',
1564
+ 'aria-live': 'polite',
1565
+ title: flash.text,
1566
+ style: {
1567
+ position: 'absolute',
1568
+ top: '24px',
1569
+ right: '0',
1570
+ zIndex: 1,
1571
+ maxWidth: '180px',
1572
+ overflow: 'hidden',
1573
+ textOverflow: 'ellipsis',
1574
+ whiteSpace: 'nowrap',
1575
+ padding: '1px 4px',
1576
+ border: '1px solid rgba(128,128,128,.35)',
1577
+ borderRadius: '4px',
1578
+ background: 'Canvas',
1579
+ color: 'CanvasText',
1580
+ fontSize: '10px',
1581
+ lineHeight: '14px',
1582
+ opacity: 0.95,
1583
+ ...(flash.tone === 'warn' ? WARN_STYLE : flash.tone === 'ok' ? { color: '#3fb950' } : {}),
1584
+ },
1585
+ }, flash.text))
1586
+ }
1587
+
1588
+ let panel = null
1589
+ 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
1630
+ panel = h('div', {
1631
+ key: 'panel',
1632
+ style: PANEL_STYLE,
1633
+ role: 'dialog',
1634
+ 'aria-label': 'Search this PDF',
1635
+ 'data-dsh-pdf-reader': 'panel',
1636
+ onKeyDown: (event) => {
1637
+ if (event.isComposing) return
1638
+ event.stopPropagation()
1639
+ if (event.key === 'Escape') {
1640
+ event.preventDefault()
1641
+ closeSearch()
1642
+ }
1643
+ },
1644
+ },
1645
+ h('div', { style: ROW_STYLE },
1646
+ h('input', {
1647
+ ref: searchInputRef,
1648
+ type: 'search',
1649
+ value: query,
1650
+ placeholder: 'Search this PDF',
1651
+ 'aria-label': 'Search this PDF',
1652
+ spellCheck: false,
1653
+ autoComplete: 'off',
1654
+ maxLength: 1000,
1655
+ style: PANEL_INPUT_STYLE,
1656
+ onChange: (event) => {
1657
+ setQuery(String(event.target.value))
1658
+ },
1659
+ onKeyDown: (event) => {
1660
+ if (event.isComposing) return
1661
+ if (event.key === 'Enter' && hits !== null && hits.length > 0) {
1662
+ event.preventDefault()
1663
+ jumpTo(hits[0].pdf)
1664
+ }
1665
+ },
1666
+ }),
1667
+ h('button', {
1668
+ type: 'button',
1669
+ 'aria-label': 'Close search',
1670
+ 'data-tip': 'closeSearch',
1671
+ onClick: closeSearch,
1672
+ style: BUTTON_STYLE,
1673
+ }, '✕')),
1674
+ 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'))
1684
+ }
1685
+
1686
+ controls.push(panel)
1687
+
1688
+ let tipNode = null
1689
+ if (tip !== null) {
1690
+ const content = tipContent(tip.key, { follow, reportState, display })
1691
+ if (content !== undefined) {
1692
+ tipNode = h('div', {
1693
+ role: 'tooltip',
1694
+ 'data-dsh-pdf-reader': 'tip',
1695
+ style: {
1696
+ ...TIP_STYLE,
1697
+ left: `${Math.round(tip.left)}px`,
1698
+ top: `${Math.round(tip.top + 6)}px`,
1699
+ transform: 'translateX(-50%)',
1700
+ },
1701
+ },
1702
+ h('div', { style: TIP_TITLE_STYLE }, content.title),
1703
+ content.body === '' ? null : h('div', { style: TIP_BODY_STYLE }, content.body))
1704
+ }
1705
+ }
1706
+
1707
+ return h('span', {
1708
+ ref: hostRef,
1709
+ style: CONTROL_STYLE,
1710
+ 'data-dsh-pdf-reader': 'controls',
1711
+ onMouseOver: armTip,
1712
+ onMouseOut: leaveTip,
1713
+ onFocus: armTip,
1714
+ onBlur: leaveTip,
1715
+ }, ...controls, tipNode)
1716
+ }
1717
+
1718
+ /** Required browser services: the slot registry and the locale preference. */
1719
+ const inject = ['slots', 'locale']
1720
+
1721
+ /**
1722
+ * Register the document-header contribution.
1723
+ * @param ctx - client root context carrying the slot registry.
1724
+ */
1725
+ function apply(ctx) {
1726
+ // Read-only use of the locale preference: the tooltips resolve their
1727
+ // language when shown, so no subscription is needed.
1728
+ localeService = ctx.locale
1729
+ ctx.effect(
1730
+ () => ctx.slots.inject('sidebar.right.tab.document.actions', () => ctx.slots.register({
1731
+ name: 'sidebar.right.tab.document.actions',
1732
+ id: 'pdf-reader-tools',
1733
+ order: -6,
1734
+ }, PdfReaderAction)),
1735
+ 'dsh-pdf-reader: PDF page tools',
1736
+ )
1737
+ }
1738
+
1739
+ exports.apply = apply
1740
+ exports.inject = inject
1741
+ exports.PdfReaderAction = PdfReaderAction
1742
+ // Pure helpers exposed for the headless tests only; the loader ignores them.
1743
+ exports.__test = {
1744
+ AI_BUTTON_STYLE,
1745
+ PANEL_STYLE,
1746
+ buildSearchReference,
1747
+ cachedInfo,
1748
+ copyText,
1749
+ createDebouncer,
1750
+ createRafThrottle,
1751
+ describeSearchFailure,
1752
+ displayPage,
1753
+ fetchJson,
1754
+ infoCache,
1755
+ insertComposerText,
1756
+ isPdfPath,
1757
+ normalizeInfo,
1758
+ normalizeSnippet,
1759
+ pageElements,
1760
+ parsePageInput,
1761
+ pickPage,
1762
+ positiveInteger,
1763
+ printedPageOf,
1764
+ rememberInfo,
1765
+ resolvePane,
1766
+ resolveScrollport,
1767
+ scrollPageIntoView,
1768
+ searchResultLabel,
1769
+ searchResultPage,
1770
+ }
1771
+ return module.exports
1772
+ },
1773
+ })