@readium/navigator 2.6.1 → 2.7.0-alpha.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 (51) hide show
  1. package/LICENSE +28 -0
  2. package/dist/index.js +4795 -3156
  3. package/dist/index.umd.cjs +42 -40
  4. package/package.json +10 -10
  5. package/src/divina/DivinaNavigator.ts +662 -0
  6. package/src/divina/DivinaPageManager.ts +322 -0
  7. package/src/divina/DivinaPagedPresenter.ts +508 -0
  8. package/src/divina/DivinaPeripherals.ts +630 -0
  9. package/src/divina/DivinaScrolledPresenter.ts +516 -0
  10. package/src/divina/DivinaSpreader.ts +202 -0
  11. package/src/divina/DivinaVariantSelector.ts +77 -0
  12. package/src/divina/index.ts +8 -0
  13. package/src/divina/preferences/DivinaDefaults.ts +37 -0
  14. package/src/divina/preferences/DivinaPreferences.ts +77 -0
  15. package/src/divina/preferences/DivinaPreferencesEditor.ts +89 -0
  16. package/src/divina/preferences/DivinaSettings.ts +38 -0
  17. package/src/divina/preferences/index.ts +4 -0
  18. package/src/divina/protection/DivinaNavigatorProtector.ts +44 -0
  19. package/src/epub/EpubNavigator.ts +59 -3
  20. package/src/epub/frame/FrameBlobBuilder.ts +85 -32
  21. package/src/epub/frame/FrameManager.ts +3 -3
  22. package/src/epub/frame/FramePoolManager.ts +78 -34
  23. package/src/epub/fxl/FXLFrameManager.ts +69 -26
  24. package/src/epub/fxl/FXLFramePoolManager.ts +88 -50
  25. package/src/epub/fxl/FXLPeripherals.ts +22 -1
  26. package/src/index.ts +1 -0
  27. package/src/peripherals/KeyboardConditionBridge.ts +15 -5
  28. package/src/peripherals/KeyboardPeripherals.ts +13 -7
  29. package/src/webpub/WebPubFrameManager.ts +3 -3
  30. package/types/src/divina/DivinaNavigator.d.ts +113 -0
  31. package/types/src/divina/DivinaPageManager.d.ts +66 -0
  32. package/types/src/divina/DivinaPagedPresenter.d.ts +122 -0
  33. package/types/src/divina/DivinaPeripherals.d.ts +121 -0
  34. package/types/src/divina/DivinaScrolledPresenter.d.ts +125 -0
  35. package/types/src/divina/DivinaSpreader.d.ts +65 -0
  36. package/types/src/divina/DivinaVariantSelector.d.ts +30 -0
  37. package/types/src/divina/index.d.ts +8 -0
  38. package/types/src/divina/preferences/DivinaDefaults.d.ts +18 -0
  39. package/types/src/divina/preferences/DivinaPreferences.d.ts +33 -0
  40. package/types/src/divina/preferences/DivinaPreferencesEditor.d.ts +20 -0
  41. package/types/src/divina/preferences/DivinaSettings.d.ts +26 -0
  42. package/types/src/divina/preferences/index.d.ts +4 -0
  43. package/types/src/divina/protection/DivinaNavigatorProtector.d.ts +14 -0
  44. package/types/src/epub/EpubNavigator.d.ts +3 -1
  45. package/types/src/epub/frame/FrameBlobBuilder.d.ts +6 -3
  46. package/types/src/epub/frame/FramePoolManager.d.ts +1 -0
  47. package/types/src/epub/fxl/FXLFrameManager.d.ts +2 -0
  48. package/types/src/epub/fxl/FXLFramePoolManager.d.ts +6 -2
  49. package/types/src/epub/fxl/FXLPeripherals.d.ts +3 -0
  50. package/types/src/index.d.ts +1 -0
  51. package/types/src/peripherals/KeyboardPeripherals.d.ts +1 -0
@@ -1,13 +1,14 @@
1
1
  import { Loader, ModuleName } from "@readium/navigator-html-injectables";
2
2
  import { Page, ReadingProgression } from "@readium/shared";
3
3
  import { FrameComms } from "../frame/FrameComms.ts";
4
- import { FXLPeripherals } from "./FXLPeripherals.ts";
4
+ import { FXLPeripherals, isTypedOMSupported } from "./FXLPeripherals.ts";
5
5
  import type { ReadiumWindow } from "../../../../navigator-html-injectables/types/src/helpers/dom";
6
6
  import { IContentProtectionConfig, IKeyboardPeripheralsConfig } from "../../Navigator.ts";
7
7
  import { KeyboardConditionBridge } from "../../peripherals/KeyboardConditionBridge.ts";
8
8
 
9
9
  export class FXLFrameManager {
10
10
  private frame: HTMLIFrameElement;
11
+ private frameIsAppended = false;
11
12
  private loader: Loader | undefined;
12
13
  public source: string;
13
14
  private comms: FrameComms | undefined;
@@ -22,6 +23,7 @@ export class FXLFrameManager {
22
23
  public debugHref: string;
23
24
  private loadPromise: Promise<Window> | undefined;
24
25
  private showPromise: Promise<void> | undefined;
26
+ private viewportSize: { width: number, height: number } | undefined = undefined;
25
27
 
26
28
  constructor(
27
29
  peripherals: FXLPeripherals,
@@ -41,7 +43,7 @@ export class FXLFrameManager {
41
43
  this.frame.classList.add("blank");
42
44
  this.frame.scrolling = "no";
43
45
  this.frame.style.visibility = "hidden";
44
- this.frame.style.setProperty("aria-hidden", "true");
46
+ this.frame.ariaHidden = "true";
45
47
  this.frame.style.display = "none";
46
48
  this.frame.style.position = "absolute";
47
49
  this.frame.style.pointerEvents = "none";
@@ -52,15 +54,17 @@ export class FXLFrameManager {
52
54
  this.frame.dataset.originalHref = debugHref;
53
55
  this.source = "about:blank";
54
56
 
55
- // NEW
56
57
  this.wrapper = document.createElement("div");
57
58
  this.wrapper.style.position = "relative";
59
+ this.wrapper.style.contain = "strict";
58
60
  this.wrapper.style.float = this.wrapper.style.cssFloat = direction === ReadingProgression.rtl ? "right" : "left";
59
-
60
- this.wrapper.appendChild(this.frame);
61
61
  }
62
62
 
63
63
  async load(modules: ModuleName[], source: string): Promise<Window> {
64
+ if(!this.frameIsAppended) {
65
+ this.wrapper.appendChild(this.frame);
66
+ this.frameIsAppended = true;
67
+ }
64
68
  if(this.source === source && this.loadPromise/* && this.loaded*/) {
65
69
  if([...this.currModules].sort().join("|") === [...modules].sort().join("|")) {
66
70
  return this.loadPromise;
@@ -106,6 +110,7 @@ export class FXLFrameManager {
106
110
 
107
111
  // Parses the page size from the viewport meta tag of the loaded resource.
108
112
  loadPageSize(): { width: number, height: number } {
113
+ if(this.viewportSize) return this.viewportSize;
109
114
  const wnd = this.frame.contentWindow!;
110
115
 
111
116
  // Try to get the page size from the viewport meta tag
@@ -122,8 +127,10 @@ export class FXLFrameManager {
122
127
  else if(match[1] === "height")
123
128
  height = Number.parseFloat(match[2]);
124
129
  }
125
- if(width > 0 && height > 0)
126
- return { width, height };
130
+ if(width > 0 && height > 0) {
131
+ this.viewportSize = { width, height };
132
+ return this.viewportSize;
133
+ }
127
134
  }
128
135
 
129
136
  // Otherwise get it from the size of the loaded content
@@ -136,25 +143,47 @@ export class FXLFrameManager {
136
143
  update(page?: Page) {
137
144
  if(!this.loaded) return;
138
145
  const dimensions = this.loadPageSize();
139
- this.frame.style.height = `${dimensions.height}px`;
140
- this.frame.style.width = `${dimensions.width}px`;
141
146
  const ratio = Math.min(this.wrapper.clientWidth / dimensions.width, this.wrapper.clientHeight / dimensions.height);
142
- this.frame.style.transform = `scale(${ratio})`;
147
+ if(isTypedOMSupported()) {
148
+ this.frame.attributeStyleMap.set("width", CSS.px(dimensions.width));
149
+ this.frame.attributeStyleMap.set("height", CSS.px(dimensions.height));
150
+ this.frame.attributeStyleMap.set("transform", new CSSTransformValue([
151
+ new CSSScale(ratio, ratio),
152
+ ]));
153
+ } else {
154
+ this.frame.style.height = `${dimensions.height}px`;
155
+ this.frame.style.width = `${dimensions.width}px`;
156
+ this.frame.style.transform = `scale(${ratio})`;
157
+ }
143
158
  const bcr = this.frame.getBoundingClientRect();
144
159
  const hdiff = this.wrapper.clientHeight - bcr.height;
145
- this.frame.style.top = `${hdiff / 2}px`;
146
- if(page === Page.left) {
147
- const wdiff = this.wrapper.clientWidth - bcr.width;
148
- this.frame.style.left = `${wdiff}px`;
149
- } else if(page === Page.center) {
150
- const wdiff = this.wrapper.clientWidth - bcr.width;
151
- this.frame.style.left = `${wdiff / 2}px`;
160
+
161
+ if(isTypedOMSupported()) {
162
+ this.frame.attributeStyleMap.set("top", CSS.px(hdiff / 2));
163
+ if(page === Page.left) {
164
+ const wdiff = this.wrapper.clientWidth - bcr.width;
165
+ this.frame.attributeStyleMap.set("left", CSS.px(wdiff));
166
+ } else if(page === Page.center) {
167
+ const wdiff = this.wrapper.clientWidth - bcr.width;
168
+ this.frame.attributeStyleMap.set("left", CSS.px(wdiff / 2));
169
+ } else {
170
+ this.frame.attributeStyleMap.set("left", CSS.px(0));
171
+ }
152
172
  } else {
153
- this.frame.style.left = "0px";
173
+ this.frame.style.top = `${hdiff / 2}px`;
174
+ if(page === Page.left) {
175
+ const wdiff = this.wrapper.clientWidth - bcr.width;
176
+ this.frame.style.left = `${wdiff}px`;
177
+ } else if(page === Page.center) {
178
+ const wdiff = this.wrapper.clientWidth - bcr.width;
179
+ this.frame.style.left = `${wdiff / 2}px`;
180
+ } else {
181
+ this.frame.style.left = "0px";
182
+ }
154
183
  }
155
184
 
156
185
  this.frame.style.removeProperty("visibility");
157
- this.frame.style.removeProperty("aria-hidden");
186
+ this.frame.ariaHidden = null;
158
187
  this.frame.style.removeProperty("pointer-events");
159
188
  this.frame.classList.remove("blank");
160
189
  this.frame.classList.add("loaded");
@@ -171,7 +200,7 @@ export class FXLFrameManager {
171
200
  if(!this.loaded) return;
172
201
  this.deselect();
173
202
  this.frame.style.visibility = "hidden";
174
- this.frame.style.setProperty("aria-hidden", "true");
203
+ this.frame.ariaHidden = "true";
175
204
  this.frame.style.pointerEvents = "none";
176
205
  this.frame.classList.add("blank");
177
206
  this.frame.classList.remove("loaded");
@@ -189,6 +218,8 @@ export class FXLFrameManager {
189
218
  this.source = "about:blank";
190
219
  this.frame.contentWindow!.location.replace("about:blank");
191
220
  this.frame.style.display = "none";
221
+ this.frame.style.width = "0px";
222
+ this.frame.style.height = "0px";
192
223
  });
193
224
  }
194
225
 
@@ -296,15 +327,27 @@ export class FXLFrameManager {
296
327
  }
297
328
 
298
329
  set width(width: number) {
299
- const newWidth = `${width}%`;
300
- if(this.wrapper.style.width === newWidth) return;
301
- this.wrapper.style.width = newWidth;
330
+ if(isTypedOMSupported()) {
331
+ const current = this.wrapper.attributeStyleMap.get("width");
332
+ if(current instanceof CSSUnitValue && current.unit === "percent" && current.value === width) return;
333
+ this.wrapper.attributeStyleMap.set("width", CSS.percent(width));
334
+ } else {
335
+ const newWidth = `${width}%`;
336
+ if(this.wrapper.style.width === newWidth) return;
337
+ this.wrapper.style.width = newWidth;
338
+ }
302
339
  }
303
340
 
304
341
  set height(height: number) {
305
- const newHeight = `${height}px`;
306
- if(this.wrapper.style.height === newHeight) return;
307
- this.wrapper.style.height = newHeight;
342
+ if(isTypedOMSupported()) {
343
+ const current = this.wrapper.attributeStyleMap.get("height");
344
+ if(current instanceof CSSUnitValue && current.unit === "px" && current.value === height) return;
345
+ this.wrapper.attributeStyleMap.set("height", CSS.px(height));
346
+ } else {
347
+ const newHeight = `${height}px`;
348
+ if(this.wrapper.style.height === newHeight) return;
349
+ this.wrapper.style.height = newHeight;
350
+ }
308
351
  }
309
352
 
310
353
  get window() {
@@ -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 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();
@@ -37,7 +37,7 @@ export class FXLFramePoolManager {
37
37
  private readonly pub: Publication;
38
38
  public width: number = 0;
39
39
  public height: number = 0;
40
- private transform: string = "";
40
+ private transform: number | undefined;
41
41
  public currentSlide: number = 0;
42
42
  private spreader: FXLSpreader;
43
43
  private spread = true; // TODO
@@ -52,6 +52,7 @@ export class FXLFramePoolManager {
52
52
  container: HTMLElement,
53
53
  positions: Locator[],
54
54
  pub: Publication,
55
+ _layout: Layout, // TODO: used by upcoming scrolled-layout support
55
56
  injector?: Injector | null,
56
57
  contentProtectionConfig?: IContentProtectionConfig,
57
58
  keyboardPeripheralsConfig?: IKeyboardPeripheralsConfig,
@@ -66,7 +67,7 @@ export class FXLFramePoolManager {
66
67
 
67
68
  if(this.pub.metadata.effectiveReadingProgression !== ReadingProgression.rtl && this.pub.metadata.effectiveReadingProgression !== ReadingProgression.ltr)
68
69
  // TODO support TTB and BTT
69
- throw Error("Unsupported reading progression for EPUB");
70
+ throw Error("Unsupported reading progression for fixed-layout EPUB");
70
71
 
71
72
  // NEW
72
73
  this.spreader = new FXLSpreader(this.pub);
@@ -86,16 +87,18 @@ export class FXLFramePoolManager {
86
87
 
87
88
  this.peripherals = new FXLPeripherals(this);
88
89
 
90
+ const fragment = document.createDocumentFragment();
89
91
  this.pub.readingOrder.items.forEach((link) => {
90
92
  // Create <iframe>
91
93
  const fm = new FXLFrameManager(this.peripherals, this.pub.metadata.effectiveReadingProgression, link.href, this.contentProtectionConfig, this.keyboardPeripheralsConfig);
92
- this.spineElement.appendChild(fm.element);
93
94
 
94
- // this.pages.push(fm);
95
95
  this.pool.set(link.href, fm);
96
96
  fm.width = 100 / this.length * (link.properties?.otherProperties["orientation"] === Orientation.landscape || link.properties?.otherProperties["addBlank"] ? this.perPage : 1);
97
97
  fm.height = this.height;
98
+ fragment.appendChild(fm.wrapper);
98
99
  });
100
+ this.spineElement.appendChild(fragment);
101
+
99
102
  }
100
103
 
101
104
  private _listener!: FrameCommsListener;
@@ -134,15 +137,18 @@ export class FXLFramePoolManager {
134
137
 
135
138
  clearTimeout(this.resizeTimeout);
136
139
  this.resizeTimeout = window.setTimeout(() => {
137
- // TODO optimize this expensive set of loops and operations
140
+ const baseWidthFactor = 100 / this.length;
141
+ const itemLookup = new Map(this.pub.readingOrder.items.map(item => [item.href, item]));
138
142
  this.pool.forEach((frm, linkHref) => {
139
- let i = this.pub.readingOrder.items.findIndex(l => l.href === linkHref);
140
- const link = this.pub.readingOrder.items[i];
141
- frm.width = 100 / this.length * (link.properties?.otherProperties["orientation"] === Orientation.landscape || link.properties?.otherProperties["addBlank"] ? this.perPage : 1);
142
- frm.height = this.height;
143
- if(!frm.loaded) return;
144
- const spread = this.spreader.findByLink(link)!;
145
- frm.update(this.spreadPosition(spread, link));
143
+ const link = itemLookup.get(linkHref);
144
+ if(!link) return;
145
+ requestAnimationFrame(() => {
146
+ frm.width = baseWidthFactor * (link.properties?.otherProperties["orientation"] === Orientation.landscape || link.properties?.otherProperties["addBlank"] ? this.perPage : 1);
147
+ frm.height = this.height;
148
+ if(!frm.loaded) return;
149
+ const spread = this.spreader.findByLink(link)!;
150
+ frm.update(this.spreadPosition(spread, link));
151
+ });
146
152
  });
147
153
  }, RESIZE_UPDATE_TIMEOUT);
148
154
  }
@@ -181,25 +187,42 @@ export class FXLFramePoolManager {
181
187
  }
182
188
 
183
189
  public updateSpineStyle(animate: boolean, fast = true) {
184
- let margin = "0";
190
+ let margin = 0;
185
191
  this.updateDimensions();
186
192
  if(this.perPage > 1 && true) // this.shift
187
- margin = `${this.width / 2}px`;
188
-
189
- const spineStyle = {
190
- transition: animate ? `all ${fast ? SLIDE_FAST : SLIDE_SLOW}ms ease-out` : "all 0ms ease-out",
191
- marginRight: this.rtl ? margin : "0",
192
- marginLeft: this.rtl ? "0" : margin,
193
- width: `${(this.width / this.perPage) * this.length}px`,
194
- transform: this.transform,
195
-
196
- // Static (should be moved to CSS)
197
- contain: "content"
198
- } as CSSStyleDeclaration;
199
-
200
- Object.assign(this.spineElement.style, spineStyle);
193
+ margin = this.width / 2;
194
+
195
+ if(isTypedOMSupported()) {
196
+ // TODO: don't use UnparsedValue, but rather the longhand of every transition property
197
+ this.spineElement.attributeStyleMap.set("transition", new CSSUnparsedValue([animate ? `all ${fast ? SLIDE_FAST : SLIDE_SLOW}ms ease-out` : "all 0ms ease-out"]));
198
+ this.spineElement.attributeStyleMap.set("margin-right", CSS.px(this.rtl ? margin : 0));
199
+ this.spineElement.attributeStyleMap.set("margin-left", CSS.px(this.rtl ? 0 : margin));
200
+ this.spineElement.attributeStyleMap.set("width", CSS.px((this.width / this.perPage) * this.length));
201
+ this.transform ? this.spineElement.attributeStyleMap.set("transform", new CSSTransformValue([
202
+ new CSSTranslate(CSS.px(this.transform || 0), CSS.px(0), CSS.px(0))
203
+ ])) : this.spineElement.attributeStyleMap.delete("transform");
204
+ this.spineElement.attributeStyleMap.set("contain", new CSSUnparsedValue(["content"]));
205
+ } else {
206
+ const spineStyle = {
207
+ transition: animate ? `all ${fast ? SLIDE_FAST : SLIDE_SLOW}ms ease-out` : "all 0ms ease-out",
208
+ marginRight: this.rtl ? `${margin}px` : "0",
209
+ marginLeft: this.rtl ? "0" : `${margin}px`,
210
+ width: `${(this.width / this.perPage) * this.length}px`,
211
+ transform: this.transform ? `translate3d(${this.transform}px, 0px, 0px)` : "",
212
+ // Static (should be moved to CSS)
213
+ contain: "content"
214
+ } as CSSStyleDeclaration;
215
+ Object.assign(this.spineElement.style, spineStyle);
216
+ }
201
217
  }
202
218
 
219
+ // Reused typed transform for the per-event zoom/pan write: allocating
220
+ // fresh CSSTransformValue objects per write is slower than string
221
+ // parsing; mutating a persistent one is faster than both
222
+ private bookScale: CSSScale | null = null;
223
+ private bookTranslate: CSSTranslate | null = null;
224
+ private bookTransform: CSSTransformValue | null = null;
225
+
203
226
  public updateBookStyle(initial=false) {
204
227
  if(initial) {
205
228
  const bookStyle = {
@@ -218,7 +241,23 @@ export class FXLFramePoolManager {
218
241
  } as CSSStyleDeclaration;
219
242
  Object.assign(this.bookElement.style, bookStyle);
220
243
  }
221
- this.bookElement.style.transform = `scale(${this.peripherals?.scale || 1})` + (this.peripherals ? ` translate3d(${this.peripherals.pan.translateX}px, ${this.peripherals.pan.translateY}px, 0px)` : "");
244
+ const scale = this.peripherals?.scale || 1;
245
+ const translateX = this.peripherals?.pan.translateX || 0;
246
+ const translateY = this.peripherals?.pan.translateY || 0;
247
+ if(isTypedOMSupported()) {
248
+ if(!this.bookTransform) {
249
+ this.bookScale = new CSSScale(1, 1);
250
+ this.bookTranslate = new CSSTranslate(CSS.px(0), CSS.px(0), CSS.px(0));
251
+ this.bookTransform = new CSSTransformValue([this.bookScale, this.bookTranslate]);
252
+ }
253
+ (this.bookScale!.x as CSSUnitValue).value = scale;
254
+ (this.bookScale!.y as CSSUnitValue).value = scale;
255
+ (this.bookTranslate!.x as CSSUnitValue).value = translateX;
256
+ (this.bookTranslate!.y as CSSUnitValue).value = translateY;
257
+ this.bookElement.attributeStyleMap.set("transform", this.bookTransform);
258
+ } else {
259
+ this.bookElement.style.transform = `scale(${scale}) translate3d(${translateX}px, ${translateY}px, 0px)`;
260
+ }
222
261
  }
223
262
 
224
263
  /**
@@ -282,22 +321,22 @@ export class FXLFramePoolManager {
282
321
  */
283
322
  slideToCurrent(enableTransition?: boolean, fast = true) {
284
323
  this.updateDimensions();
324
+ // Always commit: the spine transform may have been written directly
325
+ // by the peripherals during a drag (snap-back relies on this), and
326
+ // reading style.transform back as a string for comparison doesn't
327
+ // match under CSS Typed OM (its serialization differs).
285
328
  if (enableTransition) {
286
329
  // This one is tricky, I know but this is a perfect explanation:
287
330
  // https://youtu.be/cCOL7MC4Pl0
288
331
  requestAnimationFrame(() => {
289
332
  requestAnimationFrame(() => {
290
- const newTransform = `translate3d(${this.offset}px, 0, 0)`;
291
- if(this.spineElement.style.transform === newTransform) return;
292
- this.transform = newTransform;
333
+ this.transform = this.offset;
293
334
  this.updateSpineStyle(true, fast);
294
335
  this.deselect();
295
336
  });
296
337
  });
297
338
  } else {
298
- const newTransform = `translate3d(${this.offset}px, 0, 0)`;
299
- if(this.spineElement.style.transform === newTransform) return;
300
- this.transform = newTransform;
339
+ this.transform = this.offset;
301
340
  this.updateSpineStyle(false);
302
341
  this.deselect();
303
342
  }
@@ -305,10 +344,10 @@ export class FXLFramePoolManager {
305
344
 
306
345
  bounce(rtl = false) {
307
346
  requestAnimationFrame(() => {
308
- this.transform = `translate3d(${this.offset+(50 * (rtl ? 1 : -1))}px, 0, 0)`;
347
+ this.transform = this.offset + (50 * (rtl ? 1 : -1));
309
348
  this.updateSpineStyle(true, true);
310
349
  setTimeout(() => {
311
- this.transform = `translate3d(${this.offset}px, 0, 0)`;
350
+ this.transform = this.offset;
312
351
  this.updateSpineStyle(true, true);
313
352
  }, 100);
314
353
  });
@@ -405,7 +444,8 @@ export class FXLFramePoolManager {
405
444
  this.pool.clear();
406
445
 
407
446
  // Revoke all blobs
408
- this.blobs.forEach(v => URL.revokeObjectURL(v));
447
+ this.blobs.forEach(v => v.reset());
448
+ this.blobs.clear();
409
449
 
410
450
  // Clean up injector if it exists
411
451
  this.injector?.dispose();
@@ -496,13 +536,13 @@ export class FXLFramePoolManager {
496
536
  if(!this.pool.has(href)) return;
497
537
  this.cancelShowing(href);
498
538
  await this.pool.get(href)?.unload();
499
- // this.pool.delete(href);
539
+ this.blobs.get(href)?.reset();
500
540
  });
501
541
 
502
542
  // Check if base URL of publication has changed
503
543
  if(this.currentBaseURL !== undefined && pub.baseURL !== this.currentBaseURL) {
504
544
  // Revoke all blobs
505
- this.blobs.forEach(v => URL.revokeObjectURL(v));
545
+ this.blobs.forEach(v => v.reset());
506
546
  this.blobs.clear();
507
547
  }
508
548
  this.currentBaseURL = pub.baseURL;
@@ -512,16 +552,14 @@ export class FXLFramePoolManager {
512
552
  const itm = pub.readingOrder.items[index];
513
553
  if(!itm) return; // TODO throw?
514
554
  if(!this.blobs.has(href)) {
515
- const blobBuilder = new FrameBlobBuider(
555
+ this.blobs.set(href, new FrameBlobBuilder(
516
556
  pub,
517
557
  this.currentBaseURL || "",
518
558
  itm,
519
559
  {
520
560
  injector: this.injector
521
561
  }
522
- );
523
- const blobURL = await blobBuilder.build(true);
524
- this.blobs.set(href, blobURL);
562
+ ));
525
563
  }
526
564
 
527
565
  // Show future offscreen frame in advance after a delay
@@ -535,7 +573,7 @@ export class FXLFramePoolManager {
535
573
  const spread = this.makeSpread(this.reAlign(index));
536
574
  const page = this.spreadPosition(spread, itm);
537
575
  const fm = this.pool.get(href)!;
538
- await fm.load(modules, this.blobs.get(href)!);
576
+ await fm.load(modules, await this.blobs.get(href)!.build(true));
539
577
  if(!this.peripherals.isScaled) // When scaled, positioning is screwed up, so wait to show
540
578
  await fm.show(page); // Show/activate new frame
541
579
  this.delayedShow.delete(href);
@@ -559,10 +597,10 @@ export class FXLFramePoolManager {
559
597
  for (const s of spread) {
560
598
  const newFrame = this.pool.get(s.href)!;
561
599
  const source = this.blobs.get(s.href);
562
- if(!source) continue; // This can get destroyed
600
+ if(!source) continue; // Thfis can get destroyed
563
601
 
564
602
  this.cancelShowing(s.href);
565
- await newFrame.load(modules, source); // In order to ensure modules match the latest configuration
603
+ await newFrame.load(modules, await source.build(true)); // In order to ensure modules match the latest configuration
566
604
  await newFrame.show(this.spreadPosition(spread, s)); // Show/activate new frame
567
605
  this.previousFrames.push(newFrame);
568
606
  await newFrame.activate();
@@ -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
  }
@@ -25,7 +25,7 @@ export class WebPubFrameManager {
25
25
  this.frame = document.createElement("iframe");
26
26
  this.frame.classList.add("readium-navigator-iframe");
27
27
  this.frame.style.visibility = "hidden";
28
- this.frame.style.setProperty("aria-hidden", "true");
28
+ this.frame.ariaHidden = "true";
29
29
  this.frame.style.opacity = "0";
30
30
  this.frame.style.position = "absolute";
31
31
  this.frame.style.pointerEvents = "none";
@@ -109,7 +109,7 @@ export class WebPubFrameManager {
109
109
  async hide(): Promise<void> {
110
110
  if(this.destroyed) return;
111
111
  this.frame.style.visibility = "hidden";
112
- this.frame.style.setProperty("aria-hidden", "true");
112
+ this.frame.ariaHidden = "true";
113
113
  this.frame.style.opacity = "0";
114
114
  this.frame.style.pointerEvents = "none";
115
115
  this.hidden = true;
@@ -140,7 +140,7 @@ export class WebPubFrameManager {
140
140
  this.applyContentProtection();
141
141
  const remove = () => {
142
142
  this.frame.style.removeProperty("visibility");
143
- this.frame.style.removeProperty("aria-hidden");
143
+ this.frame.ariaHidden = null;
144
144
  this.frame.style.removeProperty("opacity");
145
145
  this.frame.style.removeProperty("pointer-events");
146
146
  this.hidden = false;