@readium/navigator-html-injectables 2.3.1 → 2.4.0-beta.2
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 +818 -658
- package/dist/index.umd.cjs +22 -11
- package/package.json +1 -1
- package/src/Loader.ts +3 -3
- package/src/helpers/document.ts +10 -1
- package/src/modules/ModuleLibrary.ts +4 -0
- package/src/modules/snapper/CJKVerticalSnapper.ts +333 -0
- package/src/modules/snapper/ColumnSnapper.ts +139 -59
- package/types/src/Loader.d.ts +2 -2
- package/types/src/helpers/document.d.ts +1 -0
- package/types/src/modules/ModuleLibrary.d.ts +1 -1
- package/types/src/modules/snapper/CJKVerticalSnapper.d.ts +58 -0
- package/types/src/modules/snapper/ColumnSnapper.d.ts +12 -0
package/package.json
CHANGED
package/src/Loader.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { Comms } from "./comms/comms";
|
|
2
|
-
import { ReadiumWindow } from "./helpers/dom";
|
|
3
|
-
import { Module, ModuleDerived, ModuleLibrary, ModuleName } from "./modules";
|
|
1
|
+
import { Comms } from "./comms/comms.ts";
|
|
2
|
+
import { ReadiumWindow } from "./helpers/dom.ts";
|
|
3
|
+
import { Module, ModuleDerived, ModuleLibrary, ModuleName } from "./modules/index.ts";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* The Module loader. Handles initialization of the HTML injectables
|
package/src/helpers/document.ts
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { ReadiumWindow } from "./dom.ts";
|
|
2
2
|
|
|
3
3
|
export function isRTL(wnd: ReadiumWindow): boolean {
|
|
4
|
-
|
|
4
|
+
// Check documentElement first, then fall back to body.
|
|
5
|
+
const dir = wnd.document.documentElement.dir || wnd.document.body.dir;
|
|
6
|
+
return dir.toLowerCase() === "rtl";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isVerticalLR(wnd: ReadiumWindow): boolean {
|
|
10
|
+
// Check documentElement first, then fall back to body.
|
|
11
|
+
const writingMode = wnd.getComputedStyle(wnd.document.documentElement).writingMode
|
|
12
|
+
|| wnd.getComputedStyle(wnd.document.body).writingMode;
|
|
13
|
+
return writingMode === 'vertical-lr';
|
|
5
14
|
}
|
|
6
15
|
|
|
7
16
|
export function getColumnCountPerScreen(wnd: ReadiumWindow): number {
|
|
@@ -8,6 +8,7 @@ import { FixedSetup } from "./setup/FixedSetup.ts";
|
|
|
8
8
|
import { Decorator } from "./Decorator.ts";
|
|
9
9
|
import { WebPubSetup } from "./setup/WebPubSetup.ts";
|
|
10
10
|
import { PrintProtector } from "../protection/PrintProtector.ts";
|
|
11
|
+
import { CJKVerticalSnapper } from "./snapper/CJKVerticalSnapper.ts";
|
|
11
12
|
|
|
12
13
|
// All the module names. TODO: Come up with a better way of collecting these in a way TS will recognize
|
|
13
14
|
export type ModuleName =
|
|
@@ -15,6 +16,7 @@ export type ModuleName =
|
|
|
15
16
|
"snapper" |
|
|
16
17
|
"column_snapper" |
|
|
17
18
|
"scroll_snapper" |
|
|
19
|
+
"cjk_vertical_snapper" |
|
|
18
20
|
"webpub_snapper" |
|
|
19
21
|
"fixed_setup" |
|
|
20
22
|
"decorator" |
|
|
@@ -38,6 +40,7 @@ export const ReflowableModules: ModuleName[] = [
|
|
|
38
40
|
"peripherals",
|
|
39
41
|
"column_snapper",
|
|
40
42
|
"scroll_snapper",
|
|
43
|
+
"cjk_vertical_snapper",
|
|
41
44
|
"print_protection"
|
|
42
45
|
];
|
|
43
46
|
|
|
@@ -60,5 +63,6 @@ export const ModuleLibrary = new Map<string, ModuleDerived>([
|
|
|
60
63
|
Decorator,
|
|
61
64
|
ColumnSnapper,
|
|
62
65
|
ScrollSnapper,
|
|
66
|
+
CJKVerticalSnapper,
|
|
63
67
|
PrintProtector
|
|
64
68
|
].map(m => [m.moduleName, m])); // Turn module list into K/V list for quick access by name
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { Locator, LocatorLocations, LocatorText } from "@readium/shared";
|
|
2
|
+
import { Comms } from "../../comms/comms.ts";
|
|
3
|
+
import { ReadiumWindow, deselect, findFirstVisibleLocator } from "../../helpers/dom.ts";
|
|
4
|
+
import { ModuleName } from "../ModuleLibrary.ts";
|
|
5
|
+
import { Snapper } from "./Snapper.ts";
|
|
6
|
+
import { rangeFromLocator } from "../../helpers/locator.ts";
|
|
7
|
+
import { forceWebkitRecalc, isVerticalLR } from "../../helpers/document.ts";
|
|
8
|
+
import { PatternAnalyzer } from "../../protection/PatternAnalyzer.ts";
|
|
9
|
+
import { SCROLL_PROTECTION_CONFIG } from "../../protection/config.ts";
|
|
10
|
+
import { BaseSuspiciousActivityEvent } from "../Peripherals.ts";
|
|
11
|
+
|
|
12
|
+
const CJK_VERTICAL_SNAPPER_STYLE_ID = "readium-cjk-vertical-snapper-style";
|
|
13
|
+
|
|
14
|
+
export interface SuspiciousCJKScrollingEvent extends BaseSuspiciousActivityEvent {
|
|
15
|
+
type: "suspicious_scrolling";
|
|
16
|
+
scrollDelta: number;
|
|
17
|
+
scrollDirection: "left" | "right";
|
|
18
|
+
targetElement: { tagName: string } | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Snapper for CJK vertical writing (writing-mode: vertical-rl).
|
|
23
|
+
*
|
|
24
|
+
* In vertical-rl, content overflows on the X axis, flowing from right (start)
|
|
25
|
+
* to left (end). This snapper is the horizontal counterpart of ScrollSnapper:
|
|
26
|
+
* it tracks scrollLeft / scrollWidth / innerWidth and reports progress
|
|
27
|
+
* accordingly.
|
|
28
|
+
*
|
|
29
|
+
* Progress convention:
|
|
30
|
+
* scrollLeft = 0 (right edge, document start) → progress 0
|
|
31
|
+
* scrollLeft = ±(scrollWidth − clientWidth) → progress 1 (document end, left edge)
|
|
32
|
+
*
|
|
33
|
+
* All modern browsers use negative scrollLeft for vertical-rl (range: [−scrollable, 0]).
|
|
34
|
+
* Read it with Math.abs() to get a non-negative offset from document start.
|
|
35
|
+
* Write absolute positions as negative values (go_progression, go_end).
|
|
36
|
+
* Write relative adjustments as viewport-coordinate deltas (go_id, go_text) — this
|
|
37
|
+
* is sign-agnostic and works regardless of browser convention.
|
|
38
|
+
*
|
|
39
|
+
* go_next / go_prev always return false so that the navigator advances to the
|
|
40
|
+
* next or previous spine item — this snapper intentionally provides free
|
|
41
|
+
* horizontal scroll without intra-resource page fragmentation.
|
|
42
|
+
*/
|
|
43
|
+
export class CJKVerticalSnapper extends Snapper {
|
|
44
|
+
static readonly moduleName: ModuleName = "cjk_vertical_snapper";
|
|
45
|
+
private wnd!: ReadiumWindow;
|
|
46
|
+
private comms!: Comms;
|
|
47
|
+
private resizeObserver!: ResizeObserver;
|
|
48
|
+
private patternAnalyzer: PatternAnalyzer | null = null;
|
|
49
|
+
private lastScrollTime: number = 0;
|
|
50
|
+
private isScrollProtectionEnabled = false;
|
|
51
|
+
|
|
52
|
+
private initialScrollHandled = false;
|
|
53
|
+
private isScrolling = false;
|
|
54
|
+
private lastScrollLeft = 0;
|
|
55
|
+
private isResizing = false;
|
|
56
|
+
private resizeDebounce: number | null = null;
|
|
57
|
+
// true for writing-mode: vertical-lr (Traditional Mongolian).
|
|
58
|
+
// vertical-lr uses non-negative scrollLeft like LTR horizontal, so
|
|
59
|
+
// absolute position writes must be positive, not negative.
|
|
60
|
+
private verticalLR = false;
|
|
61
|
+
|
|
62
|
+
private doc() {
|
|
63
|
+
return this.wnd.document.scrollingElement as HTMLElement;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Total horizontally scrollable distance (magnitude). */
|
|
67
|
+
private scrollable() {
|
|
68
|
+
return Math.max(0, this.doc().scrollWidth - this.wnd.innerWidth);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private reportProgress() {
|
|
72
|
+
if (!this.comms.ready) return;
|
|
73
|
+
const scrollWidth = this.doc().scrollWidth;
|
|
74
|
+
const viewportWidth = this.wnd.innerWidth;
|
|
75
|
+
const scrollable = Math.max(1, scrollWidth - viewportWidth);
|
|
76
|
+
|
|
77
|
+
// scrollLeft may be negative depending on the browser — normalize to a
|
|
78
|
+
// non-negative distance from the document start.
|
|
79
|
+
const norm = Math.abs(this.doc().scrollLeft);
|
|
80
|
+
const progress = Math.max(0, Math.min(1, norm / scrollable));
|
|
81
|
+
const viewportEnd = Math.max(0, Math.min(1, (norm + viewportWidth) / scrollWidth));
|
|
82
|
+
|
|
83
|
+
this.comms.send("progress", {
|
|
84
|
+
start: progress,
|
|
85
|
+
end: viewportEnd
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private handleScroll = (_e: Event) => {
|
|
90
|
+
if (!this.comms.ready) return;
|
|
91
|
+
if (this.isResizing) return;
|
|
92
|
+
|
|
93
|
+
if (!this.initialScrollHandled) {
|
|
94
|
+
this.lastScrollLeft = Math.abs(this.doc().scrollLeft);
|
|
95
|
+
this.initialScrollHandled = true;
|
|
96
|
+
this.reportProgress();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!this.isScrolling) {
|
|
101
|
+
this.isScrolling = true;
|
|
102
|
+
this.wnd.requestAnimationFrame(() => {
|
|
103
|
+
this.reportProgress();
|
|
104
|
+
|
|
105
|
+
const currentScrollLeft = Math.abs(this.doc().scrollLeft);
|
|
106
|
+
const deltaX = currentScrollLeft - this.lastScrollLeft;
|
|
107
|
+
this.lastScrollLeft = currentScrollLeft;
|
|
108
|
+
|
|
109
|
+
if (this.isScrollProtectionEnabled && Math.abs(deltaX) > 5) {
|
|
110
|
+
const now = Date.now();
|
|
111
|
+
const timeDelta = now - (this.lastScrollTime || now);
|
|
112
|
+
if (this.patternAnalyzer) {
|
|
113
|
+
const isSuspicious = this.patternAnalyzer.analyze(
|
|
114
|
+
// In vertical-rl, norm increases when scrolling forward (toward left/end),
|
|
115
|
+
// so deltaX > 0 = going forward.
|
|
116
|
+
deltaX > 0 ? "down" : "up",
|
|
117
|
+
Math.abs(deltaX),
|
|
118
|
+
timeDelta
|
|
119
|
+
);
|
|
120
|
+
if (isSuspicious) {
|
|
121
|
+
const target = _e.target && "tagName" in _e.target
|
|
122
|
+
? { tagName: (_e.target as Element).tagName }
|
|
123
|
+
: null;
|
|
124
|
+
this.comms?.send("content_protection", {
|
|
125
|
+
type: "suspicious_scrolling",
|
|
126
|
+
timestamp: Date.now(),
|
|
127
|
+
scrollDelta: deltaX,
|
|
128
|
+
scrollDirection: deltaX > 0 ? "left" : "right",
|
|
129
|
+
targetElement: target
|
|
130
|
+
} as SuspiciousCJKScrollingEvent);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
this.lastScrollTime = now;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
this.comms.send("scroll", deltaX);
|
|
137
|
+
this.isScrolling = false;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
private enableScrollProtection() {
|
|
143
|
+
if (!this.patternAnalyzer) {
|
|
144
|
+
this.patternAnalyzer = new PatternAnalyzer(SCROLL_PROTECTION_CONFIG);
|
|
145
|
+
this.isScrollProtectionEnabled = true;
|
|
146
|
+
this.comms?.log("Scroll protection enabled");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
mount(wnd: ReadiumWindow, comms: Comms): boolean {
|
|
151
|
+
this.wnd = wnd;
|
|
152
|
+
this.comms = comms;
|
|
153
|
+
|
|
154
|
+
this.initialScrollHandled = false;
|
|
155
|
+
this.lastScrollLeft = 0;
|
|
156
|
+
this.isResizing = false;
|
|
157
|
+
this.verticalLR = isVerticalLR(wnd);
|
|
158
|
+
if (this.resizeDebounce) {
|
|
159
|
+
this.wnd.clearTimeout(this.resizeDebounce);
|
|
160
|
+
this.resizeDebounce = null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
wnd.navigator.epubReadingSystem && (wnd.navigator.epubReadingSystem.layoutStyle = "scrolling");
|
|
164
|
+
|
|
165
|
+
// Hide scrollbar
|
|
166
|
+
const style = wnd.document.createElement("style");
|
|
167
|
+
style.dataset.readium = "true";
|
|
168
|
+
style.id = CJK_VERTICAL_SNAPPER_STYLE_ID;
|
|
169
|
+
style.textContent = `
|
|
170
|
+
* {
|
|
171
|
+
scrollbar-width: none;
|
|
172
|
+
}
|
|
173
|
+
body::-webkit-scrollbar {
|
|
174
|
+
display: none;
|
|
175
|
+
}
|
|
176
|
+
html {
|
|
177
|
+
overflow-x: auto !important;
|
|
178
|
+
overflow-y: hidden !important;
|
|
179
|
+
}
|
|
180
|
+
`;
|
|
181
|
+
wnd.document.head.appendChild(style);
|
|
182
|
+
|
|
183
|
+
this.resizeObserver = new ResizeObserver(() => {
|
|
184
|
+
if (this.resizeDebounce) {
|
|
185
|
+
this.wnd.clearTimeout(this.resizeDebounce);
|
|
186
|
+
}
|
|
187
|
+
this.isResizing = true;
|
|
188
|
+
this.resizeDebounce = this.wnd.setTimeout(() => {
|
|
189
|
+
this.isResizing = false;
|
|
190
|
+
this.resizeDebounce = null;
|
|
191
|
+
this.reportProgress();
|
|
192
|
+
}, 50);
|
|
193
|
+
});
|
|
194
|
+
this.resizeObserver.observe(wnd.document.body);
|
|
195
|
+
|
|
196
|
+
wnd.addEventListener("scroll", this.handleScroll, { passive: true });
|
|
197
|
+
|
|
198
|
+
comms.register("force_webkit_recalc", CJKVerticalSnapper.moduleName, () => {
|
|
199
|
+
forceWebkitRecalc(this.wnd);
|
|
200
|
+
const current = this.doc().scrollLeft;
|
|
201
|
+
// Nudge by ±1 then restore to force a style recalculation.
|
|
202
|
+
// vertical-lr: scrollLeft ≥ 0, nudge toward 0 when possible.
|
|
203
|
+
// vertical-rl: scrollLeft ≤ 0, nudge toward 0 when possible.
|
|
204
|
+
if (this.verticalLR) {
|
|
205
|
+
this.doc().scrollLeft = current > 1 ? current - 1 : current + 1;
|
|
206
|
+
} else {
|
|
207
|
+
this.doc().scrollLeft = current < -1 ? current + 1 : current - 1;
|
|
208
|
+
}
|
|
209
|
+
this.doc().scrollLeft = current;
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
comms.register("go_progression", CJKVerticalSnapper.moduleName, (data, ack) => {
|
|
213
|
+
const position = data as number;
|
|
214
|
+
if (position < 0 || position > 1) {
|
|
215
|
+
comms.send("error", {
|
|
216
|
+
message: "go_progression must be given a position from 0.0 to 1.0"
|
|
217
|
+
});
|
|
218
|
+
ack(false);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
this.wnd.requestAnimationFrame(() => {
|
|
222
|
+
// position 0 = start (scrollLeft=0), position 1 = end.
|
|
223
|
+
// vertical-lr: scrollLeft is positive; vertical-rl: negative.
|
|
224
|
+
const target = this.scrollable() * position;
|
|
225
|
+
this.doc().scrollLeft = this.verticalLR ? target : -target;
|
|
226
|
+
this.reportProgress();
|
|
227
|
+
deselect(this.wnd);
|
|
228
|
+
ack(true);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
comms.register("go_id", CJKVerticalSnapper.moduleName, (data, ack) => {
|
|
233
|
+
const element = wnd.document.getElementById(data as string);
|
|
234
|
+
if (!element) { ack(false); return; }
|
|
235
|
+
this.wnd.requestAnimationFrame(() => {
|
|
236
|
+
// getBoundingClientRect().left is in viewport coords; translate to scroll coords
|
|
237
|
+
this.doc().scrollLeft += element.getBoundingClientRect().left - wnd.innerWidth / 2;
|
|
238
|
+
this.reportProgress();
|
|
239
|
+
deselect(this.wnd);
|
|
240
|
+
ack(true);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
comms.register("go_text", CJKVerticalSnapper.moduleName, (data: unknown | unknown[], ack) => {
|
|
245
|
+
let cssSelector = undefined;
|
|
246
|
+
if (Array.isArray(data)) {
|
|
247
|
+
if (data.length > 1) cssSelector = (data as unknown[])[1] as string;
|
|
248
|
+
data = data[0];
|
|
249
|
+
}
|
|
250
|
+
const text = LocatorText.deserialize(data);
|
|
251
|
+
const r = rangeFromLocator(this.wnd.document, new Locator({
|
|
252
|
+
href: wnd.location.href,
|
|
253
|
+
type: "text/html",
|
|
254
|
+
text,
|
|
255
|
+
locations: cssSelector ? new LocatorLocations({
|
|
256
|
+
otherLocations: new Map([["cssSelector", cssSelector]])
|
|
257
|
+
}) : undefined
|
|
258
|
+
}));
|
|
259
|
+
if (!r) { ack(false); return; }
|
|
260
|
+
this.wnd.requestAnimationFrame(() => {
|
|
261
|
+
this.doc().scrollLeft += r.getBoundingClientRect().left - wnd.innerWidth / 2;
|
|
262
|
+
this.reportProgress();
|
|
263
|
+
deselect(this.wnd);
|
|
264
|
+
ack(true);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// go_start = document start = right edge (scrollLeft = 0)
|
|
269
|
+
comms.register("go_start", CJKVerticalSnapper.moduleName, (_, ack) => {
|
|
270
|
+
if (this.doc().scrollLeft === 0) return ack(false);
|
|
271
|
+
this.doc().scrollLeft = 0;
|
|
272
|
+
this.reportProgress();
|
|
273
|
+
ack(true);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// go_end = document end.
|
|
277
|
+
// vertical-lr: scrollLeft = +scrollable (rightmost column).
|
|
278
|
+
// vertical-rl: scrollLeft = -scrollable (leftmost column).
|
|
279
|
+
comms.register("go_end", CJKVerticalSnapper.moduleName, (_, ack) => {
|
|
280
|
+
if (Math.abs(this.doc().scrollLeft) === this.scrollable()) return ack(false);
|
|
281
|
+
this.doc().scrollLeft = this.verticalLR ? this.scrollable() : -this.scrollable();
|
|
282
|
+
this.reportProgress();
|
|
283
|
+
ack(true);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Free-scroll: no intra-resource page stepping.
|
|
287
|
+
// The navigator will move to the next/previous spine item.
|
|
288
|
+
comms.register([
|
|
289
|
+
"go_next",
|
|
290
|
+
"go_prev",
|
|
291
|
+
], CJKVerticalSnapper.moduleName, (_, ack) => ack(false));
|
|
292
|
+
|
|
293
|
+
comms.register("unfocus", CJKVerticalSnapper.moduleName, (_, ack) => {
|
|
294
|
+
deselect(this.wnd);
|
|
295
|
+
ack(true);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
comms.register("scroll_protection", CJKVerticalSnapper.moduleName, (_, ack) => {
|
|
299
|
+
this.enableScrollProtection();
|
|
300
|
+
ack(true);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
comms.register("focus", CJKVerticalSnapper.moduleName, (_, ack) => {
|
|
304
|
+
this.reportProgress();
|
|
305
|
+
ack(true);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
comms.register("first_visible_locator", CJKVerticalSnapper.moduleName, (_, ack) => {
|
|
309
|
+
const locator = findFirstVisibleLocator(wnd as ReadiumWindow, true);
|
|
310
|
+
this.comms.send("first_visible_locator", locator.serialize());
|
|
311
|
+
ack(true);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
comms.log("CJKVerticalSnapper Mounted");
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
unmount(wnd: ReadiumWindow, comms: Comms): boolean {
|
|
319
|
+
comms.unregisterAll(CJKVerticalSnapper.moduleName);
|
|
320
|
+
this.resizeObserver.disconnect();
|
|
321
|
+
if (this.handleScroll) wnd.removeEventListener("scroll", this.handleScroll);
|
|
322
|
+
wnd.document.getElementById(CJK_VERTICAL_SNAPPER_STYLE_ID)?.remove();
|
|
323
|
+
|
|
324
|
+
if (this.patternAnalyzer) {
|
|
325
|
+
this.patternAnalyzer.clear();
|
|
326
|
+
this.patternAnalyzer = null;
|
|
327
|
+
this.isScrollProtectionEnabled = false;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
comms.log("CJKVerticalSnapper Unmounted");
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
}
|