@readium/navigator-html-injectables 1.2.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 (77) hide show
  1. package/README.MD +13 -0
  2. package/dist/index.js +3144 -0
  3. package/dist/index.umd.cjs +69 -0
  4. package/package.json +58 -0
  5. package/src/Loader.ts +85 -0
  6. package/src/comms/comms.ts +168 -0
  7. package/src/comms/index.ts +3 -0
  8. package/src/comms/keys.ts +42 -0
  9. package/src/comms/mid.ts +5 -0
  10. package/src/helpers/animation.ts +5 -0
  11. package/src/helpers/css.ts +16 -0
  12. package/src/helpers/document.ts +43 -0
  13. package/src/helpers/dom.ts +126 -0
  14. package/src/helpers/locator.ts +67 -0
  15. package/src/helpers/rect.ts +301 -0
  16. package/src/helpers/scrollSnapperHelper.ts +43 -0
  17. package/src/index.ts +3 -0
  18. package/src/modules/Decorator.ts +462 -0
  19. package/src/modules/Module.ts +23 -0
  20. package/src/modules/ModuleLibrary.ts +44 -0
  21. package/src/modules/Peripherals.ts +148 -0
  22. package/src/modules/index.ts +3 -0
  23. package/src/modules/setup/FixedSetup.ts +73 -0
  24. package/src/modules/setup/ReflowableSetup.ts +65 -0
  25. package/src/modules/setup/Setup.ts +131 -0
  26. package/src/modules/snapper/ColumnSnapper.ts +468 -0
  27. package/src/modules/snapper/ScrollSnapper.ts +168 -0
  28. package/src/modules/snapper/Snapper.ts +49 -0
  29. package/src/vendor/approx-string-match/LICENSE +21 -0
  30. package/src/vendor/approx-string-match/README.MD +1 -0
  31. package/src/vendor/approx-string-match/index.ts +362 -0
  32. package/src/vendor/hypothesis/README.MD +1 -0
  33. package/src/vendor/hypothesis/anchoring/api-types.ts +309 -0
  34. package/src/vendor/hypothesis/anchoring/html.ts +134 -0
  35. package/src/vendor/hypothesis/anchoring/match-quote.ts +163 -0
  36. package/src/vendor/hypothesis/anchoring/placeholder.ts +59 -0
  37. package/src/vendor/hypothesis/anchoring/text-range.ts +327 -0
  38. package/src/vendor/hypothesis/anchoring/trim-range.ts +220 -0
  39. package/src/vendor/hypothesis/anchoring/types.ts +377 -0
  40. package/src/vendor/hypothesis/anchoring/xpath.ts +164 -0
  41. package/src/vendor/hypothesis/tsconfig.json +33 -0
  42. package/src/vendor/hypothesis/types/shared.ts +40 -0
  43. package/types/src/Loader.d.ts +33 -0
  44. package/types/src/comms/comms.d.ts +40 -0
  45. package/types/src/comms/index.d.ts +3 -0
  46. package/types/src/comms/keys.d.ts +2 -0
  47. package/types/src/comms/mid.d.ts +1 -0
  48. package/types/src/helpers/animation.d.ts +1 -0
  49. package/types/src/helpers/css.d.ts +4 -0
  50. package/types/src/helpers/document.d.ts +8 -0
  51. package/types/src/helpers/dom.d.ts +15 -0
  52. package/types/src/helpers/locator.d.ts +2 -0
  53. package/types/src/helpers/rect.d.ts +10 -0
  54. package/types/src/helpers/scrollSnapperHelper.d.ts +5 -0
  55. package/types/src/index.d.ts +3 -0
  56. package/types/src/modules/Decorator.d.ts +43 -0
  57. package/types/src/modules/Module.d.ts +11 -0
  58. package/types/src/modules/ModuleLibrary.d.ts +5 -0
  59. package/types/src/modules/Peripherals.d.ts +39 -0
  60. package/types/src/modules/ReflowablePeripherals.d.ts +37 -0
  61. package/types/src/modules/index.d.ts +3 -0
  62. package/types/src/modules/setup/FixedSetup.d.ts +9 -0
  63. package/types/src/modules/setup/ReflowableSetup.d.ts +9 -0
  64. package/types/src/modules/setup/Setup.d.ts +17 -0
  65. package/types/src/modules/snapper/ColumnSnapper.d.ts +41 -0
  66. package/types/src/modules/snapper/ScrollSnapper.d.ts +16 -0
  67. package/types/src/modules/snapper/Snapper.d.ts +11 -0
  68. package/types/src/vendor/approx-string-match/index.d.ts +54 -0
  69. package/types/src/vendor/hypothesis/anchoring/api-types.d.ts +266 -0
  70. package/types/src/vendor/hypothesis/anchoring/html.d.ts +17 -0
  71. package/types/src/vendor/hypothesis/anchoring/match-quote.d.ts +30 -0
  72. package/types/src/vendor/hypothesis/anchoring/placeholder.d.ts +32 -0
  73. package/types/src/vendor/hypothesis/anchoring/text-range.d.ts +103 -0
  74. package/types/src/vendor/hypothesis/anchoring/trim-range.d.ts +17 -0
  75. package/types/src/vendor/hypothesis/anchoring/types.d.ts +102 -0
  76. package/types/src/vendor/hypothesis/anchoring/xpath.d.ts +15 -0
  77. package/types/src/vendor/hypothesis/types/shared.d.ts +32 -0
@@ -0,0 +1,462 @@
1
+ import { Locator } from "@readium/shared";
2
+ import { Comms } from "../comms/comms";
3
+ import { Module } from "./Module";
4
+ import { rangeFromLocator } from "../helpers/locator";
5
+ import { ModuleName } from "./ModuleLibrary";
6
+ import { Rect, getClientRectsNoOverlap } from "../helpers/rect";
7
+ import { ResizeObserver as Polyfill } from '@juggle/resize-observer';
8
+ import { getProperty } from "../helpers/css";
9
+ import { ReadiumWindow } from "../helpers/dom";
10
+
11
+ // Necessary for iOS 13 and below
12
+ const ResizeObserver = window.ResizeObserver || Polyfill;
13
+
14
+ export enum Width {
15
+ Wrap = "wrap", // Smallest width fitting the CSS border box.
16
+ Viewport = "viewport", // Fills the whole viewport.
17
+ Bounds = "bounds", // Fills the anchor page, useful for dual page.
18
+ Page = "page", // Fills the whole viewport.
19
+ }
20
+
21
+ export enum Layout {
22
+ Boxes = "boxes", // One HTML element for each CSS border box (e.g. line of text).
23
+ Bounds = "bounds", // A single HTML element covering the smallest region containing all CSS border boxes.
24
+ }
25
+
26
+ // TODO improve
27
+ export interface Style {
28
+ tint: string; // CSS color string
29
+ layout: Layout; // Determines the number of created HTML elements and their position relative to the matching DOM range.
30
+ width: Width; // Indicates how the width of each created HTML element expands in the viewport.
31
+ }
32
+
33
+ export interface Decoration {
34
+ id: string; // Unique ID of the decoration. It must be unique in the group the decoration is applied to.
35
+ locator: Locator; // Location in the publication where the decoration will be rendered.
36
+ style: Style; // Declares the look and feel of the decoration.
37
+ // TODO extras (userInfo)
38
+ }
39
+
40
+ export interface DecoratorRequest {
41
+ group: string; // Unique ID of the decoration group
42
+ action: "add" | "remove" | "clear" | "update"; // Command
43
+ decoration: Decoration | undefined;
44
+ }
45
+
46
+ interface DecorationItem {
47
+ id: string;
48
+ decoration: Decoration;
49
+ range: Range;
50
+
51
+ clickableElements: HTMLElement[] | undefined;
52
+ container: HTMLElement | undefined;
53
+ }
54
+
55
+ const canNativeHighlight = "Highlight" in window;
56
+ const cannotNativeHighlight = ["IMG", "IMAGE", "AUDIO", "VIDEO", "SVG"];
57
+
58
+ class DecorationGroup {
59
+ public readonly items: DecorationItem[] = [];
60
+ private lastItemId = 0;
61
+ private container: HTMLDivElement | undefined = undefined;
62
+ private activateable = false;
63
+ public readonly experimentalHighlights: boolean = false;
64
+ private readonly notTextFlag: Map<string, boolean> | undefined;
65
+
66
+ /**
67
+ * Creates a DecorationGroup object
68
+ * @param id Unique HTML ID-adhering name of the group
69
+ * @param name Human-readable name of the group
70
+ */
71
+ constructor(
72
+ private readonly wnd: ReadiumWindow,
73
+ private readonly comms: Comms,
74
+ private readonly id: string,
75
+ private readonly name: string
76
+ ) {
77
+ if (canNativeHighlight) {
78
+ this.experimentalHighlights = true;
79
+ this.notTextFlag = new Map<string, boolean>();
80
+ }
81
+ }
82
+
83
+ get activeable() {
84
+ return this.activateable;
85
+ }
86
+
87
+ set activeable(value: boolean) {
88
+ this.activateable = value;
89
+ }
90
+
91
+ /**
92
+ * Adds a new decoration to the group.
93
+ * @param decoration Decoration to add
94
+ */
95
+ add(decoration: Decoration) {
96
+ const id = `${this.id}-${this.lastItemId++}`;
97
+
98
+ const range = rangeFromLocator(this.wnd.document, decoration.locator);
99
+ if (!range) {
100
+ this.comms.log("Can't locate DOM range for decoration", decoration);
101
+ return;
102
+ }
103
+ const ancestor = range.commonAncestorContainer as HTMLElement;
104
+ if(ancestor.nodeType !== Node.TEXT_NODE && this.experimentalHighlights) {
105
+ if(cannotNativeHighlight.includes(ancestor.nodeName.toUpperCase())) {
106
+ // The common ancestor is an element that definitely cannot be highlighted
107
+ this.notTextFlag?.set(id, true);
108
+ }
109
+ if(ancestor.querySelector(cannotNativeHighlight.join(", ").toLowerCase())) {
110
+ // Contains elements that definitely cannot be highlighted as children
111
+ this.notTextFlag?.set(id, true);
112
+ }
113
+ if((ancestor.textContent?.trim() || "").length === 0) {
114
+ // No text to be highlighted
115
+ this.notTextFlag?.set(id, true);
116
+ }
117
+ }
118
+
119
+ const item = {
120
+ decoration,
121
+ id,
122
+ range,
123
+ } as DecorationItem;
124
+
125
+ this.items.push(item);
126
+ this.layout(item);
127
+ this.renderLayout([item]);
128
+ }
129
+
130
+ /**
131
+ * Removes the decoration with given ID from the group.
132
+ * @param identifier ID of item to remove
133
+ */
134
+ remove(identifier: string) {
135
+ const index = this.items.findIndex(i => i.decoration.id === identifier);
136
+ if (index < 0) return;
137
+
138
+ const item = this.items[index];
139
+ this.items.splice(index, 1);
140
+ item.clickableElements = undefined;
141
+ if (item.container) {
142
+ item.container.remove();
143
+ item.container = undefined;
144
+ }
145
+ if (this.experimentalHighlights && !this.notTextFlag?.has(item.id)) {
146
+ // Remove highlight from ranges
147
+ const mm = ((this.wnd as any).CSS.highlights as Map<string, unknown>).get(this.id) as Set<Range>;
148
+ mm?.delete(item.range);
149
+ }
150
+ this.notTextFlag?.delete(item.id);
151
+ }
152
+
153
+ /**
154
+ * Notifies that the given decoration was modified and needs to be updated.
155
+ * @param decoration Decoration to update
156
+ */
157
+ update(decoration: Decoration) {
158
+ this.remove(decoration.id);
159
+ this.add(decoration);
160
+ }
161
+
162
+ /**
163
+ * Removes all decorations from this group.
164
+ */
165
+ clear() {
166
+ this.clearContainer();
167
+ this.items.length = 0;
168
+ this.notTextFlag?.clear();
169
+ }
170
+
171
+ /**
172
+ * Recreates the decoration elements.
173
+ * To be called after reflowing the resource, for example.
174
+ */
175
+ requestLayout() {
176
+ this.wnd.cancelAnimationFrame(this.currentRender);
177
+ this.clearContainer();
178
+ this.items.forEach(i => this.layout(i));
179
+ this.renderLayout(this.items);
180
+ }
181
+
182
+ private experimentalLayout(item: DecorationItem) {
183
+ const [stylesheet, highlighter]: [HTMLStyleElement, any] = this.requireContainer(true) as [HTMLStyleElement, unknown];
184
+ highlighter.add(item.range);
185
+
186
+ // TODO add caching layer ("vdom") to this so we aren't completely replacing the CSS every time
187
+ stylesheet.innerHTML = `
188
+ ::highlight(${this.id}) {
189
+ color: black;
190
+ background-color: ${item.decoration?.style?.tint ?? "yellow"};
191
+ }`;
192
+ }
193
+
194
+ /**
195
+ * Layouts a single DecorationItem.
196
+ * @param item
197
+ */
198
+ private layout(item: DecorationItem) {
199
+ if (this.experimentalHighlights && !this.notTextFlag?.has(item.id)) {
200
+ // Highlight using the new Highlight Web API!
201
+ return this.experimentalLayout(item);
202
+ }
203
+ // this.comms.log("Environment does not support experimental Web Highlight API, can't layout decorations");
204
+
205
+ const itemContainer = this.wnd.document.createElement("div");
206
+ itemContainer.setAttribute("id", item.id);
207
+ // itemContainer.dataset.style = item.decoration.style; // TODO style
208
+ itemContainer.style.setProperty("pointer-events", "none");
209
+
210
+ const viewportWidth = this.wnd.innerWidth;
211
+ const columnCount = parseInt(
212
+ getComputedStyle(this.wnd.document.documentElement).getPropertyValue(
213
+ "column-count"
214
+ )
215
+ );
216
+ const pageWidth = viewportWidth / (columnCount || 1);
217
+ const scrollingElement = this.wnd.document.scrollingElement!;
218
+ const xOffset = scrollingElement.scrollLeft;
219
+ const yOffset = scrollingElement.scrollTop;
220
+
221
+ const positionElement = (element: HTMLElement, rect: Rect, boundingRect: DOMRect) => {
222
+ element.style.position = "absolute";
223
+
224
+ // TODO change to switch
225
+ if (item.decoration?.style?.width === Width.Viewport) {
226
+ element.style.width = `${viewportWidth}px`;
227
+ element.style.height = `${rect.height}px`;
228
+ let left = Math.floor(rect.left / viewportWidth) * viewportWidth;
229
+ element.style.left = `${left + xOffset}px`;
230
+ element.style.top = `${rect.top + yOffset}px`;
231
+ } else if (item.decoration?.style?.width === Width.Bounds) {
232
+ element.style.width = `${boundingRect.width}px`;
233
+ element.style.height = `${rect.height}px`;
234
+ element.style.left = `${boundingRect.left + xOffset}px`;
235
+ element.style.top = `${rect.top + yOffset}px`;
236
+ } else if (item.decoration?.style?.width === Width.Page) {
237
+ element.style.width = `${pageWidth}px`;
238
+ element.style.height = `${rect.height}px`;
239
+ let left = Math.floor(rect.left / pageWidth) * pageWidth;
240
+ element.style.left = `${left + xOffset}px`;
241
+ element.style.top = `${rect.top + yOffset}px`;
242
+ } else {
243
+ // Fall back to "wrap"
244
+ element.style.width = `${rect.width}px`;
245
+ element.style.height = `${rect.height}px`;
246
+ element.style.left = `${rect.left + xOffset}px`;
247
+ element.style.top = `${rect.top + yOffset}px`;
248
+ }
249
+ }
250
+
251
+ const boundingRect = item.range.getBoundingClientRect();
252
+
253
+ let template = this.wnd.document.createElement("template");
254
+ // template.innerHTML = item.decoration.element.trim();
255
+ // TODO more styles logic
256
+
257
+ const isDarkMode = getProperty(this.wnd, "--USER__appearance") === "readium-night-on";
258
+
259
+ template.innerHTML = `
260
+ <div
261
+ class="r2-highlight-0"
262
+ style="${[
263
+ `background-color: ${item.decoration?.style?.tint ?? "yellow"} !important`,
264
+ //"opacity: 0.3 !important",
265
+ `mix-blend-mode: ${isDarkMode ? "exclusion" : "multiply"} !important`,
266
+ "opacity: 1 !important",
267
+ "box-sizing: border-box !important"
268
+ ].join("; ")}"
269
+ >
270
+ </div>
271
+ `.trim();
272
+ const elementTemplate = template.content.firstElementChild!;
273
+
274
+ if(item.decoration?.style?.layout === Layout.Bounds) {
275
+ const bounds = elementTemplate.cloneNode(true) as HTMLDivElement;
276
+ bounds.style.setProperty("pointer-events", "none");
277
+ positionElement(bounds, boundingRect, boundingRect);
278
+ itemContainer.append(bounds);
279
+ } else {
280
+ // Fall back to "boxes" value for layout
281
+ let clientRects = getClientRectsNoOverlap(
282
+ item.range,
283
+ true // doNotMergeHorizontallyAlignedRects
284
+ );
285
+
286
+ clientRects = clientRects.sort((r1, r2) => {
287
+ if (r1.top < r2.top) {
288
+ return -1;
289
+ } else if (r1.top > r2.top) {
290
+ return 1;
291
+ } else {
292
+ return 0;
293
+ }
294
+ });
295
+
296
+ for (let clientRect of clientRects) {
297
+ const line = elementTemplate.cloneNode(true) as HTMLDivElement;
298
+ line.style.setProperty("pointer-events", "none");
299
+ positionElement(line, clientRect, boundingRect);
300
+ itemContainer.append(line);
301
+ }
302
+ }
303
+
304
+ item.container = itemContainer;
305
+ item.clickableElements = Array.from(
306
+ itemContainer.querySelectorAll("[data-activable='1']")
307
+ );
308
+ if(!item.clickableElements.length) {
309
+ item.clickableElements = Array.from(itemContainer.children) as HTMLElement[];
310
+ }
311
+ }
312
+
313
+ private currentRender = 0;
314
+ private renderLayout(items: DecorationItem[]) {
315
+ this.wnd.cancelAnimationFrame(this.currentRender);
316
+ this.currentRender = this.wnd.requestAnimationFrame(() => {
317
+ items = items.filter(i => !this.experimentalHighlights || !!this.notTextFlag?.has(i.id));
318
+ if(!items || items.length === 0) return;
319
+ const groupContainer = this.requireContainer() as HTMLDivElement;
320
+ groupContainer.append(...items.map(i => i.container).filter(c => !!c) as Node[])
321
+ });
322
+ }
323
+
324
+ /**
325
+ * Returns the group container element, after making sure it exists.
326
+ * @returns Group's container
327
+ */
328
+ private requireContainer(experimental=false): [HTMLStyleElement, any] | HTMLDivElement {
329
+ if (experimental) {
330
+ // Setup <style> for highlights
331
+ let d: HTMLStyleElement;
332
+ if (this.wnd.document.getElementById(`${this.id}-style`)) {
333
+ d = this.wnd.document.getElementById(`${this.id}-style`) as HTMLStyleElement;
334
+ } else {
335
+ d = this.wnd.document.createElement("style");
336
+ d.dataset.readium = "true";
337
+ d.id = `${this.id}-style`;
338
+ this.wnd.document.head.appendChild(d);
339
+ }
340
+
341
+ // Setup CSS.highlights
342
+ let h: unknown;
343
+ if (((this.wnd as any).CSS.highlights as Map<string, unknown>).has(this.id)) {
344
+ h = ((this.wnd as any).CSS.highlights as Map<string, unknown>).get(this.id)
345
+ } else {
346
+ h = new (this.wnd as any).Highlight();
347
+ ((this.wnd as any).CSS.highlights as Map<string, unknown>).set(this.id, h);
348
+ }
349
+ return [d, h];
350
+ }
351
+
352
+ if (!this.container) {
353
+ this.container = this.wnd.document.createElement("div");
354
+ this.container.setAttribute("id", this.id);
355
+ this.container.dataset.group = this.name;
356
+ this.container.dataset.readium = "true";
357
+ this.container.style.setProperty("pointer-events", "none");
358
+ this.container.style.display = "contents";
359
+ this.wnd.document.body.append(this.container);
360
+ }
361
+ return this.container;
362
+ }
363
+
364
+ /**
365
+ * Removes the group container.
366
+ */
367
+ private clearContainer() {
368
+ if (this.experimentalHighlights) {
369
+ ((this.wnd as any).CSS.highlights as Map<string, unknown>).delete(this.id);
370
+ }
371
+ if (this.container) {
372
+ this.container.remove();
373
+ this.container = undefined;
374
+ }
375
+ }
376
+ }
377
+
378
+ export class Decorator extends Module {
379
+ static readonly moduleName: ModuleName = "decorator";
380
+ private resizeObserver!: ResizeObserver;
381
+ private wnd!: ReadiumWindow;
382
+ /*private readonly lastSize = {
383
+ width: 0,
384
+ height: 0
385
+ };*/
386
+ private resizeFrame = 0;
387
+
388
+ private lastGroupId = 0;
389
+ private groups = new Map<string, DecorationGroup>();
390
+
391
+ private cleanup() {
392
+ // TODO cleanup all decorators
393
+ this.groups.forEach(g => g.clear());
394
+ this.groups.clear();
395
+ }
396
+
397
+ private handleResize() {
398
+ this.wnd.clearTimeout(this.resizeFrame);
399
+ this.resizeFrame = this.wnd.setTimeout(() => {
400
+ this.groups.forEach(g => {
401
+ if(!g.experimentalHighlights) g.requestLayout();
402
+ });
403
+ }, 50);
404
+ }
405
+ private readonly handleResizer = this.handleResize.bind(this);
406
+
407
+ mount(wnd: ReadiumWindow, comms: Comms): boolean {
408
+ this.wnd = wnd;
409
+
410
+ comms.register("decorate", Decorator.moduleName, (data, ack) => {
411
+ const req = data as DecoratorRequest;
412
+ if (req.decoration && req.decoration.locator) {
413
+ req.decoration.locator = Locator.deserialize(req.decoration.locator)!;
414
+ }
415
+ if (!this.groups.has(req.group)) {
416
+ this.groups.set(req.group, new DecorationGroup(
417
+ wnd,
418
+ comms,
419
+ `readium-decoration-${this.lastGroupId++}`,
420
+ req.group
421
+ ));
422
+ }
423
+ const group = this.groups.get(req.group);
424
+ switch (req.action) {
425
+ case "add":
426
+ group?.add(req.decoration!);
427
+ break;
428
+ case "remove":
429
+ group?.remove(req.decoration!.id);
430
+ break;
431
+ case "clear":
432
+ group?.clear();
433
+ break;
434
+ case "update":
435
+ group?.update(req.decoration!);
436
+ break;
437
+ }
438
+
439
+ ack(true);
440
+ });
441
+
442
+ this.resizeObserver = new ResizeObserver(() => wnd.requestAnimationFrame(() => this.handleResize()));
443
+ this.resizeObserver.observe(wnd.document.body);
444
+ wnd.addEventListener("orientationchange", this.handleResizer);
445
+ wnd.addEventListener("resize", this.handleResizer);
446
+
447
+ comms.log("Decorator Mounted");
448
+ return true;
449
+ }
450
+
451
+ unmount(wnd: ReadiumWindow, comms: Comms): boolean {
452
+ wnd.removeEventListener("orientationchange", this.handleResizer);
453
+ wnd.removeEventListener("resize", this.handleResizer);
454
+
455
+ comms.unregisterAll(Decorator.moduleName);
456
+ this.resizeObserver.disconnect();
457
+ this.cleanup();
458
+
459
+ comms.log("Decorator Unmounted");
460
+ return true;
461
+ }
462
+ }
@@ -0,0 +1,23 @@
1
+ import { Comms } from "../comms";
2
+ import { ReadiumWindow } from "../helpers/dom";
3
+ import { ModuleName } from "./ModuleLibrary";
4
+
5
+ export abstract class Module {
6
+ static readonly moduleName: ModuleName; // snake_case name of the module, must be unique.
7
+
8
+ // Called by the loader, where modules are mounted one after another.
9
+ // In this function, the Module can register any commands it should listen
10
+ // to using comms.register, and send any commands using comms.send. Modules
11
+ // must be designed to not use comms.send before the communication handshake
12
+ // has finished (so not during the mount function).
13
+ abstract mount(wnd: ReadiumWindow, comms: Comms): boolean;
14
+
15
+ // Unmounting should completely clean up any event listeners or
16
+ // active modification of the HTML content, and cease all activity. This is
17
+ // because an implementer should be able to mount and unmount Modules at any
18
+ // time, to, for example, switch reading modes. There should be no difference
19
+ // in behavior after mounting and unmounting a Module repeatedly.
20
+ abstract unmount(wnd: ReadiumWindow, comms: Comms): boolean;
21
+ }
22
+
23
+ export type ModuleDerived = {new (): Module} & typeof Module;
@@ -0,0 +1,44 @@
1
+ import { ModuleDerived } from "./Module";
2
+ import { ColumnSnapper } from "./snapper/ColumnSnapper";
3
+ import { ScrollSnapper } from "./snapper/ScrollSnapper";
4
+ import { Peripherals } from "./Peripherals";
5
+ import { ReflowableSetup } from "./setup/ReflowableSetup";
6
+ import { FixedSetup } from "./setup/FixedSetup";
7
+ import { Decorator } from "./Decorator";
8
+
9
+ // All the module names. TODO: Come up with a better way of collecting these in a way TS will recognize
10
+ export type ModuleName =
11
+ "setup" |
12
+ "snapper" |
13
+ "column_snapper" |
14
+ "scroll_snapper" |
15
+ "fixed_setup" |
16
+ "decorator" |
17
+ "reflowable_setup" |
18
+ "peripherals";
19
+
20
+ // Modules that are valid for FXL publications
21
+ export const FXLModules: ModuleName[] = [
22
+ "fixed_setup",
23
+ "decorator",
24
+ "peripherals"
25
+ ];
26
+
27
+ // Modules that are valid for reflowable publications
28
+ export const ReflowableModules: ModuleName[] = [
29
+ "reflowable_setup",
30
+ "decorator",
31
+ "peripherals",
32
+ "column_snapper",
33
+ "scroll_snapper"
34
+ ];
35
+
36
+ export const ModuleLibrary = new Map<string, ModuleDerived>([
37
+ // All modules go here
38
+ FixedSetup,
39
+ ReflowableSetup,
40
+ Peripherals,
41
+ Decorator,
42
+ ColumnSnapper,
43
+ ScrollSnapper,
44
+ ].map(m => [m.moduleName, m])); // Turn module list into K/V list for quick access by name
@@ -0,0 +1,148 @@
1
+ import { Comms } from "../comms/comms";
2
+ import { Module } from "./Module";
3
+ import { ReadiumWindow, nearestInteractiveElement } from "../helpers/dom";
4
+
5
+ export interface FrameClickEvent {
6
+ defaultPrevented: boolean;
7
+ doNotDisturb: boolean;
8
+ interactiveElement: string | undefined;
9
+ cssSelector: string | undefined;
10
+ targetElement: string;
11
+ targetFrameSrc: string;
12
+ x: number;
13
+ y: number;
14
+ }
15
+
16
+ export interface BasicTextSelection {
17
+ text: string;
18
+ x: number;
19
+ y: number;
20
+ width: number;
21
+ height: number;
22
+ targetFrameSrc: string;
23
+ }
24
+
25
+ export class Peripherals extends Module {
26
+ static readonly moduleName = "peripherals";
27
+ private wnd!: ReadiumWindow;
28
+ private comms!: Comms;
29
+
30
+ private pointerMoved = false;
31
+
32
+ onPointUp(event: PointerEvent) {
33
+ const selection = this.wnd.getSelection();
34
+ if (!!selection && selection.toString()?.length > 0) {
35
+ const domRectList = selection.getRangeAt(0)?.getClientRects();
36
+ // Sanity check to avoid sending empty selections
37
+ if (!domRectList || domRectList.length === 0) {
38
+ return;
39
+ }
40
+ const rect = domRectList[0];
41
+ const textSelection = {
42
+ text: selection.toString(),
43
+ x: rect.x,
44
+ y: rect.y,
45
+ width: rect.width,
46
+ height: rect.height,
47
+ targetFrameSrc: this.wnd?.location?.href,
48
+ }
49
+ this.comms.send("text_selected", textSelection as BasicTextSelection);
50
+ }
51
+ if (this.pointerMoved) {
52
+ // If the pointer moved while tapping it's not a tap to consider
53
+ this.pointerMoved = false;
54
+ return;
55
+ }
56
+
57
+ if(!selection?.isCollapsed)
58
+ // There's an ongoing selection, the tap will dismiss it so we don't forward it.
59
+ return;
60
+
61
+ // if(handleDecorationClickEvent) // TODO handle clicking on decorators
62
+ // return;
63
+ if(!event.isPrimary) return;
64
+
65
+ const pixelRatio = this.wnd.devicePixelRatio;
66
+ event.preventDefault(); // May have side-effects
67
+ this.comms.send(event.pointerType === "touch" ? "tap" : "click", {
68
+ defaultPrevented: event.defaultPrevented,
69
+ x: event.clientX * pixelRatio,
70
+ y: event.clientY * pixelRatio,
71
+ targetFrameSrc: this.wnd.location.href,
72
+ targetElement: (event.target as Element).outerHTML,
73
+ interactiveElement: nearestInteractiveElement(event.target as Element)?.outerHTML,
74
+ cssSelector: this.wnd._readium_cssSelectorGenerator.getCssSelector(event.target),
75
+ } as FrameClickEvent);
76
+
77
+ this.pointerMoved = false;
78
+ }
79
+ private readonly onPointerUp = this.onPointUp.bind(this);
80
+
81
+ onPointMove(event: PointerEvent) {
82
+ if(event.movementY !== undefined && event.movementX !== undefined) {
83
+ if(Math.abs(event.movementX) > 1 || Math.abs(event.movementY) > 1) {
84
+ this.pointerMoved = true;
85
+ }
86
+ return;
87
+ }
88
+
89
+ this.pointerMoved = true;
90
+ }
91
+ private readonly onPointerMove = this.onPointMove.bind(this);
92
+
93
+ onPointDown() {
94
+ this.pointerMoved = false;
95
+ }
96
+ private readonly onPointerDown = this.onPointDown.bind(this);
97
+
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
+ onClick(event: MouseEvent) {
106
+ event.preventDefault(); // To prevent certain browser actions. May have side-effects
107
+ if(!event.isTrusted) {
108
+ // Synthetic events (probably triggered by some JavaScript) are probably not going to
109
+ // also send a `pointerup` event, so we have to compensate by doing so for them
110
+ const synthEvent = new PointerEvent("pointerup", {
111
+ isPrimary: true,
112
+ pointerType: "mouse", // Not really a better choice than this
113
+ clientX: event.clientX,
114
+ clientY: event.clientY,
115
+ });
116
+ // 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 });
119
+ // Trigger `pointerup` event
120
+ this.onPointUp(synthEvent);
121
+ }
122
+ }
123
+ private readonly onClicker = this.onClick.bind(this);
124
+
125
+ mount(wnd: ReadiumWindow, comms: Comms): boolean {
126
+ this.wnd = wnd;
127
+ this.comms = comms;
128
+ wnd.document.addEventListener("pointerdown", this.onPointerDown);
129
+ wnd.document.addEventListener("pointerup", this.onPointerUp);
130
+ wnd.document.addEventListener("contextmenu", this.onContextMenu);
131
+ wnd.document.addEventListener("pointermove", this.onPointerMove);
132
+ wnd.document.addEventListener("click", this.onClicker);
133
+
134
+ comms.log("Peripherals Mounted");
135
+ return true;
136
+ }
137
+
138
+ unmount(wnd: ReadiumWindow, comms: Comms): boolean {
139
+ wnd.document.removeEventListener("pointerdown", this.onPointerDown);
140
+ wnd.document.removeEventListener("pointerup", this.onPointerUp);
141
+ wnd.document.removeEventListener("contextmenu", this.onContextMenu);
142
+ wnd.document.removeEventListener("pointermove", this.onPointerMove);
143
+ wnd.document.removeEventListener("click", this.onClicker);
144
+
145
+ comms.log("Peripherals Unmounted");
146
+ return true;
147
+ }
148
+ }
@@ -0,0 +1,3 @@
1
+ export * from './Module';
2
+ export * from './ModuleLibrary';
3
+ export * from './Peripherals';