@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.
- package/README.MD +13 -0
- package/dist/index.js +3144 -0
- package/dist/index.umd.cjs +69 -0
- package/package.json +58 -0
- package/src/Loader.ts +85 -0
- package/src/comms/comms.ts +168 -0
- package/src/comms/index.ts +3 -0
- package/src/comms/keys.ts +42 -0
- package/src/comms/mid.ts +5 -0
- package/src/helpers/animation.ts +5 -0
- package/src/helpers/css.ts +16 -0
- package/src/helpers/document.ts +43 -0
- package/src/helpers/dom.ts +126 -0
- package/src/helpers/locator.ts +67 -0
- package/src/helpers/rect.ts +301 -0
- package/src/helpers/scrollSnapperHelper.ts +43 -0
- package/src/index.ts +3 -0
- package/src/modules/Decorator.ts +462 -0
- package/src/modules/Module.ts +23 -0
- package/src/modules/ModuleLibrary.ts +44 -0
- package/src/modules/Peripherals.ts +148 -0
- package/src/modules/index.ts +3 -0
- package/src/modules/setup/FixedSetup.ts +73 -0
- package/src/modules/setup/ReflowableSetup.ts +65 -0
- package/src/modules/setup/Setup.ts +131 -0
- package/src/modules/snapper/ColumnSnapper.ts +468 -0
- package/src/modules/snapper/ScrollSnapper.ts +168 -0
- package/src/modules/snapper/Snapper.ts +49 -0
- package/src/vendor/approx-string-match/LICENSE +21 -0
- package/src/vendor/approx-string-match/README.MD +1 -0
- package/src/vendor/approx-string-match/index.ts +362 -0
- package/src/vendor/hypothesis/README.MD +1 -0
- package/src/vendor/hypothesis/anchoring/api-types.ts +309 -0
- package/src/vendor/hypothesis/anchoring/html.ts +134 -0
- package/src/vendor/hypothesis/anchoring/match-quote.ts +163 -0
- package/src/vendor/hypothesis/anchoring/placeholder.ts +59 -0
- package/src/vendor/hypothesis/anchoring/text-range.ts +327 -0
- package/src/vendor/hypothesis/anchoring/trim-range.ts +220 -0
- package/src/vendor/hypothesis/anchoring/types.ts +377 -0
- package/src/vendor/hypothesis/anchoring/xpath.ts +164 -0
- package/src/vendor/hypothesis/tsconfig.json +33 -0
- package/src/vendor/hypothesis/types/shared.ts +40 -0
- package/types/src/Loader.d.ts +33 -0
- package/types/src/comms/comms.d.ts +40 -0
- package/types/src/comms/index.d.ts +3 -0
- package/types/src/comms/keys.d.ts +2 -0
- package/types/src/comms/mid.d.ts +1 -0
- package/types/src/helpers/animation.d.ts +1 -0
- package/types/src/helpers/css.d.ts +4 -0
- package/types/src/helpers/document.d.ts +8 -0
- package/types/src/helpers/dom.d.ts +15 -0
- package/types/src/helpers/locator.d.ts +2 -0
- package/types/src/helpers/rect.d.ts +10 -0
- package/types/src/helpers/scrollSnapperHelper.d.ts +5 -0
- package/types/src/index.d.ts +3 -0
- package/types/src/modules/Decorator.d.ts +43 -0
- package/types/src/modules/Module.d.ts +11 -0
- package/types/src/modules/ModuleLibrary.d.ts +5 -0
- package/types/src/modules/Peripherals.d.ts +39 -0
- package/types/src/modules/ReflowablePeripherals.d.ts +37 -0
- package/types/src/modules/index.d.ts +3 -0
- package/types/src/modules/setup/FixedSetup.d.ts +9 -0
- package/types/src/modules/setup/ReflowableSetup.d.ts +9 -0
- package/types/src/modules/setup/Setup.d.ts +17 -0
- package/types/src/modules/snapper/ColumnSnapper.d.ts +41 -0
- package/types/src/modules/snapper/ScrollSnapper.d.ts +16 -0
- package/types/src/modules/snapper/Snapper.d.ts +11 -0
- package/types/src/vendor/approx-string-match/index.d.ts +54 -0
- package/types/src/vendor/hypothesis/anchoring/api-types.d.ts +266 -0
- package/types/src/vendor/hypothesis/anchoring/html.d.ts +17 -0
- package/types/src/vendor/hypothesis/anchoring/match-quote.d.ts +30 -0
- package/types/src/vendor/hypothesis/anchoring/placeholder.d.ts +32 -0
- package/types/src/vendor/hypothesis/anchoring/text-range.d.ts +103 -0
- package/types/src/vendor/hypothesis/anchoring/trim-range.d.ts +17 -0
- package/types/src/vendor/hypothesis/anchoring/types.d.ts +102 -0
- package/types/src/vendor/hypothesis/anchoring/xpath.d.ts +15 -0
- 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
|
+
}
|