@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.
- package/dist/ar-DyHX_uy2.js +7 -0
- package/dist/da-Dct0PS3E.js +7 -0
- package/dist/fr-C5HEel98.js +7 -0
- package/dist/index.js +2121 -1352
- package/dist/index.umd.cjs +24 -10
- package/dist/it-DFOBoXGy.js +7 -0
- package/dist/pt_PT-Di3sVjze.js +7 -0
- package/dist/sv-BfzAFsVN.js +7 -0
- package/package.json +4 -2
- package/src/comms/keys.ts +22 -3
- 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 +63 -2
- 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/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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export interface KeyCombo {
|
|
2
|
+
keyCode: number; // Use stable keyCode that doesn't change across layouts
|
|
3
|
+
ctrl?: boolean;
|
|
4
|
+
shift?: boolean;
|
|
5
|
+
alt?: boolean;
|
|
6
|
+
meta?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface KeyboardPeripheral {
|
|
10
|
+
type: string;
|
|
11
|
+
keyCombos: KeyCombo[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Developer tools shortcuts as KeyboardPeripheral
|
|
15
|
+
export const DEV_TOOLS: KeyboardPeripheral = {
|
|
16
|
+
type: "developer_tools",
|
|
17
|
+
keyCombos: [
|
|
18
|
+
{ keyCode: 73, meta: true, alt: true }, // I key
|
|
19
|
+
{ keyCode: 74, meta: true, alt: true }, // J key
|
|
20
|
+
{ keyCode: 85, meta: true, alt: true }, // U key
|
|
21
|
+
{ keyCode: 67, meta: true, alt: true }, // C key
|
|
22
|
+
{ keyCode: 67, meta: true, shift: true }, // C key (Cmd+Shift+C)
|
|
23
|
+
{ keyCode: 67, ctrl: true, shift: true }, // C key (Ctrl+Shift+C)
|
|
24
|
+
{ keyCode: 123 }, // F12
|
|
25
|
+
{ keyCode: 123, shift: true }, // F12+Shift
|
|
26
|
+
{ keyCode: 123, ctrl: true, shift: true }, // F12+Ctrl+Shift
|
|
27
|
+
{ keyCode: 123, meta: true, alt: true }, // F12+Meta+Alt
|
|
28
|
+
]
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Select all shortcuts as KeyboardPeripheral
|
|
32
|
+
export const SELECT_ALL: KeyboardPeripheral = {
|
|
33
|
+
type: "select_all",
|
|
34
|
+
keyCombos: [
|
|
35
|
+
{ keyCode: 65, meta: true }, // A key (Cmd+A)
|
|
36
|
+
{ keyCode: 65, ctrl: true } // A key (Ctrl+A)
|
|
37
|
+
]
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Print shortcuts as KeyboardPeripheral
|
|
41
|
+
export const PRINT: KeyboardPeripheral = {
|
|
42
|
+
type: "print",
|
|
43
|
+
keyCombos: [
|
|
44
|
+
{ keyCode: 80, meta: true }, // P key (Cmd+P)
|
|
45
|
+
{ keyCode: 80, ctrl: true }, // P key (Ctrl+P)
|
|
46
|
+
{ keyCode: 80, meta: true, shift: true }, // P key (Cmd+Shift+P)
|
|
47
|
+
{ keyCode: 80, ctrl: true, shift: true }, // P key (Ctrl+Shift+P)
|
|
48
|
+
{ keyCode: 80, meta: true, alt: true }, // P key (Cmd+Alt+P)
|
|
49
|
+
{ keyCode: 80, ctrl: true, alt: true } // P key (Ctrl+Alt+P)
|
|
50
|
+
]
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Save shortcuts as KeyboardPeripheral
|
|
54
|
+
export const SAVE: KeyboardPeripheral = {
|
|
55
|
+
type: "save",
|
|
56
|
+
keyCombos: [
|
|
57
|
+
{ keyCode: 83, meta: true }, // S key (Cmd+S)
|
|
58
|
+
{ keyCode: 83, ctrl: true } // S key (Ctrl+S)
|
|
59
|
+
]
|
|
60
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { DEFAULT_PATTERN_ANALYZER_CONFIG } from "./config";
|
|
2
|
+
|
|
3
|
+
type Direction = "left" | "right" | "up" | "down";
|
|
4
|
+
|
|
5
|
+
export interface PatternAnalyzerOptions {
|
|
6
|
+
maxVelocity: number; // pixels/ms
|
|
7
|
+
minVariance: number; // 0-1
|
|
8
|
+
historySize?: number; // Number of scroll events to keep in history
|
|
9
|
+
minDirectionChanges?: number; // Minimum ratio of direction changes to total events (0-1)
|
|
10
|
+
maxConsistentScrolls?: number; // Maximum number of consistent scrolls before flagging
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// TODO: Improve for scroll protection,
|
|
14
|
+
// currently disabled because of false positives
|
|
15
|
+
export class PatternAnalyzer {
|
|
16
|
+
private options: PatternAnalyzerOptions;
|
|
17
|
+
private history: Array<{
|
|
18
|
+
timestamp: number;
|
|
19
|
+
direction: Direction;
|
|
20
|
+
velocity: number;
|
|
21
|
+
distance: number;
|
|
22
|
+
}> = [];
|
|
23
|
+
|
|
24
|
+
private consistentScrollCount = 0;
|
|
25
|
+
|
|
26
|
+
constructor(
|
|
27
|
+
options: Partial<PatternAnalyzerOptions> = {}
|
|
28
|
+
) {
|
|
29
|
+
// Merge provided options with default config
|
|
30
|
+
this.options = { ...DEFAULT_PATTERN_ANALYZER_CONFIG, ...options };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
analyze(direction: Direction, distance: number, timeDelta: number): boolean {
|
|
34
|
+
if (timeDelta <= 0) return false;
|
|
35
|
+
|
|
36
|
+
const velocity = Math.abs(distance) / timeDelta;
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
|
|
39
|
+
// Add new event
|
|
40
|
+
this.history.push({
|
|
41
|
+
timestamp: now,
|
|
42
|
+
direction,
|
|
43
|
+
velocity,
|
|
44
|
+
distance: Math.abs(distance)
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Clean up old events (keep last 2 seconds of data or historySize, whichever is smaller)
|
|
48
|
+
this.history = this.history
|
|
49
|
+
.filter(h => now - h.timestamp < 2000)
|
|
50
|
+
.slice(-(this.options.historySize || 20));
|
|
51
|
+
|
|
52
|
+
// Need at least 3 events to start detecting patterns
|
|
53
|
+
if (this.history.length < 3) return false;
|
|
54
|
+
|
|
55
|
+
// 1. Check for excessive speed (unrealistically fast scrolling)
|
|
56
|
+
if (velocity > this.options.maxVelocity) {
|
|
57
|
+
this.resetAfterDetection();
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 2. Check for consistent scrolling patterns
|
|
62
|
+
const velocities = this.history.map(h => h.velocity);
|
|
63
|
+
const distances = this.history.map(h => h.distance);
|
|
64
|
+
|
|
65
|
+
// Calculate mean and variance of velocities and distances
|
|
66
|
+
const meanVelocity = velocities.reduce((a, b) => a + b, 0) / velocities.length;
|
|
67
|
+
const meanDistance = distances.reduce((a, b) => a + b, 0) / distances.length;
|
|
68
|
+
|
|
69
|
+
const velocityVariance = velocities.reduce((a, b) => a + Math.pow(b - meanVelocity, 2), 0) / velocities.length;
|
|
70
|
+
const distanceVariance = distances.reduce((a, b) => a + Math.pow(b - meanDistance, 2), 0) / distances.length;
|
|
71
|
+
|
|
72
|
+
// Check for suspiciously consistent scrolling (low variance in speed and distance)
|
|
73
|
+
if (velocityVariance < this.options.minVariance &&
|
|
74
|
+
distanceVariance < (meanDistance * 0.1)) { // Allow 10% variance in distance
|
|
75
|
+
this.consistentScrollCount++;
|
|
76
|
+
|
|
77
|
+
// Only trigger if we've seen consistent scrolling for a while
|
|
78
|
+
if (this.consistentScrollCount >= (this.options.maxConsistentScrolls || 10)) {
|
|
79
|
+
this.resetAfterDetection();
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
// Reset counter if pattern breaks
|
|
84
|
+
this.consistentScrollCount = Math.max(0, this.consistentScrollCount - 1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 3. Check for rapid direction changes (unnatural back-and-forth scrolling)
|
|
88
|
+
let directionChanges = 0;
|
|
89
|
+
let lastDirection = this.history[0].direction;
|
|
90
|
+
|
|
91
|
+
for (let i = 1; i < this.history.length; i++) {
|
|
92
|
+
if (this.history[i].direction !== lastDirection) {
|
|
93
|
+
directionChanges++;
|
|
94
|
+
lastDirection = this.history[i].direction;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const directionChangeRatio = directionChanges / this.history.length;
|
|
99
|
+
if (directionChangeRatio > (this.options.minDirectionChanges || 0.3)) {
|
|
100
|
+
this.resetAfterDetection();
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private resetAfterDetection(): void {
|
|
108
|
+
// Keep some history to prevent immediate re-triggering
|
|
109
|
+
this.history = this.history.slice(-3);
|
|
110
|
+
this.consistentScrollCount = 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
clear(): void {
|
|
114
|
+
this.history = [];
|
|
115
|
+
this.consistentScrollCount = 0;
|
|
116
|
+
}
|
|
117
|
+
}
|