@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.
Files changed (37) hide show
  1. package/dist/index.js +1133 -563
  2. package/dist/index.umd.cjs +23 -9
  3. package/package.json +8 -8
  4. package/src/comms/keys.ts +22 -3
  5. package/src/helpers/css.ts +2 -0
  6. package/src/index.ts +2 -1
  7. package/src/modules/ModuleLibrary.ts +10 -4
  8. package/src/modules/Peripherals.ts +458 -13
  9. package/src/modules/snapper/ColumnSnapper.ts +85 -5
  10. package/src/modules/snapper/ScrollSnapper.ts +59 -1
  11. package/src/modules/snapper/WebPubSnapper.ts +57 -3
  12. package/src/protection/BulkCopyProtector.ts +134 -0
  13. package/src/protection/KeyCombinationManager.ts +139 -0
  14. package/src/protection/KeyboardCombinations.ts +60 -0
  15. package/src/protection/PatternAnalyzer.ts +117 -0
  16. package/src/protection/PrintProtector.ts +87 -0
  17. package/src/protection/SelectionAnalyzer.ts +133 -0
  18. package/src/protection/config.ts +33 -0
  19. package/src/protection/index.ts +6 -0
  20. package/types/src/comms/keys.d.ts +3 -2
  21. package/types/src/helpers/css.d.ts +1 -0
  22. package/types/src/index.d.ts +1 -0
  23. package/types/src/modules/ModuleLibrary.d.ts +1 -1
  24. package/types/src/modules/Peripherals.d.ts +102 -2
  25. package/types/src/modules/snapper/ColumnSnapper.d.ts +10 -0
  26. package/types/src/modules/snapper/ScrollSnapper.d.ts +13 -0
  27. package/types/src/modules/snapper/WebPubSnapper.d.ts +6 -0
  28. package/types/src/protection/BulkCopyProtector.d.ts +20 -0
  29. package/types/src/protection/KeyCombinationManager.d.ts +33 -0
  30. package/types/src/protection/KeyboardCombinations.d.ts +15 -0
  31. package/types/src/protection/PatternAnalyzer.d.ts +18 -0
  32. package/types/src/protection/PrintProtector.d.ts +20 -0
  33. package/types/src/protection/SelectionAnalyzer.d.ts +20 -0
  34. package/types/src/protection/config.d.ts +7 -0
  35. package/types/src/protection/index.d.ts +6 -0
  36. package/src/types/protection.ts +0 -16
  37. 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
- doc.style.transform = `translate3d(${-lpos}px, 0px, 0px)`;
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
- this.doc().style.transform = `translate3d(${-this.overscroll}px, 0px, 0px)`;
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
- this.doc().style.transform = `translate3d(${-newpos}px, 0px, 0px)`;
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('touchstart', () => {});
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
- this.comms.register("go_id", WebPubSnapper.moduleName, (data, ack) => {
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
+ }