@readium/navigator 2.10.3 → 2.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/dist/audio/AudioNavigator.js +1 -1
  2. package/dist/audio/AudioPoolManager.js +1 -1
  3. package/dist/audio/engine/WebAudioEngine.js +1 -1
  4. package/dist/divina/DivinaNavigator.js +1 -0
  5. package/dist/divina/DivinaPageManager.js +1 -0
  6. package/dist/divina/DivinaPagedPresenter.js +1 -0
  7. package/dist/divina/DivinaPeripherals.js +1 -0
  8. package/dist/divina/DivinaScrolledPresenter.js +1 -0
  9. package/dist/divina/DivinaSpreader.js +1 -0
  10. package/dist/divina/DivinaVariantSelector.js +1 -0
  11. package/dist/divina/preferences/DivinaDefaults.js +1 -0
  12. package/dist/divina/preferences/DivinaPreferences.js +1 -0
  13. package/dist/divina/preferences/DivinaPreferencesEditor.js +1 -0
  14. package/dist/divina/preferences/DivinaSettings.js +1 -0
  15. package/dist/divina/protection/DivinaNavigatorProtector.js +1 -0
  16. package/dist/epub/EpubNavigator.js +1 -1
  17. package/dist/epub/frame/FrameBlobBuilder.js +3 -1
  18. package/dist/epub/frame/FrameComms.js +1 -1
  19. package/dist/epub/frame/FrameManager.js +1 -1
  20. package/dist/epub/frame/FramePoolManager.js +1 -1
  21. package/dist/epub/fxl/FXLFrameManager.js +1 -1
  22. package/dist/epub/fxl/FXLFramePoolManager.js +1 -1
  23. package/dist/epub/fxl/FXLPeripherals.js +2 -2
  24. package/dist/helpers/serialize.js +1 -0
  25. package/dist/index.js +1 -1
  26. package/dist/peripherals/KeyboardConditionBridge.js +1 -1
  27. package/dist/peripherals/KeyboardPeripherals.js +1 -1
  28. package/dist/webpub/WebPubFrameManager.js +1 -1
  29. package/dist/webpub/WebPubFramePoolManager.js +1 -1
  30. package/dist/webpub/WebPubNavigator.js +1 -1
  31. package/package.json +5 -5
  32. package/src/audio/AudioNavigator.ts +107 -5
  33. package/src/audio/AudioPoolManager.ts +14 -8
  34. package/src/audio/engine/AudioEngine.ts +34 -1
  35. package/src/audio/engine/WebAudioEngine.ts +48 -3
  36. package/src/divina/DivinaNavigator.ts +704 -0
  37. package/src/divina/DivinaPageManager.ts +322 -0
  38. package/src/divina/DivinaPagedPresenter.ts +508 -0
  39. package/src/divina/DivinaPeripherals.ts +630 -0
  40. package/src/divina/DivinaScrolledPresenter.ts +516 -0
  41. package/src/divina/DivinaSpreader.ts +202 -0
  42. package/src/divina/DivinaVariantSelector.ts +77 -0
  43. package/src/divina/index.ts +8 -0
  44. package/src/divina/preferences/DivinaDefaults.ts +37 -0
  45. package/src/divina/preferences/DivinaPreferences.ts +77 -0
  46. package/src/divina/preferences/DivinaPreferencesEditor.ts +96 -0
  47. package/src/divina/preferences/DivinaSettings.ts +38 -0
  48. package/src/divina/preferences/index.ts +4 -0
  49. package/src/divina/protection/DivinaNavigatorProtector.ts +44 -0
  50. package/src/epub/EpubNavigator.ts +73 -7
  51. package/src/epub/frame/FrameBlobBuilder.ts +144 -39
  52. package/src/epub/frame/FrameComms.ts +42 -7
  53. package/src/epub/frame/FrameManager.ts +27 -16
  54. package/src/epub/frame/FramePoolManager.ts +106 -48
  55. package/src/epub/fxl/FXLFrameManager.ts +99 -34
  56. package/src/epub/fxl/FXLFramePoolManager.ts +98 -53
  57. package/src/epub/fxl/FXLPeripherals.ts +22 -1
  58. package/src/helpers/serialize.ts +9 -0
  59. package/src/index.ts +1 -0
  60. package/src/peripherals/KeyboardConditionBridge.ts +15 -5
  61. package/src/peripherals/KeyboardPeripherals.ts +13 -7
  62. package/src/webpub/WebPubFrameManager.ts +27 -16
  63. package/src/webpub/WebPubFramePoolManager.ts +5 -2
  64. package/src/webpub/WebPubNavigator.ts +44 -0
  65. package/types/src/audio/AudioNavigator.d.ts +29 -0
  66. package/types/src/audio/engine/AudioEngine.d.ts +27 -1
  67. package/types/src/audio/engine/WebAudioEngine.d.ts +14 -2
  68. package/types/src/divina/DivinaNavigator.d.ts +119 -0
  69. package/types/src/divina/DivinaPageManager.d.ts +66 -0
  70. package/types/src/divina/DivinaPagedPresenter.d.ts +122 -0
  71. package/types/src/divina/DivinaPeripherals.d.ts +121 -0
  72. package/types/src/divina/DivinaScrolledPresenter.d.ts +125 -0
  73. package/types/src/divina/DivinaSpreader.d.ts +65 -0
  74. package/types/src/divina/DivinaVariantSelector.d.ts +30 -0
  75. package/types/src/divina/index.d.ts +8 -0
  76. package/types/src/divina/preferences/DivinaDefaults.d.ts +18 -0
  77. package/types/src/divina/preferences/DivinaPreferences.d.ts +33 -0
  78. package/types/src/divina/preferences/DivinaPreferencesEditor.d.ts +20 -0
  79. package/types/src/divina/preferences/DivinaSettings.d.ts +26 -0
  80. package/types/src/divina/preferences/index.d.ts +4 -0
  81. package/types/src/divina/protection/DivinaNavigatorProtector.d.ts +14 -0
  82. package/types/src/epub/EpubNavigator.d.ts +7 -1
  83. package/types/src/epub/frame/FrameBlobBuilder.d.ts +19 -3
  84. package/types/src/epub/frame/FrameComms.d.ts +8 -1
  85. package/types/src/epub/frame/FrameManager.d.ts +3 -0
  86. package/types/src/epub/frame/FramePoolManager.d.ts +5 -1
  87. package/types/src/epub/fxl/FXLFrameManager.d.ts +7 -0
  88. package/types/src/epub/fxl/FXLFramePoolManager.d.ts +8 -2
  89. package/types/src/epub/fxl/FXLPeripherals.d.ts +3 -0
  90. package/types/src/helpers/serialize.d.ts +1 -0
  91. package/types/src/index.d.ts +1 -0
  92. package/types/src/peripherals/KeyboardPeripherals.d.ts +1 -0
  93. package/types/src/webpub/WebPubFrameManager.d.ts +3 -0
  94. package/types/src/webpub/WebPubNavigator.d.ts +2 -0
@@ -0,0 +1,516 @@
1
+ import { ProgressionRange, VisualNavigatorViewport } from "../Navigator.ts";
2
+ import { DivinaPageManager } from "./DivinaPageManager.ts";
3
+ import { DivinaSpreader } from "./DivinaSpreader.ts";
4
+
5
+ const UPPER_BOUNDARY = 10; // Unload pages further than this many items away
6
+ const LOWER_BOUNDARY = 5; // Load pages within this many items
7
+ const SCROLL_STEP_FACTOR = 0.8; // goForward/goBackward scroll this fraction of the viewport
8
+ const POSITION_DEBOUNCE = 150; // ms of scroll quiet before reporting the position
9
+ const PROGRAMMATIC_SCROLL_TIMEOUT = 1200; // Fallback for browsers without scrollend
10
+ // Owned scroll animation (native smooth scrolling restarts its easing curve on
11
+ // every retarget, which crawls under key autorepeat and then sprints the
12
+ // accumulated backlog on release)
13
+ const SCROLL_CRUISE_SPEED = 8; // Top speed, in viewports per second (held page keys reach it, line steps stay demand-limited)
14
+ const SCROLL_APPROACH_RATE = 20; // 1/s: fraction of the remaining distance per second (ease-out)
15
+ const SCROLL_TARGET_MAX_LEAD = 1.5; // Chained targets may lead the position by at most this many viewports
16
+
17
+ export type DivinaScrolledEventKey = "scroll" | "position" | "tap" | "click";
18
+ export type DivinaScrolledListener = (key: DivinaScrolledEventKey, data: unknown) => void;
19
+
20
+ /**
21
+ * Vertical scrolled ("webtoon") presenter for Divina publications: all pages
22
+ * stacked in a native scroll container with no gaps, constrained to a
23
+ * configurable strip width, based on xbreader but improved
24
+ *
25
+ * Page offsets are cached and looked up with binary search so per-frame
26
+ * scroll work stays constant regardless of publication size.
27
+ */
28
+ export class DivinaScrolledPresenter {
29
+ private readonly container: HTMLElement;
30
+ private readonly spreader: DivinaSpreader;
31
+ private readonly pool: Map<number, DivinaPageManager>;
32
+
33
+ public readonly scrollerElement: HTMLDivElement;
34
+ private stripWidth: number;
35
+ private destroyed = false;
36
+ private scrollRAF = 0;
37
+ private restoreRAF = 0;
38
+ private positionTimer = 0;
39
+ private lastScrollTop = 0;
40
+ /** Position within the strip as a fraction of total strip height (resize retention) */
41
+ private fraction = 0;
42
+
43
+ // Cached layout metrics, refreshed on relayout and self-healed on drift
44
+ private pageTops: number[] = [];
45
+ private pageHeights: number[] = [];
46
+ private stripHeightCached = 0;
47
+ private viewportHeightCached = 0;
48
+
49
+ // Load-window bookkeeping
50
+ private loadedIndices: Set<number> = new Set();
51
+ private lastWindowIndex = -1;
52
+
53
+ // Programmatic scrolling (keyboard nav, goTo, restores): suppresses
54
+ // "scroll" listener emissions and accumulates chained smooth targets
55
+ private programmaticScroll = false;
56
+ private programmaticTimer = 0;
57
+ private scrollTarget: number | null = null;
58
+
59
+ // Owned smooth-scroll animation state
60
+ private animating = false;
61
+ private animRAF = 0;
62
+ private animLastTime = 0;
63
+
64
+ public listener: DivinaScrolledListener = () => {};
65
+
66
+ constructor(
67
+ container: HTMLElement,
68
+ spreader: DivinaSpreader,
69
+ pool: Map<number, DivinaPageManager>,
70
+ stripWidth: number
71
+ ) {
72
+ this.container = container;
73
+ this.spreader = spreader;
74
+ this.pool = pool;
75
+ this.stripWidth = stripWidth;
76
+
77
+ this.scrollerElement = document.createElement("div");
78
+ this.scrollerElement.ariaLabel = "Book";
79
+ this.scrollerElement.tabIndex = -1;
80
+ Object.assign(this.scrollerElement.style, {
81
+ overflowY: "auto",
82
+ overflowX: "hidden",
83
+ height: "100%",
84
+ width: "100%",
85
+ position: "relative",
86
+ outline: "none",
87
+ overscrollBehavior: "contain",
88
+ } as unknown as CSSStyleDeclaration);
89
+
90
+ const fragment = document.createDocumentFragment();
91
+ this.spreader.pages.forEach((page) => {
92
+ const pm = this.pool.get(page.index)!;
93
+ pm.setMode("scrolled");
94
+ pm.wrapper.style.removeProperty("contain");
95
+ fragment.appendChild(pm.wrapper);
96
+ });
97
+ this.scrollerElement.appendChild(fragment);
98
+ this.container.appendChild(this.scrollerElement);
99
+
100
+ this.applyStripWidth(stripWidth);
101
+
102
+ this.scrollerElement.addEventListener("scroll", this.bscrollHandler, { passive: true });
103
+ this.scrollerElement.addEventListener("scrollend", this.bscrollendHandler);
104
+ this.scrollerElement.addEventListener("wheel", this.buserTakeoverHandler, { passive: true });
105
+ this.scrollerElement.addEventListener("mousedown", this.bmousedownHandler);
106
+ this.scrollerElement.addEventListener("mouseup", this.bclickHandler);
107
+ this.scrollerElement.addEventListener("touchend", this.btouchendHandler);
108
+ this.scrollerElement.addEventListener("touchstart", this.btouchstartHandler, { passive: true });
109
+ }
110
+
111
+ private readonly bscrollHandler = this.scrollHandler.bind(this);
112
+ private readonly bscrollendHandler = this.scrollendHandler.bind(this);
113
+ private readonly banimStep = this.animStep.bind(this);
114
+ private readonly buserTakeoverHandler = this.userTakeoverHandler.bind(this);
115
+ private readonly bmousedownHandler = this.mousedownHandler.bind(this);
116
+ private readonly bclickHandler = this.clickHandler.bind(this);
117
+ private readonly btouchendHandler = this.touchendHandler.bind(this);
118
+ private readonly btouchstartHandler = this.touchstartHandler.bind(this);
119
+
120
+ /** Display width of each page in the strip */
121
+ private get displayWidth(): number {
122
+ return Math.max(1, Math.min(this.stripWidth, this.scrollerElement.clientWidth || this.container.clientWidth));
123
+ }
124
+
125
+ /**
126
+ * Recomputes the cached page offsets and viewport metrics.
127
+ * Cheap relative to the per-frame layout reads it replaces, but still a
128
+ * forced layout: only call after actual layout changes.
129
+ */
130
+ private refreshLayoutCache() {
131
+ this.viewportHeightCached = this.scrollerElement.clientHeight;
132
+ this.stripHeightCached = this.scrollerElement.scrollHeight;
133
+ const n = this.spreader.pages.length;
134
+ this.pageTops = new Array(n);
135
+ this.pageHeights = new Array(n);
136
+ for (const page of this.spreader.pages) {
137
+ const w = this.pool.get(page.index)!.wrapper;
138
+ this.pageTops[page.index] = w.offsetTop;
139
+ this.pageHeights[page.index] = Math.max(1, w.offsetHeight);
140
+ }
141
+ }
142
+
143
+ /** The index of the last page whose top is at or above the given strip offset */
144
+ private indexAtOffset(offset: number): number {
145
+ const tops = this.pageTops;
146
+ let lo = 0, hi = tops.length - 1, ans = 0;
147
+ while (lo <= hi) {
148
+ const mid = (lo + hi) >> 1;
149
+ if (tops[mid] <= offset) { ans = mid; lo = mid + 1; } else hi = mid - 1;
150
+ }
151
+ return ans;
152
+ }
153
+
154
+ applyStripWidth(stripWidth: number) {
155
+ this.stripWidth = stripWidth;
156
+ const fraction = this.fraction;
157
+ const w = this.displayWidth;
158
+ this.spreader.pages.forEach((page) => {
159
+ this.pool.get(page.index)?.applyStripWidth(w);
160
+ });
161
+ this.refreshLayoutCache();
162
+ // Restore the relative position after the relayout
163
+ this.restoreFraction(fraction);
164
+ }
165
+
166
+ private restoreFraction(fraction: number) {
167
+ cancelAnimationFrame(this.restoreRAF);
168
+ this.restoreRAF = requestAnimationFrame(() => {
169
+ if(this.destroyed) return;
170
+ const target = fraction * this.stripHeightCached;
171
+ if(Math.abs(this.scrollerElement.scrollTop - target) >= 1) {
172
+ this.beginProgrammaticScroll();
173
+ this.scrollTarget = target;
174
+ this.scrollerElement.scrollTop = target;
175
+ }
176
+ this.updateFraction();
177
+ });
178
+ }
179
+
180
+ private updateFraction() {
181
+ const h = this.stripHeightCached;
182
+ this.fraction = h > 0 ? this.scrollerElement.scrollTop / h : 0;
183
+ }
184
+
185
+ /** Top offset in pixels of the given page within the strip */
186
+ private pageTop(index: number): number {
187
+ return this.pageTops[index] ?? 0;
188
+ }
189
+
190
+ private pageHeight(index: number): number {
191
+ return this.pageHeights[index] ?? 1;
192
+ }
193
+
194
+ /**
195
+ * The reading-order index of the current page: the last page whose top
196
+ * is above the middle of the viewport.
197
+ */
198
+ get currentIndex(): number {
199
+ if(!this.pageTops.length) return 0;
200
+ return this.indexAtOffset(this.scrollerElement.scrollTop + this.viewportHeightCached * 0.5);
201
+ }
202
+
203
+ /** Progression within the current page (0..1) */
204
+ get currentPageProgression(): number {
205
+ const i = this.currentIndex;
206
+ return Math.max(0, Math.min(1, (this.scrollerElement.scrollTop - this.pageTop(i)) / this.pageHeight(i)));
207
+ }
208
+
209
+ /**
210
+ * Marks the start of a programmatic scroll: "scroll" events are not
211
+ * reported to the host until the scroll settles (scrollend or timeout),
212
+ * so navigation doesn't masquerade as user scrolling.
213
+ */
214
+ private beginProgrammaticScroll() {
215
+ this.programmaticScroll = true;
216
+ window.clearTimeout(this.programmaticTimer);
217
+ this.programmaticTimer = window.setTimeout(() => {
218
+ this.programmaticScroll = false;
219
+ this.scrollTarget = null;
220
+ this.settleWindow();
221
+ }, PROGRAMMATIC_SCROLL_TIMEOUT);
222
+ }
223
+
224
+ private scrollendHandler() {
225
+ // Instant scrollTop writes fire scrollend per write, so while the
226
+ // owned animation runs this event says nothing about settling —
227
+ // acting on it would kill the animation one frame in
228
+ if(this.animating) return;
229
+ window.clearTimeout(this.programmaticTimer);
230
+ this.programmaticScroll = false;
231
+ this.scrollTarget = null;
232
+ this.settleWindow();
233
+ }
234
+
235
+ /** Load the window around wherever the scroll settled */
236
+ private settleWindow() {
237
+ if(this.destroyed || !this.pageTops.length) return;
238
+ const index = this.currentIndex;
239
+ this.lastWindowIndex = index;
240
+ this.updateWindow(index);
241
+ }
242
+
243
+ /** Direct user input takes over any in-flight programmatic scroll */
244
+ private userTakeoverHandler() {
245
+ window.clearTimeout(this.programmaticTimer);
246
+ this.programmaticScroll = false;
247
+ this.scrollTarget = null;
248
+ this.stopAnimation();
249
+ }
250
+
251
+ private scrollHandler() {
252
+ if(this.destroyed) return;
253
+ cancelAnimationFrame(this.scrollRAF);
254
+ this.scrollRAF = requestAnimationFrame(() => {
255
+ if(this.destroyed) return;
256
+ const st = this.scrollerElement.scrollTop;
257
+ const delta = st - this.lastScrollTop;
258
+ this.lastScrollTop = st;
259
+
260
+ // Self-heal the layout cache if something (e.g. an image decode
261
+ // correcting an unknown aspect ratio) changed the strip height
262
+ if(Math.abs(this.scrollerElement.scrollHeight - this.stripHeightCached) > 1)
263
+ this.refreshLayoutCache();
264
+
265
+ this.updateFraction();
266
+ if(!this.programmaticScroll && delta !== 0) {
267
+ this.listener("scroll", delta);
268
+ }
269
+
270
+ // While a programmatic scroll (e.g. a held navigation key) is in
271
+ // flight, defer loading: pages rushing by would only churn
272
+ // fetches. The window is loaded when the scroll settles.
273
+ const index = this.currentIndex;
274
+ if(index !== this.lastWindowIndex && !this.programmaticScroll) {
275
+ this.lastWindowIndex = index;
276
+ this.updateWindow(index);
277
+ }
278
+
279
+ window.clearTimeout(this.positionTimer);
280
+ this.positionTimer = window.setTimeout(() => {
281
+ if(this.destroyed) return;
282
+ this.listener("position", this.currentIndex);
283
+ }, POSITION_DEBOUNCE);
284
+ });
285
+ }
286
+
287
+ /** Load nearby pages, unload distant ones */
288
+ private updateWindow(center: number) {
289
+ const n = this.spreader.pages.length;
290
+ for (let i = Math.max(0, center - LOWER_BOUNDARY); i <= Math.min(n - 1, center + LOWER_BOUNDARY); i++) {
291
+ this.pool.get(i)?.load();
292
+ this.loadedIndices.add(i);
293
+ }
294
+ for (const i of this.loadedIndices) {
295
+ if(Math.abs(i - center) > UPPER_BOUNDARY) {
296
+ this.pool.get(i)?.unload();
297
+ this.loadedIndices.delete(i);
298
+ }
299
+ }
300
+ }
301
+
302
+ private touchStartY: number | null = null;
303
+ private lastTouchEnd = 0;
304
+
305
+ private touchstartHandler(e: TouchEvent) {
306
+ this.userTakeoverHandler();
307
+ this.touchStartY = e.touches.length === 1 ? e.touches[0].clientY : null;
308
+ }
309
+
310
+ private touchendHandler(e: TouchEvent) {
311
+ if(this.touchStartY === null || e.changedTouches.length !== 1) return;
312
+ const dy = Math.abs(e.changedTouches[0].clientY - this.touchStartY);
313
+ this.touchStartY = null;
314
+ if(dy <= 5) {
315
+ // Suppress the compatibility mouse events for this tap
316
+ if(e.cancelable) e.preventDefault();
317
+ this.lastTouchEnd = performance.now();
318
+ this.emitPointer("tap", e.changedTouches[0].clientX, e.changedTouches[0].clientY);
319
+ }
320
+ }
321
+
322
+ private mouseDownPos: { x: number, y: number } | null = null;
323
+
324
+ private mousedownHandler(e: MouseEvent) {
325
+ this.mouseDownPos = e.button === 0 ? { x: e.clientX, y: e.clientY } : null;
326
+ }
327
+
328
+ private clickHandler(e: MouseEvent) {
329
+ // Mouseup alone can't distinguish a click from the end of a drag; compare with mousedown
330
+ if(e.button !== 0 || this.mouseDownPos === null) return;
331
+ const moved = Math.abs(e.clientX - this.mouseDownPos.x) + Math.abs(e.clientY - this.mouseDownPos.y);
332
+ this.mouseDownPos = null;
333
+ // Ignore compatibility mouse events synthesized after a touch tap
334
+ if(performance.now() - this.lastTouchEnd < 700) return;
335
+ if(moved <= 5) this.emitPointer("click", e.clientX, e.clientY);
336
+ }
337
+
338
+ private emitPointer(key: "tap" | "click", clientX: number, clientY: number) {
339
+ const rect = this.scrollerElement.getBoundingClientRect();
340
+ const dpr = window.devicePixelRatio || 1;
341
+ this.listener(key, {
342
+ x: (clientX - rect.left) * dpr,
343
+ y: (clientY - rect.top) * dpr,
344
+ clientX,
345
+ clientY,
346
+ doNotDisturb: false
347
+ });
348
+ }
349
+
350
+ /**
351
+ * Scroll forward/backward by a fraction of the viewport.
352
+ * Repeated calls chain from the pending target, so rapid key presses
353
+ * (including autorepeat from a held key) extend one animation that keeps
354
+ * cruising instead of restarting; the target is clamped to stay within
355
+ * reach of the position so releasing the key stops promptly rather than
356
+ * replaying the queued distance.
357
+ * @returns whether scrolling was possible
358
+ */
359
+ next(animated = true, stepPx?: number): boolean {
360
+ const max = this.scrollerElement.scrollHeight - this.scrollerElement.clientHeight;
361
+ const base = this.scrollTarget ?? this.scrollerElement.scrollTop;
362
+ if(base >= max - 1) return false;
363
+ let target = Math.min(base + (stepPx ?? this.viewportHeightCached * SCROLL_STEP_FACTOR), max);
364
+ if(animated) target = Math.min(target,
365
+ this.scrollerElement.scrollTop + this.viewportHeightCached * SCROLL_TARGET_MAX_LEAD);
366
+ this.scrollToTarget(target, animated);
367
+ return true;
368
+ }
369
+
370
+ prev(animated = true, stepPx?: number): boolean {
371
+ const base = this.scrollTarget ?? this.scrollerElement.scrollTop;
372
+ if(base <= 1) return false;
373
+ let target = Math.max(base - (stepPx ?? this.viewportHeightCached * SCROLL_STEP_FACTOR), 0);
374
+ if(animated) target = Math.max(target,
375
+ this.scrollerElement.scrollTop - this.viewportHeightCached * SCROLL_TARGET_MAX_LEAD);
376
+ this.scrollToTarget(target, animated);
377
+ return true;
378
+ }
379
+
380
+ private scrollToTarget(target: number, animated: boolean) {
381
+ this.beginProgrammaticScroll();
382
+ this.scrollTarget = target;
383
+ if(!animated) {
384
+ this.stopAnimation();
385
+ this.scrollerElement.scrollTo({ top: target, behavior: "auto" });
386
+ return;
387
+ }
388
+ // A running animation just picks up the new target on its next frame
389
+ if(!this.animating) {
390
+ this.animating = true;
391
+ this.animLastTime = performance.now();
392
+ this.animRAF = requestAnimationFrame(this.banimStep);
393
+ }
394
+ }
395
+
396
+ /**
397
+ * One frame of the owned scroll animation: exponential approach toward
398
+ * the (re)targetable scrollTarget, capped at a cruise speed. Under a held
399
+ * key the cap dominates and the scroll advances at constant speed; once
400
+ * input stops the exponential term eases it out into the target.
401
+ */
402
+ private animStep(now: number) {
403
+ if(this.destroyed) return;
404
+ const target = this.scrollTarget;
405
+ if(target === null) { this.stopAnimation(); return; } // User took over
406
+ const dt = Math.min(Math.max(now - this.animLastTime, 0) / 1000, 0.1);
407
+ this.animLastTime = now;
408
+
409
+ const el = this.scrollerElement;
410
+ const delta = target - el.scrollTop;
411
+ if(Math.abs(delta) <= 1) {
412
+ el.scrollTop = target;
413
+ this.stopAnimation();
414
+ return;
415
+ }
416
+ const maxStep = this.viewportHeightCached * SCROLL_CRUISE_SPEED * dt;
417
+ let step = delta * Math.min(1, SCROLL_APPROACH_RATE * dt);
418
+ if(Math.abs(step) > maxStep) step = Math.sign(delta) * maxStep;
419
+ el.scrollTop += step;
420
+ this.animRAF = requestAnimationFrame(this.banimStep);
421
+ }
422
+
423
+ private stopAnimation() {
424
+ this.animating = false;
425
+ cancelAnimationFrame(this.animRAF);
426
+ }
427
+
428
+ /**
429
+ * Go to the top of a page, optionally at a progression within it.
430
+ * @returns whether the position changed
431
+ */
432
+ goToItem(itemIndex: number, animated = false, progression = 0): boolean {
433
+ // A pending layout-restore would stomp this explicit navigation
434
+ cancelAnimationFrame(this.restoreRAF);
435
+ const index = Math.max(0, Math.min(itemIndex, this.spreader.pages.length - 1));
436
+ const top = this.pageTop(index) + progression * this.pageHeight(index);
437
+ if(Math.abs(this.scrollerElement.scrollTop - top) < 1) return false;
438
+ this.scrollToTarget(top, animated);
439
+ if(!animated) this.updateFraction();
440
+ return true;
441
+ }
442
+
443
+ async update(): Promise<void> {
444
+ if(this.destroyed) return;
445
+ const index = this.currentIndex;
446
+ this.lastWindowIndex = index;
447
+ this.updateWindow(index);
448
+ const pm = this.pool.get(index);
449
+ if(pm) await pm.load();
450
+ }
451
+
452
+ get atStart(): boolean {
453
+ return this.scrollerElement.scrollTop <= 1;
454
+ }
455
+
456
+ get atEnd(): boolean {
457
+ return this.scrollerElement.scrollTop >= this.stripHeightCached - this.viewportHeightCached - 1;
458
+ }
459
+
460
+ get viewport(): VisualNavigatorViewport {
461
+ const viewport: VisualNavigatorViewport = {
462
+ readingOrder: [],
463
+ progressions: new Map(),
464
+ positions: null
465
+ };
466
+ if(!this.pageTops.length) return viewport;
467
+ const top = this.scrollerElement.scrollTop;
468
+ const bottom = top + this.viewportHeightCached;
469
+ const positions: number[] = [];
470
+ for (let i = this.indexAtOffset(top); i < this.spreader.pages.length; i++) {
471
+ const pTop = this.pageTop(i);
472
+ if(pTop >= bottom) break;
473
+ const h = this.pageHeight(i);
474
+ if(pTop + h <= top) continue;
475
+ const page = this.spreader.pages[i];
476
+ const range: ProgressionRange = {
477
+ start: Math.max(0, (top - pTop) / h),
478
+ end: Math.min(1, (bottom - pTop) / h)
479
+ };
480
+ viewport.readingOrder.push(page.link.href);
481
+ viewport.progressions.set(page.link.href, range);
482
+ positions.push(page.number);
483
+ }
484
+ viewport.positions = positions.length ? positions : null;
485
+ return viewport;
486
+ }
487
+
488
+ resizeHandler() {
489
+ const fraction = this.fraction;
490
+ const w = this.displayWidth;
491
+ this.spreader.pages.forEach((page) => {
492
+ this.pool.get(page.index)?.applyStripWidth(w);
493
+ });
494
+ this.refreshLayoutCache();
495
+ this.restoreFraction(fraction);
496
+ }
497
+
498
+ async destroy() {
499
+ this.destroyed = true;
500
+ cancelAnimationFrame(this.scrollRAF);
501
+ cancelAnimationFrame(this.restoreRAF);
502
+ cancelAnimationFrame(this.animRAF);
503
+ window.clearTimeout(this.positionTimer);
504
+ window.clearTimeout(this.programmaticTimer);
505
+ this.scrollerElement.removeEventListener("scroll", this.bscrollHandler);
506
+ this.scrollerElement.removeEventListener("scrollend", this.bscrollendHandler);
507
+ this.scrollerElement.removeEventListener("wheel", this.buserTakeoverHandler);
508
+ this.scrollerElement.removeEventListener("mousedown", this.bmousedownHandler);
509
+ this.scrollerElement.removeEventListener("mouseup", this.bclickHandler);
510
+ this.scrollerElement.removeEventListener("touchend", this.btouchendHandler);
511
+ this.scrollerElement.removeEventListener("touchstart", this.btouchstartHandler);
512
+ // The page wrappers belong to the shared pool; detach without destroying
513
+ this.scrollerElement.replaceChildren();
514
+ this.scrollerElement.remove();
515
+ }
516
+ }