@readium/navigator-html-injectables 2.5.0 → 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 (73) 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 -4601
  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 -8
  38. package/src/comms/comms.ts +11 -1
  39. package/src/comms/keys.ts +6 -2
  40. package/src/helpers/css.ts +1 -1
  41. package/src/helpers/document.ts +5 -5
  42. package/src/helpers/locator.ts +5 -5
  43. package/src/helpers/rect.ts +79 -18
  44. package/src/index.ts +1 -1
  45. package/src/modules/Decorator.ts +404 -107
  46. package/src/modules/snapper/CJKVerticalSnapper.ts +34 -7
  47. package/src/modules/snapper/ColumnSnapper.ts +54 -6
  48. package/src/modules/snapper/ScrollSnapper.ts +30 -7
  49. package/src/modules/snapper/Snapper.ts +144 -0
  50. package/src/modules/snapper/WebPubSnapper.ts +31 -7
  51. package/types/src/comms/comms.d.ts +10 -1
  52. package/types/src/comms/keys.d.ts +2 -2
  53. package/types/src/helpers/css.d.ts +1 -1
  54. package/types/src/helpers/document.d.ts +5 -5
  55. package/types/src/helpers/rect.d.ts +2 -1
  56. package/types/src/index.d.ts +1 -1
  57. package/types/src/modules/Decorator.d.ts +33 -7
  58. package/types/src/modules/snapper/CJKVerticalSnapper.d.ts +1 -0
  59. package/types/src/modules/snapper/ColumnSnapper.d.ts +14 -1
  60. package/types/src/modules/snapper/ScrollSnapper.d.ts +1 -0
  61. package/types/src/modules/snapper/Snapper.d.ts +58 -0
  62. package/types/src/modules/snapper/WebPubSnapper.d.ts +1 -0
  63. package/dist/ar-DyHX_uy2.js +0 -7
  64. package/dist/da-Dct0PS3E.js +0 -7
  65. package/dist/fr-C5HEel98.js +0 -7
  66. package/dist/index.umd.cjs +0 -96
  67. package/dist/it-DFOBoXGy.js +0 -7
  68. package/dist/pt_PT-Di3sVjze.js +0 -7
  69. package/dist/sv-BfzAFsVN.js +0 -7
  70. package/src/helpers/color.ts +0 -268
  71. package/src/helpers/sML.ts +0 -143
  72. package/types/src/helpers/color.d.ts +0 -27
  73. package/types/src/helpers/sML.d.ts +0 -56
@@ -1,14 +1,13 @@
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, rectContainsPoint } 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, adjustColorForContrast } from "../helpers/color.ts";
8
+ import { isDarkColor, getContrastingTextColor, adjustColorForContrast, colorToRgba } from "@readium/helpers";
10
9
  import { makeWritingContext } from "../helpers/document.ts";
11
- import { sML } from "../helpers/sML.ts";
10
+ import { sML } from "@readium/helpers";
12
11
  import { sanitizeHTML } from "../helpers/sanitize.ts";
13
12
 
14
13
  function defaultTint(type: DecorationStyleType): string {
@@ -16,6 +15,7 @@ function defaultTint(type: DecorationStyleType): string {
16
15
  case DecorationStyleType.Mask:
17
16
  return "rgba(255, 255, 255, 0.5)";
18
17
  case DecorationStyleType.Highlight:
18
+ case DecorationStyleType.HighlightUnderline:
19
19
  return "#FFFF00";
20
20
  default:
21
21
  return "#FF0000";
@@ -23,12 +23,14 @@ function defaultTint(type: DecorationStyleType): string {
23
23
  }
24
24
 
25
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).
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).
32
34
  } as const;
33
35
  export type DecorationStyleType = typeof DecorationStyleType[keyof typeof DecorationStyleType];
34
36
 
@@ -50,8 +52,8 @@ export interface BuiltinDecorationStyle {
50
52
  tint?: string;
51
53
  layout?: DecorationLayout;
52
54
  width?: DecorationWidth;
53
- isActive?: boolean;
54
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.
55
57
  }
56
58
 
57
59
  /**
@@ -66,7 +68,6 @@ export interface HTMLDecorationTemplate {
66
68
  width: DecorationWidth;
67
69
  element: string;
68
70
  stylesheet?: string;
69
- isActive?: boolean;
70
71
  }
71
72
 
72
73
  export type DecorationStyle = BuiltinDecorationStyle | HTMLDecorationTemplate;
@@ -78,13 +79,25 @@ export interface Decoration {
78
79
  extras?: Record<string, unknown>; // App-specific context data passed through to DecorationActivationEvent.
79
80
  }
80
81
 
81
- export interface DecorationActivatedEvent {
82
+ interface DecorationEventBase {
82
83
  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.
84
+ group: string;
85
+ rect?: { top: number; left: number; width: number; height: number };
86
+ point?: { x: number; y: number };
86
87
  }
87
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
+
88
101
  export type DecoratorRequest =
89
102
  | { group: string; action: "add" | "update"; decoration: Decoration }
90
103
  | { group: string; action: "remove"; decoration: Pick<Decoration, "id"> }
@@ -94,9 +107,11 @@ interface DecorationItem {
94
107
  id: string;
95
108
  decoration: Decoration;
96
109
  range: Range;
97
-
110
+ hitRects: Rect[]; // Merged client rects for hit testing; refreshed after each layout.
98
111
  clickableElements: HTMLElement[] | undefined;
99
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.
100
115
  }
101
116
 
102
117
  const canNativeHighlight = () => ("Highlight" in window);
@@ -107,9 +122,14 @@ class DecorationGroup {
107
122
  private lastItemId = 0;
108
123
  private container: HTMLDivElement | undefined = undefined;
109
124
  private _activatable = false;
125
+ private _hoverable = false;
126
+ private hoveredItem: DecorationItem | undefined = undefined;
110
127
  public readonly experimentalHighlights: boolean = false;
111
128
  private readonly notTextFlag: Map<string, boolean> | undefined;
129
+ private readonly _tintSubKeys = new Map<string, string>(); // (type::adjustedTint) → subKey
130
+ private _subKeyCounter = 0;
112
131
  private readonly activationHandler: (e: PointerEvent) => void;
132
+ private readonly hoverHandler: (e: PointerEvent) => void;
113
133
  private maskSvg: SVGSVGElement | undefined = undefined;
114
134
  private shadowHost: HTMLDivElement | undefined = undefined;
115
135
  private shadowRoot: ShadowRoot | undefined = undefined;
@@ -120,8 +140,8 @@ class DecorationGroup {
120
140
  * @param name Human-readable name of the group
121
141
  */
122
142
  constructor(
123
- private readonly wnd: ReadiumWindow,
124
- private readonly comms: Comms,
143
+ private readonly wnd: Window,
144
+ private readonly comms: IComms,
125
145
  private readonly id: string,
126
146
  private readonly name: string
127
147
  ) {
@@ -131,6 +151,8 @@ class DecorationGroup {
131
151
  }
132
152
  this.activationHandler = this.handleActivation.bind(this);
133
153
  this.wnd.document.addEventListener("pointerup", this.activationHandler);
154
+ this.hoverHandler = this.handleHover.bind(this);
155
+ this.wnd.document.addEventListener("pointermove", this.hoverHandler);
134
156
  }
135
157
 
136
158
  get activatable() {
@@ -141,6 +163,29 @@ class DecorationGroup {
141
163
  this._activatable = value;
142
164
  }
143
165
 
166
+ get hoverable() {
167
+ return this._hoverable;
168
+ }
169
+
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
+ }
187
+ }
188
+
144
189
  /**
145
190
  * Adds a new decoration to the group.
146
191
  * @param decoration Decoration to add
@@ -170,9 +215,23 @@ class DecorationGroup {
170
215
  this.notTextFlag?.set(id, true);
171
216
  }
172
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
+ }
173
232
  if (this.experimentalHighlights) {
174
233
  const { type } = decoration.style;
175
- const { layout, width } = decoration.style as BuiltinDecorationStyle;
234
+ const { layout, width, expand } = decoration.style as BuiltinDecorationStyle;
176
235
  // CSS Highlight API only handles text-level highlight styling (boxes + wrap).
177
236
  // Everything else must go through the DOM overlay path.
178
237
  const needsDomOverlay =
@@ -181,7 +240,8 @@ class DecorationGroup {
181
240
  type === DecorationStyleType.Template ||
182
241
  type === DecorationStyleType.Mask ||
183
242
  (layout !== undefined && layout !== DecorationLayout.Boxes) ||
184
- (width !== undefined && width !== DecorationWidth.Wrap)
243
+ (width !== undefined && width !== DecorationWidth.Wrap) ||
244
+ !!expand
185
245
  );
186
246
  if (needsDomOverlay) this.notTextFlag?.set(id, true);
187
247
  }
@@ -190,10 +250,14 @@ class DecorationGroup {
190
250
  decoration,
191
251
  id,
192
252
  range,
253
+ hitRects: [],
254
+ clickableElements: undefined,
255
+ container: undefined,
193
256
  } as DecorationItem;
194
257
 
195
258
  this.items.push(item);
196
259
  this.layout(item);
260
+ item.hitRects = this.clientRectsToDocCoords(getClientRectsNoOverlap(item.range, false, false, ((item.decoration.style as BuiltinDecorationStyle).expand ?? 0) + this.hitGap()));
197
261
  this.renderLayout([item]);
198
262
  }
199
263
 
@@ -214,13 +278,20 @@ class DecorationGroup {
214
278
  item.container.remove();
215
279
  item.container = undefined;
216
280
  }
217
- if (this.experimentalHighlights && !this.notTextFlag?.has(item.id)) {
218
- // Remove highlight from ranges
219
- const mm = ((this.wnd as any).CSS.highlights as Map<string, unknown>).get(this.id) as Set<Range>;
220
- 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);
221
289
  }
222
290
  this.notTextFlag?.delete(item.id);
223
-
291
+ if (this.hoveredItem === item) {
292
+ this.hoveredItem = undefined;
293
+ }
294
+
224
295
  // Update shared mask if we removed a mask decoration
225
296
  if (wasMask) {
226
297
  this.updateSharedMask();
@@ -243,6 +314,7 @@ class DecorationGroup {
243
314
  this.clearContainer();
244
315
  this.items.length = 0;
245
316
  this.notTextFlag?.clear();
317
+ this.hoveredItem = undefined;
246
318
  // Clear shared mask
247
319
  if (this.maskSvg) {
248
320
  this.maskSvg.remove();
@@ -262,17 +334,43 @@ class DecorationGroup {
262
334
  destroy() {
263
335
  this.clear();
264
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();
265
366
  }
266
367
 
267
368
  private handleActivation(e: PointerEvent) {
268
369
  if (!this._activatable) return;
269
- const cssX = e.clientX;
270
- const cssY = e.clientY;
370
+ const { docX, docY } = this.pointerToDocCoords(e);
271
371
  const pixelRatio = this.wnd.devicePixelRatio;
272
372
 
273
373
  for (const item of this.items) {
274
- if (!item.decoration.style?.isActive) continue;
275
-
276
374
  let hitRect: DOMRect | undefined;
277
375
 
278
376
  if (item.decoration.style.type === DecorationStyleType.Template) {
@@ -280,18 +378,16 @@ class DecorationGroup {
280
378
  // against the rendered elements rather than the text range rects.
281
379
  for (const el of (item.clickableElements ?? [])) {
282
380
  const r = el.getBoundingClientRect();
283
- if (rectContainsPoint(r as Rect, cssX, cssY, 0)) {
381
+ if (rectContainsPoint(r as Rect, e.clientX, e.clientY, 0)) {
284
382
  hitRect = r;
285
383
  break;
286
384
  }
287
385
  }
288
386
  } 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)) {
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)) {
295
391
  hitRect = item.range.getBoundingClientRect();
296
392
  break;
297
393
  }
@@ -308,13 +404,79 @@ class DecorationGroup {
308
404
  width: hitRect.width * pixelRatio,
309
405
  height: hitRect.height * pixelRatio,
310
406
  },
311
- point: { x: cssX * pixelRatio, y: cssY * pixelRatio },
407
+ point: { x: e.clientX * pixelRatio, y: e.clientY * pixelRatio },
312
408
  } as DecorationActivatedEvent);
313
409
  return;
314
410
  }
315
411
  }
316
412
  }
317
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
+ }
478
+ }
479
+
318
480
  /**
319
481
  * Recreates the decoration elements.
320
482
  * To be called after reflowing the resource, for example.
@@ -328,7 +490,10 @@ class DecorationGroup {
328
490
  // against stale or fallback-font layout.
329
491
  this.wnd.document.fonts.ready.then(() => {
330
492
  this.currentRender = this.wnd.requestAnimationFrame(() => {
331
- this.items.forEach(i => this.layout(i));
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
+ });
332
497
  this.renderLayout(this.items);
333
498
  // Update shared mask after layout
334
499
  this.updateSharedMask();
@@ -337,7 +502,8 @@ class DecorationGroup {
337
502
  }
338
503
 
339
504
  private experimentalLayout(item: DecorationItem) {
340
- const [stylesheet, highlighter]: [HTMLStyleElement, any] = this.requireContainer(true) as [HTMLStyleElement, unknown];
505
+ const stylesheet = this.requireContainer(true) as HTMLStyleElement;
506
+ const cssHighlights = (this.wnd as any).CSS.highlights as Map<string, any>;
341
507
 
342
508
  // Template items are always routed to the DOM overlay; only BuiltinDecorationStyle reaches here.
343
509
  const style = item.decoration.style as BuiltinDecorationStyle;
@@ -346,6 +512,29 @@ class DecorationGroup {
346
512
  const width = style.width;
347
513
  const layout = style.layout;
348
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
+ }
537
+
349
538
  // Helper for caret position
350
539
  const caretPositionFromPoint = (x: number, y: number): CaretPosition | null => {
351
540
  return this.wnd.document.caretPositionFromPoint?.(x, y) ?? null;
@@ -360,7 +549,7 @@ class DecorationGroup {
360
549
  const ctx = makeWritingContext(this.wnd);
361
550
  if (ctx.isVertical) {
362
551
  console.warn('Vertical writing detected: caretPositionFromPoint has known bugs, falling back to original range');
363
- highlighter.add(item.range);
552
+ sub.add(item.range);
364
553
  } else {
365
554
  const boundingRect = item.range.getBoundingClientRect();
366
555
  // Page snaps to the full page inline extent; Bounds/layout:Bounds uses the actual bounding rect.
@@ -380,53 +569,90 @@ class DecorationGroup {
380
569
  const expandedRange = this.wnd.document.createRange();
381
570
  expandedRange.setStart(startCaret.offsetNode, startCaret.offset);
382
571
  expandedRange.setEnd(endCaret.offsetNode, endCaret.offset);
383
- highlighter.add(expandedRange);
572
+ sub.add(expandedRange);
384
573
  item.range = expandedRange;
385
574
  } else {
386
- highlighter.add(item.range);
575
+ sub.add(item.range);
387
576
  }
388
577
  }
389
578
  } else {
390
- highlighter.add(item.range);
579
+ sub.add(item.range);
391
580
  }
392
581
 
393
- // TODO add caching layer ("vdom") to this so we aren't completely replacing the CSS every time
394
582
  const backgroundColor = this.getBackgroundColor();
395
583
  const applyContrast = style.enforceContrast !== false;
584
+ const adjustedTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
585
+
396
586
  let css: string;
397
587
  switch (type) {
398
588
  case DecorationStyleType.Underline:
399
- const adjustedUnderlineTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
400
- css = `::highlight(${this.id}) {
589
+ css = `::highlight(${subKey}) {
401
590
  text-decoration: underline;
402
- text-decoration-color: ${adjustedUnderlineTint};
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};
403
599
  text-decoration-thickness: 0.1em;
404
600
  }`;
405
601
  break;
406
602
  case DecorationStyleType.Outline:
407
- const adjustedOutlineTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
408
- css = `::highlight(${this.id}) {
409
- outline: 2px solid ${adjustedOutlineTint};
603
+ css = `::highlight(${subKey}) {
604
+ outline: 2px solid ${adjustedTint};
410
605
  outline-offset: 1px;
411
606
  }`;
412
607
  break;
413
- case DecorationStyleType.TextColor: {
414
- const adjustedTextTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
415
- css = `::highlight(${this.id}) {
416
- color: ${adjustedTextTint};
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;
417
622
  }`;
418
623
  break;
419
624
  }
420
625
  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};
626
+ default:
627
+ css = `::highlight(${subKey}) {
628
+ color: ${getContrastingTextColor(adjustedTint, backgroundColor)};
629
+ background-color: ${adjustedTint};
426
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);
427
653
  }
428
654
  }
429
- stylesheet.innerHTML = css;
655
+ stylesheet.innerHTML = rules.join("\n");
430
656
  }
431
657
 
432
658
  /**
@@ -448,33 +674,29 @@ class DecorationGroup {
448
674
 
449
675
  const ctx = makeWritingContext(this.wnd);
450
676
 
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;
457
- }
677
+ const iz = 1 / this.effectiveZoom();
458
678
 
679
+ const expand = (item.decoration.style as BuiltinDecorationStyle).expand ?? 0;
459
680
  const positionElement = (element: HTMLElement, rect: Rect, boundingRect: DOMRect, inlineInset = 0) => {
460
681
  const w = item.decoration?.style?.width;
682
+ const r = rect;
461
683
  switch (w) {
462
684
  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);
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);
465
687
  break;
466
688
  }
467
689
  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);
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);
470
692
  break;
471
693
  }
472
694
  case DecorationWidth.Bounds: {
473
- ctx.applyPosition(element, ctx.inlineStart(boundingRect) + ctx.inlineScrollOffset, ctx.blockStart(rect) + ctx.blockScrollOffset, ctx.inlineSize(boundingRect), ctx.blockSize(rect), iz);
695
+ ctx.applyPosition(element, ctx.inlineStart(boundingRect) + ctx.inlineScrollOffset, ctx.blockStart(r) + ctx.blockScrollOffset, ctx.inlineSize(boundingRect), ctx.blockSize(r), iz);
474
696
  break;
475
697
  }
476
698
  default: {
477
- ctx.applyPosition(element, ctx.inlineStart(rect) + ctx.inlineScrollOffset, ctx.blockStart(rect) + ctx.blockScrollOffset, ctx.inlineSize(rect), ctx.blockSize(rect), iz);
699
+ ctx.applyPosition(element, ctx.inlineStart(r) + ctx.inlineScrollOffset, ctx.blockStart(r) + ctx.blockScrollOffset, ctx.inlineSize(r), ctx.blockSize(r), iz);
478
700
  }
479
701
  }
480
702
  }
@@ -534,15 +756,36 @@ class DecorationGroup {
534
756
  case DecorationStyleType.Underline: {
535
757
  const adjustedUnderlineTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
536
758
  const isBounds = style.layout === DecorationLayout.Bounds;
759
+ const [underlineSide, overlineSide] = ctx.isVertical
760
+ ? ["border-right", "border-left"]
761
+ : ["border-bottom", "border-top"];
537
762
  return [
538
763
  isBounds
539
- ? `border-top: 0.1em solid ${adjustedUnderlineTint} !important`
764
+ ? `${overlineSide}: 0.1em solid ${adjustedUnderlineTint} !important`
540
765
  : null,
541
- `border-bottom: 0.1em solid ${adjustedUnderlineTint} !important`,
766
+ `${underlineSide}: 0.1em solid ${adjustedUnderlineTint} !important`,
542
767
  "background-color: transparent !important",
543
768
  "box-sizing: border-box !important",
544
769
  ].filter(Boolean).join("; ");
545
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
+ }
546
789
  case DecorationStyleType.Outline:
547
790
  const adjustedOutlineTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
548
791
  return [
@@ -551,6 +794,21 @@ class DecorationGroup {
551
794
  "background-color: transparent !important",
552
795
  "box-sizing: border-box !important",
553
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
+ }
554
812
  case DecorationStyleType.Highlight:
555
813
  default: {
556
814
  const adjustedHighlightTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
@@ -572,14 +830,34 @@ class DecorationGroup {
572
830
  if(item.decoration?.style?.layout === DecorationLayout.Bounds) {
573
831
  const bounds = elementTemplate.cloneNode(true) as HTMLDivElement;
574
832
  bounds.style.setProperty("pointer-events", "none");
575
- positionElement(bounds, boundingRect, boundingRect, outlineInset);
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);
576
842
  itemContainer.append(bounds);
577
843
  } else {
578
- // 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.
579
856
  let clientRects = getClientRectsNoOverlap(
580
- item.range,
857
+ rectSource,
581
858
  true, // doNotMergeHorizontallyAlignedRects
582
- ctx.isVertical // doNotMergeVerticallyAlignedRects
859
+ ctx.isVertical, // doNotMergeVerticallyAlignedRects
860
+ isLineDecoration ? 0 : expand
583
861
  );
584
862
 
585
863
  clientRects = clientRects.sort((r1, r2) => {
@@ -594,7 +872,23 @@ class DecorationGroup {
594
872
  for (let clientRect of clientRects) {
595
873
  const line = elementTemplate.cloneNode(true) as HTMLDivElement;
596
874
  line.style.setProperty("pointer-events", "none");
597
- positionElement(line, clientRect, boundingRect, outlineInset);
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);
598
892
  itemContainer.append(line);
599
893
  }
600
894
  }
@@ -623,7 +917,7 @@ class DecorationGroup {
623
917
  * Returns the group container element, after making sure it exists.
624
918
  * @returns Group's container
625
919
  */
626
- private requireContainer(experimental=false): [HTMLStyleElement, any] | HTMLDivElement {
920
+ private requireContainer(experimental=false): HTMLStyleElement | HTMLDivElement {
627
921
  if (experimental) {
628
922
  // Setup <style> for highlights
629
923
  let d: HTMLStyleElement;
@@ -636,15 +930,7 @@ class DecorationGroup {
636
930
  this.wnd.document.head.appendChild(d);
637
931
  }
638
932
 
639
- // Setup CSS.highlights
640
- let h: unknown;
641
- if (((this.wnd as any).CSS.highlights as Map<string, unknown>).has(this.id)) {
642
- h = ((this.wnd as any).CSS.highlights as Map<string, unknown>).get(this.id)
643
- } else {
644
- h = new (this.wnd as any).Highlight();
645
- ((this.wnd as any).CSS.highlights as Map<string, unknown>).set(this.id, h);
646
- }
647
- return [d, h];
933
+ return d;
648
934
  }
649
935
 
650
936
  if (!this.container) {
@@ -697,13 +983,7 @@ class DecorationGroup {
697
983
 
698
984
  const ctx = makeWritingContext(this.wnd);
699
985
 
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
- }
986
+ const iz = 1 / this.effectiveZoom();
707
987
 
708
988
  // Collect all hole rects from mask decorations
709
989
  const docEl = this.wnd.document.documentElement;
@@ -714,12 +994,15 @@ class DecorationGroup {
714
994
  const style = item.decoration.style as BuiltinDecorationStyle;
715
995
  const layout = style.layout ?? DecorationLayout.Boxes;
716
996
  const width = style.width ?? DecorationWidth.Wrap;
997
+ const ex = style.expand ?? 0;
717
998
 
718
999
  const boundingRect = item.range.getBoundingClientRect();
719
1000
 
720
- const baseRects: DOMRect[] = layout === DecorationLayout.Bounds
721
- ? [boundingRect]
722
- : Array.from(item.range.getClientRects());
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);
723
1006
 
724
1007
  for (const rect of baseRects) {
725
1008
  let hole: DOMRect;
@@ -739,7 +1022,7 @@ class DecorationGroup {
739
1022
  break;
740
1023
  }
741
1024
  default:
742
- hole = rect;
1025
+ hole = ctx.toRect(ctx.inlineStart(rect), ctx.blockStart(rect), ctx.inlineSize(rect), ctx.blockSize(rect));
743
1026
  }
744
1027
  allHoleRects.push(hole);
745
1028
  }
@@ -839,7 +1122,12 @@ class DecorationGroup {
839
1122
  */
840
1123
  private clearContainer() {
841
1124
  if (this.experimentalHighlights) {
842
- ((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;
843
1131
  }
844
1132
  this.wnd.document.getElementById(`${this.id}-custom-style`)?.remove();
845
1133
  if (this.container) {
@@ -853,7 +1141,7 @@ export class Decorator extends Module {
853
1141
  static readonly moduleName: ModuleName = "decorator";
854
1142
  private resizeObserver!: ResizeObserver;
855
1143
  private styleObserver!: MutationObserver;
856
- private wnd!: ReadiumWindow;
1144
+ private wnd!: Window;
857
1145
  /*private readonly lastSize = {
858
1146
  width: 0,
859
1147
  height: 0
@@ -884,7 +1172,7 @@ export class Decorator extends Module {
884
1172
  }
885
1173
  private readonly handleResizer = this.handleResize.bind(this);
886
1174
 
887
- mount(wnd: ReadiumWindow, comms: Comms): boolean {
1175
+ mount(wnd: Window, comms: IComms): boolean {
888
1176
  this.wnd = wnd;
889
1177
 
890
1178
  comms.register("decorate", Decorator.moduleName, (data, ack) => {
@@ -930,6 +1218,15 @@ export class Decorator extends Module {
930
1218
  ack(true);
931
1219
  });
932
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
+
933
1230
  this.resizeObserver = new ResizeObserver(() => wnd.requestAnimationFrame(() => this.handleResize()));
934
1231
  this.resizeObserver.observe(wnd.document.documentElement);
935
1232
  wnd.addEventListener("orientationchange", this.handleResizer);
@@ -956,7 +1253,7 @@ export class Decorator extends Module {
956
1253
  return true;
957
1254
  }
958
1255
 
959
- unmount(wnd: ReadiumWindow, comms: Comms): boolean {
1256
+ unmount(wnd: Window, comms: IComms): boolean {
960
1257
  wnd.removeEventListener("orientationchange", this.handleResizer);
961
1258
  wnd.removeEventListener("resize", this.handleResizer);
962
1259