@readium/navigator-html-injectables 2.2.4 → 2.3.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.
Files changed (41) hide show
  1. package/dist/ar-DyHX_uy2.js +7 -0
  2. package/dist/da-Dct0PS3E.js +7 -0
  3. package/dist/fr-C5HEel98.js +7 -0
  4. package/dist/index.js +2121 -1352
  5. package/dist/index.umd.cjs +24 -10
  6. package/dist/it-DFOBoXGy.js +7 -0
  7. package/dist/pt_PT-Di3sVjze.js +7 -0
  8. package/dist/sv-BfzAFsVN.js +7 -0
  9. package/package.json +4 -2
  10. package/src/comms/keys.ts +22 -3
  11. package/src/index.ts +2 -1
  12. package/src/modules/ModuleLibrary.ts +10 -4
  13. package/src/modules/Peripherals.ts +458 -13
  14. package/src/modules/snapper/ColumnSnapper.ts +63 -2
  15. package/src/modules/snapper/ScrollSnapper.ts +59 -1
  16. package/src/modules/snapper/WebPubSnapper.ts +57 -3
  17. package/src/protection/BulkCopyProtector.ts +134 -0
  18. package/src/protection/KeyCombinationManager.ts +139 -0
  19. package/src/protection/KeyboardCombinations.ts +60 -0
  20. package/src/protection/PatternAnalyzer.ts +117 -0
  21. package/src/protection/PrintProtector.ts +87 -0
  22. package/src/protection/SelectionAnalyzer.ts +133 -0
  23. package/src/protection/config.ts +33 -0
  24. package/src/protection/index.ts +6 -0
  25. package/types/src/comms/keys.d.ts +3 -2
  26. package/types/src/index.d.ts +1 -0
  27. package/types/src/modules/ModuleLibrary.d.ts +1 -1
  28. package/types/src/modules/Peripherals.d.ts +102 -2
  29. package/types/src/modules/snapper/ColumnSnapper.d.ts +10 -0
  30. package/types/src/modules/snapper/ScrollSnapper.d.ts +13 -0
  31. package/types/src/modules/snapper/WebPubSnapper.d.ts +6 -0
  32. package/types/src/protection/BulkCopyProtector.d.ts +20 -0
  33. package/types/src/protection/KeyCombinationManager.d.ts +33 -0
  34. package/types/src/protection/KeyboardCombinations.d.ts +15 -0
  35. package/types/src/protection/PatternAnalyzer.d.ts +18 -0
  36. package/types/src/protection/PrintProtector.d.ts +20 -0
  37. package/types/src/protection/SelectionAnalyzer.d.ts +20 -0
  38. package/types/src/protection/config.d.ts +7 -0
  39. package/types/src/protection/index.d.ts +6 -0
  40. package/src/types/protection.ts +0 -16
  41. package/types/src/types/protection.d.ts +0 -7
@@ -1,6 +1,20 @@
1
1
  import { Comms } from "../comms/comms";
2
2
  import { Module } from "./Module";
3
3
  import { ReadiumWindow, nearestInteractiveElement } from "../helpers/dom";
4
+ import { BulkCopyProtector, BulkCopyProtectionOptions } from "../protection/BulkCopyProtector";
5
+ import { SelectionAnalyzer, SelectionAnalyzerOptions } from "../protection/SelectionAnalyzer";
6
+ import {
7
+ KeyboardPeripheral
8
+ } from "../protection";
9
+ import { SuspiciousActivityType } from "../comms";
10
+ import { BULK_COPY_CONFIG, SELECTION_ANALYZER_CONFIG } from "../protection/config";
11
+ import {
12
+ KeyCombinationManager,
13
+ ActivityEventDispatcher,
14
+ KeyboardPeripheralEvent
15
+ } from "../protection/KeyCombinationManager";
16
+ import { SuspiciousScrollingEvent } from "./snapper/ScrollSnapper";
17
+ import { SuspiciousSnappingEvent } from "./snapper/ColumnSnapper";
4
18
 
5
19
  export interface FrameClickEvent {
6
20
  defaultPrevented: boolean;
@@ -22,12 +36,363 @@ export interface BasicTextSelection {
22
36
  targetFrameSrc: string;
23
37
  }
24
38
 
39
+ export interface BaseSuspiciousActivityEvent {
40
+ type: SuspiciousActivityType;
41
+ timestamp: number;
42
+ targetFrameSrc: string;
43
+ }
44
+
45
+ // Common interface for all keyboard-related events
46
+ export interface KeyboardEventData {
47
+ key: string;
48
+ code: string;
49
+ keyCode: number;
50
+ ctrlKey: boolean;
51
+ altKey: boolean;
52
+ shiftKey: boolean;
53
+ metaKey: boolean;
54
+ }
55
+
56
+ // Base interface for keyboard peripheral events
57
+ export interface BaseKeyboardPeripheralEvent {
58
+ type: string;
59
+ timestamp: number;
60
+ targetFrameSrc: string;
61
+ selectedText?: Omit<BasicTextSelection, "targetFrameSrc">;
62
+ }
63
+
64
+ export interface DeveloperToolsEvent extends BaseSuspiciousActivityEvent, KeyboardEventData {
65
+ type: "developer_tools";
66
+ }
67
+
68
+ export interface SelectAllEvent extends BaseSuspiciousActivityEvent, KeyboardEventData {
69
+ type: "select_all";
70
+ }
71
+
72
+ export interface PrintEvent extends BaseSuspiciousActivityEvent, KeyboardEventData {
73
+ type: "print";
74
+ }
75
+
76
+ export interface SaveEvent extends BaseSuspiciousActivityEvent, KeyboardEventData {
77
+ type: "save";
78
+ }
79
+
80
+ export interface BulkCopyEvent extends BaseSuspiciousActivityEvent {
81
+ type: "bulk_copy";
82
+ clipboardTypes: readonly string[];
83
+ selectedText?: Omit<BasicTextSelection, "targetFrameSrc">;
84
+ selectionLength?: number;
85
+ }
86
+
87
+ export interface SuspiciousSelectionEvent extends BaseSuspiciousActivityEvent {
88
+ type: "suspicious_selection";
89
+ selectionLength: number;
90
+ selectedText: Omit<BasicTextSelection, "targetFrameSrc">;
91
+ eventType: string;
92
+ }
93
+
94
+ export interface DragDetectedEvent extends BaseSuspiciousActivityEvent {
95
+ type: "drag_detected";
96
+ dataTransferTypes: readonly string[];
97
+ }
98
+
99
+ export interface DropDetectedEvent extends BaseSuspiciousActivityEvent {
100
+ type: "drop_detected";
101
+ dataTransferTypes: readonly string[];
102
+ fileCount: number;
103
+ }
104
+
105
+ export interface ContextMenuEvent extends Omit<BaseSuspiciousActivityEvent, "type"> {
106
+ clientX: number;
107
+ clientY: number;
108
+ selectedText?: Omit<BasicTextSelection, "targetFrameSrc">;
109
+ }
110
+
111
+ export type SuspiciousActivityEvent =
112
+ | DeveloperToolsEvent
113
+ | SelectAllEvent
114
+ | BulkCopyEvent
115
+ | SuspiciousSelectionEvent
116
+ | DragDetectedEvent
117
+ | DropDetectedEvent
118
+ | PrintEvent
119
+ | SaveEvent
120
+ | SuspiciousScrollingEvent
121
+ | SuspiciousSnappingEvent
122
+ | KeyboardPeripheralEvent;
123
+
124
+ export interface ContentProtectionConfig {
125
+ monitorSelection?: boolean | SelectionAnalyzerOptions;
126
+ protectCopy?: boolean | Omit<BulkCopyProtectionOptions, "enabled">;
127
+ disableContextMenu?: boolean;
128
+ disableDragAndDrop?: boolean;
129
+ disableSelectAll?: boolean;
130
+ disableSave?: boolean;
131
+ monitorDevTools?: boolean;
132
+ monitorScrollingExperimental?: boolean;
133
+ }
134
+
25
135
  export class Peripherals extends Module {
26
136
  static readonly moduleName = "peripherals";
27
137
  private wnd!: ReadiumWindow;
28
138
  private comms!: Comms;
139
+ private configApplied = false; // Track if config has been applied
29
140
 
141
+ // State management
142
+ private cleanupCallbacks: (() => void)[] = [];
30
143
  private pointerMoved = false;
144
+
145
+ // Feature flags
146
+ private isContextMenuEnabled = false;
147
+ private isDragAndDropEnabled = false;
148
+ private isSelectionMonitoringEnabled = false;
149
+ private isBulkCopyProtectionEnabled = false;
150
+
151
+ // Selection analysis
152
+ private selectionAnalyzer: SelectionAnalyzer | null = null;
153
+ private currentSelection: string | null = null;
154
+
155
+ // Bulk copy protection
156
+ private bulkCopyProtector: BulkCopyProtector | null = null;
157
+
158
+ // Key combination manager
159
+ private keyManager = new KeyCombinationManager();
160
+
161
+ private addContextMenuPrevention(): void {
162
+ if (this.isContextMenuEnabled || !this.wnd) return;
163
+ this.wnd.document.addEventListener("contextmenu", this.onContext);
164
+ this.isContextMenuEnabled = true;
165
+ }
166
+
167
+ private removeContextMenuPrevention(): void {
168
+ if (!this.isContextMenuEnabled || !this.wnd) return;
169
+ this.wnd.document.removeEventListener("contextmenu", this.onContext);
170
+ this.isContextMenuEnabled = false;
171
+ }
172
+
173
+ private addDragAndDropPrevention(): void {
174
+ if (this.isDragAndDropEnabled || !this.wnd) return;
175
+ this.wnd.document.addEventListener("dragstart", this.onDragStart);
176
+ this.wnd.document.addEventListener("drop", this.onDrop);
177
+ this.isDragAndDropEnabled = true;
178
+ }
179
+
180
+ private removeDragAndDropPrevention(): void {
181
+ if (!this.isDragAndDropEnabled || !this.wnd) return;
182
+ this.wnd.document.removeEventListener("dragstart", this.onDragStart);
183
+ this.wnd.document.removeEventListener("drop", this.onDrop);
184
+ this.isDragAndDropEnabled = false;
185
+ }
186
+
187
+ private keyDownHandler: ((event: KeyboardEvent) => void) | null = null;
188
+
189
+ private enableKeyboardPeripherals(shortcuts: KeyboardPeripheral[] = []): void {
190
+ // Clear any existing state
191
+ this.disableKeyboardPeripherals();
192
+
193
+ // Create activity event dispatcher
194
+ const dispatcher: ActivityEventDispatcher = (activityEvent) => {
195
+ this.comms?.send("keyboard_peripherals", activityEvent);
196
+ };
197
+
198
+ // Create unified handler using centralized KeyCombinationManager
199
+ this.keyDownHandler = this.keyManager.createUnifiedHandler(this.wnd.location.href, shortcuts, dispatcher, this.wnd);
200
+
201
+ // Add the event listener
202
+ if (this.wnd) {
203
+ this.wnd.document.addEventListener("keydown", this.keyDownHandler, {
204
+ capture: true
205
+ });
206
+ }
207
+ }
208
+
209
+ private disableKeyboardPeripherals(): void {
210
+ if (this.wnd && this.keyDownHandler) {
211
+ this.wnd.document.removeEventListener("keydown", this.keyDownHandler, {
212
+ capture: true
213
+ });
214
+ this.keyDownHandler = null;
215
+ }
216
+ }
217
+
218
+ private addBulkCopyProtection(options: Partial<BulkCopyProtectionOptions> = {}): void {
219
+ if (this.isBulkCopyProtectionEnabled || !this.wnd) return;
220
+
221
+ const defaultOptions = BULK_COPY_CONFIG;
222
+
223
+ const finalOptions = options ? { ...defaultOptions, ...options } : defaultOptions;
224
+
225
+ this.bulkCopyProtector = new BulkCopyProtector(this.wnd, finalOptions);
226
+ this.wnd.document.addEventListener("copy", this.preventBulkCopy, true);
227
+ this.wnd.document.addEventListener("cut", this.preventBulkCopy, true);
228
+ this.isBulkCopyProtectionEnabled = true;
229
+ }
230
+
231
+ private removeBulkCopyProtection(): void {
232
+ if (!this.isBulkCopyProtectionEnabled || !this.wnd) return;
233
+
234
+ this.wnd.document.removeEventListener("copy", this.preventBulkCopy, true);
235
+ this.wnd.document.removeEventListener("cut", this.preventBulkCopy, true);
236
+ this.bulkCopyProtector?.destroy();
237
+ this.bulkCopyProtector = null;
238
+ this.isBulkCopyProtectionEnabled = false;
239
+ }
240
+
241
+ private preventBulkCopy = (event: ClipboardEvent) => {
242
+ if (!this.isBulkCopyProtectionEnabled || !this.bulkCopyProtector) {
243
+ return true;
244
+ }
245
+
246
+ if (!this.bulkCopyProtector.shouldAllowCopy(event)) {
247
+ event.preventDefault();
248
+
249
+ const selection = this.wnd.getSelection();
250
+ const selectedText = selection?.toString() || '';
251
+ const domRectList = selectedText ? selection?.getRangeAt(0)?.getClientRects() : null;
252
+ const rect = domRectList?.[0];
253
+
254
+ const activityEvent: BulkCopyEvent = {
255
+ type: "bulk_copy",
256
+ timestamp: Date.now(),
257
+ clipboardTypes: event.clipboardData?.types ? [...event.clipboardData.types] : [],
258
+ selectedText: rect ? {
259
+ text: selectedText,
260
+ x: rect.x,
261
+ y: rect.y,
262
+ width: rect.width,
263
+ height: rect.height
264
+ } : undefined,
265
+ selectionLength: selectedText.length,
266
+ targetFrameSrc: this.wnd.location.href
267
+
268
+ };
269
+ this.comms?.send("content_protection", activityEvent);
270
+ return false;
271
+ }
272
+ return true;
273
+ };
274
+
275
+ private handleSelection = (event?: Event) => {
276
+ if (!this.isSelectionMonitoringEnabled || !this.wnd || !this.selectionAnalyzer) {
277
+ return;
278
+ }
279
+
280
+ const selection = this.wnd.getSelection();
281
+ if (selection) {
282
+ this.currentSelection = selection.toString();
283
+ const isSuspicious = this.selectionAnalyzer.analyze(selection);
284
+
285
+ if (isSuspicious && this.currentSelection) {
286
+ const selection = this.wnd.getSelection();
287
+ const selectedText = selection?.toString() || '';
288
+ const domRectList = (selectedText && selection?.rangeCount) ? selection.getRangeAt(0)?.getClientRects() : null;
289
+ const rect = domRectList?.[0];
290
+
291
+ const activityEvent: SuspiciousSelectionEvent = {
292
+ type: "suspicious_selection",
293
+ timestamp: Date.now(),
294
+ selectionLength: selectedText.length,
295
+ selectedText: {
296
+ text: selectedText,
297
+ x: rect?.x ?? 0,
298
+ y: rect?.y ?? 0,
299
+ width: rect?.width ?? 0,
300
+ height: rect?.height ?? 0
301
+ },
302
+ eventType: event?.type || "selectionchange",
303
+ targetFrameSrc: this.wnd.location.href
304
+ };
305
+ this.comms?.send("content_protection", activityEvent);
306
+ }
307
+ } else {
308
+ this.currentSelection = null;
309
+ }
310
+ };
311
+
312
+ private addSelectionMonitoring(options?: SelectionAnalyzerOptions): void {
313
+ if (this.isSelectionMonitoringEnabled || !this.wnd) return;
314
+
315
+ // Use provided options or fall back to default config
316
+ const analyzerOptions = options || SELECTION_ANALYZER_CONFIG;
317
+ this.selectionAnalyzer = new SelectionAnalyzer(analyzerOptions);
318
+ this.wnd.document.addEventListener("selectionchange", this.handleSelection);
319
+ this.isSelectionMonitoringEnabled = true;
320
+ }
321
+
322
+ private removeSelectionMonitoring(): void {
323
+ if (!this.isSelectionMonitoringEnabled || !this.wnd) {
324
+ return;
325
+ }
326
+
327
+ this.wnd.document.removeEventListener("selectionchange", this.handleSelection);
328
+ this.selectionAnalyzer?.clear();
329
+ this.selectionAnalyzer = null;
330
+ this.isSelectionMonitoringEnabled = false;
331
+ }
332
+
333
+ private onDragStart = (event: DragEvent) => {
334
+ if (this.isDragAndDropEnabled) {
335
+ // Drag and drop protection is enabled - prevent it
336
+ event.preventDefault();
337
+ const activityEvent: DragDetectedEvent = {
338
+ type: "drag_detected",
339
+ timestamp: Date.now(),
340
+ dataTransferTypes: event.dataTransfer?.types ? [...event.dataTransfer.types] : [],
341
+ targetFrameSrc: this.wnd.location.href
342
+ };
343
+ this.comms?.send("content_protection", activityEvent);
344
+ return false;
345
+ } else {
346
+ return true;
347
+ }
348
+ };
349
+
350
+ private onDrop = (event: DragEvent) => {
351
+ if (this.isDragAndDropEnabled) {
352
+ // Drag and drop protection is enabled - prevent it
353
+ event.preventDefault();
354
+ const dataTransfer = event.dataTransfer;
355
+ const activityEvent: DropDetectedEvent = {
356
+ type: "drop_detected",
357
+ timestamp: Date.now(),
358
+ dataTransferTypes: dataTransfer?.types ? [...dataTransfer.types] : [],
359
+ fileCount: dataTransfer?.files?.length || 0,
360
+ targetFrameSrc: this.wnd.location.href
361
+ };
362
+ this.comms?.send("content_protection", activityEvent);
363
+ return false;
364
+ } else {
365
+ return true;
366
+ }
367
+ };
368
+
369
+ private onContext = (event: MouseEvent) => {
370
+ if (this.isContextMenuEnabled) {
371
+ // Context menu protection is enabled - prevent it
372
+ event.preventDefault();
373
+ const selection = this.wnd.getSelection();
374
+ const selectedText = selection?.toString() || '';
375
+ const domRectList = (selectedText && selection?.rangeCount) ? selection.getRangeAt(0)?.getClientRects() : null;
376
+ const rect = domRectList?.[0];
377
+
378
+ const activityEvent: ContextMenuEvent = {
379
+ timestamp: Date.now(),
380
+ clientX: event.clientX,
381
+ clientY: event.clientY,
382
+ ...(rect && {
383
+ selectedText: {
384
+ text: selectedText,
385
+ x: rect.x,
386
+ y: rect.y,
387
+ width: rect.width,
388
+ height: rect.height
389
+ }
390
+ }),
391
+ targetFrameSrc: this.wnd.location.href
392
+ };
393
+ this.comms?.send("context_menu", activityEvent);
394
+ }
395
+ };
31
396
 
32
397
  onPointUp(event: PointerEvent) {
33
398
  const selection = this.wnd.getSelection();
@@ -44,7 +409,7 @@ export class Peripherals extends Module {
44
409
  y: rect.y,
45
410
  width: rect.width,
46
411
  height: rect.height,
47
- targetFrameSrc: this.wnd?.location?.href,
412
+ targetFrameSrc: this.wnd?.location?.href
48
413
  }
49
414
  this.comms.send("text_selected", textSelection as BasicTextSelection);
50
415
  }
@@ -95,13 +460,6 @@ export class Peripherals extends Module {
95
460
  }
96
461
  private readonly onPointerDown = this.onPointDown.bind(this);
97
462
 
98
- onContext(event: MouseEvent) {
99
- // If the context menu is triggered by a long press, we manage the event using the pointup event
100
- this.onPointUp(event as PointerEvent);
101
- this.pointerMoved = false;
102
- }
103
- private readonly onContextMenu = this.onContext.bind(this);
104
-
105
463
  onClick(event: MouseEvent) {
106
464
  event.preventDefault(); // To prevent certain browser actions. May have side-effects
107
465
  if(!event.isTrusted) {
@@ -114,20 +472,90 @@ export class Peripherals extends Module {
114
472
  clientY: event.clientY,
115
473
  });
116
474
  // Override properties we cannot set in the constructor
117
- Object.defineProperty(synthEvent, 'target', {writable: false, value: event.target});
118
- Object.defineProperty(synthEvent, 'defaultPrevented', {writable: false, value: event.defaultPrevented });
475
+ Object.defineProperty(synthEvent, "target", {writable: false, value: event.target});
476
+ Object.defineProperty(synthEvent, "defaultPrevented", {writable: false, value: event.defaultPrevented });
119
477
  // Trigger `pointerup` event
120
478
  this.onPointUp(synthEvent);
121
479
  }
122
480
  }
123
481
  private readonly onClicker = this.onClick.bind(this);
124
482
 
483
+ private registerProtectionHandlers() {
484
+ // Single handler for all content protection features
485
+ this.comms?.register("peripherals_protection", Peripherals.moduleName, (data: unknown, ack) => {
486
+ const config = data as ContentProtectionConfig;
487
+
488
+ // Apply config only on first call, then ignore subsequent calls (immutable)
489
+ if (!this.configApplied) {
490
+ this.configApplied = true;
491
+
492
+ // Selection monitoring with optional configuration
493
+ if (config.monitorSelection) {
494
+ const options = typeof config.monitorSelection === "boolean"
495
+ ? undefined
496
+ : config.monitorSelection;
497
+ this.addSelectionMonitoring(options);
498
+ this.comms?.log("Selection monitoring enabled");
499
+ }
500
+
501
+ // Copy
502
+ if (typeof config.protectCopy === "object") {
503
+ // Limited copying with custom thresholds
504
+ this.addBulkCopyProtection({
505
+ enabled: true,
506
+ ...config.protectCopy
507
+ });
508
+ this.comms?.log("Copy protection enabled (limited)");
509
+ } else if (config.protectCopy === true) {
510
+ // Block all copying
511
+ this.addBulkCopyProtection({
512
+ enabled: true,
513
+ maxSelectionPercent: 0,
514
+ minThreshold: 0,
515
+ absoluteMaxChars: 0
516
+ });
517
+ this.comms?.log("Copy protection enabled");
518
+ }
519
+
520
+ // Context menu
521
+ if (config.disableContextMenu) {
522
+ this.addContextMenuPrevention();
523
+ this.comms?.log("Context menu protection enabled");
524
+ }
525
+
526
+ // Drag and drop
527
+ if (config.disableDragAndDrop) {
528
+ this.addDragAndDropPrevention();
529
+ this.comms?.log("Drag and drop protection enabled");
530
+ }
531
+ }
532
+
533
+ ack(true);
534
+ });
535
+
536
+ // Separate handler for keyboard peripherals
537
+ this.comms?.register("keyboard_peripherals", Peripherals.moduleName, (data: unknown, ack) => {
538
+ const keyboardPeripherals = data as KeyboardPeripheral[];
539
+
540
+ if (keyboardPeripherals && keyboardPeripherals.length > 0) {
541
+ this.enableKeyboardPeripherals(keyboardPeripherals);
542
+ this.comms?.log(`Keyboard peripherals enabled: ${keyboardPeripherals.map(p => p.type).join(", ")}`);
543
+ }
544
+
545
+ ack(true);
546
+ });
547
+ }
548
+
125
549
  mount(wnd: ReadiumWindow, comms: Comms): boolean {
126
550
  this.wnd = wnd;
127
551
  this.comms = comms;
552
+
553
+ // Register protection handlers
554
+ this.registerProtectionHandlers();
555
+
556
+ // Core event listeners (always active)
128
557
  wnd.document.addEventListener("pointerdown", this.onPointerDown);
129
558
  wnd.document.addEventListener("pointerup", this.onPointerUp);
130
- wnd.document.addEventListener("contextmenu", this.onContextMenu);
131
559
  wnd.document.addEventListener("pointermove", this.onPointerMove);
132
560
  wnd.document.addEventListener("click", this.onClicker);
133
561
 
@@ -136,12 +564,29 @@ export class Peripherals extends Module {
136
564
  }
137
565
 
138
566
  unmount(wnd: ReadiumWindow, comms: Comms): boolean {
567
+ // Clean up optional features
568
+ this.removeBulkCopyProtection();
569
+ this.removeSelectionMonitoring();
570
+ this.removeContextMenuPrevention();
571
+ this.removeDragAndDropPrevention();
572
+ this.disableKeyboardPeripherals();
573
+
574
+ // Clean up event listeners
575
+ this.cleanupCallbacks.forEach(cleanup => cleanup());
576
+ this.cleanupCallbacks = [];
577
+
578
+ // Remove core event listeners
139
579
  wnd.document.removeEventListener("pointerdown", this.onPointerDown);
140
580
  wnd.document.removeEventListener("pointerup", this.onPointerUp);
141
- wnd.document.removeEventListener("contextmenu", this.onContextMenu);
142
581
  wnd.document.removeEventListener("pointermove", this.onPointerMove);
143
582
  wnd.document.removeEventListener("click", this.onClicker);
144
-
583
+
584
+ // Unregister all handlers
585
+ comms.unregisterAll(Peripherals.moduleName);
586
+
587
+ // Reset config applied flag for fresh instances
588
+ this.configApplied = false;
589
+
145
590
  comms.log("Peripherals Unmounted");
146
591
  return true;
147
592
  }
@@ -6,8 +6,16 @@ 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";
9
11
 
10
12
  const COLUMN_SNAPPER_STYLE_ID = "readium-column-snapper-style";
13
+
14
+ export interface SuspiciousSnappingEvent extends BaseSuspiciousActivityEvent {
15
+ type: "suspicious_snapping";
16
+ event: null;
17
+ }
18
+
11
19
  const SNAP_DURATION = 200; // Milliseconds
12
20
 
13
21
  enum ScrollTouchState {
@@ -21,8 +29,11 @@ enum ScrollTouchState {
21
29
  */
22
30
  export class ColumnSnapper extends Snapper {
23
31
  static readonly moduleName: ModuleName = "column_snapper";
32
+ private isSnapProtectionEnabled = false;
24
33
  private resizeObserver!: ResizeObserver;
25
34
  private mutationObserver!: MutationObserver;
35
+ private patternAnalyzer: PatternAnalyzer | null = null;
36
+ private lastTurnTime: number = 0;
26
37
  private wnd!: ReadiumWindow;
27
38
  private comms!: Comms;
28
39
  private doc() { return this.wnd.document.scrollingElement as HTMLElement; }
@@ -72,6 +83,7 @@ export class ColumnSnapper extends Snapper {
72
83
  private alreadyScrollLeft = 0;
73
84
  private overscroll = 0;
74
85
  private cachedScrollWidth = 0; // We have to cache this because during overscroll (transform, or left) the width is incorrect due to browser
86
+
75
87
  private takeOverSnap() {
76
88
  this.snappingCancelled = true;
77
89
  this.clearTouches();
@@ -80,7 +92,7 @@ export class ColumnSnapper extends Snapper {
80
92
  // translate3d(XXXpx, 0px, 0px) -> slice 12 -> XXXpx, 0px, 0px) -> split "px" [0] -> XXX
81
93
  this.overscroll = doc.style.transform?.length > 12 ? parseFloat(doc.style.transform.slice(12).split("px")[0]) : 0;
82
94
  }
83
-
95
+
84
96
  // Snaps the current offset to the page width.
85
97
  snapCurrentOffset(smooth=false, noprogress=false) {
86
98
  const startX = this.wnd.scrollX > 0 ? this.wnd.scrollX : this.alreadyScrollLeft;
@@ -96,6 +108,11 @@ export class ColumnSnapper extends Snapper {
96
108
  ((factor * cdo) > 0 ? 2 : 1);
97
109
 
98
110
  const so = this.snapOffset(currentOffset + hurdle);
111
+
112
+ const direction = so > this.scrollOffset() ? "right" : "left";
113
+
114
+ this.checkSuspiciousSnap(direction, Math.abs(so - this.scrollOffset()));
115
+
99
116
  if(smooth && so !== this.scrollOffset()) { // Smooth snapping
100
117
  this.snappingCancelled = false;
101
118
  const position = (start: number, end: number, elapsed: number, period: number) => {
@@ -232,6 +249,37 @@ export class ColumnSnapper extends Snapper {
232
249
  }
233
250
  private readonly onTouchMover = this.onTouchMove.bind(this);
234
251
 
252
+ private enableSnapProtection() {
253
+ if (!this.patternAnalyzer) {
254
+ this.patternAnalyzer = new PatternAnalyzer({
255
+ maxVelocity: this.wnd.innerWidth, // page width
256
+ minVariance: 0.1, // Allow for some variation in swipe speed
257
+ historySize: 5, // Fewer samples needed for swipe detection
258
+ maxConsistentScrolls: 3, // Lower threshold for consistent scrolling
259
+ minDirectionChanges: 0.3 // Require more direction changes
260
+ });
261
+ this.isSnapProtectionEnabled = true;
262
+ this.comms?.log("Snap protection enabled");
263
+ }
264
+ }
265
+
266
+ private checkSuspiciousSnap(direction: "left" | "right", distance: number) {
267
+ if (!this.isSnapProtectionEnabled || !this.patternAnalyzer) return;
268
+
269
+ const now = Date.now();
270
+ const timeDelta = now - (this.lastTurnTime || now);
271
+ this.lastTurnTime = now;
272
+
273
+ const isSuspicious = this.patternAnalyzer.analyze(direction, distance, timeDelta);
274
+ if (isSuspicious) {
275
+ this.comms?.send("content_protection", {
276
+ type: "suspicious_snapping",
277
+ timestamp: Date.now(),
278
+ event: null
279
+ } as SuspiciousSnappingEvent);
280
+ }
281
+ }
282
+
235
283
  mount(wnd: ReadiumWindow, comms: Comms): boolean {
236
284
  this.wnd = wnd;
237
285
  this.comms = comms;
@@ -317,6 +365,11 @@ export class ColumnSnapper extends Snapper {
317
365
  // For cases the resizeObserver is not able to detect cos body is not resizing despite colCount,
318
366
  // we need to check the syle attribute on the documentElement (ReadiumCSS props)
319
367
  this.mutationObserver.observe(wnd.document.documentElement, {attributes: true, attributeFilter: ["style"]});
368
+
369
+ comms.register("scroll_protection", ColumnSnapper.moduleName, (_, ack) => {
370
+ this.enableSnapProtection();
371
+ ack(true);
372
+ });
320
373
 
321
374
  const scrollToOffset = (offset: number): boolean => {
322
375
  const oldScrollLeft = this.doc().scrollLeft;
@@ -427,6 +480,7 @@ export class ColumnSnapper extends Snapper {
427
480
  if(change) {
428
481
  this.reportProgress();
429
482
  deselect(this.wnd);
483
+ this.checkSuspiciousSnap("left", this.wnd.innerWidth);
430
484
  }
431
485
  ack(change);
432
486
  });
@@ -441,6 +495,7 @@ export class ColumnSnapper extends Snapper {
441
495
  if(change) {
442
496
  this.reportProgress();
443
497
  deselect(this.wnd);
498
+ this.checkSuspiciousSnap("right", this.wnd.innerWidth);
444
499
  }
445
500
  ack(change);
446
501
  });
@@ -478,7 +533,7 @@ export class ColumnSnapper extends Snapper {
478
533
  wnd.addEventListener("touchmove", this.onTouchMover, { passive: true });
479
534
 
480
535
  // Safari hack, otherwise other events won't register
481
- wnd.document.addEventListener('touchstart', () => {});
536
+ wnd.document.addEventListener("touchstart", () => {});
482
537
 
483
538
  comms.log("ColumnSnapper Mounted");
484
539
  return true;
@@ -490,6 +545,12 @@ export class ColumnSnapper extends Snapper {
490
545
  this.resizeObserver.disconnect();
491
546
  this.mutationObserver.disconnect();
492
547
 
548
+ if (this.patternAnalyzer) {
549
+ this.patternAnalyzer.clear();
550
+ this.patternAnalyzer = null;
551
+ this.isSnapProtectionEnabled = false;
552
+ }
553
+
493
554
  wnd.removeEventListener("touchstart", this.onTouchStarter);
494
555
  wnd.removeEventListener("touchend", this.onTouchEnder);
495
556
  wnd.removeEventListener("touchmove", this.onTouchMover);