@readium/navigator-html-injectables 2.5.0 → 2.6.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 (73) hide show
  1. package/dist/Loader.js +1 -0
  2. package/dist/comms/comms.js +1 -0
  3. package/dist/comms/mid.js +1 -0
  4. package/dist/helpers/animation.js +1 -0
  5. package/dist/helpers/css.js +1 -0
  6. package/dist/helpers/document.js +1 -0
  7. package/dist/helpers/dom.js +1 -0
  8. package/dist/helpers/locator.js +1 -0
  9. package/dist/helpers/rect.js +1 -0
  10. package/dist/helpers/sanitize.js +1 -0
  11. package/dist/index.js +1 -4601
  12. package/dist/keyboard/KeyCombinationManager.js +1 -0
  13. package/dist/keyboard/KeyboardCombinations.js +1 -0
  14. package/dist/modules/Decorator.js +24 -0
  15. package/dist/modules/Module.js +1 -0
  16. package/dist/modules/ModuleLibrary.js +1 -0
  17. package/dist/modules/Peripherals.js +1 -0
  18. package/dist/modules/setup/FixedSetup.js +13 -0
  19. package/dist/modules/setup/ReflowableSetup.js +1 -0
  20. package/dist/modules/setup/Setup.js +1 -0
  21. package/dist/modules/setup/WebPubSetup.js +1 -0
  22. package/dist/modules/snapper/CJKVerticalSnapper.js +12 -0
  23. package/dist/modules/snapper/ColumnSnapper.js +35 -0
  24. package/dist/modules/snapper/ScrollSnapper.js +9 -0
  25. package/dist/modules/snapper/Snapper.js +5 -0
  26. package/dist/modules/snapper/WebPubSnapper.js +1 -0
  27. package/dist/protection/BulkCopyProtector.js +1 -0
  28. package/dist/protection/PatternAnalyzer.js +1 -0
  29. package/dist/protection/PrintProtector.js +15 -0
  30. package/dist/protection/SelectionAnalyzer.js +1 -0
  31. package/dist/protection/config.js +1 -0
  32. package/dist/vendor/approx-string-match/index.js +1 -0
  33. package/dist/vendor/hypothesis/anchoring/match-quote.js +1 -0
  34. package/dist/vendor/hypothesis/anchoring/text-range.js +1 -0
  35. package/dist/vendor/hypothesis/anchoring/trim-range.js +1 -0
  36. package/dist/vendor/hypothesis/anchoring/types.js +1 -0
  37. package/package.json +14 -13
  38. package/src/comms/comms.ts +11 -1
  39. package/src/comms/keys.ts +6 -2
  40. package/src/helpers/css.ts +1 -1
  41. package/src/helpers/document.ts +5 -5
  42. package/src/helpers/locator.ts +5 -5
  43. package/src/helpers/rect.ts +79 -18
  44. package/src/index.ts +1 -1
  45. package/src/modules/Decorator.ts +404 -107
  46. package/src/modules/snapper/CJKVerticalSnapper.ts +34 -7
  47. package/src/modules/snapper/ColumnSnapper.ts +54 -6
  48. package/src/modules/snapper/ScrollSnapper.ts +30 -7
  49. package/src/modules/snapper/Snapper.ts +144 -0
  50. package/src/modules/snapper/WebPubSnapper.ts +31 -7
  51. package/types/src/comms/comms.d.ts +10 -1
  52. package/types/src/comms/keys.d.ts +2 -2
  53. package/types/src/helpers/css.d.ts +1 -1
  54. package/types/src/helpers/document.d.ts +5 -5
  55. package/types/src/helpers/rect.d.ts +2 -1
  56. package/types/src/index.d.ts +1 -1
  57. package/types/src/modules/Decorator.d.ts +33 -7
  58. package/types/src/modules/snapper/CJKVerticalSnapper.d.ts +1 -0
  59. package/types/src/modules/snapper/ColumnSnapper.d.ts +14 -1
  60. package/types/src/modules/snapper/ScrollSnapper.d.ts +1 -0
  61. package/types/src/modules/snapper/Snapper.d.ts +58 -0
  62. package/types/src/modules/snapper/WebPubSnapper.d.ts +1 -0
  63. package/dist/ar-DyHX_uy2.js +0 -7
  64. package/dist/da-Dct0PS3E.js +0 -7
  65. package/dist/fr-C5HEel98.js +0 -7
  66. package/dist/index.umd.cjs +0 -96
  67. package/dist/it-DFOBoXGy.js +0 -7
  68. package/dist/pt_PT-Di3sVjze.js +0 -7
  69. package/dist/sv-BfzAFsVN.js +0 -7
  70. package/src/helpers/color.ts +0 -268
  71. package/src/helpers/sML.ts +0 -143
  72. package/types/src/helpers/color.d.ts +0 -27
  73. package/types/src/helpers/sML.d.ts +0 -56
@@ -68,7 +68,14 @@ export class CJKVerticalSnapper extends Snapper {
68
68
  return Math.max(0, this.doc().scrollWidth - this.wnd.innerWidth);
69
69
  }
70
70
 
71
- private reportProgress() {
71
+ protected hasScrolledPast(el: Element): boolean {
72
+ const rect = el.getBoundingClientRect();
73
+ // vertical-rl: content flows right→left; leading edge is right, scrolled past when off-screen right.
74
+ // vertical-lr: content flows left→right; leading edge is left, scrolled past when off-screen left.
75
+ return this.verticalLR ? rect.right <= 0 : rect.left >= this.wnd.innerWidth;
76
+ }
77
+
78
+ private reportProgress(forcedFragmentId?: string) {
72
79
  if (!this.comms.ready) return;
73
80
  const scrollWidth = this.doc().scrollWidth;
74
81
  const viewportWidth = this.wnd.innerWidth;
@@ -82,7 +89,8 @@ export class CJKVerticalSnapper extends Snapper {
82
89
 
83
90
  this.comms.send("progress", {
84
91
  start: progress,
85
- end: viewportEnd
92
+ end: viewportEnd,
93
+ fragmentId: forcedFragmentId !== undefined ? forcedFragmentId : this.currentTimelineFragment()
86
94
  });
87
95
  }
88
96
 
@@ -150,6 +158,7 @@ export class CJKVerticalSnapper extends Snapper {
150
158
  mount(wnd: ReadiumWindow, comms: Comms): boolean {
151
159
  this.wnd = wnd;
152
160
  this.comms = comms;
161
+ this.setupTimelineObserver();
153
162
 
154
163
  this.initialScrollHandled = false;
155
164
  this.lastScrollLeft = 0;
@@ -223,7 +232,9 @@ export class CJKVerticalSnapper extends Snapper {
223
232
  // vertical-lr: scrollLeft is positive; vertical-rl: negative.
224
233
  const target = this.scrollable() * position;
225
234
  this.doc().scrollLeft = this.verticalLR ? target : -target;
226
- this.reportProgress();
235
+ // No known target fragment for an arbitrary position — force a fresh
236
+ // geometry scan instead of trusting a possibly-stale visibility cache.
237
+ this.reportProgress(this.fragmentFromGeometry());
227
238
  deselect(this.wnd);
228
239
  ack(true);
229
240
  });
@@ -235,7 +246,10 @@ export class CJKVerticalSnapper extends Snapper {
235
246
  this.wnd.requestAnimationFrame(() => {
236
247
  // getBoundingClientRect().left is in viewport coords; translate to scroll coords
237
248
  this.doc().scrollLeft += element.getBoundingClientRect().left - wnd.innerWidth / 2;
238
- this.reportProgress();
249
+ const targetId = data as string;
250
+ this.reportProgress(
251
+ this.timelineEntries.has(targetId) ? targetId : this.nearestPrecedingTimelineEntry(element)
252
+ );
239
253
  deselect(this.wnd);
240
254
  ack(true);
241
255
  });
@@ -259,7 +273,7 @@ export class CJKVerticalSnapper extends Snapper {
259
273
  if (!r) { ack(false); return; }
260
274
  this.wnd.requestAnimationFrame(() => {
261
275
  this.doc().scrollLeft += r.getBoundingClientRect().left - wnd.innerWidth / 2;
262
- this.reportProgress();
276
+ this.reportProgress(this.nearestPrecedingTimelineEntry(r.startContainer));
263
277
  deselect(this.wnd);
264
278
  ack(true);
265
279
  });
@@ -269,7 +283,10 @@ export class CJKVerticalSnapper extends Snapper {
269
283
  comms.register("go_start", CJKVerticalSnapper.moduleName, (_, ack) => {
270
284
  if (this.doc().scrollLeft === 0) return ack(false);
271
285
  this.doc().scrollLeft = 0;
272
- this.reportProgress();
286
+ // The first fragment isn't necessarily reached at document start
287
+ // (there may be content before it) — check only that one element
288
+ // instead of assuming sortedFragmentIds[0] is already visible.
289
+ this.reportProgress(this.firstFragmentIfReached());
273
290
  ack(true);
274
291
  });
275
292
 
@@ -279,7 +296,7 @@ export class CJKVerticalSnapper extends Snapper {
279
296
  comms.register("go_end", CJKVerticalSnapper.moduleName, (_, ack) => {
280
297
  if (Math.abs(this.doc().scrollLeft) === this.scrollable()) return ack(false);
281
298
  this.doc().scrollLeft = this.verticalLR ? this.scrollable() : -this.scrollable();
282
- this.reportProgress();
299
+ this.reportProgress(this.sortedFragmentIds[this.sortedFragmentIds.length - 1]);
283
300
  ack(true);
284
301
  });
285
302
 
@@ -311,6 +328,11 @@ export class CJKVerticalSnapper extends Snapper {
311
328
  ack(true);
312
329
  });
313
330
 
331
+ comms.register("timeline_entries", CJKVerticalSnapper.moduleName, (data, ack) => {
332
+ this.updateTimelineEntries(Array.isArray(data) ? data as string[] : [], wnd);
333
+ ack(true);
334
+ });
335
+
314
336
  comms.log("CJKVerticalSnapper Mounted");
315
337
  return true;
316
338
  }
@@ -327,6 +349,11 @@ export class CJKVerticalSnapper extends Snapper {
327
349
  this.isScrollProtectionEnabled = false;
328
350
  }
329
351
 
352
+ this.timelineObserver?.disconnect();
353
+ this.timelineObserver = null;
354
+ this.visibleFragmentIds.clear();
355
+ this.timelineEntries.clear();
356
+
330
357
  comms.log("CJKVerticalSnapper Unmounted");
331
358
  return true;
332
359
  }
@@ -72,7 +72,40 @@ export class ColumnSnapper extends Snapper {
72
72
  return this.rtl ? Math.abs(raw) : Math.max(0, this.wnd.scrollX > 0 ? this.wnd.scrollX : raw);
73
73
  }
74
74
 
75
- reportProgress() {
75
+ protected hasScrolledPast(el: Element): boolean {
76
+ const rect = el.getBoundingClientRect();
77
+ return this.rtl ? rect.left >= this.wnd.innerWidth : rect.right <= 0;
78
+ }
79
+
80
+ /**
81
+ * IntersectionObserver cannot be used here. In Chrome and Safari, body's computed
82
+ * dimensions in a CSS multi-column layout are wrong unless body height is constrained
83
+ * to 100% *and* overflow is visible — but we can't set overflow: visible, since content
84
+ * overflowing a column must stay clipped to it rather than bleeding into adjacent
85
+ * columns. Even with that fix applied, Safari's IntersectionObserver still never fires
86
+ * a single callback — confirmed dead end there, and WebKit is mandatory on iOS. Firefox
87
+ * needs no fix at all: it reports accurate body dimensions in this layout regardless.
88
+ * Use rect-based detection instead: walk elements in DOM order and return the last one
89
+ * whose leading edge has entered or passed the viewport — that is the current section.
90
+ */
91
+ protected currentTimelineFragment(): string | undefined {
92
+ if (this.timelineEntries.size === 0) return undefined;
93
+
94
+ const vw = this.wnd.innerWidth;
95
+ let result: string | undefined;
96
+ for (const id of this.sortedFragmentIds) {
97
+ const el = this.timelineEntries.get(id)!;
98
+ const rect = el.getBoundingClientRect();
99
+ // LTR: element has started when its left edge is before the viewport's right.
100
+ // RTL: element has started when its right edge is past the viewport's left.
101
+ const started = this.rtl ? rect.right > 0 : rect.left < vw;
102
+ if (started) result = id;
103
+ else break;
104
+ }
105
+ return result;
106
+ }
107
+
108
+ reportProgress(forcedFragmentId?: string) {
76
109
  const scrollWidth = this.cachedScrollWidth;
77
110
  const viewportWidth = this.wnd.innerWidth;
78
111
  const norm = this.normScroll();
@@ -83,7 +116,8 @@ export class ColumnSnapper extends Snapper {
83
116
  const viewportEnd = Math.max(0, Math.min(1, (norm + viewportWidth) / scrollWidth));
84
117
  this.comms.send("progress", {
85
118
  start: progress,
86
- end: viewportEnd
119
+ end: viewportEnd,
120
+ fragmentId: forcedFragmentId !== undefined ? forcedFragmentId : this.currentTimelineFragment()
87
121
  });
88
122
  }
89
123
 
@@ -474,7 +508,10 @@ export class ColumnSnapper extends Snapper {
474
508
  } else {
475
509
  this.doc().scrollLeft = this.snapOffset(element.getBoundingClientRect().left + wnd.scrollX);
476
510
  }
477
- this.reportProgress();
511
+ const targetId = data as string;
512
+ this.reportProgress(
513
+ this.timelineEntries.has(targetId) ? targetId : this.nearestPrecedingTimelineEntry(element)
514
+ );
478
515
  deselect(this.wnd);
479
516
  ack(true);
480
517
  });
@@ -509,7 +546,7 @@ export class ColumnSnapper extends Snapper {
509
546
  } else {
510
547
  this.doc().scrollLeft = this.snapOffset(r.getBoundingClientRect().left + wnd.scrollX);
511
548
  }
512
- this.reportProgress();
549
+ this.reportProgress(this.nearestPrecedingTimelineEntry(r.startContainer));
513
550
  deselect(this.wnd);
514
551
  ack(true);
515
552
  });
@@ -528,7 +565,7 @@ export class ColumnSnapper extends Snapper {
528
565
  }
529
566
  if(this.doc().scrollLeft === snappedFinal) return ack(false);
530
567
  this.doc().scrollLeft = snappedFinal;
531
- this.reportProgress();
568
+ this.reportProgress(this.sortedFragmentIds[this.sortedFragmentIds.length - 1]);
532
569
  deselect(this.wnd);
533
570
  ack(true);
534
571
  });
@@ -538,7 +575,10 @@ export class ColumnSnapper extends Snapper {
538
575
  this.wnd.requestAnimationFrame(() => {
539
576
  if(this.doc().scrollLeft === 0) return ack(false);
540
577
  this.doc().scrollLeft = 0;
541
- this.reportProgress();
578
+ // The first fragment isn't necessarily reached at document start
579
+ // (there may be content before it) — check only that one element
580
+ // instead of assuming sortedFragmentIds[0] is already visible.
581
+ this.reportProgress(this.firstFragmentIfReached());
542
582
  deselect(this.wnd);
543
583
  ack(true);
544
584
  });
@@ -608,6 +648,12 @@ export class ColumnSnapper extends Snapper {
608
648
  ack(true);
609
649
  });
610
650
 
651
+ comms.register("timeline_entries", ColumnSnapper.moduleName, (data, ack) => {
652
+ this.updateTimelineEntries(Array.isArray(data) ? data as string[] : [], wnd);
653
+ wnd.requestAnimationFrame(() => this.reportProgress());
654
+ ack(true);
655
+ });
656
+
611
657
  // Add interaction listeners
612
658
  wnd.addEventListener("touchstart", this.onTouchStarter, { passive: true });
613
659
  wnd.addEventListener("touchend", this.onTouchEnder, { passive: true });
@@ -641,6 +687,8 @@ export class ColumnSnapper extends Snapper {
641
687
 
642
688
  wnd.document.getElementById(COLUMN_SNAPPER_STYLE_ID)?.remove();
643
689
 
690
+ this.timelineEntries.clear();
691
+
644
692
  comms.log("ColumnSnapper Unmounted");
645
693
  return super.unmount(wnd, comms);
646
694
  }
@@ -37,7 +37,11 @@ export class ScrollSnapper extends Snapper {
37
37
  return this.wnd.document.scrollingElement as HTMLElement;
38
38
  }
39
39
 
40
- private reportProgress() {
40
+ protected hasScrolledPast(el: Element): boolean {
41
+ return el.getBoundingClientRect().bottom <= 0;
42
+ }
43
+
44
+ private reportProgress(forcedFragmentId?: string) {
41
45
  if (!this.comms.ready) return;
42
46
  // We have to round up the scroll position because
43
47
  // Android may never reach 100% of the scroll height
@@ -50,7 +54,8 @@ export class ScrollSnapper extends Snapper {
50
54
 
51
55
  this.comms.send("progress", {
52
56
  start: progress,
53
- end: viewportEnd
57
+ end: viewportEnd,
58
+ fragmentId: forcedFragmentId !== undefined ? forcedFragmentId : this.currentTimelineFragment()
54
59
  });
55
60
  }
56
61
 
@@ -124,6 +129,7 @@ export class ScrollSnapper extends Snapper {
124
129
  mount(wnd: ReadiumWindow, comms: Comms): boolean {
125
130
  this.wnd = wnd;
126
131
  this.comms = comms;
132
+ this.setupTimelineObserver();
127
133
 
128
134
  this.initialScrollHandled = false;
129
135
  this.lastScrollTop = 0;
@@ -198,7 +204,9 @@ export class ScrollSnapper extends Snapper {
198
204
 
199
205
  this.wnd.requestAnimationFrame(() => {
200
206
  this.doc().scrollTop = this.doc().offsetHeight * position;
201
- this.reportProgress();
207
+ // No known target fragment for an arbitrary position — force a fresh
208
+ // geometry scan instead of trusting a possibly-stale visibility cache.
209
+ this.reportProgress(this.fragmentFromGeometry());
202
210
  deselect(this.wnd);
203
211
  ack(true);
204
212
  });
@@ -212,7 +220,10 @@ export class ScrollSnapper extends Snapper {
212
220
  }
213
221
  this.wnd.requestAnimationFrame(() => {
214
222
  this.doc().scrollTop = element.getBoundingClientRect().top + wnd.scrollY - wnd.innerHeight / 2;
215
- this.reportProgress();
223
+ const targetId = data as string;
224
+ this.reportProgress(
225
+ this.timelineEntries.has(targetId) ? targetId : this.nearestPrecedingTimelineEntry(element)
226
+ );
216
227
  deselect(this.wnd);
217
228
  ack(true);
218
229
  });
@@ -243,7 +254,7 @@ export class ScrollSnapper extends Snapper {
243
254
  }
244
255
  this.wnd.requestAnimationFrame(() => {
245
256
  this.doc().scrollTop = r.getBoundingClientRect().top + wnd.scrollY - wnd.innerHeight / 2;
246
- this.reportProgress();
257
+ this.reportProgress(this.nearestPrecedingTimelineEntry(r.startContainer));
247
258
  deselect(this.wnd);
248
259
  ack(true);
249
260
  });
@@ -252,14 +263,17 @@ export class ScrollSnapper extends Snapper {
252
263
  comms.register("go_start", ScrollSnapper.moduleName, (_, ack) => {
253
264
  if (this.doc().scrollTop === 0) return ack(false);
254
265
  this.doc().scrollTop = 0;
255
- this.reportProgress();
266
+ // The first fragment isn't necessarily reached at document start
267
+ // (there may be content before it) — check only that one element
268
+ // instead of assuming sortedFragmentIds[0] is already visible.
269
+ this.reportProgress(this.firstFragmentIfReached());
256
270
  ack(true);
257
271
  });
258
272
 
259
273
  comms.register("go_end", ScrollSnapper.moduleName, (_, ack) => {
260
274
  if (this.doc().scrollTop === this.doc().scrollHeight - this.doc().offsetHeight) return ack(false);
261
275
  this.doc().scrollTop = this.doc().scrollHeight - this.doc().offsetHeight;
262
- this.reportProgress();
276
+ this.reportProgress(this.sortedFragmentIds[this.sortedFragmentIds.length - 1]);
263
277
  ack(true);
264
278
  })
265
279
 
@@ -289,6 +303,11 @@ export class ScrollSnapper extends Snapper {
289
303
  ack(true);
290
304
  });
291
305
 
306
+ comms.register("timeline_entries", ScrollSnapper.moduleName, (data, ack) => {
307
+ this.updateTimelineEntries(Array.isArray(data) ? data as string[] : [], wnd);
308
+ ack(true);
309
+ });
310
+
292
311
  comms.log("ScrollSnapper Mounted");
293
312
  return true;
294
313
  }
@@ -298,6 +317,10 @@ export class ScrollSnapper extends Snapper {
298
317
  this.resizeObserver.disconnect();
299
318
  if (this.handleScroll) wnd.removeEventListener("scroll", this.handleScroll);
300
319
  wnd.document.getElementById(SCROLL_SNAPPER_STYLE_ID)?.remove();
320
+ this.timelineObserver?.disconnect();
321
+ this.timelineObserver = null;
322
+ this.visibleFragmentIds.clear();
323
+ this.timelineEntries.clear();
301
324
 
302
325
  if (this.patternAnalyzer) {
303
326
  this.patternAnalyzer.clear();
@@ -10,6 +10,150 @@ export abstract class Snapper extends Module {
10
10
 
11
11
  private protected = false;
12
12
 
13
+ // Timeline fragment tracking
14
+ protected timelineObserver: IntersectionObserver | null = null;
15
+ protected timelineEntries: Map<string, Element> = new Map();
16
+ protected visibleFragmentIds: Set<string> = new Set();
17
+ protected cachedFragmentIds: string[] = [];
18
+ // DOM-order-sorted fragment ids, recomputed only when entries are (re)populated —
19
+ // sorting on every progress report (i.e. every scroll frame) is wasted work.
20
+ protected sortedFragmentIds: string[] = [];
21
+
22
+ private static inDomOrder(entries: Map<string, Element>, a: string, b: string): number {
23
+ const ea = entries.get(a);
24
+ const eb = entries.get(b);
25
+ if (!ea || !eb) return 0;
26
+ const cmp = ea.compareDocumentPosition(eb);
27
+ return cmp & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
28
+ }
29
+
30
+ /**
31
+ * A zero-area target (e.g. an empty `<a id="…">` landmark) always has
32
+ * intersectionRatio 0 by spec, so `isIntersecting` can never become true for it —
33
+ * no threshold value changes that. For those entries, fall back to a geometric
34
+ * containment check against `rootBounds` instead of trusting `isIntersecting`.
35
+ */
36
+ private static isVisibleEntry(entry: IntersectionObserverEntry): boolean {
37
+ const rect = entry.boundingClientRect;
38
+ // Element has real width and height: isIntersecting is spec-correct, trust it as-is.
39
+ if (rect.width > 0 && rect.height > 0) return entry.isIntersecting;
40
+ // Zero-width or zero-height element: isIntersecting is stuck at false (ratio is
41
+ // area / 0, defined as 0), so it can't be trusted. Do our own overlap check instead.
42
+ const root = entry.rootBounds;
43
+ if (!root) return entry.isIntersecting;
44
+ return rect.left <= root.right && rect.right >= root.left &&
45
+ rect.top <= root.bottom && rect.bottom >= root.top;
46
+ }
47
+
48
+ protected setupTimelineObserver(): void {
49
+ if (this.timelineObserver) this.timelineObserver.disconnect();
50
+ this.timelineObserver = new IntersectionObserver(
51
+ (entries) => {
52
+ for (const entry of entries) {
53
+ if (Snapper.isVisibleEntry(entry))
54
+ this.visibleFragmentIds.add((entry.target as HTMLElement).id);
55
+ else
56
+ this.visibleFragmentIds.delete((entry.target as HTMLElement).id);
57
+ }
58
+ },
59
+ { threshold: [0.01] }
60
+ );
61
+ }
62
+
63
+ /**
64
+ * Populates `timelineEntries`/`sortedFragmentIds` from a fresh id list, and
65
+ * (re)starts IntersectionObserver-based visibility tracking for subclasses that use it.
66
+ * Shared by all snappers' `timeline_entries` comms handler so DOM-order sorting and
67
+ * observer bookkeeping only happen once per (re)population, not per report.
68
+ */
69
+ protected updateTimelineEntries(ids: string[], wnd: ReadiumWindow): void {
70
+ this.cachedFragmentIds = ids;
71
+ this.timelineObserver?.disconnect();
72
+ this.visibleFragmentIds.clear();
73
+ this.timelineEntries.clear();
74
+ for (const id of ids) {
75
+ const el = wnd.document.getElementById(id);
76
+ if (el) {
77
+ this.timelineEntries.set(id, el);
78
+ this.timelineObserver?.observe(el);
79
+ }
80
+ }
81
+ this.sortedFragmentIds = Array.from(this.timelineEntries.keys())
82
+ .sort((a, b) => Snapper.inDomOrder(this.timelineEntries, a, b));
83
+ }
84
+
85
+ /**
86
+ * Returns the nearest fragment at or before `node` in DOM (reading) order, with no
87
+ * dependency on rendered geometry — safe to call for programmatic navigation where the
88
+ * target is already known, regardless of whether layout/IntersectionObserver has settled.
89
+ */
90
+ protected nearestPrecedingTimelineEntry(node: Node): string | undefined {
91
+ let nearestId: string | undefined;
92
+ for (const id of this.sortedFragmentIds) {
93
+ const el = this.timelineEntries.get(id)!;
94
+ const cmp = el.compareDocumentPosition(node);
95
+ const nodeFollowsEl = el === node || el.contains(node) ||
96
+ (cmp & Node.DOCUMENT_POSITION_FOLLOWING) !== 0;
97
+ if (nodeFollowsEl) nearestId = id;
98
+ else break;
99
+ }
100
+ return nearestId;
101
+ }
102
+
103
+ /**
104
+ * Rect-based scan: last element in DOM order whose leading edge has already been
105
+ * scrolled past. Subclasses implement `hasScrolledPast` for their layout/axis.
106
+ * Exposed so callers can force an accurate one-off geometry check (e.g. right after
107
+ * a `go_progression` jump) instead of trusting a possibly-stale visibility cache.
108
+ */
109
+ protected fragmentFromGeometry(): string | undefined {
110
+ let nearestId: string | undefined;
111
+ for (const id of this.sortedFragmentIds) {
112
+ const el = this.timelineEntries.get(id)!;
113
+ if (this.hasScrolledPast(el)) nearestId = id;
114
+ else break;
115
+ }
116
+ return nearestId;
117
+ }
118
+
119
+ /**
120
+ * go_start-specific check: whether the first known fragment's leading edge has
121
+ * already been scrolled past, checking only that one element rather than scanning
122
+ * the whole timeline. At document start there is nothing before the first fragment
123
+ * to confuse the result, so a single rect check is sufficient (and cheaper than
124
+ * `fragmentFromGeometry`).
125
+ */
126
+ protected firstFragmentIfReached(): string | undefined {
127
+ const id = this.sortedFragmentIds[0];
128
+ if (id === undefined) return undefined;
129
+ const el = this.timelineEntries.get(id)!;
130
+ return this.hasScrolledPast(el) ? id : undefined;
131
+ }
132
+
133
+ /**
134
+ * Returns the ID of the currently active timeline fragment:
135
+ * 1. Primary — IntersectionObserver: returns the first visible element in DOM (reading) order.
136
+ * This is direction-agnostic; works for LTR, RTL, and vertical writing modes. Reliable for
137
+ * continuous, gradual scrolling.
138
+ * 2. Fallback — rect scan (`fragmentFromGeometry`): used when IntersectionObserver hasn't
139
+ * fired yet. Not reliable right after an instantaneous programmatic jump — callers that
140
+ * know the target (goTo-style navigation) should bypass this method entirely.
141
+ */
142
+ protected currentTimelineFragment(): string | undefined {
143
+ if (this.visibleFragmentIds.size > 0) {
144
+ return Array.from(this.visibleFragmentIds)
145
+ .sort((a, b) => Snapper.inDomOrder(this.timelineEntries, a, b))[0];
146
+ }
147
+ return this.fragmentFromGeometry();
148
+ }
149
+
150
+ /**
151
+ * Returns true if the element's leading edge (in reading direction) has been scrolled past
152
+ * — i.e. the element is before the current viewport position.
153
+ * Subclasses implement this for their specific scroll axis and reading direction.
154
+ */
155
+ protected abstract hasScrolledPast(el: Element): boolean;
156
+
13
157
  buildStyles() {
14
158
  return `
15
159
  html, body {
@@ -31,7 +31,11 @@ export class WebPubSnapper extends Snapper {
31
31
  return this.wnd.document.scrollingElement as HTMLElement;
32
32
  }
33
33
 
34
- private reportProgress() {
34
+ protected hasScrolledPast(el: Element): boolean {
35
+ return el.getBoundingClientRect().bottom <= 0;
36
+ }
37
+
38
+ private reportProgress(forcedFragmentId?: string) {
35
39
  if (!this.comms.ready) return;
36
40
 
37
41
  const scrollTop = Math.ceil(this.doc().scrollTop);
@@ -42,7 +46,8 @@ export class WebPubSnapper extends Snapper {
42
46
 
43
47
  this.comms.send("progress", {
44
48
  start: progress,
45
- end: viewportEnd
49
+ end: viewportEnd,
50
+ fragmentId: forcedFragmentId !== undefined ? forcedFragmentId : this.currentTimelineFragment()
46
51
  });
47
52
  }
48
53
 
@@ -115,6 +120,7 @@ export class WebPubSnapper extends Snapper {
115
120
  mount(wnd: ReadiumWindow, comms: Comms): boolean {
116
121
  this.wnd = wnd;
117
122
  this.comms = comms;
123
+ this.setupTimelineObserver();
118
124
 
119
125
  this.initialScrollHandled = false;
120
126
  this.lastScrollTop = 0;
@@ -170,7 +176,9 @@ export class WebPubSnapper extends Snapper {
170
176
 
171
177
  this.wnd.requestAnimationFrame(() => {
172
178
  this.doc().scrollTop = this.doc().offsetHeight * position;
173
- this.reportProgress();
179
+ // No known target fragment for an arbitrary position — force a fresh
180
+ // geometry scan instead of trusting a possibly-stale visibility cache.
181
+ this.reportProgress(this.fragmentFromGeometry());
174
182
  deselect(this.wnd);
175
183
  ack(true);
176
184
  });
@@ -184,7 +192,10 @@ export class WebPubSnapper extends Snapper {
184
192
  }
185
193
  this.wnd.requestAnimationFrame(() => {
186
194
  this.doc().scrollTop = element.getBoundingClientRect().top + wnd.scrollY - wnd.innerHeight / 2;
187
- this.reportProgress();
195
+ const targetId = data as string;
196
+ this.reportProgress(
197
+ this.timelineEntries.has(targetId) ? targetId : this.nearestPrecedingTimelineEntry(element)
198
+ );
188
199
  deselect(this.wnd);
189
200
  ack(true);
190
201
  });
@@ -214,7 +225,7 @@ export class WebPubSnapper extends Snapper {
214
225
  }
215
226
  this.wnd.requestAnimationFrame(() => {
216
227
  this.doc().scrollTop = r.getBoundingClientRect().top + wnd.scrollY - wnd.innerHeight / 2;
217
- this.reportProgress();
228
+ this.reportProgress(this.nearestPrecedingTimelineEntry(r.startContainer));
218
229
  deselect(this.wnd);
219
230
  ack(true);
220
231
  });
@@ -223,14 +234,17 @@ export class WebPubSnapper extends Snapper {
223
234
  comms.register("go_start", WebPubSnapper.moduleName, (_, ack) => {
224
235
  if (this.doc().scrollTop === 0) return ack(false);
225
236
  this.doc().scrollTop = 0;
226
- this.reportProgress();
237
+ // The first fragment isn't necessarily reached at document start
238
+ // (there may be content before it) — check only that one element
239
+ // instead of assuming sortedFragmentIds[0] is already visible.
240
+ this.reportProgress(this.firstFragmentIfReached());
227
241
  ack(true);
228
242
  });
229
243
 
230
244
  comms.register("go_end", WebPubSnapper.moduleName, (_, ack) => {
231
245
  if (this.doc().scrollTop === this.doc().scrollHeight - this.doc().offsetHeight) return ack(false);
232
246
  this.doc().scrollTop = this.doc().scrollHeight - this.doc().offsetHeight;
233
- this.reportProgress();
247
+ this.reportProgress(this.sortedFragmentIds[this.sortedFragmentIds.length - 1]);
234
248
  ack(true);
235
249
  });
236
250
 
@@ -259,6 +273,11 @@ export class WebPubSnapper extends Snapper {
259
273
  ack(true);
260
274
  });
261
275
 
276
+ comms.register("timeline_entries", WebPubSnapper.moduleName, (data, ack) => {
277
+ this.updateTimelineEntries(Array.isArray(data) ? data as string[] : [], wnd);
278
+ ack(true);
279
+ });
280
+
262
281
  comms.log("WebPubSnapper Mounted");
263
282
  return true;
264
283
  }
@@ -274,6 +293,11 @@ export class WebPubSnapper extends Snapper {
274
293
  this.isScrollProtectionEnabled = false;
275
294
  }
276
295
 
296
+ this.timelineObserver?.disconnect();
297
+ this.timelineObserver = null;
298
+ this.visibleFragmentIds.clear();
299
+ this.timelineEntries.clear();
300
+
277
301
  comms.log("WebPubSnapper Unmounted");
278
302
  return true;
279
303
  }
@@ -15,11 +15,20 @@ export interface Registrant {
15
15
  }
16
16
  export type CommsAck = (ok: boolean) => void;
17
17
  export type CommsCallback = (data: unknown, ack: CommsAck) => void;
18
+ export interface IComms {
19
+ register(key: CommsCommandKey | CommsCommandKey[], module: string, callback: CommsCallback): void;
20
+ unregister(key: CommsCommandKey | CommsCommandKey[], module: string): void;
21
+ unregisterAll(module: string): void;
22
+ send(key: CommsEventKey, data: unknown, id?: unknown, transfer?: Transferable[]): void;
23
+ log(...data: any[]): void;
24
+ readonly ready: boolean;
25
+ destroy(): void;
26
+ }
18
27
  /**
19
28
  * Comms is basically a wrapper around window.postMessage that
20
29
  * adds structure to the messages and lets modules register callbacks.
21
30
  */
22
- export declare class Comms {
31
+ export declare class Comms implements IComms {
23
32
  private readonly wnd;
24
33
  private destination;
25
34
  private registrar;
@@ -1,3 +1,3 @@
1
- export type CommsEventKey = "_pong" | "_unhandled" | "_ack" | "log" | "error" | "click" | "tap" | "tap_more" | "no_more" | "no_less" | "swipe" | "scroll" | "progress" | "first_visible_locator" | "text_selected" | "context_menu" | "media_play" | "media_pause" | "content_protection" | "keyboard_peripherals" | "decoration_activated";
2
- export type CommsCommandKey = "_ping" | "go_prev" | "go_next" | "go_id" | "go_text" | "go_end" | "go_start" | "go_progression" | "get_properties" | "update_properties" | "set_property" | "remove_property" | "first_visible_locator" | "decorate" | "decoration_activatable" | "protect" | "unprotect" | "unfocus" | "focus" | "activate" | "shake" | "force_webkit_recalc" | "peripherals_protection" | "keyboard_peripherals" | "scroll_protection" | "print_protection";
1
+ export type CommsEventKey = "_pong" | "_unhandled" | "_ack" | "log" | "error" | "click" | "tap" | "tap_more" | "no_more" | "no_less" | "swipe" | "scroll" | "progress" | "first_visible_locator" | "text_selected" | "context_menu" | "media_play" | "media_pause" | "content_protection" | "keyboard_peripherals" | "decoration_activated" | "decoration_pointer_enter" | "decoration_pointer_leave";
2
+ export type CommsCommandKey = "_ping" | "go_prev" | "go_next" | "go_id" | "go_text" | "go_end" | "go_start" | "go_progression" | "get_properties" | "update_properties" | "set_property" | "remove_property" | "first_visible_locator" | "decorate" | "decoration_activatable" | "decoration_hoverable" | "protect" | "unprotect" | "unfocus" | "focus" | "activate" | "shake" | "force_webkit_recalc" | "peripherals_protection" | "keyboard_peripherals" | "scroll_protection" | "print_protection" | "timeline_entries";
3
3
  export type SuspiciousActivityType = "developer_tools" | "select_all" | "save" | "suspicious_selection" | "bulk_copy" | "suspicious_scrolling" | "suspicious_snapping" | "drag_detected" | "drop_detected" | "print";
@@ -11,6 +11,6 @@ export declare function getProperties(wnd: ReadiumWindow): {
11
11
  export declare function updateProperties(wnd: ReadiumWindow, properties: {
12
12
  [key: string]: string;
13
13
  }): void;
14
- export declare function getProperty(wnd: ReadiumWindow, key: string): string;
14
+ export declare function getProperty(wnd: Window, key: string): string;
15
15
  export declare function setProperty(wnd: ReadiumWindow, key: string, value: string): void;
16
16
  export declare function removeProperty(wnd: ReadiumWindow, key: string): void;
@@ -1,7 +1,7 @@
1
1
  import { ReadiumWindow } from "./dom.ts";
2
- export declare function isRTL(wnd: ReadiumWindow): boolean;
3
- export declare function isVerticalLR(wnd: ReadiumWindow): boolean;
4
- export declare function isVerticalWriting(wnd: ReadiumWindow): boolean;
2
+ export declare function isRTL(wnd: Window): boolean;
3
+ export declare function isVerticalLR(wnd: Window): boolean;
4
+ export declare function isVerticalWriting(wnd: Window): boolean;
5
5
  /**
6
6
  * Axis-normalizing context for decoration layout.
7
7
  *
@@ -66,8 +66,8 @@ export interface WritingContext {
66
66
  /** Convert logical coordinates back to a physical DOMRect. */
67
67
  toRect(inlineStart: number, blockStart: number, inlineSize: number, blockSize: number): DOMRect;
68
68
  }
69
- export declare function makeWritingContext(wnd: ReadiumWindow): WritingContext;
70
- export declare function getColumnCountPerScreen(wnd: ReadiumWindow): number;
69
+ export declare function makeWritingContext(wnd: Window): WritingContext;
70
+ export declare function getColumnCountPerScreen(wnd: Window): number;
71
71
  /**
72
72
  * Returns the "content height" of an element, which is its clientHeight
73
73
  * minus any vertical padding.
@@ -6,5 +6,6 @@ export interface Rect {
6
6
  top: number;
7
7
  width: number;
8
8
  }
9
- export declare function getClientRectsNoOverlap(range: Range, doNotMergeHorizontallyAlignedRects: boolean, doNotMergeVerticallyAlignedRects?: boolean): Rect[];
9
+ export declare function getTextClientRects(range: Range, skipTags: string[]): Rect[];
10
+ export declare function getClientRectsNoOverlap(source: Range | Rect[], doNotMergeHorizontallyAlignedRects: boolean, doNotMergeVerticallyAlignedRects?: boolean, expand?: number): Rect[];
10
11
  export declare function rectContainsPoint(rect: Rect, x: number, y: number, tolerance: number): boolean;
@@ -2,5 +2,5 @@ export * from './comms/index.ts';
2
2
  export * from './modules/index.ts';
3
3
  export * from './Loader.ts';
4
4
  export * from './protection/index.ts';
5
- export * from './helpers/sML.ts';
5
+ export type { ReadiumWindow } from './helpers/dom.ts';
6
6
  export * from './keyboard/index.ts';