@readium/navigator 2.10.2 → 2.11.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.
Files changed (86) 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/FrameManager.js +1 -1
  19. package/dist/epub/frame/FramePoolManager.js +1 -1
  20. package/dist/epub/fxl/FXLFrameManager.js +1 -1
  21. package/dist/epub/fxl/FXLFramePoolManager.js +1 -1
  22. package/dist/epub/fxl/FXLPeripherals.js +2 -2
  23. package/dist/index.js +1 -1
  24. package/dist/peripherals/KeyboardConditionBridge.js +1 -1
  25. package/dist/peripherals/KeyboardPeripherals.js +1 -1
  26. package/dist/webpub/WebPubFrameManager.js +1 -1
  27. package/dist/webpub/WebPubFramePoolManager.js +1 -1
  28. package/dist/webpub/WebPubNavigator.js +1 -1
  29. package/package.json +5 -5
  30. package/src/audio/AudioNavigator.ts +107 -5
  31. package/src/audio/AudioPoolManager.ts +14 -8
  32. package/src/audio/engine/AudioEngine.ts +34 -1
  33. package/src/audio/engine/WebAudioEngine.ts +48 -3
  34. package/src/divina/DivinaNavigator.ts +704 -0
  35. package/src/divina/DivinaPageManager.ts +322 -0
  36. package/src/divina/DivinaPagedPresenter.ts +508 -0
  37. package/src/divina/DivinaPeripherals.ts +630 -0
  38. package/src/divina/DivinaScrolledPresenter.ts +516 -0
  39. package/src/divina/DivinaSpreader.ts +202 -0
  40. package/src/divina/DivinaVariantSelector.ts +77 -0
  41. package/src/divina/index.ts +8 -0
  42. package/src/divina/preferences/DivinaDefaults.ts +37 -0
  43. package/src/divina/preferences/DivinaPreferences.ts +77 -0
  44. package/src/divina/preferences/DivinaPreferencesEditor.ts +96 -0
  45. package/src/divina/preferences/DivinaSettings.ts +38 -0
  46. package/src/divina/preferences/index.ts +4 -0
  47. package/src/divina/protection/DivinaNavigatorProtector.ts +44 -0
  48. package/src/epub/EpubNavigator.ts +73 -7
  49. package/src/epub/frame/FrameBlobBuilder.ts +144 -39
  50. package/src/epub/frame/FrameManager.ts +7 -9
  51. package/src/epub/frame/FramePoolManager.ts +106 -48
  52. package/src/epub/fxl/FXLFrameManager.ts +69 -26
  53. package/src/epub/fxl/FXLFramePoolManager.ts +98 -53
  54. package/src/epub/fxl/FXLPeripherals.ts +22 -1
  55. package/src/index.ts +1 -0
  56. package/src/peripherals/KeyboardConditionBridge.ts +15 -5
  57. package/src/peripherals/KeyboardPeripherals.ts +13 -7
  58. package/src/webpub/WebPubFrameManager.ts +7 -9
  59. package/src/webpub/WebPubFramePoolManager.ts +5 -2
  60. package/src/webpub/WebPubNavigator.ts +44 -0
  61. package/types/src/audio/AudioNavigator.d.ts +29 -0
  62. package/types/src/audio/engine/AudioEngine.d.ts +27 -1
  63. package/types/src/audio/engine/WebAudioEngine.d.ts +14 -2
  64. package/types/src/divina/DivinaNavigator.d.ts +119 -0
  65. package/types/src/divina/DivinaPageManager.d.ts +66 -0
  66. package/types/src/divina/DivinaPagedPresenter.d.ts +122 -0
  67. package/types/src/divina/DivinaPeripherals.d.ts +121 -0
  68. package/types/src/divina/DivinaScrolledPresenter.d.ts +125 -0
  69. package/types/src/divina/DivinaSpreader.d.ts +65 -0
  70. package/types/src/divina/DivinaVariantSelector.d.ts +30 -0
  71. package/types/src/divina/index.d.ts +8 -0
  72. package/types/src/divina/preferences/DivinaDefaults.d.ts +18 -0
  73. package/types/src/divina/preferences/DivinaPreferences.d.ts +33 -0
  74. package/types/src/divina/preferences/DivinaPreferencesEditor.d.ts +20 -0
  75. package/types/src/divina/preferences/DivinaSettings.d.ts +26 -0
  76. package/types/src/divina/preferences/index.d.ts +4 -0
  77. package/types/src/divina/protection/DivinaNavigatorProtector.d.ts +14 -0
  78. package/types/src/epub/EpubNavigator.d.ts +7 -1
  79. package/types/src/epub/frame/FrameBlobBuilder.d.ts +19 -3
  80. package/types/src/epub/frame/FramePoolManager.d.ts +5 -1
  81. package/types/src/epub/fxl/FXLFrameManager.d.ts +2 -0
  82. package/types/src/epub/fxl/FXLFramePoolManager.d.ts +8 -2
  83. package/types/src/epub/fxl/FXLPeripherals.d.ts +3 -0
  84. package/types/src/index.d.ts +1 -0
  85. package/types/src/peripherals/KeyboardPeripherals.d.ts +1 -0
  86. package/types/src/webpub/WebPubNavigator.d.ts +2 -0
@@ -0,0 +1,508 @@
1
+ import { Page, Publication, ReadingProgression } from "@readium/shared";
2
+ import { VisualNavigatorViewport } from "../Navigator.ts";
3
+ import { isTypedOMSupported } from "../epub/fxl/FXLPeripherals.ts";
4
+ import { DivinaPageManager } from "./DivinaPageManager.ts";
5
+ import { DivinaManagerEventKey, DivinaPagedManager, DivinaPeripherals } from "./DivinaPeripherals.ts";
6
+ import { DivinaPage, DivinaSpreader } from "./DivinaSpreader.ts";
7
+
8
+ const UPPER_BOUNDARY = 8; // Unload pages further than this many items away
9
+ const LOWER_BOUNDARY = 4; // Load pages within this many items
10
+ const OFFSCREEN_LOAD_DELAY = 300; // Delay offscreen loads to avoid janking the slide animation
11
+ const SLIDE_FAST = 150;
12
+ const SLIDE_SLOW = 500;
13
+
14
+ export type DivinaPresenterListener = (key: DivinaManagerEventKey, data: unknown) => void;
15
+
16
+ /**
17
+ * Horizontal paged presenter for Divina publications: a sliding spine of
18
+ * page slots with synthetic spreads, ported from the FXLFramePoolManager
19
+ * slider core but rendering <img> pages instead of iframes.
20
+ *
21
+ * Slot model: in double-page mode every spread occupies exactly two slots
22
+ * (single pages get a double-width slot), so slide indices are always even
23
+ * and always aligned with spread boundaries.
24
+ */
25
+ export class DivinaPagedPresenter implements DivinaPagedManager {
26
+ private readonly container: HTMLElement;
27
+ private readonly pub: Publication;
28
+ private readonly spreader: DivinaSpreader;
29
+ private readonly pool: Map<number, DivinaPageManager>;
30
+
31
+ public readonly bookElement: HTMLDivElement;
32
+ public readonly spineElement: HTMLDivElement;
33
+ public readonly peripherals: DivinaPeripherals;
34
+
35
+ public width = 0;
36
+ public height = 0;
37
+ public currentSlide = 0;
38
+ private transform: number | undefined;
39
+ private spread: boolean;
40
+ private orientationInternal = -1; // Portrait = 1, Landscape = 0, Unknown = -1
41
+ private containerHeightCached: number;
42
+ private destroyed = false;
43
+ private loadTimers: Map<number, number> = new Map();
44
+
45
+ public listener: DivinaPresenterListener = () => {};
46
+
47
+ constructor(
48
+ container: HTMLElement,
49
+ pub: Publication,
50
+ spreader: DivinaSpreader,
51
+ pool: Map<number, DivinaPageManager>,
52
+ spreads: boolean
53
+ ) {
54
+ this.container = container;
55
+ this.pub = pub;
56
+ this.spreader = spreader;
57
+ this.pool = pool;
58
+ this.spread = spreads;
59
+ this.containerHeightCached = container.clientHeight;
60
+
61
+ this.bookElement = document.createElement("div");
62
+ this.bookElement.ariaLabel = "Book";
63
+ this.bookElement.tabIndex = -1;
64
+ this.updateBookStyle(true);
65
+
66
+ this.spineElement = document.createElement("div");
67
+ this.spineElement.ariaLabel = "Spine";
68
+
69
+ this.bookElement.appendChild(this.spineElement);
70
+ this.container.appendChild(this.bookElement);
71
+
72
+ const fragment = document.createDocumentFragment();
73
+ this.spreader.pages.forEach((page) => {
74
+ const pm = this.pool.get(page.index)!;
75
+ pm.setMode("paged");
76
+ pm.wrapper.style.cssFloat = pm.wrapper.style.float = this.rtl ? "right" : "left";
77
+ pm.wrapper.style.contain = "strict";
78
+ fragment.appendChild(pm.wrapper);
79
+ });
80
+ this.spineElement.appendChild(fragment);
81
+
82
+ this.updateSpineStyle(false);
83
+ this.updateSlotSizes();
84
+
85
+ this.peripherals = new DivinaPeripherals(this);
86
+ }
87
+
88
+ public get rtl() {
89
+ return this.pub.metadata.effectiveReadingProgression === ReadingProgression.rtl;
90
+ }
91
+
92
+ private get single() {
93
+ return !this.spread || this.portrait;
94
+ }
95
+
96
+ public get perPage() {
97
+ return (this.spread && !this.portrait) ? 2 : 1;
98
+ }
99
+
100
+ get threshold(): number {
101
+ return 50;
102
+ }
103
+
104
+ get portrait(): boolean {
105
+ if(this.orientationInternal === -1) {
106
+ this.orientationInternal = this.containerHeightCached > this.container.clientWidth ? 1 : 0;
107
+ }
108
+ return this.orientationInternal === 1;
109
+ }
110
+
111
+ get slength() {
112
+ return this.spreader.pages.length;
113
+ }
114
+
115
+ /** Total slot count in the current mode */
116
+ get length() {
117
+ return this.single ? this.slength : this.spreader.slotCount;
118
+ }
119
+
120
+ /**
121
+ * The x position of a slot boundary within the spine, snapped to device
122
+ * pixels. Fractional boundaries leave hairline gaps at the spread gutter
123
+ * (the background shows through between the two page images), varying
124
+ * with the window size.
125
+ */
126
+ private slotEdge(slot: number): number {
127
+ const dpr = window.devicePixelRatio || 1;
128
+ return Math.round(slot * (this.width / this.perPage) * dpr) / dpr;
129
+ }
130
+
131
+ private get offset() {
132
+ return (this.rtl ? 1 : -1) * this.slotEdge(this.currentSlide);
133
+ }
134
+
135
+ get doNotDisturb() {
136
+ return this.peripherals.pan.touchID > 0;
137
+ }
138
+
139
+ /** The reading-order index of the first page of the current spread */
140
+ get currentIndex(): number {
141
+ return this.single ?
142
+ Math.max(0, Math.min(this.currentSlide, this.slength - 1)) :
143
+ this.spreader.itemOfSlot(this.currentSlide);
144
+ }
145
+
146
+ /** The pages currently (meant to be) visible */
147
+ get currentSpread(): DivinaPage[] {
148
+ if(this.perPage < 2) {
149
+ return [this.spreader.pages[this.currentIndex]];
150
+ }
151
+ return this.spreader.spreadOfItem(this.currentIndex);
152
+ }
153
+
154
+ private updateDimensions() {
155
+ this.width = this.bookElement.clientWidth;
156
+ this.height = this.bookElement.clientHeight;
157
+ }
158
+
159
+ // Reused typed transform for the per-event zoom/pan write (see the
160
+ // matching note in DivinaPeripherals: mutation beats reallocation)
161
+ private bookScale: CSSScale | null = null;
162
+ private bookTranslate: CSSTranslate | null = null;
163
+ private bookTransform: CSSTransformValue | null = null;
164
+
165
+ public updateBookStyle(initial = false) {
166
+ if(initial) {
167
+ const bookStyle = {
168
+ overflow: "hidden",
169
+ direction: this.pub.metadata.effectiveReadingProgression,
170
+ cursor: "",
171
+ height: "100%",
172
+ width: "100%",
173
+ position: "relative",
174
+ outline: "none",
175
+ transition: this.peripherals?.dragState ? "none" : "transform .15s ease-in-out",
176
+ touchAction: "none",
177
+ } as unknown as CSSStyleDeclaration;
178
+ Object.assign(this.bookElement.style, bookStyle);
179
+ }
180
+ const scale = this.peripherals?.scale || 1;
181
+ const translateX = this.peripherals?.pan.translateX || 0;
182
+ const translateY = this.peripherals?.pan.translateY || 0;
183
+ if(isTypedOMSupported()) {
184
+ if(!this.bookTransform) {
185
+ this.bookScale = new CSSScale(1, 1);
186
+ this.bookTranslate = new CSSTranslate(CSS.px(0), CSS.px(0), CSS.px(0));
187
+ this.bookTransform = new CSSTransformValue([this.bookScale, this.bookTranslate]);
188
+ }
189
+ (this.bookScale!.x as CSSUnitValue).value = scale;
190
+ (this.bookScale!.y as CSSUnitValue).value = scale;
191
+ (this.bookTranslate!.x as CSSUnitValue).value = translateX;
192
+ (this.bookTranslate!.y as CSSUnitValue).value = translateY;
193
+ this.bookElement.attributeStyleMap.set("transform", this.bookTransform);
194
+ } else {
195
+ this.bookElement.style.transform = `scale(${scale}) translate3d(${translateX}px, ${translateY}px, 0px)`;
196
+ }
197
+ }
198
+
199
+ public updateSpineStyle(animate: boolean, fast = true) {
200
+ this.updateDimensions();
201
+ if(isTypedOMSupported()) {
202
+ this.spineElement.attributeStyleMap.set("transition", new CSSUnparsedValue([animate ? `all ${fast ? SLIDE_FAST : SLIDE_SLOW}ms ease-out` : "all 0ms ease-out"]));
203
+ this.spineElement.attributeStyleMap.set("width", CSS.px(this.slotEdge(this.length)));
204
+ this.spineElement.attributeStyleMap.set("height", CSS.percent(100));
205
+ this.transform !== undefined ? this.spineElement.attributeStyleMap.set("transform", new CSSTransformValue([
206
+ new CSSTranslate(CSS.px(this.transform || 0), CSS.px(0), CSS.px(0))
207
+ ])) : this.spineElement.attributeStyleMap.delete("transform");
208
+ this.spineElement.attributeStyleMap.set("contain", new CSSUnparsedValue(["content"]));
209
+ } else {
210
+ const spineStyle = {
211
+ transition: animate ? `all ${fast ? SLIDE_FAST : SLIDE_SLOW}ms ease-out` : "all 0ms ease-out",
212
+ width: `${this.slotEdge(this.length)}px`,
213
+ height: "100%",
214
+ transform: this.transform !== undefined ? `translate3d(${this.transform}px, 0px, 0px)` : "",
215
+ contain: "content"
216
+ } as unknown as CSSStyleDeclaration;
217
+ Object.assign(this.spineElement.style, spineStyle);
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Recompute every slot's width and height. Widths are derived from
223
+ * device-pixel-snapped slot boundaries so adjacent pages of a spread
224
+ * meet exactly at the gutter without subpixel seams.
225
+ */
226
+ private updateSlotSizes() {
227
+ this.updateDimensions();
228
+ const typedOM = isTypedOMSupported();
229
+ this.spreader.pages.forEach((page) => {
230
+ const pm = this.pool.get(page.index)!;
231
+ const span = !this.single && this.spreader.isDouble(page.index) ? 2 : 1;
232
+ const start = this.single ? page.index : this.spreader.slotOfItem(page.index);
233
+ const width = this.slotEdge(start + span) - this.slotEdge(start);
234
+ if(typedOM) {
235
+ pm.wrapper.attributeStyleMap.set("width", CSS.px(width));
236
+ pm.wrapper.attributeStyleMap.set("height", CSS.px(this.height));
237
+ } else {
238
+ pm.wrapper.style.width = `${width}px`;
239
+ pm.wrapper.style.height = `${this.height}px`;
240
+ }
241
+ });
242
+ }
243
+
244
+ reAlign(index: number = this.currentSlide) {
245
+ if (index % 2 && !this.single) // Prevent getting out of track
246
+ index++;
247
+ return index;
248
+ }
249
+
250
+ /**
251
+ * Moves the spine to the position of the currently active slide.
252
+ * Always commits: the spine transform may have been written directly by
253
+ * the peripherals during a drag (snap-back relies on this), and reading
254
+ * style.transform back for comparison is unreliable under CSS Typed OM.
255
+ */
256
+ slideToCurrent(enableTransition?: boolean, fast = true) {
257
+ this.updateDimensions();
258
+ const commit = (animate: boolean) => {
259
+ this.transform = this.offset;
260
+ this.updateSpineStyle(animate, fast);
261
+ this.deselect();
262
+ };
263
+ if (enableTransition) {
264
+ // Double rAF guarantees the transition fires:
265
+ // https://youtu.be/cCOL7MC4Pl0
266
+ requestAnimationFrame(() => {
267
+ requestAnimationFrame(() => commit(true));
268
+ });
269
+ } else {
270
+ commit(false);
271
+ }
272
+ }
273
+
274
+ bounce(rtl = false) {
275
+ requestAnimationFrame(() => {
276
+ this.transform = this.offset + (50 * (rtl ? 1 : -1));
277
+ this.updateSpineStyle(true, true);
278
+ setTimeout(() => {
279
+ if(this.destroyed) return;
280
+ this.transform = this.offset;
281
+ this.updateSpineStyle(true, true);
282
+ }, 100);
283
+ });
284
+ }
285
+
286
+ private onChange() {
287
+ this.peripherals.scale = 1;
288
+ this.peripherals.pan.translateX = 0;
289
+ this.peripherals.pan.translateY = 0;
290
+ this.updateBookStyle();
291
+ }
292
+
293
+ /**
294
+ * Go to the next spread/page.
295
+ * @returns whether moving forward was possible
296
+ */
297
+ next(): boolean {
298
+ if (this.length <= this.perPage) return false;
299
+
300
+ const beforeChange = this.currentSlide;
301
+ this.currentSlide = Math.min(this.currentSlide + this.perPage, this.length - this.perPage);
302
+ this.currentSlide = this.reAlign();
303
+
304
+ if (beforeChange !== this.currentSlide) {
305
+ this.slideToCurrent(true);
306
+ this.onChange();
307
+ return true;
308
+ } else {
309
+ this.bounce(this.rtl);
310
+ return false;
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Go to the previous spread/page.
316
+ * @returns whether moving backward was possible
317
+ */
318
+ prev(): boolean {
319
+ if (this.length <= this.perPage) return false;
320
+
321
+ const beforeChange = this.currentSlide;
322
+ this.currentSlide = this.reAlign(Math.max(this.currentSlide - this.perPage, 0));
323
+
324
+ if (beforeChange !== this.currentSlide) {
325
+ this.slideToCurrent(true);
326
+ this.onChange();
327
+ return true;
328
+ } else {
329
+ this.bounce(!this.rtl);
330
+ return false;
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Go to the slide showing the given reading order item.
336
+ * @returns whether the slide changed
337
+ */
338
+ /**
339
+ * The slide showing the given item in the current mode: the item index
340
+ * itself in single mode, or the start slot of the item's spread in
341
+ * double-page mode (the item may occupy the spread's second slot).
342
+ */
343
+ private slideForItem(itemIndex: number): number {
344
+ const clamped = Math.max(0, Math.min(itemIndex, this.slength - 1));
345
+ return this.single ?
346
+ clamped :
347
+ this.spreader.slotOfItem(this.spreader.spreadOfItem(clamped)[0].index);
348
+ }
349
+
350
+ goToItem(itemIndex: number, animated = true): boolean {
351
+ const target = this.slideForItem(itemIndex);
352
+ if(target === this.currentSlide) return false;
353
+ this.currentSlide = target;
354
+ this.slideToCurrent(animated);
355
+ this.onChange();
356
+ return true;
357
+ }
358
+
359
+ /**
360
+ * Loads pages within the load window around the current position,
361
+ * unloads distant ones, and positions the current spread.
362
+ */
363
+ async update(): Promise<void> {
364
+ if(this.destroyed) return;
365
+ const i = this.currentIndex;
366
+ const spread = this.currentSpread;
367
+ const spreadIndexes = new Set(spread.map(p => p.index));
368
+
369
+ this.spreader.pages.forEach((page) => {
370
+ const pm = this.pool.get(page.index)!;
371
+ const distance = Math.abs(page.index - i);
372
+ if(distance > UPPER_BOUNDARY) {
373
+ const timer = this.loadTimers.get(page.index);
374
+ if(timer) {
375
+ clearTimeout(timer);
376
+ this.loadTimers.delete(page.index);
377
+ }
378
+ pm.unload();
379
+ } else if(distance <= LOWER_BOUNDARY && !spreadIndexes.has(page.index)) {
380
+ // Load offscreen neighbors after a delay so decoding doesn't
381
+ // jank the slide animation
382
+ if(!pm.loaded && !this.loadTimers.has(page.index)) {
383
+ const timer = window.setTimeout(() => {
384
+ this.loadTimers.delete(page.index);
385
+ if(!this.destroyed) pm.load();
386
+ }, OFFSCREEN_LOAD_DELAY);
387
+ this.loadTimers.set(page.index, timer);
388
+ }
389
+ }
390
+ });
391
+
392
+ // Current spread: load immediately and position against the gutter
393
+ await Promise.all(spread.map((page) => {
394
+ const pm = this.pool.get(page.index)!;
395
+ const timer = this.loadTimers.get(page.index);
396
+ if(timer) {
397
+ clearTimeout(timer);
398
+ this.loadTimers.delete(page.index);
399
+ }
400
+ this.placePage(spread, page);
401
+ return pm.load();
402
+ }));
403
+ }
404
+
405
+ /**
406
+ * Fit a page of the current spread within its slot. A lone portrait page
407
+ * with a left/right hint (shifted cover, orphaned spread half) is fitted
408
+ * into its half of the full-spread slot; everything else is fitted to the
409
+ * whole slot and anchored against the gutter (or centered).
410
+ */
411
+ private placePage(spread: DivinaPage[], page: DivinaPage) {
412
+ const pm = this.pool.get(page.index);
413
+ if(!pm) return;
414
+ if(this.perPage > 1 && spread.length === 1 && !page.isLandscape && page.page !== Page.center) {
415
+ pm.fit(page.page, true);
416
+ } else {
417
+ pm.fit(this.spreader.spreadPosition(spread, page, this.perPage));
418
+ }
419
+ }
420
+
421
+ /** Union of the current spread's image bounds, used for zoom/pan clamping */
422
+ get currentBounds(): DOMRect {
423
+ const ret = {
424
+ x: 0, y: 0, width: 0, height: 0,
425
+ top: 0, right: 0, bottom: 0, left: 0,
426
+ toJSON() { return this; },
427
+ };
428
+ this.currentSpread.forEach(page => {
429
+ const pm = this.pool.get(page.index);
430
+ if(!pm) return;
431
+ const b = pm.img.getBoundingClientRect();
432
+ ret.x = Math.min(ret.x, b.x);
433
+ ret.y = Math.min(ret.y, b.y);
434
+ ret.width += b.width;
435
+ ret.height = Math.max(ret.height, b.height);
436
+ ret.top = Math.min(ret.top, b.top);
437
+ ret.right = Math.min(ret.right, b.right);
438
+ ret.bottom = Math.min(ret.bottom, b.bottom);
439
+ ret.left = Math.min(ret.left, b.left);
440
+ });
441
+ return ret as DOMRect;
442
+ }
443
+
444
+ get viewport(): VisualNavigatorViewport {
445
+ const viewport: VisualNavigatorViewport = {
446
+ readingOrder: [],
447
+ progressions: new Map(),
448
+ positions: null
449
+ };
450
+ const positions: number[] = [];
451
+ this.currentSpread.forEach(page => {
452
+ viewport.readingOrder.push(page.link.href);
453
+ viewport.progressions.set(page.link.href, { start: 0, end: 1 }); // Paged always uses [0,1]
454
+ positions.push(page.number);
455
+ });
456
+ viewport.positions = positions;
457
+ return viewport;
458
+ }
459
+
460
+ /**
461
+ * When the container resizes, resize slider components as well
462
+ */
463
+ resizeHandler(slide = true, fast = true) {
464
+ // Capture the current item BEFORE invalidating the orientation cache:
465
+ // currentSlide's domain (item vs slot index) depends on the mode, which
466
+ // may flip with the orientation.
467
+ const item = this.currentIndex;
468
+ this.containerHeightCached = this.container.clientHeight;
469
+ this.orientationInternal = -1;
470
+ this.currentSlide = this.slideForItem(item);
471
+
472
+ this.updateSpineStyle(false);
473
+ this.updateSlotSizes();
474
+ if(slide) {
475
+ this.slideToCurrent(!fast, fast);
476
+ }
477
+ // Reposition the current spread against the gutter (perPage may have changed)
478
+ const spread = this.currentSpread;
479
+ spread.forEach((page) => this.placePage(spread, page));
480
+ }
481
+
482
+ /** Toggle double-page spreads (from preferences) */
483
+ setSpreads(spreads: boolean) {
484
+ if(this.spread === spreads) return;
485
+ const item = this.currentIndex;
486
+ this.spread = spreads;
487
+ this.currentSlide = this.slideForItem(item);
488
+ requestAnimationFrame(() => {
489
+ if(this.destroyed) return;
490
+ this.resizeHandler(true);
491
+ this.update();
492
+ });
493
+ }
494
+
495
+ deselect() {
496
+ this.container.ownerDocument.defaultView?.getSelection()?.removeAllRanges();
497
+ }
498
+
499
+ async destroy() {
500
+ this.destroyed = true;
501
+ this.loadTimers.forEach(t => clearTimeout(t));
502
+ this.loadTimers.clear();
503
+ this.peripherals.destroy();
504
+ // The page wrappers belong to the shared pool; detach them without destroying
505
+ this.spineElement.replaceChildren();
506
+ this.bookElement.remove();
507
+ }
508
+ }