dsh-book-reader 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -2
- package/README.md +64 -28
- package/README.zh-CN.md +62 -26
- package/SKILL.md +22 -4
- package/lib/client.js +636 -217
- package/lib/core/README.md +47 -22
- package/lib/core/pdf.js +564 -35
- package/lib/index.js +152 -19
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -3,16 +3,15 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Decorates DSH's official PDF preview; it never replaces the renderer:
|
|
5
5
|
* - current-page readout, prev/next page, and a page-jump box;
|
|
6
|
-
* - a
|
|
7
|
-
*
|
|
6
|
+
* - a find bar (Cmd/Ctrl+F) backed by the Host text route, with in-page
|
|
7
|
+
* highlights and sequential previous/next navigation between occurrences;
|
|
8
8
|
* - debounced current-page reporting for AI context, with an on/off toggle.
|
|
9
9
|
*
|
|
10
10
|
* The contribution mounts into the preview's own header slot
|
|
11
11
|
* `sidebar.right.tab.document.actions`, which the preview declares as a
|
|
12
12
|
* Session-scoped list and passes `{ absolutePath }` to. Session-scoped slots
|
|
13
|
-
* also receive the standard `sessionId
|
|
14
|
-
*
|
|
15
|
-
* preserved and the edit is undoable. It never calls `setDraft`.
|
|
13
|
+
* also receive the standard `sessionId`, used only to tag the current-page
|
|
14
|
+
* reports; the plugin never writes to the chat composer.
|
|
16
15
|
*
|
|
17
16
|
* The bundle is the module-loader factory shape used by the profile:
|
|
18
17
|
* `window.__ModuleLoader__.load({ id, factory })`, React is the only external.
|
|
@@ -41,6 +40,8 @@ window.__ModuleLoader__.load({
|
|
|
41
40
|
const ZOOM_VAR = '--document-zoom'
|
|
42
41
|
const PAGE_ATTR = 'data-pdf-page'
|
|
43
42
|
const PDF_SECTION_ATTR = 'data-pdf-preview'
|
|
43
|
+
/** Text-layer container created by pdf.js TextLayerBuilder for one page. */
|
|
44
|
+
const TEXT_LAYER_ATTR = 'data-pdf-text'
|
|
44
45
|
|
|
45
46
|
/** Timing. Reporting is the only debounced write; scrolling uses one rAF. */
|
|
46
47
|
const REPORT_DELAY_MS = 400
|
|
@@ -52,6 +53,23 @@ window.__ModuleLoader__.load({
|
|
|
52
53
|
const SEARCH_LIMIT = 100
|
|
53
54
|
const INFO_CACHE_LIMIT = 16
|
|
54
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Search highlighting.
|
|
58
|
+
*
|
|
59
|
+
* The panel owns two document-wide CSS Custom Highlights: one for every
|
|
60
|
+
* match on the pages the Host reported, and one for the occurrence the
|
|
61
|
+
* counter is on. Pages render lazily and re-render on zoom, so the text
|
|
62
|
+
* layer is retried for about three seconds and then observed for replacement.
|
|
63
|
+
*/
|
|
64
|
+
const SEARCH_HIGHLIGHT_NAME = 'dsh-book-reader-search'
|
|
65
|
+
const SEARCH_ACTIVE_HIGHLIGHT_NAME = 'dsh-book-reader-search-active'
|
|
66
|
+
const SEARCH_HIT_CLASS = 'dsh-book-reader-search-hit'
|
|
67
|
+
const SEARCH_ACTIVE_HIT_CLASS = 'dsh-book-reader-search-active-hit'
|
|
68
|
+
const SEARCH_HIGHLIGHT_STYLE_ID = 'dsh-book-reader-search-highlight-style'
|
|
69
|
+
const SEARCH_HIGHLIGHT_RETRY_MS = 150
|
|
70
|
+
const SEARCH_HIGHLIGHT_RETRY_LIMIT = 20
|
|
71
|
+
const SEARCH_HIGHLIGHT_REBUILD_MS = 60
|
|
72
|
+
|
|
55
73
|
/**
|
|
56
74
|
* Reading-position memory timing.
|
|
57
75
|
*
|
|
@@ -138,7 +156,7 @@ window.__ModuleLoader__.load({
|
|
|
138
156
|
return page
|
|
139
157
|
}
|
|
140
158
|
|
|
141
|
-
/** Collapse whitespace and bound a
|
|
159
|
+
/** Collapse whitespace and bound a diagnostic detail for the quiet panel. */
|
|
142
160
|
function normalizeSnippet(value, limit = 160) {
|
|
143
161
|
const source = typeof value === 'string' ? value : ''
|
|
144
162
|
const clean = source.replace(/\s+/gu, ' ').trim()
|
|
@@ -147,51 +165,6 @@ window.__ModuleLoader__.load({
|
|
|
147
165
|
return `${clean.slice(0, max - 1).trimEnd()}…`
|
|
148
166
|
}
|
|
149
167
|
|
|
150
|
-
/** @returns the `p.N` label for one search hit. */
|
|
151
|
-
function searchResultPage(hit) {
|
|
152
|
-
if (positiveInteger(hit?.printed)) return hit.printed
|
|
153
|
-
if (positiveInteger(hit?.pdf)) return hit.pdf
|
|
154
|
-
return undefined
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/** @returns the short list label `p.132`, or `p.?` for a malformed hit. */
|
|
158
|
-
function searchResultLabel(hit) {
|
|
159
|
-
const page = searchResultPage(hit)
|
|
160
|
-
return page === undefined ? 'p.?' : `p.${page}`
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** @returns `PDF p.132: <snippet>` (or `[PDF p.132]` when there is no snippet). */
|
|
164
|
-
function buildSearchReference(hit) {
|
|
165
|
-
const page = searchResultPage(hit)
|
|
166
|
-
const label = page === undefined ? '?' : String(page)
|
|
167
|
-
const snippet = normalizeSnippet(hit?.snippet)
|
|
168
|
-
return snippet === '' ? `[PDF p.${label}]` : `PDF p.${label}: ${snippet}`
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Insert into the composer through the Session standard props.
|
|
173
|
-
*
|
|
174
|
-
* `captureInsertion()` snapshots the selection and draft revision;
|
|
175
|
-
* `insertText()` applies one undoable plain-text edit only while that
|
|
176
|
-
* revision is current. Rejected insertions are reported as `false` so the
|
|
177
|
-
* caller can copy the reference instead; the draft is never replaced.
|
|
178
|
-
* @param inputActions - standard slot prop.
|
|
179
|
-
* @param text - plain text to insert.
|
|
180
|
-
* @returns whether the edit applied.
|
|
181
|
-
*/
|
|
182
|
-
function insertComposerText(inputActions, text) {
|
|
183
|
-
if (inputActions === null || typeof inputActions !== 'object') return false
|
|
184
|
-
if (typeof inputActions.captureInsertion !== 'function') return false
|
|
185
|
-
if (typeof inputActions.insertText !== 'function') return false
|
|
186
|
-
try {
|
|
187
|
-
const span = inputActions.captureInsertion()
|
|
188
|
-
if (span === null || typeof span !== 'object') return false
|
|
189
|
-
return inputActions.insertText(String(text), span) === true
|
|
190
|
-
} catch (_error) {
|
|
191
|
-
return false
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
168
|
/**
|
|
196
169
|
* Pick the page occupying the reading anchor near the top of the viewport.
|
|
197
170
|
*
|
|
@@ -249,6 +222,348 @@ window.__ModuleLoader__.load({
|
|
|
249
222
|
return out
|
|
250
223
|
}
|
|
251
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Case-fold text one UTF-16 code unit at a time.
|
|
227
|
+
*
|
|
228
|
+
* Search offsets have to stay 1:1 with the text layer, so characters whose
|
|
229
|
+
* lowercase form changes length (such as `İ`) keep their original form. The
|
|
230
|
+
* query is folded with the same function, which mirrors the Host's
|
|
231
|
+
* case-insensitive literal search closely enough to number occurrences.
|
|
232
|
+
* @param value - text to fold.
|
|
233
|
+
* @returns the folded text, same length as the input.
|
|
234
|
+
*/
|
|
235
|
+
function foldSearchText(value) {
|
|
236
|
+
const text = typeof value === 'string' ? value : String(value ?? '')
|
|
237
|
+
let folded = ''
|
|
238
|
+
for (let at = 0; at < text.length; at += 1) {
|
|
239
|
+
const char = text[at]
|
|
240
|
+
let lower = char
|
|
241
|
+
try {
|
|
242
|
+
lower = char.toLowerCase()
|
|
243
|
+
} catch (_error) {
|
|
244
|
+
lower = char
|
|
245
|
+
}
|
|
246
|
+
folded += typeof lower === 'string' && lower.length === 1 ? lower : char
|
|
247
|
+
}
|
|
248
|
+
return folded
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Every non-empty text node under one element, in document order.
|
|
253
|
+
* @param root - text-layer container.
|
|
254
|
+
* @returns text nodes, or an empty array when the DOM cannot be walked.
|
|
255
|
+
*/
|
|
256
|
+
function collectTextNodes(root) {
|
|
257
|
+
const nodes = []
|
|
258
|
+
if (root === null || root === undefined) return nodes
|
|
259
|
+
const documentRef = root.ownerDocument
|
|
260
|
+
if (documentRef === null || documentRef === undefined) return nodes
|
|
261
|
+
if (typeof documentRef.createTreeWalker !== 'function') return nodes
|
|
262
|
+
try {
|
|
263
|
+
// 4 is NodeFilter.SHOW_TEXT; pdf.js text layers contain no other nodes
|
|
264
|
+
// that matter, and this avoids depending on the global NodeFilter.
|
|
265
|
+
const walker = documentRef.createTreeWalker(root, 4)
|
|
266
|
+
let node = walker.nextNode()
|
|
267
|
+
while (node !== null) {
|
|
268
|
+
const text = node.nodeValue
|
|
269
|
+
if (typeof text === 'string' && text !== '') nodes.push(node)
|
|
270
|
+
node = walker.nextNode()
|
|
271
|
+
}
|
|
272
|
+
} catch (_error) {
|
|
273
|
+
return []
|
|
274
|
+
}
|
|
275
|
+
return nodes
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Locate literal case-insensitive occurrences across a run of text nodes.
|
|
280
|
+
*
|
|
281
|
+
* The text layer is split into arbitrarily many spans, so concatenate the
|
|
282
|
+
* node values and keep each node's start offset. A match may therefore span
|
|
283
|
+
* multiple nodes; `Range` creation maps both ends back to the right node.
|
|
284
|
+
* @param nodes - text nodes in document order.
|
|
285
|
+
* @param needle - literal query (already trimmed).
|
|
286
|
+
* @returns `{ nodes, starts, matches }`, or `undefined` when there is no text.
|
|
287
|
+
*/
|
|
288
|
+
function matchTextNodes(nodes, needle) {
|
|
289
|
+
const foldedNeedle = foldSearchText(needle)
|
|
290
|
+
if (nodes.length === 0 || foldedNeedle === '') return undefined
|
|
291
|
+
const starts = []
|
|
292
|
+
let text = ''
|
|
293
|
+
for (const node of nodes) {
|
|
294
|
+
starts.push(text.length)
|
|
295
|
+
text += typeof node.nodeValue === 'string' ? node.nodeValue : ''
|
|
296
|
+
}
|
|
297
|
+
const folded = foldSearchText(text)
|
|
298
|
+
const matches = []
|
|
299
|
+
let from = 0
|
|
300
|
+
for (;;) {
|
|
301
|
+
const at = folded.indexOf(foldedNeedle, from)
|
|
302
|
+
if (at < 0) break
|
|
303
|
+
matches.push({ start: at, end: at + foldedNeedle.length })
|
|
304
|
+
from = at + Math.max(1, foldedNeedle.length)
|
|
305
|
+
}
|
|
306
|
+
if (matches.length === 0) return undefined
|
|
307
|
+
return { nodes, starts, matches }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* @returns the text node and offset containing one global text offset.
|
|
312
|
+
*/
|
|
313
|
+
function locateTextOffset(nodes, starts, offset) {
|
|
314
|
+
let low = 0
|
|
315
|
+
let high = starts.length - 1
|
|
316
|
+
let found = 0
|
|
317
|
+
while (low <= high) {
|
|
318
|
+
const mid = (low + high) >> 1
|
|
319
|
+
if (starts[mid] <= offset) {
|
|
320
|
+
found = mid
|
|
321
|
+
low = mid + 1
|
|
322
|
+
} else {
|
|
323
|
+
high = mid - 1
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const node = nodes[found]
|
|
327
|
+
const length = typeof node.nodeValue === 'string' ? node.nodeValue.length : 0
|
|
328
|
+
return { node, offset: Math.max(0, Math.min(offset - starts[found], length)) }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Build a DOM Range for one match, or `undefined` when it cannot be made. */
|
|
332
|
+
function createTextRange(documentRef, nodes, starts, match) {
|
|
333
|
+
if (documentRef === null || documentRef === undefined || typeof documentRef.createRange !== 'function') {
|
|
334
|
+
return undefined
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
const first = locateTextOffset(nodes, starts, match.start)
|
|
338
|
+
const last = locateTextOffset(nodes, starts, match.end)
|
|
339
|
+
const range = documentRef.createRange()
|
|
340
|
+
range.setStart(first.node, first.offset)
|
|
341
|
+
range.setEnd(last.node, last.offset)
|
|
342
|
+
return range
|
|
343
|
+
} catch (_error) {
|
|
344
|
+
return undefined
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Add the highlight stylesheet once per document.
|
|
350
|
+
*
|
|
351
|
+
* Both APIs are styled from the same palette: translucent yellow for every
|
|
352
|
+
* match, a stronger orange for the current one. `rgba` keeps them readable
|
|
353
|
+
* over light and dark themes and avoids depending on theme variables a
|
|
354
|
+
* browser might not resolve inside `::highlight`.
|
|
355
|
+
*/
|
|
356
|
+
function ensureSearchHighlightStyle(documentRef) {
|
|
357
|
+
if (documentRef === null || documentRef === undefined) return
|
|
358
|
+
if (typeof documentRef.createElement !== 'function') return
|
|
359
|
+
try {
|
|
360
|
+
if (typeof documentRef.getElementById === 'function'
|
|
361
|
+
&& documentRef.getElementById(SEARCH_HIGHLIGHT_STYLE_ID) !== null) {
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
const style = documentRef.createElement('style')
|
|
365
|
+
style.id = SEARCH_HIGHLIGHT_STYLE_ID
|
|
366
|
+
style.textContent = [
|
|
367
|
+
`::highlight(${SEARCH_HIGHLIGHT_NAME}) { background-color: rgba(255, 213, 0, 0.42); color: inherit; }`,
|
|
368
|
+
`::highlight(${SEARCH_ACTIVE_HIGHLIGHT_NAME}) { background-color: rgba(255, 138, 0, 0.68); color: inherit; }`,
|
|
369
|
+
`.${SEARCH_HIT_CLASS} { background-color: rgba(255, 213, 0, 0.42); color: inherit; }`,
|
|
370
|
+
`.${SEARCH_ACTIVE_HIT_CLASS} { background-color: rgba(255, 138, 0, 0.68); color: inherit; }`,
|
|
371
|
+
].join('\n')
|
|
372
|
+
const parent = documentRef.head ?? documentRef.documentElement
|
|
373
|
+
if (parent !== null && parent !== undefined && typeof parent.appendChild === 'function') {
|
|
374
|
+
parent.appendChild(style)
|
|
375
|
+
}
|
|
376
|
+
} catch (_error) {
|
|
377
|
+
// Highlight color is decorative: a missing <style> must never break search.
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Detect the CSS Custom Highlight API.
|
|
383
|
+
* @returns `{ registry, Highlight }`, or `undefined` when unavailable.
|
|
384
|
+
*/
|
|
385
|
+
function searchHighlightSupport() {
|
|
386
|
+
const candidates = []
|
|
387
|
+
try {
|
|
388
|
+
if (typeof window !== 'undefined' && window !== null) candidates.push(window)
|
|
389
|
+
} catch (_error) {
|
|
390
|
+
// No window in some headless environments; globalThis is enough.
|
|
391
|
+
}
|
|
392
|
+
candidates.push(globalThis)
|
|
393
|
+
for (const candidate of candidates) {
|
|
394
|
+
try {
|
|
395
|
+
const registry = candidate?.CSS?.highlights
|
|
396
|
+
const HighlightClass = candidate?.Highlight
|
|
397
|
+
if (registry !== null && registry !== undefined
|
|
398
|
+
&& typeof registry.set === 'function'
|
|
399
|
+
&& typeof registry.delete === 'function'
|
|
400
|
+
&& typeof HighlightClass === 'function') {
|
|
401
|
+
return { registry, Highlight: HighlightClass }
|
|
402
|
+
}
|
|
403
|
+
} catch (_error) {
|
|
404
|
+
// Try the next global.
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return undefined
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Remove both highlight layers and any fallback span classes.
|
|
412
|
+
* @param pane - preview body, when still known.
|
|
413
|
+
*/
|
|
414
|
+
function clearSearchHighlights(pane) {
|
|
415
|
+
try {
|
|
416
|
+
const support = searchHighlightSupport()
|
|
417
|
+
if (support !== undefined) {
|
|
418
|
+
support.registry.delete(SEARCH_HIGHLIGHT_NAME)
|
|
419
|
+
support.registry.delete(SEARCH_ACTIVE_HIGHLIGHT_NAME)
|
|
420
|
+
}
|
|
421
|
+
} catch (_error) {
|
|
422
|
+
// Clearing is best effort.
|
|
423
|
+
}
|
|
424
|
+
try {
|
|
425
|
+
const documentRef = pane?.ownerDocument
|
|
426
|
+
?? (typeof document === 'undefined' ? undefined : document)
|
|
427
|
+
if (documentRef === undefined) return
|
|
428
|
+
const roots = pane !== null && pane !== undefined ? [pane] : [documentRef]
|
|
429
|
+
for (const root of roots) {
|
|
430
|
+
if (typeof root.querySelectorAll !== 'function') continue
|
|
431
|
+
for (const element of root.querySelectorAll(`.${SEARCH_HIT_CLASS}, .${SEARCH_ACTIVE_HIT_CLASS}`)) {
|
|
432
|
+
if (typeof element.classList?.remove === 'function') {
|
|
433
|
+
element.classList.remove(SEARCH_HIT_CLASS, SEARCH_ACTIVE_HIT_CLASS)
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
} catch (_error) {
|
|
438
|
+
// Clearing is best effort.
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Highlight the query on every hit page, with the current occurrence stronger.
|
|
444
|
+
*
|
|
445
|
+
* The active occurrence is chosen by its per-page ordinal: count the hits
|
|
446
|
+
* before `active` that share the page. That ordinal indexes the client-side
|
|
447
|
+
* match list for that page. When the client has fewer matches than the Host
|
|
448
|
+
* claimed (extraction and the text layer can disagree), every match is still
|
|
449
|
+
* highlighted and the first one becomes active.
|
|
450
|
+
* @param pane - preview body.
|
|
451
|
+
* @param needle - literal query.
|
|
452
|
+
* @param hits - ordered Host hits (`{ pdf }` is the only field used here).
|
|
453
|
+
* @param active - index into `hits`.
|
|
454
|
+
*/
|
|
455
|
+
function applySearchHighlights(pane, needle, hits, active) {
|
|
456
|
+
if (pane === null || pane === undefined || !Array.isArray(hits) || hits.length === 0) {
|
|
457
|
+
clearSearchHighlights(pane)
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
const queryText = String(needle ?? '').trim()
|
|
461
|
+
if (queryText === '') {
|
|
462
|
+
clearSearchHighlights(pane)
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
const documentRef = pane.ownerDocument
|
|
466
|
+
ensureSearchHighlightStyle(documentRef)
|
|
467
|
+
clearSearchHighlights(pane)
|
|
468
|
+
|
|
469
|
+
const elements = new Map()
|
|
470
|
+
for (const entry of pageElements(pane)) elements.set(entry.number, entry.element)
|
|
471
|
+
const pageNumbers = []
|
|
472
|
+
const seenPages = new Set()
|
|
473
|
+
for (const hit of hits) {
|
|
474
|
+
const page = hit?.pdf
|
|
475
|
+
if (!positiveInteger(page) || seenPages.has(page)) continue
|
|
476
|
+
seenPages.add(page)
|
|
477
|
+
pageNumbers.push(page)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const safeActive = Number.isInteger(active) && active >= 0 && active < hits.length ? active : 0
|
|
481
|
+
const activePage = positiveInteger(hits[safeActive]?.pdf) ? hits[safeActive].pdf : undefined
|
|
482
|
+
let activeOrdinal = 0
|
|
483
|
+
if (activePage !== undefined) {
|
|
484
|
+
for (let at = 0; at < safeActive; at += 1) {
|
|
485
|
+
if (hits[at]?.pdf === activePage) activeOrdinal += 1
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const pageMatches = []
|
|
490
|
+
for (const page of pageNumbers) {
|
|
491
|
+
const element = elements.get(page)
|
|
492
|
+
if (element === undefined) continue
|
|
493
|
+
const nodes = []
|
|
494
|
+
for (const container of element.querySelectorAll(`[${TEXT_LAYER_ATTR}]`)) {
|
|
495
|
+
for (const node of collectTextNodes(container)) nodes.push(node)
|
|
496
|
+
}
|
|
497
|
+
const matched = matchTextNodes(nodes, queryText)
|
|
498
|
+
if (matched !== undefined) pageMatches.push({ page, ...matched })
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const support = searchHighlightSupport()
|
|
502
|
+
if (support !== undefined) {
|
|
503
|
+
try {
|
|
504
|
+
const allRanges = []
|
|
505
|
+
let activeRange
|
|
506
|
+
for (const page of pageMatches) {
|
|
507
|
+
const ranges = []
|
|
508
|
+
for (const match of page.matches) {
|
|
509
|
+
const range = createTextRange(documentRef, page.nodes, page.starts, match)
|
|
510
|
+
if (range !== undefined) {
|
|
511
|
+
ranges.push(range)
|
|
512
|
+
allRanges.push(range)
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if (page.page === activePage && ranges.length > 0) {
|
|
516
|
+
activeRange = activeOrdinal < ranges.length ? ranges[activeOrdinal] : ranges[0]
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if (allRanges.length > 0) {
|
|
520
|
+
support.registry.set(SEARCH_HIGHLIGHT_NAME, new support.Highlight(...allRanges))
|
|
521
|
+
} else {
|
|
522
|
+
support.registry.delete(SEARCH_HIGHLIGHT_NAME)
|
|
523
|
+
}
|
|
524
|
+
if (activeRange !== undefined) {
|
|
525
|
+
support.registry.set(SEARCH_ACTIVE_HIGHLIGHT_NAME, new support.Highlight(activeRange))
|
|
526
|
+
} else {
|
|
527
|
+
support.registry.delete(SEARCH_ACTIVE_HIGHLIGHT_NAME)
|
|
528
|
+
}
|
|
529
|
+
return
|
|
530
|
+
} catch (_error) {
|
|
531
|
+
try {
|
|
532
|
+
support.registry.delete(SEARCH_HIGHLIGHT_NAME)
|
|
533
|
+
support.registry.delete(SEARCH_ACTIVE_HIGHLIGHT_NAME)
|
|
534
|
+
} catch (_ignored) {
|
|
535
|
+
// The span fallback below is still available.
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Fallback for older Chromium: tint the parent spans overlapping a match.
|
|
541
|
+
for (const page of pageMatches) {
|
|
542
|
+
const activeIndex = page.page === activePage
|
|
543
|
+
? (activeOrdinal < page.matches.length ? activeOrdinal : 0)
|
|
544
|
+
: -1
|
|
545
|
+
for (let index = 0; index < page.matches.length; index += 1) {
|
|
546
|
+
const match = page.matches[index]
|
|
547
|
+
for (let at = 0; at < page.nodes.length; at += 1) {
|
|
548
|
+
const node = page.nodes[at]
|
|
549
|
+
const start = page.starts[at]
|
|
550
|
+
const length = typeof node.nodeValue === 'string' ? node.nodeValue.length : 0
|
|
551
|
+
if (start + length <= match.start || start >= match.end) continue
|
|
552
|
+
const parent = node.parentElement
|
|
553
|
+
if (parent === null || parent === undefined) continue
|
|
554
|
+
if (typeof parent.classList?.add !== 'function') continue
|
|
555
|
+
try {
|
|
556
|
+
parent.classList.add(SEARCH_HIT_CLASS)
|
|
557
|
+
if (index === activeIndex) parent.classList.add(SEARCH_ACTIVE_HIT_CLASS)
|
|
558
|
+
} catch (_error) {
|
|
559
|
+
// The fallback is best effort.
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
|
|
252
567
|
/**
|
|
253
568
|
* Find the preview body that owns one header contribution.
|
|
254
569
|
*
|
|
@@ -640,7 +955,7 @@ window.__ModuleLoader__.load({
|
|
|
640
955
|
}
|
|
641
956
|
|
|
642
957
|
/**
|
|
643
|
-
* Which mounted contribution answers the next Cmd/Ctrl+
|
|
958
|
+
* Which mounted contribution answers the next Cmd/Ctrl+F.
|
|
644
959
|
* Pointing at a preview claims it, exactly like dsh-pdf-search's Ctrl+F.
|
|
645
960
|
*/
|
|
646
961
|
let activeOwner
|
|
@@ -741,51 +1056,17 @@ window.__ModuleLoader__.load({
|
|
|
741
1056
|
userSelect: 'text',
|
|
742
1057
|
}
|
|
743
1058
|
|
|
744
|
-
const
|
|
745
|
-
|
|
746
|
-
margin: '0',
|
|
747
|
-
padding: '0',
|
|
748
|
-
maxHeight: '46vh',
|
|
749
|
-
overflowY: 'auto',
|
|
750
|
-
overscrollBehavior: 'contain',
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
const RESULT_ROW_STYLE = {
|
|
754
|
-
display: 'flex',
|
|
755
|
-
alignItems: 'center',
|
|
756
|
-
gap: '4px',
|
|
757
|
-
borderTop: '1px solid rgba(128,128,128,.18)',
|
|
758
|
-
padding: '2px 0',
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
const RESULT_JUMP_STYLE = {
|
|
762
|
-
flex: '1 1 auto',
|
|
763
|
-
minWidth: '0',
|
|
764
|
-
padding: '3px 2px',
|
|
765
|
-
border: 'none',
|
|
766
|
-
background: 'transparent',
|
|
767
|
-
color: 'inherit',
|
|
768
|
-
font: 'inherit',
|
|
769
|
-
textAlign: 'left',
|
|
770
|
-
cursor: 'pointer',
|
|
771
|
-
overflow: 'hidden',
|
|
772
|
-
whiteSpace: 'nowrap',
|
|
773
|
-
textOverflow: 'ellipsis',
|
|
774
|
-
borderRadius: '4px',
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
const AI_BUTTON_STYLE = {
|
|
1059
|
+
const SEARCH_COUNT_STYLE = {
|
|
1060
|
+
minWidth: '42px',
|
|
778
1061
|
flex: '0 0 auto',
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
color: 'inherit',
|
|
784
|
-
font: 'inherit',
|
|
785
|
-
cursor: 'pointer',
|
|
786
|
-
opacity: 0.9,
|
|
1062
|
+
textAlign: 'center',
|
|
1063
|
+
fontVariantNumeric: 'tabular-nums',
|
|
1064
|
+
opacity: 0.8,
|
|
1065
|
+
fontSize: '11px',
|
|
787
1066
|
}
|
|
788
1067
|
|
|
1068
|
+
const SEARCH_HINT_STYLE = { ...MUTED_STYLE, fontSize: '10px' }
|
|
1069
|
+
|
|
789
1070
|
/**
|
|
790
1071
|
* The Client locale service, attached by `apply`. Absent in headless tests,
|
|
791
1072
|
* where the language then falls back to the browser's own preference.
|
|
@@ -832,8 +1113,8 @@ window.__ModuleLoader__.load({
|
|
|
832
1113
|
en: ['Page number', 'Type a page and press Enter; printed page by default, prefix p for a PDF page'],
|
|
833
1114
|
},
|
|
834
1115
|
search: {
|
|
835
|
-
zh: ['搜索', '搜索这个 PDF 的文字层(Cmd/Ctrl+
|
|
836
|
-
en: ['Search', "Search this PDF's text layer (Cmd/Ctrl+
|
|
1116
|
+
zh: ['搜索', '搜索这个 PDF 的文字层(Cmd/Ctrl+F)'],
|
|
1117
|
+
en: ['Search', "Search this PDF's text layer (Cmd/Ctrl+F)"],
|
|
837
1118
|
},
|
|
838
1119
|
follow: {
|
|
839
1120
|
zh: ['AI 上下文', '把当前阅读页报告给模型'],
|
|
@@ -843,14 +1124,32 @@ window.__ModuleLoader__.load({
|
|
|
843
1124
|
zh: ['关闭搜索', '收起搜索面板(Esc)'],
|
|
844
1125
|
en: ['Close search', 'Close the search panel (Esc)'],
|
|
845
1126
|
},
|
|
846
|
-
|
|
847
|
-
zh: ['
|
|
848
|
-
en: ['
|
|
1127
|
+
prevHit: {
|
|
1128
|
+
zh: ['上一个匹配', '跳到上一个命中(Shift+Enter)'],
|
|
1129
|
+
en: ['Previous match', 'Jump to the previous occurrence (Shift+Enter)'],
|
|
1130
|
+
},
|
|
1131
|
+
nextHit: {
|
|
1132
|
+
zh: ['下一个匹配', '跳到下一个命中(Enter)'],
|
|
1133
|
+
en: ['Next match', 'Jump to the next occurrence (Enter)'],
|
|
849
1134
|
},
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/** Find-bar copy, in both UI languages. */
|
|
1138
|
+
const SEARCH_COPY = {
|
|
1139
|
+
label: { zh: '搜索这个 PDF', en: 'Search this PDF' },
|
|
1140
|
+
hint: {
|
|
1141
|
+
zh: 'Enter 下一个 · Shift+Enter 上一个 · Esc 关闭',
|
|
1142
|
+
en: 'Enter next · Shift+Enter previous · Esc close',
|
|
853
1143
|
},
|
|
1144
|
+
prev: { zh: '上一个匹配', en: 'Previous match' },
|
|
1145
|
+
next: { zh: '下一个匹配', en: 'Next match' },
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/** @returns one find-bar string in the active UI language. */
|
|
1149
|
+
function searchCopy(key) {
|
|
1150
|
+
const entry = SEARCH_COPY[key]
|
|
1151
|
+
if (entry === undefined) return ''
|
|
1152
|
+
return entry[activeLanguage()] ?? entry.en
|
|
854
1153
|
}
|
|
855
1154
|
|
|
856
1155
|
/** @returns the localized explanation body for the follow control. */
|
|
@@ -923,21 +1222,6 @@ window.__ModuleLoader__.load({
|
|
|
923
1222
|
h('circle', { cx: 7, cy: 7, r: 4.4, stroke: 'currentColor', strokeWidth: 1.6 }),
|
|
924
1223
|
h('path', { d: 'M10.4 10.4 14.2 14.2', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round' }))
|
|
925
1224
|
|
|
926
|
-
/**
|
|
927
|
-
* Copy plain text, used only when the composer insertion is unavailable.
|
|
928
|
-
* @returns whether the clipboard write succeeded.
|
|
929
|
-
*/
|
|
930
|
-
async function copyText(text) {
|
|
931
|
-
try {
|
|
932
|
-
if (typeof navigator === 'undefined' || navigator.clipboard === undefined) return false
|
|
933
|
-
if (typeof navigator.clipboard.writeText !== 'function') return false
|
|
934
|
-
await navigator.clipboard.writeText(text)
|
|
935
|
-
return true
|
|
936
|
-
} catch (_error) {
|
|
937
|
-
return false
|
|
938
|
-
}
|
|
939
|
-
}
|
|
940
|
-
|
|
941
1225
|
/**
|
|
942
1226
|
* The header contribution for one PDF preview.
|
|
943
1227
|
* @param props - Session-scope standard props plus the preview's `absolutePath`.
|
|
@@ -945,7 +1229,6 @@ window.__ModuleLoader__.load({
|
|
|
945
1229
|
function PdfReaderAction(props) {
|
|
946
1230
|
const absolutePath = typeof props?.absolutePath === 'string' ? props.absolutePath : undefined
|
|
947
1231
|
const isPdf = isPdfPath(absolutePath)
|
|
948
|
-
const inputActions = props?.inputActions
|
|
949
1232
|
const sessionId = typeof props?.sessionId === 'string' ? props.sessionId : undefined
|
|
950
1233
|
|
|
951
1234
|
const hostRef = useRef(null)
|
|
@@ -953,7 +1236,6 @@ window.__ModuleLoader__.load({
|
|
|
953
1236
|
const searchInputRef = useRef(null)
|
|
954
1237
|
const ownerRef = useRef(null)
|
|
955
1238
|
if (ownerRef.current === null) ownerRef.current = Symbol('dsh-pdf-reader')
|
|
956
|
-
const focusTimerRef = useRef(null)
|
|
957
1239
|
const flashTimerRef = useRef(null)
|
|
958
1240
|
const reportAbortRef = useRef(null)
|
|
959
1241
|
const reportSeqRef = useRef(0)
|
|
@@ -983,6 +1265,7 @@ window.__ModuleLoader__.load({
|
|
|
983
1265
|
const [open, setOpen] = useState(false)
|
|
984
1266
|
const [query, setQuery] = useState('')
|
|
985
1267
|
const [hits, setHits] = useState(null)
|
|
1268
|
+
const [active, setActive] = useState(0)
|
|
986
1269
|
const [searching, setSearching] = useState(false)
|
|
987
1270
|
const [searchError, setSearchError] = useState(null)
|
|
988
1271
|
const [tip, setTip] = useState(null)
|
|
@@ -1052,41 +1335,51 @@ window.__ModuleLoader__.load({
|
|
|
1052
1335
|
|
|
1053
1336
|
/** Focus the search input; keep the query highlighted for quick replacement. */
|
|
1054
1337
|
const openSearch = () => {
|
|
1338
|
+
const input = searchInputRef.current
|
|
1339
|
+
if (input === null) {
|
|
1340
|
+
// The panel is not mounted yet, so this is a fresh open: start clean
|
|
1341
|
+
// and let the search effect re-query the kept query.
|
|
1342
|
+
setHits(null)
|
|
1343
|
+
setActive(0)
|
|
1344
|
+
setSearching(false)
|
|
1345
|
+
setSearchError(null)
|
|
1346
|
+
}
|
|
1055
1347
|
setOpen(true)
|
|
1056
|
-
if (
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
searchInputRef.current?.select()
|
|
1061
|
-
}, 0)
|
|
1348
|
+
if (input !== null) {
|
|
1349
|
+
input.focus()
|
|
1350
|
+
input.select()
|
|
1351
|
+
}
|
|
1062
1352
|
}
|
|
1063
1353
|
|
|
1064
1354
|
/** Close the panel and give focus back to the header button. */
|
|
1065
1355
|
const closeSearch = () => {
|
|
1066
1356
|
setOpen(false)
|
|
1067
|
-
if (focusTimerRef.current !== null) {
|
|
1068
|
-
globalThis.clearTimeout(focusTimerRef.current)
|
|
1069
|
-
focusTimerRef.current = null
|
|
1070
|
-
}
|
|
1071
1357
|
searchButtonRef.current?.focus()
|
|
1072
1358
|
}
|
|
1073
1359
|
|
|
1074
1360
|
/**
|
|
1075
|
-
*
|
|
1076
|
-
*
|
|
1077
|
-
* is
|
|
1361
|
+
* Highlighted navigation helpers.
|
|
1362
|
+
*
|
|
1363
|
+
* Search is deliberately the most basic kind: one query, a list of Host
|
|
1364
|
+
* hits, and previous/next stepping that wraps at both ends. The counter
|
|
1365
|
+
* updates immediately; the in-page highlight effect waits for the page's
|
|
1366
|
+
* lazily rendered text layer.
|
|
1078
1367
|
*/
|
|
1079
|
-
const
|
|
1080
|
-
if (
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1368
|
+
const navigateHits = (list, index) => {
|
|
1369
|
+
if (!Array.isArray(list) || list.length === 0) return false
|
|
1370
|
+
const count = list.length
|
|
1371
|
+
const requested = Number.isFinite(index) ? Math.trunc(index) : 0
|
|
1372
|
+
const next = ((requested % count) + count) % count
|
|
1373
|
+
const hit = list[next]
|
|
1374
|
+
if (hit === undefined) return false
|
|
1375
|
+
jumpTo(hit.pdf)
|
|
1376
|
+
setActive(next)
|
|
1377
|
+
return true
|
|
1088
1378
|
}
|
|
1089
1379
|
|
|
1380
|
+
/** Move to an absolute hit index (the caller adds or subtracts one). */
|
|
1381
|
+
const navigate = (index) => navigateHits(hits, index)
|
|
1382
|
+
|
|
1090
1383
|
/** Scroll a physical page into view and update the readout immediately. */
|
|
1091
1384
|
const jumpTo = (target) => {
|
|
1092
1385
|
if (!positiveInteger(target)) return false
|
|
@@ -1145,15 +1438,12 @@ window.__ModuleLoader__.load({
|
|
|
1145
1438
|
setOpen(false)
|
|
1146
1439
|
setQuery('')
|
|
1147
1440
|
setHits(null)
|
|
1441
|
+
setActive(0)
|
|
1148
1442
|
setSearching(false)
|
|
1149
1443
|
setSearchError(null)
|
|
1150
1444
|
lastReportedKeyRef.current = undefined
|
|
1151
1445
|
pageRef.current = 1
|
|
1152
1446
|
reportAbortRef.current?.abort()
|
|
1153
|
-
if (focusTimerRef.current !== null) {
|
|
1154
|
-
globalThis.clearTimeout(focusTimerRef.current)
|
|
1155
|
-
focusTimerRef.current = null
|
|
1156
|
-
}
|
|
1157
1447
|
if (flashTimerRef.current !== null) {
|
|
1158
1448
|
globalThis.clearTimeout(flashTimerRef.current)
|
|
1159
1449
|
flashTimerRef.current = null
|
|
@@ -1608,29 +1898,40 @@ window.__ModuleLoader__.load({
|
|
|
1608
1898
|
/**
|
|
1609
1899
|
* Scroll the saved page into view, then re-apply once. The second scroll
|
|
1610
1900
|
* absorbs pages that were 3:4 placeholders and grew when lazily rendered.
|
|
1901
|
+
*
|
|
1902
|
+
* With `finishGate: false` the page jump happens immediately but the save
|
|
1903
|
+
* gate stays closed — the caller restores zoom afterwards and then
|
|
1904
|
+
* calls `finish()`. That keeps the viewer from showing page 1 while a
|
|
1905
|
+
* fixed zoom is being replayed gesture by gesture.
|
|
1906
|
+
* @param options - `{ finishGate }`, default true.
|
|
1907
|
+
* @returns whether the scroll was applied.
|
|
1611
1908
|
*/
|
|
1612
|
-
const placePage = () => {
|
|
1613
|
-
if (disposed || restoreTarget === undefined) return
|
|
1909
|
+
const placePage = ({ finishGate = true } = {}) => {
|
|
1910
|
+
if (disposed || restoreTarget === undefined) return false
|
|
1614
1911
|
const target = restoreTarget.pdfPage
|
|
1615
1912
|
if (!refresh() || !scrollPageIntoView(pane, scrollport, target)) {
|
|
1616
1913
|
retryRestore()
|
|
1617
|
-
return
|
|
1914
|
+
return false
|
|
1618
1915
|
}
|
|
1619
1916
|
// The save debounce reads this ref, and the restore runs from a timer
|
|
1620
1917
|
// (not a React event), so publish the page before state can render.
|
|
1621
1918
|
pageRef.current = target
|
|
1622
1919
|
setPage(target)
|
|
1623
1920
|
setSampled(true)
|
|
1624
|
-
finish()
|
|
1921
|
+
if (finishGate) finish()
|
|
1625
1922
|
if (reapplyTimer !== undefined) globalThis.clearTimeout(reapplyTimer)
|
|
1626
1923
|
reapplyTimer = globalThis.setTimeout(() => {
|
|
1627
1924
|
reapplyTimer = undefined
|
|
1628
1925
|
if (disposed || !refresh()) return
|
|
1629
1926
|
scrollPageIntoView(pane, scrollport, target)
|
|
1630
1927
|
}, VIEW_REAPPLY_MS)
|
|
1928
|
+
return true
|
|
1631
1929
|
}
|
|
1632
1930
|
|
|
1633
|
-
/**
|
|
1931
|
+
/**
|
|
1932
|
+
* Let the renderer's 120 ms zoom settlement commit, then scroll again so
|
|
1933
|
+
* the saved page is aligned after the zoom reflowed every page.
|
|
1934
|
+
*/
|
|
1634
1935
|
const settleZoom = () => {
|
|
1635
1936
|
if (zoomTimer !== undefined) globalThis.clearTimeout(zoomTimer)
|
|
1636
1937
|
zoomTimer = globalThis.setTimeout(() => {
|
|
@@ -1649,7 +1950,7 @@ window.__ModuleLoader__.load({
|
|
|
1649
1950
|
if (disposed || restoreTarget === undefined) return
|
|
1650
1951
|
const target = restoreTarget.zoomScale
|
|
1651
1952
|
if (!refresh() || frame === undefined || scrollport === undefined) {
|
|
1652
|
-
|
|
1953
|
+
finish()
|
|
1653
1954
|
return
|
|
1654
1955
|
}
|
|
1655
1956
|
const current = frameZoomScale(frame)
|
|
@@ -1658,7 +1959,7 @@ window.__ModuleLoader__.load({
|
|
|
1658
1959
|
// Same scale, fit-width mode: a zero-delta gesture only asks the
|
|
1659
1960
|
// renderer to commit `{kind:'fixed'}` at the current scale.
|
|
1660
1961
|
if (!dispatchZoomWheel(scrollport, 0)) {
|
|
1661
|
-
|
|
1962
|
+
finish()
|
|
1662
1963
|
return
|
|
1663
1964
|
}
|
|
1664
1965
|
}
|
|
@@ -1699,6 +2000,8 @@ window.__ModuleLoader__.load({
|
|
|
1699
2000
|
retryRestore()
|
|
1700
2001
|
return
|
|
1701
2002
|
}
|
|
2003
|
+
// Jump first so the cover never flashes while a fixed zoom is replayed.
|
|
2004
|
+
if (!placePage({ finishGate: false })) return
|
|
1702
2005
|
if (target.zoomMode === 'fixed' && frame !== undefined && scrollport !== undefined) {
|
|
1703
2006
|
if (Math.abs(frameZoomScale(frame) / target.zoomScale - 1) > VIEW_ZOOM_TOLERANCE
|
|
1704
2007
|
|| frameZoomMode(frame) !== 'fixed') {
|
|
@@ -1706,7 +2009,7 @@ window.__ModuleLoader__.load({
|
|
|
1706
2009
|
return
|
|
1707
2010
|
}
|
|
1708
2011
|
}
|
|
1709
|
-
|
|
2012
|
+
finish()
|
|
1710
2013
|
}
|
|
1711
2014
|
|
|
1712
2015
|
const loadSavedView = async () => {
|
|
@@ -1841,17 +2144,21 @@ window.__ModuleLoader__.load({
|
|
|
1841
2144
|
}, [isPdf, absolutePath, sampled, page, printed, sessionId, follow])
|
|
1842
2145
|
|
|
1843
2146
|
// Search: type, wait 220 ms, query the Host route, cancel the stale query.
|
|
2147
|
+
// A new query starts from a clean slate: no stale highlights or counter.
|
|
1844
2148
|
useEffect(() => {
|
|
1845
2149
|
if (!open || !isPdf || absolutePath === undefined) return undefined
|
|
1846
2150
|
const needle = query.trim()
|
|
1847
2151
|
if (needle === '') {
|
|
1848
2152
|
setHits(null)
|
|
2153
|
+
setActive(0)
|
|
1849
2154
|
setSearchError(null)
|
|
1850
2155
|
setSearching(false)
|
|
1851
2156
|
return undefined
|
|
1852
2157
|
}
|
|
1853
2158
|
setSearching(true)
|
|
1854
2159
|
setSearchError(null)
|
|
2160
|
+
setHits(null)
|
|
2161
|
+
setActive(0)
|
|
1855
2162
|
let disposed = false
|
|
1856
2163
|
let timedOut = false
|
|
1857
2164
|
let timer
|
|
@@ -1869,6 +2176,7 @@ window.__ModuleLoader__.load({
|
|
|
1869
2176
|
if (disposed) return
|
|
1870
2177
|
if (body === null || typeof body !== 'object' || body.ok !== true) {
|
|
1871
2178
|
setHits([])
|
|
2179
|
+
setActive(0)
|
|
1872
2180
|
setSearchError(describeSearchFailure(body, status))
|
|
1873
2181
|
return
|
|
1874
2182
|
}
|
|
@@ -1877,10 +2185,18 @@ window.__ModuleLoader__.load({
|
|
|
1877
2185
|
: []
|
|
1878
2186
|
setHits(list)
|
|
1879
2187
|
setSearchError(null)
|
|
2188
|
+
if (list.length > 0) {
|
|
2189
|
+
// Basic find behavior: the first occurrence becomes current and
|
|
2190
|
+
// the preview immediately jumps to its page.
|
|
2191
|
+
navigateHits(list, 0)
|
|
2192
|
+
} else {
|
|
2193
|
+
setActive(0)
|
|
2194
|
+
}
|
|
1880
2195
|
})
|
|
1881
2196
|
.catch((error) => {
|
|
1882
2197
|
if (disposed || (isAbortError(error) && !timedOut)) return
|
|
1883
2198
|
setHits([])
|
|
2199
|
+
setActive(0)
|
|
1884
2200
|
setSearchError(timedOut ? 'Search timed out.' : 'Search unavailable.')
|
|
1885
2201
|
})
|
|
1886
2202
|
.finally(() => {
|
|
@@ -1897,14 +2213,106 @@ window.__ModuleLoader__.load({
|
|
|
1897
2213
|
}
|
|
1898
2214
|
}, [open, isPdf, absolutePath, query])
|
|
1899
2215
|
|
|
1900
|
-
//
|
|
1901
|
-
//
|
|
2216
|
+
// In-page highlights: locate each hit page's text layer, map the query to
|
|
2217
|
+
// Ranges, and rebuild when pdf.js renders or re-renders a page (zoom).
|
|
2218
|
+
//
|
|
2219
|
+
// The text layer arrives lazily, so the first attempt retries for about
|
|
2220
|
+
// three seconds. Once the active page has a text layer, the timers stop
|
|
2221
|
+
// and the MutationObserver keeps the highlights in sync. Every path is
|
|
2222
|
+
// guarded: highlighting must never break reading or search itself.
|
|
2223
|
+
useEffect(() => {
|
|
2224
|
+
if (!open || !isPdf || absolutePath === undefined) return undefined
|
|
2225
|
+
const needle = query.trim()
|
|
2226
|
+
if (searching || searchError !== null || hits === null || hits.length === 0 || needle === '') {
|
|
2227
|
+
clearSearchHighlights(resolvePane(hostRef.current))
|
|
2228
|
+
return undefined
|
|
2229
|
+
}
|
|
2230
|
+
const host = hostRef.current
|
|
2231
|
+
if (host === null) return undefined
|
|
2232
|
+
const list = hits
|
|
2233
|
+
const current = Number.isInteger(active) ? active : 0
|
|
2234
|
+
const activePage = list[Math.min(Math.max(current, 0), list.length - 1)]?.pdf
|
|
2235
|
+
|
|
2236
|
+
let disposed = false
|
|
2237
|
+
let observer
|
|
2238
|
+
let observedPane
|
|
2239
|
+
let retryTimer
|
|
2240
|
+
let retryCount = 0
|
|
2241
|
+
let rebuildTimer
|
|
2242
|
+
|
|
2243
|
+
const activeTextReady = () => {
|
|
2244
|
+
if (activePage === undefined) return true
|
|
2245
|
+
const pane = resolvePane(host)
|
|
2246
|
+
if (pane === undefined) return false
|
|
2247
|
+
const page = pane.querySelector(`[${PAGE_ATTR}="${activePage}"]`)
|
|
2248
|
+
if (page === null || page === undefined) return false
|
|
2249
|
+
return page.querySelector(`[${TEXT_LAYER_ATTR}]`) !== null
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
const observe = (pane) => {
|
|
2253
|
+
if (pane === observedPane) return
|
|
2254
|
+
observer?.disconnect()
|
|
2255
|
+
observer = undefined
|
|
2256
|
+
observedPane = pane
|
|
2257
|
+
if (typeof MutationObserver !== 'function') return
|
|
2258
|
+
try {
|
|
2259
|
+
observer = new MutationObserver(() => {
|
|
2260
|
+
scheduleRebuild()
|
|
2261
|
+
})
|
|
2262
|
+
observer.observe(pane, { childList: true, subtree: true, characterData: true })
|
|
2263
|
+
} catch (_error) {
|
|
2264
|
+
observer = undefined
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
const rebuild = () => {
|
|
2269
|
+
if (disposed) return
|
|
2270
|
+
const pane = resolvePane(host)
|
|
2271
|
+
if (pane === undefined) return
|
|
2272
|
+
observe(pane)
|
|
2273
|
+
applySearchHighlights(pane, needle, list, current)
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
const scheduleRebuild = () => {
|
|
2277
|
+
if (disposed || rebuildTimer !== undefined) return
|
|
2278
|
+
rebuildTimer = globalThis.setTimeout(() => {
|
|
2279
|
+
rebuildTimer = undefined
|
|
2280
|
+
rebuild()
|
|
2281
|
+
}, SEARCH_HIGHLIGHT_REBUILD_MS)
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
const retry = () => {
|
|
2285
|
+
if (disposed || retryTimer !== undefined || retryCount >= SEARCH_HIGHLIGHT_RETRY_LIMIT) return
|
|
2286
|
+
retryCount += 1
|
|
2287
|
+
retryTimer = globalThis.setTimeout(() => {
|
|
2288
|
+
retryTimer = undefined
|
|
2289
|
+
if (disposed) return
|
|
2290
|
+
rebuild()
|
|
2291
|
+
if (!activeTextReady()) retry()
|
|
2292
|
+
}, SEARCH_HIGHLIGHT_RETRY_MS)
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
rebuild()
|
|
2296
|
+
if (!activeTextReady()) retry()
|
|
2297
|
+
|
|
2298
|
+
return () => {
|
|
2299
|
+
disposed = true
|
|
2300
|
+
observer?.disconnect()
|
|
2301
|
+
observer = undefined
|
|
2302
|
+
if (retryTimer !== undefined) globalThis.clearTimeout(retryTimer)
|
|
2303
|
+
if (rebuildTimer !== undefined) globalThis.clearTimeout(rebuildTimer)
|
|
2304
|
+
clearSearchHighlights(resolvePane(host))
|
|
2305
|
+
}
|
|
2306
|
+
}, [open, isPdf, absolutePath, query, hits, active, searching, searchError])
|
|
2307
|
+
|
|
2308
|
+
// Cmd/Ctrl+F (with or without Shift) opens our find bar. The first panel
|
|
2309
|
+
// claims the shortcut; pointing at another preview claims it for that one.
|
|
1902
2310
|
useEffect(() => {
|
|
1903
2311
|
if (!isPdf) return undefined
|
|
1904
2312
|
const owner = ownerRef.current
|
|
1905
2313
|
const onKeyDown = (event) => {
|
|
1906
2314
|
if (event.defaultPrevented || event.repeat || event.isComposing) return
|
|
1907
|
-
if (!(event.metaKey || event.ctrlKey) || event.altKey
|
|
2315
|
+
if (!(event.metaKey || event.ctrlKey) || event.altKey) return
|
|
1908
2316
|
if (String(event.key).toLowerCase() !== 'f') return
|
|
1909
2317
|
if (activeOwner === undefined) activeOwner = owner
|
|
1910
2318
|
if (activeOwner !== owner) return
|
|
@@ -1927,9 +2335,24 @@ window.__ModuleLoader__.load({
|
|
|
1927
2335
|
}
|
|
1928
2336
|
}, [isPdf])
|
|
1929
2337
|
|
|
2338
|
+
// Focus and select the find input once the panel has actually mounted.
|
|
2339
|
+
// Doing this in an effect (instead of the click handler's timer) also
|
|
2340
|
+
// covers the very first open, when the input ref does not exist yet.
|
|
2341
|
+
useEffect(() => {
|
|
2342
|
+
if (!open) return undefined
|
|
2343
|
+
const input = searchInputRef.current
|
|
2344
|
+
if (input === null) return undefined
|
|
2345
|
+
try {
|
|
2346
|
+
input.focus()
|
|
2347
|
+
input.select()
|
|
2348
|
+
} catch (_error) {
|
|
2349
|
+
// Focus is best effort (for example while the tab is hidden).
|
|
2350
|
+
}
|
|
2351
|
+
return undefined
|
|
2352
|
+
}, [open])
|
|
2353
|
+
|
|
1930
2354
|
// Timers that outlive a render and must not outlive the component.
|
|
1931
2355
|
useEffect(() => () => {
|
|
1932
|
-
if (focusTimerRef.current !== null) globalThis.clearTimeout(focusTimerRef.current)
|
|
1933
2356
|
if (flashTimerRef.current !== null) globalThis.clearTimeout(flashTimerRef.current)
|
|
1934
2357
|
if (tipTimerRef.current !== null) globalThis.clearTimeout(tipTimerRef.current)
|
|
1935
2358
|
}, [])
|
|
@@ -2094,51 +2517,16 @@ window.__ModuleLoader__.load({
|
|
|
2094
2517
|
|
|
2095
2518
|
let panel = null
|
|
2096
2519
|
if (open) {
|
|
2097
|
-
const
|
|
2098
|
-
const
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
? searchError
|
|
2102
|
-
: needle === ''
|
|
2103
|
-
? `${total > 0 ? total : '?'} pages · type to search`
|
|
2104
|
-
: hits === null
|
|
2105
|
-
? ''
|
|
2106
|
-
: hits.length === 0
|
|
2107
|
-
? 'No matches — scanned PDFs have no text layer.'
|
|
2108
|
-
: `${hits.length}${hits.length >= SEARCH_LIMIT ? '+' : ''} result${hits.length === 1 ? '' : 's'}`
|
|
2109
|
-
const list = hits !== null && hits.length > 0
|
|
2110
|
-
? h('ul', { style: LIST_STYLE }, hits.map((hit, index) => {
|
|
2111
|
-
const label = searchResultLabel(hit)
|
|
2112
|
-
const snippet = normalizeSnippet(hit.snippet, 140)
|
|
2113
|
-
return h('li', {
|
|
2114
|
-
key: `${hit.pdf}:${index}`,
|
|
2115
|
-
style: RESULT_ROW_STYLE,
|
|
2116
|
-
},
|
|
2117
|
-
h('button', {
|
|
2118
|
-
type: 'button',
|
|
2119
|
-
'data-tip': 'resultJump',
|
|
2120
|
-
'data-dsh-pdf-reader': 'result',
|
|
2121
|
-
onClick: () => {
|
|
2122
|
-
jumpTo(hit.pdf)
|
|
2123
|
-
},
|
|
2124
|
-
style: RESULT_JUMP_STYLE,
|
|
2125
|
-
}, snippet === '' ? label : `${label} — ${snippet}`),
|
|
2126
|
-
h('button', {
|
|
2127
|
-
type: 'button',
|
|
2128
|
-
'data-tip': 'resultInsert',
|
|
2129
|
-
'data-dsh-pdf-reader': 'result-ai',
|
|
2130
|
-
onClick: () => {
|
|
2131
|
-
deliverReference(buildSearchReference(hit), 'Inserted')
|
|
2132
|
-
},
|
|
2133
|
-
style: AI_BUTTON_STYLE,
|
|
2134
|
-
}, '→ AI'))
|
|
2135
|
-
}))
|
|
2136
|
-
: null
|
|
2520
|
+
const hitCount = hits === null ? 0 : hits.length
|
|
2521
|
+
const shownHit = hitCount === 0 ? 0 : Math.min(Math.max(active, 0), hitCount - 1) + 1
|
|
2522
|
+
const noHits = hitCount === 0
|
|
2523
|
+
const searchDisabledStyle = noHits ? { opacity: 0.35 } : null
|
|
2137
2524
|
panel = h('div', {
|
|
2138
2525
|
key: 'panel',
|
|
2139
2526
|
style: PANEL_STYLE,
|
|
2140
2527
|
role: 'dialog',
|
|
2141
|
-
'aria-label': '
|
|
2528
|
+
'aria-label': searchCopy('label'),
|
|
2529
|
+
'aria-busy': searching,
|
|
2142
2530
|
'data-dsh-pdf-reader': 'panel',
|
|
2143
2531
|
onKeyDown: (event) => {
|
|
2144
2532
|
if (event.isComposing) return
|
|
@@ -2154,40 +2542,74 @@ window.__ModuleLoader__.load({
|
|
|
2154
2542
|
ref: searchInputRef,
|
|
2155
2543
|
type: 'search',
|
|
2156
2544
|
value: query,
|
|
2157
|
-
placeholder: '
|
|
2158
|
-
'aria-label': '
|
|
2545
|
+
placeholder: searchCopy('label'),
|
|
2546
|
+
'aria-label': searchCopy('label'),
|
|
2547
|
+
'data-dsh-pdf-reader': 'search-input',
|
|
2159
2548
|
spellCheck: false,
|
|
2160
2549
|
autoComplete: 'off',
|
|
2161
2550
|
maxLength: 1000,
|
|
2162
2551
|
style: PANEL_INPUT_STYLE,
|
|
2163
2552
|
onChange: (event) => {
|
|
2164
2553
|
setQuery(String(event.target.value))
|
|
2554
|
+
setHits(null)
|
|
2555
|
+
setActive(0)
|
|
2556
|
+
setSearchError(null)
|
|
2165
2557
|
},
|
|
2166
2558
|
onKeyDown: (event) => {
|
|
2167
2559
|
if (event.isComposing) return
|
|
2168
|
-
if (event.key === 'Enter'
|
|
2560
|
+
if (event.key === 'Enter') {
|
|
2169
2561
|
event.preventDefault()
|
|
2170
|
-
|
|
2562
|
+
event.stopPropagation()
|
|
2563
|
+
navigate(active + (event.shiftKey ? -1 : 1))
|
|
2171
2564
|
}
|
|
2172
2565
|
},
|
|
2173
2566
|
}),
|
|
2567
|
+
h('button', {
|
|
2568
|
+
type: 'button',
|
|
2569
|
+
'aria-label': searchCopy('prev'),
|
|
2570
|
+
'data-tip': 'prevHit',
|
|
2571
|
+
'data-dsh-pdf-reader': 'search-prev',
|
|
2572
|
+
disabled: noHits,
|
|
2573
|
+
onClick: () => {
|
|
2574
|
+
navigate(active - 1)
|
|
2575
|
+
},
|
|
2576
|
+
style: { ...BUTTON_STYLE, ...searchDisabledStyle },
|
|
2577
|
+
}, '‹'),
|
|
2578
|
+
h('span', {
|
|
2579
|
+
'data-dsh-pdf-reader': 'search-count',
|
|
2580
|
+
'aria-live': 'polite',
|
|
2581
|
+
style: SEARCH_COUNT_STYLE,
|
|
2582
|
+
}, `${shownHit} / ${hitCount}`),
|
|
2583
|
+
h('button', {
|
|
2584
|
+
type: 'button',
|
|
2585
|
+
'aria-label': searchCopy('next'),
|
|
2586
|
+
'data-tip': 'nextHit',
|
|
2587
|
+
'data-dsh-pdf-reader': 'search-next',
|
|
2588
|
+
disabled: noHits,
|
|
2589
|
+
onClick: () => {
|
|
2590
|
+
navigate(active + 1)
|
|
2591
|
+
},
|
|
2592
|
+
style: { ...BUTTON_STYLE, ...searchDisabledStyle },
|
|
2593
|
+
}, '›'),
|
|
2174
2594
|
h('button', {
|
|
2175
2595
|
type: 'button',
|
|
2176
2596
|
'aria-label': 'Close search',
|
|
2177
2597
|
'data-tip': 'closeSearch',
|
|
2598
|
+
'data-dsh-pdf-reader': 'search-close',
|
|
2178
2599
|
onClick: closeSearch,
|
|
2179
2600
|
style: BUTTON_STYLE,
|
|
2180
2601
|
}, '✕')),
|
|
2181
2602
|
h('div', {
|
|
2182
|
-
'
|
|
2183
|
-
style:
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2603
|
+
'data-dsh-pdf-reader': 'search-hint',
|
|
2604
|
+
style: SEARCH_HINT_STYLE,
|
|
2605
|
+
}, searchCopy('hint')),
|
|
2606
|
+
searchError === null
|
|
2607
|
+
? null
|
|
2608
|
+
: h('div', {
|
|
2609
|
+
'data-dsh-pdf-reader': 'search-error',
|
|
2610
|
+
role: 'status',
|
|
2611
|
+
style: { ...ERROR_STYLE, fontSize: '11px' },
|
|
2612
|
+
}, searchError))
|
|
2191
2613
|
}
|
|
2192
2614
|
|
|
2193
2615
|
controls.push(panel)
|
|
@@ -2248,20 +2670,19 @@ window.__ModuleLoader__.load({
|
|
|
2248
2670
|
exports.PdfReaderAction = PdfReaderAction
|
|
2249
2671
|
// Pure helpers exposed for the headless tests only; the loader ignores them.
|
|
2250
2672
|
exports.__test = {
|
|
2251
|
-
AI_BUTTON_STYLE,
|
|
2252
2673
|
PANEL_STYLE,
|
|
2253
|
-
|
|
2674
|
+
SEARCH_ACTIVE_HIGHLIGHT_NAME,
|
|
2675
|
+
SEARCH_HIGHLIGHT_NAME,
|
|
2254
2676
|
cachedInfo,
|
|
2255
2677
|
clampZoomScale,
|
|
2256
|
-
copyText,
|
|
2257
2678
|
createDebouncer,
|
|
2258
2679
|
createRafThrottle,
|
|
2259
2680
|
describeSearchFailure,
|
|
2260
2681
|
displayPage,
|
|
2261
2682
|
dispatchZoomWheel,
|
|
2262
2683
|
fetchJson,
|
|
2684
|
+
foldSearchText,
|
|
2263
2685
|
infoCache,
|
|
2264
|
-
insertComposerText,
|
|
2265
2686
|
isPdfPath,
|
|
2266
2687
|
normalizeInfo,
|
|
2267
2688
|
normalizeSnippet,
|
|
@@ -2277,8 +2698,6 @@ window.__ModuleLoader__.load({
|
|
|
2277
2698
|
resolveScrollport,
|
|
2278
2699
|
resolveZoomFrame,
|
|
2279
2700
|
scrollPageIntoView,
|
|
2280
|
-
searchResultLabel,
|
|
2281
|
-
searchResultPage,
|
|
2282
2701
|
}
|
|
2283
2702
|
return module.exports
|
|
2284
2703
|
},
|