@readium/navigator-html-injectables 2.4.3 → 2.5.0

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