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/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" and "share this page" insertions into the chat composer;
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,