@readium/navigator 2.11.1 → 2.12.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 (52) hide show
  1. package/dist/divina/DivinaNavigator.js +1 -1
  2. package/dist/epub/EpubNavigator.js +1 -1
  3. package/dist/epub/frame/FramePoolManager.js +1 -1
  4. package/dist/index.js +1 -1
  5. package/dist/preferences/guards.js +1 -1
  6. package/dist/readaloud/GuidedNavigationProvider.js +1 -0
  7. package/dist/readaloud/ReadAloudNavigator.js +1 -0
  8. package/dist/readaloud/ReadingUnit.js +1 -0
  9. package/dist/readaloud/SpeechProgress.js +1 -0
  10. package/dist/readaloud/preferences/ReadAloudDefaults.js +1 -0
  11. package/dist/readaloud/preferences/ReadAloudPreferences.js +1 -0
  12. package/dist/readaloud/preferences/ReadAloudPreferencesEditor.js +1 -0
  13. package/dist/readaloud/preferences/ReadAloudSettings.js +1 -0
  14. package/dist/webpub/WebPubFrameManager.js +1 -1
  15. package/dist/webpub/WebPubFramePoolManager.js +1 -1
  16. package/dist/webpub/WebPubNavigator.js +1 -1
  17. package/package.json +8 -6
  18. package/src/divina/DivinaNavigator.ts +1 -0
  19. package/src/epub/EpubNavigator.ts +127 -21
  20. package/src/epub/frame/FramePoolManager.ts +5 -0
  21. package/src/index.ts +1 -0
  22. package/src/preferences/guards.ts +11 -0
  23. package/src/readaloud/GuidedNavigationProvider.ts +87 -0
  24. package/src/readaloud/ReadAloudNavigator.ts +949 -0
  25. package/src/readaloud/ReadingUnit.ts +154 -0
  26. package/src/readaloud/SpeechProgress.ts +60 -0
  27. package/src/readaloud/index.ts +23 -0
  28. package/src/readaloud/preferences/ReadAloudDefaults.ts +24 -0
  29. package/src/readaloud/preferences/ReadAloudPreferences.ts +104 -0
  30. package/src/readaloud/preferences/ReadAloudPreferencesEditor.ts +125 -0
  31. package/src/readaloud/preferences/ReadAloudSettings.ts +44 -0
  32. package/src/readaloud/preferences/index.ts +4 -0
  33. package/src/webpub/WebPubFrameManager.ts +4 -0
  34. package/src/webpub/WebPubFramePoolManager.ts +5 -0
  35. package/src/webpub/WebPubNavigator.ts +114 -10
  36. package/types/src/epub/EpubNavigator.d.ts +26 -1
  37. package/types/src/epub/frame/FramePoolManager.d.ts +2 -0
  38. package/types/src/index.d.ts +1 -0
  39. package/types/src/preferences/guards.d.ts +1 -0
  40. package/types/src/readaloud/GuidedNavigationProvider.d.ts +28 -0
  41. package/types/src/readaloud/ReadAloudNavigator.d.ts +158 -0
  42. package/types/src/readaloud/ReadingUnit.d.ts +58 -0
  43. package/types/src/readaloud/SpeechProgress.d.ts +19 -0
  44. package/types/src/readaloud/index.d.ts +7 -0
  45. package/types/src/readaloud/preferences/ReadAloudDefaults.d.ts +11 -0
  46. package/types/src/readaloud/preferences/ReadAloudPreferences.d.ts +58 -0
  47. package/types/src/readaloud/preferences/ReadAloudPreferencesEditor.d.ts +38 -0
  48. package/types/src/readaloud/preferences/ReadAloudSettings.d.ts +26 -0
  49. package/types/src/readaloud/preferences/index.d.ts +4 -0
  50. package/types/src/webpub/WebPubFrameManager.d.ts +1 -0
  51. package/types/src/webpub/WebPubFramePoolManager.d.ts +2 -0
  52. package/types/src/webpub/WebPubNavigator.d.ts +25 -2
@@ -349,6 +349,7 @@ export class DivinaNavigator extends VisualNavigator implements Configurable<Con
349
349
  cssSelector: undefined,
350
350
  targetElement: "",
351
351
  targetFrameSrc: "",
352
+ onContent: false,
352
353
  x: e.x,
353
354
  y: e.y
354
355
  };
@@ -2,7 +2,7 @@ import { Layout, Link, Locator, LocatorText, Profile, Publication, ReadingProgre
2
2
  import { Configurable, ConfigurableSettings, LineLengths, ProgressionRange, VisualNavigator, VisualNavigatorViewport } from "../index.ts";
3
3
  import { FramePoolManager } from "./frame/FramePoolManager.ts";
4
4
  import { FXLFramePoolManager } from "./fxl/FXLFramePoolManager.ts";
5
- import { CommsEventKey, ContextMenuEvent, DecorationActivatedEvent, DecorationPointerEnterData, DecorationPointerLeaveData, FXLModules, ModuleLibrary, ModuleName, ReflowableModules, BasicTextSelection, FrameClickEvent, SuspiciousActivityEvent, KeyboardPeripheralEvent } from "@readium/navigator-html-injectables";
5
+ import { CommsEventKey, ContextMenuEvent, DecorationActivatedEvent, DecorationPointerEnterData, DecorationPointerLeaveData, FXLModules, ModuleLibrary, ModuleName, ReflowableModules, BasicTextSelection, FrameClickEvent, SuspiciousActivityEvent, KeyboardPeripheralEvent, TextLayout, PointedPieceRequest, PointedPieceResponse } from "@readium/navigator-html-injectables";
6
6
  import { Decoration, OnDecorationActivatedEvent, OnDecorationPointerEnterEvent, OnDecorationPointerLeaveEvent, DecorationObserver, DecorableNavigator, DecoratorConfig, decorationsEqual, resolveDecorationForWire, supportsDecorationStyle as canRenderDecorationStyle, DecorationStyleType } from "../decorations/index.ts";
7
7
  import * as path from "path-browserify";
8
8
  import { FXLFrameManager } from "./fxl/FXLFrameManager.ts";
@@ -56,6 +56,9 @@ export interface EpubNavigatorListeners {
56
56
  // showToc: () => void;
57
57
  }
58
58
 
59
+ // Expired comms callbacks are dropped, never called.
60
+ const POINTED_PIECE_TIMEOUT = 1000;
61
+
59
62
  const defaultListeners = (listeners: EpubNavigatorListeners): EpubNavigatorListeners => ({
60
63
  frameLoaded: listeners.frameLoaded || (() => {}),
61
64
  positionChanged: listeners.positionChanged || (() => {}),
@@ -122,6 +125,11 @@ export class EpubNavigator extends VisualNavigator implements Configurable<Confi
122
125
  private _decorationHoverState: Map<string, boolean> = new Map();
123
126
  private _decorationActivationConsumed = false;
124
127
  private _decorationResizeSelectors: Set<string>;
128
+ private _textLayoutWatch?: { href: string; pieces: unknown[]; cb: (layout: TextLayout) => void };
129
+ private _textLayoutFrame?: FrameManager;
130
+ private _navigationLocked = false;
131
+ private readonly _pointedPieceRequests = new Map<string, (index: number) => void>();
132
+ private _pointedPieceRequestId = 0;
125
133
 
126
134
  private reflowViewport: VisualNavigatorViewport = {
127
135
  readingOrder: [],
@@ -491,7 +499,18 @@ export class EpubNavigator extends VisualNavigator implements Configurable<Confi
491
499
  } else {
492
500
  this._reapplyDecorationsToCurrentFrames();
493
501
  }
502
+ this._watchTextLayoutInCurrentFrame(true);
503
+ this._sendNavigationLock();
504
+ break;
505
+ case "text_layout":
506
+ // A frame being hidden after moving to another resource lays out again, collapsed.
507
+ if (sourceFrame && sourceFrame === this._textLayoutFrame && this.currentLocation.href.split("#")[0] === this._textLayoutWatch?.href) this._textLayoutWatch.cb(data as TextLayout);
494
508
  break;
509
+ case "pointed_piece": {
510
+ const { id, index } = data as PointedPieceResponse;
511
+ this._pointedPieceRequests.get(id)?.(index);
512
+ break;
513
+ }
495
514
  case "first_visible_locator":
496
515
  const loc = Locator.deserialize(data as string);
497
516
  if(!loc) break;
@@ -549,7 +568,7 @@ export class EpubNavigator extends VisualNavigator implements Configurable<Confi
549
568
  ) {
550
569
  const origHref = element.attributes.getNamedItem("href")?.value!;
551
570
  if (origHref.startsWith("#")) {
552
- this.go(this.currentLocation.copyWithLocations({
571
+ if (!this._navigationLocked) this.go(this.currentLocation.copyWithLocations({
553
572
  fragments: [origHref.substring(1)]
554
573
  }), false, () => { });
555
574
  } else if(
@@ -561,7 +580,7 @@ export class EpubNavigator extends VisualNavigator implements Configurable<Confi
561
580
  this.listeners.handleLocator(new Link({
562
581
  href: origHref,
563
582
  }).locator);
564
- } else {
583
+ } else if (!this._navigationLocked) {
565
584
  try {
566
585
  this.goLink(new Link({
567
586
  href: path.join(path.dirname(this.currentLocation.href), origHref)
@@ -578,21 +597,7 @@ export class EpubNavigator extends VisualNavigator implements Configurable<Confi
578
597
  if(this._layout === Layout.fixed && (this.framePool as FXLFramePoolManager).doNotDisturb)
579
598
  edata.doNotDisturb = true;
580
599
 
581
- if(this._layout === Layout.fixed
582
- && (
583
- this.currentProgression === ReadingProgression.rtl ||
584
- this.currentProgression === ReadingProgression.ltr
585
- )
586
- ) {
587
- if(this.framePool.currentFrames.length > 1) {
588
- // Spread page dimensions
589
- const cfs = this.framePool.currentFrames;
590
- if(edata.targetFrameSrc === cfs[this.currentProgression === ReadingProgression.rtl ? 0 : 1]?.source) {
591
- // The right page (screen-wise) was clicked, so we add the left page's width to the click's x
592
- edata.x += (cfs[this.currentProgression === ReadingProgression.rtl ? 1 : 0]?.iframe.contentWindow?.innerWidth ?? 0) * window.devicePixelRatio;
593
- }
594
- }
595
- }
600
+ edata.x += this._spreadOffsetX(edata.targetFrameSrc);
596
601
 
597
602
  const handled = key === "click" ? this.listeners.click(edata) : this.listeners.tap(edata);
598
603
  if(handled) break;
@@ -600,6 +605,7 @@ export class EpubNavigator extends VisualNavigator implements Configurable<Confi
600
605
  const oneQuarter = ((this._cframes.length === 2 ? this._cframes[0]!.window.innerWidth + this._cframes[1]!.window.innerWidth : this._cframes[0]!.window.innerWidth) * window.devicePixelRatio) / 4;
601
606
  // open UI if middle screen is clicked/tapped
602
607
  if (edata.x >= oneQuarter && edata.x <= oneQuarter * 3) this.listeners.miscPointer(1);
608
+ if (this._navigationLocked) break;
603
609
  if (edata.x < oneQuarter) this.goLeft(false, () => { }); // Go left if left quarter clicked
604
610
  else if (edata.x > oneQuarter * 3) this.goRight(false, () => { }); // Go right if right quarter clicked
605
611
  }
@@ -608,10 +614,10 @@ export class EpubNavigator extends VisualNavigator implements Configurable<Confi
608
614
  this.listeners.miscPointer(data as number);
609
615
  break;
610
616
  case "no_more":
611
- this.changeResource(1);
617
+ if (!this._navigationLocked) this.changeResource(1);
612
618
  break;
613
619
  case "no_less":
614
- this.changeResource(-1);
620
+ if (!this._navigationLocked) this.changeResource(-1);
615
621
  break;
616
622
  case "swipe":
617
623
  // Swipe event
@@ -622,9 +628,13 @@ export class EpubNavigator extends VisualNavigator implements Configurable<Confi
622
628
  case "zoom":
623
629
  this.listeners.zoom(data as number);
624
630
  break;
625
- case "progress":
631
+ case "progress": {
632
+ // The previous resource's frame can still report once moving to another, which would take its progress.
633
+ const frame = sourceFrame instanceof FrameManager ? (this.framePool as FramePoolManager)._frameFor(this.currentLocation.href.split("#")[0]) : undefined;
634
+ if (sourceFrame instanceof FrameManager && sourceFrame !== frame) break;
626
635
  this.syncLocation(data as ProgressionRange);
627
636
  break;
637
+ }
628
638
  case "content_protection":
629
639
  const activity = data as SuspiciousActivityEvent;
630
640
  this.listeners.contentProtection(activity.type, activity);
@@ -685,6 +695,8 @@ export class EpubNavigator extends VisualNavigator implements Configurable<Confi
685
695
  }
686
696
  })
687
697
  this._reapplyDecorationsToCurrentFrames();
698
+ this._watchTextLayoutInCurrentFrame();
699
+ this._sendNavigationLock();
688
700
  }
689
701
 
690
702
  private async apply() {
@@ -859,6 +871,100 @@ export class EpubNavigator extends VisualNavigator implements Configurable<Confi
859
871
  });
860
872
  }
861
873
 
874
+ /**
875
+ * Reports where the text of `locators`, all in one reflowable resource, starts each line or column
876
+ * along the scroll axis, with the viewport. Reported again whenever that resource is scrolled or laid out again.
877
+ * Replaces any previous watch, and returns a function that stops this one.
878
+ */
879
+ public watchTextLayout(locators: Locator[], cb: (layout: TextLayout) => void): () => void {
880
+ const watch = {
881
+ href: locators[0]?.href.split("#")[0] ?? "",
882
+ pieces: locators.map(locator => [locator.text?.serialize(), getCssSelector(locator.locations)]),
883
+ cb,
884
+ };
885
+ this._textLayoutWatch = watch;
886
+ this._watchTextLayoutInCurrentFrame(true);
887
+ return () => {
888
+ if (this._textLayoutWatch !== watch) return;
889
+ this._textLayoutWatch = undefined;
890
+ this._watchTextLayoutInCurrentFrame();
891
+ };
892
+ }
893
+
894
+ /**
895
+ * Locks or unlocks navigating in the content: scrolling, swiping, tapping its edges and following its links.
896
+ * Calls to the navigator still move it.
897
+ */
898
+ public lockNavigation(locked: boolean): void {
899
+ this._navigationLocked = locked;
900
+ this._sendNavigationLock();
901
+ }
902
+
903
+ // Fixed-layout frames don't scroll, and their swipes are reported as no_more and no_less.
904
+ private _sendNavigationLock(): void {
905
+ this._cframes.forEach(frame => {
906
+ if (frame instanceof FrameManager) frame.msg?.send("lock_navigation", this._navigationLocked);
907
+ });
908
+ }
909
+
910
+ /** The left page's width when `targetFrameSrc` is the right page (screen-wise) of a fixed-layout spread, else 0. */
911
+ private _spreadOffsetX(targetFrameSrc: string): number {
912
+ if (this._layout !== Layout.fixed || (
913
+ this.currentProgression !== ReadingProgression.rtl &&
914
+ this.currentProgression !== ReadingProgression.ltr
915
+ )) return 0;
916
+ const cfs = this.framePool.currentFrames;
917
+ if (cfs.length < 2) return 0;
918
+ const rtl = this.currentProgression === ReadingProgression.rtl;
919
+ if (targetFrameSrc !== cfs[rtl ? 0 : 1]?.source) return 0;
920
+ return (cfs[rtl ? 1 : 0]?.iframe.contentWindow?.innerWidth ?? 0) * window.devicePixelRatio;
921
+ }
922
+
923
+ /**
924
+ * Returns the first of `pieces` under the point of `event`, else the first containing the element it targets,
925
+ * in the resource of the frame it comes from. Undefined when none does.
926
+ */
927
+ public async findPointedPiece(event: FrameClickEvent, pieces: Locator[]): Promise<Locator | undefined> {
928
+ const frames = this._cframes.filter(f => !!f) as (FrameManager | FXLFrameManager)[];
929
+ const i = frames.findIndex(frame => frame.source === event.targetFrameSrc);
930
+ const href = i >= 0 ? this.viewport.readingOrder[i] : undefined;
931
+ if (!href || !event.cssSelector) return undefined;
932
+ const candidates = pieces.filter(piece => piece.href.split("#")[0] === href);
933
+ if (candidates.length === 0) return undefined;
934
+
935
+ const id = `${++this._pointedPieceRequestId}`;
936
+ let timeout: ReturnType<typeof setTimeout> | undefined;
937
+ const index = await new Promise<number>(resolve => {
938
+ this._pointedPieceRequests.set(id, resolve);
939
+ timeout = setTimeout(() => resolve(-1), POINTED_PIECE_TIMEOUT);
940
+ const request: PointedPieceRequest = {
941
+ id,
942
+ cssSelector: event.cssSelector!,
943
+ x: event.x - this._spreadOffsetX(event.targetFrameSrc),
944
+ y: event.y,
945
+ pieces: candidates.map(piece => [piece.text?.serialize(), getCssSelector(piece.locations)])
946
+ };
947
+ // The reply is posted before the ack, so a pending request on ack got no reply.
948
+ const sent = frames[i].msg?.send("pointed_piece", request, () => resolve(-1));
949
+ if (!sent) resolve(-1);
950
+ });
951
+ clearTimeout(timeout);
952
+ this._pointedPieceRequests.delete(id);
953
+ return candidates[index];
954
+ }
955
+
956
+ // The previous frame stops watching, so only the displayed resource reports its layout.
957
+ private _watchTextLayoutInCurrentFrame(resend = false): void {
958
+ const frame = this._cframes[0];
959
+ const watch = this._textLayoutWatch;
960
+ const target = watch && frame instanceof FrameManager && this.currentLocation?.href.split("#")[0] === watch.href ? frame : undefined;
961
+ if (target === this._textLayoutFrame && !resend) return;
962
+ const previous = this._textLayoutFrame;
963
+ if (previous && previous !== target && !previous.isDestroyed) previous.msg?.send("watch_text_layout", []);
964
+ this._textLayoutFrame = target;
965
+ if (target && watch) target.msg?.send("watch_text_layout", watch.pieces);
966
+ }
967
+
862
968
  private _handleDecorationActivated(data: DecorationActivatedEvent): boolean {
863
969
  const observers = this._decorationObservers.get(data.group);
864
970
  if (!observers || observers.size === 0) return false;
@@ -327,6 +327,11 @@ export class FramePoolManager {
327
327
  return [this._currentFrame];
328
328
  }
329
329
 
330
+ /** The pooled frame of `href`, whether or not it's shown. */
331
+ _frameFor(href: string): FrameManager | undefined {
332
+ return this.pool.get(href);
333
+ }
334
+
330
335
  get currentBounds(): DOMRect {
331
336
  const ret = {
332
337
  x: 0,
package/src/index.ts CHANGED
@@ -8,3 +8,4 @@ export * from './helpers/index.ts';
8
8
  export * from './preferences/index.ts';
9
9
  export * from './css/index.ts';
10
10
  export * from './injection/index.ts';
11
+ export * from './readaloud/index.ts';
@@ -82,6 +82,17 @@ export function ensureValueInRange(value: number | null | undefined, range: [num
82
82
  return value >= min && value <= max ? value : undefined;
83
83
  }
84
84
 
85
+ export function ensureDecorationStyle<T extends object>(value: T | false | null | undefined): T | false | null | undefined {
86
+ if (value === undefined || value === null || value === false) {
87
+ return value;
88
+ }
89
+ if (typeof value !== "object") {
90
+ return undefined;
91
+ }
92
+ const type = (value as { type?: unknown }).type;
93
+ return type === undefined || typeof type === "string" ? value : undefined;
94
+ }
95
+
85
96
  export function withFallback<T>(value: T | null | undefined, defaultValue: T | null): T | null {
86
97
  return value === undefined ? defaultValue : value;
87
98
  }
@@ -0,0 +1,87 @@
1
+ import { GuidedNavigationObject, Link, Publication } from "@readium/shared";
2
+ import { decodeTextref, makeGnd } from "@readium/guided-navigation";
3
+
4
+ export interface GuidedNavigationProvider {
5
+ /**
6
+ * Guided Navigation objects of a resource, with every textref qualified by the resource's href.
7
+ * Resolves to undefined when the resource has nothing to read.
8
+ */
9
+ guideFor(link: Link): Promise<GuidedNavigationObject[] | undefined>;
10
+ }
11
+
12
+ export interface PublicationGuidedNavigationProviderOptions {
13
+ /** Generates from the resource's markup without using the publication's Guided Navigation document. */
14
+ generateFromMarkup?: boolean;
15
+ }
16
+
17
+ /**
18
+ * Uses the publication's Guided Navigation document when its text can be located in the resource,
19
+ * and generates one from the resource's markup otherwise, or always with `generateFromMarkup`.
20
+ * Only the objects of the resource are kept from a document covering the whole publication.
21
+ */
22
+ export class PublicationGuidedNavigationProvider implements GuidedNavigationProvider {
23
+ constructor(private readonly publication: Publication, private readonly options: PublicationGuidedNavigationProviderOptions = {}) {}
24
+
25
+ async guideFor(link: Link): Promise<GuidedNavigationObject[] | undefined> {
26
+ const href = link.href.split("#")[0];
27
+ if (!this.options.generateFromMarkup) {
28
+ const document = await this.publication.guideForLink(link, { skipAudio: true }).catch(() => undefined);
29
+ if (document) {
30
+ const guided = this.objectsIn(document.guided, href);
31
+ if (guided.length > 0 && this.isLocatable(guided)) return guided;
32
+ }
33
+ }
34
+ return this.generate(link, href);
35
+ }
36
+
37
+ private async generate(link: Link, href: string): Promise<GuidedNavigationObject[] | undefined> {
38
+ const resource = this.publication.get(link);
39
+ try {
40
+ const resourceLink = await resource.link();
41
+ if (!resourceLink.mediaType.isHTML) return undefined;
42
+ const doc = await resource.readAsXML();
43
+ if (!doc || doc.getElementsByTagNameNS("*", "parsererror").length > 0) return undefined;
44
+ const body = doc.querySelector("body");
45
+ if (!body) return undefined;
46
+ const mediaType = resourceLink.mediaType.essence === "text/html" ? "text/html" : "application/xhtml+xml";
47
+ return makeGnd(body, mediaType, { textrefs: { roles: true }, href })?.guided;
48
+ } finally {
49
+ resource.close();
50
+ }
51
+ }
52
+
53
+ // Objects without a resource of their own belong to their parent's, and are kept when that is `href` or unknown.
54
+ private objectsIn(objects: GuidedNavigationObject[], href: string, parentHref?: string): GuidedNavigationObject[] {
55
+ return objects.flatMap(object => {
56
+ const objectHref = this.resourceOf(object) ?? parentHref;
57
+ const children = object.children ? this.objectsIn(object.children, href, objectHref) : [];
58
+ if (objectHref !== undefined && objectHref !== href) return children;
59
+ if (children.length === 0 && !object.text && !object.textref && !object.imgref) return [];
60
+ // A fragment-only textref, as a resource's own document may use, is in that resource.
61
+ const textref = object.textref?.startsWith("#") ? href + object.textref : object.textref;
62
+ return [new GuidedNavigationObject({ ...object, textref, children: children.length > 0 ? children : undefined })];
63
+ });
64
+ }
65
+
66
+ private resourceOf(object: GuidedNavigationObject): string | undefined {
67
+ const textHref = object.textref?.split("#")[0];
68
+ if (textHref) return textHref;
69
+ return this.imageOf(object);
70
+ }
71
+
72
+ // An imgref only names a resource when it's an image of the reading order, as in Divina.
73
+ private imageOf(object: GuidedNavigationObject): string | undefined {
74
+ const href = object.imgref?.split("#")[0];
75
+ return href && this.publication.readingOrder.findWithHref(href) ? href : undefined;
76
+ }
77
+
78
+ // Every object with text must be locatable by its own ref or an ancestor's, or following would miss it.
79
+ private isLocatable(objects: GuidedNavigationObject[], ancestorLocatable = false): boolean {
80
+ return objects.every(object => {
81
+ const own = object.textref ? decodeTextref(object) !== undefined : this.imageOf(object) !== undefined;
82
+ const locatable = ancestorLocatable || own;
83
+ if (object.text && !locatable) return false;
84
+ return !object.children || this.isLocatable(object.children, locatable);
85
+ });
86
+ }
87
+ }