@readium/navigator-html-injectables 2.3.1 → 2.4.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@readium/navigator-html-injectables",
3
- "version": "2.3.1",
3
+ "version": "2.4.0-beta.1",
4
4
  "type": "module",
5
5
  "description": "An embeddable solution for connecting frames of HTML publications with a Readium Navigator",
6
6
  "author": "readium",
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
@@ -1,7 +1,10 @@
1
1
  import { ReadiumWindow } from "./dom.ts";
2
2
 
3
3
  export function isRTL(wnd: ReadiumWindow): boolean {
4
- return wnd.document.body.dir.toLowerCase() === "rtl";
4
+ // Check body first (author-declared direction), then fall back to the
5
+ // root element where we inject dir="rtl" for RTL publications.
6
+ const dir = wnd.document.body.dir || wnd.document.documentElement.dir;
7
+ return dir.toLowerCase() === "rtl";
5
8
  }
6
9
 
7
10
  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,322 @@
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 } 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
+
58
+ private doc() {
59
+ return this.wnd.document.scrollingElement as HTMLElement;
60
+ }
61
+
62
+ /** Total horizontally scrollable distance (magnitude). */
63
+ private scrollable() {
64
+ return Math.max(0, this.doc().scrollWidth - this.wnd.innerWidth);
65
+ }
66
+
67
+ private reportProgress() {
68
+ if (!this.comms.ready) return;
69
+ const scrollWidth = this.doc().scrollWidth;
70
+ const viewportWidth = this.wnd.innerWidth;
71
+ const scrollable = Math.max(1, scrollWidth - viewportWidth);
72
+
73
+ // scrollLeft may be negative depending on the browser — normalize to a
74
+ // non-negative distance from the document start.
75
+ const norm = Math.abs(this.doc().scrollLeft);
76
+ const progress = Math.max(0, Math.min(1, norm / scrollable));
77
+ const viewportEnd = Math.max(0, Math.min(1, (norm + viewportWidth) / scrollWidth));
78
+
79
+ this.comms.send("progress", {
80
+ start: progress,
81
+ end: viewportEnd
82
+ });
83
+ }
84
+
85
+ private handleScroll = (_e: Event) => {
86
+ if (!this.comms.ready) return;
87
+ if (this.isResizing) return;
88
+
89
+ if (!this.initialScrollHandled) {
90
+ this.lastScrollLeft = Math.abs(this.doc().scrollLeft);
91
+ this.initialScrollHandled = true;
92
+ this.reportProgress();
93
+ return;
94
+ }
95
+
96
+ if (!this.isScrolling) {
97
+ this.isScrolling = true;
98
+ this.wnd.requestAnimationFrame(() => {
99
+ this.reportProgress();
100
+
101
+ const currentScrollLeft = Math.abs(this.doc().scrollLeft);
102
+ const deltaX = currentScrollLeft - this.lastScrollLeft;
103
+ this.lastScrollLeft = currentScrollLeft;
104
+
105
+ if (this.isScrollProtectionEnabled && Math.abs(deltaX) > 5) {
106
+ const now = Date.now();
107
+ const timeDelta = now - (this.lastScrollTime || now);
108
+ if (this.patternAnalyzer) {
109
+ const isSuspicious = this.patternAnalyzer.analyze(
110
+ // In vertical-rl, norm increases when scrolling forward (toward left/end),
111
+ // so deltaX > 0 = going forward.
112
+ deltaX > 0 ? "down" : "up",
113
+ Math.abs(deltaX),
114
+ timeDelta
115
+ );
116
+ if (isSuspicious) {
117
+ const target = _e.target && "tagName" in _e.target
118
+ ? { tagName: (_e.target as Element).tagName }
119
+ : null;
120
+ this.comms?.send("content_protection", {
121
+ type: "suspicious_scrolling",
122
+ timestamp: Date.now(),
123
+ scrollDelta: deltaX,
124
+ scrollDirection: deltaX > 0 ? "left" : "right",
125
+ targetElement: target
126
+ } as SuspiciousCJKScrollingEvent);
127
+ }
128
+ }
129
+ this.lastScrollTime = now;
130
+ }
131
+
132
+ this.comms.send("scroll", deltaX);
133
+ this.isScrolling = false;
134
+ });
135
+ }
136
+ };
137
+
138
+ private enableScrollProtection() {
139
+ if (!this.patternAnalyzer) {
140
+ this.patternAnalyzer = new PatternAnalyzer(SCROLL_PROTECTION_CONFIG);
141
+ this.isScrollProtectionEnabled = true;
142
+ this.comms?.log("Scroll protection enabled");
143
+ }
144
+ }
145
+
146
+ mount(wnd: ReadiumWindow, comms: Comms): boolean {
147
+ this.wnd = wnd;
148
+ this.comms = comms;
149
+
150
+ this.initialScrollHandled = false;
151
+ this.lastScrollLeft = 0;
152
+ this.isResizing = false;
153
+ if (this.resizeDebounce) {
154
+ this.wnd.clearTimeout(this.resizeDebounce);
155
+ this.resizeDebounce = null;
156
+ }
157
+
158
+ wnd.navigator.epubReadingSystem && (wnd.navigator.epubReadingSystem.layoutStyle = "scrolling");
159
+
160
+ // Hide scrollbar
161
+ const style = wnd.document.createElement("style");
162
+ style.dataset.readium = "true";
163
+ style.id = CJK_VERTICAL_SNAPPER_STYLE_ID;
164
+ style.textContent = `
165
+ * {
166
+ scrollbar-width: none;
167
+ }
168
+ body::-webkit-scrollbar {
169
+ display: none;
170
+ }
171
+ html {
172
+ overflow-x: auto !important;
173
+ overflow-y: hidden !important;
174
+ }
175
+ `;
176
+ wnd.document.head.appendChild(style);
177
+
178
+ this.resizeObserver = new ResizeObserver(() => {
179
+ if (this.resizeDebounce) {
180
+ this.wnd.clearTimeout(this.resizeDebounce);
181
+ }
182
+ this.isResizing = true;
183
+ this.resizeDebounce = this.wnd.setTimeout(() => {
184
+ this.isResizing = false;
185
+ this.resizeDebounce = null;
186
+ this.reportProgress();
187
+ }, 50);
188
+ });
189
+ this.resizeObserver.observe(wnd.document.body);
190
+
191
+ wnd.addEventListener("scroll", this.handleScroll, { passive: true });
192
+
193
+ comms.register("force_webkit_recalc", CJKVerticalSnapper.moduleName, () => {
194
+ forceWebkitRecalc(this.wnd);
195
+ const current = this.doc().scrollLeft;
196
+ if (Math.abs(current) > 1) {
197
+ this.doc().scrollLeft = current < 0 ? current + 1 : current - 1;
198
+ } else {
199
+ this.doc().scrollLeft = current < 0 ? current - 1 : current + 1;
200
+ }
201
+ this.doc().scrollLeft = current;
202
+ });
203
+
204
+ comms.register("go_progression", CJKVerticalSnapper.moduleName, (data, ack) => {
205
+ const position = data as number;
206
+ if (position < 0 || position > 1) {
207
+ comms.send("error", {
208
+ message: "go_progression must be given a position from 0.0 to 1.0"
209
+ });
210
+ ack(false);
211
+ return;
212
+ }
213
+ this.wnd.requestAnimationFrame(() => {
214
+ // position 0 = start (scrollLeft=0), position 1 = end (scrollLeft=-scrollable)
215
+ this.doc().scrollLeft = -(this.scrollable() * position);
216
+ this.reportProgress();
217
+ deselect(this.wnd);
218
+ ack(true);
219
+ });
220
+ });
221
+
222
+ comms.register("go_id", CJKVerticalSnapper.moduleName, (data, ack) => {
223
+ const element = wnd.document.getElementById(data as string);
224
+ if (!element) { ack(false); return; }
225
+ this.wnd.requestAnimationFrame(() => {
226
+ // getBoundingClientRect().left is in viewport coords; translate to scroll coords
227
+ this.doc().scrollLeft += element.getBoundingClientRect().left - wnd.innerWidth / 2;
228
+ this.reportProgress();
229
+ deselect(this.wnd);
230
+ ack(true);
231
+ });
232
+ });
233
+
234
+ comms.register("go_text", CJKVerticalSnapper.moduleName, (data: unknown | unknown[], ack) => {
235
+ let cssSelector = undefined;
236
+ if (Array.isArray(data)) {
237
+ if (data.length > 1) cssSelector = (data as unknown[])[1] as string;
238
+ data = data[0];
239
+ }
240
+ const text = LocatorText.deserialize(data);
241
+ const r = rangeFromLocator(this.wnd.document, new Locator({
242
+ href: wnd.location.href,
243
+ type: "text/html",
244
+ text,
245
+ locations: cssSelector ? new LocatorLocations({
246
+ otherLocations: new Map([["cssSelector", cssSelector]])
247
+ }) : undefined
248
+ }));
249
+ if (!r) { ack(false); return; }
250
+ this.wnd.requestAnimationFrame(() => {
251
+ this.doc().scrollLeft += r.getBoundingClientRect().left - wnd.innerWidth / 2;
252
+ this.reportProgress();
253
+ deselect(this.wnd);
254
+ ack(true);
255
+ });
256
+ });
257
+
258
+ // go_start = document start = right edge (scrollLeft = 0)
259
+ comms.register("go_start", CJKVerticalSnapper.moduleName, (_, ack) => {
260
+ if (this.doc().scrollLeft === 0) return ack(false);
261
+ this.doc().scrollLeft = 0;
262
+ this.reportProgress();
263
+ ack(true);
264
+ });
265
+
266
+ // go_end = document end = left edge. Write -scrollable; browsers normalize
267
+ // the sign internally so this works regardless of their scrollLeft convention.
268
+ comms.register("go_end", CJKVerticalSnapper.moduleName, (_, ack) => {
269
+ if (Math.abs(this.doc().scrollLeft) === this.scrollable()) return ack(false);
270
+ this.doc().scrollLeft = -this.scrollable();
271
+ this.reportProgress();
272
+ ack(true);
273
+ });
274
+
275
+ // Free-scroll: no intra-resource page stepping.
276
+ // The navigator will move to the next/previous spine item.
277
+ comms.register([
278
+ "go_next",
279
+ "go_prev",
280
+ ], CJKVerticalSnapper.moduleName, (_, ack) => ack(false));
281
+
282
+ comms.register("unfocus", CJKVerticalSnapper.moduleName, (_, ack) => {
283
+ deselect(this.wnd);
284
+ ack(true);
285
+ });
286
+
287
+ comms.register("scroll_protection", CJKVerticalSnapper.moduleName, (_, ack) => {
288
+ this.enableScrollProtection();
289
+ ack(true);
290
+ });
291
+
292
+ comms.register("focus", CJKVerticalSnapper.moduleName, (_, ack) => {
293
+ this.reportProgress();
294
+ ack(true);
295
+ });
296
+
297
+ comms.register("first_visible_locator", CJKVerticalSnapper.moduleName, (_, ack) => {
298
+ const locator = findFirstVisibleLocator(wnd as ReadiumWindow, true);
299
+ this.comms.send("first_visible_locator", locator.serialize());
300
+ ack(true);
301
+ });
302
+
303
+ comms.log("CJKVerticalSnapper Mounted");
304
+ return true;
305
+ }
306
+
307
+ unmount(wnd: ReadiumWindow, comms: Comms): boolean {
308
+ comms.unregisterAll(CJKVerticalSnapper.moduleName);
309
+ this.resizeObserver.disconnect();
310
+ if (this.handleScroll) wnd.removeEventListener("scroll", this.handleScroll);
311
+ wnd.document.getElementById(CJK_VERTICAL_SNAPPER_STYLE_ID)?.remove();
312
+
313
+ if (this.patternAnalyzer) {
314
+ this.patternAnalyzer.clear();
315
+ this.patternAnalyzer = null;
316
+ this.isScrollProtectionEnabled = false;
317
+ }
318
+
319
+ comms.log("CJKVerticalSnapper Unmounted");
320
+ return true;
321
+ }
322
+ }