@readium/navigator-html-injectables 2.5.0 → 2.6.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.
- package/dist/Loader.js +1 -0
- package/dist/comms/comms.js +1 -0
- package/dist/comms/mid.js +1 -0
- package/dist/helpers/animation.js +1 -0
- package/dist/helpers/css.js +1 -0
- package/dist/helpers/document.js +1 -0
- package/dist/helpers/dom.js +1 -0
- package/dist/helpers/locator.js +1 -0
- package/dist/helpers/rect.js +1 -0
- package/dist/helpers/sanitize.js +1 -0
- package/dist/index.js +1 -4601
- package/dist/keyboard/KeyCombinationManager.js +1 -0
- package/dist/keyboard/KeyboardCombinations.js +1 -0
- package/dist/modules/Decorator.js +24 -0
- package/dist/modules/Module.js +1 -0
- package/dist/modules/ModuleLibrary.js +1 -0
- package/dist/modules/Peripherals.js +1 -0
- package/dist/modules/setup/FixedSetup.js +13 -0
- package/dist/modules/setup/ReflowableSetup.js +1 -0
- package/dist/modules/setup/Setup.js +1 -0
- package/dist/modules/setup/WebPubSetup.js +1 -0
- package/dist/modules/snapper/CJKVerticalSnapper.js +12 -0
- package/dist/modules/snapper/ColumnSnapper.js +35 -0
- package/dist/modules/snapper/ScrollSnapper.js +9 -0
- package/dist/modules/snapper/Snapper.js +5 -0
- package/dist/modules/snapper/WebPubSnapper.js +1 -0
- package/dist/protection/BulkCopyProtector.js +1 -0
- package/dist/protection/PatternAnalyzer.js +1 -0
- package/dist/protection/PrintProtector.js +15 -0
- package/dist/protection/SelectionAnalyzer.js +1 -0
- package/dist/protection/config.js +1 -0
- package/dist/vendor/approx-string-match/index.js +1 -0
- package/dist/vendor/hypothesis/anchoring/match-quote.js +1 -0
- package/dist/vendor/hypothesis/anchoring/text-range.js +1 -0
- package/dist/vendor/hypothesis/anchoring/trim-range.js +1 -0
- package/dist/vendor/hypothesis/anchoring/types.js +1 -0
- package/package.json +9 -8
- package/src/comms/comms.ts +11 -1
- package/src/comms/keys.ts +6 -2
- package/src/helpers/css.ts +1 -1
- package/src/helpers/document.ts +5 -5
- package/src/helpers/locator.ts +5 -5
- package/src/helpers/rect.ts +79 -18
- package/src/index.ts +1 -1
- package/src/modules/Decorator.ts +404 -107
- package/src/modules/snapper/CJKVerticalSnapper.ts +34 -7
- package/src/modules/snapper/ColumnSnapper.ts +54 -6
- package/src/modules/snapper/ScrollSnapper.ts +30 -7
- package/src/modules/snapper/Snapper.ts +144 -0
- package/src/modules/snapper/WebPubSnapper.ts +31 -7
- package/types/src/comms/comms.d.ts +10 -1
- package/types/src/comms/keys.d.ts +2 -2
- package/types/src/helpers/css.d.ts +1 -1
- package/types/src/helpers/document.d.ts +5 -5
- package/types/src/helpers/rect.d.ts +2 -1
- package/types/src/index.d.ts +1 -1
- package/types/src/modules/Decorator.d.ts +33 -7
- package/types/src/modules/snapper/CJKVerticalSnapper.d.ts +1 -0
- package/types/src/modules/snapper/ColumnSnapper.d.ts +14 -1
- package/types/src/modules/snapper/ScrollSnapper.d.ts +1 -0
- package/types/src/modules/snapper/Snapper.d.ts +58 -0
- package/types/src/modules/snapper/WebPubSnapper.d.ts +1 -0
- package/dist/ar-DyHX_uy2.js +0 -7
- package/dist/da-Dct0PS3E.js +0 -7
- package/dist/fr-C5HEel98.js +0 -7
- package/dist/index.umd.cjs +0 -96
- package/dist/it-DFOBoXGy.js +0 -7
- package/dist/pt_PT-Di3sVjze.js +0 -7
- package/dist/sv-BfzAFsVN.js +0 -7
- package/src/helpers/color.ts +0 -268
- package/src/helpers/sML.ts +0 -143
- package/types/src/helpers/color.d.ts +0 -27
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
3
|
-
export declare function isVerticalLR(wnd:
|
|
4
|
-
export declare function isVerticalWriting(wnd:
|
|
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:
|
|
70
|
-
export declare function getColumnCountPerScreen(wnd:
|
|
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
|
|
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;
|
package/types/src/index.d.ts
CHANGED
|
@@ -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
|
|
5
|
+
export type { ReadiumWindow } from './helpers/dom.ts';
|
|
6
6
|
export * from './keyboard/index.ts';
|