@readium/navigator 2.10.3 → 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,322 @@
1
+ import { Link, Page } from "@readium/shared";
2
+ import { isTypedOMSupported } from "../epub/fxl/FXLPeripherals.ts";
3
+ import { DivinaPage } from "./DivinaSpreader.ts";
4
+ import { DivinaQuality, selectVariant } from "./DivinaVariantSelector.ts";
5
+
6
+ /**
7
+ * Generates an SVG data-URI placeholder with the same intrinsic dimensions as
8
+ * the image it stands in for, so the layout box is identical before/after load.
9
+ */
10
+ const escapeXML = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
11
+
12
+ // Placeholders are cached so pages with equal dimensions share one data URI —
13
+ // and therefore one decoded image — instead of one per page
14
+ const placeholderCache: Map<string, string> = new Map();
15
+
16
+ export function placeholderSVG(width: number, height: number, message: string, detail?: string): string {
17
+ const key = `${width}x${height}|${message}|${detail ?? ""}`;
18
+ const cached = placeholderCache.get(key);
19
+ if(cached) return cached;
20
+ const fontSize = Math.max(24, Math.round(Math.min(width, height) / 10));
21
+ message = escapeXML(message);
22
+ detail = detail && escapeXML(detail);
23
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}">` +
24
+ `<rect width="100%" height="100%" fill="rgba(128,128,128,0.08)"/>` +
25
+ `<text x="50%" y="50%" text-anchor="middle" dominant-baseline="central" ` +
26
+ `font-family="sans-serif" font-weight="bold" font-size="${fontSize}" fill="#999">${message}</text>` +
27
+ (detail ? `<text x="50%" y="97%" text-anchor="middle" font-family="sans-serif" font-size="${Math.round(fontSize / 3)}" fill="#aaa">${detail.length > 40 ? "…" + detail.slice(detail.length - 40) : detail}</text>` : "") +
28
+ `</svg>`;
29
+ const uri = `data:image/svg+xml,${encodeURIComponent(svg)}`;
30
+ placeholderCache.set(key, uri);
31
+ return uri;
32
+ }
33
+
34
+ const FALLBACK_WIDTH = 1080;
35
+ const FALLBACK_HEIGHT = 1440;
36
+
37
+ export type DivinaPageMode = "paged" | "scrolled";
38
+
39
+ /**
40
+ * Manages the lifecycle of a single Divina page: an <img> element inside a
41
+ * wrapper <div>, with blob-based loading, an intrinsic-size SVG placeholder,
42
+ * and per-mode layout. The image-based equivalent of FXLFrameManager.
43
+ */
44
+ export class DivinaPageManager {
45
+ readonly page: DivinaPage;
46
+ readonly wrapper: HTMLDivElement;
47
+ readonly img: HTMLImageElement;
48
+
49
+ private readonly fetchBlob: (link: Link, signal?: AbortSignal) => Promise<Blob>;
50
+ private readonly getQuality: () => DivinaQuality;
51
+ private readonly placeholder: string;
52
+ private objectURL: string | null = null;
53
+ private loadPromise: Promise<void> | null = null;
54
+ private abortController: AbortController | null = null;
55
+ private _loaded = false;
56
+ private destroyed = false;
57
+ private generation = 0; // Bumped on unload/destroy to invalidate in-flight loads
58
+ private mode: DivinaPageMode = "paged";
59
+
60
+ constructor(
61
+ page: DivinaPage,
62
+ fetchBlob: (link: Link, signal?: AbortSignal) => Promise<Blob>,
63
+ getQuality: () => DivinaQuality = () => DivinaQuality.auto
64
+ ) {
65
+ this.page = page;
66
+ this.fetchBlob = fetchBlob;
67
+ this.getQuality = getQuality;
68
+
69
+ this.wrapper = document.createElement("div");
70
+ this.wrapper.style.position = "relative";
71
+ this.wrapper.style.overflow = "hidden";
72
+ this.wrapper.dataset.href = page.link.href;
73
+ this.wrapper.dataset.pageNumber = `${page.number}`;
74
+
75
+ this.img = document.createElement("img");
76
+ this.img.decoding = "async";
77
+ this.img.draggable = false;
78
+ this.img.alt = page.link.title || `Page ${page.number}`;
79
+ this.img.style.display = "block";
80
+ this.img.style.userSelect = "none";
81
+ this.img.style.webkitUserSelect = "none";
82
+ // @ts-expect-error webkitUserDrag is non-standard but needed for Safari
83
+ this.img.style.webkitUserDrag = "none";
84
+
85
+ this.placeholder = placeholderSVG(this.intrinsicWidth, this.intrinsicHeight, "···", this.page.link.title ? this.page.link.title : `Page ${this.page.number}`);
86
+ this.img.src = this.placeholder;
87
+ this.wrapper.appendChild(this.img);
88
+ }
89
+
90
+ get intrinsicWidth(): number {
91
+ return this.page.link.width || (this._loaded ? this.img.naturalWidth : 0) || FALLBACK_WIDTH;
92
+ }
93
+
94
+ get intrinsicHeight(): number {
95
+ return this.page.link.height || (this._loaded ? this.img.naturalHeight : 0) || FALLBACK_HEIGHT;
96
+ }
97
+
98
+ get loaded(): boolean {
99
+ return this._loaded;
100
+ }
101
+
102
+ get element(): HTMLDivElement {
103
+ return this.wrapper;
104
+ }
105
+
106
+ /**
107
+ * Configure the wrapper/image styles for the given presentation mode.
108
+ * Paged: the image is fitted (letterboxed) inside the slot-sized wrapper.
109
+ * Scrolled: the image dictates the height via its aspect ratio.
110
+ */
111
+ setMode(mode: DivinaPageMode) {
112
+ this.mode = mode;
113
+ if(isTypedOMSupported()) {
114
+ if(mode === "paged") {
115
+ ["margin-top", "margin-right", "margin-bottom", "margin-left"]
116
+ .forEach(p => this.wrapper.attributeStyleMap.delete(p));
117
+ this.img.attributeStyleMap.set("position", "absolute");
118
+ this.img.attributeStyleMap.set("top", CSS.px(0));
119
+ this.img.attributeStyleMap.set("left", CSS.px(0));
120
+ this.img.attributeStyleMap.set("width", CSS.percent(100));
121
+ this.img.attributeStyleMap.set("height", CSS.percent(100));
122
+ this.img.attributeStyleMap.set("object-fit", "contain");
123
+ this.img.attributeStyleMap.delete("aspect-ratio");
124
+ } else {
125
+ this.wrapper.attributeStyleMap.set("float", "none");
126
+ this.wrapper.attributeStyleMap.set("width", CSS.percent(100));
127
+ this.wrapper.attributeStyleMap.set("height", "auto");
128
+ this.wrapper.attributeStyleMap.set("margin-top", CSS.px(0));
129
+ this.wrapper.attributeStyleMap.set("margin-bottom", CSS.px(0));
130
+ this.wrapper.attributeStyleMap.set("margin-left", "auto");
131
+ this.wrapper.attributeStyleMap.set("margin-right", "auto");
132
+ this.img.attributeStyleMap.set("position", "static");
133
+ this.img.attributeStyleMap.delete("top");
134
+ this.img.attributeStyleMap.delete("left");
135
+ this.img.attributeStyleMap.set("width", CSS.percent(100));
136
+ this.img.attributeStyleMap.set("height", "auto");
137
+ this.img.attributeStyleMap.delete("object-fit");
138
+ this.img.attributeStyleMap.set("aspect-ratio", `${this.intrinsicWidth} / ${this.intrinsicHeight}`);
139
+ }
140
+ } else {
141
+ if(mode === "paged") {
142
+ this.wrapper.style.removeProperty("margin");
143
+ this.img.style.position = "absolute";
144
+ this.img.style.top = "0";
145
+ this.img.style.left = "0";
146
+ this.img.style.width = "100%";
147
+ this.img.style.height = "100%";
148
+ this.img.style.objectFit = "contain";
149
+ this.img.style.removeProperty("aspect-ratio");
150
+ } else {
151
+ this.wrapper.style.cssFloat = this.wrapper.style.float = "none";
152
+ this.wrapper.style.width = "100%";
153
+ this.wrapper.style.height = "auto";
154
+ this.wrapper.style.margin = "0 auto";
155
+ this.img.style.position = "static";
156
+ this.img.style.removeProperty("top");
157
+ this.img.style.removeProperty("left");
158
+ this.img.style.width = "100%";
159
+ this.img.style.height = "auto";
160
+ this.img.style.removeProperty("object-fit");
161
+ this.img.style.aspectRatio = `${this.intrinsicWidth} / ${this.intrinsicHeight}`;
162
+ }
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Position the fitted image within its wrapper (paged mode only).
168
+ *
169
+ * @param position In a two-page spread, the side of the gutter this page
170
+ * sits on (pages butt against the gutter). With `half`, the half of a
171
+ * full-spread wrapper the page occupies.
172
+ * @param half The page is alone in a double-width (full-spread) slot but
173
+ * carries a left/right position hint — e.g. a shifted cover or the
174
+ * orphaned component of a spread — so it is fitted to and centered
175
+ * within its half of the spread.
176
+ */
177
+ fit(position: Page, half = false) {
178
+ if(this.mode !== "paged") return;
179
+ let width: number, left: number, objectPosition: string;
180
+ if(half && position !== Page.center) {
181
+ width = 50;
182
+ left = position === Page.right ? 50 : 0;
183
+ objectPosition = "center center";
184
+ } else {
185
+ width = 100;
186
+ left = 0;
187
+ // Pages of a spread butt against the gutter
188
+ objectPosition = position === Page.left ? "right center"
189
+ : position === Page.right ? "left center"
190
+ : "center center";
191
+ }
192
+ if(isTypedOMSupported()) {
193
+ this.img.attributeStyleMap.set("width", CSS.percent(width));
194
+ this.img.attributeStyleMap.set("left", CSS.percent(left));
195
+ this.img.attributeStyleMap.set("object-position", objectPosition);
196
+ } else {
197
+ this.img.style.width = `${width}%`;
198
+ this.img.style.left = `${left}%`;
199
+ this.img.style.objectPosition = objectPosition;
200
+ }
201
+ }
202
+
203
+ /** Scrolled mode: constrain the display width of this page in the strip */
204
+ applyStripWidth(displayWidth: number) {
205
+ if(this.mode !== "scrolled") return;
206
+ if(isTypedOMSupported()) {
207
+ this.wrapper.attributeStyleMap.set("width", CSS.px(Math.round(displayWidth)));
208
+ this.wrapper.attributeStyleMap.set("max-width", CSS.percent(100));
209
+ } else {
210
+ this.wrapper.style.width = `${Math.round(displayWidth)}px`;
211
+ this.wrapper.style.maxWidth = "100%";
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Fetch the image as a blob and swap it in for the placeholder.
217
+ * Idempotent; concurrent calls await the same promise.
218
+ */
219
+ /**
220
+ * The most appropriate variant (main link or alternate) for the page's
221
+ * current display box and the user's quality preference.
222
+ */
223
+ private pickVariant(): Link {
224
+ const dpr = window.devicePixelRatio || 1;
225
+ const boxW = this.wrapper.clientWidth || 0;
226
+ const boxH = this.wrapper.clientHeight || 0;
227
+ let targetWidth = boxW * dpr;
228
+ let targetHeight = boxH * dpr;
229
+ if(this.mode === "paged" && boxW > 0 && boxH > 0) {
230
+ // The image is contain-fitted in the slot: target its displayed size
231
+ const scale = Math.min(boxW / this.intrinsicWidth, boxH / this.intrinsicHeight);
232
+ targetWidth = this.intrinsicWidth * scale * dpr;
233
+ targetHeight = this.intrinsicHeight * scale * dpr;
234
+ }
235
+ return selectVariant(this.page.link, {
236
+ targetWidth,
237
+ targetHeight,
238
+ axis: this.mode === "scrolled" ? "width" : "height",
239
+ quality: this.getQuality()
240
+ });
241
+ }
242
+
243
+ async load(): Promise<void> {
244
+ if(this._loaded || this.destroyed) return;
245
+ if(this.loadPromise) return this.loadPromise;
246
+ const gen = this.generation;
247
+ this.abortController = new AbortController();
248
+ const signal = this.abortController.signal;
249
+ this.loadPromise = (async () => {
250
+ try {
251
+ const blob = await this.fetchBlob(this.pickVariant(), signal);
252
+ if(this.destroyed || gen !== this.generation) return;
253
+ const url = URL.createObjectURL(blob);
254
+ try {
255
+ await new Promise<void>((res, rej) => {
256
+ const onLoad = () => { cleanup(); res(); };
257
+ const onError = () => { cleanup(); rej(new Error(`Failed to decode ${this.page.link.href}`)); };
258
+ const cleanup = () => {
259
+ this.img.removeEventListener("load", onLoad);
260
+ this.img.removeEventListener("error", onError);
261
+ };
262
+ this.img.addEventListener("load", onLoad);
263
+ this.img.addEventListener("error", onError);
264
+ this.img.src = url;
265
+ });
266
+ } catch (e) {
267
+ URL.revokeObjectURL(url);
268
+ throw e;
269
+ }
270
+ if(this.destroyed || gen !== this.generation) {
271
+ URL.revokeObjectURL(url);
272
+ if(this.img.src === url) this.img.src = this.placeholder;
273
+ return;
274
+ }
275
+ this.objectURL = url;
276
+ this._loaded = true;
277
+ // Correct the layout box with the decoded dimensions when the
278
+ // manifest lacked width/height (the fallback ratio would
279
+ // otherwise distort the page in scrolled mode)
280
+ if(this.mode === "scrolled" && this.img.naturalWidth && this.img.naturalHeight) {
281
+ const ratio = `${this.img.naturalWidth} / ${this.img.naturalHeight}`;
282
+ if(isTypedOMSupported())
283
+ this.img.attributeStyleMap.set("aspect-ratio", ratio);
284
+ else
285
+ this.img.style.aspectRatio = ratio;
286
+ }
287
+ } catch (error) {
288
+ if(!this.destroyed && gen === this.generation) {
289
+ console.warn("Divina page load failed", this.page.link.href, error);
290
+ this.img.src = placeholderSVG(this.intrinsicWidth, this.intrinsicHeight, "!", this.page.link.title ? this.page.link.title : `Page ${this.page.number}`);
291
+ }
292
+ } finally {
293
+ if(gen === this.generation) this.loadPromise = null;
294
+ }
295
+ })();
296
+ return this.loadPromise;
297
+ }
298
+
299
+ /**
300
+ * Release the blob and go back to the placeholder (memory reclamation),
301
+ * aborting any in-flight fetch.
302
+ */
303
+ unload() {
304
+ if(!this._loaded && !this.loadPromise) return;
305
+ this.generation++;
306
+ this._loaded = false;
307
+ this.loadPromise = null;
308
+ this.abortController?.abort();
309
+ this.abortController = null;
310
+ this.img.src = this.placeholder;
311
+ if(this.objectURL) {
312
+ URL.revokeObjectURL(this.objectURL);
313
+ this.objectURL = null;
314
+ }
315
+ }
316
+
317
+ destroy() {
318
+ this.destroyed = true;
319
+ this.unload();
320
+ this.wrapper.remove();
321
+ }
322
+ }