@readium/navigator-html-injectables 2.4.4 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1604 -1177
- package/dist/index.umd.cjs +19 -18
- package/package.json +2 -1
- package/src/comms/keys.ts +3 -2
- package/src/helpers/color.ts +63 -0
- package/src/helpers/css.ts +29 -0
- package/src/helpers/document.ts +120 -0
- package/src/helpers/rect.ts +10 -5
- package/src/helpers/sML.ts +143 -0
- package/src/helpers/sanitize.ts +77 -0
- package/src/index.ts +2 -1
- package/src/modules/Decorator.ts +600 -151
- package/src/modules/Peripherals.ts +3 -0
- package/src/modules/snapper/ColumnSnapper.ts +2 -1
- package/types/src/comms/keys.d.ts +2 -2
- package/types/src/helpers/color.d.ts +1 -0
- package/types/src/helpers/css.d.ts +6 -0
- package/types/src/helpers/document.d.ts +66 -0
- package/types/src/helpers/rect.d.ts +1 -1
- package/types/src/helpers/sML.d.ts +56 -0
- package/types/src/helpers/sanitize.d.ts +13 -0
- package/types/src/index.d.ts +1 -0
- package/types/src/modules/Decorator.d.ts +62 -13
- package/types/src/modules/Peripherals.d.ts +3 -0
package/src/modules/Decorator.ts
CHANGED
|
@@ -3,45 +3,93 @@ import { Comms } from "../comms/comms.ts";
|
|
|
3
3
|
import { Module } from "./Module.ts";
|
|
4
4
|
import { rangeFromLocator } from "../helpers/locator.ts";
|
|
5
5
|
import { ModuleName } from "./ModuleLibrary.ts";
|
|
6
|
-
import { Rect, getClientRectsNoOverlap } from "../helpers/rect.ts";
|
|
6
|
+
import { Rect, getClientRectsNoOverlap, rectContainsPoint } from "../helpers/rect.ts";
|
|
7
7
|
import { getProperty } from "../helpers/css.ts";
|
|
8
8
|
import { ReadiumWindow } from "../helpers/dom.ts";
|
|
9
|
-
import { isDarkColor, getContrastingTextColor } from "../helpers/color.ts";
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
import { isDarkColor, getContrastingTextColor, adjustColorForContrast } from "../helpers/color.ts";
|
|
10
|
+
import { makeWritingContext } from "../helpers/document.ts";
|
|
11
|
+
import { sML } from "../helpers/sML.ts";
|
|
12
|
+
import { sanitizeHTML } from "../helpers/sanitize.ts";
|
|
13
|
+
|
|
14
|
+
function defaultTint(type: DecorationStyleType): string {
|
|
15
|
+
switch (type) {
|
|
16
|
+
case DecorationStyleType.Mask:
|
|
17
|
+
return "rgba(255, 255, 255, 0.5)";
|
|
18
|
+
case DecorationStyleType.Highlight:
|
|
19
|
+
return "#FFFF00";
|
|
20
|
+
default:
|
|
21
|
+
return "#FF0000";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
12
24
|
|
|
13
|
-
export
|
|
25
|
+
export const DecorationStyleType = {
|
|
26
|
+
Highlight: "highlight", // Background color overlay.
|
|
27
|
+
Underline: "underline", // Underline drawn beneath the text.
|
|
28
|
+
Outline: "outline", // Border drawn around the text boxes.
|
|
29
|
+
TextColor: "textColor", // Changes the text color directly.
|
|
30
|
+
Mask: "mask", // Dims everything outside the selection rects. Use width: Page for block-level behaviour.
|
|
31
|
+
Template: "template", // Custom HTML template (HTMLDecorationTemplate).
|
|
32
|
+
} as const;
|
|
33
|
+
export type DecorationStyleType = typeof DecorationStyleType[keyof typeof DecorationStyleType];
|
|
34
|
+
|
|
35
|
+
export enum DecorationWidth {
|
|
14
36
|
Wrap = "wrap", // Smallest width fitting the CSS border box.
|
|
15
37
|
Viewport = "viewport", // Fills the whole viewport.
|
|
16
|
-
Bounds = "bounds", // Fills the
|
|
17
|
-
Page = "page", // Fills the
|
|
38
|
+
Bounds = "bounds", // Fills the bounding region of all CSS border boxes.
|
|
39
|
+
Page = "page", // Fills the anchor page, useful for dual-page layouts.
|
|
18
40
|
}
|
|
19
41
|
|
|
20
|
-
export enum
|
|
42
|
+
export enum DecorationLayout {
|
|
21
43
|
Boxes = "boxes", // One HTML element for each CSS border box (e.g. line of text).
|
|
22
44
|
Bounds = "bounds", // A single HTML element covering the smallest region containing all CSS border boxes.
|
|
23
45
|
}
|
|
24
46
|
|
|
25
|
-
|
|
26
|
-
export interface
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
47
|
+
/** Built-in decoration styles. layout/width are optional overrides; defaults are Boxes/Wrap. */
|
|
48
|
+
export interface BuiltinDecorationStyle {
|
|
49
|
+
type?: Exclude<DecorationStyleType, "template">;
|
|
50
|
+
tint?: string;
|
|
51
|
+
layout?: DecorationLayout;
|
|
52
|
+
width?: DecorationWidth;
|
|
53
|
+
isActive?: boolean;
|
|
54
|
+
enforceContrast?: boolean; // When true (default), tint is adjusted for contrast against the background.
|
|
30
55
|
}
|
|
31
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Custom decoration style backed by caller-supplied HTML.
|
|
59
|
+
* Matches the HTMLDecorationTemplate class from the Readium spec.
|
|
60
|
+
* The element string is sanitized before injection.
|
|
61
|
+
* --readium-tint is injected as a CSS custom property on each created element.
|
|
62
|
+
*/
|
|
63
|
+
export interface HTMLDecorationTemplate {
|
|
64
|
+
type: "template";
|
|
65
|
+
layout: DecorationLayout;
|
|
66
|
+
width: DecorationWidth;
|
|
67
|
+
element: string;
|
|
68
|
+
stylesheet?: string;
|
|
69
|
+
isActive?: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type DecorationStyle = BuiltinDecorationStyle | HTMLDecorationTemplate;
|
|
73
|
+
|
|
32
74
|
export interface Decoration {
|
|
33
75
|
id: string; // Unique ID of the decoration. It must be unique in the group the decoration is applied to.
|
|
34
76
|
locator: Locator; // Location in the publication where the decoration will be rendered.
|
|
35
|
-
style:
|
|
36
|
-
//
|
|
77
|
+
style: DecorationStyle; // Declares the look and feel of the decoration.
|
|
78
|
+
extras?: Record<string, unknown>; // App-specific context data passed through to DecorationActivationEvent.
|
|
37
79
|
}
|
|
38
80
|
|
|
39
|
-
export interface
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
81
|
+
export interface DecorationActivatedEvent {
|
|
82
|
+
decorationId: string;
|
|
83
|
+
group: string; // Human-readable group name (matches DecoratorRequest.group).
|
|
84
|
+
rect: { top: number; left: number; width: number; height: number }; // Bounding rect in iframe client coords.
|
|
85
|
+
point: { x: number; y: number }; // Click point in iframe client coords.
|
|
43
86
|
}
|
|
44
87
|
|
|
88
|
+
export type DecoratorRequest =
|
|
89
|
+
| { group: string; action: "add" | "update"; decoration: Decoration }
|
|
90
|
+
| { group: string; action: "remove"; decoration: Pick<Decoration, "id"> }
|
|
91
|
+
| { group: string; action: "clear" };
|
|
92
|
+
|
|
45
93
|
interface DecorationItem {
|
|
46
94
|
id: string;
|
|
47
95
|
decoration: Decoration;
|
|
@@ -58,9 +106,13 @@ class DecorationGroup {
|
|
|
58
106
|
public readonly items: DecorationItem[] = [];
|
|
59
107
|
private lastItemId = 0;
|
|
60
108
|
private container: HTMLDivElement | undefined = undefined;
|
|
61
|
-
private
|
|
109
|
+
private _activatable = false;
|
|
62
110
|
public readonly experimentalHighlights: boolean = false;
|
|
63
111
|
private readonly notTextFlag: Map<string, boolean> | undefined;
|
|
112
|
+
private readonly activationHandler: (e: PointerEvent) => void;
|
|
113
|
+
private maskSvg: SVGSVGElement | undefined = undefined;
|
|
114
|
+
private shadowHost: HTMLDivElement | undefined = undefined;
|
|
115
|
+
private shadowRoot: ShadowRoot | undefined = undefined;
|
|
64
116
|
|
|
65
117
|
/**
|
|
66
118
|
* Creates a DecorationGroup object
|
|
@@ -77,14 +129,16 @@ class DecorationGroup {
|
|
|
77
129
|
this.experimentalHighlights = true;
|
|
78
130
|
this.notTextFlag = new Map<string, boolean>();
|
|
79
131
|
}
|
|
132
|
+
this.activationHandler = this.handleActivation.bind(this);
|
|
133
|
+
this.wnd.document.addEventListener("pointerup", this.activationHandler);
|
|
80
134
|
}
|
|
81
135
|
|
|
82
|
-
get
|
|
83
|
-
return this.
|
|
136
|
+
get activatable() {
|
|
137
|
+
return this._activatable;
|
|
84
138
|
}
|
|
85
139
|
|
|
86
|
-
set
|
|
87
|
-
this.
|
|
140
|
+
set activatable(value: boolean) {
|
|
141
|
+
this._activatable = value;
|
|
88
142
|
}
|
|
89
143
|
|
|
90
144
|
/**
|
|
@@ -116,6 +170,21 @@ class DecorationGroup {
|
|
|
116
170
|
this.notTextFlag?.set(id, true);
|
|
117
171
|
}
|
|
118
172
|
}
|
|
173
|
+
if (this.experimentalHighlights) {
|
|
174
|
+
const { type } = decoration.style;
|
|
175
|
+
const { layout, width } = decoration.style as BuiltinDecorationStyle;
|
|
176
|
+
// CSS Highlight API only handles text-level highlight styling (boxes + wrap).
|
|
177
|
+
// Everything else must go through the DOM overlay path.
|
|
178
|
+
const needsDomOverlay =
|
|
179
|
+
type !== DecorationStyleType.TextColor && (
|
|
180
|
+
type === DecorationStyleType.Outline ||
|
|
181
|
+
type === DecorationStyleType.Template ||
|
|
182
|
+
type === DecorationStyleType.Mask ||
|
|
183
|
+
(layout !== undefined && layout !== DecorationLayout.Boxes) ||
|
|
184
|
+
(width !== undefined && width !== DecorationWidth.Wrap)
|
|
185
|
+
);
|
|
186
|
+
if (needsDomOverlay) this.notTextFlag?.set(id, true);
|
|
187
|
+
}
|
|
119
188
|
|
|
120
189
|
const item = {
|
|
121
190
|
decoration,
|
|
@@ -137,6 +206,8 @@ class DecorationGroup {
|
|
|
137
206
|
if (index < 0) return;
|
|
138
207
|
|
|
139
208
|
const item = this.items[index];
|
|
209
|
+
const wasMask = item.decoration.style?.type === DecorationStyleType.Mask;
|
|
210
|
+
|
|
140
211
|
this.items.splice(index, 1);
|
|
141
212
|
item.clickableElements = undefined;
|
|
142
213
|
if (item.container) {
|
|
@@ -149,6 +220,11 @@ class DecorationGroup {
|
|
|
149
220
|
mm?.delete(item.range);
|
|
150
221
|
}
|
|
151
222
|
this.notTextFlag?.delete(item.id);
|
|
223
|
+
|
|
224
|
+
// Update shared mask if we removed a mask decoration
|
|
225
|
+
if (wasMask) {
|
|
226
|
+
this.updateSharedMask();
|
|
227
|
+
}
|
|
152
228
|
}
|
|
153
229
|
|
|
154
230
|
/**
|
|
@@ -167,6 +243,76 @@ class DecorationGroup {
|
|
|
167
243
|
this.clearContainer();
|
|
168
244
|
this.items.length = 0;
|
|
169
245
|
this.notTextFlag?.clear();
|
|
246
|
+
// Clear shared mask
|
|
247
|
+
if (this.maskSvg) {
|
|
248
|
+
this.maskSvg.remove();
|
|
249
|
+
this.maskSvg = undefined;
|
|
250
|
+
}
|
|
251
|
+
if (this.shadowHost) {
|
|
252
|
+
this.shadowHost.remove();
|
|
253
|
+
this.shadowHost = undefined;
|
|
254
|
+
this.shadowRoot = undefined;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Removes all decorations and tears down event listeners.
|
|
260
|
+
* Must be called when the group is permanently discarded.
|
|
261
|
+
*/
|
|
262
|
+
destroy() {
|
|
263
|
+
this.clear();
|
|
264
|
+
this.wnd.document.removeEventListener("pointerup", this.activationHandler);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private handleActivation(e: PointerEvent) {
|
|
268
|
+
if (!this._activatable) return;
|
|
269
|
+
const cssX = e.clientX;
|
|
270
|
+
const cssY = e.clientY;
|
|
271
|
+
const pixelRatio = this.wnd.devicePixelRatio;
|
|
272
|
+
|
|
273
|
+
for (const item of this.items) {
|
|
274
|
+
if (!item.decoration.style?.isActive) continue;
|
|
275
|
+
|
|
276
|
+
let hitRect: DOMRect | undefined;
|
|
277
|
+
|
|
278
|
+
if (item.decoration.style.type === DecorationStyleType.Template) {
|
|
279
|
+
// Templates can be positioned anywhere (e.g. a margin sidemark), so hit-test
|
|
280
|
+
// against the rendered elements rather than the text range rects.
|
|
281
|
+
for (const el of (item.clickableElements ?? [])) {
|
|
282
|
+
const r = el.getBoundingClientRect();
|
|
283
|
+
if (rectContainsPoint(r as Rect, cssX, cssY, 0)) {
|
|
284
|
+
hitRect = r;
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
} else {
|
|
289
|
+
// Built-in styles sit over the text. Range.getClientRects() works for both
|
|
290
|
+
// rendering paths: the CSS Highlight API has no DOM overlay to target, and the
|
|
291
|
+
// DOM overlay divs have pointer-events: none, so neither intercepts the event.
|
|
292
|
+
const rects = item.range.getClientRects();
|
|
293
|
+
for (const rect of rects) {
|
|
294
|
+
if (rectContainsPoint(rect as Rect, cssX, cssY, 0)) {
|
|
295
|
+
hitRect = item.range.getBoundingClientRect();
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (hitRect) {
|
|
302
|
+
this.comms.send("decoration_activated", {
|
|
303
|
+
decorationId: item.decoration.id,
|
|
304
|
+
group: this.name,
|
|
305
|
+
rect: {
|
|
306
|
+
top: hitRect.top * pixelRatio,
|
|
307
|
+
left: hitRect.left * pixelRatio,
|
|
308
|
+
width: hitRect.width * pixelRatio,
|
|
309
|
+
height: hitRect.height * pixelRatio,
|
|
310
|
+
},
|
|
311
|
+
point: { x: cssX * pixelRatio, y: cssY * pixelRatio },
|
|
312
|
+
} as DecorationActivatedEvent);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
170
316
|
}
|
|
171
317
|
|
|
172
318
|
/**
|
|
@@ -176,24 +322,111 @@ class DecorationGroup {
|
|
|
176
322
|
requestLayout() {
|
|
177
323
|
this.wnd.cancelAnimationFrame(this.currentRender);
|
|
178
324
|
this.clearContainer();
|
|
179
|
-
|
|
180
|
-
|
|
325
|
+
// Wait for fonts to finish loading before reading geometry, then use a
|
|
326
|
+
// rAF to ensure the browser has finished reflowing with the new metrics.
|
|
327
|
+
// Without this, font-family / zoom changes cause positions to be read
|
|
328
|
+
// against stale or fallback-font layout.
|
|
329
|
+
this.wnd.document.fonts.ready.then(() => {
|
|
330
|
+
this.currentRender = this.wnd.requestAnimationFrame(() => {
|
|
331
|
+
this.items.forEach(i => this.layout(i));
|
|
332
|
+
this.renderLayout(this.items);
|
|
333
|
+
// Update shared mask after layout
|
|
334
|
+
this.updateSharedMask();
|
|
335
|
+
});
|
|
336
|
+
});
|
|
181
337
|
}
|
|
182
338
|
|
|
183
339
|
private experimentalLayout(item: DecorationItem) {
|
|
184
340
|
const [stylesheet, highlighter]: [HTMLStyleElement, any] = this.requireContainer(true) as [HTMLStyleElement, unknown];
|
|
185
|
-
highlighter.add(item.range);
|
|
186
341
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const
|
|
342
|
+
// Template items are always routed to the DOM overlay; only BuiltinDecorationStyle reaches here.
|
|
343
|
+
const style = item.decoration.style as BuiltinDecorationStyle;
|
|
344
|
+
const type = style.type ?? DecorationStyleType.Highlight;
|
|
345
|
+
const tint = style.tint ?? defaultTint(type);
|
|
346
|
+
const width = style.width;
|
|
347
|
+
const layout = style.layout;
|
|
348
|
+
|
|
349
|
+
// Helper for caret position
|
|
350
|
+
const caretPositionFromPoint = (x: number, y: number): CaretPosition | null => {
|
|
351
|
+
return this.wnd.document.caretPositionFromPoint?.(x, y) ?? null;
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
// TextColor range registration: expand to bounding rect when layout/width asks for it.
|
|
355
|
+
if (
|
|
356
|
+
type === DecorationStyleType.TextColor &&
|
|
357
|
+
(layout === DecorationLayout.Bounds || width === DecorationWidth.Bounds || width === DecorationWidth.Page)
|
|
358
|
+
) {
|
|
359
|
+
// For vertical writing, caretPositionFromPoint has browser bugs - use fallback
|
|
360
|
+
const ctx = makeWritingContext(this.wnd);
|
|
361
|
+
if (ctx.isVertical) {
|
|
362
|
+
console.warn('Vertical writing detected: caretPositionFromPoint has known bugs, falling back to original range');
|
|
363
|
+
highlighter.add(item.range);
|
|
364
|
+
} else {
|
|
365
|
+
const boundingRect = item.range.getBoundingClientRect();
|
|
366
|
+
// Page snaps to the full page inline extent; Bounds/layout:Bounds uses the actual bounding rect.
|
|
367
|
+
let inlineOrigin: number;
|
|
368
|
+
let inlineExtent: number;
|
|
369
|
+
if (width === DecorationWidth.Page) {
|
|
370
|
+
const snap = Math.floor(ctx.inlineStart(boundingRect) / ctx.pageInlineSize) * ctx.pageInlineSize;
|
|
371
|
+
inlineOrigin = snap;
|
|
372
|
+
inlineExtent = ctx.pageInlineSize;
|
|
373
|
+
} else {
|
|
374
|
+
inlineOrigin = ctx.inlineStart(boundingRect);
|
|
375
|
+
inlineExtent = ctx.inlineSize(boundingRect);
|
|
376
|
+
}
|
|
377
|
+
const startCaret = caretPositionFromPoint(inlineOrigin, ctx.blockStart(boundingRect) + 1);
|
|
378
|
+
const endCaret = caretPositionFromPoint(inlineOrigin + inlineExtent, ctx.blockStart(boundingRect) + ctx.blockSize(boundingRect) - 1);
|
|
379
|
+
if (startCaret && endCaret) {
|
|
380
|
+
const expandedRange = this.wnd.document.createRange();
|
|
381
|
+
expandedRange.setStart(startCaret.offsetNode, startCaret.offset);
|
|
382
|
+
expandedRange.setEnd(endCaret.offsetNode, endCaret.offset);
|
|
383
|
+
highlighter.add(expandedRange);
|
|
384
|
+
item.range = expandedRange;
|
|
385
|
+
} else {
|
|
386
|
+
highlighter.add(item.range);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
} else {
|
|
390
|
+
highlighter.add(item.range);
|
|
391
|
+
}
|
|
190
392
|
|
|
191
393
|
// TODO add caching layer ("vdom") to this so we aren't completely replacing the CSS every time
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
394
|
+
const backgroundColor = this.getBackgroundColor();
|
|
395
|
+
const applyContrast = style.enforceContrast !== false;
|
|
396
|
+
let css: string;
|
|
397
|
+
switch (type) {
|
|
398
|
+
case DecorationStyleType.Underline:
|
|
399
|
+
const adjustedUnderlineTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
|
|
400
|
+
css = `::highlight(${this.id}) {
|
|
401
|
+
text-decoration: underline;
|
|
402
|
+
text-decoration-color: ${adjustedUnderlineTint};
|
|
403
|
+
text-decoration-thickness: 0.1em;
|
|
404
|
+
}`;
|
|
405
|
+
break;
|
|
406
|
+
case DecorationStyleType.Outline:
|
|
407
|
+
const adjustedOutlineTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
|
|
408
|
+
css = `::highlight(${this.id}) {
|
|
409
|
+
outline: 2px solid ${adjustedOutlineTint};
|
|
410
|
+
outline-offset: 1px;
|
|
411
|
+
}`;
|
|
412
|
+
break;
|
|
413
|
+
case DecorationStyleType.TextColor: {
|
|
414
|
+
const adjustedTextTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
|
|
415
|
+
css = `::highlight(${this.id}) {
|
|
416
|
+
color: ${adjustedTextTint};
|
|
417
|
+
}`;
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
case DecorationStyleType.Highlight:
|
|
421
|
+
default: {
|
|
422
|
+
const adjustedHighlightTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
|
|
423
|
+
css = `::highlight(${this.id}) {
|
|
424
|
+
color: ${getContrastingTextColor(adjustedHighlightTint, backgroundColor)};
|
|
425
|
+
background-color: ${adjustedHighlightTint};
|
|
426
|
+
}`;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
stylesheet.innerHTML = css;
|
|
197
430
|
}
|
|
198
431
|
|
|
199
432
|
/**
|
|
@@ -213,97 +446,155 @@ class DecorationGroup {
|
|
|
213
446
|
// itemContainer.dataset.style = item.decoration.style; // TODO style
|
|
214
447
|
itemContainer.style.setProperty("pointer-events", "none");
|
|
215
448
|
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
)
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const xOffset = scrollingElement.scrollLeft;
|
|
225
|
-
const yOffset = scrollingElement.scrollTop;
|
|
226
|
-
|
|
227
|
-
const positionElement = (element: HTMLElement, rect: Rect, boundingRect: DOMRect) => {
|
|
228
|
-
element.style.position = "absolute";
|
|
229
|
-
|
|
230
|
-
// TODO change to switch
|
|
231
|
-
if (item.decoration?.style?.width === Width.Viewport) {
|
|
232
|
-
element.style.width = `${viewportWidth}px`;
|
|
233
|
-
element.style.height = `${rect.height}px`;
|
|
234
|
-
let left = Math.floor(rect.left / viewportWidth) * viewportWidth;
|
|
235
|
-
element.style.left = `${left + xOffset}px`;
|
|
236
|
-
element.style.top = `${rect.top + yOffset}px`;
|
|
237
|
-
} else if (item.decoration?.style?.width === Width.Bounds) {
|
|
238
|
-
element.style.width = `${boundingRect.width}px`;
|
|
239
|
-
element.style.height = `${rect.height}px`;
|
|
240
|
-
element.style.left = `${boundingRect.left + xOffset}px`;
|
|
241
|
-
element.style.top = `${rect.top + yOffset}px`;
|
|
242
|
-
} else if (item.decoration?.style?.width === Width.Page) {
|
|
243
|
-
element.style.width = `${pageWidth}px`;
|
|
244
|
-
element.style.height = `${rect.height}px`;
|
|
245
|
-
let left = Math.floor(rect.left / pageWidth) * pageWidth;
|
|
246
|
-
element.style.left = `${left + xOffset}px`;
|
|
247
|
-
element.style.top = `${rect.top + yOffset}px`;
|
|
248
|
-
} else {
|
|
249
|
-
// Fall back to "wrap"
|
|
250
|
-
element.style.width = `${rect.width}px`;
|
|
251
|
-
element.style.height = `${rect.height}px`;
|
|
252
|
-
element.style.left = `${rect.left + xOffset}px`;
|
|
253
|
-
element.style.top = `${rect.top + yOffset}px`;
|
|
254
|
-
}
|
|
449
|
+
const ctx = makeWritingContext(this.wnd);
|
|
450
|
+
|
|
451
|
+
let iz = 1;
|
|
452
|
+
if (sML.UA.Blink) {
|
|
453
|
+
const rootZoom = parseFloat(this.wnd.getComputedStyle(this.wnd.document.documentElement).zoom);
|
|
454
|
+
const bodyZoom = parseFloat(this.wnd.getComputedStyle(this.wnd.document.body).zoom);
|
|
455
|
+
const effectiveZoom = (rootZoom || 1) * (bodyZoom || 1);
|
|
456
|
+
if (effectiveZoom) iz = 1 / effectiveZoom;
|
|
255
457
|
}
|
|
256
458
|
|
|
459
|
+
const positionElement = (element: HTMLElement, rect: Rect, boundingRect: DOMRect, inlineInset = 0) => {
|
|
460
|
+
const w = item.decoration?.style?.width;
|
|
461
|
+
switch (w) {
|
|
462
|
+
case DecorationWidth.Viewport: {
|
|
463
|
+
const snap = Math.floor(ctx.inlineStart(rect) / ctx.viewportInlineSize) * ctx.viewportInlineSize;
|
|
464
|
+
ctx.applyPosition(element, snap + ctx.inlineScrollOffset + inlineInset, ctx.blockStart(rect) + ctx.blockScrollOffset, ctx.viewportInlineSize - 2 * inlineInset, ctx.blockSize(rect), iz);
|
|
465
|
+
break;
|
|
466
|
+
}
|
|
467
|
+
case DecorationWidth.Page: {
|
|
468
|
+
const snap = Math.floor(ctx.inlineStart(rect) / ctx.pageInlineSize) * ctx.pageInlineSize;
|
|
469
|
+
ctx.applyPosition(element, snap + ctx.inlineScrollOffset + inlineInset, ctx.blockStart(rect) + ctx.blockScrollOffset, ctx.pageInlineSize - 2 * inlineInset, ctx.blockSize(rect), iz);
|
|
470
|
+
break;
|
|
471
|
+
}
|
|
472
|
+
case DecorationWidth.Bounds: {
|
|
473
|
+
ctx.applyPosition(element, ctx.inlineStart(boundingRect) + ctx.inlineScrollOffset, ctx.blockStart(rect) + ctx.blockScrollOffset, ctx.inlineSize(boundingRect), ctx.blockSize(rect), iz);
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
default: {
|
|
477
|
+
ctx.applyPosition(element, ctx.inlineStart(rect) + ctx.inlineScrollOffset, ctx.blockStart(rect) + ctx.blockScrollOffset, ctx.inlineSize(rect), ctx.blockSize(rect), iz);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
257
481
|
const boundingRect = item.range.getBoundingClientRect();
|
|
258
482
|
|
|
259
|
-
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
483
|
+
const decoStyle = item.decoration.style;
|
|
484
|
+
// outline: 2px + outline-offset: 1px = 3px bleed outside the box on each side.
|
|
485
|
+
// For Page/Viewport widths the snap edge coincides with the viewport edge, so the
|
|
486
|
+
// outline would be clipped. Inset the element to give that bleed room to render.
|
|
487
|
+
const outlineInset = (() => {
|
|
488
|
+
if ((decoStyle as BuiltinDecorationStyle).type !== DecorationStyleType.Outline) return 0;
|
|
489
|
+
const w = (decoStyle as BuiltinDecorationStyle).width;
|
|
490
|
+
return (w === DecorationWidth.Page || w === DecorationWidth.Viewport) ? 3 : 0;
|
|
491
|
+
})();
|
|
492
|
+
let elementTemplate: Element;
|
|
493
|
+
|
|
494
|
+
if (decoStyle.type === DecorationStyleType.Template) {
|
|
495
|
+
// HTMLDecorationTemplate — fully custom HTML provided by the caller.
|
|
496
|
+
if (decoStyle.stylesheet) {
|
|
497
|
+
this.injectCustomStylesheet(decoStyle.stylesheet);
|
|
498
|
+
}
|
|
499
|
+
const customEl = sanitizeHTML(this.wnd, decoStyle.element) as HTMLElement | null;
|
|
500
|
+
if (!customEl) {
|
|
501
|
+
item.container = itemContainer;
|
|
502
|
+
item.clickableElements = [];
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
customEl.style.setProperty("pointer-events", "none");
|
|
506
|
+
elementTemplate = customEl;
|
|
507
|
+
} else {
|
|
508
|
+
// BuiltinDecorationStyle path.
|
|
509
|
+
const style = decoStyle as BuiltinDecorationStyle;
|
|
510
|
+
const type = style.type ?? DecorationStyleType.Highlight;
|
|
511
|
+
const tint = style.tint ?? defaultTint(type);
|
|
512
|
+
|
|
513
|
+
// TextColor requires CSS Highlight API; DOM overlay has no equivalent.
|
|
514
|
+
if (type === DecorationStyleType.TextColor) {
|
|
515
|
+
item.container = itemContainer;
|
|
516
|
+
item.clickableElements = [];
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Mask: dim overlay covering the full document with SVG clip-path holes.
|
|
521
|
+
if (type === DecorationStyleType.Mask) {
|
|
522
|
+
// Mask decorations use a shared overlay - just mark the item and update the shared mask
|
|
523
|
+
item.container = itemContainer;
|
|
524
|
+
item.clickableElements = [];
|
|
525
|
+
this.updateSharedMask();
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const isDarkMode = this.getCurrentDarkMode();
|
|
530
|
+
const backgroundColor = this.getBackgroundColor();
|
|
531
|
+
const applyContrast = style.enforceContrast !== false;
|
|
532
|
+
const styleAttr = (() => {
|
|
533
|
+
switch (type) {
|
|
534
|
+
case DecorationStyleType.Underline: {
|
|
535
|
+
const adjustedUnderlineTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
|
|
536
|
+
const isBounds = style.layout === DecorationLayout.Bounds;
|
|
537
|
+
return [
|
|
538
|
+
isBounds
|
|
539
|
+
? `border-top: 0.1em solid ${adjustedUnderlineTint} !important`
|
|
540
|
+
: null,
|
|
541
|
+
`border-bottom: 0.1em solid ${adjustedUnderlineTint} !important`,
|
|
542
|
+
"background-color: transparent !important",
|
|
543
|
+
"box-sizing: border-box !important",
|
|
544
|
+
].filter(Boolean).join("; ");
|
|
545
|
+
}
|
|
546
|
+
case DecorationStyleType.Outline:
|
|
547
|
+
const adjustedOutlineTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
|
|
548
|
+
return [
|
|
549
|
+
`outline: 2px solid ${adjustedOutlineTint} !important`,
|
|
550
|
+
"outline-offset: 1px !important",
|
|
551
|
+
"background-color: transparent !important",
|
|
552
|
+
"box-sizing: border-box !important",
|
|
553
|
+
].join("; ");
|
|
554
|
+
case DecorationStyleType.Highlight:
|
|
555
|
+
default: {
|
|
556
|
+
const adjustedHighlightTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
|
|
557
|
+
return [
|
|
558
|
+
`background-color: ${adjustedHighlightTint} !important`,
|
|
559
|
+
`mix-blend-mode: ${isDarkMode ? "exclusion" : "multiply"} !important`,
|
|
560
|
+
"opacity: 1 !important",
|
|
561
|
+
"box-sizing: border-box !important",
|
|
562
|
+
].join("; ");
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
})();
|
|
566
|
+
|
|
567
|
+
const template = this.wnd.document.createElement("template");
|
|
568
|
+
template.innerHTML = `<div data-readium="true" class="readium-${type}" style="${styleAttr}"></div>`.trim();
|
|
569
|
+
elementTemplate = template.content.firstElementChild!;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if(item.decoration?.style?.layout === DecorationLayout.Bounds) {
|
|
282
573
|
const bounds = elementTemplate.cloneNode(true) as HTMLDivElement;
|
|
283
574
|
bounds.style.setProperty("pointer-events", "none");
|
|
284
|
-
positionElement(bounds, boundingRect, boundingRect);
|
|
575
|
+
positionElement(bounds, boundingRect, boundingRect, outlineInset);
|
|
285
576
|
itemContainer.append(bounds);
|
|
286
577
|
} else {
|
|
287
578
|
// Fall back to "boxes" value for layout
|
|
288
579
|
let clientRects = getClientRectsNoOverlap(
|
|
289
580
|
item.range,
|
|
290
|
-
true
|
|
581
|
+
true, // doNotMergeHorizontallyAlignedRects
|
|
582
|
+
ctx.isVertical // doNotMergeVerticallyAlignedRects
|
|
291
583
|
);
|
|
292
584
|
|
|
293
585
|
clientRects = clientRects.sort((r1, r2) => {
|
|
294
|
-
if (
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
return
|
|
298
|
-
} else {
|
|
299
|
-
return 0;
|
|
586
|
+
if (ctx.isVertical) {
|
|
587
|
+
// vertical-rl: rightmost column first; vertical-lr: leftmost first
|
|
588
|
+
const factor = ctx.isVertLR ? 1 : -1;
|
|
589
|
+
return factor * (r1.left - r2.left);
|
|
300
590
|
}
|
|
591
|
+
return r1.top - r2.top;
|
|
301
592
|
});
|
|
302
593
|
|
|
303
594
|
for (let clientRect of clientRects) {
|
|
304
595
|
const line = elementTemplate.cloneNode(true) as HTMLDivElement;
|
|
305
596
|
line.style.setProperty("pointer-events", "none");
|
|
306
|
-
positionElement(line, clientRect, boundingRect);
|
|
597
|
+
positionElement(line, clientRect, boundingRect, outlineInset);
|
|
307
598
|
itemContainer.append(line);
|
|
308
599
|
}
|
|
309
600
|
}
|
|
@@ -357,21 +648,190 @@ class DecorationGroup {
|
|
|
357
648
|
}
|
|
358
649
|
|
|
359
650
|
if (!this.container) {
|
|
651
|
+
// Create shared shadow host if it doesn't exist
|
|
652
|
+
if (!this.shadowRoot) {
|
|
653
|
+
this.shadowHost = this.wnd.document.createElement("div");
|
|
654
|
+
this.shadowHost.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none";
|
|
655
|
+
this.wnd.document.body.appendChild(this.shadowHost);
|
|
656
|
+
this.shadowRoot = this.shadowHost.attachShadow({ mode: "open" });
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Create container in shared shadow root
|
|
360
660
|
this.container = this.wnd.document.createElement("div");
|
|
361
661
|
this.container.setAttribute("id", this.id);
|
|
362
662
|
this.container.dataset.group = this.name;
|
|
363
663
|
this.container.dataset.readium = "true";
|
|
364
664
|
this.container.style.setProperty("pointer-events", "none");
|
|
365
665
|
this.container.style.display = "contents";
|
|
366
|
-
this.
|
|
666
|
+
this.shadowRoot.appendChild(this.container);
|
|
367
667
|
}
|
|
368
668
|
return this.container;
|
|
369
669
|
}
|
|
370
670
|
|
|
371
671
|
getCurrentDarkMode(): boolean {
|
|
372
672
|
return getProperty(this.wnd, "--USER__appearance") === "readium-night-on" ||
|
|
373
|
-
isDarkColor(
|
|
374
|
-
|
|
673
|
+
isDarkColor(this.getBackgroundColor());
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
getBackgroundColor(): string {
|
|
677
|
+
return getProperty(this.wnd, "--USER__backgroundColor") ||
|
|
678
|
+
this.wnd.getComputedStyle(this.wnd.document.documentElement).getPropertyValue("background-color");
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
private updateSharedMask() {
|
|
682
|
+
const maskItems = this.items.filter(item =>
|
|
683
|
+
item.decoration.style?.type === DecorationStyleType.Mask
|
|
684
|
+
);
|
|
685
|
+
|
|
686
|
+
if (maskItems.length === 0) {
|
|
687
|
+
// Remove shared mask if no mask decorations exist
|
|
688
|
+
if (this.maskSvg) {
|
|
689
|
+
this.maskSvg.remove();
|
|
690
|
+
this.maskSvg = undefined;
|
|
691
|
+
}
|
|
692
|
+
if (this.shadowRoot) {
|
|
693
|
+
this.shadowRoot.innerHTML = '';
|
|
694
|
+
}
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const ctx = makeWritingContext(this.wnd);
|
|
699
|
+
|
|
700
|
+
let iz = 1;
|
|
701
|
+
if (sML.UA.Blink) {
|
|
702
|
+
const rootZoom = parseFloat(this.wnd.getComputedStyle(this.wnd.document.documentElement).zoom);
|
|
703
|
+
const bodyZoom = parseFloat(this.wnd.getComputedStyle(this.wnd.document.body).zoom);
|
|
704
|
+
const effectiveZoom = (rootZoom || 1) * (bodyZoom || 1);
|
|
705
|
+
if (effectiveZoom) iz = 1 / effectiveZoom;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Collect all hole rects from mask decorations
|
|
709
|
+
const docEl = this.wnd.document.documentElement;
|
|
710
|
+
const docW = docEl.scrollWidth;
|
|
711
|
+
const docH = docEl.scrollHeight;
|
|
712
|
+
const allHoleRects: DOMRect[] = [];
|
|
713
|
+
for (const item of maskItems) {
|
|
714
|
+
const style = item.decoration.style as BuiltinDecorationStyle;
|
|
715
|
+
const layout = style.layout ?? DecorationLayout.Boxes;
|
|
716
|
+
const width = style.width ?? DecorationWidth.Wrap;
|
|
717
|
+
|
|
718
|
+
const boundingRect = item.range.getBoundingClientRect();
|
|
719
|
+
|
|
720
|
+
const baseRects: DOMRect[] = layout === DecorationLayout.Bounds
|
|
721
|
+
? [boundingRect]
|
|
722
|
+
: Array.from(item.range.getClientRects());
|
|
723
|
+
|
|
724
|
+
for (const rect of baseRects) {
|
|
725
|
+
let hole: DOMRect;
|
|
726
|
+
switch (width) {
|
|
727
|
+
case DecorationWidth.Viewport: {
|
|
728
|
+
const snap = Math.floor(ctx.inlineStart(rect) / ctx.viewportInlineSize) * ctx.viewportInlineSize;
|
|
729
|
+
hole = ctx.toRect(snap, ctx.blockStart(rect), ctx.viewportInlineSize, ctx.blockSize(rect));
|
|
730
|
+
break;
|
|
731
|
+
}
|
|
732
|
+
case DecorationWidth.Page: {
|
|
733
|
+
const snap = Math.floor(ctx.inlineStart(rect) / ctx.pageInlineSize) * ctx.pageInlineSize;
|
|
734
|
+
hole = ctx.toRect(snap, ctx.blockStart(rect), ctx.pageInlineSize, ctx.blockSize(rect));
|
|
735
|
+
break;
|
|
736
|
+
}
|
|
737
|
+
case DecorationWidth.Bounds: {
|
|
738
|
+
hole = ctx.toRect(ctx.inlineStart(boundingRect), ctx.blockStart(rect), ctx.inlineSize(boundingRect), ctx.blockSize(rect));
|
|
739
|
+
break;
|
|
740
|
+
}
|
|
741
|
+
default:
|
|
742
|
+
hole = rect;
|
|
743
|
+
}
|
|
744
|
+
allHoleRects.push(hole);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Build SVG path with all holes (physical coords — always left/top regardless of writing mode)
|
|
749
|
+
const pathData = [
|
|
750
|
+
`M0 0 H${docW} V${docH} H0 Z`,
|
|
751
|
+
...allHoleRects.map(r => {
|
|
752
|
+
const l = (r.left + ctx.xDocOffset) * iz;
|
|
753
|
+
const t = (r.top + ctx.yDocOffset) * iz;
|
|
754
|
+
const ri = (r.right + ctx.xDocOffset) * iz;
|
|
755
|
+
const b = (r.bottom + ctx.yDocOffset) * iz;
|
|
756
|
+
return `M${l} ${t} H${ri} V${b} H${l} Z`;
|
|
757
|
+
}),
|
|
758
|
+
].join(" ");
|
|
759
|
+
|
|
760
|
+
const svgNS = "http://www.w3.org/2000/svg";
|
|
761
|
+
|
|
762
|
+
// Create or update SVG
|
|
763
|
+
if (!this.maskSvg) {
|
|
764
|
+
// Ensure shared shadow host exists
|
|
765
|
+
if (!this.shadowRoot) {
|
|
766
|
+
this.shadowHost = this.wnd.document.createElement("div");
|
|
767
|
+
this.shadowHost.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none";
|
|
768
|
+
this.wnd.document.body.appendChild(this.shadowHost);
|
|
769
|
+
this.shadowRoot = this.shadowHost.attachShadow({ mode: "open" });
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Create SVG in shared shadow root
|
|
773
|
+
this.maskSvg = this.wnd.document.createElementNS(svgNS, "svg") as SVGSVGElement;
|
|
774
|
+
this.maskSvg.style.cssText = `position:absolute;top:0;left:0;width:${docW}px;height:${docH}px;pointer-events:none;z-index:9999`;
|
|
775
|
+
this.maskSvg.dataset.readium = "true";
|
|
776
|
+
const defs = this.wnd.document.createElementNS(svgNS, "defs");
|
|
777
|
+
const clipPath = this.wnd.document.createElementNS(svgNS, "clipPath") as SVGClipPathElement;
|
|
778
|
+
const clipId = `${this.id}-mask-clip`;
|
|
779
|
+
clipPath.setAttribute("id", clipId);
|
|
780
|
+
clipPath.setAttribute("clipPathUnits", "userSpaceOnUse");
|
|
781
|
+
const svgPath = this.wnd.document.createElementNS(svgNS, "path") as SVGPathElement;
|
|
782
|
+
svgPath.setAttribute("clip-rule", "evenodd");
|
|
783
|
+
clipPath.appendChild(svgPath);
|
|
784
|
+
defs.appendChild(clipPath);
|
|
785
|
+
this.maskSvg.appendChild(defs);
|
|
786
|
+
|
|
787
|
+
// Add SVG rect for the overlay (bypasses ReadiumCSS)
|
|
788
|
+
const maskRect = this.wnd.document.createElementNS(svgNS, "rect") as SVGRectElement;
|
|
789
|
+
maskRect.setAttribute("id", `${this.id}-mask-rect`);
|
|
790
|
+
maskRect.setAttribute("clip-path", `url(#${clipId})`);
|
|
791
|
+
maskRect.style.pointerEvents = "none";
|
|
792
|
+
this.maskSvg.appendChild(maskRect);
|
|
793
|
+
|
|
794
|
+
this.shadowRoot!.appendChild(this.maskSvg);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Update SVG dimensions to cover full document
|
|
798
|
+
this.maskSvg.style.width = `${docW}px`;
|
|
799
|
+
this.maskSvg.style.height = `${docH}px`;
|
|
800
|
+
|
|
801
|
+
// Update the path data
|
|
802
|
+
const svgPath = this.maskSvg.querySelector("path") as SVGPathElement;
|
|
803
|
+
if (svgPath) {
|
|
804
|
+
svgPath.setAttribute("d", pathData);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Update the mask rect
|
|
808
|
+
const maskRect = this.maskSvg.querySelector("rect") as SVGRectElement;
|
|
809
|
+
if (maskRect) {
|
|
810
|
+
const firstMaskStyle = maskItems[0].decoration.style as BuiltinDecorationStyle;
|
|
811
|
+
const userTint = firstMaskStyle.tint;
|
|
812
|
+
// User-supplied tint: alpha is their responsibility (SVG fill respects rgba natively).
|
|
813
|
+
// Background color fallback: always fully opaque, so apply default dimming.
|
|
814
|
+
const fillColor = userTint ?? this.getBackgroundColor() ?? defaultTint(DecorationStyleType.Mask);
|
|
815
|
+
const fillOpacity = userTint ? "1" : "0.5";
|
|
816
|
+
maskRect.setAttribute("x", "0");
|
|
817
|
+
maskRect.setAttribute("y", "0");
|
|
818
|
+
maskRect.setAttribute("width", String(docW));
|
|
819
|
+
maskRect.setAttribute("height", String(docH));
|
|
820
|
+
maskRect.setAttribute("fill", fillColor);
|
|
821
|
+
maskRect.setAttribute("fill-opacity", fillOpacity);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
private injectCustomStylesheet(css: string) {
|
|
826
|
+
const id = `${this.id}-custom-style`;
|
|
827
|
+
let el = this.wnd.document.getElementById(id) as HTMLStyleElement | null;
|
|
828
|
+
if (!el) {
|
|
829
|
+
el = this.wnd.document.createElement("style");
|
|
830
|
+
el.id = id;
|
|
831
|
+
el.dataset.readium = "true";
|
|
832
|
+
this.wnd.document.head.appendChild(el);
|
|
833
|
+
}
|
|
834
|
+
el.innerHTML = css;
|
|
375
835
|
}
|
|
376
836
|
|
|
377
837
|
/**
|
|
@@ -381,6 +841,7 @@ class DecorationGroup {
|
|
|
381
841
|
if (this.experimentalHighlights) {
|
|
382
842
|
((this.wnd as any).CSS.highlights as Map<string, unknown>).delete(this.id);
|
|
383
843
|
}
|
|
844
|
+
this.wnd.document.getElementById(`${this.id}-custom-style`)?.remove();
|
|
384
845
|
if (this.container) {
|
|
385
846
|
this.container.remove();
|
|
386
847
|
this.container = undefined;
|
|
@@ -391,7 +852,7 @@ class DecorationGroup {
|
|
|
391
852
|
export class Decorator extends Module {
|
|
392
853
|
static readonly moduleName: ModuleName = "decorator";
|
|
393
854
|
private resizeObserver!: ResizeObserver;
|
|
394
|
-
private
|
|
855
|
+
private styleObserver!: MutationObserver;
|
|
395
856
|
private wnd!: ReadiumWindow;
|
|
396
857
|
/*private readonly lastSize = {
|
|
397
858
|
width: 0,
|
|
@@ -403,8 +864,7 @@ export class Decorator extends Module {
|
|
|
403
864
|
private groups = new Map<string, DecorationGroup>();
|
|
404
865
|
|
|
405
866
|
private cleanup() {
|
|
406
|
-
|
|
407
|
-
this.groups.forEach(g => g.clear());
|
|
867
|
+
this.groups.forEach(g => g.destroy());
|
|
408
868
|
this.groups.clear();
|
|
409
869
|
}
|
|
410
870
|
|
|
@@ -414,13 +874,6 @@ export class Decorator extends Module {
|
|
|
414
874
|
});
|
|
415
875
|
}
|
|
416
876
|
|
|
417
|
-
private extractCustomProperty(style: string | null, propertyName: string): string | null {
|
|
418
|
-
if (!style) return null;
|
|
419
|
-
|
|
420
|
-
const match = style.match(new RegExp(`${propertyName}:\\s*([^;]+)`));
|
|
421
|
-
return match ? match[1].trim() : null;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
877
|
private handleResize() {
|
|
425
878
|
this.wnd.clearTimeout(this.resizeFrame);
|
|
426
879
|
this.resizeFrame = this.wnd.setTimeout(() => {
|
|
@@ -436,8 +889,10 @@ export class Decorator extends Module {
|
|
|
436
889
|
|
|
437
890
|
comms.register("decorate", Decorator.moduleName, (data, ack) => {
|
|
438
891
|
const req = data as DecoratorRequest;
|
|
439
|
-
if (req.
|
|
440
|
-
|
|
892
|
+
if (req.action === "add" || req.action === "update") {
|
|
893
|
+
if (req.decoration.locator) {
|
|
894
|
+
req.decoration.locator = Locator.deserialize(req.decoration.locator)!;
|
|
895
|
+
}
|
|
441
896
|
}
|
|
442
897
|
if (!this.groups.has(req.group)) {
|
|
443
898
|
this.groups.set(req.group, new DecorationGroup(
|
|
@@ -450,57 +905,51 @@ export class Decorator extends Module {
|
|
|
450
905
|
const group = this.groups.get(req.group);
|
|
451
906
|
switch (req.action) {
|
|
452
907
|
case "add":
|
|
453
|
-
group?.add(req.decoration
|
|
908
|
+
group?.add(req.decoration);
|
|
454
909
|
break;
|
|
455
910
|
case "remove":
|
|
456
|
-
group?.remove(req.decoration
|
|
911
|
+
group?.remove(req.decoration.id);
|
|
457
912
|
break;
|
|
458
913
|
case "clear":
|
|
459
914
|
group?.clear();
|
|
460
915
|
break;
|
|
461
916
|
case "update":
|
|
462
|
-
group?.update(req.decoration
|
|
917
|
+
group?.update(req.decoration);
|
|
463
918
|
break;
|
|
464
919
|
}
|
|
465
920
|
|
|
466
921
|
ack(true);
|
|
467
922
|
});
|
|
468
923
|
|
|
924
|
+
comms.register("decoration_activatable", Decorator.moduleName, (data, ack) => {
|
|
925
|
+
const req = data as { group: string; activatable: boolean };
|
|
926
|
+
const group = this.groups.get(req.group);
|
|
927
|
+
if (group) {
|
|
928
|
+
group.activatable = req.activatable;
|
|
929
|
+
}
|
|
930
|
+
ack(true);
|
|
931
|
+
});
|
|
932
|
+
|
|
469
933
|
this.resizeObserver = new ResizeObserver(() => wnd.requestAnimationFrame(() => this.handleResize()));
|
|
470
|
-
this.resizeObserver.observe(wnd.document.
|
|
934
|
+
this.resizeObserver.observe(wnd.document.documentElement);
|
|
471
935
|
wnd.addEventListener("orientationchange", this.handleResizer);
|
|
472
936
|
wnd.addEventListener("resize", this.handleResizer);
|
|
473
937
|
|
|
474
|
-
//
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
const oldAppearance = this.extractCustomProperty(oldStyle, "--USER__appearance");
|
|
484
|
-
const newAppearance = this.extractCustomProperty(newStyle, "--USER__appearance");
|
|
485
|
-
const oldBgColor = this.extractCustomProperty(oldStyle, "--USER__backgroundColor");
|
|
486
|
-
const newBgColor = this.extractCustomProperty(newStyle, "--USER__backgroundColor");
|
|
487
|
-
|
|
488
|
-
return oldAppearance !== newAppearance ||
|
|
489
|
-
oldBgColor !== newBgColor;
|
|
490
|
-
}
|
|
491
|
-
return false;
|
|
492
|
-
});
|
|
493
|
-
|
|
494
|
-
if (shouldUpdate) {
|
|
495
|
-
this.updateHighlightStyles();
|
|
496
|
-
}
|
|
938
|
+
// Watch for any style change on <html> — covers appearance, background color,
|
|
939
|
+
// font size, line height, margins, and anything else that reflows text.
|
|
940
|
+
this.styleObserver = new MutationObserver((mutations) => {
|
|
941
|
+
const shouldUpdate = mutations.some(mutation =>
|
|
942
|
+
mutation.type === "attributes" &&
|
|
943
|
+
mutation.attributeName === "style" &&
|
|
944
|
+
mutation.oldValue !== (mutation.target as Element).getAttribute("style")
|
|
945
|
+
);
|
|
946
|
+
if (shouldUpdate) this.updateHighlightStyles();
|
|
497
947
|
});
|
|
498
948
|
|
|
499
|
-
this.
|
|
949
|
+
this.styleObserver.observe(wnd.document.documentElement, {
|
|
500
950
|
attributes: true,
|
|
501
951
|
attributeFilter: ["style"],
|
|
502
952
|
attributeOldValue: true,
|
|
503
|
-
subtree: true
|
|
504
953
|
});
|
|
505
954
|
|
|
506
955
|
comms.log("Decorator Mounted");
|
|
@@ -513,7 +962,7 @@ export class Decorator extends Module {
|
|
|
513
962
|
|
|
514
963
|
comms.unregisterAll(Decorator.moduleName);
|
|
515
964
|
this.resizeObserver.disconnect();
|
|
516
|
-
this.
|
|
965
|
+
this.styleObserver.disconnect();
|
|
517
966
|
this.cleanup();
|
|
518
967
|
|
|
519
968
|
comms.log("Decorator Unmounted");
|