@readium/navigator-html-injectables 2.4.4 → 2.6.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 (75) hide show
  1. package/dist/Loader.js +1 -0
  2. package/dist/comms/comms.js +1 -0
  3. package/dist/comms/mid.js +1 -0
  4. package/dist/helpers/animation.js +1 -0
  5. package/dist/helpers/css.js +1 -0
  6. package/dist/helpers/document.js +1 -0
  7. package/dist/helpers/dom.js +1 -0
  8. package/dist/helpers/locator.js +1 -0
  9. package/dist/helpers/rect.js +1 -0
  10. package/dist/helpers/sanitize.js +1 -0
  11. package/dist/index.js +1 -4174
  12. package/dist/keyboard/KeyCombinationManager.js +1 -0
  13. package/dist/keyboard/KeyboardCombinations.js +1 -0
  14. package/dist/modules/Decorator.js +24 -0
  15. package/dist/modules/Module.js +1 -0
  16. package/dist/modules/ModuleLibrary.js +1 -0
  17. package/dist/modules/Peripherals.js +1 -0
  18. package/dist/modules/setup/FixedSetup.js +13 -0
  19. package/dist/modules/setup/ReflowableSetup.js +1 -0
  20. package/dist/modules/setup/Setup.js +1 -0
  21. package/dist/modules/setup/WebPubSetup.js +1 -0
  22. package/dist/modules/snapper/CJKVerticalSnapper.js +12 -0
  23. package/dist/modules/snapper/ColumnSnapper.js +35 -0
  24. package/dist/modules/snapper/ScrollSnapper.js +9 -0
  25. package/dist/modules/snapper/Snapper.js +5 -0
  26. package/dist/modules/snapper/WebPubSnapper.js +1 -0
  27. package/dist/protection/BulkCopyProtector.js +1 -0
  28. package/dist/protection/PatternAnalyzer.js +1 -0
  29. package/dist/protection/PrintProtector.js +15 -0
  30. package/dist/protection/SelectionAnalyzer.js +1 -0
  31. package/dist/protection/config.js +1 -0
  32. package/dist/vendor/approx-string-match/index.js +1 -0
  33. package/dist/vendor/hypothesis/anchoring/match-quote.js +1 -0
  34. package/dist/vendor/hypothesis/anchoring/text-range.js +1 -0
  35. package/dist/vendor/hypothesis/anchoring/trim-range.js +1 -0
  36. package/dist/vendor/hypothesis/anchoring/types.js +1 -0
  37. package/package.json +9 -7
  38. package/src/comms/comms.ts +11 -1
  39. package/src/comms/keys.ts +8 -3
  40. package/src/helpers/css.ts +30 -1
  41. package/src/helpers/document.ts +123 -3
  42. package/src/helpers/locator.ts +5 -5
  43. package/src/helpers/rect.ts +88 -22
  44. package/src/helpers/sanitize.ts +77 -0
  45. package/src/index.ts +2 -1
  46. package/src/modules/Decorator.ts +924 -178
  47. package/src/modules/Peripherals.ts +3 -0
  48. package/src/modules/snapper/CJKVerticalSnapper.ts +34 -7
  49. package/src/modules/snapper/ColumnSnapper.ts +56 -7
  50. package/src/modules/snapper/ScrollSnapper.ts +30 -7
  51. package/src/modules/snapper/Snapper.ts +144 -0
  52. package/src/modules/snapper/WebPubSnapper.ts +31 -7
  53. package/types/src/comms/comms.d.ts +10 -1
  54. package/types/src/comms/keys.d.ts +2 -2
  55. package/types/src/helpers/css.d.ts +7 -1
  56. package/types/src/helpers/document.d.ts +69 -3
  57. package/types/src/helpers/rect.d.ts +2 -1
  58. package/types/src/helpers/sanitize.d.ts +13 -0
  59. package/types/src/index.d.ts +1 -0
  60. package/types/src/modules/Decorator.d.ts +92 -17
  61. package/types/src/modules/Peripherals.d.ts +3 -0
  62. package/types/src/modules/snapper/CJKVerticalSnapper.d.ts +1 -0
  63. package/types/src/modules/snapper/ColumnSnapper.d.ts +14 -1
  64. package/types/src/modules/snapper/ScrollSnapper.d.ts +1 -0
  65. package/types/src/modules/snapper/Snapper.d.ts +58 -0
  66. package/types/src/modules/snapper/WebPubSnapper.d.ts +1 -0
  67. package/dist/ar-DyHX_uy2.js +0 -7
  68. package/dist/da-Dct0PS3E.js +0 -7
  69. package/dist/fr-C5HEel98.js +0 -7
  70. package/dist/index.umd.cjs +0 -95
  71. package/dist/it-DFOBoXGy.js +0 -7
  72. package/dist/pt_PT-Di3sVjze.js +0 -7
  73. package/dist/sv-BfzAFsVN.js +0 -7
  74. package/src/helpers/color.ts +0 -205
  75. package/types/src/helpers/color.d.ts +0 -26
@@ -1,54 +1,117 @@
1
1
  import { Locator } from "@readium/shared";
2
- import { Comms } from "../comms/comms.ts";
2
+ import { IComms } 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, getTextClientRects, rectContainsPoint } from "../helpers/rect.ts";
7
7
  import { getProperty } from "../helpers/css.ts";
8
- import { ReadiumWindow } from "../helpers/dom.ts";
9
- import { isDarkColor, getContrastingTextColor } from "../helpers/color.ts";
10
-
11
- const DEFAULT_HIGHLIGHT_COLOR = "#FFFF00"; // Yellow in HEX
8
+ import { isDarkColor, getContrastingTextColor, adjustColorForContrast, colorToRgba } from "@readium/helpers";
9
+ import { makeWritingContext } from "../helpers/document.ts";
10
+ import { sML } from "@readium/helpers";
11
+ import { sanitizeHTML } from "../helpers/sanitize.ts";
12
+
13
+ function defaultTint(type: DecorationStyleType): string {
14
+ switch (type) {
15
+ case DecorationStyleType.Mask:
16
+ return "rgba(255, 255, 255, 0.5)";
17
+ case DecorationStyleType.Highlight:
18
+ case DecorationStyleType.HighlightUnderline:
19
+ return "#FFFF00";
20
+ default:
21
+ return "#FF0000";
22
+ }
23
+ }
12
24
 
13
- export enum Width {
25
+ export const DecorationStyleType = {
26
+ Highlight: "highlight", // Background color overlay.
27
+ HighlightUnderline: "highlightUnderline", // Background color overlay + underline (the converged active state from RFC 008).
28
+ Underline: "underline", // Underline drawn beneath the text.
29
+ Strikethrough: "strikethrough", // Line drawn through the vertical centre of the text.
30
+ Outline: "outline", // Border drawn around the text boxes.
31
+ TextColor: "textColor", // Changes the text color directly.
32
+ Mask: "mask", // Dims everything outside the selection rects. Use width: Page for block-level behaviour.
33
+ Template: "template", // Custom HTML template (HTMLDecorationTemplate).
34
+ } as const;
35
+ export type DecorationStyleType = typeof DecorationStyleType[keyof typeof DecorationStyleType];
36
+
37
+ export enum DecorationWidth {
14
38
  Wrap = "wrap", // Smallest width fitting the CSS border box.
15
39
  Viewport = "viewport", // Fills the whole viewport.
16
- Bounds = "bounds", // Fills the anchor page, useful for dual page.
17
- Page = "page", // Fills the whole viewport.
40
+ Bounds = "bounds", // Fills the bounding region of all CSS border boxes.
41
+ Page = "page", // Fills the anchor page, useful for dual-page layouts.
18
42
  }
19
43
 
20
- export enum Layout {
44
+ export enum DecorationLayout {
21
45
  Boxes = "boxes", // One HTML element for each CSS border box (e.g. line of text).
22
46
  Bounds = "bounds", // A single HTML element covering the smallest region containing all CSS border boxes.
23
47
  }
24
48
 
25
- // TODO improve
26
- export interface Style {
27
- tint: string; // CSS color string
28
- layout: Layout; // Determines the number of created HTML elements and their position relative to the matching DOM range.
29
- width: Width; // Indicates how the width of each created HTML element expands in the viewport.
49
+ /** Built-in decoration styles. layout/width are optional overrides; defaults are Boxes/Wrap. */
50
+ export interface BuiltinDecorationStyle {
51
+ type?: Exclude<DecorationStyleType, "template">;
52
+ tint?: string;
53
+ layout?: DecorationLayout;
54
+ width?: DecorationWidth;
55
+ enforceContrast?: boolean; // When true (default), tint is adjusted for contrast against the background.
56
+ expand?: number; // Inflates each client rect outward by this many CSS pixels on all sides.
30
57
  }
31
58
 
59
+ /**
60
+ * Custom decoration style backed by caller-supplied HTML.
61
+ * Matches the HTMLDecorationTemplate class from the Readium spec.
62
+ * The element string is sanitized before injection.
63
+ * --readium-tint is injected as a CSS custom property on each created element.
64
+ */
65
+ export interface HTMLDecorationTemplate {
66
+ type: "template";
67
+ layout: DecorationLayout;
68
+ width: DecorationWidth;
69
+ element: string;
70
+ stylesheet?: string;
71
+ }
72
+
73
+ export type DecorationStyle = BuiltinDecorationStyle | HTMLDecorationTemplate;
74
+
32
75
  export interface Decoration {
33
76
  id: string; // Unique ID of the decoration. It must be unique in the group the decoration is applied to.
34
77
  locator: Locator; // Location in the publication where the decoration will be rendered.
35
- style: Style; // Declares the look and feel of the decoration.
36
- // TODO extras (userInfo)
78
+ style: DecorationStyle; // Declares the look and feel of the decoration.
79
+ extras?: Record<string, unknown>; // App-specific context data passed through to DecorationActivationEvent.
37
80
  }
38
81
 
39
- export interface DecoratorRequest {
40
- group: string; // Unique ID of the decoration group
41
- action: "add" | "remove" | "clear" | "update"; // Command
42
- decoration: Decoration | undefined;
82
+ interface DecorationEventBase {
83
+ decorationId: string;
84
+ group: string;
85
+ rect?: { top: number; left: number; width: number; height: number };
86
+ point?: { x: number; y: number };
43
87
  }
44
88
 
89
+ export interface DecorationActivatedEvent extends DecorationEventBase {
90
+ rect: { top: number; left: number; width: number; height: number }; // Always present on activation.
91
+ point: { x: number; y: number }; // Always present on activation.
92
+ }
93
+
94
+ export interface DecorationPointerEnterData extends DecorationEventBase {
95
+ rect: { top: number; left: number; width: number; height: number }; // Always present on enter.
96
+ point: { x: number; y: number }; // Always present on enter.
97
+ }
98
+
99
+ export type DecorationPointerLeaveData = DecorationEventBase;
100
+
101
+ export type DecoratorRequest =
102
+ | { group: string; action: "add" | "update"; decoration: Decoration }
103
+ | { group: string; action: "remove"; decoration: Pick<Decoration, "id"> }
104
+ | { group: string; action: "clear" };
105
+
45
106
  interface DecorationItem {
46
107
  id: string;
47
108
  decoration: Decoration;
48
109
  range: Range;
49
-
110
+ hitRects: Rect[]; // Merged client rects for hit testing; refreshed after each layout.
50
111
  clickableElements: HTMLElement[] | undefined;
51
112
  container: HTMLElement | undefined;
113
+ highlightSubKey?: string; // CSS.highlights key shared by all items with the same type+tint.
114
+ highlightCSS?: string; // The ::highlight() rule for this item's tint group.
52
115
  }
53
116
 
54
117
  const canNativeHighlight = () => ("Highlight" in window);
@@ -58,9 +121,18 @@ class DecorationGroup {
58
121
  public readonly items: DecorationItem[] = [];
59
122
  private lastItemId = 0;
60
123
  private container: HTMLDivElement | undefined = undefined;
61
- private activateable = false;
124
+ private _activatable = false;
125
+ private _hoverable = false;
126
+ private hoveredItem: DecorationItem | undefined = undefined;
62
127
  public readonly experimentalHighlights: boolean = false;
63
128
  private readonly notTextFlag: Map<string, boolean> | undefined;
129
+ private readonly _tintSubKeys = new Map<string, string>(); // (type::adjustedTint) → subKey
130
+ private _subKeyCounter = 0;
131
+ private readonly activationHandler: (e: PointerEvent) => void;
132
+ private readonly hoverHandler: (e: PointerEvent) => void;
133
+ private maskSvg: SVGSVGElement | undefined = undefined;
134
+ private shadowHost: HTMLDivElement | undefined = undefined;
135
+ private shadowRoot: ShadowRoot | undefined = undefined;
64
136
 
65
137
  /**
66
138
  * Creates a DecorationGroup object
@@ -68,8 +140,8 @@ class DecorationGroup {
68
140
  * @param name Human-readable name of the group
69
141
  */
70
142
  constructor(
71
- private readonly wnd: ReadiumWindow,
72
- private readonly comms: Comms,
143
+ private readonly wnd: Window,
144
+ private readonly comms: IComms,
73
145
  private readonly id: string,
74
146
  private readonly name: string
75
147
  ) {
@@ -77,14 +149,41 @@ class DecorationGroup {
77
149
  this.experimentalHighlights = true;
78
150
  this.notTextFlag = new Map<string, boolean>();
79
151
  }
152
+ this.activationHandler = this.handleActivation.bind(this);
153
+ this.wnd.document.addEventListener("pointerup", this.activationHandler);
154
+ this.hoverHandler = this.handleHover.bind(this);
155
+ this.wnd.document.addEventListener("pointermove", this.hoverHandler);
156
+ }
157
+
158
+ get activatable() {
159
+ return this._activatable;
160
+ }
161
+
162
+ set activatable(value: boolean) {
163
+ this._activatable = value;
80
164
  }
81
165
 
82
- get activeable() {
83
- return this.activateable;
166
+ get hoverable() {
167
+ return this._hoverable;
84
168
  }
85
169
 
86
- set activeable(value: boolean) {
87
- this.activateable = value;
170
+ set hoverable(value: boolean) {
171
+ this._hoverable = value;
172
+ if (!value && this.hoveredItem) {
173
+ const leaveRect = this.hoveredItem.range.getBoundingClientRect();
174
+ const pixelRatio = this.wnd.devicePixelRatio;
175
+ this.comms.send("decoration_pointer_leave", {
176
+ decorationId: this.hoveredItem.decoration.id,
177
+ group: this.name,
178
+ rect: {
179
+ top: leaveRect.top * pixelRatio,
180
+ left: leaveRect.left * pixelRatio,
181
+ width: leaveRect.width * pixelRatio,
182
+ height: leaveRect.height * pixelRatio,
183
+ },
184
+ } as DecorationPointerLeaveData);
185
+ this.hoveredItem = undefined;
186
+ }
88
187
  }
89
188
 
90
189
  /**
@@ -116,15 +215,49 @@ class DecorationGroup {
116
215
  this.notTextFlag?.set(id, true);
117
216
  }
118
217
  }
218
+ // Walk up from both ends of the range to detect inline SVG ancestry (namespace check
219
+ // catches <text> inside <svg> which tag-name checks above would miss).
220
+ if(this.experimentalHighlights && !this.notTextFlag?.has(id)) {
221
+ const hasSvgAncestor = (node: Node | null): boolean => {
222
+ while (node && node.nodeType === Node.ELEMENT_NODE) {
223
+ if ((node as Element).namespaceURI?.includes("svg")) return true;
224
+ node = node.parentNode;
225
+ }
226
+ return false;
227
+ };
228
+ if (hasSvgAncestor(range.startContainer) || hasSvgAncestor(range.endContainer)) {
229
+ this.notTextFlag?.set(id, true);
230
+ }
231
+ }
232
+ if (this.experimentalHighlights) {
233
+ const { type } = decoration.style;
234
+ const { layout, width, expand } = decoration.style as BuiltinDecorationStyle;
235
+ // CSS Highlight API only handles text-level highlight styling (boxes + wrap).
236
+ // Everything else must go through the DOM overlay path.
237
+ const needsDomOverlay =
238
+ type !== DecorationStyleType.TextColor && (
239
+ type === DecorationStyleType.Outline ||
240
+ type === DecorationStyleType.Template ||
241
+ type === DecorationStyleType.Mask ||
242
+ (layout !== undefined && layout !== DecorationLayout.Boxes) ||
243
+ (width !== undefined && width !== DecorationWidth.Wrap) ||
244
+ !!expand
245
+ );
246
+ if (needsDomOverlay) this.notTextFlag?.set(id, true);
247
+ }
119
248
 
120
249
  const item = {
121
250
  decoration,
122
251
  id,
123
252
  range,
253
+ hitRects: [],
254
+ clickableElements: undefined,
255
+ container: undefined,
124
256
  } as DecorationItem;
125
257
 
126
258
  this.items.push(item);
127
259
  this.layout(item);
260
+ item.hitRects = this.clientRectsToDocCoords(getClientRectsNoOverlap(item.range, false, false, ((item.decoration.style as BuiltinDecorationStyle).expand ?? 0) + this.hitGap()));
128
261
  this.renderLayout([item]);
129
262
  }
130
263
 
@@ -137,18 +270,32 @@ class DecorationGroup {
137
270
  if (index < 0) return;
138
271
 
139
272
  const item = this.items[index];
273
+ const wasMask = item.decoration.style?.type === DecorationStyleType.Mask;
274
+
140
275
  this.items.splice(index, 1);
141
276
  item.clickableElements = undefined;
142
277
  if (item.container) {
143
278
  item.container.remove();
144
279
  item.container = undefined;
145
280
  }
146
- if (this.experimentalHighlights && !this.notTextFlag?.has(item.id)) {
147
- // Remove highlight from ranges
148
- const mm = ((this.wnd as any).CSS.highlights as Map<string, unknown>).get(this.id) as Set<Range>;
149
- mm?.delete(item.range);
281
+ if (this.experimentalHighlights && !this.notTextFlag?.has(item.id) && item.highlightSubKey) {
282
+ const cssHighlights = (this.wnd as any).CSS.highlights as Map<string, any>;
283
+ cssHighlights.get(item.highlightSubKey)?.delete(item.range);
284
+ if (!this.items.some(i => i.highlightSubKey === item.highlightSubKey)) {
285
+ cssHighlights.delete(item.highlightSubKey);
286
+ }
287
+ const stylesheet = this.wnd.document.getElementById(`${this.id}-style`) as HTMLStyleElement | null;
288
+ if (stylesheet) this._rebuildHighlightStylesheet(stylesheet);
150
289
  }
151
290
  this.notTextFlag?.delete(item.id);
291
+ if (this.hoveredItem === item) {
292
+ this.hoveredItem = undefined;
293
+ }
294
+
295
+ // Update shared mask if we removed a mask decoration
296
+ if (wasMask) {
297
+ this.updateSharedMask();
298
+ }
152
299
  }
153
300
 
154
301
  /**
@@ -167,6 +314,167 @@ class DecorationGroup {
167
314
  this.clearContainer();
168
315
  this.items.length = 0;
169
316
  this.notTextFlag?.clear();
317
+ this.hoveredItem = undefined;
318
+ // Clear shared mask
319
+ if (this.maskSvg) {
320
+ this.maskSvg.remove();
321
+ this.maskSvg = undefined;
322
+ }
323
+ if (this.shadowHost) {
324
+ this.shadowHost.remove();
325
+ this.shadowHost = undefined;
326
+ this.shadowRoot = undefined;
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Removes all decorations and tears down event listeners.
332
+ * Must be called when the group is permanently discarded.
333
+ */
334
+ destroy() {
335
+ this.clear();
336
+ this.wnd.document.removeEventListener("pointerup", this.activationHandler);
337
+ this.wnd.document.removeEventListener("pointermove", this.hoverHandler);
338
+ }
339
+
340
+ private clientRectsToDocCoords(rects: Rect[]): Rect[] {
341
+ const ctx = makeWritingContext(this.wnd);
342
+ const dx = ctx.xDocOffset;
343
+ const dy = ctx.yDocOffset;
344
+ if (dx === 0 && dy === 0) return rects;
345
+ return rects.map(r => ({
346
+ left: r.left + dx, top: r.top + dy,
347
+ right: r.right + dx, bottom: r.bottom + dy,
348
+ width: r.width, height: r.height,
349
+ }));
350
+ }
351
+
352
+ private pointerToDocCoords(e: PointerEvent): { docX: number; docY: number } {
353
+ const ctx = makeWritingContext(this.wnd);
354
+ return { docX: e.clientX + ctx.xDocOffset, docY: e.clientY + ctx.yDocOffset };
355
+ }
356
+
357
+ private effectiveZoom(): number {
358
+ if (!sML.UA.Blink) return 1;
359
+ const rootZoom = parseFloat(this.wnd.getComputedStyle(this.wnd.document.documentElement).zoom);
360
+ const bodyZoom = parseFloat(this.wnd.getComputedStyle(this.wnd.document.body).zoom);
361
+ return (rootZoom || 1) * (bodyZoom || 1);
362
+ }
363
+
364
+ private hitGap(): number {
365
+ return 2 * this.effectiveZoom();
366
+ }
367
+
368
+ private handleActivation(e: PointerEvent) {
369
+ if (!this._activatable) return;
370
+ const { docX, docY } = this.pointerToDocCoords(e);
371
+ const pixelRatio = this.wnd.devicePixelRatio;
372
+
373
+ for (const item of this.items) {
374
+ let hitRect: DOMRect | undefined;
375
+
376
+ if (item.decoration.style.type === DecorationStyleType.Template) {
377
+ // Templates can be positioned anywhere (e.g. a margin sidemark), so hit-test
378
+ // against the rendered elements rather than the text range rects.
379
+ for (const el of (item.clickableElements ?? [])) {
380
+ const r = el.getBoundingClientRect();
381
+ if (rectContainsPoint(r as Rect, e.clientX, e.clientY, 0)) {
382
+ hitRect = r;
383
+ break;
384
+ }
385
+ }
386
+ } else {
387
+ // Use pre-merged hit rects stored in document coordinates so they remain
388
+ // valid across column/scroll navigation.
389
+ for (const rect of item.hitRects) {
390
+ if (rectContainsPoint(rect, docX, docY, 0)) {
391
+ hitRect = item.range.getBoundingClientRect();
392
+ break;
393
+ }
394
+ }
395
+ }
396
+
397
+ if (hitRect) {
398
+ this.comms.send("decoration_activated", {
399
+ decorationId: item.decoration.id,
400
+ group: this.name,
401
+ rect: {
402
+ top: hitRect.top * pixelRatio,
403
+ left: hitRect.left * pixelRatio,
404
+ width: hitRect.width * pixelRatio,
405
+ height: hitRect.height * pixelRatio,
406
+ },
407
+ point: { x: e.clientX * pixelRatio, y: e.clientY * pixelRatio },
408
+ } as DecorationActivatedEvent);
409
+ return;
410
+ }
411
+ }
412
+ }
413
+
414
+ private handleHover(e: PointerEvent) {
415
+ if (!this._hoverable) return;
416
+ const { docX, docY } = this.pointerToDocCoords(e);
417
+ const pixelRatio = this.wnd.devicePixelRatio;
418
+
419
+ let hitItem: DecorationItem | undefined;
420
+ let hitRect: DOMRect | undefined;
421
+
422
+ for (const item of this.items) {
423
+ if (item.decoration.style.type === DecorationStyleType.Template) {
424
+ for (const el of (item.clickableElements ?? [])) {
425
+ const r = el.getBoundingClientRect();
426
+ if (rectContainsPoint(r as Rect, e.clientX, e.clientY, 0)) {
427
+ hitItem = item;
428
+ hitRect = r;
429
+ break;
430
+ }
431
+ }
432
+ } else {
433
+ for (const rect of item.hitRects) {
434
+ if (rectContainsPoint(rect, docX, docY, 0)) {
435
+ hitItem = item;
436
+ hitRect = item.range.getBoundingClientRect();
437
+ break;
438
+ }
439
+ }
440
+ }
441
+
442
+ if (hitItem) break;
443
+ }
444
+
445
+ if (hitItem === this.hoveredItem) return;
446
+
447
+ if (this.hoveredItem) {
448
+ const connected = this.hoveredItem.range.commonAncestorContainer.isConnected;
449
+ const leaveRect = connected ? this.hoveredItem.range.getBoundingClientRect() : null;
450
+ this.comms.send("decoration_pointer_leave", {
451
+ decorationId: this.hoveredItem.decoration.id,
452
+ group: this.name,
453
+ rect: leaveRect ? {
454
+ top: leaveRect.top * pixelRatio,
455
+ left: leaveRect.left * pixelRatio,
456
+ width: leaveRect.width * pixelRatio,
457
+ height: leaveRect.height * pixelRatio,
458
+ } : undefined,
459
+ point: { x: e.clientX * pixelRatio, y: e.clientY * pixelRatio },
460
+ } as DecorationPointerLeaveData);
461
+ }
462
+
463
+ this.hoveredItem = hitItem;
464
+
465
+ if (hitItem && hitRect) {
466
+ this.comms.send("decoration_pointer_enter", {
467
+ decorationId: hitItem.decoration.id,
468
+ group: this.name,
469
+ rect: {
470
+ top: hitRect.top * pixelRatio,
471
+ left: hitRect.left * pixelRatio,
472
+ width: hitRect.width * pixelRatio,
473
+ height: hitRect.height * pixelRatio,
474
+ },
475
+ point: { x: e.clientX * pixelRatio, y: e.clientY * pixelRatio },
476
+ } as DecorationPointerEnterData);
477
+ }
170
478
  }
171
479
 
172
480
  /**
@@ -176,24 +484,175 @@ class DecorationGroup {
176
484
  requestLayout() {
177
485
  this.wnd.cancelAnimationFrame(this.currentRender);
178
486
  this.clearContainer();
179
- this.items.forEach(i => this.layout(i));
180
- this.renderLayout(this.items);
487
+ // Wait for fonts to finish loading before reading geometry, then use a
488
+ // rAF to ensure the browser has finished reflowing with the new metrics.
489
+ // Without this, font-family / zoom changes cause positions to be read
490
+ // against stale or fallback-font layout.
491
+ this.wnd.document.fonts.ready.then(() => {
492
+ this.currentRender = this.wnd.requestAnimationFrame(() => {
493
+ this.items.forEach(i => {
494
+ this.layout(i);
495
+ i.hitRects = this.clientRectsToDocCoords(getClientRectsNoOverlap(i.range, false, false, ((i.decoration.style as BuiltinDecorationStyle).expand ?? 0) + this.hitGap()));
496
+ });
497
+ this.renderLayout(this.items);
498
+ // Update shared mask after layout
499
+ this.updateSharedMask();
500
+ });
501
+ });
181
502
  }
182
503
 
183
504
  private experimentalLayout(item: DecorationItem) {
184
- const [stylesheet, highlighter]: [HTMLStyleElement, any] = this.requireContainer(true) as [HTMLStyleElement, unknown];
185
- highlighter.add(item.range);
505
+ const stylesheet = this.requireContainer(true) as HTMLStyleElement;
506
+ const cssHighlights = (this.wnd as any).CSS.highlights as Map<string, any>;
507
+
508
+ // Template items are always routed to the DOM overlay; only BuiltinDecorationStyle reaches here.
509
+ const style = item.decoration.style as BuiltinDecorationStyle;
510
+ const type = style.type ?? DecorationStyleType.Highlight;
511
+ const tint = style.tint ?? defaultTint(type);
512
+ const width = style.width;
513
+ const layout = style.layout;
514
+
515
+ // Group by (type, tint) — items sharing the same visual style share one sub-highlight and one CSS rule.
516
+ const subKey = this._getSubKey(type, tint);
517
+
518
+ // On update: remove this item's range from its previous sub-highlight.
519
+ if (item.highlightSubKey) {
520
+ const oldSub = cssHighlights.get(item.highlightSubKey) as any;
521
+ oldSub?.delete(item.range);
522
+ if (item.highlightSubKey !== subKey &&
523
+ !this.items.some(i => i !== item && i.highlightSubKey === item.highlightSubKey)) {
524
+ cssHighlights.delete(item.highlightSubKey);
525
+ }
526
+ }
527
+ item.highlightSubKey = subKey;
528
+
529
+ // Get or create the shared sub-highlight for this tint group.
530
+ let sub: any;
531
+ if (cssHighlights.has(subKey)) {
532
+ sub = cssHighlights.get(subKey);
533
+ } else {
534
+ sub = new (this.wnd as any).Highlight();
535
+ cssHighlights.set(subKey, sub);
536
+ }
186
537
 
187
- const backgroundColor = getProperty(this.wnd, "--USER__backgroundColor") ||
188
- this.wnd.getComputedStyle(this.wnd.document.documentElement).getPropertyValue("background-color");
189
- const tint = item.decoration?.style?.tint ?? DEFAULT_HIGHLIGHT_COLOR;
538
+ // Helper for caret position
539
+ const caretPositionFromPoint = (x: number, y: number): CaretPosition | null => {
540
+ return this.wnd.document.caretPositionFromPoint?.(x, y) ?? null;
541
+ };
542
+
543
+ // TextColor range registration: expand to bounding rect when layout/width asks for it.
544
+ if (
545
+ type === DecorationStyleType.TextColor &&
546
+ (layout === DecorationLayout.Bounds || width === DecorationWidth.Bounds || width === DecorationWidth.Page)
547
+ ) {
548
+ // For vertical writing, caretPositionFromPoint has browser bugs - use fallback
549
+ const ctx = makeWritingContext(this.wnd);
550
+ if (ctx.isVertical) {
551
+ console.warn('Vertical writing detected: caretPositionFromPoint has known bugs, falling back to original range');
552
+ sub.add(item.range);
553
+ } else {
554
+ const boundingRect = item.range.getBoundingClientRect();
555
+ // Page snaps to the full page inline extent; Bounds/layout:Bounds uses the actual bounding rect.
556
+ let inlineOrigin: number;
557
+ let inlineExtent: number;
558
+ if (width === DecorationWidth.Page) {
559
+ const snap = Math.floor(ctx.inlineStart(boundingRect) / ctx.pageInlineSize) * ctx.pageInlineSize;
560
+ inlineOrigin = snap;
561
+ inlineExtent = ctx.pageInlineSize;
562
+ } else {
563
+ inlineOrigin = ctx.inlineStart(boundingRect);
564
+ inlineExtent = ctx.inlineSize(boundingRect);
565
+ }
566
+ const startCaret = caretPositionFromPoint(inlineOrigin, ctx.blockStart(boundingRect) + 1);
567
+ const endCaret = caretPositionFromPoint(inlineOrigin + inlineExtent, ctx.blockStart(boundingRect) + ctx.blockSize(boundingRect) - 1);
568
+ if (startCaret && endCaret) {
569
+ const expandedRange = this.wnd.document.createRange();
570
+ expandedRange.setStart(startCaret.offsetNode, startCaret.offset);
571
+ expandedRange.setEnd(endCaret.offsetNode, endCaret.offset);
572
+ sub.add(expandedRange);
573
+ item.range = expandedRange;
574
+ } else {
575
+ sub.add(item.range);
576
+ }
577
+ }
578
+ } else {
579
+ sub.add(item.range);
580
+ }
190
581
 
191
- // TODO add caching layer ("vdom") to this so we aren't completely replacing the CSS every time
192
- stylesheet.innerHTML = `
193
- ::highlight(${this.id}) {
194
- color: ${getContrastingTextColor(tint, backgroundColor)};
195
- background-color: ${tint};
196
- }`;
582
+ const backgroundColor = this.getBackgroundColor();
583
+ const applyContrast = style.enforceContrast !== false;
584
+ const adjustedTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
585
+
586
+ let css: string;
587
+ switch (type) {
588
+ case DecorationStyleType.Underline:
589
+ css = `::highlight(${subKey}) {
590
+ text-decoration: underline;
591
+ text-decoration-color: ${adjustedTint};
592
+ text-decoration-thickness: 0.1em;
593
+ }`;
594
+ break;
595
+ case DecorationStyleType.Strikethrough:
596
+ css = `::highlight(${subKey}) {
597
+ text-decoration: line-through;
598
+ text-decoration-color: ${adjustedTint};
599
+ text-decoration-thickness: 0.1em;
600
+ }`;
601
+ break;
602
+ case DecorationStyleType.Outline:
603
+ css = `::highlight(${subKey}) {
604
+ outline: 2px solid ${adjustedTint};
605
+ outline-offset: 1px;
606
+ }`;
607
+ break;
608
+ case DecorationStyleType.TextColor:
609
+ css = `::highlight(${subKey}) {
610
+ color: ${adjustedTint};
611
+ }`;
612
+ break;
613
+ case DecorationStyleType.HighlightUnderline: {
614
+ const { r, g, b } = colorToRgba(adjustedTint);
615
+ const fillTint = `rgba(${r}, ${g}, ${b}, 0.3)`;
616
+ css = `::highlight(${subKey}) {
617
+ color: ${getContrastingTextColor(adjustedTint, backgroundColor)};
618
+ background-color: ${fillTint};
619
+ text-decoration: underline;
620
+ text-decoration-color: ${adjustedTint};
621
+ text-decoration-thickness: 0.1em;
622
+ }`;
623
+ break;
624
+ }
625
+ case DecorationStyleType.Highlight:
626
+ default:
627
+ css = `::highlight(${subKey}) {
628
+ color: ${getContrastingTextColor(adjustedTint, backgroundColor)};
629
+ background-color: ${adjustedTint};
630
+ }`;
631
+ }
632
+ item.highlightCSS = css;
633
+ this._rebuildHighlightStylesheet(stylesheet);
634
+ }
635
+
636
+ private _getSubKey(type: DecorationStyleType | string, tint: string): string {
637
+ const fingerprint = `${type}::${tint}`;
638
+ let subKey = this._tintSubKeys.get(fingerprint);
639
+ if (!subKey) {
640
+ subKey = `${this.id}--${this._subKeyCounter++}`;
641
+ this._tintSubKeys.set(fingerprint, subKey);
642
+ }
643
+ return subKey;
644
+ }
645
+
646
+ private _rebuildHighlightStylesheet(stylesheet: HTMLStyleElement) {
647
+ const seen = new Set<string>();
648
+ const rules: string[] = [];
649
+ for (const item of this.items) {
650
+ if (item.highlightSubKey && item.highlightCSS && !seen.has(item.highlightSubKey)) {
651
+ seen.add(item.highlightSubKey);
652
+ rules.push(item.highlightCSS);
653
+ }
654
+ }
655
+ stylesheet.innerHTML = rules.join("\n");
197
656
  }
198
657
 
199
658
  /**
@@ -213,97 +672,223 @@ class DecorationGroup {
213
672
  // itemContainer.dataset.style = item.decoration.style; // TODO style
214
673
  itemContainer.style.setProperty("pointer-events", "none");
215
674
 
216
- const viewportWidth = this.wnd.innerWidth;
217
- const columnCount = parseInt(
218
- getComputedStyle(this.wnd.document.documentElement).getPropertyValue(
219
- "column-count"
220
- )
221
- );
222
- const pageWidth = viewportWidth / (columnCount || 1);
223
- const scrollingElement = this.wnd.document.scrollingElement!;
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`;
675
+ const ctx = makeWritingContext(this.wnd);
676
+
677
+ const iz = 1 / this.effectiveZoom();
678
+
679
+ const expand = (item.decoration.style as BuiltinDecorationStyle).expand ?? 0;
680
+ const positionElement = (element: HTMLElement, rect: Rect, boundingRect: DOMRect, inlineInset = 0) => {
681
+ const w = item.decoration?.style?.width;
682
+ const r = rect;
683
+ switch (w) {
684
+ case DecorationWidth.Viewport: {
685
+ const snap = Math.floor(ctx.inlineStart(r) / ctx.viewportInlineSize) * ctx.viewportInlineSize;
686
+ ctx.applyPosition(element, snap + ctx.inlineScrollOffset + inlineInset, ctx.blockStart(r) + ctx.blockScrollOffset, ctx.viewportInlineSize - 2 * inlineInset, ctx.blockSize(r), iz);
687
+ break;
688
+ }
689
+ case DecorationWidth.Page: {
690
+ const snap = Math.floor(ctx.inlineStart(r) / ctx.pageInlineSize) * ctx.pageInlineSize;
691
+ ctx.applyPosition(element, snap + ctx.inlineScrollOffset + inlineInset, ctx.blockStart(r) + ctx.blockScrollOffset, ctx.pageInlineSize - 2 * inlineInset, ctx.blockSize(r), iz);
692
+ break;
693
+ }
694
+ case DecorationWidth.Bounds: {
695
+ ctx.applyPosition(element, ctx.inlineStart(boundingRect) + ctx.inlineScrollOffset, ctx.blockStart(r) + ctx.blockScrollOffset, ctx.inlineSize(boundingRect), ctx.blockSize(r), iz);
696
+ break;
697
+ }
698
+ default: {
699
+ ctx.applyPosition(element, ctx.inlineStart(r) + ctx.inlineScrollOffset, ctx.blockStart(r) + ctx.blockScrollOffset, ctx.inlineSize(r), ctx.blockSize(r), iz);
700
+ }
254
701
  }
255
702
  }
256
-
257
703
  const boundingRect = item.range.getBoundingClientRect();
258
704
 
259
- let template = this.wnd.document.createElement("template");
260
- // template.innerHTML = item.decoration.element.trim();
261
- // TODO more styles logic
262
-
263
- const isDarkMode = this.getCurrentDarkMode();
264
-
265
- template.innerHTML = `
266
- <div
267
- data-readium="true"
268
- class="readium-highlight"
269
- style="${[
270
- `background-color: ${item.decoration?.style?.tint ?? DEFAULT_HIGHLIGHT_COLOR} !important`,
271
- //"opacity: 0.3 !important",
272
- `mix-blend-mode: ${isDarkMode ? "exclusion" : "multiply"} !important`,
273
- "opacity: 1 !important",
274
- "box-sizing: border-box !important"
275
- ].join("; ")}"
276
- >
277
- </div>
278
- `.trim();
279
- const elementTemplate = template.content.firstElementChild!;
280
-
281
- if(item.decoration?.style?.layout === Layout.Bounds) {
705
+ const decoStyle = item.decoration.style;
706
+ // outline: 2px + outline-offset: 1px = 3px bleed outside the box on each side.
707
+ // For Page/Viewport widths the snap edge coincides with the viewport edge, so the
708
+ // outline would be clipped. Inset the element to give that bleed room to render.
709
+ const outlineInset = (() => {
710
+ if ((decoStyle as BuiltinDecorationStyle).type !== DecorationStyleType.Outline) return 0;
711
+ const w = (decoStyle as BuiltinDecorationStyle).width;
712
+ return (w === DecorationWidth.Page || w === DecorationWidth.Viewport) ? 3 : 0;
713
+ })();
714
+ let elementTemplate: Element;
715
+
716
+ if (decoStyle.type === DecorationStyleType.Template) {
717
+ // HTMLDecorationTemplate — fully custom HTML provided by the caller.
718
+ if (decoStyle.stylesheet) {
719
+ this.injectCustomStylesheet(decoStyle.stylesheet);
720
+ }
721
+ const customEl = sanitizeHTML(this.wnd, decoStyle.element) as HTMLElement | null;
722
+ if (!customEl) {
723
+ item.container = itemContainer;
724
+ item.clickableElements = [];
725
+ return;
726
+ }
727
+ customEl.style.setProperty("pointer-events", "none");
728
+ elementTemplate = customEl;
729
+ } else {
730
+ // BuiltinDecorationStyle path.
731
+ const style = decoStyle as BuiltinDecorationStyle;
732
+ const type = style.type ?? DecorationStyleType.Highlight;
733
+ const tint = style.tint ?? defaultTint(type);
734
+
735
+ // TextColor requires CSS Highlight API; DOM overlay has no equivalent.
736
+ if (type === DecorationStyleType.TextColor) {
737
+ item.container = itemContainer;
738
+ item.clickableElements = [];
739
+ return;
740
+ }
741
+
742
+ // Mask: dim overlay covering the full document with SVG clip-path holes.
743
+ if (type === DecorationStyleType.Mask) {
744
+ // Mask decorations use a shared overlay - just mark the item and update the shared mask
745
+ item.container = itemContainer;
746
+ item.clickableElements = [];
747
+ this.updateSharedMask();
748
+ return;
749
+ }
750
+
751
+ const isDarkMode = this.getCurrentDarkMode();
752
+ const backgroundColor = this.getBackgroundColor();
753
+ const applyContrast = style.enforceContrast !== false;
754
+ const styleAttr = (() => {
755
+ switch (type) {
756
+ case DecorationStyleType.Underline: {
757
+ const adjustedUnderlineTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
758
+ const isBounds = style.layout === DecorationLayout.Bounds;
759
+ const [underlineSide, overlineSide] = ctx.isVertical
760
+ ? ["border-right", "border-left"]
761
+ : ["border-bottom", "border-top"];
762
+ return [
763
+ isBounds
764
+ ? `${overlineSide}: 0.1em solid ${adjustedUnderlineTint} !important`
765
+ : null,
766
+ `${underlineSide}: 0.1em solid ${adjustedUnderlineTint} !important`,
767
+ "background-color: transparent !important",
768
+ "box-sizing: border-box !important",
769
+ ].filter(Boolean).join("; ");
770
+ }
771
+ case DecorationStyleType.Strikethrough: {
772
+ const adjustedStrikeTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
773
+ const isBounds = style.layout === DecorationLayout.Bounds;
774
+ if (isBounds) {
775
+ // Bounds covers the full height of the selection — use diagonal hatch lines
776
+ // so the text remains readable (physical "crossing out" appearance).
777
+ return [
778
+ `background: repeating-linear-gradient(-45deg, transparent, transparent 19px, ${adjustedStrikeTint} 19px, ${adjustedStrikeTint} 20px) !important`,
779
+ "background-color: transparent !important",
780
+ "box-sizing: border-box !important",
781
+ ].join("; ");
782
+ }
783
+ // Boxes path: the rect is thinned and centred by the boxes loop below; just fill it.
784
+ return [
785
+ `background-color: ${adjustedStrikeTint} !important`,
786
+ "box-sizing: border-box !important",
787
+ ].join("; ");
788
+ }
789
+ case DecorationStyleType.Outline:
790
+ const adjustedOutlineTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
791
+ return [
792
+ `outline: 2px solid ${adjustedOutlineTint} !important`,
793
+ "outline-offset: 1px !important",
794
+ "background-color: transparent !important",
795
+ "box-sizing: border-box !important",
796
+ ].join("; ");
797
+ case DecorationStyleType.HighlightUnderline: {
798
+ const adjustedHUTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
799
+ const { r, g, b } = colorToRgba(adjustedHUTint);
800
+ const huFillTint = `rgba(${r}, ${g}, ${b}, 0.3)`;
801
+ const isBounds = style.layout === DecorationLayout.Bounds;
802
+ const [huUnderlineSide, huOverlineSide] = ctx.isVertical
803
+ ? ["border-right", "border-left"]
804
+ : ["border-bottom", "border-top"];
805
+ return [
806
+ `background-color: ${huFillTint} !important`,
807
+ isBounds ? `${huOverlineSide}: 0.1em solid ${adjustedHUTint} !important` : null,
808
+ `${huUnderlineSide}: 0.1em solid ${adjustedHUTint} !important`,
809
+ "box-sizing: border-box !important",
810
+ ].filter(Boolean).join("; ");
811
+ }
812
+ case DecorationStyleType.Highlight:
813
+ default: {
814
+ const adjustedHighlightTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
815
+ return [
816
+ `background-color: ${adjustedHighlightTint} !important`,
817
+ `mix-blend-mode: ${isDarkMode ? "exclusion" : "multiply"} !important`,
818
+ "opacity: 1 !important",
819
+ "box-sizing: border-box !important",
820
+ ].join("; ");
821
+ }
822
+ }
823
+ })();
824
+
825
+ const template = this.wnd.document.createElement("template");
826
+ template.innerHTML = `<div data-readium="true" class="readium-${type}" style="${styleAttr}"></div>`.trim();
827
+ elementTemplate = template.content.firstElementChild!;
828
+ }
829
+
830
+ if(item.decoration?.style?.layout === DecorationLayout.Bounds) {
282
831
  const bounds = elementTemplate.cloneNode(true) as HTMLDivElement;
283
832
  bounds.style.setProperty("pointer-events", "none");
284
- positionElement(bounds, boundingRect, boundingRect);
833
+ const boundsRect: Rect = expand ? {
834
+ left: boundingRect.left - expand,
835
+ right: boundingRect.right + expand,
836
+ top: boundingRect.top - expand,
837
+ bottom: boundingRect.bottom + expand,
838
+ width: boundingRect.width + expand * 2,
839
+ height: boundingRect.height + expand * 2,
840
+ } : boundingRect;
841
+ positionElement(bounds, boundsRect, boundingRect, outlineInset);
285
842
  itemContainer.append(bounds);
286
843
  } else {
287
- // Fall back to "boxes" value for layout
844
+ // Fall back to "boxes" value for layout.
845
+ // For underline/strikethrough, pre-filter to text-node rects only so Ruby
846
+ // (rt/rp) glyphs don't produce a separate decoration segment above the base text.
847
+ const decoType = (decoStyle as BuiltinDecorationStyle).type;
848
+ const isLineDecoration = decoType === DecorationStyleType.Underline
849
+ || decoType === DecorationStyleType.Strikethrough;
850
+ const isStrikethrough = decoType === DecorationStyleType.Strikethrough;
851
+ const rectSource = isLineDecoration
852
+ ? getTextClientRects(item.range, ["rt", "rp"])
853
+ : item.range;
854
+ // Line decorations (underline/strikethrough) don't expand in the block axis —
855
+ // expand extends endpoints along the inline axis only.
288
856
  let clientRects = getClientRectsNoOverlap(
289
- item.range,
290
- true // doNotMergeHorizontallyAlignedRects
857
+ rectSource,
858
+ true, // doNotMergeHorizontallyAlignedRects
859
+ ctx.isVertical, // doNotMergeVerticallyAlignedRects
860
+ isLineDecoration ? 0 : expand
291
861
  );
292
862
 
293
863
  clientRects = clientRects.sort((r1, r2) => {
294
- if (r1.top < r2.top) {
295
- return -1;
296
- } else if (r1.top > r2.top) {
297
- return 1;
298
- } else {
299
- return 0;
864
+ if (ctx.isVertical) {
865
+ // vertical-rl: rightmost column first; vertical-lr: leftmost first
866
+ const factor = ctx.isVertLR ? 1 : -1;
867
+ return factor * (r1.left - r2.left);
300
868
  }
869
+ return r1.top - r2.top;
301
870
  });
302
871
 
303
872
  for (let clientRect of clientRects) {
304
873
  const line = elementTemplate.cloneNode(true) as HTMLDivElement;
305
874
  line.style.setProperty("pointer-events", "none");
306
- positionElement(line, clientRect, boundingRect);
875
+ let posRect: Rect = clientRect;
876
+ if (isStrikethrough) {
877
+ // Thin the rect to ~10% of block size, centred on the mid-line.
878
+ const thickness = ctx.blockSize(clientRect) * 0.1;
879
+ const blockMid = ctx.blockStart(clientRect) + ctx.blockSize(clientRect) / 2;
880
+ const bs = blockMid - thickness / 2;
881
+ posRect = ctx.isVertical
882
+ ? { left: bs, right: bs + thickness, top: clientRect.top, bottom: clientRect.bottom, width: thickness, height: clientRect.height }
883
+ : { top: bs, bottom: bs + thickness, left: clientRect.left, right: clientRect.right, height: thickness, width: clientRect.width };
884
+ }
885
+ // Expand line decorations along the inline axis only (extend endpoints, not block size).
886
+ if (expand && isLineDecoration) {
887
+ posRect = ctx.isVertical
888
+ ? { ...posRect, top: posRect.top - expand, bottom: posRect.bottom + expand, height: posRect.height + expand * 2 }
889
+ : { ...posRect, left: posRect.left - expand, right: posRect.right + expand, width: posRect.width + expand * 2 };
890
+ }
891
+ positionElement(line, posRect, boundingRect, outlineInset);
307
892
  itemContainer.append(line);
308
893
  }
309
894
  }
@@ -332,7 +917,7 @@ class DecorationGroup {
332
917
  * Returns the group container element, after making sure it exists.
333
918
  * @returns Group's container
334
919
  */
335
- private requireContainer(experimental=false): [HTMLStyleElement, any] | HTMLDivElement {
920
+ private requireContainer(experimental=false): HTMLStyleElement | HTMLDivElement {
336
921
  if (experimental) {
337
922
  // Setup <style> for highlights
338
923
  let d: HTMLStyleElement;
@@ -345,33 +930,191 @@ class DecorationGroup {
345
930
  this.wnd.document.head.appendChild(d);
346
931
  }
347
932
 
348
- // Setup CSS.highlights
349
- let h: unknown;
350
- if (((this.wnd as any).CSS.highlights as Map<string, unknown>).has(this.id)) {
351
- h = ((this.wnd as any).CSS.highlights as Map<string, unknown>).get(this.id)
352
- } else {
353
- h = new (this.wnd as any).Highlight();
354
- ((this.wnd as any).CSS.highlights as Map<string, unknown>).set(this.id, h);
355
- }
356
- return [d, h];
933
+ return d;
357
934
  }
358
935
 
359
936
  if (!this.container) {
937
+ // Create shared shadow host if it doesn't exist
938
+ if (!this.shadowRoot) {
939
+ this.shadowHost = this.wnd.document.createElement("div");
940
+ this.shadowHost.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none";
941
+ this.wnd.document.body.appendChild(this.shadowHost);
942
+ this.shadowRoot = this.shadowHost.attachShadow({ mode: "open" });
943
+ }
944
+
945
+ // Create container in shared shadow root
360
946
  this.container = this.wnd.document.createElement("div");
361
947
  this.container.setAttribute("id", this.id);
362
948
  this.container.dataset.group = this.name;
363
949
  this.container.dataset.readium = "true";
364
950
  this.container.style.setProperty("pointer-events", "none");
365
951
  this.container.style.display = "contents";
366
- this.wnd.document.body.append(this.container);
952
+ this.shadowRoot.appendChild(this.container);
367
953
  }
368
954
  return this.container;
369
955
  }
370
956
 
371
957
  getCurrentDarkMode(): boolean {
372
958
  return getProperty(this.wnd, "--USER__appearance") === "readium-night-on" ||
373
- isDarkColor(getProperty(this.wnd, "--USER__backgroundColor")) ||
374
- isDarkColor(this.wnd.getComputedStyle(this.wnd.document.documentElement).getPropertyValue("background-color"));
959
+ isDarkColor(this.getBackgroundColor());
960
+ }
961
+
962
+ getBackgroundColor(): string {
963
+ return getProperty(this.wnd, "--USER__backgroundColor") ||
964
+ this.wnd.getComputedStyle(this.wnd.document.documentElement).getPropertyValue("background-color");
965
+ }
966
+
967
+ private updateSharedMask() {
968
+ const maskItems = this.items.filter(item =>
969
+ item.decoration.style?.type === DecorationStyleType.Mask
970
+ );
971
+
972
+ if (maskItems.length === 0) {
973
+ // Remove shared mask if no mask decorations exist
974
+ if (this.maskSvg) {
975
+ this.maskSvg.remove();
976
+ this.maskSvg = undefined;
977
+ }
978
+ if (this.shadowRoot) {
979
+ this.shadowRoot.innerHTML = '';
980
+ }
981
+ return;
982
+ }
983
+
984
+ const ctx = makeWritingContext(this.wnd);
985
+
986
+ const iz = 1 / this.effectiveZoom();
987
+
988
+ // Collect all hole rects from mask decorations
989
+ const docEl = this.wnd.document.documentElement;
990
+ const docW = docEl.scrollWidth;
991
+ const docH = docEl.scrollHeight;
992
+ const allHoleRects: DOMRect[] = [];
993
+ for (const item of maskItems) {
994
+ const style = item.decoration.style as BuiltinDecorationStyle;
995
+ const layout = style.layout ?? DecorationLayout.Boxes;
996
+ const width = style.width ?? DecorationWidth.Wrap;
997
+ const ex = style.expand ?? 0;
998
+
999
+ const boundingRect = item.range.getBoundingClientRect();
1000
+
1001
+ // For Bounds layout the hole is a single bounding rect; expand it directly.
1002
+ // For Boxes layout, merge rects first with expand baked in (same as highlights).
1003
+ const baseRects: Rect[] = layout === DecorationLayout.Bounds
1004
+ ? [ex ? { left: boundingRect.left - ex, top: boundingRect.top - ex, right: boundingRect.right + ex, bottom: boundingRect.bottom + ex, width: boundingRect.width + ex * 2, height: boundingRect.height + ex * 2 } : boundingRect]
1005
+ : getClientRectsNoOverlap(item.range, false, false, ex);
1006
+
1007
+ for (const rect of baseRects) {
1008
+ let hole: DOMRect;
1009
+ switch (width) {
1010
+ case DecorationWidth.Viewport: {
1011
+ const snap = Math.floor(ctx.inlineStart(rect) / ctx.viewportInlineSize) * ctx.viewportInlineSize;
1012
+ hole = ctx.toRect(snap, ctx.blockStart(rect), ctx.viewportInlineSize, ctx.blockSize(rect));
1013
+ break;
1014
+ }
1015
+ case DecorationWidth.Page: {
1016
+ const snap = Math.floor(ctx.inlineStart(rect) / ctx.pageInlineSize) * ctx.pageInlineSize;
1017
+ hole = ctx.toRect(snap, ctx.blockStart(rect), ctx.pageInlineSize, ctx.blockSize(rect));
1018
+ break;
1019
+ }
1020
+ case DecorationWidth.Bounds: {
1021
+ hole = ctx.toRect(ctx.inlineStart(boundingRect), ctx.blockStart(rect), ctx.inlineSize(boundingRect), ctx.blockSize(rect));
1022
+ break;
1023
+ }
1024
+ default:
1025
+ hole = ctx.toRect(ctx.inlineStart(rect), ctx.blockStart(rect), ctx.inlineSize(rect), ctx.blockSize(rect));
1026
+ }
1027
+ allHoleRects.push(hole);
1028
+ }
1029
+ }
1030
+
1031
+ // Build SVG path with all holes (physical coords — always left/top regardless of writing mode)
1032
+ const pathData = [
1033
+ `M0 0 H${docW} V${docH} H0 Z`,
1034
+ ...allHoleRects.map(r => {
1035
+ const l = (r.left + ctx.xDocOffset) * iz;
1036
+ const t = (r.top + ctx.yDocOffset) * iz;
1037
+ const ri = (r.right + ctx.xDocOffset) * iz;
1038
+ const b = (r.bottom + ctx.yDocOffset) * iz;
1039
+ return `M${l} ${t} H${ri} V${b} H${l} Z`;
1040
+ }),
1041
+ ].join(" ");
1042
+
1043
+ const svgNS = "http://www.w3.org/2000/svg";
1044
+
1045
+ // Create or update SVG
1046
+ if (!this.maskSvg) {
1047
+ // Ensure shared shadow host exists
1048
+ if (!this.shadowRoot) {
1049
+ this.shadowHost = this.wnd.document.createElement("div");
1050
+ this.shadowHost.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none";
1051
+ this.wnd.document.body.appendChild(this.shadowHost);
1052
+ this.shadowRoot = this.shadowHost.attachShadow({ mode: "open" });
1053
+ }
1054
+
1055
+ // Create SVG in shared shadow root
1056
+ this.maskSvg = this.wnd.document.createElementNS(svgNS, "svg") as SVGSVGElement;
1057
+ this.maskSvg.style.cssText = `position:absolute;top:0;left:0;width:${docW}px;height:${docH}px;pointer-events:none;z-index:9999`;
1058
+ this.maskSvg.dataset.readium = "true";
1059
+ const defs = this.wnd.document.createElementNS(svgNS, "defs");
1060
+ const clipPath = this.wnd.document.createElementNS(svgNS, "clipPath") as SVGClipPathElement;
1061
+ const clipId = `${this.id}-mask-clip`;
1062
+ clipPath.setAttribute("id", clipId);
1063
+ clipPath.setAttribute("clipPathUnits", "userSpaceOnUse");
1064
+ const svgPath = this.wnd.document.createElementNS(svgNS, "path") as SVGPathElement;
1065
+ svgPath.setAttribute("clip-rule", "evenodd");
1066
+ clipPath.appendChild(svgPath);
1067
+ defs.appendChild(clipPath);
1068
+ this.maskSvg.appendChild(defs);
1069
+
1070
+ // Add SVG rect for the overlay (bypasses ReadiumCSS)
1071
+ const maskRect = this.wnd.document.createElementNS(svgNS, "rect") as SVGRectElement;
1072
+ maskRect.setAttribute("id", `${this.id}-mask-rect`);
1073
+ maskRect.setAttribute("clip-path", `url(#${clipId})`);
1074
+ maskRect.style.pointerEvents = "none";
1075
+ this.maskSvg.appendChild(maskRect);
1076
+
1077
+ this.shadowRoot!.appendChild(this.maskSvg);
1078
+ }
1079
+
1080
+ // Update SVG dimensions to cover full document
1081
+ this.maskSvg.style.width = `${docW}px`;
1082
+ this.maskSvg.style.height = `${docH}px`;
1083
+
1084
+ // Update the path data
1085
+ const svgPath = this.maskSvg.querySelector("path") as SVGPathElement;
1086
+ if (svgPath) {
1087
+ svgPath.setAttribute("d", pathData);
1088
+ }
1089
+
1090
+ // Update the mask rect
1091
+ const maskRect = this.maskSvg.querySelector("rect") as SVGRectElement;
1092
+ if (maskRect) {
1093
+ const firstMaskStyle = maskItems[0].decoration.style as BuiltinDecorationStyle;
1094
+ const userTint = firstMaskStyle.tint;
1095
+ // User-supplied tint: alpha is their responsibility (SVG fill respects rgba natively).
1096
+ // Background color fallback: always fully opaque, so apply default dimming.
1097
+ const fillColor = userTint ?? this.getBackgroundColor() ?? defaultTint(DecorationStyleType.Mask);
1098
+ const fillOpacity = userTint ? "1" : "0.5";
1099
+ maskRect.setAttribute("x", "0");
1100
+ maskRect.setAttribute("y", "0");
1101
+ maskRect.setAttribute("width", String(docW));
1102
+ maskRect.setAttribute("height", String(docH));
1103
+ maskRect.setAttribute("fill", fillColor);
1104
+ maskRect.setAttribute("fill-opacity", fillOpacity);
1105
+ }
1106
+ }
1107
+
1108
+ private injectCustomStylesheet(css: string) {
1109
+ const id = `${this.id}-custom-style`;
1110
+ let el = this.wnd.document.getElementById(id) as HTMLStyleElement | null;
1111
+ if (!el) {
1112
+ el = this.wnd.document.createElement("style");
1113
+ el.id = id;
1114
+ el.dataset.readium = "true";
1115
+ this.wnd.document.head.appendChild(el);
1116
+ }
1117
+ el.innerHTML = css;
375
1118
  }
376
1119
 
377
1120
  /**
@@ -379,8 +1122,14 @@ class DecorationGroup {
379
1122
  */
380
1123
  private clearContainer() {
381
1124
  if (this.experimentalHighlights) {
382
- ((this.wnd as any).CSS.highlights as Map<string, unknown>).delete(this.id);
1125
+ const cssHighlights = (this.wnd as any).CSS.highlights as Map<string, unknown>;
1126
+ for (const subKey of this._tintSubKeys.values()) {
1127
+ cssHighlights.delete(subKey);
1128
+ }
1129
+ this._tintSubKeys.clear();
1130
+ this._subKeyCounter = 0;
383
1131
  }
1132
+ this.wnd.document.getElementById(`${this.id}-custom-style`)?.remove();
384
1133
  if (this.container) {
385
1134
  this.container.remove();
386
1135
  this.container = undefined;
@@ -391,8 +1140,8 @@ class DecorationGroup {
391
1140
  export class Decorator extends Module {
392
1141
  static readonly moduleName: ModuleName = "decorator";
393
1142
  private resizeObserver!: ResizeObserver;
394
- private backgroundObserver!: MutationObserver;
395
- private wnd!: ReadiumWindow;
1143
+ private styleObserver!: MutationObserver;
1144
+ private wnd!: Window;
396
1145
  /*private readonly lastSize = {
397
1146
  width: 0,
398
1147
  height: 0
@@ -403,8 +1152,7 @@ export class Decorator extends Module {
403
1152
  private groups = new Map<string, DecorationGroup>();
404
1153
 
405
1154
  private cleanup() {
406
- // TODO cleanup all decorators
407
- this.groups.forEach(g => g.clear());
1155
+ this.groups.forEach(g => g.destroy());
408
1156
  this.groups.clear();
409
1157
  }
410
1158
 
@@ -414,13 +1162,6 @@ export class Decorator extends Module {
414
1162
  });
415
1163
  }
416
1164
 
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
1165
  private handleResize() {
425
1166
  this.wnd.clearTimeout(this.resizeFrame);
426
1167
  this.resizeFrame = this.wnd.setTimeout(() => {
@@ -431,13 +1172,15 @@ export class Decorator extends Module {
431
1172
  }
432
1173
  private readonly handleResizer = this.handleResize.bind(this);
433
1174
 
434
- mount(wnd: ReadiumWindow, comms: Comms): boolean {
1175
+ mount(wnd: Window, comms: IComms): boolean {
435
1176
  this.wnd = wnd;
436
1177
 
437
1178
  comms.register("decorate", Decorator.moduleName, (data, ack) => {
438
1179
  const req = data as DecoratorRequest;
439
- if (req.decoration && req.decoration.locator) {
440
- req.decoration.locator = Locator.deserialize(req.decoration.locator)!;
1180
+ if (req.action === "add" || req.action === "update") {
1181
+ if (req.decoration.locator) {
1182
+ req.decoration.locator = Locator.deserialize(req.decoration.locator)!;
1183
+ }
441
1184
  }
442
1185
  if (!this.groups.has(req.group)) {
443
1186
  this.groups.set(req.group, new DecorationGroup(
@@ -450,70 +1193,73 @@ export class Decorator extends Module {
450
1193
  const group = this.groups.get(req.group);
451
1194
  switch (req.action) {
452
1195
  case "add":
453
- group?.add(req.decoration!);
1196
+ group?.add(req.decoration);
454
1197
  break;
455
1198
  case "remove":
456
- group?.remove(req.decoration!.id);
1199
+ group?.remove(req.decoration.id);
457
1200
  break;
458
1201
  case "clear":
459
1202
  group?.clear();
460
1203
  break;
461
1204
  case "update":
462
- group?.update(req.decoration!);
1205
+ group?.update(req.decoration);
463
1206
  break;
464
1207
  }
465
1208
 
466
1209
  ack(true);
467
1210
  });
468
1211
 
1212
+ comms.register("decoration_activatable", Decorator.moduleName, (data, ack) => {
1213
+ const req = data as { group: string; activatable: boolean };
1214
+ const group = this.groups.get(req.group);
1215
+ if (group) {
1216
+ group.activatable = req.activatable;
1217
+ }
1218
+ ack(true);
1219
+ });
1220
+
1221
+ comms.register("decoration_hoverable", Decorator.moduleName, (data, ack) => {
1222
+ const req = data as { group: string; hoverable: boolean };
1223
+ const group = this.groups.get(req.group);
1224
+ if (group) {
1225
+ group.hoverable = req.hoverable;
1226
+ }
1227
+ ack(true);
1228
+ });
1229
+
469
1230
  this.resizeObserver = new ResizeObserver(() => wnd.requestAnimationFrame(() => this.handleResize()));
470
- this.resizeObserver.observe(wnd.document.body);
1231
+ this.resizeObserver.observe(wnd.document.documentElement);
471
1232
  wnd.addEventListener("orientationchange", this.handleResizer);
472
1233
  wnd.addEventListener("resize", this.handleResizer);
473
1234
 
474
- // Set up MutationObserver to watch for CSS custom property changes
475
- this.backgroundObserver = new MutationObserver((mutations) => {
476
- const shouldUpdate = mutations.some(mutation => {
477
- if (mutation.type === "attributes" && mutation.attributeName === "style") {
478
- const element = mutation.target as Element;
479
- const oldStyle = mutation.oldValue;
480
- const newStyle = element.getAttribute("style");
481
-
482
- // Check if the relevant CSS custom properties actually changed
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
- }
1235
+ // Watch for any style change on <html> — covers appearance, background color,
1236
+ // font size, line height, margins, and anything else that reflows text.
1237
+ this.styleObserver = new MutationObserver((mutations) => {
1238
+ const shouldUpdate = mutations.some(mutation =>
1239
+ mutation.type === "attributes" &&
1240
+ mutation.attributeName === "style" &&
1241
+ mutation.oldValue !== (mutation.target as Element).getAttribute("style")
1242
+ );
1243
+ if (shouldUpdate) this.updateHighlightStyles();
497
1244
  });
498
1245
 
499
- this.backgroundObserver.observe(wnd.document.documentElement, {
1246
+ this.styleObserver.observe(wnd.document.documentElement, {
500
1247
  attributes: true,
501
1248
  attributeFilter: ["style"],
502
1249
  attributeOldValue: true,
503
- subtree: true
504
1250
  });
505
1251
 
506
1252
  comms.log("Decorator Mounted");
507
1253
  return true;
508
1254
  }
509
1255
 
510
- unmount(wnd: ReadiumWindow, comms: Comms): boolean {
1256
+ unmount(wnd: Window, comms: IComms): boolean {
511
1257
  wnd.removeEventListener("orientationchange", this.handleResizer);
512
1258
  wnd.removeEventListener("resize", this.handleResizer);
513
1259
 
514
1260
  comms.unregisterAll(Decorator.moduleName);
515
1261
  this.resizeObserver.disconnect();
516
- this.backgroundObserver.disconnect();
1262
+ this.styleObserver.disconnect();
517
1263
  this.cleanup();
518
1264
 
519
1265
  comms.log("Decorator Unmounted");