dsh-book-reader 0.0.0-stage → 0.1.0

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