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 `