@readium/navigator-html-injectables 2.2.5 → 2.3.1-alpha.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.
- package/dist/index.js +1133 -563
- package/dist/index.umd.cjs +23 -9
- package/package.json +8 -8
- package/src/comms/keys.ts +22 -3
- package/src/helpers/css.ts +2 -0
- package/src/index.ts +2 -1
- package/src/modules/ModuleLibrary.ts +10 -4
- package/src/modules/Peripherals.ts +458 -13
- package/src/modules/snapper/ColumnSnapper.ts +85 -5
- package/src/modules/snapper/ScrollSnapper.ts +59 -1
- package/src/modules/snapper/WebPubSnapper.ts +57 -3
- package/src/protection/BulkCopyProtector.ts +134 -0
- package/src/protection/KeyCombinationManager.ts +139 -0
- package/src/protection/KeyboardCombinations.ts +60 -0
- package/src/protection/PatternAnalyzer.ts +117 -0
- package/src/protection/PrintProtector.ts +87 -0
- package/src/protection/SelectionAnalyzer.ts +133 -0
- package/src/protection/config.ts +33 -0
- package/src/protection/index.ts +6 -0
- package/types/src/comms/keys.d.ts +3 -2
- package/types/src/helpers/css.d.ts +1 -0
- package/types/src/index.d.ts +1 -0
- package/types/src/modules/ModuleLibrary.d.ts +1 -1
- package/types/src/modules/Peripherals.d.ts +102 -2
- package/types/src/modules/snapper/ColumnSnapper.d.ts +10 -0
- package/types/src/modules/snapper/ScrollSnapper.d.ts +13 -0
- package/types/src/modules/snapper/WebPubSnapper.d.ts +6 -0
- package/types/src/protection/BulkCopyProtector.d.ts +20 -0
- package/types/src/protection/KeyCombinationManager.d.ts +33 -0
- package/types/src/protection/KeyboardCombinations.d.ts +15 -0
- package/types/src/protection/PatternAnalyzer.d.ts +18 -0
- package/types/src/protection/PrintProtector.d.ts +20 -0
- package/types/src/protection/SelectionAnalyzer.d.ts +20 -0
- package/types/src/protection/config.d.ts +7 -0
- package/types/src/protection/index.d.ts +6 -0
- package/src/types/protection.ts +0 -16
- package/types/src/types/protection.d.ts +0 -7
|
@@ -6,8 +6,17 @@ import { ModuleName } from "../ModuleLibrary";
|
|
|
6
6
|
import { Locator, LocatorLocations, LocatorText } from "@readium/shared";
|
|
7
7
|
import { rangeFromLocator } from "../../helpers/locator";
|
|
8
8
|
import { ReadiumWindow, deselect, findFirstVisibleLocator } from "../../helpers/dom";
|
|
9
|
+
import { PatternAnalyzer } from "../../protection/PatternAnalyzer";
|
|
10
|
+
import { BaseSuspiciousActivityEvent } from "../Peripherals";
|
|
11
|
+
import { isTypedOMSupported } from "../../helpers/css";
|
|
9
12
|
|
|
10
13
|
const COLUMN_SNAPPER_STYLE_ID = "readium-column-snapper-style";
|
|
14
|
+
|
|
15
|
+
export interface SuspiciousSnappingEvent extends BaseSuspiciousActivityEvent {
|
|
16
|
+
type: "suspicious_snapping";
|
|
17
|
+
event: null;
|
|
18
|
+
}
|
|
19
|
+
|
|
11
20
|
const SNAP_DURATION = 200; // Milliseconds
|
|
12
21
|
|
|
13
22
|
enum ScrollTouchState {
|
|
@@ -21,8 +30,11 @@ enum ScrollTouchState {
|
|
|
21
30
|
*/
|
|
22
31
|
export class ColumnSnapper extends Snapper {
|
|
23
32
|
static readonly moduleName: ModuleName = "column_snapper";
|
|
33
|
+
private isSnapProtectionEnabled = false;
|
|
24
34
|
private resizeObserver!: ResizeObserver;
|
|
25
35
|
private mutationObserver!: MutationObserver;
|
|
36
|
+
private patternAnalyzer: PatternAnalyzer | null = null;
|
|
37
|
+
private lastTurnTime: number = 0;
|
|
26
38
|
private wnd!: ReadiumWindow;
|
|
27
39
|
private comms!: Comms;
|
|
28
40
|
private doc() { return this.wnd.document.scrollingElement as HTMLElement; }
|
|
@@ -72,6 +84,7 @@ export class ColumnSnapper extends Snapper {
|
|
|
72
84
|
private alreadyScrollLeft = 0;
|
|
73
85
|
private overscroll = 0;
|
|
74
86
|
private cachedScrollWidth = 0; // We have to cache this because during overscroll (transform, or left) the width is incorrect due to browser
|
|
87
|
+
|
|
75
88
|
private takeOverSnap() {
|
|
76
89
|
this.snappingCancelled = true;
|
|
77
90
|
this.clearTouches();
|
|
@@ -80,7 +93,7 @@ export class ColumnSnapper extends Snapper {
|
|
|
80
93
|
// translate3d(XXXpx, 0px, 0px) -> slice 12 -> XXXpx, 0px, 0px) -> split "px" [0] -> XXX
|
|
81
94
|
this.overscroll = doc.style.transform?.length > 12 ? parseFloat(doc.style.transform.slice(12).split("px")[0]) : 0;
|
|
82
95
|
}
|
|
83
|
-
|
|
96
|
+
|
|
84
97
|
// Snaps the current offset to the page width.
|
|
85
98
|
snapCurrentOffset(smooth=false, noprogress=false) {
|
|
86
99
|
const startX = this.wnd.scrollX > 0 ? this.wnd.scrollX : this.alreadyScrollLeft;
|
|
@@ -96,6 +109,11 @@ export class ColumnSnapper extends Snapper {
|
|
|
96
109
|
((factor * cdo) > 0 ? 2 : 1);
|
|
97
110
|
|
|
98
111
|
const so = this.snapOffset(currentOffset + hurdle);
|
|
112
|
+
|
|
113
|
+
const direction = so > this.scrollOffset() ? "right" : "left";
|
|
114
|
+
|
|
115
|
+
this.checkSuspiciousSnap(direction, Math.abs(so - this.scrollOffset()));
|
|
116
|
+
|
|
99
117
|
if(smooth && so !== this.scrollOffset()) { // Smooth snapping
|
|
100
118
|
this.snappingCancelled = false;
|
|
101
119
|
const position = (start: number, end: number, elapsed: number, period: number) => {
|
|
@@ -116,7 +134,13 @@ export class ColumnSnapper extends Snapper {
|
|
|
116
134
|
const spos = position(startX, so, elapsed, period);
|
|
117
135
|
doc.scrollLeft = spos;
|
|
118
136
|
if(this.overscroll !== 0)
|
|
119
|
-
|
|
137
|
+
if(isTypedOMSupported) {
|
|
138
|
+
doc.attributeStyleMap.set("transform", new CSSTransformValue([
|
|
139
|
+
new CSSTranslate(CSS.px(-lpos), CSS.px(0), CSS.px(0))
|
|
140
|
+
]));
|
|
141
|
+
} else {
|
|
142
|
+
doc.style.transform = `translate3d(${-lpos}px, 0px, 0px)`;
|
|
143
|
+
}
|
|
120
144
|
|
|
121
145
|
if (elapsed < period)
|
|
122
146
|
this.wnd.requestAnimationFrame(step);
|
|
@@ -220,10 +244,22 @@ export class ColumnSnapper extends Snapper {
|
|
|
220
244
|
const newpos = this.alreadyScrollLeft + dro;
|
|
221
245
|
if(newpos < 0) {
|
|
222
246
|
this.overscroll = newpos;
|
|
223
|
-
|
|
247
|
+
if(isTypedOMSupported) {
|
|
248
|
+
this.doc().attributeStyleMap.set("transform", new CSSTransformValue([
|
|
249
|
+
new CSSTranslate(CSS.px(-newpos), CSS.px(0), CSS.px(0))
|
|
250
|
+
]));
|
|
251
|
+
} else {
|
|
252
|
+
this.doc().style.transform = `translate3d(${-newpos}px, 0px, 0px)`;
|
|
253
|
+
}
|
|
224
254
|
} else if((newpos + this.wnd.innerWidth) > this.cachedScrollWidth) {
|
|
225
255
|
this.overscroll = newpos;
|
|
226
|
-
|
|
256
|
+
if(isTypedOMSupported) {
|
|
257
|
+
this.doc().attributeStyleMap.set("transform", new CSSTransformValue([
|
|
258
|
+
new CSSTranslate(CSS.px(-newpos), CSS.px(0), CSS.px(0))
|
|
259
|
+
]));
|
|
260
|
+
} else {
|
|
261
|
+
this.doc().style.transform = `translate3d(${-newpos}px, 0px, 0px)`;
|
|
262
|
+
}
|
|
227
263
|
} else {
|
|
228
264
|
this.overscroll = 0;
|
|
229
265
|
this.doc().style.removeProperty("transform");
|
|
@@ -232,6 +268,37 @@ export class ColumnSnapper extends Snapper {
|
|
|
232
268
|
}
|
|
233
269
|
private readonly onTouchMover = this.onTouchMove.bind(this);
|
|
234
270
|
|
|
271
|
+
private enableSnapProtection() {
|
|
272
|
+
if (!this.patternAnalyzer) {
|
|
273
|
+
this.patternAnalyzer = new PatternAnalyzer({
|
|
274
|
+
maxVelocity: this.wnd.innerWidth, // page width
|
|
275
|
+
minVariance: 0.1, // Allow for some variation in swipe speed
|
|
276
|
+
historySize: 5, // Fewer samples needed for swipe detection
|
|
277
|
+
maxConsistentScrolls: 3, // Lower threshold for consistent scrolling
|
|
278
|
+
minDirectionChanges: 0.3 // Require more direction changes
|
|
279
|
+
});
|
|
280
|
+
this.isSnapProtectionEnabled = true;
|
|
281
|
+
this.comms?.log("Snap protection enabled");
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private checkSuspiciousSnap(direction: "left" | "right", distance: number) {
|
|
286
|
+
if (!this.isSnapProtectionEnabled || !this.patternAnalyzer) return;
|
|
287
|
+
|
|
288
|
+
const now = Date.now();
|
|
289
|
+
const timeDelta = now - (this.lastTurnTime || now);
|
|
290
|
+
this.lastTurnTime = now;
|
|
291
|
+
|
|
292
|
+
const isSuspicious = this.patternAnalyzer.analyze(direction, distance, timeDelta);
|
|
293
|
+
if (isSuspicious) {
|
|
294
|
+
this.comms?.send("content_protection", {
|
|
295
|
+
type: "suspicious_snapping",
|
|
296
|
+
timestamp: Date.now(),
|
|
297
|
+
event: null
|
|
298
|
+
} as SuspiciousSnappingEvent);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
235
302
|
mount(wnd: ReadiumWindow, comms: Comms): boolean {
|
|
236
303
|
this.wnd = wnd;
|
|
237
304
|
this.comms = comms;
|
|
@@ -317,6 +384,11 @@ export class ColumnSnapper extends Snapper {
|
|
|
317
384
|
// For cases the resizeObserver is not able to detect cos body is not resizing despite colCount,
|
|
318
385
|
// we need to check the syle attribute on the documentElement (ReadiumCSS props)
|
|
319
386
|
this.mutationObserver.observe(wnd.document.documentElement, {attributes: true, attributeFilter: ["style"]});
|
|
387
|
+
|
|
388
|
+
comms.register("scroll_protection", ColumnSnapper.moduleName, (_, ack) => {
|
|
389
|
+
this.enableSnapProtection();
|
|
390
|
+
ack(true);
|
|
391
|
+
});
|
|
320
392
|
|
|
321
393
|
const scrollToOffset = (offset: number): boolean => {
|
|
322
394
|
const oldScrollLeft = this.doc().scrollLeft;
|
|
@@ -427,6 +499,7 @@ export class ColumnSnapper extends Snapper {
|
|
|
427
499
|
if(change) {
|
|
428
500
|
this.reportProgress();
|
|
429
501
|
deselect(this.wnd);
|
|
502
|
+
this.checkSuspiciousSnap("left", this.wnd.innerWidth);
|
|
430
503
|
}
|
|
431
504
|
ack(change);
|
|
432
505
|
});
|
|
@@ -441,6 +514,7 @@ export class ColumnSnapper extends Snapper {
|
|
|
441
514
|
if(change) {
|
|
442
515
|
this.reportProgress();
|
|
443
516
|
deselect(this.wnd);
|
|
517
|
+
this.checkSuspiciousSnap("right", this.wnd.innerWidth);
|
|
444
518
|
}
|
|
445
519
|
ack(change);
|
|
446
520
|
});
|
|
@@ -478,7 +552,7 @@ export class ColumnSnapper extends Snapper {
|
|
|
478
552
|
wnd.addEventListener("touchmove", this.onTouchMover, { passive: true });
|
|
479
553
|
|
|
480
554
|
// Safari hack, otherwise other events won't register
|
|
481
|
-
wnd.document.addEventListener(
|
|
555
|
+
wnd.document.addEventListener("touchstart", () => {});
|
|
482
556
|
|
|
483
557
|
comms.log("ColumnSnapper Mounted");
|
|
484
558
|
return true;
|
|
@@ -490,6 +564,12 @@ export class ColumnSnapper extends Snapper {
|
|
|
490
564
|
this.resizeObserver.disconnect();
|
|
491
565
|
this.mutationObserver.disconnect();
|
|
492
566
|
|
|
567
|
+
if (this.patternAnalyzer) {
|
|
568
|
+
this.patternAnalyzer.clear();
|
|
569
|
+
this.patternAnalyzer = null;
|
|
570
|
+
this.isSnapProtectionEnabled = false;
|
|
571
|
+
}
|
|
572
|
+
|
|
493
573
|
wnd.removeEventListener("touchstart", this.onTouchStarter);
|
|
494
574
|
wnd.removeEventListener("touchend", this.onTouchEnder);
|
|
495
575
|
wnd.removeEventListener("touchmove", this.onTouchMover);
|
|
@@ -5,14 +5,27 @@ import { ModuleName } from "../ModuleLibrary";
|
|
|
5
5
|
import { Snapper } from "./Snapper";
|
|
6
6
|
import { rangeFromLocator } from "../../helpers/locator";
|
|
7
7
|
import { forceWebkitRecalc } from "../../helpers/document";
|
|
8
|
+
import { PatternAnalyzer } from "../../protection/PatternAnalyzer";
|
|
9
|
+
import { SCROLL_PROTECTION_CONFIG } from "../../protection/config";
|
|
10
|
+
import { BaseSuspiciousActivityEvent } from "../Peripherals";
|
|
8
11
|
|
|
9
12
|
const SCROLL_SNAPPER_STYLE_ID = "readium-scroll-snapper-style";
|
|
10
13
|
|
|
14
|
+
export interface SuspiciousScrollingEvent extends BaseSuspiciousActivityEvent {
|
|
15
|
+
type: "suspicious_scrolling";
|
|
16
|
+
scrollDelta: number;
|
|
17
|
+
scrollDirection: "up" | "down";
|
|
18
|
+
targetElement: { tagName: string } | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
11
21
|
export class ScrollSnapper extends Snapper {
|
|
12
22
|
static readonly moduleName: ModuleName = "scroll_snapper";
|
|
13
23
|
private wnd!: ReadiumWindow;
|
|
14
24
|
private comms!: Comms;
|
|
15
25
|
private resizeObserver!: ResizeObserver;
|
|
26
|
+
private patternAnalyzer: PatternAnalyzer | null = null;
|
|
27
|
+
private lastScrollTime: number = 0;
|
|
28
|
+
private isScrollProtectionEnabled = false;
|
|
16
29
|
|
|
17
30
|
private initialScrollHandled = false;
|
|
18
31
|
private isScrolling = false;
|
|
@@ -41,7 +54,7 @@ export class ScrollSnapper extends Snapper {
|
|
|
41
54
|
});
|
|
42
55
|
}
|
|
43
56
|
|
|
44
|
-
private handleScroll = () => {
|
|
57
|
+
private handleScroll = (_e: Event) => {
|
|
45
58
|
if (!this.comms.ready) return;
|
|
46
59
|
|
|
47
60
|
// We have to filter scroll from resize events
|
|
@@ -68,6 +81,31 @@ export class ScrollSnapper extends Snapper {
|
|
|
68
81
|
const deltaY = currentScrollTop - this.lastScrollTop;
|
|
69
82
|
this.lastScrollTop = currentScrollTop;
|
|
70
83
|
|
|
84
|
+
if (this.isScrollProtectionEnabled && Math.abs(deltaY) > 5) { // Ignore tiny scrolls
|
|
85
|
+
const now = Date.now();
|
|
86
|
+
const timeDelta = now - (this.lastScrollTime || now);
|
|
87
|
+
if (this.patternAnalyzer) {
|
|
88
|
+
const isSuspicious = this.patternAnalyzer.analyze(
|
|
89
|
+
deltaY > 0 ? "down" : "up",
|
|
90
|
+
Math.abs(deltaY),
|
|
91
|
+
timeDelta
|
|
92
|
+
);
|
|
93
|
+
if (isSuspicious) {
|
|
94
|
+
const target = _e.target && "tagName" in _e.target ?
|
|
95
|
+
{ tagName: (_e.target as Element).tagName } : null;
|
|
96
|
+
|
|
97
|
+
this.comms?.send("content_protection", {
|
|
98
|
+
type: "suspicious_scrolling",
|
|
99
|
+
timestamp: Date.now(),
|
|
100
|
+
scrollDelta: deltaY,
|
|
101
|
+
scrollDirection: deltaY > 0 ? "down" : "up",
|
|
102
|
+
targetElement: target
|
|
103
|
+
} as SuspiciousScrollingEvent);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
this.lastScrollTime = now;
|
|
107
|
+
}
|
|
108
|
+
|
|
71
109
|
this.comms.send("scroll", deltaY);
|
|
72
110
|
|
|
73
111
|
this.isScrolling = false;
|
|
@@ -75,6 +113,14 @@ export class ScrollSnapper extends Snapper {
|
|
|
75
113
|
}
|
|
76
114
|
};
|
|
77
115
|
|
|
116
|
+
private enableScrollProtection() {
|
|
117
|
+
if (!this.patternAnalyzer) {
|
|
118
|
+
this.patternAnalyzer = new PatternAnalyzer(SCROLL_PROTECTION_CONFIG);
|
|
119
|
+
this.isScrollProtectionEnabled = true;
|
|
120
|
+
this.comms?.log("Scroll protection enabled");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
78
124
|
mount(wnd: ReadiumWindow, comms: Comms): boolean {
|
|
79
125
|
this.wnd = wnd;
|
|
80
126
|
this.comms = comms;
|
|
@@ -222,6 +268,11 @@ export class ScrollSnapper extends Snapper {
|
|
|
222
268
|
ack(true);
|
|
223
269
|
});
|
|
224
270
|
|
|
271
|
+
comms.register("scroll_protection", ScrollSnapper.moduleName, (_, ack) => {
|
|
272
|
+
this.enableScrollProtection();
|
|
273
|
+
ack(true);
|
|
274
|
+
});
|
|
275
|
+
|
|
225
276
|
comms.register([
|
|
226
277
|
"go_next",
|
|
227
278
|
"go_prev",
|
|
@@ -247,6 +298,13 @@ export class ScrollSnapper extends Snapper {
|
|
|
247
298
|
this.resizeObserver.disconnect();
|
|
248
299
|
if (this.handleScroll) wnd.removeEventListener("scroll", this.handleScroll);
|
|
249
300
|
wnd.document.getElementById(SCROLL_SNAPPER_STYLE_ID)?.remove();
|
|
301
|
+
|
|
302
|
+
if (this.patternAnalyzer) {
|
|
303
|
+
this.patternAnalyzer.clear();
|
|
304
|
+
this.patternAnalyzer = null;
|
|
305
|
+
this.isScrollProtectionEnabled = false;
|
|
306
|
+
}
|
|
307
|
+
|
|
250
308
|
comms.log("ScrollSnapper Unmounted");
|
|
251
309
|
return true;
|
|
252
310
|
}
|
|
@@ -5,6 +5,11 @@ import { ModuleName } from "../ModuleLibrary";
|
|
|
5
5
|
import { Snapper } from "./Snapper";
|
|
6
6
|
import { rangeFromLocator } from "../../helpers/locator";
|
|
7
7
|
import { forceWebkitRecalc } from "../../helpers/document";
|
|
8
|
+
import { PatternAnalyzer } from "../../protection/PatternAnalyzer";
|
|
9
|
+
import { SCROLL_PROTECTION_CONFIG } from "../../protection/config";
|
|
10
|
+
import { SuspiciousScrollingEvent } from "./ScrollSnapper";
|
|
11
|
+
|
|
12
|
+
export type { SuspiciousScrollingEvent };
|
|
8
13
|
|
|
9
14
|
export class WebPubSnapper extends Snapper {
|
|
10
15
|
static readonly moduleName: ModuleName = "webpub_snapper";
|
|
@@ -12,6 +17,9 @@ export class WebPubSnapper extends Snapper {
|
|
|
12
17
|
private wnd!: ReadiumWindow;
|
|
13
18
|
private comms!: Comms;
|
|
14
19
|
private resizeObserver!: ResizeObserver;
|
|
20
|
+
private patternAnalyzer: PatternAnalyzer | null = null;
|
|
21
|
+
private lastScrollTime: number = 0;
|
|
22
|
+
private isScrollProtectionEnabled = false;
|
|
15
23
|
|
|
16
24
|
private initialScrollHandled = false;
|
|
17
25
|
private isScrolling = false;
|
|
@@ -38,7 +46,7 @@ export class WebPubSnapper extends Snapper {
|
|
|
38
46
|
});
|
|
39
47
|
}
|
|
40
48
|
|
|
41
|
-
private handleScroll = () => {
|
|
49
|
+
private handleScroll = (_e: Event) => {
|
|
42
50
|
if (!this.comms.ready) return;
|
|
43
51
|
|
|
44
52
|
// Filter resize events
|
|
@@ -63,6 +71,32 @@ export class WebPubSnapper extends Snapper {
|
|
|
63
71
|
const deltaY = currentScrollTop - this.lastScrollTop;
|
|
64
72
|
this.lastScrollTop = currentScrollTop;
|
|
65
73
|
|
|
74
|
+
// Record scroll for pattern analysis if protection is enabled
|
|
75
|
+
if (this.isScrollProtectionEnabled && Math.abs(deltaY) > 5) { // Ignore tiny scrolls
|
|
76
|
+
const now = Date.now();
|
|
77
|
+
const timeDelta = now - (this.lastScrollTime || now);
|
|
78
|
+
if (this.patternAnalyzer) {
|
|
79
|
+
const isSuspicious = this.patternAnalyzer.analyze(
|
|
80
|
+
deltaY > 0 ? "down" : "up",
|
|
81
|
+
Math.abs(deltaY),
|
|
82
|
+
timeDelta
|
|
83
|
+
);
|
|
84
|
+
if (isSuspicious) {
|
|
85
|
+
const target = _e.target && "tagName" in _e.target ?
|
|
86
|
+
{ tagName: (_e.target as Element).tagName } : null;
|
|
87
|
+
|
|
88
|
+
this.comms?.send("content_protection", {
|
|
89
|
+
type: "suspicious_scrolling",
|
|
90
|
+
timestamp: Date.now(),
|
|
91
|
+
scrollDelta: deltaY,
|
|
92
|
+
scrollDirection: deltaY > 0 ? "down" : "up",
|
|
93
|
+
targetElement: target
|
|
94
|
+
} as SuspiciousScrollingEvent);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
this.lastScrollTime = now;
|
|
98
|
+
}
|
|
99
|
+
|
|
66
100
|
this.comms.send("scroll", deltaY);
|
|
67
101
|
|
|
68
102
|
this.isScrolling = false;
|
|
@@ -70,6 +104,14 @@ export class WebPubSnapper extends Snapper {
|
|
|
70
104
|
}
|
|
71
105
|
};
|
|
72
106
|
|
|
107
|
+
private enableScrollProtection() {
|
|
108
|
+
if (!this.patternAnalyzer) {
|
|
109
|
+
this.patternAnalyzer = new PatternAnalyzer(SCROLL_PROTECTION_CONFIG);
|
|
110
|
+
this.isScrollProtectionEnabled = true;
|
|
111
|
+
this.comms?.log("Scroll protection enabled");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
73
115
|
mount(wnd: ReadiumWindow, comms: Comms): boolean {
|
|
74
116
|
this.wnd = wnd;
|
|
75
117
|
this.comms = comms;
|
|
@@ -134,7 +176,7 @@ export class WebPubSnapper extends Snapper {
|
|
|
134
176
|
});
|
|
135
177
|
});
|
|
136
178
|
|
|
137
|
-
|
|
179
|
+
comms.register("go_id", WebPubSnapper.moduleName, (data, ack) => {
|
|
138
180
|
const element = wnd.document.getElementById(data as string);
|
|
139
181
|
if(!element) {
|
|
140
182
|
ack(false);
|
|
@@ -197,6 +239,11 @@ export class WebPubSnapper extends Snapper {
|
|
|
197
239
|
ack(true);
|
|
198
240
|
});
|
|
199
241
|
|
|
242
|
+
comms.register("scroll_protection", WebPubSnapper.moduleName, (_, ack) => {
|
|
243
|
+
this.enableScrollProtection();
|
|
244
|
+
ack(true);
|
|
245
|
+
});
|
|
246
|
+
|
|
200
247
|
comms.register([
|
|
201
248
|
"go_next",
|
|
202
249
|
"go_prev",
|
|
@@ -220,7 +267,14 @@ export class WebPubSnapper extends Snapper {
|
|
|
220
267
|
comms.unregisterAll(WebPubSnapper.moduleName);
|
|
221
268
|
this.resizeObserver.disconnect();
|
|
222
269
|
if (this.handleScroll) wnd.removeEventListener("scroll", this.handleScroll);
|
|
270
|
+
|
|
271
|
+
if (this.patternAnalyzer) {
|
|
272
|
+
this.patternAnalyzer.clear();
|
|
273
|
+
this.patternAnalyzer = null;
|
|
274
|
+
this.isScrollProtectionEnabled = false;
|
|
275
|
+
}
|
|
276
|
+
|
|
223
277
|
comms.log("WebPubSnapper Unmounted");
|
|
224
278
|
return true;
|
|
225
279
|
}
|
|
226
|
-
}
|
|
280
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { ReadiumWindow } from "../helpers/dom";
|
|
2
|
+
|
|
3
|
+
interface CopyAttempt {
|
|
4
|
+
timestamp: number;
|
|
5
|
+
length: number;
|
|
6
|
+
wasBlocked: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface BulkCopyProtectionOptions {
|
|
10
|
+
enabled: boolean;
|
|
11
|
+
maxSelectionPercent: number;
|
|
12
|
+
absoluteMaxChars: number;
|
|
13
|
+
minThreshold: number;
|
|
14
|
+
historySize: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class BulkCopyProtector {
|
|
18
|
+
private options: BulkCopyProtectionOptions;
|
|
19
|
+
private copyHistory: CopyAttempt[] = [];
|
|
20
|
+
private lastSelectionLength = 0;
|
|
21
|
+
private lastSelectionTime = 0;
|
|
22
|
+
|
|
23
|
+
constructor(
|
|
24
|
+
private readonly window: ReadiumWindow,
|
|
25
|
+
options: BulkCopyProtectionOptions
|
|
26
|
+
) {
|
|
27
|
+
this.options = options;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private cleanupOldHistory(now: number): void {
|
|
31
|
+
// Keep history for the last 10 seconds
|
|
32
|
+
const historyWindow = 10000; // 10 seconds
|
|
33
|
+
this.copyHistory = this.copyHistory.filter(
|
|
34
|
+
attempt => now - attempt.timestamp < historyWindow
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
// Trim to max history size
|
|
38
|
+
if (this.copyHistory.length > this.options.historySize) {
|
|
39
|
+
this.copyHistory = this.copyHistory.slice(-this.options.historySize);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private isSuspiciousPattern(now: number): boolean {
|
|
44
|
+
if (this.copyHistory.length < 3) return false;
|
|
45
|
+
|
|
46
|
+
// Check for rapid successive copy attempts
|
|
47
|
+
const recentAttempts = this.copyHistory.filter(
|
|
48
|
+
attempt => now - attempt.timestamp < 2000 // Last 2 seconds
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
if (recentAttempts.length >= 3) {
|
|
52
|
+
// If multiple rapid copy attempts, it's suspicious
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Check for increasing selection sizes (bulk copy pattern)
|
|
57
|
+
const increasingSelections = this.copyHistory
|
|
58
|
+
.slice()
|
|
59
|
+
.sort((a, b) => a.timestamp - b.timestamp)
|
|
60
|
+
.every((attempt, i, arr) => {
|
|
61
|
+
if (i === 0) return true;
|
|
62
|
+
return attempt.length > arr[i - 1].length * 1.5; // Each selection is significantly larger
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return increasingSelections;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
public shouldAllowCopy(event: ClipboardEvent): boolean {
|
|
69
|
+
if (!this.options.enabled) return true;
|
|
70
|
+
|
|
71
|
+
const selection = this.window.getSelection();
|
|
72
|
+
if (!selection) return true;
|
|
73
|
+
|
|
74
|
+
const selectedText = selection.toString();
|
|
75
|
+
const selectedLength = selectedText.length;
|
|
76
|
+
const docLength = this.window.document.body.innerText.length;
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
|
|
79
|
+
// Clean up old history entries
|
|
80
|
+
this.cleanupOldHistory(now);
|
|
81
|
+
|
|
82
|
+
// Always allow small selections
|
|
83
|
+
if (selectedLength < this.options.minThreshold) {
|
|
84
|
+
this.copyHistory.push({
|
|
85
|
+
timestamp: now,
|
|
86
|
+
length: selectedLength,
|
|
87
|
+
wasBlocked: false
|
|
88
|
+
});
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const timeSinceLastSelection = now - this.lastSelectionTime;
|
|
93
|
+
|
|
94
|
+
// Check for rapid successive selections
|
|
95
|
+
const isRapidSelection =
|
|
96
|
+
timeSinceLastSelection < 100 &&
|
|
97
|
+
selectedLength > this.lastSelectionLength * 1.5;
|
|
98
|
+
|
|
99
|
+
const maxAllowedSelection = Math.min(
|
|
100
|
+
docLength * this.options.maxSelectionPercent,
|
|
101
|
+
this.options.absoluteMaxChars
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
// Check if this matches a suspicious pattern
|
|
105
|
+
const isSuspicious = this.isSuspiciousPattern(now);
|
|
106
|
+
|
|
107
|
+
const shouldBlock = selectedLength > maxAllowedSelection ||
|
|
108
|
+
isRapidSelection ||
|
|
109
|
+
isSuspicious;
|
|
110
|
+
|
|
111
|
+
// Record this attempt in history
|
|
112
|
+
this.copyHistory.push({
|
|
113
|
+
timestamp: now,
|
|
114
|
+
length: selectedLength,
|
|
115
|
+
wasBlocked: shouldBlock
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (shouldBlock) {
|
|
119
|
+
event?.preventDefault();
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
this.lastSelectionLength = selectedLength;
|
|
124
|
+
this.lastSelectionTime = now;
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
public destroy(): void {
|
|
129
|
+
this.lastSelectionLength = 0;
|
|
130
|
+
this.lastSelectionTime = 0;
|
|
131
|
+
this.copyHistory = [];
|
|
132
|
+
this.options.enabled = false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { KeyCombo, KeyboardPeripheral } from "./KeyboardCombinations";
|
|
2
|
+
import { BaseKeyboardPeripheralEvent, KeyboardEventData, BasicTextSelection } from "../modules/Peripherals";
|
|
3
|
+
import { ReadiumWindow } from "../helpers/dom";
|
|
4
|
+
|
|
5
|
+
export type KeyHandler = (event: KeyboardEvent) => void;
|
|
6
|
+
export type ActivityEventDispatcher = (event: KeyboardPeripheralEvent) => void;
|
|
7
|
+
|
|
8
|
+
export interface KeyboardPeripheralEvent extends BaseKeyboardPeripheralEvent, KeyboardEventData {
|
|
9
|
+
type: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface KeyComboWithHandler extends KeyCombo {
|
|
13
|
+
handler?: (event: KeyboardEvent) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class KeyCombinationManager {
|
|
17
|
+
/**
|
|
18
|
+
* Checks if the given keyboard event matches any of the provided key combinations
|
|
19
|
+
*/
|
|
20
|
+
public match(event: KeyboardEvent, combos: KeyCombo[]): boolean {
|
|
21
|
+
for (const combo of combos) {
|
|
22
|
+
const keyMatch = event.keyCode === combo.keyCode;
|
|
23
|
+
const ctrlMatch = combo.ctrl === undefined || event.ctrlKey === combo.ctrl;
|
|
24
|
+
const shiftMatch = combo.shift === undefined || event.shiftKey === combo.shift;
|
|
25
|
+
const altMatch = combo.alt === undefined || event.altKey === combo.alt;
|
|
26
|
+
const metaMatch = combo.meta === undefined || event.metaKey === combo.meta;
|
|
27
|
+
|
|
28
|
+
if (keyMatch && ctrlMatch && shiftMatch && altMatch && metaMatch) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Creates an event handler that will call the provided handler when any of the key combinations match
|
|
37
|
+
*/
|
|
38
|
+
public createKeyHandler(combos: KeyCombo[], handler: KeyHandler): (event: KeyboardEvent) => void {
|
|
39
|
+
return (event: KeyboardEvent) => {
|
|
40
|
+
if (this.match(event, combos)) {
|
|
41
|
+
event.preventDefault();
|
|
42
|
+
event.stopPropagation();
|
|
43
|
+
handler(event);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Creates a standardized activity event for keyboard shortcuts
|
|
50
|
+
*/
|
|
51
|
+
private createActivityEvent(
|
|
52
|
+
event: KeyboardEvent,
|
|
53
|
+
type: string,
|
|
54
|
+
targetFrameSrc: string,
|
|
55
|
+
wnd?: ReadiumWindow,
|
|
56
|
+
): KeyboardPeripheralEvent {
|
|
57
|
+
// Capture selected text if window is available
|
|
58
|
+
let selectedText: Omit<BasicTextSelection, "targetFrameSrc"> | undefined;
|
|
59
|
+
if (wnd) {
|
|
60
|
+
const selection = wnd.getSelection();
|
|
61
|
+
const selectedTextStr = selection?.toString() || '';
|
|
62
|
+
const domRectList = (selectedTextStr && selection?.rangeCount) ? selection.getRangeAt(0)?.getClientRects() : null;
|
|
63
|
+
const rect = domRectList?.[0];
|
|
64
|
+
|
|
65
|
+
if (rect && selectedTextStr) {
|
|
66
|
+
selectedText = {
|
|
67
|
+
text: selectedTextStr,
|
|
68
|
+
x: rect.x,
|
|
69
|
+
y: rect.y,
|
|
70
|
+
width: rect.width,
|
|
71
|
+
height: rect.height
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
type: type,
|
|
78
|
+
timestamp: Date.now(),
|
|
79
|
+
key: event.key,
|
|
80
|
+
code: event.code,
|
|
81
|
+
keyCode: event.keyCode,
|
|
82
|
+
ctrlKey: event.ctrlKey,
|
|
83
|
+
altKey: event.altKey,
|
|
84
|
+
shiftKey: event.shiftKey,
|
|
85
|
+
metaKey: event.metaKey,
|
|
86
|
+
targetFrameSrc: targetFrameSrc,
|
|
87
|
+
selectedText
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Creates handlers for keyboard shortcuts with centralized activity event dispatch
|
|
93
|
+
*/
|
|
94
|
+
public createKeyboardHandlers(
|
|
95
|
+
targetFrameSrc: string,
|
|
96
|
+
shortcuts: KeyboardPeripheral[],
|
|
97
|
+
dispatcher: ActivityEventDispatcher,
|
|
98
|
+
wnd?: ReadiumWindow,
|
|
99
|
+
): KeyComboWithHandler[] {
|
|
100
|
+
const handlers: KeyComboWithHandler[] = [];
|
|
101
|
+
|
|
102
|
+
// Handle KeyboardPeripheral objects only
|
|
103
|
+
shortcuts.forEach(shortcut => {
|
|
104
|
+
handlers.push(...shortcut.keyCombos.map(combo => ({
|
|
105
|
+
...combo,
|
|
106
|
+
handler: (event: KeyboardEvent) => {
|
|
107
|
+
const eventType = shortcut.type;
|
|
108
|
+
const activityEvent = this.createActivityEvent(event, eventType, targetFrameSrc, wnd);
|
|
109
|
+
dispatcher(activityEvent);
|
|
110
|
+
}
|
|
111
|
+
})));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return handlers;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Creates a unified keyboard event handler that processes all shortcuts
|
|
119
|
+
*/
|
|
120
|
+
public createUnifiedHandler(
|
|
121
|
+
targetFrameSrc: string,
|
|
122
|
+
shortcuts: KeyboardPeripheral[],
|
|
123
|
+
dispatcher: ActivityEventDispatcher,
|
|
124
|
+
wnd?: ReadiumWindow,
|
|
125
|
+
): (event: KeyboardEvent) => void {
|
|
126
|
+
const handlers = this.createKeyboardHandlers(targetFrameSrc, shortcuts, dispatcher, wnd);
|
|
127
|
+
|
|
128
|
+
return (event: KeyboardEvent) => {
|
|
129
|
+
for (const handlerConfig of handlers) {
|
|
130
|
+
if (this.match(event, [handlerConfig])) {
|
|
131
|
+
event.preventDefault();
|
|
132
|
+
event.stopPropagation();
|
|
133
|
+
handlerConfig.handler!(event);
|
|
134
|
+
return; // Stop after first match
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|