dsh-book-reader 0.1.0 → 0.2.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 +41 -1
- package/README.md +35 -475
- package/README.zh-CN.md +55 -0
- package/SKILL.md +1 -2
- package/cordis.patch.yml +9 -8
- package/lib/client.js +513 -22
- package/lib/index.js +415 -104
- package/package.json +15 -6
package/lib/client.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
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
6
|
* - a search panel (Cmd/Ctrl+Shift+F) backed by the Host text route;
|
|
7
|
-
* - "→ AI"
|
|
7
|
+
* - "→ AI" insertion of a search hit into the chat composer;
|
|
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
|
|
@@ -31,10 +31,14 @@ window.__ModuleLoader__.load({
|
|
|
31
31
|
const INFO_ROUTE = '/dsh-pdf-reader/info'
|
|
32
32
|
const SEARCH_ROUTE = '/dsh-pdf-reader/search'
|
|
33
33
|
const CURRENT_ROUTE = '/dsh-pdf-reader/current'
|
|
34
|
+
const VIEW_ROUTE = '/dsh-pdf-reader/view'
|
|
34
35
|
|
|
35
36
|
/** DOM markers owned by the official preview (same ones dsh-pdf-search uses). */
|
|
36
37
|
const PANE_ATTR = 'data-textpreview-body'
|
|
37
38
|
const SCROLL_ATTR = 'data-document-zoom-scrollport'
|
|
39
|
+
const ZOOM_FRAME_ATTR = 'data-document-zoom-frame'
|
|
40
|
+
const ZOOM_MODE_ATTR = 'data-document-zoom-mode'
|
|
41
|
+
const ZOOM_VAR = '--document-zoom'
|
|
38
42
|
const PAGE_ATTR = 'data-pdf-page'
|
|
39
43
|
const PDF_SECTION_ATTR = 'data-pdf-preview'
|
|
40
44
|
|
|
@@ -48,6 +52,28 @@ window.__ModuleLoader__.load({
|
|
|
48
52
|
const SEARCH_LIMIT = 100
|
|
49
53
|
const INFO_CACHE_LIMIT = 16
|
|
50
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Reading-position memory timing.
|
|
57
|
+
*
|
|
58
|
+
* Restoring is best effort and must not block the preview: the saved view is
|
|
59
|
+
* fetched in parallel, then the structure/page markers are retried for about
|
|
60
|
+
* eight seconds. Saving starts only after restore finished (success or
|
|
61
|
+
* failure) so opening a book cannot overwrite its saved page with page 1.
|
|
62
|
+
* The save debounce is deliberately longer than the report debounce so a
|
|
63
|
+
* scroll settles before either write happens.
|
|
64
|
+
*/
|
|
65
|
+
const VIEW_TIMEOUT_MS = 6_000
|
|
66
|
+
const VIEW_SAVE_DELAY_MS = 500
|
|
67
|
+
const VIEW_RETRY_MS = 200
|
|
68
|
+
const VIEW_RETRY_LIMIT = 40
|
|
69
|
+
const VIEW_REAPPLY_MS = 300
|
|
70
|
+
const VIEW_ZOOM_STEP_MS = 50
|
|
71
|
+
const VIEW_ZOOM_SETTLE_MS = 160
|
|
72
|
+
const VIEW_ZOOM_EVENT_LIMIT = 20
|
|
73
|
+
const VIEW_ZOOM_TOLERANCE = 0.02
|
|
74
|
+
const VIEW_ZOOM_MIN = 0.25
|
|
75
|
+
const VIEW_ZOOM_MAX = 4
|
|
76
|
+
|
|
51
77
|
/** Non-PDF files are left entirely alone (Office previews have .docx paths). */
|
|
52
78
|
function isPdfPath(value) {
|
|
53
79
|
return typeof value === 'string' && /\.pdf$/iu.test(value)
|
|
@@ -134,11 +160,6 @@ window.__ModuleLoader__.load({
|
|
|
134
160
|
return page === undefined ? 'p.?' : `p.${page}`
|
|
135
161
|
}
|
|
136
162
|
|
|
137
|
-
/** @returns `[PDF p.132]` for the share button. */
|
|
138
|
-
function buildShareReference(pdfPage, printedOffset) {
|
|
139
|
-
return `[PDF p.${displayPage(pdfPage, printedOffset)}]`
|
|
140
|
-
}
|
|
141
|
-
|
|
142
163
|
/** @returns `PDF p.132: <snippet>` (or `[PDF p.132]` when there is no snippet). */
|
|
143
164
|
function buildSearchReference(hit) {
|
|
144
165
|
const page = searchResultPage(hit)
|
|
@@ -272,6 +293,29 @@ window.__ModuleLoader__.load({
|
|
|
272
293
|
return undefined
|
|
273
294
|
}
|
|
274
295
|
|
|
296
|
+
/**
|
|
297
|
+
* Find the zoom wrapper that owns the preview scrollport.
|
|
298
|
+
*
|
|
299
|
+
* The PDF renderer nests the frame inside the preview body, so the frame is
|
|
300
|
+
* usually the scrollport's closest ancestor. A pane descendant is also
|
|
301
|
+
* checked so a renderer reshuffle degrades to "no zoom restore" instead of
|
|
302
|
+
* failing the page restore.
|
|
303
|
+
* @param pane - preview body.
|
|
304
|
+
* @param scrollport - inner scrolling element, when one exists.
|
|
305
|
+
* @returns the zoom frame, or `undefined` for a renderer without zoom.
|
|
306
|
+
*/
|
|
307
|
+
function resolveZoomFrame(pane, scrollport) {
|
|
308
|
+
if (scrollport !== null && scrollport !== undefined && typeof scrollport.closest === 'function') {
|
|
309
|
+
const nearest = scrollport.closest(`[${ZOOM_FRAME_ATTR}]`)
|
|
310
|
+
if (isElement(nearest)) return nearest
|
|
311
|
+
}
|
|
312
|
+
if (pane !== null && pane !== undefined && typeof pane.querySelector === 'function') {
|
|
313
|
+
const nested = pane.querySelector(`[${ZOOM_FRAME_ATTR}]`)
|
|
314
|
+
if (isElement(nested)) return nested
|
|
315
|
+
}
|
|
316
|
+
return undefined
|
|
317
|
+
}
|
|
318
|
+
|
|
275
319
|
/**
|
|
276
320
|
* Scroll one page near the top of the preview.
|
|
277
321
|
*
|
|
@@ -305,6 +349,118 @@ window.__ModuleLoader__.load({
|
|
|
305
349
|
return false
|
|
306
350
|
}
|
|
307
351
|
|
|
352
|
+
/**
|
|
353
|
+
* Ask the official preview to change zoom through its own gesture path.
|
|
354
|
+
*
|
|
355
|
+
* The preview implements zoom as a native `wheel` listener on the scrollport:
|
|
356
|
+
* it only reacts to `ctrlKey`, multiplies the current scale by
|
|
357
|
+
* `exp(-deltaY * 0.01)`, clamps `deltaY` to ±40, and commits the fixed
|
|
358
|
+
* preference 120 ms after the last event. Dispatching that synthetic event
|
|
359
|
+
* is therefore the only supported way for a plugin to set an arbitrary
|
|
360
|
+
* scale (0.25..4) without writing React state it does not own.
|
|
361
|
+
* @param scrollport - the preview's inner scrolling element.
|
|
362
|
+
* @param deltaY - wheel delta; negative zooms in.
|
|
363
|
+
* @returns whether an event was dispatched.
|
|
364
|
+
*/
|
|
365
|
+
function dispatchZoomWheel(scrollport, deltaY) {
|
|
366
|
+
if (scrollport === null || scrollport === undefined || typeof scrollport.dispatchEvent !== 'function') {
|
|
367
|
+
return false
|
|
368
|
+
}
|
|
369
|
+
const view = scrollport.ownerDocument?.defaultView
|
|
370
|
+
const Wheel = view?.WheelEvent ?? (typeof WheelEvent === 'function' ? WheelEvent : undefined)
|
|
371
|
+
if (typeof Wheel !== 'function') return false
|
|
372
|
+
let event
|
|
373
|
+
try {
|
|
374
|
+
event = new Wheel('wheel', {
|
|
375
|
+
ctrlKey: true,
|
|
376
|
+
deltaY,
|
|
377
|
+
deltaMode: 0,
|
|
378
|
+
clientX: 0,
|
|
379
|
+
clientY: 0,
|
|
380
|
+
bubbles: true,
|
|
381
|
+
cancelable: true,
|
|
382
|
+
})
|
|
383
|
+
} catch (_error) {
|
|
384
|
+
return false
|
|
385
|
+
}
|
|
386
|
+
try {
|
|
387
|
+
scrollport.dispatchEvent(event)
|
|
388
|
+
} catch (_error) {
|
|
389
|
+
return false
|
|
390
|
+
}
|
|
391
|
+
return true
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Clamp one zoom ratio into the range the preview and the Host agree on.
|
|
396
|
+
* @param value - number or CSS custom-property string.
|
|
397
|
+
* @returns a 3-decimal scale in 0.25..4, or `undefined` when unreadable.
|
|
398
|
+
*/
|
|
399
|
+
function clampZoomScale(value) {
|
|
400
|
+
const numeric = typeof value === 'number' ? value : Number.parseFloat(String(value ?? ''))
|
|
401
|
+
if (!Number.isFinite(numeric)) return undefined
|
|
402
|
+
return Math.round(Math.min(VIEW_ZOOM_MAX, Math.max(VIEW_ZOOM_MIN, numeric)) * 1000) / 1000
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Normalize one `GET /dsh-pdf-reader/view` payload.
|
|
407
|
+
*
|
|
408
|
+
* A missing row, a 404, and a malformed row all mean "nothing to restore";
|
|
409
|
+
* the Host owns validation, so the client only keeps values it can act on.
|
|
410
|
+
* @param body - parsed response body.
|
|
411
|
+
* @returns `{ pdfPage, zoomMode, zoomScale }`, or `undefined`.
|
|
412
|
+
*/
|
|
413
|
+
function normalizeView(body) {
|
|
414
|
+
if (body === null || typeof body !== 'object' || body.ok !== true) return undefined
|
|
415
|
+
const view = body.view
|
|
416
|
+
if (view === null || typeof view !== 'object') return undefined
|
|
417
|
+
if (!positiveInteger(view.pdfPage)) return undefined
|
|
418
|
+
return {
|
|
419
|
+
pdfPage: view.pdfPage,
|
|
420
|
+
zoomMode: view.zoomMode === 'fixed' ? 'fixed' : 'fit-width',
|
|
421
|
+
zoomScale: clampZoomScale(view.zoomScale) ?? 1,
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** @returns the zoom mode the preview frame currently reports. */
|
|
426
|
+
function frameZoomMode(frame) {
|
|
427
|
+
if (frame === null || frame === undefined || typeof frame.getAttribute !== 'function') return 'fit-width'
|
|
428
|
+
return frame.getAttribute(ZOOM_MODE_ATTR) === 'fixed' ? 'fixed' : 'fit-width'
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Read the effective zoom the preview frame currently shows.
|
|
433
|
+
*
|
|
434
|
+
* The renderer writes the resolved scale to the frame's inline
|
|
435
|
+
* `--document-zoom` (fit-width included), so the inline value is read first
|
|
436
|
+
* and the computed value only serves a frame whose style was not inlined.
|
|
437
|
+
* @param frame - zoom frame, when one exists.
|
|
438
|
+
* @returns a clamped, 3-decimal scale.
|
|
439
|
+
*/
|
|
440
|
+
function frameZoomScale(frame) {
|
|
441
|
+
if (frame === null || frame === undefined || typeof frame.getAttribute !== 'function') return 1
|
|
442
|
+
let raw
|
|
443
|
+
try {
|
|
444
|
+
raw = typeof frame.style?.getPropertyValue === 'function'
|
|
445
|
+
? frame.style.getPropertyValue(ZOOM_VAR)
|
|
446
|
+
: undefined
|
|
447
|
+
if (raw === undefined || raw === null || String(raw).trim() === '') {
|
|
448
|
+
const view = frame.ownerDocument?.defaultView
|
|
449
|
+
if (view !== null && view !== undefined && typeof view.getComputedStyle === 'function') {
|
|
450
|
+
raw = view.getComputedStyle(frame).getPropertyValue(ZOOM_VAR)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
} catch (_error) {
|
|
454
|
+
raw = undefined
|
|
455
|
+
}
|
|
456
|
+
return clampZoomScale(raw) ?? 1
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** @returns `{ zoomMode, zoomScale }` as the frame currently shows them. */
|
|
460
|
+
function readFrameZoom(frame) {
|
|
461
|
+
return { zoomMode: frameZoomMode(frame), zoomScale: frameZoomScale(frame) }
|
|
462
|
+
}
|
|
463
|
+
|
|
308
464
|
/**
|
|
309
465
|
* A cancellable trailing-edge debouncer.
|
|
310
466
|
* @param delay - milliseconds.
|
|
@@ -679,10 +835,6 @@ window.__ModuleLoader__.load({
|
|
|
679
835
|
zh: ['搜索', '搜索这个 PDF 的文字层(Cmd/Ctrl+Shift+F)'],
|
|
680
836
|
en: ['Search', "Search this PDF's text layer (Cmd/Ctrl+Shift+F)"],
|
|
681
837
|
},
|
|
682
|
-
share: {
|
|
683
|
-
zh: ['引用当前页', '把当前位置引用插入输入框'],
|
|
684
|
-
en: ['Insert reference', 'Insert this page reference into the chat'],
|
|
685
|
-
},
|
|
686
838
|
follow: {
|
|
687
839
|
zh: ['AI 上下文', '把当前阅读页报告给模型'],
|
|
688
840
|
en: ['AI context', 'Report the page you are reading to the model'],
|
|
@@ -814,6 +966,10 @@ window.__ModuleLoader__.load({
|
|
|
814
966
|
(id) => globalThis.clearTimeout(id),
|
|
815
967
|
)
|
|
816
968
|
}
|
|
969
|
+
/** Latest sampled page, readable from the debounced view save. */
|
|
970
|
+
const pageRef = useRef(1)
|
|
971
|
+
/** Installed by the view effect; `null` when this preview keeps no view. */
|
|
972
|
+
const requestViewSaveRef = useRef(null)
|
|
817
973
|
|
|
818
974
|
const [page, setPage] = useState(1)
|
|
819
975
|
const [sampled, setSampled] = useState(false)
|
|
@@ -992,6 +1148,7 @@ window.__ModuleLoader__.load({
|
|
|
992
1148
|
setSearching(false)
|
|
993
1149
|
setSearchError(null)
|
|
994
1150
|
lastReportedKeyRef.current = undefined
|
|
1151
|
+
pageRef.current = 1
|
|
995
1152
|
reportAbortRef.current?.abort()
|
|
996
1153
|
if (focusTimerRef.current !== null) {
|
|
997
1154
|
globalThis.clearTimeout(focusTimerRef.current)
|
|
@@ -1263,6 +1420,347 @@ window.__ModuleLoader__.load({
|
|
|
1263
1420
|
}
|
|
1264
1421
|
}, [isPdf, absolutePath])
|
|
1265
1422
|
|
|
1423
|
+
// Restore the last page/zoom for this book, then persist both best-effort.
|
|
1424
|
+
//
|
|
1425
|
+
// The official preview keeps neither across tabs, so this owns one restore
|
|
1426
|
+
// per absolutePath: fetch the saved view in parallel with rendering, wait
|
|
1427
|
+
// (up to ~8 s) for the preview structure and the target page, drive the
|
|
1428
|
+
// renderer's own Ctrl+wheel path for a fixed zoom, and only then allow
|
|
1429
|
+
// saving. The gate matters: saving before the restore lands would replace
|
|
1430
|
+
// the saved page with page 1 every time a book is opened.
|
|
1431
|
+
useEffect(() => {
|
|
1432
|
+
if (!isPdf || absolutePath === undefined) return undefined
|
|
1433
|
+
const host = hostRef.current
|
|
1434
|
+
if (host === null) return undefined
|
|
1435
|
+
const pdfPath = absolutePath
|
|
1436
|
+
|
|
1437
|
+
let disposed = false
|
|
1438
|
+
let finished = false
|
|
1439
|
+
let pane
|
|
1440
|
+
let scrollport
|
|
1441
|
+
let frame
|
|
1442
|
+
let attributeObserver
|
|
1443
|
+
let structureObserver
|
|
1444
|
+
let retryTimer
|
|
1445
|
+
let retryCount = 0
|
|
1446
|
+
let reapplyTimer
|
|
1447
|
+
let zoomTimer
|
|
1448
|
+
let zoomEvents = 0
|
|
1449
|
+
let restoreTarget
|
|
1450
|
+
let lastAttemptKey = ''
|
|
1451
|
+
const restoreController = new AbortController()
|
|
1452
|
+
const saveController = new AbortController()
|
|
1453
|
+
|
|
1454
|
+
const saveDebouncer = createDebouncer(
|
|
1455
|
+
VIEW_SAVE_DELAY_MS,
|
|
1456
|
+
(fn, ms) => globalThis.setTimeout(fn, ms),
|
|
1457
|
+
(id) => globalThis.clearTimeout(id),
|
|
1458
|
+
)
|
|
1459
|
+
const structureDebouncer = createDebouncer(
|
|
1460
|
+
250,
|
|
1461
|
+
(fn, ms) => globalThis.setTimeout(fn, ms),
|
|
1462
|
+
(id) => globalThis.clearTimeout(id),
|
|
1463
|
+
)
|
|
1464
|
+
|
|
1465
|
+
/**
|
|
1466
|
+
* Persist the current page and zoom. The four client fields are the whole
|
|
1467
|
+
* contract; the Host stamps `updatedAt`. Identical consecutive payloads
|
|
1468
|
+
* are suppressed, and a failed write is retried by the next trigger.
|
|
1469
|
+
*/
|
|
1470
|
+
const saveNow = () => {
|
|
1471
|
+
if (disposed || !finished) return
|
|
1472
|
+
if (typeof fetch !== 'function') return
|
|
1473
|
+
// A debounced save is also a cheap reconnect point: if the preview
|
|
1474
|
+
// replaced its structure, resolve it again before reading the frame.
|
|
1475
|
+
refresh()
|
|
1476
|
+
const zoom = readFrameZoom(frame)
|
|
1477
|
+
const payload = {
|
|
1478
|
+
path: pdfPath,
|
|
1479
|
+
pdfPage: positiveInteger(pageRef.current) ? pageRef.current : 1,
|
|
1480
|
+
zoomMode: zoom.zoomMode,
|
|
1481
|
+
zoomScale: zoom.zoomScale,
|
|
1482
|
+
}
|
|
1483
|
+
const key = JSON.stringify(payload)
|
|
1484
|
+
if (key === lastAttemptKey) return
|
|
1485
|
+
lastAttemptKey = key
|
|
1486
|
+
let request
|
|
1487
|
+
try {
|
|
1488
|
+
request = fetch(VIEW_ROUTE, {
|
|
1489
|
+
method: 'POST',
|
|
1490
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
1491
|
+
body: JSON.stringify(payload),
|
|
1492
|
+
signal: saveController.signal,
|
|
1493
|
+
keepalive: true,
|
|
1494
|
+
})
|
|
1495
|
+
} catch (_error) {
|
|
1496
|
+
lastAttemptKey = ''
|
|
1497
|
+
return
|
|
1498
|
+
}
|
|
1499
|
+
Promise.resolve(request)
|
|
1500
|
+
.then((response) => {
|
|
1501
|
+
if (disposed) return
|
|
1502
|
+
if (response !== null && response !== undefined && response.ok === true) return
|
|
1503
|
+
if (lastAttemptKey === key) lastAttemptKey = ''
|
|
1504
|
+
})
|
|
1505
|
+
.catch(() => {
|
|
1506
|
+
if (disposed) return
|
|
1507
|
+
if (lastAttemptKey === key) lastAttemptKey = ''
|
|
1508
|
+
})
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
const requestSave = () => {
|
|
1512
|
+
if (disposed) return
|
|
1513
|
+
saveDebouncer.schedule(saveNow)
|
|
1514
|
+
}
|
|
1515
|
+
requestViewSaveRef.current = requestSave
|
|
1516
|
+
|
|
1517
|
+
/** Allow saving once, including when the restore failed or timed out. */
|
|
1518
|
+
const finish = () => {
|
|
1519
|
+
if (disposed || finished) return
|
|
1520
|
+
finished = true
|
|
1521
|
+
requestSave()
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
const detach = () => {
|
|
1525
|
+
attributeObserver?.disconnect()
|
|
1526
|
+
attributeObserver = undefined
|
|
1527
|
+
structureObserver?.disconnect()
|
|
1528
|
+
structureObserver = undefined
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
const observeStructure = () => {
|
|
1532
|
+
structureObserver?.disconnect()
|
|
1533
|
+
structureObserver = undefined
|
|
1534
|
+
if (typeof MutationObserver !== 'function') return
|
|
1535
|
+
const root = pane?.parentElement ?? host.parentElement ?? pane ?? host
|
|
1536
|
+
if (root === null || root === undefined) return
|
|
1537
|
+
structureObserver = new MutationObserver(() => {
|
|
1538
|
+
if (disposed) return
|
|
1539
|
+
structureDebouncer.schedule(syncStructure)
|
|
1540
|
+
})
|
|
1541
|
+
structureObserver.observe(root, { childList: true, subtree: true })
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
const observeFrame = (target) => {
|
|
1545
|
+
attributeObserver?.disconnect()
|
|
1546
|
+
attributeObserver = undefined
|
|
1547
|
+
if (target === null || target === undefined) return
|
|
1548
|
+
if (typeof MutationObserver !== 'function') return
|
|
1549
|
+
attributeObserver = new MutationObserver(() => {
|
|
1550
|
+
if (disposed) return
|
|
1551
|
+
requestSave()
|
|
1552
|
+
})
|
|
1553
|
+
attributeObserver.observe(target, {
|
|
1554
|
+
attributes: true,
|
|
1555
|
+
attributeFilter: [ZOOM_MODE_ATTR, 'style'],
|
|
1556
|
+
subtree: false,
|
|
1557
|
+
})
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
const bind = (nextPane, nextScrollport, nextFrame) => {
|
|
1561
|
+
attributeObserver?.disconnect()
|
|
1562
|
+
attributeObserver = undefined
|
|
1563
|
+
structureObserver?.disconnect()
|
|
1564
|
+
structureObserver = undefined
|
|
1565
|
+
pane = nextPane
|
|
1566
|
+
scrollport = nextScrollport
|
|
1567
|
+
frame = nextFrame
|
|
1568
|
+
observeFrame(frame)
|
|
1569
|
+
observeStructure()
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
const syncStructure = () => {
|
|
1573
|
+
if (disposed) return
|
|
1574
|
+
const nextPane = resolvePane(host)
|
|
1575
|
+
if (nextPane === undefined) return
|
|
1576
|
+
const nextScrollport = resolveScrollport(nextPane)
|
|
1577
|
+
const nextFrame = resolveZoomFrame(nextPane, nextScrollport)
|
|
1578
|
+
if (nextPane !== pane || nextScrollport !== scrollport || nextFrame !== frame) {
|
|
1579
|
+
bind(nextPane, nextScrollport, nextFrame)
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
/** Re-resolve the preview structure; true when a body exists. */
|
|
1584
|
+
const refresh = () => {
|
|
1585
|
+
const nextPane = resolvePane(host)
|
|
1586
|
+
if (nextPane === undefined) return false
|
|
1587
|
+
const nextScrollport = resolveScrollport(nextPane)
|
|
1588
|
+
const nextFrame = resolveZoomFrame(nextPane, nextScrollport)
|
|
1589
|
+
if (nextPane !== pane || nextScrollport !== scrollport || nextFrame !== frame) {
|
|
1590
|
+
bind(nextPane, nextScrollport, nextFrame)
|
|
1591
|
+
}
|
|
1592
|
+
return true
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
const retryRestore = () => {
|
|
1596
|
+
if (disposed || finished || retryTimer !== undefined) return
|
|
1597
|
+
if (retryCount >= VIEW_RETRY_LIMIT) {
|
|
1598
|
+
finish()
|
|
1599
|
+
return
|
|
1600
|
+
}
|
|
1601
|
+
retryCount += 1
|
|
1602
|
+
retryTimer = globalThis.setTimeout(() => {
|
|
1603
|
+
retryTimer = undefined
|
|
1604
|
+
if (!disposed) stepRestore()
|
|
1605
|
+
}, VIEW_RETRY_MS)
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
/**
|
|
1609
|
+
* Scroll the saved page into view, then re-apply once. The second scroll
|
|
1610
|
+
* absorbs pages that were 3:4 placeholders and grew when lazily rendered.
|
|
1611
|
+
*/
|
|
1612
|
+
const placePage = () => {
|
|
1613
|
+
if (disposed || restoreTarget === undefined) return
|
|
1614
|
+
const target = restoreTarget.pdfPage
|
|
1615
|
+
if (!refresh() || !scrollPageIntoView(pane, scrollport, target)) {
|
|
1616
|
+
retryRestore()
|
|
1617
|
+
return
|
|
1618
|
+
}
|
|
1619
|
+
// The save debounce reads this ref, and the restore runs from a timer
|
|
1620
|
+
// (not a React event), so publish the page before state can render.
|
|
1621
|
+
pageRef.current = target
|
|
1622
|
+
setPage(target)
|
|
1623
|
+
setSampled(true)
|
|
1624
|
+
finish()
|
|
1625
|
+
if (reapplyTimer !== undefined) globalThis.clearTimeout(reapplyTimer)
|
|
1626
|
+
reapplyTimer = globalThis.setTimeout(() => {
|
|
1627
|
+
reapplyTimer = undefined
|
|
1628
|
+
if (disposed || !refresh()) return
|
|
1629
|
+
scrollPageIntoView(pane, scrollport, target)
|
|
1630
|
+
}, VIEW_REAPPLY_MS)
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
/** Let the renderer's 120 ms zoom settlement commit before scrolling. */
|
|
1634
|
+
const settleZoom = () => {
|
|
1635
|
+
if (zoomTimer !== undefined) globalThis.clearTimeout(zoomTimer)
|
|
1636
|
+
zoomTimer = globalThis.setTimeout(() => {
|
|
1637
|
+
zoomTimer = undefined
|
|
1638
|
+
if (!disposed) placePage()
|
|
1639
|
+
}, VIEW_ZOOM_SETTLE_MS)
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
/**
|
|
1643
|
+
* Drive Ctrl+wheel events until the frame scale is within 2% of the saved
|
|
1644
|
+
* fixed scale. Each event can change the ratio by at most ~1.49x, so a
|
|
1645
|
+
* large jump needs a few events; the event budget bounds a missing or
|
|
1646
|
+
* non-responsive handler.
|
|
1647
|
+
*/
|
|
1648
|
+
const stepZoom = () => {
|
|
1649
|
+
if (disposed || restoreTarget === undefined) return
|
|
1650
|
+
const target = restoreTarget.zoomScale
|
|
1651
|
+
if (!refresh() || frame === undefined || scrollport === undefined) {
|
|
1652
|
+
placePage()
|
|
1653
|
+
return
|
|
1654
|
+
}
|
|
1655
|
+
const current = frameZoomScale(frame)
|
|
1656
|
+
if (Math.abs(current / target - 1) <= VIEW_ZOOM_TOLERANCE) {
|
|
1657
|
+
if (frameZoomMode(frame) !== 'fixed') {
|
|
1658
|
+
// Same scale, fit-width mode: a zero-delta gesture only asks the
|
|
1659
|
+
// renderer to commit `{kind:'fixed'}` at the current scale.
|
|
1660
|
+
if (!dispatchZoomWheel(scrollport, 0)) {
|
|
1661
|
+
placePage()
|
|
1662
|
+
return
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
// The renderer commits its fixed preference 120 ms after the last
|
|
1666
|
+
// gesture and only then re-renders pages at the new scale.
|
|
1667
|
+
settleZoom()
|
|
1668
|
+
return
|
|
1669
|
+
}
|
|
1670
|
+
if (zoomEvents >= VIEW_ZOOM_EVENT_LIMIT) {
|
|
1671
|
+
settleZoom()
|
|
1672
|
+
return
|
|
1673
|
+
}
|
|
1674
|
+
const deltaY = Math.max(-40, Math.min(40, -100 * Math.log(target / current)))
|
|
1675
|
+
if (!Number.isFinite(deltaY) || Math.abs(deltaY) < 0.05) {
|
|
1676
|
+
settleZoom()
|
|
1677
|
+
return
|
|
1678
|
+
}
|
|
1679
|
+
zoomEvents += 1
|
|
1680
|
+
if (!dispatchZoomWheel(scrollport, deltaY)) {
|
|
1681
|
+
settleZoom()
|
|
1682
|
+
return
|
|
1683
|
+
}
|
|
1684
|
+
zoomTimer = globalThis.setTimeout(() => {
|
|
1685
|
+
zoomTimer = undefined
|
|
1686
|
+
if (!disposed) stepZoom()
|
|
1687
|
+
}, VIEW_ZOOM_STEP_MS)
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
const stepRestore = () => {
|
|
1691
|
+
if (disposed || finished || restoreTarget === undefined) return
|
|
1692
|
+
const target = restoreTarget
|
|
1693
|
+
if (!refresh()) {
|
|
1694
|
+
retryRestore()
|
|
1695
|
+
return
|
|
1696
|
+
}
|
|
1697
|
+
const page = pane.querySelector(`[${PAGE_ATTR}="${target.pdfPage}"]`)
|
|
1698
|
+
if (!isElement(page)) {
|
|
1699
|
+
retryRestore()
|
|
1700
|
+
return
|
|
1701
|
+
}
|
|
1702
|
+
if (target.zoomMode === 'fixed' && frame !== undefined && scrollport !== undefined) {
|
|
1703
|
+
if (Math.abs(frameZoomScale(frame) / target.zoomScale - 1) > VIEW_ZOOM_TOLERANCE
|
|
1704
|
+
|| frameZoomMode(frame) !== 'fixed') {
|
|
1705
|
+
stepZoom()
|
|
1706
|
+
return
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
placePage()
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
const loadSavedView = async () => {
|
|
1713
|
+
const timer = globalThis.setTimeout(() => {
|
|
1714
|
+
restoreController.abort()
|
|
1715
|
+
}, VIEW_TIMEOUT_MS)
|
|
1716
|
+
try {
|
|
1717
|
+
const { body } = await fetchJson(
|
|
1718
|
+
`${VIEW_ROUTE}?path=${encodeURIComponent(pdfPath)}`,
|
|
1719
|
+
{ headers: { accept: 'application/json' }, signal: restoreController.signal },
|
|
1720
|
+
)
|
|
1721
|
+
if (disposed) return
|
|
1722
|
+
const saved = normalizeView(body)
|
|
1723
|
+
if (saved === undefined) {
|
|
1724
|
+
finish()
|
|
1725
|
+
return
|
|
1726
|
+
}
|
|
1727
|
+
restoreTarget = saved
|
|
1728
|
+
stepRestore()
|
|
1729
|
+
} catch (_error) {
|
|
1730
|
+
if (disposed) return
|
|
1731
|
+
// A missing route, a network failure, or a timeout must not block
|
|
1732
|
+
// saving forever: the restore is best effort.
|
|
1733
|
+
finish()
|
|
1734
|
+
} finally {
|
|
1735
|
+
globalThis.clearTimeout(timer)
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
// Observe the current structure while the GET is in flight, so a zoom the
|
|
1740
|
+
// reader performs before the response still gets saved once the gate opens.
|
|
1741
|
+
refresh()
|
|
1742
|
+
void loadSavedView()
|
|
1743
|
+
|
|
1744
|
+
return () => {
|
|
1745
|
+
disposed = true
|
|
1746
|
+
restoreController.abort()
|
|
1747
|
+
saveController.abort()
|
|
1748
|
+
saveDebouncer.cancel()
|
|
1749
|
+
structureDebouncer.cancel()
|
|
1750
|
+
if (retryTimer !== undefined) globalThis.clearTimeout(retryTimer)
|
|
1751
|
+
if (reapplyTimer !== undefined) globalThis.clearTimeout(reapplyTimer)
|
|
1752
|
+
if (zoomTimer !== undefined) globalThis.clearTimeout(zoomTimer)
|
|
1753
|
+
detach()
|
|
1754
|
+
if (requestViewSaveRef.current === requestSave) requestViewSaveRef.current = null
|
|
1755
|
+
}
|
|
1756
|
+
}, [isPdf, absolutePath])
|
|
1757
|
+
|
|
1758
|
+
// Save the sampled page through the same 500 ms gate as zoom changes.
|
|
1759
|
+
useEffect(() => {
|
|
1760
|
+
pageRef.current = page
|
|
1761
|
+
if (sampled) requestViewSaveRef.current?.()
|
|
1762
|
+
}, [page, sampled])
|
|
1763
|
+
|
|
1266
1764
|
// Debounced AI-context reporting. One timer, one in-flight POST, abort on
|
|
1267
1765
|
// page change, toggle-off, path change, or unmount.
|
|
1268
1766
|
useEffect(() => {
|
|
@@ -1536,17 +2034,6 @@ window.__ModuleLoader__.load({
|
|
|
1536
2034
|
},
|
|
1537
2035
|
style: BUTTON_STYLE,
|
|
1538
2036
|
}, SEARCH_ICON),
|
|
1539
|
-
h('button', {
|
|
1540
|
-
key: 'share',
|
|
1541
|
-
type: 'button',
|
|
1542
|
-
'aria-label': 'Insert this page reference into the chat',
|
|
1543
|
-
'data-tip': 'share',
|
|
1544
|
-
'data-dsh-pdf-reader': 'share',
|
|
1545
|
-
onClick: () => {
|
|
1546
|
-
deliverReference(buildShareReference(page, printedOffset), 'Inserted')
|
|
1547
|
-
},
|
|
1548
|
-
style: BUTTON_STYLE,
|
|
1549
|
-
}, h('span', { 'aria-hidden': 'true', style: { fontSize: '14px' } }, '↗')),
|
|
1550
2037
|
h('button', {
|
|
1551
2038
|
key: 'follow',
|
|
1552
2039
|
type: 'button',
|
|
@@ -1764,27 +2251,31 @@ window.__ModuleLoader__.load({
|
|
|
1764
2251
|
AI_BUTTON_STYLE,
|
|
1765
2252
|
PANEL_STYLE,
|
|
1766
2253
|
buildSearchReference,
|
|
1767
|
-
buildShareReference,
|
|
1768
2254
|
cachedInfo,
|
|
2255
|
+
clampZoomScale,
|
|
1769
2256
|
copyText,
|
|
1770
2257
|
createDebouncer,
|
|
1771
2258
|
createRafThrottle,
|
|
1772
2259
|
describeSearchFailure,
|
|
1773
2260
|
displayPage,
|
|
2261
|
+
dispatchZoomWheel,
|
|
1774
2262
|
fetchJson,
|
|
1775
2263
|
infoCache,
|
|
1776
2264
|
insertComposerText,
|
|
1777
2265
|
isPdfPath,
|
|
1778
2266
|
normalizeInfo,
|
|
1779
2267
|
normalizeSnippet,
|
|
2268
|
+
normalizeView,
|
|
1780
2269
|
pageElements,
|
|
1781
2270
|
parsePageInput,
|
|
1782
2271
|
pickPage,
|
|
1783
2272
|
positiveInteger,
|
|
1784
2273
|
printedPageOf,
|
|
2274
|
+
readFrameZoom,
|
|
1785
2275
|
rememberInfo,
|
|
1786
2276
|
resolvePane,
|
|
1787
2277
|
resolveScrollport,
|
|
2278
|
+
resolveZoomFrame,
|
|
1788
2279
|
scrollPageIntoView,
|
|
1789
2280
|
searchResultLabel,
|
|
1790
2281
|
searchResultPage,
|