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/CHANGELOG.md +60 -0
- package/LICENSE +21 -0
- package/README.md +100 -2
- package/SKILL.md +96 -0
- package/cordis.patch.yml +30 -0
- package/icon.svg +13 -0
- package/lib/client.js +1773 -0
- package/lib/core/README.md +299 -0
- package/lib/core/pdf.js +962 -0
- package/lib/core/render.js +351 -0
- package/lib/core/tokens.js +116 -0
- package/lib/index.js +1850 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +83 -4
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
|
+
})
|