@readium/navigator 1.2.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 (49) hide show
  1. package/LICENSE +28 -0
  2. package/README.MD +11 -0
  3. package/dist/assets/AccessibleDfA.otf +0 -0
  4. package/dist/assets/iAWriterDuospace-Regular.ttf +0 -0
  5. package/dist/index.js +6263 -0
  6. package/dist/index.umd.cjs +107 -0
  7. package/package.json +65 -0
  8. package/src/Navigator.ts +66 -0
  9. package/src/audio/engine/AudioEngine.ts +136 -0
  10. package/src/audio/engine/WebAudioEngine.ts +286 -0
  11. package/src/audio/engine/index.ts +2 -0
  12. package/src/audio/index.ts +1 -0
  13. package/src/epub/EpubNavigator.ts +507 -0
  14. package/src/epub/frame/FrameBlobBuilder.ts +211 -0
  15. package/src/epub/frame/FrameComms.ts +142 -0
  16. package/src/epub/frame/FrameManager.ts +134 -0
  17. package/src/epub/frame/FramePoolManager.ts +179 -0
  18. package/src/epub/frame/index.ts +3 -0
  19. package/src/epub/fxl/FXLCoordinator.ts +152 -0
  20. package/src/epub/fxl/FXLFrameManager.ts +286 -0
  21. package/src/epub/fxl/FXLFramePoolManager.ts +632 -0
  22. package/src/epub/fxl/FXLPeripherals.ts +587 -0
  23. package/src/epub/fxl/FXLPeripheralsDebug.ts +46 -0
  24. package/src/epub/fxl/FXLSpreader.ts +95 -0
  25. package/src/epub/fxl/index.ts +5 -0
  26. package/src/epub/index.ts +3 -0
  27. package/src/helpers/sML.ts +120 -0
  28. package/src/index.ts +3 -0
  29. package/types/src/Navigator.d.ts +41 -0
  30. package/types/src/audio/engine/AudioEngine.d.ts +114 -0
  31. package/types/src/audio/engine/WebAudioEngine.d.ts +107 -0
  32. package/types/src/audio/engine/index.d.ts +2 -0
  33. package/types/src/audio/index.d.ts +1 -0
  34. package/types/src/epub/EpubNavigator.d.ts +66 -0
  35. package/types/src/epub/frame/FrameBlobBuilder.d.ts +13 -0
  36. package/types/src/epub/frame/FrameComms.d.ts +26 -0
  37. package/types/src/epub/frame/FrameManager.d.ts +21 -0
  38. package/types/src/epub/frame/FramePoolManager.d.ts +17 -0
  39. package/types/src/epub/frame/index.d.ts +3 -0
  40. package/types/src/epub/fxl/FXLCoordinator.d.ts +37 -0
  41. package/types/src/epub/fxl/FXLFrameManager.d.ts +41 -0
  42. package/types/src/epub/fxl/FXLFramePoolManager.d.ts +93 -0
  43. package/types/src/epub/fxl/FXLPeripherals.d.ts +97 -0
  44. package/types/src/epub/fxl/FXLPeripheralsDebug.d.ts +13 -0
  45. package/types/src/epub/fxl/FXLSpreader.d.ts +12 -0
  46. package/types/src/epub/fxl/index.d.ts +5 -0
  47. package/types/src/epub/index.d.ts +3 -0
  48. package/types/src/helpers/sML.d.ts +51 -0
  49. package/types/src/index.d.ts +3 -0
@@ -0,0 +1,507 @@
1
+ import { EPUBLayout, Link, Locator, Publication, ReadingProgression } from "@readium/shared";
2
+ import { VisualNavigator } from "../";
3
+ import { FramePoolManager } from "./frame/FramePoolManager";
4
+ import { FXLFramePoolManager } from "./fxl/FXLFramePoolManager";
5
+ import { CommsEventKey, FXLModules, ModuleLibrary, ModuleName, ReflowableModules } from "@readium/navigator-html-injectables";
6
+ import { BasicTextSelection, FrameClickEvent } from "@readium/navigator-html-injectables";
7
+ import * as path from "path-browserify";
8
+ import { FXLFrameManager } from "./fxl/FXLFrameManager";
9
+ import { FrameManager } from "./frame/FrameManager";
10
+
11
+ export type ManagerEventKey = "zoom";
12
+
13
+ export interface EpubNavigatorListeners {
14
+ frameLoaded: (wnd: Window) => void;
15
+ positionChanged: (locator: Locator) => void;
16
+ tap: (e: FrameClickEvent) => boolean; // Return true to prevent handling here
17
+ click: (e: FrameClickEvent) => boolean; // Return true to prevent handling here
18
+ zoom: (scale: number) => void;
19
+ miscPointer: (amount: number) => void;
20
+ customEvent: (key: string, data: unknown) => void;
21
+ handleLocator: (locator: Locator) => boolean; // Retrun true to prevent handling here
22
+ textSelected: (selection: BasicTextSelection) => void;
23
+ // showToc: () => void;
24
+ }
25
+
26
+ const defaultListeners = (listeners: EpubNavigatorListeners): EpubNavigatorListeners => ({
27
+ frameLoaded: listeners.frameLoaded || (() => {}),
28
+ positionChanged: listeners.positionChanged || (() => {}),
29
+ tap: listeners.tap || (() => false),
30
+ click: listeners.click || (() => false),
31
+ zoom: listeners.zoom || (() => {}),
32
+ miscPointer: listeners.miscPointer || (() => {}),
33
+ customEvent: listeners.customEvent || (() => {}),
34
+ handleLocator: listeners.handleLocator || (() => false),
35
+ textSelected: listeners.textSelected || (() => {})
36
+ })
37
+
38
+ export class EpubNavigator extends VisualNavigator {
39
+ private readonly pub: Publication;
40
+ private readonly container: HTMLElement;
41
+ private readonly listeners: EpubNavigatorListeners;
42
+ private framePool!: FramePoolManager | FXLFramePoolManager;
43
+ private positions!: Locator[];
44
+ private currentLocation!: Locator;
45
+ private currentProgression: ReadingProgression;
46
+ public readonly layout: EPUBLayout;
47
+
48
+ constructor(container: HTMLElement, pub: Publication, listeners: EpubNavigatorListeners, positions: Locator[] = [], initialPosition: Locator | undefined = undefined) {
49
+ super();
50
+ this.pub = pub;
51
+ this.layout = EpubNavigator.determineLayout(pub);
52
+ this.currentProgression = pub.metadata.effectiveReadingProgression;
53
+ this.container = container;
54
+ this.listeners = defaultListeners(listeners);
55
+ this.currentLocation = initialPosition!;
56
+ if (positions.length)
57
+ this.positions = positions;
58
+ }
59
+
60
+ public static determineLayout(pub: Publication): EPUBLayout {
61
+ const presentation = pub.metadata.getPresentation();
62
+ if(presentation?.layout == EPUBLayout.fixed) return EPUBLayout.fixed;
63
+ if(pub.metadata.otherMetadata && ("http://openmangaformat.org/schema/1.0#version" in pub.metadata.otherMetadata))
64
+ return EPUBLayout.fixed; // It's fixed layout even though it lacks presentation, although this should really be a divina
65
+ if(pub.metadata.otherMetadata?.conformsTo === "https://readium.org/webpub-manifest/profiles/divina")
66
+ // TODO: this is temporary until there's a divina reader in place
67
+ return EPUBLayout.fixed;
68
+ // TODO other logic to detect fixed layout publications
69
+
70
+ return EPUBLayout.reflowable;
71
+ }
72
+
73
+ public async load() {
74
+ if (!this.positions?.length)
75
+ this.positions = await this.pub.positionsFromManifest();
76
+ if(this.layout === EPUBLayout.fixed) {
77
+ this.framePool = new FXLFramePoolManager(this.container, this.positions, this.pub);
78
+ this.framePool.listener = (key: CommsEventKey | ManagerEventKey, data: unknown) => {
79
+ this.eventListener(key, data);
80
+ }
81
+ } else
82
+ this.framePool = new FramePoolManager(this.container, this.positions);
83
+ if(this.currentLocation === undefined)
84
+ this.currentLocation = this.positions[0];
85
+ await this.apply();
86
+ }
87
+
88
+ /**
89
+ * Exposed to the public to compensate for lack of implemented readium conveniences
90
+ * TODO remove when settings management is incorporated
91
+ */
92
+ public get _cframes(): (FXLFrameManager | FrameManager | undefined)[] {
93
+ return this.framePool.currentFrames;
94
+ }
95
+
96
+ /**
97
+ * Exposed to the public to compensate for lack of implemented readium conveniences
98
+ * TODO remove when settings management is incorporated
99
+ */
100
+ public get pool() {
101
+ return this.framePool;
102
+ }
103
+
104
+ /**
105
+ * Left intentionally public so you can pass in your own events here
106
+ * to trigger the navigator when user's mouse/keyboard focus is
107
+ * outside the readium-controller navigator. Be careful!
108
+ */
109
+ public eventListener(key: CommsEventKey | ManagerEventKey, data: unknown) {
110
+ switch (key) {
111
+ case "_pong":
112
+ this.listeners.frameLoaded(this._cframes[0]!.iframe.contentWindow!);
113
+ this.listeners.positionChanged(this.currentLocation);
114
+ break;
115
+ case "first_visible_locator":
116
+ const loc = Locator.deserialize(data as string);
117
+ if(!loc) break;
118
+ this.currentLocation = new Locator({
119
+ href: this.currentLocation.href,
120
+ type: this.currentLocation.type,
121
+ title: this.currentLocation.title,
122
+ locations: loc?.locations,
123
+ text: loc?.text
124
+ });
125
+ this.listeners.positionChanged(this.currentLocation);
126
+ break;
127
+ case "text_selected":
128
+ this.listeners.textSelected(data as BasicTextSelection);
129
+ break;
130
+ case "click":
131
+ case "tap":
132
+ const edata = data as FrameClickEvent;
133
+ if (edata.interactiveElement) {
134
+ const element = new DOMParser().parseFromString(
135
+ edata.interactiveElement,
136
+ "text/html"
137
+ ).body.children[0];
138
+ if (
139
+ element.nodeType === element.ELEMENT_NODE &&
140
+ element.nodeName === "A" &&
141
+ element.hasAttribute("href")
142
+ ) {
143
+ const origHref = element.attributes.getNamedItem("href")?.value!;
144
+ if (origHref.startsWith("#")) {
145
+ this.go(this.currentLocation.copyWithLocations({
146
+ fragments: [origHref.substring(1)]
147
+ }), false, () => { });
148
+ } else if(
149
+ origHref.startsWith("http://") ||
150
+ origHref.startsWith("https://") ||
151
+ origHref.startsWith("mailto:") ||
152
+ origHref.startsWith("tel:")
153
+ ) {
154
+ this.listeners.handleLocator(new Link({
155
+ href: origHref,
156
+ }).locator);
157
+ } else {
158
+ try {
159
+ this.goLink(new Link({
160
+ href: path.join(path.dirname(this.currentLocation.href), origHref)
161
+ }), false, () => { });
162
+ } catch (error) {
163
+ console.warn(`Couldn't go to link for ${origHref}: ${error}`);
164
+ this.listeners.handleLocator(new Link({
165
+ href: origHref,
166
+ }).locator);
167
+ }
168
+ }
169
+ } else console.log("Clicked on", element);
170
+ } else {
171
+ if(this.layout === EPUBLayout.fixed && (this.framePool as FXLFramePoolManager).doNotDisturb)
172
+ edata.doNotDisturb = true;
173
+
174
+ if(this.layout === EPUBLayout.fixed
175
+ // TODO handle ttb/btt
176
+ && (
177
+ this.currentProgression === ReadingProgression.rtl ||
178
+ this.currentProgression === ReadingProgression.ltr
179
+ )
180
+ ) {
181
+ if(this.framePool.currentFrames.length > 1) {
182
+ // Spread page dimensions
183
+ const cfs = this.framePool.currentFrames;
184
+ if(edata.targetFrameSrc === cfs[this.currentProgression === ReadingProgression.rtl ? 0 : 1]?.source) {
185
+ // The right page (screen-wise) was clicked, so we add the left page's width to the click's x
186
+ edata.x += (cfs[this.currentProgression === ReadingProgression.rtl ? 1 : 0]?.iframe.contentWindow?.innerWidth ?? 0) * window.devicePixelRatio;
187
+ }
188
+ }
189
+ }
190
+
191
+ const handled = key === "click" ? this.listeners.click(edata) : this.listeners.tap(edata);
192
+ if(handled) break;
193
+ if (this.currentProgression === ReadingProgression.ttb || this.currentProgression === ReadingProgression.btt)
194
+ return; // Not applicable to vertical reading yet. TODO
195
+
196
+ const oneQuarter = ((this._cframes.length === 2 ? this._cframes[0]!.window.innerWidth + this._cframes[1]!.window.innerWidth : this._cframes[0]!.window.innerWidth) * window.devicePixelRatio) / 4;
197
+ // open UI if middle screen is clicked/tapped
198
+ if (edata.x >= oneQuarter && edata.x <= oneQuarter * 3) this.listeners.miscPointer(1);
199
+ if (edata.x < oneQuarter) this.goLeft(false, () => { }); // Go left if left quarter clicked
200
+ else if (edata.x > oneQuarter * 3) this.goRight(false, () => { }); // Go right if right quarter clicked
201
+ }
202
+ break;
203
+ case "tap_more":
204
+ this.listeners.miscPointer(data as number);
205
+ break;
206
+ case "no_more":
207
+ this.changeResource(1);
208
+ break;
209
+ case "no_less":
210
+ this.changeResource(-1);
211
+ break;
212
+ case "swipe":
213
+ // Swipe event
214
+ break;
215
+ case "zoom":
216
+ this.listeners.zoom(data as number);
217
+ break;
218
+ case "progress":
219
+ this.syncLocation(data as number);
220
+ break;
221
+ case "log":
222
+ console.log(this._cframes[0]?.source?.split("/")[3], ...(data as any[]));
223
+ break;
224
+ default:
225
+ this.listeners.customEvent(key, data);
226
+ break;
227
+ }
228
+ }
229
+
230
+ private determineModules() {
231
+ let modules = Array.from(ModuleLibrary.keys()) as ModuleName[];
232
+
233
+ if(this.layout === EPUBLayout.fixed) {
234
+ return modules.filter((m) => FXLModules.includes(m));
235
+ } else modules = modules.filter((m) => ReflowableModules.includes(m));
236
+
237
+ // Horizontal vs. Vertical reading
238
+ if (this.readingProgression === ReadingProgression.ttb || this.readingProgression === ReadingProgression.btt)
239
+ modules = modules.filter((m) => m !== "column_snapper");
240
+ else
241
+ modules = modules.filter((m) => m !== "scroll_snapper");
242
+
243
+ return modules;
244
+ }
245
+
246
+ // Start listening to messages from the current iframe
247
+ private attachListener() {
248
+ const vframes = this._cframes.filter(f => !!f) as (FXLFrameManager | FrameManager)[];
249
+ if(vframes.length === 0) throw Error("no cframe to attach listener to");
250
+ vframes.forEach(f => {
251
+ if(f.msg) f.msg.listener = (key: CommsEventKey | ManagerEventKey, value: unknown) => {
252
+ this.eventListener(key, value);
253
+ }
254
+ })
255
+
256
+ }
257
+
258
+ private async apply() {
259
+ await this.framePool.update(this.pub, this.currentLocator, this.determineModules());
260
+
261
+ this.attachListener();
262
+
263
+ const idx = this.pub.readingOrder.findIndexWithHref(this.currentLocation.href);
264
+ if (idx < 0)
265
+ throw Error("Link for " + this.currentLocation.href + " not found!");
266
+ }
267
+
268
+ public async destroy() {
269
+ await this.framePool?.destroy();
270
+ }
271
+
272
+ private async changeResource(relative: number): Promise<boolean> {
273
+ if (relative === 0) return false;
274
+
275
+ if(this.layout === EPUBLayout.fixed) {
276
+ const p = this.framePool as FXLFramePoolManager;
277
+ const old = p.currentNumbers[0];
278
+ if(relative === 1) {
279
+ if(!p.next(p.perPage)) return false;
280
+ } else if(relative === -1) {
281
+ if(!p.prev(p.perPage)) return false;
282
+ } else
283
+ throw Error("Invalid relative value for FXL");
284
+
285
+ // Apply change
286
+ const neW = p.currentNumbers[0]
287
+ if(old > neW)
288
+ for (let j = this.positions.length - 1; j >= 0; j--) {
289
+ if(this.positions[j].href === this.pub.readingOrder.items[neW-1].href) {
290
+ this.currentLocation = this.positions[j].copyWithLocations({
291
+ progression: 0.999999999999
292
+ });
293
+ break;
294
+ }
295
+ }
296
+ else if(old < neW)
297
+ for (let j = 0; j < this.positions.length; j++) {
298
+ if(this.positions[j].href === this.pub.readingOrder.items[neW-1].href) {
299
+ this.currentLocation = this.positions[j];
300
+ break;
301
+ }
302
+ }
303
+ await this.apply();
304
+ return true;
305
+ }
306
+
307
+ const curr = this.pub.readingOrder.findIndexWithHref(this.currentLocation.href);
308
+ const i = Math.max(
309
+ 0,
310
+ Math.min(this.pub.readingOrder.items.length - 1, curr + relative)
311
+ );
312
+ if (i === curr) {
313
+ this._cframes[0]?.msg?.send("shake", undefined, async (_) => {});
314
+ return false;
315
+ }
316
+
317
+ // Apply change
318
+ if(curr > i)
319
+ for (let j = this.positions.length - 1; j >= 0; j--) {
320
+ if(this.positions[j].href === this.pub.readingOrder.items[i].href) {
321
+ this.currentLocation = this.positions[j].copyWithLocations({
322
+ progression: 0.999999999999
323
+ });
324
+ break;
325
+ }
326
+ }
327
+ else
328
+ for (let j = 0; j < this.positions.length; j++) {
329
+ if(this.positions[j].href === this.pub.readingOrder.items[i].href) {
330
+ this.currentLocation = this.positions[j];
331
+ break;
332
+ }
333
+ }
334
+
335
+ await this.apply();
336
+ return true;
337
+ }
338
+
339
+ private findNearestPosition(fromProgression: number): Locator {
340
+ // TODO replace with locator service
341
+ const potentialPositions = this.positions.filter(
342
+ (p) => p.href === this.currentLocation.href
343
+ );
344
+ let pos = this.currentLocation;
345
+
346
+ // Find the last locator with a progrssion that's
347
+ // smaller than or equal to the requested progression.
348
+ potentialPositions.some((p) => {
349
+ const pr = p.locations.progression ?? 0;
350
+ if (fromProgression <= pr) {
351
+ pos = p;
352
+ return true;
353
+ }
354
+ else return false;
355
+ });
356
+ return pos;
357
+ }
358
+
359
+ private async syncLocation(iframeProgress: number) {
360
+ this.currentLocation = this.findNearestPosition(iframeProgress).copyWithLocations({
361
+ progression: iframeProgress // Most accurate progression in resource
362
+ });
363
+ this.listeners.positionChanged(this.currentLocation);
364
+ await this.framePool.update(this.pub, this.currentLocation, this.determineModules());
365
+ }
366
+
367
+ public goBackward(_: boolean, cb: (ok: boolean) => void): void {
368
+ if(this.layout === EPUBLayout.fixed) {
369
+ this.changeResource(-1);
370
+ cb(true);
371
+ } else {
372
+ this._cframes[0]?.msg?.send("go_prev", undefined, async (ack) => {
373
+ if(ack)
374
+ // OK
375
+ cb(true);
376
+ else
377
+ // Need to change resources because we're at the beginning of the current one
378
+ cb(await this.changeResource(-1));
379
+ });
380
+ }
381
+ }
382
+
383
+ public goForward(_: boolean, cb: (ok: boolean) => void): void {
384
+ if(this.layout === EPUBLayout.fixed) {
385
+ this.changeResource(1);
386
+ cb(true);
387
+ } else {
388
+ this._cframes[0]?.msg?.send("go_next", undefined, async (ack) => {
389
+ if(ack)
390
+ // OK
391
+ cb(true);
392
+ else
393
+ // Need to change resources because we're at the end of the current one
394
+ cb(await this.changeResource(1));
395
+ });
396
+ }
397
+ }
398
+
399
+ get currentLocator(): Locator {
400
+ // TODO seed locator with detailed info if this property is accessed
401
+ /*return (async () => { // Wrapped because JS doesn't support async getters
402
+ return this.currentLocation;
403
+ })();*/
404
+
405
+ return this.currentLocation;
406
+ }
407
+
408
+ // Starting and ending position currently showing in the reader
409
+ get currentPositionNumbers(): number[] {
410
+ if(this.layout === EPUBLayout.fixed)
411
+ return (this.framePool as FXLFramePoolManager).currentNumbers;
412
+
413
+ return [this.currentLocator?.locations.position ?? 0];
414
+ }
415
+
416
+ // TODO: This is temporary until user settings are implemented.
417
+ get readingProgression(): ReadingProgression {
418
+ return this.currentProgression;
419
+ }
420
+
421
+ // TODO: This is temporary until user settings are implemented.
422
+ public async setReadingProgression(newProgression: ReadingProgression) {
423
+ if(this.currentProgression === newProgression) return;
424
+ this.currentProgression = newProgression;
425
+ await this.framePool.update(this.pub, this.currentLocator, this.determineModules(), true);
426
+ this.attachListener();
427
+ }
428
+
429
+ get publication(): Publication {
430
+ return this.pub;
431
+ }
432
+
433
+ private async loadLocator(locator: Locator, cb: (ok: boolean) => void) {
434
+ let done = false;
435
+ let cssSelector = (typeof locator.locations.getCssSelector === 'function') && locator.locations.getCssSelector();
436
+ if(locator.text?.highlight) {
437
+ done = await new Promise<boolean>((res, _) => {
438
+ // Attempt to go to a highlighted piece of text in the resource
439
+ this._cframes[0]!.msg!.send(
440
+ "go_text",
441
+ cssSelector ? [
442
+ locator.text?.serialize(),
443
+ cssSelector // Include CSS selector if it exists
444
+ ] : locator.text?.serialize(),
445
+ (ok) => res(ok)
446
+ );
447
+ });
448
+ } else if(cssSelector) {
449
+ done = await new Promise<boolean>((res, _) => {
450
+ this._cframes[0]!.msg!.send(
451
+ "go_text",
452
+ [
453
+ "", // No text!
454
+ cssSelector // Just CSS selector
455
+ ],
456
+ (ok) => res(ok)
457
+ );
458
+ });
459
+ }
460
+ if(done) {
461
+ cb(done);
462
+ return;
463
+ }
464
+ // This sanity check has to be performed because we're still passing non-locator class
465
+ // locator objects to this function. This is not good and should eventually be forbidden
466
+ // or the locator should be deserialized sometime before this function.
467
+ const hid = (typeof locator.locations.htmlId === 'function') && locator.locations.htmlId();
468
+ if(hid)
469
+ done = await new Promise<boolean>((res, _) => {
470
+ // Attempt to go to an HTML ID in the resource
471
+ this._cframes[0]!.msg!.send("go_id", hid, (ok) => res(ok));
472
+ });
473
+ if(done) {
474
+ cb(done);
475
+ return;
476
+ }
477
+
478
+ const progression = locator?.locations?.progression;
479
+ const hasProgression = progression && progression > 0;
480
+ if(hasProgression)
481
+ done = await new Promise<boolean>((res, _) => {
482
+ // Attempt to go to a progression in the resource
483
+ this._cframes[0]!.msg!.send("go_progression", progression, (ok) => res(ok));
484
+ });
485
+ else done = true;
486
+ cb(done);
487
+ }
488
+
489
+ public go(locator: Locator, _: boolean, cb: (ok: boolean) => void): void {
490
+ const href = locator.href.split("#")[0];
491
+ let link = this.pub.readingOrder.findWithHref(href);
492
+ if(!link) {
493
+ return cb(this.listeners.handleLocator(locator));
494
+ }
495
+
496
+ this.currentLocation = this.positions.find(p => p.href === link!.href)!;
497
+ this.apply().then(() => this.loadLocator(locator, (ok) => cb(ok))).then(() => {
498
+ // Now that we've gone to the right locator, we can attach the listeners.
499
+ // Doing this only at this stage reduces janky UI with multiple locator updates.
500
+ this.attachListener();
501
+ });
502
+ }
503
+
504
+ public goLink(link: Link, animated: boolean, cb: (ok: boolean) => void): void {
505
+ return this.go(link.locator, animated, cb);
506
+ }
507
+ }