@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
@@ -1,9 +1,9 @@
1
1
  import { ModuleName } from "@readium/navigator-html-injectables";
2
- import { Locator, Publication, ReadingProgression, Page, Link } from "@readium/shared";
2
+ import { Locator, Publication, ReadingProgression, Page, Link, Layout } from "@readium/shared";
3
3
  import { FrameCommsListener } from "../frame/index.ts";
4
- import FrameBlobBuider from "../frame/FrameBlobBuilder.ts";
4
+ import FrameBlobBuilder, { UpgradeInsecureRequests } from "../frame/FrameBlobBuilder.ts";
5
5
  import { FXLFrameManager } from "./FXLFrameManager.ts";
6
- import { FXLPeripherals } from "./FXLPeripherals.ts";
6
+ import { FXLPeripherals, isTypedOMSupported } from "./FXLPeripherals.ts";
7
7
  import { FXLSpreader, Orientation, Spread } from "./FXLSpreader.ts";
8
8
  import { VisualNavigatorViewport, IContentProtectionConfig, IKeyboardPeripheralsConfig } from "../../Navigator.ts";
9
9
  import { Injector } from "../../injection/Injector.ts";
@@ -21,7 +21,7 @@ export class FXLFramePoolManager {
21
21
  private readonly container: HTMLElement;
22
22
  private readonly positions: Locator[];
23
23
  private readonly pool: Map<string, FXLFrameManager> = new Map();
24
- private readonly blobs: Map<string, string> = new Map();
24
+ private readonly blobs: Map<string, FrameBlobBuilder> = new Map();
25
25
  private readonly inprogress: Map<string, Promise<void>> = new Map();
26
26
  private readonly delayedShow: Map<string, Promise<void>> = new Map();
27
27
  private readonly delayedTimeout: Map<string, number> = new Map();
@@ -31,6 +31,7 @@ export class FXLFramePoolManager {
31
31
  private readonly contentProtectionConfig: IContentProtectionConfig;
32
32
  private readonly keyboardPeripheralsConfig: IKeyboardPeripheralsConfig;
33
33
  private resizeWatchSelectors: string[];
34
+ private readonly upgradeInsecureRequests: UpgradeInsecureRequests;
34
35
 
35
36
  // NEW
36
37
  private readonly bookElement: HTMLDivElement;
@@ -38,7 +39,7 @@ export class FXLFramePoolManager {
38
39
  private readonly pub: Publication;
39
40
  public width: number = 0;
40
41
  public height: number = 0;
41
- private transform: string = "";
42
+ private transform: number | undefined;
42
43
  public currentSlide: number = 0;
43
44
  private spreader: FXLSpreader;
44
45
  private spread = true; // TODO
@@ -53,10 +54,12 @@ export class FXLFramePoolManager {
53
54
  container: HTMLElement,
54
55
  positions: Locator[],
55
56
  pub: Publication,
57
+ _layout: Layout, // TODO: used by upcoming scrolled-layout support
56
58
  injector?: Injector | null,
57
59
  contentProtectionConfig?: IContentProtectionConfig,
58
60
  keyboardPeripheralsConfig?: IKeyboardPeripheralsConfig,
59
61
  resizeWatchSelectors: string[] = [],
62
+ upgradeInsecureRequests?: UpgradeInsecureRequests,
60
63
  ) {
61
64
  this.container = container;
62
65
  this.positions = positions;
@@ -65,11 +68,12 @@ export class FXLFramePoolManager {
65
68
  this.contentProtectionConfig = contentProtectionConfig || {};
66
69
  this.keyboardPeripheralsConfig = keyboardPeripheralsConfig || [];
67
70
  this.resizeWatchSelectors = [...resizeWatchSelectors];
71
+ this.upgradeInsecureRequests = upgradeInsecureRequests ?? 'always';
68
72
  this.spreadPresentation = pub.metadata.otherMetadata?.spread || Spread.auto;
69
73
 
70
74
  if(this.pub.metadata.effectiveReadingProgression !== ReadingProgression.rtl && this.pub.metadata.effectiveReadingProgression !== ReadingProgression.ltr)
71
75
  // TODO support TTB and BTT
72
- throw Error("Unsupported reading progression for EPUB");
76
+ throw Error("Unsupported reading progression for fixed-layout EPUB");
73
77
 
74
78
  // NEW
75
79
  this.spreader = new FXLSpreader(this.pub);
@@ -89,16 +93,18 @@ export class FXLFramePoolManager {
89
93
 
90
94
  this.peripherals = new FXLPeripherals(this);
91
95
 
96
+ const fragment = document.createDocumentFragment();
92
97
  this.pub.readingOrder.items.forEach((link) => {
93
98
  // Create <iframe>
94
99
  const fm = new FXLFrameManager(this.peripherals, this.pub.metadata.effectiveReadingProgression, link.href, this.contentProtectionConfig, this.keyboardPeripheralsConfig, this.resizeWatchSelectors);
95
- this.spineElement.appendChild(fm.element);
96
100
 
97
- // this.pages.push(fm);
98
101
  this.pool.set(link.href, fm);
99
102
  fm.width = 100 / this.length * (link.properties?.otherProperties["orientation"] === Orientation.landscape || link.properties?.otherProperties["addBlank"] ? this.perPage : 1);
100
103
  fm.height = this.height;
104
+ fragment.appendChild(fm.wrapper);
101
105
  });
106
+ this.spineElement.appendChild(fragment);
107
+
102
108
  }
103
109
 
104
110
  private _listener!: FrameCommsListener;
@@ -137,15 +143,18 @@ export class FXLFramePoolManager {
137
143
 
138
144
  clearTimeout(this.resizeTimeout);
139
145
  this.resizeTimeout = window.setTimeout(() => {
140
- // TODO optimize this expensive set of loops and operations
146
+ const baseWidthFactor = 100 / this.length;
147
+ const itemLookup = new Map(this.pub.readingOrder.items.map(item => [item.href, item]));
141
148
  this.pool.forEach((frm, linkHref) => {
142
- let i = this.pub.readingOrder.items.findIndex(l => l.href === linkHref);
143
- const link = this.pub.readingOrder.items[i];
144
- frm.width = 100 / this.length * (link.properties?.otherProperties["orientation"] === Orientation.landscape || link.properties?.otherProperties["addBlank"] ? this.perPage : 1);
145
- frm.height = this.height;
146
- if(!frm.loaded) return;
147
- const spread = this.spreader.findByLink(link)!;
148
- frm.update(this.spreadPosition(spread, link));
149
+ const link = itemLookup.get(linkHref);
150
+ if(!link) return;
151
+ requestAnimationFrame(() => {
152
+ frm.width = baseWidthFactor * (link.properties?.otherProperties["orientation"] === Orientation.landscape || link.properties?.otherProperties["addBlank"] ? this.perPage : 1);
153
+ frm.height = this.height;
154
+ if(!frm.loaded) return;
155
+ const spread = this.spreader.findByLink(link)!;
156
+ frm.update(this.spreadPosition(spread, link));
157
+ });
149
158
  });
150
159
  }, RESIZE_UPDATE_TIMEOUT);
151
160
  }
@@ -184,25 +193,42 @@ export class FXLFramePoolManager {
184
193
  }
185
194
 
186
195
  public updateSpineStyle(animate: boolean, fast = true) {
187
- let margin = "0";
196
+ let margin = 0;
188
197
  this.updateDimensions();
189
198
  if(this.perPage > 1 && true) // this.shift
190
- margin = `${this.width / 2}px`;
191
-
192
- const spineStyle = {
193
- transition: animate ? `all ${fast ? SLIDE_FAST : SLIDE_SLOW}ms ease-out` : "all 0ms ease-out",
194
- marginRight: this.rtl ? margin : "0",
195
- marginLeft: this.rtl ? "0" : margin,
196
- width: `${(this.width / this.perPage) * this.length}px`,
197
- transform: this.transform,
198
-
199
- // Static (should be moved to CSS)
200
- contain: "content"
201
- } as CSSStyleDeclaration;
202
-
203
- Object.assign(this.spineElement.style, spineStyle);
199
+ margin = this.width / 2;
200
+
201
+ if(isTypedOMSupported()) {
202
+ // TODO: don't use UnparsedValue, but rather the longhand of every transition property
203
+ this.spineElement.attributeStyleMap.set("transition", new CSSUnparsedValue([animate ? `all ${fast ? SLIDE_FAST : SLIDE_SLOW}ms ease-out` : "all 0ms ease-out"]));
204
+ this.spineElement.attributeStyleMap.set("margin-right", CSS.px(this.rtl ? margin : 0));
205
+ this.spineElement.attributeStyleMap.set("margin-left", CSS.px(this.rtl ? 0 : margin));
206
+ this.spineElement.attributeStyleMap.set("width", CSS.px((this.width / this.perPage) * this.length));
207
+ this.transform ? this.spineElement.attributeStyleMap.set("transform", new CSSTransformValue([
208
+ new CSSTranslate(CSS.px(this.transform || 0), CSS.px(0), CSS.px(0))
209
+ ])) : this.spineElement.attributeStyleMap.delete("transform");
210
+ this.spineElement.attributeStyleMap.set("contain", new CSSUnparsedValue(["content"]));
211
+ } else {
212
+ const spineStyle = {
213
+ transition: animate ? `all ${fast ? SLIDE_FAST : SLIDE_SLOW}ms ease-out` : "all 0ms ease-out",
214
+ marginRight: this.rtl ? `${margin}px` : "0",
215
+ marginLeft: this.rtl ? "0" : `${margin}px`,
216
+ width: `${(this.width / this.perPage) * this.length}px`,
217
+ transform: this.transform ? `translate3d(${this.transform}px, 0px, 0px)` : "",
218
+ // Static (should be moved to CSS)
219
+ contain: "content"
220
+ } as CSSStyleDeclaration;
221
+ Object.assign(this.spineElement.style, spineStyle);
222
+ }
204
223
  }
205
224
 
225
+ // Reused typed transform for the per-event zoom/pan write: allocating
226
+ // fresh CSSTransformValue objects per write is slower than string
227
+ // parsing; mutating a persistent one is faster than both
228
+ private bookScale: CSSScale | null = null;
229
+ private bookTranslate: CSSTranslate | null = null;
230
+ private bookTransform: CSSTransformValue | null = null;
231
+
206
232
  public updateBookStyle(initial=false) {
207
233
  if(initial) {
208
234
  const bookStyle = {
@@ -221,7 +247,23 @@ export class FXLFramePoolManager {
221
247
  } as CSSStyleDeclaration;
222
248
  Object.assign(this.bookElement.style, bookStyle);
223
249
  }
224
- this.bookElement.style.transform = `scale(${this.peripherals?.scale || 1})` + (this.peripherals ? ` translate3d(${this.peripherals.pan.translateX}px, ${this.peripherals.pan.translateY}px, 0px)` : "");
250
+ const scale = this.peripherals?.scale || 1;
251
+ const translateX = this.peripherals?.pan.translateX || 0;
252
+ const translateY = this.peripherals?.pan.translateY || 0;
253
+ if(isTypedOMSupported()) {
254
+ if(!this.bookTransform) {
255
+ this.bookScale = new CSSScale(1, 1);
256
+ this.bookTranslate = new CSSTranslate(CSS.px(0), CSS.px(0), CSS.px(0));
257
+ this.bookTransform = new CSSTransformValue([this.bookScale, this.bookTranslate]);
258
+ }
259
+ (this.bookScale!.x as CSSUnitValue).value = scale;
260
+ (this.bookScale!.y as CSSUnitValue).value = scale;
261
+ (this.bookTranslate!.x as CSSUnitValue).value = translateX;
262
+ (this.bookTranslate!.y as CSSUnitValue).value = translateY;
263
+ this.bookElement.attributeStyleMap.set("transform", this.bookTransform);
264
+ } else {
265
+ this.bookElement.style.transform = `scale(${scale}) translate3d(${translateX}px, ${translateY}px, 0px)`;
266
+ }
225
267
  }
226
268
 
227
269
  /**
@@ -285,22 +327,22 @@ export class FXLFramePoolManager {
285
327
  */
286
328
  slideToCurrent(enableTransition?: boolean, fast = true) {
287
329
  this.updateDimensions();
330
+ // Always commit: the spine transform may have been written directly
331
+ // by the peripherals during a drag (snap-back relies on this), and
332
+ // reading style.transform back as a string for comparison doesn't
333
+ // match under CSS Typed OM (its serialization differs).
288
334
  if (enableTransition) {
289
335
  // This one is tricky, I know but this is a perfect explanation:
290
336
  // https://youtu.be/cCOL7MC4Pl0
291
337
  requestAnimationFrame(() => {
292
338
  requestAnimationFrame(() => {
293
- const newTransform = `translate3d(${this.offset}px, 0, 0)`;
294
- if(this.spineElement.style.transform === newTransform) return;
295
- this.transform = newTransform;
339
+ this.transform = this.offset;
296
340
  this.updateSpineStyle(true, fast);
297
341
  this.deselect();
298
342
  });
299
343
  });
300
344
  } else {
301
- const newTransform = `translate3d(${this.offset}px, 0, 0)`;
302
- if(this.spineElement.style.transform === newTransform) return;
303
- this.transform = newTransform;
345
+ this.transform = this.offset;
304
346
  this.updateSpineStyle(false);
305
347
  this.deselect();
306
348
  }
@@ -308,10 +350,10 @@ export class FXLFramePoolManager {
308
350
 
309
351
  bounce(rtl = false) {
310
352
  requestAnimationFrame(() => {
311
- this.transform = `translate3d(${this.offset+(50 * (rtl ? 1 : -1))}px, 0, 0)`;
353
+ this.transform = this.offset + (50 * (rtl ? 1 : -1));
312
354
  this.updateSpineStyle(true, true);
313
355
  setTimeout(() => {
314
- this.transform = `translate3d(${this.offset}px, 0, 0)`;
356
+ this.transform = this.offset;
315
357
  this.updateSpineStyle(true, true);
316
358
  }, 100);
317
359
  });
@@ -408,7 +450,8 @@ export class FXLFramePoolManager {
408
450
  this.pool.clear();
409
451
 
410
452
  // Revoke all blobs
411
- this.blobs.forEach(v => URL.revokeObjectURL(v));
453
+ this.blobs.forEach(v => v.reset());
454
+ this.blobs.clear();
412
455
 
413
456
  // Clean up injector if it exists
414
457
  this.injector?.dispose();
@@ -499,13 +542,13 @@ export class FXLFramePoolManager {
499
542
  if(!this.pool.has(href)) return;
500
543
  this.cancelShowing(href);
501
544
  await this.pool.get(href)?.unload();
502
- // this.pool.delete(href);
545
+ this.blobs.get(href)?.reset();
503
546
  });
504
547
 
505
548
  // Check if base URL of publication has changed
506
549
  if(this.currentBaseURL !== undefined && pub.baseURL !== this.currentBaseURL) {
507
550
  // Revoke all blobs
508
- this.blobs.forEach(v => URL.revokeObjectURL(v));
551
+ this.blobs.forEach(v => v.reset());
509
552
  this.blobs.clear();
510
553
  }
511
554
  this.currentBaseURL = pub.baseURL;
@@ -515,16 +558,15 @@ export class FXLFramePoolManager {
515
558
  const itm = pub.readingOrder.items[index];
516
559
  if(!itm) return; // TODO throw?
517
560
  if(!this.blobs.has(href)) {
518
- const blobBuilder = new FrameBlobBuider(
561
+ this.blobs.set(href, new FrameBlobBuilder(
519
562
  pub,
520
563
  this.currentBaseURL || "",
521
564
  itm,
522
565
  {
523
- injector: this.injector
566
+ injector: this.injector,
567
+ upgradeInsecureRequests: this.upgradeInsecureRequests
524
568
  }
525
- );
526
- const blobURL = await blobBuilder.build(true);
527
- this.blobs.set(href, blobURL);
569
+ ));
528
570
  }
529
571
 
530
572
  // Show future offscreen frame in advance after a delay
@@ -538,7 +580,7 @@ export class FXLFramePoolManager {
538
580
  const spread = this.makeSpread(this.reAlign(index));
539
581
  const page = this.spreadPosition(spread, itm);
540
582
  const fm = this.pool.get(href)!;
541
- await fm.load(modules, this.blobs.get(href)!);
583
+ await fm.load(modules, await this.blobs.get(href)!.build(true));
542
584
  if(!this.peripherals.isScaled) // When scaled, positioning is screwed up, so wait to show
543
585
  await fm.show(page); // Show/activate new frame
544
586
  this.delayedShow.delete(href);
@@ -565,7 +607,7 @@ export class FXLFramePoolManager {
565
607
  if(!source) continue; // This can get destroyed
566
608
 
567
609
  this.cancelShowing(s.href);
568
- await newFrame.load(modules, source); // In order to ensure modules match the latest configuration
610
+ await newFrame.load(modules, await source.build(true)); // In order to ensure modules match the latest configuration
569
611
  await newFrame.show(this.spreadPosition(spread, s)); // Show/activate new frame
570
612
  this.previousFrames.push(newFrame);
571
613
  await newFrame.activate();
@@ -592,9 +634,12 @@ export class FXLFramePoolManager {
592
634
  for (const s of spread) {
593
635
  this.inprogress.set(s.href, progressPromise); // Add the job to the in progress map
594
636
  }
595
- await progressPromise; // Wait on the job to finish...
596
- for (const s of spread) {
597
- this.inprogress.delete(s.href); // Delete it from the in progress map!
637
+ try {
638
+ await progressPromise; // Wait on the job to finish...
639
+ } finally {
640
+ for (const s of spread) {
641
+ this.inprogress.delete(s.href); // Delete it from the in progress map!
642
+ }
598
643
  }
599
644
  }
600
645
 
@@ -35,6 +35,8 @@ export interface Zoomer {
35
35
  };
36
36
  }
37
37
 
38
+ export const isTypedOMSupported = () => typeof window.CSSTransformValue !== 'undefined';
39
+
38
40
  export class FXLPeripherals {
39
41
 
40
42
  private readonly manager: FXLFramePoolManager;
@@ -339,6 +341,12 @@ export class FXLPeripherals {
339
341
 
340
342
  private moveFrame = 0;
341
343
 
344
+ // Reused typed transform for the per-frame drag write: allocating fresh
345
+ // CSSTransformValue/CSSTranslate objects per write is slower than string
346
+ // parsing; mutating a persistent one is faster than both
347
+ private dragTranslate: CSSTranslate | null = null;
348
+ private dragTransform: CSSTransformValue | null = null;
349
+
342
350
  /**
343
351
  * touchmove event handler
344
352
  */
@@ -494,11 +502,24 @@ export class FXLPeripherals {
494
502
  const currentOffset = currentSlide * (this.manager.width / this.manager.perPage);
495
503
  const dragOffset = (this.pan.endX - this.pan.startX);
496
504
  const offset = this.manager.rtl ? currentOffset + dragOffset : currentOffset - dragOffset;
505
+ // Snap the translation to device pixels: at fractional offsets the
506
+ // spread gutter is rasterized with a hairline gap between the pages
507
+ const dpr = window.devicePixelRatio || 1;
508
+ const snapped = Math.round((this.manager.rtl ? 1 : -1) * offset * dpr) / dpr;
497
509
 
498
510
  cancelAnimationFrame(this.moveFrame);
499
511
  this.moveFrame = requestAnimationFrame(() => {
500
512
  requestAnimationFrame(() => {
501
- this.manager.spineElement.style.transform = `translate3d(${(this.manager.rtl ? 1 : -1) * offset}px, 0, 0)`;
513
+ if(isTypedOMSupported()) {
514
+ if(!this.dragTransform) {
515
+ this.dragTranslate = new CSSTranslate(CSS.px(0), CSS.px(0), CSS.px(0));
516
+ this.dragTransform = new CSSTransformValue([this.dragTranslate]);
517
+ }
518
+ (this.dragTranslate!.x as CSSUnitValue).value = snapped;
519
+ this.manager.spineElement.attributeStyleMap.set("transform", this.dragTransform);
520
+ } else {
521
+ this.manager.spineElement.style.transform = `translate3d(${snapped}px, 0, 0)`;
522
+ }
502
523
  })
503
524
  });
504
525
  }
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ export * from './Navigator.ts';
2
2
  export * from './decorations/index.ts';
3
3
  export * from './webpub/index.ts';
4
4
  export * from './epub/index.ts';
5
+ export * from './divina/index.ts';
5
6
  export * from './audio/index.ts';
6
7
  export * from './helpers/index.ts';
7
8
  export * from './preferences/index.ts';
@@ -12,18 +12,28 @@ export class KeyboardConditionBridge {
12
12
  setup() {
13
13
  let initializing = true;
14
14
 
15
+ // Subscribe once per distinct condition object: combos commonly share
16
+ // one condition, and a per-combo subscription would trigger one full
17
+ // onUpdate rebuild per combo for a single condition change.
18
+ const combosByCondition = new Map<NonNullable<KeyCombo["condition"]>, KeyCombo[]>();
15
19
  this.config.forEach(peripheral =>
16
20
  peripheral.keyCombos.forEach(combo => {
17
21
  if (combo.condition) {
18
- const unsub = combo.condition.subscribe((value) => {
19
- this.conditionValues.set(combo, value);
20
- if (!initializing) this.onUpdate(this.buildSerializable());
21
- });
22
- this.unsubs.push(unsub);
22
+ const combos = combosByCondition.get(combo.condition) ?? [];
23
+ combos.push(combo);
24
+ combosByCondition.set(combo.condition, combos);
23
25
  }
24
26
  })
25
27
  );
26
28
 
29
+ combosByCondition.forEach((combos, condition) => {
30
+ const unsub = condition.subscribe((value) => {
31
+ combos.forEach(combo => this.conditionValues.set(combo, value));
32
+ if (!initializing) this.onUpdate(this.buildSerializable());
33
+ });
34
+ this.unsubs.push(unsub);
35
+ });
36
+
27
37
  initializing = false;
28
38
  this.onUpdate(this.buildSerializable());
29
39
  }
@@ -14,11 +14,16 @@ export interface KeyboardPeripheralOptions {
14
14
  export class KeyboardPeripherals {
15
15
  private keydownHandler?: (event: KeyboardEvent) => void;
16
16
  private keyManager = new KeyCombinationManager();
17
+ // pagehide instead of unload: an unload listener disqualifies the page
18
+ // from the back/forward cache; only tear down on real unloads.
19
+ private readonly pagehideHandler = (e: PageTransitionEvent) => {
20
+ if (!e.persisted) this.destroy();
21
+ };
17
22
 
18
23
  constructor(options: KeyboardPeripheralOptions = {}) {
19
24
  this.setupKeyboardPeripherals(options.keyboardPeripherals || []);
20
25
  }
21
-
26
+
22
27
  private setupKeyboardPeripherals(keyboardPeripherals: KeyboardPeripheral[]) {
23
28
  if (keyboardPeripherals.length > 0) {
24
29
  // Create activity event dispatcher
@@ -28,26 +33,27 @@ export class KeyboardPeripherals {
28
33
  });
29
34
  window.dispatchEvent(customEvent);
30
35
  };
31
-
36
+
32
37
  // Create unified handler using the provided keyboard peripherals
33
38
  this.keydownHandler = this.keyManager.createUnifiedHandler(
34
39
  "", // Empty string as target frame source for main window
35
- keyboardPeripherals,
40
+ keyboardPeripherals,
36
41
  dispatcher
37
42
  );
38
-
43
+
39
44
  if (this.keydownHandler) {
40
45
  document.addEventListener("keydown", this.keydownHandler, true);
41
46
  }
42
47
  }
43
-
44
- window.addEventListener("unload", () => this.destroy());
48
+
49
+ window.addEventListener("pagehide", this.pagehideHandler);
45
50
  }
46
-
51
+
47
52
  public destroy() {
48
53
  if (this.keydownHandler) {
49
54
  document.removeEventListener("keydown", this.keydownHandler, true);
50
55
  this.keydownHandler = undefined;
51
56
  }
57
+ window.removeEventListener("pagehide", this.pagehideHandler);
52
58
  }
53
59
  }
@@ -29,7 +29,7 @@ export class WebPubFrameManager {
29
29
  this.frame = document.createElement("iframe");
30
30
  this.frame.classList.add("readium-navigator-iframe");
31
31
  this.frame.style.visibility = "hidden";
32
- this.frame.style.setProperty("aria-hidden", "true");
32
+ this.frame.ariaHidden = "true";
33
33
  this.frame.style.opacity = "0";
34
34
  this.frame.style.position = "absolute";
35
35
  this.frame.style.pointerEvents = "none";
@@ -140,7 +140,7 @@ export class WebPubFrameManager {
140
140
  async hide(): Promise<void> {
141
141
  if(this.destroyed) return;
142
142
  this.frame.style.visibility = "hidden";
143
- this.frame.style.setProperty("aria-hidden", "true");
143
+ this.frame.ariaHidden = "true";
144
144
  this.frame.style.opacity = "0";
145
145
  this.frame.style.pointerEvents = "none";
146
146
  this.hidden = true;
@@ -176,7 +176,7 @@ export class WebPubFrameManager {
176
176
 
177
177
  const remove = () => {
178
178
  this.frame.style.removeProperty("visibility");
179
- this.frame.style.removeProperty("aria-hidden");
179
+ this.frame.ariaHidden = null;
180
180
  this.frame.style.removeProperty("opacity");
181
181
  this.frame.style.removeProperty("pointer-events");
182
182
  this.hidden = false;
@@ -201,14 +201,12 @@ export class WebPubFrameManager {
201
201
  setCSSProperties(properties: { [key: string]: string }) {
202
202
  if(this.destroyed || !this.frame.contentWindow) return;
203
203
 
204
- // We need to resume and halt postMessage to update the properties
205
- // if the frame is hidden since it's been halted in hide()
206
- if (this.hidden) {
207
- if (this.comms) this.comms?.resume();
208
- else this.comms = new FrameComms(this.frame.contentWindow!, this.source);
204
+ // A halted comms can still send, so only a never-shown hidden frame needs one created
205
+ if (this.hidden && !this.comms) {
206
+ this.comms = new FrameComms(this.frame.contentWindow, this.source);
207
+ this.comms.halt();
209
208
  }
210
209
  this.comms?.send("update_properties", properties);
211
- if (this.hidden) this.comms?.halt();
212
210
  }
213
211
 
214
212
  get iframe() {
@@ -208,8 +208,11 @@ export class WebPubFramePoolManager {
208
208
  });
209
209
 
210
210
  this.inprogress.set(newHref, progressPromise);
211
- await progressPromise;
212
- this.inprogress.delete(newHref);
211
+ try {
212
+ await progressPromise;
213
+ } finally {
214
+ this.inprogress.delete(newHref);
215
+ }
213
216
  }
214
217
 
215
218
  setCSSProperties(properties: { [key: string]: string }) {
@@ -73,6 +73,7 @@ export class WebPubNavigator extends VisualNavigator implements Configurable<Web
73
73
  private framePool!: WebPubFramePoolManager;
74
74
  private currentIndex: number = 0;
75
75
  private currentLocation: Locator;
76
+ private _destroyed = false;
76
77
  private _currentTimelineItem: TimelineItem | undefined;
77
78
  private _visibleFragmentIds: string[] = [];
78
79
  private _notifiedVisibleFragmentIds: string[] = [];
@@ -192,6 +193,7 @@ export class WebPubNavigator extends VisualNavigator implements Configurable<Web
192
193
 
193
194
  public async load() {
194
195
  await this.updateCSS(false);
196
+ if (this._destroyed) return;
195
197
  const cssProperties = this.compileCSSProperties(this._css);
196
198
  this.framePool = new WebPubFramePoolManager(
197
199
  this.container,
@@ -206,6 +208,9 @@ export class WebPubNavigator extends VisualNavigator implements Configurable<Web
206
208
  [...this._decorationResizeSelectors]
207
209
  );
208
210
 
211
+ if (this._destroyed) return;
212
+ this.currentLocation = this.completeLocator(this.currentLocation);
213
+
209
214
  await this.apply();
210
215
  }
211
216
 
@@ -456,6 +461,8 @@ export class WebPubNavigator extends VisualNavigator implements Configurable<Web
456
461
  }
457
462
 
458
463
  public async destroy() {
464
+ // Flag synchronously so an in-flight load() bails before attaching frames.
465
+ this._destroyed = true;
459
466
  if (this._suspiciousActivityListener) {
460
467
  window.removeEventListener(NAVIGATOR_SUSPICIOUS_ACTIVITY_EVENT, this._suspiciousActivityListener);
461
468
  }
@@ -859,7 +866,44 @@ export class WebPubNavigator extends VisualNavigator implements Configurable<Web
859
866
  cb(done);
860
867
  }
861
868
 
869
+ private completeLocator(locator: Locator): Locator {
870
+ if (!locator.href) {
871
+ const items = this.pub.readingOrder.items;
872
+ let fellback = false;
873
+ if (typeof locator.locations.position === "number") {
874
+ const link = items[locator.locations.position - 1];
875
+ if (link) {
876
+ locator = (this.pub.manifest.locatorFromLink(link) || new Locator({
877
+ href: link.href,
878
+ type: link.type || "text/html"
879
+ })).copyWithLocations(locator.locations);
880
+ fellback = true;
881
+ }
882
+ }
883
+ if (!fellback && typeof locator.locations?.totalProgression === "number") {
884
+ // WebPub has no per-resource totalProgression like EPUB's position list,
885
+ // so approximate by spreading the reading order evenly. This is here to
886
+ // help with conversion from OPDS locators which only require the total
887
+ // progression in the publication.
888
+ const targetProgression = locator.locations.totalProgression;
889
+ const closestIdx = Math.min(
890
+ items.length - 1,
891
+ Math.max(0, Math.round(targetProgression * (items.length - 1)))
892
+ );
893
+ const link = items[closestIdx];
894
+ if (link) {
895
+ locator = (this.pub.manifest.locatorFromLink(link) || new Locator({
896
+ href: link.href,
897
+ type: link.type || "text/html"
898
+ })).copyWithLocations(locator.locations);
899
+ }
900
+ }
901
+ }
902
+ return locator;
903
+ }
904
+
862
905
  public go(locator: Locator, _: boolean, cb: (ok: boolean) => void): void {
906
+ locator = this.completeLocator(locator);
863
907
  const href = locator.href.split("#")[0];
864
908
  let link = this.pub.readingOrder.findWithHref(href);
865
909
  if(!link) {
@@ -1,6 +1,7 @@
1
1
  import { Link, Locator, Publication, Timeline, TimelineItem } from "@readium/shared";
2
2
  import { MediaNavigator, IContentProtectionConfig, IKeyboardPeripheralsConfig, KeyboardPeripheralEventData } from "../Navigator.ts";
3
3
  import { Configurable } from "../preferences/Configurable.ts";
4
+ import { AudioMseLoaderFactory } from "./engine/index.ts";
4
5
  import { AudioPreferences, AudioSettings, AudioPreferencesEditor, IAudioPreferences, IAudioDefaults } from "./preferences/index.ts";
5
6
  import { ContextMenuEvent, SuspiciousActivityEvent } from "@readium/navigator-html-injectables";
6
7
  export interface AudioMetadata {
@@ -35,6 +36,24 @@ export interface AudioNavigatorConfiguration {
35
36
  defaults: IAudioDefaults;
36
37
  contentProtection?: IAudioContentProtectionConfig;
37
38
  keyboardPeripherals?: IKeyboardPeripheralsConfig;
39
+ /**
40
+ * Called with the persistent playback element before
41
+ * the first src is assigned, so the host can prepare it. Use for MSE/EME
42
+ * setups. When it returns a promise, loading of
43
+ * the initial track (and prefetching of adjacent ones) is deferred until
44
+ * the promise settles; a rejection is forwarded to the error listener and
45
+ * loading proceeds anyway.
46
+ */
47
+ mediaElementSetup?: (element: HTMLMediaElement) => void | Promise<void>;
48
+ /**
49
+ * When provided, media bytes reach the playback element through Media
50
+ * Source Extensions instead of direct `src` assignment: the engine
51
+ * creates one loader per track via this factory and the loader owns all
52
+ * fetching. Supply one for e.g. EME encrypted audio, since browsers have
53
+ * poor support for encrypted audio directly through `src`. The container
54
+ * handling (WebM, fMP4, …) is the loader implementation's concern.
55
+ */
56
+ mseLoaderFactory?: AudioMseLoaderFactory;
38
57
  }
39
58
  export declare class AudioNavigator extends MediaNavigator implements Configurable<AudioSettings, AudioPreferences> {
40
59
  private readonly pub;
@@ -58,9 +77,13 @@ export declare class AudioNavigator extends MediaNavigator implements Configurab
58
77
  /** True while a track transition is in progress; suppresses spurious mid-navigation events. */
59
78
  private _isNavigating;
60
79
  private _isStalled;
80
+ /** Set by destroy(); stops the deferred initial load from touching a dead instance. */
81
+ private _destroyed;
61
82
  private _stalledWatchdog;
62
83
  private _stalledCheckTime;
63
84
  constructor(publication: Publication, listeners: AudioNavigatorListeners, initialPosition?: Locator, configuration?: AudioNavigatorConfiguration);
85
+ /** Sets the initial track on the primary element and seeks to the starting position. */
86
+ private startInitialLoad;
64
87
  get settings(): AudioSettings;
65
88
  get preferencesEditor(): AudioPreferencesEditor;
66
89
  submitPreferences(preferences: AudioPreferences): Promise<void>;
@@ -73,6 +96,12 @@ export declare class AudioNavigator extends MediaNavigator implements Configurab
73
96
  private hrefToTrackIndex;
74
97
  /** Current track index derived from the current location's href. */
75
98
  private currentTrackIndex;
99
+ /**
100
+ * Builds a locator for an overall publication progression (0–1) by walking
101
+ * the cumulative track durations. Falls back to the start of the first
102
+ * track when the reading order carries no duration metadata.
103
+ */
104
+ private locatorFromTotalProgression;
76
105
  get currentLocator(): Locator;
77
106
  get isPlaying(): boolean;
78
107
  get isPaused(): boolean;