diff --git a/navigator-html-injectables/CHANGELOG.MD b/navigator-html-injectables/CHANGELOG.MD
index f9253230..27617df3 100644
--- a/navigator-html-injectables/CHANGELOG.MD
+++ b/navigator-html-injectables/CHANGELOG.MD
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [2.5.0] – 2026–06–04
+
+### Added
+
+- Implemented Decorator API ([#209](https://github.com/readium/ts-toolkit/pull/209))
+
+### Changed
+
+- Snappers and Decorator modules will avoid running callbacks on mutation when not necessary
+- `BasicTextSelection` now returns a ready-to-use `Locator`
+- Decorations appended into the DOM are now appended into the Shadow DOM
+
## [2.4.4] – 2026–06–04
### Added
diff --git a/navigator-html-injectables/package.json b/navigator-html-injectables/package.json
index 4c88e229..67430dc1 100644
--- a/navigator-html-injectables/package.json
+++ b/navigator-html-injectables/package.json
@@ -1,6 +1,6 @@
{
"name": "@readium/navigator-html-injectables",
- "version": "2.4.4",
+ "version": "2.5.0",
"type": "module",
"description": "An embeddable solution for connecting frames of HTML publications with a Readium Navigator",
"author": "readium",
@@ -54,6 +54,7 @@
"rimraf": "^6.1.2",
"tslib": "^2.8.1",
"typescript": "^5.9.3",
+ "user-agent-data-types": "^0.4.2",
"vite": "^7.3.1"
}
}
diff --git a/navigator-html-injectables/src/comms/keys.ts b/navigator-html-injectables/src/comms/keys.ts
index c2a530db..18b907ba 100644
--- a/navigator-html-injectables/src/comms/keys.ts
+++ b/navigator-html-injectables/src/comms/keys.ts
@@ -20,8 +20,8 @@ export type CommsEventKey =
"media_play" |
"media_pause" |
"content_protection" |
- "keyboard_peripherals";
-;
+ "keyboard_peripherals" |
+ "decoration_activated";
export type CommsCommandKey =
"_ping" |
@@ -39,6 +39,7 @@ export type CommsCommandKey =
// "exact_progress" |
"first_visible_locator" |
"decorate" |
+ "decoration_activatable" |
"protect" |
"unprotect" |
"unfocus" |
diff --git a/navigator-html-injectables/src/helpers/color.ts b/navigator-html-injectables/src/helpers/color.ts
index d7a01363..5723b792 100644
--- a/navigator-html-injectables/src/helpers/color.ts
+++ b/navigator-html-injectables/src/helpers/color.ts
@@ -202,4 +202,67 @@ export const isLightColor = (color: string, blendedWith: string | null = null):
export const getContrastingTextColor = (color: string, blendedWith: string | null = null): "black" | "white" => {
return isDarkColor(color, blendedWith) ? "white" : "black";
+};
+
+const rgbaToCss = (rgba: { r: number; g: number; b: number; a?: number }): string => {
+ const a = rgba.a !== undefined ? rgba.a : 1;
+ return `rgba(${Math.round(rgba.r)}, ${Math.round(rgba.g)}, ${Math.round(rgba.b)}, ${a})`;
+};
+
+const lightenColor = (rgba: { r: number; g: number; b: number; a?: number }, factor: number): { r: number; g: number; b: number; a: number } => {
+ return {
+ r: Math.min(255, rgba.r + (255 - rgba.r) * factor),
+ g: Math.min(255, rgba.g + (255 - rgba.g) * factor),
+ b: Math.min(255, rgba.b + (255 - rgba.b) * factor),
+ a: rgba.a ?? 1
+ };
+};
+
+const darkenColor = (rgba: { r: number; g: number; b: number; a?: number }, factor: number): { r: number; g: number; b: number; a: number } => {
+ return {
+ r: Math.max(0, rgba.r * (1 - factor)),
+ g: Math.max(0, rgba.g * (1 - factor)),
+ b: Math.max(0, rgba.b * (1 - factor)),
+ a: rgba.a ?? 1
+ };
+};
+
+export const adjustColorForContrast = (
+ baseColor: string,
+ backgroundColor: string | null = null,
+ targetContrast: number = 3
+): string => {
+ const baseRgba = colorToRgba(baseColor);
+ const bgRgba = backgroundColor ? colorToRgba(backgroundColor) : { r: 255, g: 255, b: 255, a: 1 };
+
+ let currentContrast = checkContrast(baseRgba, bgRgba);
+
+ // If already meets target, return as-is
+ if (currentContrast >= targetContrast) {
+ return baseColor;
+ }
+
+ const bgLuminance = getLuminance(bgRgba);
+ const isBgDark = bgLuminance < 0.5;
+
+ let adjustedRgba = { ...baseRgba, a: baseRgba.a ?? 1 };
+ const maxIterations = 20;
+ const step = 0.1;
+
+ for (let i = 0; i < maxIterations; i++) {
+ if (isBgDark) {
+ // Dark background: lighten the base color
+ adjustedRgba = lightenColor(adjustedRgba, step);
+ } else {
+ // Light background: darken the base color
+ adjustedRgba = darkenColor(adjustedRgba, step);
+ }
+
+ currentContrast = checkContrast(adjustedRgba, bgRgba);
+ if (currentContrast >= targetContrast) {
+ break;
+ }
+ }
+
+ return rgbaToCss(adjustedRgba);
};
\ No newline at end of file
diff --git a/navigator-html-injectables/src/helpers/css.ts b/navigator-html-injectables/src/helpers/css.ts
index c609bd4e..845d4a61 100644
--- a/navigator-html-injectables/src/helpers/css.ts
+++ b/navigator-html-injectables/src/helpers/css.ts
@@ -1,5 +1,34 @@
import { ReadiumWindow } from "./dom.ts";
+const COSMETIC_PROPERTIES = new Set([
+ "backgroundColor", "textColor",
+ "linkColor", "visitedColor",
+ "primaryColor", "secondaryColor",
+ "selectionBackgroundColor", "selectionTextColor",
+ "blendFilter", "darkenFilter", "invertFilter", "invertGaiji",
+]);
+
+const CSS_PROP_RE = /--(?:USER|RS)__([\w-]+)/g;
+
+/**
+ * Returns true if the style attribute change between oldValue and newValue
+ * includes at least one ReadiumCSS property that affects document layout/geometry.
+ * Pure appearance changes (colors, filters) return false.
+ */
+export function styleChangeAffectsLayout(oldValue: string | null, newValue: string | null): boolean {
+ const old_ = oldValue ?? "";
+ const new_ = newValue ?? "";
+ const seen = new Set();
+
+ for (const match of old_.matchAll(CSS_PROP_RE)) seen.add(match[1]);
+ for (const match of new_.matchAll(CSS_PROP_RE)) seen.add(match[1]);
+
+ for (const suffix of seen) {
+ if (!COSMETIC_PROPERTIES.has(suffix)) return true;
+ }
+ return false;
+}
+
export function getProperties(wnd: ReadiumWindow) {
const cssProperties: { [key: string]: string } = {};
diff --git a/navigator-html-injectables/src/helpers/document.ts b/navigator-html-injectables/src/helpers/document.ts
index 75b666a8..a5548315 100644
--- a/navigator-html-injectables/src/helpers/document.ts
+++ b/navigator-html-injectables/src/helpers/document.ts
@@ -13,6 +13,126 @@ export function isVerticalLR(wnd: ReadiumWindow): boolean {
return writingMode === 'vertical-lr';
}
+export function isVerticalWriting(wnd: ReadiumWindow): boolean {
+ const writingMode = wnd.getComputedStyle(wnd.document.documentElement).writingMode
+ || wnd.getComputedStyle(wnd.document.body).writingMode;
+ return writingMode === 'vertical-rl' || writingMode === 'vertical-lr';
+}
+
+/**
+ * Axis-normalizing context for decoration layout.
+ *
+ * Translates CSS physical properties into logical inline/block terms so that
+ * callers can position elements identically regardless of writing mode:
+ *
+ * horizontal writing → inline = horizontal, block = vertical
+ * vertical writing → inline = vertical, block = horizontal
+ */
+export interface WritingContext {
+ isVertical: boolean;
+ isVertLR: boolean;
+ /** Viewport size along the inline axis (innerWidth / innerHeight). */
+ viewportInlineSize: number;
+ /** Viewport size along the block axis (innerHeight / innerWidth). */
+ viewportBlockSize: number;
+ /**
+ * Size of a single page along the inline axis.
+ * Horizontal: viewportWidth / columnCount.
+ * Vertical: viewportHeight (no columns).
+ */
+ pageInlineSize: number;
+ /**
+ * Physical x offset to add to a client-space x coordinate to get document space.
+ * Horizontal: scrollLeft (non-negative).
+ * Vertical-rl: scrollWidth − viewportWidth + scrollLeft (scrollLeft is negative,
+ * so this normalises it to the viewport's left edge in document coordinates).
+ * Vertical-lr: scrollLeft (non-negative, same as horizontal).
+ */
+ xDocOffset: number;
+ /** Physical y offset to add to a client-space y coordinate to get document space (scrollTop). */
+ yDocOffset: number;
+ /** Scroll offset along the inline axis, derived from xDocOffset / yDocOffset. */
+ inlineScrollOffset: number;
+ /** Scroll offset along the block axis, derived from xDocOffset / yDocOffset. */
+ blockScrollOffset: number;
+
+ /** Logical inline start of a rect (left / top). */
+ inlineStart(r: DOMRect | { top: number; left: number }): number;
+ /** Logical block start of a rect (top / left). */
+ blockStart(r: DOMRect | { top: number; left: number }): number;
+ /** Logical inline size of a rect (width / height). */
+ inlineSize(r: DOMRect | { width: number; height: number }): number;
+ /** Logical block size of a rect (height / width). */
+ blockSize(r: DOMRect | { width: number; height: number }): number;
+
+ /**
+ * Set absolute position on an element using logical coordinates.
+ * `iz` is the inverse zoom factor (1 in non-Blink, 1/zoom in Blink).
+ */
+ applyPosition(el: HTMLElement, inlineStart: number, blockStart: number, inlineSize: number, blockSize: number, iz: number): void;
+
+ /** Convert logical coordinates back to a physical DOMRect. */
+ toRect(inlineStart: number, blockStart: number, inlineSize: number, blockSize: number): DOMRect;
+}
+
+export function makeWritingContext(wnd: ReadiumWindow): WritingContext {
+ const isVert = isVerticalWriting(wnd);
+ const isVLR = isVert && isVerticalLR(wnd);
+ const vw = wnd.innerWidth;
+ const vh = wnd.innerHeight;
+ const se = wnd.document.scrollingElement!;
+ const xOff = se.scrollLeft; // negative for vertical-rl, non-negative otherwise
+ const yOff = se.scrollTop;
+ const cols = parseInt(wnd.getComputedStyle(wnd.document.documentElement).getPropertyValue("column-count"));
+
+ // In vertical-rl, scrollLeft starts at 0 (document start = right side) and goes
+ // negative as the reader scrolls left. The viewport's left edge in document space
+ // is therefore (scrollWidth − viewportWidth + scrollLeft). For vertical-lr and
+ // horizontal modes, scrollLeft is non-negative and is the offset directly.
+ const xDocOffset = (isVert && !isVLR)
+ ? se.scrollWidth - vw + xOff
+ : xOff;
+ const yDocOffset = yOff;
+
+ return {
+ isVertical: isVert,
+ isVertLR: isVLR,
+ viewportInlineSize: isVert ? vh : vw,
+ viewportBlockSize: isVert ? vw : vh,
+ pageInlineSize: isVert ? vh : vw / (cols || 1),
+ xDocOffset,
+ yDocOffset,
+ inlineScrollOffset: isVert ? yDocOffset : xDocOffset,
+ blockScrollOffset: isVert ? xDocOffset : yDocOffset,
+
+ inlineStart: (r) => isVert ? r.top : r.left,
+ blockStart: (r) => isVert ? r.left : r.top,
+ inlineSize: (r) => isVert ? r.height : r.width,
+ blockSize: (r) => isVert ? r.width : r.height,
+
+ applyPosition(el, inlineStart, blockStart, inlineSize, blockSize, iz) {
+ el.style.position = "absolute";
+ if (isVert) {
+ el.style.top = `${inlineStart * iz}px`;
+ el.style.left = `${blockStart * iz}px`;
+ el.style.height = `${inlineSize * iz}px`;
+ el.style.width = `${blockSize * iz}px`;
+ } else {
+ el.style.left = `${inlineStart * iz}px`;
+ el.style.top = `${blockStart * iz}px`;
+ el.style.width = `${inlineSize * iz}px`;
+ el.style.height = `${blockSize * iz}px`;
+ }
+ },
+
+ toRect(inlineStart, blockStart, inlineSize, blockSize) {
+ return isVert
+ ? new DOMRect(blockStart, inlineStart, blockSize, inlineSize)
+ : new DOMRect(inlineStart, blockStart, inlineSize, blockSize);
+ },
+ };
+}
+
export function getColumnCountPerScreen(wnd: ReadiumWindow): number {
return parseInt(
wnd.getComputedStyle(
diff --git a/navigator-html-injectables/src/helpers/rect.ts b/navigator-html-injectables/src/helpers/rect.ts
index bbd520a3..d47e4040 100644
--- a/navigator-html-injectables/src/helpers/rect.ts
+++ b/navigator-html-injectables/src/helpers/rect.ts
@@ -12,7 +12,8 @@ export interface Rect {
export function getClientRectsNoOverlap(
range: Range,
- doNotMergeHorizontallyAlignedRects: boolean
+ doNotMergeHorizontallyAlignedRects: boolean,
+ doNotMergeVerticallyAlignedRects: boolean = false
) {
let clientRects = range.getClientRects();
@@ -36,7 +37,8 @@ export function getClientRectsNoOverlap(
const mergedRects = mergeTouchingRects(
originalRects,
tolerance,
- doNotMergeHorizontallyAlignedRects
+ doNotMergeHorizontallyAlignedRects,
+ doNotMergeVerticallyAlignedRects
);
const noContainedRects = removeContainedRects(mergedRects, tolerance);
const newRects = replaceOverlapingRects(noContainedRects);
@@ -61,7 +63,8 @@ export function getClientRectsNoOverlap(
function mergeTouchingRects(
rects: Rect[],
tolerance: number,
- doNotMergeHorizontallyAlignedRects: boolean
+ doNotMergeHorizontallyAlignedRects: boolean,
+ doNotMergeVerticallyAlignedRects: boolean = false
): Rect[] {
for (let i = 0; i < rects.length; i++) {
for (let j = i + 1; j < rects.length; j++) {
@@ -78,8 +81,9 @@ function mergeTouchingRects(
almostEqual(rect1.left, rect2.left, tolerance) &&
almostEqual(rect1.right, rect2.right, tolerance);
const horizontalAllowed = !doNotMergeHorizontallyAlignedRects;
+ const verticalAllowed = !doNotMergeVerticallyAlignedRects;
const aligned =
- (rectsLineUpHorizontally && horizontalAllowed) ||
+ (rectsLineUpHorizontally && horizontalAllowed && verticalAllowed) ||
(rectsLineUpVertically && !rectsLineUpHorizontally);
const canMerge = aligned && rectsTouchOrOverlap(rect1, rect2, tolerance);
if (canMerge) {
@@ -94,7 +98,8 @@ function mergeTouchingRects(
return mergeTouchingRects(
newRects,
tolerance,
- doNotMergeHorizontallyAlignedRects
+ doNotMergeHorizontallyAlignedRects,
+ doNotMergeVerticallyAlignedRects
);
}
}
diff --git a/navigator/src/helpers/sML.ts b/navigator-html-injectables/src/helpers/sML.ts
similarity index 100%
rename from navigator/src/helpers/sML.ts
rename to navigator-html-injectables/src/helpers/sML.ts
diff --git a/navigator-html-injectables/src/helpers/sanitize.ts b/navigator-html-injectables/src/helpers/sanitize.ts
new file mode 100644
index 00000000..273ca5fd
--- /dev/null
+++ b/navigator-html-injectables/src/helpers/sanitize.ts
@@ -0,0 +1,77 @@
+/**
+ * Elements a decoration template is allowed to contain.
+ * Allowlisting instead of denylisting: anything not listed here is stripped,
+ * including elements that do not yet exist in the HTML spec.
+ */
+const ALLOWED_ELEMENTS = [
+ // Structure / presentation
+ "div", "span", "p", "br", "hr",
+ "b", "i", "em", "strong", "s", "u", "mark", "small", "sub", "sup",
+ "abbr", "cite", "code", "data", "dfn", "kbd", "q", "samp", "time", "var",
+ "blockquote", "pre",
+ // SVG — useful for icon-style decorations (e.g. sidemarks)
+ "svg", "g", "path", "circle", "ellipse", "rect", "line",
+ "polygon", "polyline", "text", "tspan", "defs", "use",
+];
+
+/** Attributes that introduce executable code on any element. */
+const DANGEROUS_ATTR = /^on/i;
+/** Attributes that carry URLs and must be checked for unsafe schemes. */
+const URL_ATTRS = new Set(["href", "src", "action", "formaction", "xlink:href"]);
+/** URL schemes that must not appear in URL-bearing attributes. */
+const DANGEROUS_SCHEME = /^\s*(javascript|data):/i;
+
+/**
+ * Parses `html` and returns its first element child with all executable
+ * content removed. Uses the Sanitizer API when available, falls back to a
+ * manual DOMParser scrub otherwise.
+ *
+ * The allowlist approach is intentional: unknown or future elements are
+ * stripped by default rather than permitted by oversight.
+ *
+ * @param wnd Window whose document is used when adopting nodes.
+ * @param html Raw HTML string supplied by the caller.
+ * @returns The sanitized first element child, or `null` for empty input.
+ */
+export function sanitizeHTML(wnd: Window, html: string): Element | null {
+ const host = wnd.document.createElement("div");
+
+ if ("Sanitizer" in wnd && typeof (host as any).setHTML === "function") {
+ try {
+ const sanitizer = new (wnd as any).Sanitizer({ allowElements: ALLOWED_ELEMENTS });
+ (host as any).setHTML(html, { sanitizer });
+ return host.firstElementChild as Element | null;
+ } catch {
+ // Sanitizer API present but call failed — fall through to DOMParser.
+ }
+ }
+
+ // DOMParser fallback: parse in an isolated document then scrub manually.
+ const scratch = wnd.document.implementation.createHTMLDocument("");
+ scratch.body.innerHTML = html;
+ scrubNode(scratch.body, new Set(ALLOWED_ELEMENTS));
+ while (scratch.body.firstChild) {
+ host.appendChild(wnd.document.adoptNode(scratch.body.firstChild));
+ }
+ return host.firstElementChild as Element | null;
+}
+
+function scrubNode(root: Element, allowed: Set): void {
+ // Walk in reverse so removals don't shift indices.
+ const all = Array.from(root.querySelectorAll("*")).reverse();
+ for (const el of all) {
+ if (!allowed.has(el.localName)) {
+ // Replace disallowed element with its children to preserve text.
+ el.replaceWith(...Array.from(el.childNodes));
+ continue;
+ }
+ for (const { name, value } of Array.from(el.attributes)) {
+ if (
+ DANGEROUS_ATTR.test(name) ||
+ (URL_ATTRS.has(name) && DANGEROUS_SCHEME.test(value))
+ ) {
+ el.removeAttribute(name);
+ }
+ }
+ }
+}
diff --git a/navigator-html-injectables/src/index.ts b/navigator-html-injectables/src/index.ts
index d136116d..085907e7 100644
--- a/navigator-html-injectables/src/index.ts
+++ b/navigator-html-injectables/src/index.ts
@@ -2,4 +2,5 @@ export * from './comms/index.ts';
export * from './modules/index.ts';
export * from './Loader.ts';
export * from './protection/index.ts';
-export * from './keyboard/index.ts'
\ No newline at end of file
+export * from './helpers/sML.ts';
+export * from './keyboard/index.ts'
diff --git a/navigator-html-injectables/src/modules/Decorator.ts b/navigator-html-injectables/src/modules/Decorator.ts
index 1699ded1..fd5914c3 100644
--- a/navigator-html-injectables/src/modules/Decorator.ts
+++ b/navigator-html-injectables/src/modules/Decorator.ts
@@ -3,45 +3,93 @@ import { Comms } from "../comms/comms.ts";
import { Module } from "./Module.ts";
import { rangeFromLocator } from "../helpers/locator.ts";
import { ModuleName } from "./ModuleLibrary.ts";
-import { Rect, getClientRectsNoOverlap } from "../helpers/rect.ts";
+import { Rect, getClientRectsNoOverlap, rectContainsPoint } from "../helpers/rect.ts";
import { getProperty } from "../helpers/css.ts";
import { ReadiumWindow } from "../helpers/dom.ts";
-import { isDarkColor, getContrastingTextColor } from "../helpers/color.ts";
-
-const DEFAULT_HIGHLIGHT_COLOR = "#FFFF00"; // Yellow in HEX
+import { isDarkColor, getContrastingTextColor, adjustColorForContrast } from "../helpers/color.ts";
+import { makeWritingContext } from "../helpers/document.ts";
+import { sML } from "../helpers/sML.ts";
+import { sanitizeHTML } from "../helpers/sanitize.ts";
+
+function defaultTint(type: DecorationStyleType): string {
+ switch (type) {
+ case DecorationStyleType.Mask:
+ return "rgba(255, 255, 255, 0.5)";
+ case DecorationStyleType.Highlight:
+ return "#FFFF00";
+ default:
+ return "#FF0000";
+ }
+}
-export enum Width {
+export const DecorationStyleType = {
+ Highlight: "highlight", // Background color overlay.
+ Underline: "underline", // Underline drawn beneath the text.
+ Outline: "outline", // Border drawn around the text boxes.
+ TextColor: "textColor", // Changes the text color directly.
+ Mask: "mask", // Dims everything outside the selection rects. Use width: Page for block-level behaviour.
+ Template: "template", // Custom HTML template (HTMLDecorationTemplate).
+} as const;
+export type DecorationStyleType = typeof DecorationStyleType[keyof typeof DecorationStyleType];
+
+export enum DecorationWidth {
Wrap = "wrap", // Smallest width fitting the CSS border box.
Viewport = "viewport", // Fills the whole viewport.
- Bounds = "bounds", // Fills the anchor page, useful for dual page.
- Page = "page", // Fills the whole viewport.
+ Bounds = "bounds", // Fills the bounding region of all CSS border boxes.
+ Page = "page", // Fills the anchor page, useful for dual-page layouts.
}
-export enum Layout {
+export enum DecorationLayout {
Boxes = "boxes", // One HTML element for each CSS border box (e.g. line of text).
Bounds = "bounds", // A single HTML element covering the smallest region containing all CSS border boxes.
}
-// TODO improve
-export interface Style {
- tint: string; // CSS color string
- layout: Layout; // Determines the number of created HTML elements and their position relative to the matching DOM range.
- width: Width; // Indicates how the width of each created HTML element expands in the viewport.
+/** Built-in decoration styles. layout/width are optional overrides; defaults are Boxes/Wrap. */
+export interface BuiltinDecorationStyle {
+ type?: Exclude;
+ tint?: string;
+ layout?: DecorationLayout;
+ width?: DecorationWidth;
+ isActive?: boolean;
+ enforceContrast?: boolean; // When true (default), tint is adjusted for contrast against the background.
}
+/**
+ * Custom decoration style backed by caller-supplied HTML.
+ * Matches the HTMLDecorationTemplate class from the Readium spec.
+ * The element string is sanitized before injection.
+ * --readium-tint is injected as a CSS custom property on each created element.
+ */
+export interface HTMLDecorationTemplate {
+ type: "template";
+ layout: DecorationLayout;
+ width: DecorationWidth;
+ element: string;
+ stylesheet?: string;
+ isActive?: boolean;
+}
+
+export type DecorationStyle = BuiltinDecorationStyle | HTMLDecorationTemplate;
+
export interface Decoration {
id: string; // Unique ID of the decoration. It must be unique in the group the decoration is applied to.
locator: Locator; // Location in the publication where the decoration will be rendered.
- style: Style; // Declares the look and feel of the decoration.
- // TODO extras (userInfo)
+ style: DecorationStyle; // Declares the look and feel of the decoration.
+ extras?: Record; // App-specific context data passed through to DecorationActivationEvent.
}
-export interface DecoratorRequest {
- group: string; // Unique ID of the decoration group
- action: "add" | "remove" | "clear" | "update"; // Command
- decoration: Decoration | undefined;
+export interface DecorationActivatedEvent {
+ decorationId: string;
+ group: string; // Human-readable group name (matches DecoratorRequest.group).
+ rect: { top: number; left: number; width: number; height: number }; // Bounding rect in iframe client coords.
+ point: { x: number; y: number }; // Click point in iframe client coords.
}
+export type DecoratorRequest =
+ | { group: string; action: "add" | "update"; decoration: Decoration }
+ | { group: string; action: "remove"; decoration: Pick }
+ | { group: string; action: "clear" };
+
interface DecorationItem {
id: string;
decoration: Decoration;
@@ -58,9 +106,13 @@ class DecorationGroup {
public readonly items: DecorationItem[] = [];
private lastItemId = 0;
private container: HTMLDivElement | undefined = undefined;
- private activateable = false;
+ private _activatable = false;
public readonly experimentalHighlights: boolean = false;
private readonly notTextFlag: Map | undefined;
+ private readonly activationHandler: (e: PointerEvent) => void;
+ private maskSvg: SVGSVGElement | undefined = undefined;
+ private shadowHost: HTMLDivElement | undefined = undefined;
+ private shadowRoot: ShadowRoot | undefined = undefined;
/**
* Creates a DecorationGroup object
@@ -77,14 +129,16 @@ class DecorationGroup {
this.experimentalHighlights = true;
this.notTextFlag = new Map();
}
+ this.activationHandler = this.handleActivation.bind(this);
+ this.wnd.document.addEventListener("pointerup", this.activationHandler);
}
- get activeable() {
- return this.activateable;
+ get activatable() {
+ return this._activatable;
}
- set activeable(value: boolean) {
- this.activateable = value;
+ set activatable(value: boolean) {
+ this._activatable = value;
}
/**
@@ -116,6 +170,21 @@ class DecorationGroup {
this.notTextFlag?.set(id, true);
}
}
+ if (this.experimentalHighlights) {
+ const { type } = decoration.style;
+ const { layout, width } = decoration.style as BuiltinDecorationStyle;
+ // CSS Highlight API only handles text-level highlight styling (boxes + wrap).
+ // Everything else must go through the DOM overlay path.
+ const needsDomOverlay =
+ type !== DecorationStyleType.TextColor && (
+ type === DecorationStyleType.Outline ||
+ type === DecorationStyleType.Template ||
+ type === DecorationStyleType.Mask ||
+ (layout !== undefined && layout !== DecorationLayout.Boxes) ||
+ (width !== undefined && width !== DecorationWidth.Wrap)
+ );
+ if (needsDomOverlay) this.notTextFlag?.set(id, true);
+ }
const item = {
decoration,
@@ -137,6 +206,8 @@ class DecorationGroup {
if (index < 0) return;
const item = this.items[index];
+ const wasMask = item.decoration.style?.type === DecorationStyleType.Mask;
+
this.items.splice(index, 1);
item.clickableElements = undefined;
if (item.container) {
@@ -149,6 +220,11 @@ class DecorationGroup {
mm?.delete(item.range);
}
this.notTextFlag?.delete(item.id);
+
+ // Update shared mask if we removed a mask decoration
+ if (wasMask) {
+ this.updateSharedMask();
+ }
}
/**
@@ -167,6 +243,76 @@ class DecorationGroup {
this.clearContainer();
this.items.length = 0;
this.notTextFlag?.clear();
+ // Clear shared mask
+ if (this.maskSvg) {
+ this.maskSvg.remove();
+ this.maskSvg = undefined;
+ }
+ if (this.shadowHost) {
+ this.shadowHost.remove();
+ this.shadowHost = undefined;
+ this.shadowRoot = undefined;
+ }
+ }
+
+ /**
+ * Removes all decorations and tears down event listeners.
+ * Must be called when the group is permanently discarded.
+ */
+ destroy() {
+ this.clear();
+ this.wnd.document.removeEventListener("pointerup", this.activationHandler);
+ }
+
+ private handleActivation(e: PointerEvent) {
+ if (!this._activatable) return;
+ const cssX = e.clientX;
+ const cssY = e.clientY;
+ const pixelRatio = this.wnd.devicePixelRatio;
+
+ for (const item of this.items) {
+ if (!item.decoration.style?.isActive) continue;
+
+ let hitRect: DOMRect | undefined;
+
+ if (item.decoration.style.type === DecorationStyleType.Template) {
+ // Templates can be positioned anywhere (e.g. a margin sidemark), so hit-test
+ // against the rendered elements rather than the text range rects.
+ for (const el of (item.clickableElements ?? [])) {
+ const r = el.getBoundingClientRect();
+ if (rectContainsPoint(r as Rect, cssX, cssY, 0)) {
+ hitRect = r;
+ break;
+ }
+ }
+ } else {
+ // Built-in styles sit over the text. Range.getClientRects() works for both
+ // rendering paths: the CSS Highlight API has no DOM overlay to target, and the
+ // DOM overlay divs have pointer-events: none, so neither intercepts the event.
+ const rects = item.range.getClientRects();
+ for (const rect of rects) {
+ if (rectContainsPoint(rect as Rect, cssX, cssY, 0)) {
+ hitRect = item.range.getBoundingClientRect();
+ break;
+ }
+ }
+ }
+
+ if (hitRect) {
+ this.comms.send("decoration_activated", {
+ decorationId: item.decoration.id,
+ group: this.name,
+ rect: {
+ top: hitRect.top * pixelRatio,
+ left: hitRect.left * pixelRatio,
+ width: hitRect.width * pixelRatio,
+ height: hitRect.height * pixelRatio,
+ },
+ point: { x: cssX * pixelRatio, y: cssY * pixelRatio },
+ } as DecorationActivatedEvent);
+ return;
+ }
+ }
}
/**
@@ -176,24 +322,111 @@ class DecorationGroup {
requestLayout() {
this.wnd.cancelAnimationFrame(this.currentRender);
this.clearContainer();
- this.items.forEach(i => this.layout(i));
- this.renderLayout(this.items);
+ // Wait for fonts to finish loading before reading geometry, then use a
+ // rAF to ensure the browser has finished reflowing with the new metrics.
+ // Without this, font-family / zoom changes cause positions to be read
+ // against stale or fallback-font layout.
+ this.wnd.document.fonts.ready.then(() => {
+ this.currentRender = this.wnd.requestAnimationFrame(() => {
+ this.items.forEach(i => this.layout(i));
+ this.renderLayout(this.items);
+ // Update shared mask after layout
+ this.updateSharedMask();
+ });
+ });
}
private experimentalLayout(item: DecorationItem) {
const [stylesheet, highlighter]: [HTMLStyleElement, any] = this.requireContainer(true) as [HTMLStyleElement, unknown];
- highlighter.add(item.range);
- const backgroundColor = getProperty(this.wnd, "--USER__backgroundColor") ||
- this.wnd.getComputedStyle(this.wnd.document.documentElement).getPropertyValue("background-color");
- const tint = item.decoration?.style?.tint ?? DEFAULT_HIGHLIGHT_COLOR;
+ // Template items are always routed to the DOM overlay; only BuiltinDecorationStyle reaches here.
+ const style = item.decoration.style as BuiltinDecorationStyle;
+ const type = style.type ?? DecorationStyleType.Highlight;
+ const tint = style.tint ?? defaultTint(type);
+ const width = style.width;
+ const layout = style.layout;
+
+ // Helper for caret position
+ const caretPositionFromPoint = (x: number, y: number): CaretPosition | null => {
+ return this.wnd.document.caretPositionFromPoint?.(x, y) ?? null;
+ };
+
+ // TextColor range registration: expand to bounding rect when layout/width asks for it.
+ if (
+ type === DecorationStyleType.TextColor &&
+ (layout === DecorationLayout.Bounds || width === DecorationWidth.Bounds || width === DecorationWidth.Page)
+ ) {
+ // For vertical writing, caretPositionFromPoint has browser bugs - use fallback
+ const ctx = makeWritingContext(this.wnd);
+ if (ctx.isVertical) {
+ console.warn('Vertical writing detected: caretPositionFromPoint has known bugs, falling back to original range');
+ highlighter.add(item.range);
+ } else {
+ const boundingRect = item.range.getBoundingClientRect();
+ // Page snaps to the full page inline extent; Bounds/layout:Bounds uses the actual bounding rect.
+ let inlineOrigin: number;
+ let inlineExtent: number;
+ if (width === DecorationWidth.Page) {
+ const snap = Math.floor(ctx.inlineStart(boundingRect) / ctx.pageInlineSize) * ctx.pageInlineSize;
+ inlineOrigin = snap;
+ inlineExtent = ctx.pageInlineSize;
+ } else {
+ inlineOrigin = ctx.inlineStart(boundingRect);
+ inlineExtent = ctx.inlineSize(boundingRect);
+ }
+ const startCaret = caretPositionFromPoint(inlineOrigin, ctx.blockStart(boundingRect) + 1);
+ const endCaret = caretPositionFromPoint(inlineOrigin + inlineExtent, ctx.blockStart(boundingRect) + ctx.blockSize(boundingRect) - 1);
+ if (startCaret && endCaret) {
+ const expandedRange = this.wnd.document.createRange();
+ expandedRange.setStart(startCaret.offsetNode, startCaret.offset);
+ expandedRange.setEnd(endCaret.offsetNode, endCaret.offset);
+ highlighter.add(expandedRange);
+ item.range = expandedRange;
+ } else {
+ highlighter.add(item.range);
+ }
+ }
+ } else {
+ highlighter.add(item.range);
+ }
// TODO add caching layer ("vdom") to this so we aren't completely replacing the CSS every time
- stylesheet.innerHTML = `
- ::highlight(${this.id}) {
- color: ${getContrastingTextColor(tint, backgroundColor)};
- background-color: ${tint};
- }`;
+ const backgroundColor = this.getBackgroundColor();
+ const applyContrast = style.enforceContrast !== false;
+ let css: string;
+ switch (type) {
+ case DecorationStyleType.Underline:
+ const adjustedUnderlineTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
+ css = `::highlight(${this.id}) {
+ text-decoration: underline;
+ text-decoration-color: ${adjustedUnderlineTint};
+ text-decoration-thickness: 0.1em;
+ }`;
+ break;
+ case DecorationStyleType.Outline:
+ const adjustedOutlineTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
+ css = `::highlight(${this.id}) {
+ outline: 2px solid ${adjustedOutlineTint};
+ outline-offset: 1px;
+ }`;
+ break;
+ case DecorationStyleType.TextColor: {
+ const adjustedTextTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
+ css = `::highlight(${this.id}) {
+ color: ${adjustedTextTint};
+ }`;
+ break;
+ }
+ case DecorationStyleType.Highlight:
+ default: {
+ const adjustedHighlightTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
+ css = `::highlight(${this.id}) {
+ color: ${getContrastingTextColor(adjustedHighlightTint, backgroundColor)};
+ background-color: ${adjustedHighlightTint};
+ }`;
+ }
+ }
+ stylesheet.innerHTML = css;
}
/**
@@ -213,97 +446,155 @@ class DecorationGroup {
// itemContainer.dataset.style = item.decoration.style; // TODO style
itemContainer.style.setProperty("pointer-events", "none");
- const viewportWidth = this.wnd.innerWidth;
- const columnCount = parseInt(
- getComputedStyle(this.wnd.document.documentElement).getPropertyValue(
- "column-count"
- )
- );
- const pageWidth = viewportWidth / (columnCount || 1);
- const scrollingElement = this.wnd.document.scrollingElement!;
- const xOffset = scrollingElement.scrollLeft;
- const yOffset = scrollingElement.scrollTop;
-
- const positionElement = (element: HTMLElement, rect: Rect, boundingRect: DOMRect) => {
- element.style.position = "absolute";
-
- // TODO change to switch
- if (item.decoration?.style?.width === Width.Viewport) {
- element.style.width = `${viewportWidth}px`;
- element.style.height = `${rect.height}px`;
- let left = Math.floor(rect.left / viewportWidth) * viewportWidth;
- element.style.left = `${left + xOffset}px`;
- element.style.top = `${rect.top + yOffset}px`;
- } else if (item.decoration?.style?.width === Width.Bounds) {
- element.style.width = `${boundingRect.width}px`;
- element.style.height = `${rect.height}px`;
- element.style.left = `${boundingRect.left + xOffset}px`;
- element.style.top = `${rect.top + yOffset}px`;
- } else if (item.decoration?.style?.width === Width.Page) {
- element.style.width = `${pageWidth}px`;
- element.style.height = `${rect.height}px`;
- let left = Math.floor(rect.left / pageWidth) * pageWidth;
- element.style.left = `${left + xOffset}px`;
- element.style.top = `${rect.top + yOffset}px`;
- } else {
- // Fall back to "wrap"
- element.style.width = `${rect.width}px`;
- element.style.height = `${rect.height}px`;
- element.style.left = `${rect.left + xOffset}px`;
- element.style.top = `${rect.top + yOffset}px`;
- }
+ const ctx = makeWritingContext(this.wnd);
+
+ let iz = 1;
+ if (sML.UA.Blink) {
+ const rootZoom = parseFloat(this.wnd.getComputedStyle(this.wnd.document.documentElement).zoom);
+ const bodyZoom = parseFloat(this.wnd.getComputedStyle(this.wnd.document.body).zoom);
+ const effectiveZoom = (rootZoom || 1) * (bodyZoom || 1);
+ if (effectiveZoom) iz = 1 / effectiveZoom;
}
+ const positionElement = (element: HTMLElement, rect: Rect, boundingRect: DOMRect, inlineInset = 0) => {
+ const w = item.decoration?.style?.width;
+ switch (w) {
+ case DecorationWidth.Viewport: {
+ const snap = Math.floor(ctx.inlineStart(rect) / ctx.viewportInlineSize) * ctx.viewportInlineSize;
+ ctx.applyPosition(element, snap + ctx.inlineScrollOffset + inlineInset, ctx.blockStart(rect) + ctx.blockScrollOffset, ctx.viewportInlineSize - 2 * inlineInset, ctx.blockSize(rect), iz);
+ break;
+ }
+ case DecorationWidth.Page: {
+ const snap = Math.floor(ctx.inlineStart(rect) / ctx.pageInlineSize) * ctx.pageInlineSize;
+ ctx.applyPosition(element, snap + ctx.inlineScrollOffset + inlineInset, ctx.blockStart(rect) + ctx.blockScrollOffset, ctx.pageInlineSize - 2 * inlineInset, ctx.blockSize(rect), iz);
+ break;
+ }
+ case DecorationWidth.Bounds: {
+ ctx.applyPosition(element, ctx.inlineStart(boundingRect) + ctx.inlineScrollOffset, ctx.blockStart(rect) + ctx.blockScrollOffset, ctx.inlineSize(boundingRect), ctx.blockSize(rect), iz);
+ break;
+ }
+ default: {
+ ctx.applyPosition(element, ctx.inlineStart(rect) + ctx.inlineScrollOffset, ctx.blockStart(rect) + ctx.blockScrollOffset, ctx.inlineSize(rect), ctx.blockSize(rect), iz);
+ }
+ }
+ }
const boundingRect = item.range.getBoundingClientRect();
- let template = this.wnd.document.createElement("template");
- // template.innerHTML = item.decoration.element.trim();
- // TODO more styles logic
-
- const isDarkMode = this.getCurrentDarkMode();
-
- template.innerHTML = `
-
-
- `.trim();
- const elementTemplate = template.content.firstElementChild!;
-
- if(item.decoration?.style?.layout === Layout.Bounds) {
+ const decoStyle = item.decoration.style;
+ // outline: 2px + outline-offset: 1px = 3px bleed outside the box on each side.
+ // For Page/Viewport widths the snap edge coincides with the viewport edge, so the
+ // outline would be clipped. Inset the element to give that bleed room to render.
+ const outlineInset = (() => {
+ if ((decoStyle as BuiltinDecorationStyle).type !== DecorationStyleType.Outline) return 0;
+ const w = (decoStyle as BuiltinDecorationStyle).width;
+ return (w === DecorationWidth.Page || w === DecorationWidth.Viewport) ? 3 : 0;
+ })();
+ let elementTemplate: Element;
+
+ if (decoStyle.type === DecorationStyleType.Template) {
+ // HTMLDecorationTemplate — fully custom HTML provided by the caller.
+ if (decoStyle.stylesheet) {
+ this.injectCustomStylesheet(decoStyle.stylesheet);
+ }
+ const customEl = sanitizeHTML(this.wnd, decoStyle.element) as HTMLElement | null;
+ if (!customEl) {
+ item.container = itemContainer;
+ item.clickableElements = [];
+ return;
+ }
+ customEl.style.setProperty("pointer-events", "none");
+ elementTemplate = customEl;
+ } else {
+ // BuiltinDecorationStyle path.
+ const style = decoStyle as BuiltinDecorationStyle;
+ const type = style.type ?? DecorationStyleType.Highlight;
+ const tint = style.tint ?? defaultTint(type);
+
+ // TextColor requires CSS Highlight API; DOM overlay has no equivalent.
+ if (type === DecorationStyleType.TextColor) {
+ item.container = itemContainer;
+ item.clickableElements = [];
+ return;
+ }
+
+ // Mask: dim overlay covering the full document with SVG clip-path holes.
+ if (type === DecorationStyleType.Mask) {
+ // Mask decorations use a shared overlay - just mark the item and update the shared mask
+ item.container = itemContainer;
+ item.clickableElements = [];
+ this.updateSharedMask();
+ return;
+ }
+
+ const isDarkMode = this.getCurrentDarkMode();
+ const backgroundColor = this.getBackgroundColor();
+ const applyContrast = style.enforceContrast !== false;
+ const styleAttr = (() => {
+ switch (type) {
+ case DecorationStyleType.Underline: {
+ const adjustedUnderlineTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
+ const isBounds = style.layout === DecorationLayout.Bounds;
+ return [
+ isBounds
+ ? `border-top: 0.1em solid ${adjustedUnderlineTint} !important`
+ : null,
+ `border-bottom: 0.1em solid ${adjustedUnderlineTint} !important`,
+ "background-color: transparent !important",
+ "box-sizing: border-box !important",
+ ].filter(Boolean).join("; ");
+ }
+ case DecorationStyleType.Outline:
+ const adjustedOutlineTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
+ return [
+ `outline: 2px solid ${adjustedOutlineTint} !important`,
+ "outline-offset: 1px !important",
+ "background-color: transparent !important",
+ "box-sizing: border-box !important",
+ ].join("; ");
+ case DecorationStyleType.Highlight:
+ default: {
+ const adjustedHighlightTint = applyContrast ? adjustColorForContrast(tint, backgroundColor) : tint;
+ return [
+ `background-color: ${adjustedHighlightTint} !important`,
+ `mix-blend-mode: ${isDarkMode ? "exclusion" : "multiply"} !important`,
+ "opacity: 1 !important",
+ "box-sizing: border-box !important",
+ ].join("; ");
+ }
+ }
+ })();
+
+ const template = this.wnd.document.createElement("template");
+ template.innerHTML = ``.trim();
+ elementTemplate = template.content.firstElementChild!;
+ }
+
+ if(item.decoration?.style?.layout === DecorationLayout.Bounds) {
const bounds = elementTemplate.cloneNode(true) as HTMLDivElement;
bounds.style.setProperty("pointer-events", "none");
- positionElement(bounds, boundingRect, boundingRect);
+ positionElement(bounds, boundingRect, boundingRect, outlineInset);
itemContainer.append(bounds);
} else {
// Fall back to "boxes" value for layout
let clientRects = getClientRectsNoOverlap(
item.range,
- true // doNotMergeHorizontallyAlignedRects
+ true, // doNotMergeHorizontallyAlignedRects
+ ctx.isVertical // doNotMergeVerticallyAlignedRects
);
clientRects = clientRects.sort((r1, r2) => {
- if (r1.top < r2.top) {
- return -1;
- } else if (r1.top > r2.top) {
- return 1;
- } else {
- return 0;
+ if (ctx.isVertical) {
+ // vertical-rl: rightmost column first; vertical-lr: leftmost first
+ const factor = ctx.isVertLR ? 1 : -1;
+ return factor * (r1.left - r2.left);
}
+ return r1.top - r2.top;
});
for (let clientRect of clientRects) {
const line = elementTemplate.cloneNode(true) as HTMLDivElement;
line.style.setProperty("pointer-events", "none");
- positionElement(line, clientRect, boundingRect);
+ positionElement(line, clientRect, boundingRect, outlineInset);
itemContainer.append(line);
}
}
@@ -357,21 +648,190 @@ class DecorationGroup {
}
if (!this.container) {
+ // Create shared shadow host if it doesn't exist
+ if (!this.shadowRoot) {
+ this.shadowHost = this.wnd.document.createElement("div");
+ this.shadowHost.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none";
+ this.wnd.document.body.appendChild(this.shadowHost);
+ this.shadowRoot = this.shadowHost.attachShadow({ mode: "open" });
+ }
+
+ // Create container in shared shadow root
this.container = this.wnd.document.createElement("div");
this.container.setAttribute("id", this.id);
this.container.dataset.group = this.name;
this.container.dataset.readium = "true";
this.container.style.setProperty("pointer-events", "none");
this.container.style.display = "contents";
- this.wnd.document.body.append(this.container);
+ this.shadowRoot.appendChild(this.container);
}
return this.container;
}
getCurrentDarkMode(): boolean {
return getProperty(this.wnd, "--USER__appearance") === "readium-night-on" ||
- isDarkColor(getProperty(this.wnd, "--USER__backgroundColor")) ||
- isDarkColor(this.wnd.getComputedStyle(this.wnd.document.documentElement).getPropertyValue("background-color"));
+ isDarkColor(this.getBackgroundColor());
+ }
+
+ getBackgroundColor(): string {
+ return getProperty(this.wnd, "--USER__backgroundColor") ||
+ this.wnd.getComputedStyle(this.wnd.document.documentElement).getPropertyValue("background-color");
+ }
+
+ private updateSharedMask() {
+ const maskItems = this.items.filter(item =>
+ item.decoration.style?.type === DecorationStyleType.Mask
+ );
+
+ if (maskItems.length === 0) {
+ // Remove shared mask if no mask decorations exist
+ if (this.maskSvg) {
+ this.maskSvg.remove();
+ this.maskSvg = undefined;
+ }
+ if (this.shadowRoot) {
+ this.shadowRoot.innerHTML = '';
+ }
+ return;
+ }
+
+ const ctx = makeWritingContext(this.wnd);
+
+ let iz = 1;
+ if (sML.UA.Blink) {
+ const rootZoom = parseFloat(this.wnd.getComputedStyle(this.wnd.document.documentElement).zoom);
+ const bodyZoom = parseFloat(this.wnd.getComputedStyle(this.wnd.document.body).zoom);
+ const effectiveZoom = (rootZoom || 1) * (bodyZoom || 1);
+ if (effectiveZoom) iz = 1 / effectiveZoom;
+ }
+
+ // Collect all hole rects from mask decorations
+ const docEl = this.wnd.document.documentElement;
+ const docW = docEl.scrollWidth;
+ const docH = docEl.scrollHeight;
+ const allHoleRects: DOMRect[] = [];
+ for (const item of maskItems) {
+ const style = item.decoration.style as BuiltinDecorationStyle;
+ const layout = style.layout ?? DecorationLayout.Boxes;
+ const width = style.width ?? DecorationWidth.Wrap;
+
+ const boundingRect = item.range.getBoundingClientRect();
+
+ const baseRects: DOMRect[] = layout === DecorationLayout.Bounds
+ ? [boundingRect]
+ : Array.from(item.range.getClientRects());
+
+ for (const rect of baseRects) {
+ let hole: DOMRect;
+ switch (width) {
+ case DecorationWidth.Viewport: {
+ const snap = Math.floor(ctx.inlineStart(rect) / ctx.viewportInlineSize) * ctx.viewportInlineSize;
+ hole = ctx.toRect(snap, ctx.blockStart(rect), ctx.viewportInlineSize, ctx.blockSize(rect));
+ break;
+ }
+ case DecorationWidth.Page: {
+ const snap = Math.floor(ctx.inlineStart(rect) / ctx.pageInlineSize) * ctx.pageInlineSize;
+ hole = ctx.toRect(snap, ctx.blockStart(rect), ctx.pageInlineSize, ctx.blockSize(rect));
+ break;
+ }
+ case DecorationWidth.Bounds: {
+ hole = ctx.toRect(ctx.inlineStart(boundingRect), ctx.blockStart(rect), ctx.inlineSize(boundingRect), ctx.blockSize(rect));
+ break;
+ }
+ default:
+ hole = rect;
+ }
+ allHoleRects.push(hole);
+ }
+ }
+
+ // Build SVG path with all holes (physical coords — always left/top regardless of writing mode)
+ const pathData = [
+ `M0 0 H${docW} V${docH} H0 Z`,
+ ...allHoleRects.map(r => {
+ const l = (r.left + ctx.xDocOffset) * iz;
+ const t = (r.top + ctx.yDocOffset) * iz;
+ const ri = (r.right + ctx.xDocOffset) * iz;
+ const b = (r.bottom + ctx.yDocOffset) * iz;
+ return `M${l} ${t} H${ri} V${b} H${l} Z`;
+ }),
+ ].join(" ");
+
+ const svgNS = "http://www.w3.org/2000/svg";
+
+ // Create or update SVG
+ if (!this.maskSvg) {
+ // Ensure shared shadow host exists
+ if (!this.shadowRoot) {
+ this.shadowHost = this.wnd.document.createElement("div");
+ this.shadowHost.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none";
+ this.wnd.document.body.appendChild(this.shadowHost);
+ this.shadowRoot = this.shadowHost.attachShadow({ mode: "open" });
+ }
+
+ // Create SVG in shared shadow root
+ this.maskSvg = this.wnd.document.createElementNS(svgNS, "svg") as SVGSVGElement;
+ this.maskSvg.style.cssText = `position:absolute;top:0;left:0;width:${docW}px;height:${docH}px;pointer-events:none;z-index:9999`;
+ this.maskSvg.dataset.readium = "true";
+ const defs = this.wnd.document.createElementNS(svgNS, "defs");
+ const clipPath = this.wnd.document.createElementNS(svgNS, "clipPath") as SVGClipPathElement;
+ const clipId = `${this.id}-mask-clip`;
+ clipPath.setAttribute("id", clipId);
+ clipPath.setAttribute("clipPathUnits", "userSpaceOnUse");
+ const svgPath = this.wnd.document.createElementNS(svgNS, "path") as SVGPathElement;
+ svgPath.setAttribute("clip-rule", "evenodd");
+ clipPath.appendChild(svgPath);
+ defs.appendChild(clipPath);
+ this.maskSvg.appendChild(defs);
+
+ // Add SVG rect for the overlay (bypasses ReadiumCSS)
+ const maskRect = this.wnd.document.createElementNS(svgNS, "rect") as SVGRectElement;
+ maskRect.setAttribute("id", `${this.id}-mask-rect`);
+ maskRect.setAttribute("clip-path", `url(#${clipId})`);
+ maskRect.style.pointerEvents = "none";
+ this.maskSvg.appendChild(maskRect);
+
+ this.shadowRoot!.appendChild(this.maskSvg);
+ }
+
+ // Update SVG dimensions to cover full document
+ this.maskSvg.style.width = `${docW}px`;
+ this.maskSvg.style.height = `${docH}px`;
+
+ // Update the path data
+ const svgPath = this.maskSvg.querySelector("path") as SVGPathElement;
+ if (svgPath) {
+ svgPath.setAttribute("d", pathData);
+ }
+
+ // Update the mask rect
+ const maskRect = this.maskSvg.querySelector("rect") as SVGRectElement;
+ if (maskRect) {
+ const firstMaskStyle = maskItems[0].decoration.style as BuiltinDecorationStyle;
+ const userTint = firstMaskStyle.tint;
+ // User-supplied tint: alpha is their responsibility (SVG fill respects rgba natively).
+ // Background color fallback: always fully opaque, so apply default dimming.
+ const fillColor = userTint ?? this.getBackgroundColor() ?? defaultTint(DecorationStyleType.Mask);
+ const fillOpacity = userTint ? "1" : "0.5";
+ maskRect.setAttribute("x", "0");
+ maskRect.setAttribute("y", "0");
+ maskRect.setAttribute("width", String(docW));
+ maskRect.setAttribute("height", String(docH));
+ maskRect.setAttribute("fill", fillColor);
+ maskRect.setAttribute("fill-opacity", fillOpacity);
+ }
+ }
+
+ private injectCustomStylesheet(css: string) {
+ const id = `${this.id}-custom-style`;
+ let el = this.wnd.document.getElementById(id) as HTMLStyleElement | null;
+ if (!el) {
+ el = this.wnd.document.createElement("style");
+ el.id = id;
+ el.dataset.readium = "true";
+ this.wnd.document.head.appendChild(el);
+ }
+ el.innerHTML = css;
}
/**
@@ -381,6 +841,7 @@ class DecorationGroup {
if (this.experimentalHighlights) {
((this.wnd as any).CSS.highlights as Map).delete(this.id);
}
+ this.wnd.document.getElementById(`${this.id}-custom-style`)?.remove();
if (this.container) {
this.container.remove();
this.container = undefined;
@@ -391,7 +852,7 @@ class DecorationGroup {
export class Decorator extends Module {
static readonly moduleName: ModuleName = "decorator";
private resizeObserver!: ResizeObserver;
- private backgroundObserver!: MutationObserver;
+ private styleObserver!: MutationObserver;
private wnd!: ReadiumWindow;
/*private readonly lastSize = {
width: 0,
@@ -403,8 +864,7 @@ export class Decorator extends Module {
private groups = new Map();
private cleanup() {
- // TODO cleanup all decorators
- this.groups.forEach(g => g.clear());
+ this.groups.forEach(g => g.destroy());
this.groups.clear();
}
@@ -414,13 +874,6 @@ export class Decorator extends Module {
});
}
- private extractCustomProperty(style: string | null, propertyName: string): string | null {
- if (!style) return null;
-
- const match = style.match(new RegExp(`${propertyName}:\\s*([^;]+)`));
- return match ? match[1].trim() : null;
- }
-
private handleResize() {
this.wnd.clearTimeout(this.resizeFrame);
this.resizeFrame = this.wnd.setTimeout(() => {
@@ -436,8 +889,10 @@ export class Decorator extends Module {
comms.register("decorate", Decorator.moduleName, (data, ack) => {
const req = data as DecoratorRequest;
- if (req.decoration && req.decoration.locator) {
- req.decoration.locator = Locator.deserialize(req.decoration.locator)!;
+ if (req.action === "add" || req.action === "update") {
+ if (req.decoration.locator) {
+ req.decoration.locator = Locator.deserialize(req.decoration.locator)!;
+ }
}
if (!this.groups.has(req.group)) {
this.groups.set(req.group, new DecorationGroup(
@@ -450,57 +905,51 @@ export class Decorator extends Module {
const group = this.groups.get(req.group);
switch (req.action) {
case "add":
- group?.add(req.decoration!);
+ group?.add(req.decoration);
break;
case "remove":
- group?.remove(req.decoration!.id);
+ group?.remove(req.decoration.id);
break;
case "clear":
group?.clear();
break;
case "update":
- group?.update(req.decoration!);
+ group?.update(req.decoration);
break;
}
ack(true);
});
+ comms.register("decoration_activatable", Decorator.moduleName, (data, ack) => {
+ const req = data as { group: string; activatable: boolean };
+ const group = this.groups.get(req.group);
+ if (group) {
+ group.activatable = req.activatable;
+ }
+ ack(true);
+ });
+
this.resizeObserver = new ResizeObserver(() => wnd.requestAnimationFrame(() => this.handleResize()));
- this.resizeObserver.observe(wnd.document.body);
+ this.resizeObserver.observe(wnd.document.documentElement);
wnd.addEventListener("orientationchange", this.handleResizer);
wnd.addEventListener("resize", this.handleResizer);
- // Set up MutationObserver to watch for CSS custom property changes
- this.backgroundObserver = new MutationObserver((mutations) => {
- const shouldUpdate = mutations.some(mutation => {
- if (mutation.type === "attributes" && mutation.attributeName === "style") {
- const element = mutation.target as Element;
- const oldStyle = mutation.oldValue;
- const newStyle = element.getAttribute("style");
-
- // Check if the relevant CSS custom properties actually changed
- const oldAppearance = this.extractCustomProperty(oldStyle, "--USER__appearance");
- const newAppearance = this.extractCustomProperty(newStyle, "--USER__appearance");
- const oldBgColor = this.extractCustomProperty(oldStyle, "--USER__backgroundColor");
- const newBgColor = this.extractCustomProperty(newStyle, "--USER__backgroundColor");
-
- return oldAppearance !== newAppearance ||
- oldBgColor !== newBgColor;
- }
- return false;
- });
-
- if (shouldUpdate) {
- this.updateHighlightStyles();
- }
+ // Watch for any style change on — covers appearance, background color,
+ // font size, line height, margins, and anything else that reflows text.
+ this.styleObserver = new MutationObserver((mutations) => {
+ const shouldUpdate = mutations.some(mutation =>
+ mutation.type === "attributes" &&
+ mutation.attributeName === "style" &&
+ mutation.oldValue !== (mutation.target as Element).getAttribute("style")
+ );
+ if (shouldUpdate) this.updateHighlightStyles();
});
- this.backgroundObserver.observe(wnd.document.documentElement, {
+ this.styleObserver.observe(wnd.document.documentElement, {
attributes: true,
attributeFilter: ["style"],
attributeOldValue: true,
- subtree: true
});
comms.log("Decorator Mounted");
@@ -513,7 +962,7 @@ export class Decorator extends Module {
comms.unregisterAll(Decorator.moduleName);
this.resizeObserver.disconnect();
- this.backgroundObserver.disconnect();
+ this.styleObserver.disconnect();
this.cleanup();
comms.log("Decorator Unmounted");
diff --git a/navigator-html-injectables/src/modules/Peripherals.ts b/navigator-html-injectables/src/modules/Peripherals.ts
index de9a385f..60ad0284 100644
--- a/navigator-html-injectables/src/modules/Peripherals.ts
+++ b/navigator-html-injectables/src/modules/Peripherals.ts
@@ -1,3 +1,4 @@
+import { Locator } from "@readium/shared";
import { Comms } from "../comms/comms.ts";
import { Module } from "./Module.ts";
import { ReadiumWindow, nearestInteractiveElement } from "../helpers/dom.ts";
@@ -28,6 +29,8 @@ export interface BasicTextSelection {
width: number;
height: number;
targetFrameSrc: string;
+ /** Locator for the frame the selection originated from. Populated by the navigator. */
+ locator?: Locator;
}
export interface BaseSuspiciousActivityEvent {
diff --git a/navigator-html-injectables/src/modules/snapper/ColumnSnapper.ts b/navigator-html-injectables/src/modules/snapper/ColumnSnapper.ts
index 62a10fe0..78adc946 100644
--- a/navigator-html-injectables/src/modules/snapper/ColumnSnapper.ts
+++ b/navigator-html-injectables/src/modules/snapper/ColumnSnapper.ts
@@ -1,6 +1,7 @@
import { Comms } from "../../comms/index.ts";
import { Snapper } from "./Snapper.ts";
import { getColumnCountPerScreen, isRTL, appendVirtualColumnIfNeeded } from "../../helpers/document.ts";
+import { styleChangeAffectsLayout } from "../../helpers/css.ts";
import { easeInOutQuad } from "../../helpers/animation.ts";
import { ModuleName } from "../ModuleLibrary.ts";
import { Locator, LocatorLocations, LocatorText } from "@readium/shared";
@@ -392,7 +393,7 @@ export class ColumnSnapper extends Snapper {
const oldValueTransform = oldValue?.match(transformRegex);
const newValueTransform = newValue?.match(transformRegex);
if (
- (!oldValueTransform && !newValueTransform) ||
+ (!oldValueTransform && !newValueTransform && styleChangeAffectsLayout(oldValue, newValue)) ||
(oldValueTransform && !newValueTransform) ||
(oldValueTransform && newValueTransform && oldValueTransform[1] !== newValueTransform[1])
) {
diff --git a/navigator/CHANGELOG.MD b/navigator/CHANGELOG.MD
index 007f4947..65a49b2e 100644
--- a/navigator/CHANGELOG.MD
+++ b/navigator/CHANGELOG.MD
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [2.6.0] – 2026–06–04
+
+### Added
+
+- Implemented Decorator API ([#209](https://github.com/readium/ts-toolkit/pull/209))
+
## [2.5.8] – 2026–06–04
### Added
diff --git a/navigator/docs/epub/ConfiguringEpubNavigator.md b/navigator/docs/epub/ConfiguringEpubNavigator.md
index 6823a7dd..829791af 100644
--- a/navigator/docs/epub/ConfiguringEpubNavigator.md
+++ b/navigator/docs/epub/ConfiguringEpubNavigator.md
@@ -78,6 +78,12 @@ const navigator = new EpubNavigator(
minimalLineLength: 20,
optimalLineLength: 55,
maximalLineLength: 65
+ },
+ decoratorConfig: {
+ // Optional — register named decoration styles; see Decorations.md
+ decorationTemplates: {
+ "app-sidemark": { ... }
+ }
}
}
);
diff --git a/navigator/docs/epub/CustomizingListeners.md b/navigator/docs/epub/CustomizingListeners.md
index 80c4572b..25f71ff2 100644
--- a/navigator/docs/epub/CustomizingListeners.md
+++ b/navigator/docs/epub/CustomizingListeners.md
@@ -114,6 +114,20 @@ Fires when an external link has been tapped or clicked.
Fires when text has been selected inside the iframe.
+```ts
+interface BasicTextSelection {
+ text: string; // The selected text
+ x: number; // X coordinate of the selection's bounding rect
+ y: number; // Y coordinate of the selection's bounding rect
+ width: number; // Width of the selection's bounding rect
+ height: number; // Height of the selection's bounding rect
+ targetFrameSrc: string; // URL of the iframe where the selection occurred
+ locator?: Locator; // Ready-to-use locator with href, type, and text.highlight set — pass directly to applyDecorations
+}
+```
+
+The locator has `href`, `type`, and `text.highlight` pre-filled. In Fixed-Layout spread mode it is also the only reliable way to determine which reading order item the selection belongs to — `currentLocator` always points to the first page of the spread.
+
### contentProtection
Fires when the content protection is triggered. See [Content Protection](./ContentProtection.md) for more information.
diff --git a/navigator/docs/epub/Decorations.md b/navigator/docs/epub/Decorations.md
new file mode 100644
index 00000000..010fafcc
--- /dev/null
+++ b/navigator/docs/epub/Decorations.md
@@ -0,0 +1,440 @@
+# Decorations
+
+`EpubNavigator` implements `DecorableNavigator`, which lets you visually annotate text in a publication — highlights, underlines, search results, TTS position indicators, and anything else that needs to sit on top of content.
+
+The API is defined by [Readium Architecture RFC 008](https://readium.org/architecture/proposals/008-decorator-api.html).
+
+## Concepts
+
+### Decoration
+
+A `Decoration` marks a single location in a publication with a visual style:
+
+```ts
+interface Decoration {
+ id: string; // Must be unique within its group
+ locator: Locator; // Where in the publication to render
+ style: DecorationStyle; // How it looks
+ extras?: Record; // App-specific data (passed back on activation)
+}
+```
+
+### DecorationStyle
+
+`DecorationStyle` is a union of the built-in style types and `HTMLDecorationTemplate`:
+
+```ts
+type DecorationStyle = BuiltinDecorationStyle | HTMLDecorationTemplate;
+```
+
+#### BuiltinDecorationStyle
+
+```ts
+interface BuiltinDecorationStyle {
+ type?: DecorationStyleType; // Defaults to Highlight when omitted
+ tint?: string; // Any CSS color — "#ffff00", "rgba(255,200,0,0.4)", etc.
+ layout?: DecorationLayout; // Defaults to Boxes
+ width?: DecorationWidth; // Defaults to Wrap
+ isActive?: boolean; // Set to true to allow the user to click/tap this decoration
+ enforceContrast?: boolean; // When true (default), tint is adjusted for contrast against the background
+}
+```
+
+**`DecorationStyleType`**
+
+| Value | Description |
+|---|---|
+| `DecorationStyleType.Highlight` | Background-color overlay (default). |
+| `DecorationStyleType.Underline` | Line drawn beneath the text. |
+| `DecorationStyleType.Outline` | Border drawn around each text box. |
+| `DecorationStyleType.TextColor` | Changes the text color directly. Requires CSS Highlight API; invisible in older browsers. **Note**: Due to CSS Highlight API limitations, viewport width behaves as wrap (fits text exactly) instead of stretching to full viewport width. Page and Bounds widths are supported for TextColor. **Vertical Writing**: Due to known browser bugs with `caretPositionFromPoint()` in vertical writing modes, bounds/page width falls back to wrap behavior to ensure reliability. |
+| `DecorationStyleType.Mask` | Dims everything outside the selection rects. Use `width: Page` for block-level behavior. |
+| `DecorationStyleType.Template` | Custom HTML template (see `HTMLDecorationTemplate`). |
+
+**`DecorationLayout`**
+
+| Value | Description |
+|---|---|
+| `DecorationLayout.Boxes` | One element per CSS border box (i.e. per line of text). Default. |
+| `DecorationLayout.Bounds` | A single element covering the bounding box of the whole range. |
+
+**`DecorationWidth`**
+
+| Value | Description |
+|---|---|
+| `DecorationWidth.Wrap` | Fits the text exactly (default). |
+| `DecorationWidth.Viewport` | Stretches to the full viewport width. **Note**: Works as expected for Highlight, Underline, and Outline styles. For TextColor, viewport behaves as wrap due to CSS Highlight API limitations. Page and Bounds widths are supported for TextColor. |
+| `DecorationWidth.Page` | Fills the anchor page, useful for dual-page layouts. |
+| `DecorationWidth.Bounds` | Fills the bounding region of all CSS border boxes. |
+
+#### HTMLDecorationTemplate
+
+For fully custom decoration rendering, use `HTMLDecorationTemplate`. The `element` function is called once per decoration to generate the HTML snippet that is sanitized before injection; the `stylesheet` is injected as a `