From e29a0450f0eaa038afd5d05fcb7c1ce1c63d796b Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Mon, 27 Apr 2026 14:34:29 +0200 Subject: [PATCH 01/58] Init Decorator API in visual --- navigator-html-injectables/src/comms/keys.ts | 5 +- .../src/modules/Decorator.ts | 125 ++++++-- navigator/docs/epub/Decorations.md | 283 ++++++++++++++++++ navigator/docs/epub/EpubNavigator.md | 1 + navigator/src/decorations/index.ts | 54 ++++ navigator/src/epub/EpubNavigator.ts | 170 ++++++++++- navigator/src/index.ts | 1 + navigator/src/webpub/WebPubNavigator.ts | 140 ++++++++- 8 files changed, 742 insertions(+), 37 deletions(-) create mode 100644 navigator/docs/epub/Decorations.md create mode 100644 navigator/src/decorations/index.ts 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/modules/Decorator.ts b/navigator-html-injectables/src/modules/Decorator.ts index 1699ded1..520eebf9 100644 --- a/navigator-html-injectables/src/modules/Decorator.ts +++ b/navigator-html-injectables/src/modules/Decorator.ts @@ -3,45 +3,52 @@ 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 -export enum Width { +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. } -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 { +export interface DecorationStyle { 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. + layout: DecorationLayout; // Determines the number of created HTML elements and their position relative to the matching DOM range. + width: DecorationWidth; // Indicates how the width of each created HTML element expands in the viewport. + isActive?: boolean; // Whether user activation (click/tap) events fire for this decoration. } 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 +65,10 @@ 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; /** * Creates a DecorationGroup object @@ -77,14 +85,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; } /** @@ -169,6 +179,55 @@ class DecorationGroup { this.notTextFlag?.clear(); } + /** + * 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 hit = false; + + // 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 in neither case is the decoration element the pointer event target. + const rects = item.range.getClientRects(); + for (const rect of rects) { + if (rectContainsPoint(rect as Rect, cssX, cssY, 0)) { + hit = true; + break; + } + } + + if (hit) { + const r = item.range.getBoundingClientRect(); + this.comms.send("decoration_activated", { + decorationId: item.decoration.id, + group: this.name, + rect: { + top: r.top * pixelRatio, + left: r.left * pixelRatio, + width: r.width * pixelRatio, + height: r.height * pixelRatio, + }, + point: { x: cssX * pixelRatio, y: cssY * pixelRatio }, + } as DecorationActivatedEvent); + return; + } + } + } + /** * Recreates the decoration elements. * To be called after reflowing the resource, for example. @@ -228,18 +287,18 @@ class DecorationGroup { element.style.position = "absolute"; // TODO change to switch - if (item.decoration?.style?.width === Width.Viewport) { + if (item.decoration?.style?.width === DecorationWidth.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) { + } else if (item.decoration?.style?.width === DecorationWidth.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) { + } else if (item.decoration?.style?.width === DecorationWidth.Page) { element.style.width = `${pageWidth}px`; element.style.height = `${rect.height}px`; let left = Math.floor(rect.left / pageWidth) * pageWidth; @@ -278,7 +337,7 @@ class DecorationGroup { `.trim(); const elementTemplate = template.content.firstElementChild!; - if(item.decoration?.style?.layout === Layout.Bounds) { + if(item.decoration?.style?.layout === DecorationLayout.Bounds) { const bounds = elementTemplate.cloneNode(true) as HTMLDivElement; bounds.style.setProperty("pointer-events", "none"); positionElement(bounds, boundingRect, boundingRect); @@ -403,8 +462,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(); } @@ -436,8 +494,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,22 +510,31 @@ 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); wnd.addEventListener("orientationchange", this.handleResizer); diff --git a/navigator/docs/epub/Decorations.md b/navigator/docs/epub/Decorations.md new file mode 100644 index 00000000..8045f10c --- /dev/null +++ b/navigator/docs/epub/Decorations.md @@ -0,0 +1,283 @@ +# 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` controls the appearance: + +```ts +interface DecorationStyle { + tint: string; // Any CSS color — "#ffff00", "rgba(255,200,0,0.4)", etc. + layout: DecorationLayout; // DecorationLayout.Boxes | DecorationLayout.Bounds + width: DecorationWidth; // DecorationWidth.Wrap | DecorationWidth.Viewport | DecorationWidth.Page | DecorationWidth.Bounds + isActive?: boolean; // Set to true to allow the user to click/tap this decoration +} +``` + +**`DecorationLayout`** + +| Value | Description | +|---|---| +| `DecorationLayout.Boxes` | One element per CSS border box (i.e. per line of text). Best for highlights. | +| `DecorationLayout.Bounds` | A single element covering the bounding box of the whole range. Best for margin icons. | + +**`DecorationWidth`** + +| Value | Description | +|---|---| +| `DecorationWidth.Wrap` | Fits the text exactly (default). | +| `DecorationWidth.Viewport` | Stretches to the full viewport width. | +| `DecorationWidth.Page` | Fills one page in a paginated layout. | +| `DecorationWidth.Bounds` | Fills the anchor page (useful in dual-page FXL). | + +### Groups + +Every decoration belongs to a named group. Groups let you manage unrelated sets of decorations independently — for example `"search"`, `"highlights"`, and `"tts"` can coexist without interfering with each other. + +Decoration IDs must be **unique within their group**, but the same ID can appear in different groups. + +## Applying Decorations + +Call `applyDecorations` with the **complete desired state** for a group. The navigator diffs the new list against the previous one and sends only the necessary add / update / remove commands to the rendered frames. + +```ts +import { Decoration, DecorationLayout, DecorationWidth } from "@readium/navigator"; + +const highlights: Decoration[] = [ + { + id: "highlight-1", + locator: myLocator, + style: { + tint: "#ffff00", + layout: DecorationLayout.Boxes, + width: DecorationWidth.Wrap, + }, + }, +]; + +navigator.applyDecorations(highlights, "user-highlights"); +``` + +To update, simply call `applyDecorations` again with the new state: + +```ts +// Change the tint of highlight-1 and add highlight-2 +navigator.applyDecorations([ + { id: "highlight-1", locator: locator1, style: { tint: "#90ee90", layout: DecorationLayout.Boxes, width: DecorationWidth.Wrap } }, + { id: "highlight-2", locator: locator2, style: { tint: "#ffb6c1", layout: DecorationLayout.Boxes, width: DecorationWidth.Wrap } }, +], "user-highlights"); +``` + +To remove all decorations from a group, pass an empty array: + +```ts +navigator.applyDecorations([], "user-highlights"); +``` + +Decorations are **automatically reapplied** when the navigator loads a new resource (including after navigating away and back), so you do not need to call `applyDecorations` again on navigation. + +## Checking DecorationStyle Support + +Before enabling a feature that relies on a specific style, you can verify the navigator supports it: + +```ts +if (navigator.supportsDecorationStyle({ tint: "#ffff00", layout: DecorationLayout.Boxes, width: DecorationWidth.Wrap })) { + // safe to apply +} +``` + +This always returns `true` for `EpubNavigator` — it is mainly useful for navigator-agnostic code. + +## Activation (Click / Tap) + +To make a decoration respond to user interaction, set `isActive: true` in its style and register a `DecorationObserver` for the group. + +```ts +import { DecorationObserver, DecorationActivationEvent } from "@readium/navigator"; + +const highlightObserver: DecorationObserver = { + onDecorationActivated(event: DecorationActivationEvent): boolean { + console.log("Decoration tapped:", event.decoration.id); + console.log("Group:", event.group); + console.log("Bounding rect (navigator coords):", event.rect); + console.log("Tap point (navigator coords):", event.point); + console.log("App data:", event.decoration.extras); + + // Return true to indicate you handled the event. + // This suppresses the default tap/click navigation (page turn, miscPointer, etc.). + return true; + }, +}; + +navigator.registerDecorationObserver("user-highlights", highlightObserver); +``` + +Then create the decoration with `isActive: true`: + +```ts +navigator.applyDecorations([ + { + id: "highlight-1", + locator: myLocator, + style: { + tint: "#ffff00", + layout: DecorationLayout.Boxes, + width: DecorationWidth.Wrap, + isActive: true, // ← required for activation events + }, + extras: { noteId: "note-42" }, // ← passed through to DecorationActivationEvent + }, +], "user-highlights"); +``` + +### `DecorationActivationEvent` + +```ts +interface DecorationActivationEvent { + decoration: Decoration; // The full decoration that was activated + group: string; // The group it belongs to + rect?: { // Bounding rect in navigator container coordinates + top: number; + left: number; + width: number; + height: number; + }; + point?: { // Tap/click point in navigator container coordinates + x: number; + y: number; + }; +} +``` + +`rect` and `point` are in CSS pixels relative to the navigator's container element — you can use them to position a popover: + +```ts +onDecorationActivated(event: DecorationActivationEvent): boolean { + if (!event.rect) return false; + + showPopover({ + content: lookupNote(event.decoration.extras?.noteId as string), + anchorRect: event.rect, + }); + + return true; +} +``` + +### Return value + +Returning `true` from `onDecorationActivated` tells the navigator that you handled the event. The navigator will **not** process the tap/click further — no page turn, no `miscPointer`, no `tap`/`click` listener call. + +Returning `false` (or not having a registered observer) lets the tap/click fall through to normal navigation. + +### Unregistering an observer + +```ts +navigator.unregisterDecorationObserver(highlightObserver); +``` + +This removes the observer from **all** groups it was registered in. + +## Complete Example — User Highlights + +```ts +import { + EpubNavigator, + Decoration, + DecorationObserver, + DecorationActivationEvent, + DecorationLayout, + DecorationWidth, +} from "@readium/navigator"; + +// 1. Keep your highlights in application state +let highlights: Decoration[] = []; + +function syncHighlights() { + navigator.applyDecorations(highlights, "highlights"); +} + +// 2. Register an observer before or after load +const observer: DecorationObserver = { + onDecorationActivated(event: DecorationActivationEvent): boolean { + const noteId = event.decoration.extras?.noteId as string | undefined; + if (noteId && event.rect) { + showNotePopover(noteId, event.rect); + return true; + } + return false; + }, +}; + +navigator.registerDecorationObserver("highlights", observer); + +// 3. Add a highlight (e.g. from a user selection) +function addHighlight(locator: Locator, color: string, noteId: string) { + highlights = [ + ...highlights, + { + id: crypto.randomUUID(), + locator, + style: { tint: color, layout: DecorationLayout.Boxes, width: DecorationWidth.Wrap, isActive: true }, + extras: { noteId }, + }, + ]; + syncHighlights(); +} + +// 4. Remove a highlight +function removeHighlight(id: string) { + highlights = highlights.filter(h => h.id !== id); + syncHighlights(); +} + +// 5. Clean up when done +navigator.unregisterDecorationObserver(observer); +await navigator.destroy(); +``` + +## Complete Example — Search Results + +Search results are a good example of non-activatable decorations managed alongside activatable ones. + +```ts +import { Decoration, DecorationLayout, DecorationWidth } from "@readium/navigator"; + +function applySearchResults(locators: Locator[], currentMatchId: string) { + const decorations: Decoration[] = locators.map((locator, i) => ({ + id: `match-${i}`, + locator, + style: { + tint: `match-${i}` === currentMatchId ? "#ff8c00" : "#ffff99", + layout: DecorationLayout.Boxes, + width: DecorationWidth.Wrap, + }, + })); + + navigator.applyDecorations(decorations, "search"); +} + +function clearSearch() { + navigator.applyDecorations([], "search"); +} +``` + +Because `isActive` is not set, tapping a search result falls through to normal navigation — no observer needed. diff --git a/navigator/docs/epub/EpubNavigator.md b/navigator/docs/epub/EpubNavigator.md index 81ecca80..1da7587f 100644 --- a/navigator/docs/epub/EpubNavigator.md +++ b/navigator/docs/epub/EpubNavigator.md @@ -46,6 +46,7 @@ The `initialPosition` is the position at which the `EpubNavigator` will `load` t 2. **Content Protection** - For security and protection features, see [Content Protection](./ContentProtection.md) 3. **Resource Injection** - For injecting custom resources into publications, see [Resource Injection](./ResourceInjection.md) 4. **Keyboard Peripherals** - For custom keyboard shortcuts, see [Keyboard Peripherals](./KeyboardPeripherals.md) +5. **Decorations** - For highlighting and annotating text, see [Decorations](./Decorations.md) Each of these configuration aspects can be combined in the `configuration` object when creating a new `EpubNavigator` instance. diff --git a/navigator/src/decorations/index.ts b/navigator/src/decorations/index.ts new file mode 100644 index 00000000..6d96e8c2 --- /dev/null +++ b/navigator/src/decorations/index.ts @@ -0,0 +1,54 @@ +import type { Decoration, DecorationStyle } from "@readium/navigator-html-injectables"; + +export type { Decoration, DecorationStyle }; +export { DecorationLayout, DecorationWidth } from "@readium/navigator-html-injectables"; + +export interface DecorationActivationEvent { + decoration: Decoration; + group: string; + /** Bounding rect of the activated decoration in navigator container coordinates (CSS pixels). */ + rect?: { top: number; left: number; width: number; height: number }; + /** Tap/click point in navigator container coordinates (CSS pixels). */ + point?: { x: number; y: number }; +} + +export interface DecorationObserver { + /** + * Called when a user activates a decoration (click or tap). + * Return true to indicate the event was handled — this suppresses normal tap/click navigation. + */ + onDecorationActivated(event: DecorationActivationEvent): boolean; +} + +export function decorationsEqual(a: Decoration, b: Decoration): boolean { + return ( + a.locator.href === b.locator.href && + JSON.stringify(a.locator.locations?.serialize()) === JSON.stringify(b.locator.locations?.serialize()) && + a.style.tint === b.style.tint && + a.style.layout === b.style.layout && + a.style.width === b.style.width && + (a.style.isActive ?? false) === (b.style.isActive ?? false) && + JSON.stringify(a.extras ?? null) === JSON.stringify(b.extras ?? null) + ); +} + +export interface DecorableNavigator { + /** + * Replaces all decorations for the given group with the provided list. + * The navigator diffs the new list against the current state and issues + * add / update / remove / clear commands as needed. + */ + applyDecorations(decorations: Decoration[], group: string): void; + + /** + * Returns whether the given style can be rendered by this navigator. + * Always returns true for HTML-based navigators. + */ + supportsDecorationStyle(style: DecorationStyle): boolean; + + /** Registers an observer for activation events on the given group. */ + registerDecorationObserver(group: string, observer: DecorationObserver): void; + + /** Unregisters a previously registered observer from all groups. */ + unregisterDecorationObserver(observer: DecorationObserver): void; +} diff --git a/navigator/src/epub/EpubNavigator.ts b/navigator/src/epub/EpubNavigator.ts index ab8a3712..755dd3b3 100644 --- a/navigator/src/epub/EpubNavigator.ts +++ b/navigator/src/epub/EpubNavigator.ts @@ -2,7 +2,8 @@ import { Layout, Link, Locator, Profile, Publication, ReadingProgression } from import { Configurable, ConfigurableSettings, LineLengths, ProgressionRange, VisualNavigator, VisualNavigatorViewport } from "../index.ts"; import { FramePoolManager } from "./frame/FramePoolManager.ts"; import { FXLFramePoolManager } from "./fxl/FXLFramePoolManager.ts"; -import { CommsEventKey, ContextMenuEvent, FXLModules, KeyboardEventData, ModuleLibrary, ModuleName, ReflowableModules, BasicTextSelection, FrameClickEvent, SuspiciousActivityEvent } from "@readium/navigator-html-injectables"; +import { CommsEventKey, ContextMenuEvent, Decoration, DecorationActivatedEvent, DecorationStyle, FXLModules, KeyboardEventData, ModuleLibrary, ModuleName, ReflowableModules, BasicTextSelection, FrameClickEvent, SuspiciousActivityEvent } from "@readium/navigator-html-injectables"; +import { DecorationActivationEvent, DecorationObserver, DecorableNavigator, decorationsEqual } from "../decorations/index.ts"; import * as path from "path-browserify"; import { FXLFrameManager } from "./fxl/FXLFrameManager.ts"; import { FrameManager } from "./frame/FrameManager.ts"; @@ -64,7 +65,7 @@ const defaultListeners = (listeners: EpubNavigatorListeners): EpubNavigatorListe peripheral: listeners.peripheral || (() => {}), }) -export class EpubNavigator extends VisualNavigator implements Configurable { +export class EpubNavigator extends VisualNavigator implements Configurable, DecorableNavigator { private readonly pub: Publication; private readonly container: HTMLElement; private readonly listeners: EpubNavigatorListeners; @@ -92,6 +93,11 @@ export class EpubNavigator extends VisualNavigator implements Configurable = new Map(); + private _decorationObservers: Map> = new Map(); + private _decorationActivationState: Map = new Map(); + private _decorationActivationConsumed = false; + private reflowViewport: VisualNavigatorViewport = { readingOrder: [], progressions: new Map(), @@ -420,11 +426,19 @@ export class EpubNavigator extends VisualNavigator implements Configurable !!f) as (FrameManager | FXLFrameManager)[]; + const i = frames.indexOf(sourceFrame); + const href = i >= 0 ? this.viewport.readingOrder[i] : undefined; + if (href) this._reapplyDecorationsToFrame(sourceFrame, href); + } else { + this._reapplyDecorationsToCurrentFrames(); + } break; case "first_visible_locator": const loc = Locator.deserialize(data as string); @@ -441,8 +455,17 @@ export class EpubNavigator extends VisualNavigator implements Configurable { if(f.msg) f.msg.listener = (key: CommsEventKey | ManagerEventKey, value: unknown) => { - this.eventListener(key, value); + this.eventListener(key, value, f); } }) - + this._reapplyDecorationsToCurrentFrames(); } private async apply() { @@ -596,6 +619,140 @@ export class EpubNavigator extends VisualNavigator implements Configurable { + if (set.has(observer)) { + set.delete(observer); + if (set.size === 0) { + this._decorationActivationState.delete(group); + this._sendDecorationActivationToFrames(group, false); + } + } + }); + } + + private _sendDecorationActivationToFrames(group: string, activatable: boolean): void { + const frames = this._cframes.filter(f => !!f) as (FrameManager | FXLFrameManager)[]; + frames.forEach(f => { + if (f.msg) f.msg.send("decoration_activatable", { group, activatable }); + }); + } + + public applyDecorations(decorations: Decoration[], group: string): void { + const previous = this._decorations.get(group) ?? []; + const prevById = new Map(previous.map(d => [d.id, d])); + const nextById = new Map(decorations.map(d => [d.id, d])); + + const toRemove: string[] = []; + const toAdd: Decoration[] = []; + const toUpdate: Decoration[] = []; + + for (const [id, prev] of prevById) { + if (!nextById.has(id)) toRemove.push(id); + else if (!decorationsEqual(prev, nextById.get(id)!)) toUpdate.push(nextById.get(id)!); + } + for (const [id, next] of nextById) { + if (!prevById.has(id)) toAdd.push(next); + } + + this._decorations.set(group, decorations); + this._sendDecorationOps(group, toRemove, toAdd, toUpdate, previous); + // Resend activation state after ops: FIFO postMessage guarantees the group + // exists in the injectable by the time this arrives, so it won't be dropped. + // Fixes the case where registerDecorationObserver was called before the group + // was created (activation sent before first decorate:add → silently dropped). + const activatable = this._decorationActivationState.get(group); + if (activatable !== undefined) { + this._sendDecorationActivationToFrames(group, activatable); + } + } + + private _sendDecorationOps( + group: string, + toRemove: string[], + toAdd: Decoration[], + toUpdate: Decoration[], + previous: Decoration[] + ): void { + const frames = this._cframes.filter(f => !!f) as (FrameManager | FXLFrameManager)[]; + const prevById = new Map(previous.map(d => [d.id, d])); + const visibleHrefs = this.viewport.readingOrder; + + frames.forEach((frame, i) => { + if (!frame.msg) return; + const href = visibleHrefs[i]; + if (!href) return; + for (const id of toRemove) { + const d = prevById.get(id); + if (!d || d.locator.href !== href) continue; + frame.msg.send("decorate", { group, action: "remove", decoration: { id } }); + } + for (const d of toAdd) { + if (d.locator.href !== href) continue; + frame.msg.send("decorate", { group, action: "add", decoration: d }); + } + for (const d of toUpdate) { + if (d.locator.href !== href) continue; + frame.msg.send("decorate", { group, action: "update", decoration: d }); + } + }); + } + + private _reapplyDecorationsToFrame(frame: FrameManager | FXLFrameManager, href: string): void { + if (!frame.msg) return; + for (const [group, decorations] of this._decorations) { + const matching = decorations.filter(d => d.locator.href === href); + if (matching.length === 0) continue; + frame.msg.send("decorate", { group, action: "clear" }); + for (const d of matching) + frame.msg.send("decorate", { group, action: "add", decoration: d }); + } + for (const [group, activatable] of this._decorationActivationState) { + frame.msg.send("decoration_activatable", { group, activatable }); + } + } + + private _reapplyDecorationsToCurrentFrames(): void { + const frames = this._cframes.filter(f => !!f) as (FrameManager | FXLFrameManager)[]; + const visibleHrefs = this.viewport.readingOrder; + frames.forEach((frame, i) => { + const href = visibleHrefs[i]; + if (href) this._reapplyDecorationsToFrame(frame, href); + }); + } + + private _handleDecorationActivated(data: DecorationActivatedEvent): boolean { + const observers = this._decorationObservers.get(data.group); + if (!observers || observers.size === 0) return false; + + const decoration = (this._decorations.get(data.group) ?? []).find(d => d.id === data.decorationId); + if (!decoration) return false; + + const event: DecorationActivationEvent = { decoration, group: data.group, rect: data.rect, point: data.point }; + let anyHandled = false; + for (const obs of observers) + if (obs.onDecorationActivated(event)) anyHandled = true; + return anyHandled; + } + + // End of Decoration + public async destroy() { if (this._suspiciousActivityListener) { window.removeEventListener(NAVIGATOR_SUSPICIOUS_ACTIVITY_EVENT, this._suspiciousActivityListener); @@ -606,6 +763,9 @@ export class EpubNavigator extends VisualNavigator implements Configurable { diff --git a/navigator/src/index.ts b/navigator/src/index.ts index 38cb5040..5e7f5b49 100644 --- a/navigator/src/index.ts +++ b/navigator/src/index.ts @@ -1,4 +1,5 @@ export * from './Navigator.ts'; +export * from './decorations/index.ts'; export * from './webpub/index.ts'; export * from './epub/index.ts'; export * from './audio/index.ts'; diff --git a/navigator/src/webpub/WebPubNavigator.ts b/navigator/src/webpub/WebPubNavigator.ts index abd2d390..8af070b6 100644 --- a/navigator/src/webpub/WebPubNavigator.ts +++ b/navigator/src/webpub/WebPubNavigator.ts @@ -2,9 +2,10 @@ import { Feature, Link, Locator, Publication, ReadingProgression, LocatorLocatio import { VisualNavigator, VisualNavigatorViewport, ProgressionRange } from "../Navigator.ts"; import { Configurable } from "../preferences/Configurable.ts"; import { WebPubFramePoolManager } from "./WebPubFramePoolManager.ts"; -import { BasicTextSelection, CommsEventKey, ContextMenuEvent, FrameClickEvent, KeyboardEventData, ModuleName, SuspiciousActivityEvent, WebPubModules } from "@readium/navigator-html-injectables"; +import { BasicTextSelection, CommsEventKey, ContextMenuEvent, Decoration, DecorationActivatedEvent, DecorationStyle, FrameClickEvent, KeyboardEventData, ModuleName, SuspiciousActivityEvent, WebPubModules } from "@readium/navigator-html-injectables"; import * as path from "path-browserify"; import { WebPubFrameManager } from "./WebPubFrameManager.ts"; +import { DecorableNavigator, DecorationActivationEvent, DecorationObserver, decorationsEqual } from "../decorations/index.ts"; import { ManagerEventKey } from "../epub/EpubNavigator.ts"; import { getScriptMode } from "../helpers/scriptMode.ts"; @@ -59,7 +60,7 @@ const defaultListeners = (listeners: WebPubNavigatorListeners): WebPubNavigatorL peripheral: listeners.peripheral || (() => {}) }) -export class WebPubNavigator extends VisualNavigator implements Configurable { +export class WebPubNavigator extends VisualNavigator implements Configurable, DecorableNavigator { private readonly pub: Publication; private readonly container: HTMLElement; private readonly listeners: WebPubNavigatorListeners; @@ -80,6 +81,11 @@ export class WebPubNavigator extends VisualNavigator implements Configurable void) | null = null; private readonly _keyboardPeripheralListener: ((event: Event) => void) | null = null; + private _decorations: Map = new Map(); + private _decorationObservers: Map> = new Map(); + private _decorationActivationState: Map = new Map(); + private _decorationActivationConsumed = false; + private webViewport: VisualNavigatorViewport = { readingOrder: [], progressions: new Map(), @@ -254,6 +260,7 @@ export class WebPubNavigator extends VisualNavigator implements Configurable { + if (set.has(observer)) { + set.delete(observer); + if (set.size === 0) { + this._decorationActivationState.delete(group); + this._sendDecorationActivatable(group, false); + } + } + }); + } + + private _sendDecorationActivatable(group: string, activatable: boolean): void { + const frame = this.framePool?.currentFrames[0]; + if (frame?.msg) frame.msg.send("decoration_activatable", { group, activatable }); + } + + public applyDecorations(decorations: Decoration[], group: string): void { + const previous = this._decorations.get(group) ?? []; + const prevById = new Map(previous.map(d => [d.id, d])); + const nextById = new Map(decorations.map(d => [d.id, d])); + + const toRemove: string[] = []; + const toAdd: Decoration[] = []; + const toUpdate: Decoration[] = []; + + for (const [id, prev] of prevById) { + if (!nextById.has(id)) toRemove.push(id); + else if (!decorationsEqual(prev, nextById.get(id)!)) toUpdate.push(nextById.get(id)!); + } + for (const [id, next] of nextById) { + if (!prevById.has(id)) toAdd.push(next); + } + + this._decorations.set(group, decorations); + this._sendDecorationOps(group, toRemove, toAdd, toUpdate, previous); + + const activatable = this._decorationActivationState.get(group); + if (activatable !== undefined) this._sendDecorationActivatable(group, activatable); + } + + private _sendDecorationOps( + group: string, + toRemove: string[], + toAdd: Decoration[], + toUpdate: Decoration[], + previous: Decoration[] + ): void { + const frame = this.framePool?.currentFrames[0]; + if (!frame?.msg) return; + const href = this.currentLocation.href; + const prevById = new Map(previous.map(d => [d.id, d])); + + for (const id of toRemove) { + const d = prevById.get(id); + if (!d || d.locator.href !== href) continue; + frame.msg.send("decorate", { group, action: "remove", decoration: { id } }); + } + for (const d of toAdd) { + if (d.locator.href !== href) continue; + frame.msg.send("decorate", { group, action: "add", decoration: d }); + } + for (const d of toUpdate) { + if (d.locator.href !== href) continue; + frame.msg.send("decorate", { group, action: "update", decoration: d }); + } } + private _reapplyDecorationsToCurrentFrame(): void { + const frame = this.framePool?.currentFrames[0]; + if (!frame?.msg) return; + const href = this.currentLocation.href; + + for (const [group, decorations] of this._decorations) { + const matching = decorations.filter(d => d.locator.href === href); + if (matching.length === 0) continue; + frame.msg.send("decorate", { group, action: "clear" }); + for (const d of matching) + frame.msg.send("decorate", { group, action: "add", decoration: d }); + } + for (const [group, activatable] of this._decorationActivationState) { + frame.msg.send("decoration_activatable", { group, activatable }); + } + } + + private _handleDecorationActivated(data: DecorationActivatedEvent): boolean { + const observers = this._decorationObservers.get(data.group); + if (!observers || observers.size === 0) return false; + + const decoration = (this._decorations.get(data.group) ?? []).find(d => d.id === data.decorationId); + if (!decoration) return false; + + const event: DecorationActivationEvent = { decoration, group: data.group, rect: data.rect, point: data.point }; + let anyHandled = false; + for (const obs of observers) + if (obs.onDecorationActivated(event)) anyHandled = true; + return anyHandled; + } + + // End of DecorableNavigator + private async changeResource(relative: number): Promise { if (relative === 0) return false; From eb6910d7cb7463e10b271fd18654eb893d228449 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Tue, 28 Apr 2026 09:46:28 +0200 Subject: [PATCH 02/58] Fix CSS updates for non-highlight API - Observe Readium CSS changes - Handle font loading - Handle Chrome Zoom discrepancy in rects/positioning --- navigator-html-injectables/package.json | 1 + .../src/helpers/sML.ts | 0 navigator-html-injectables/src/index.ts | 1 + .../src/modules/Decorator.ts | 101 +++++++++--------- navigator/package.json | 1 - navigator/src/epub/frame/FrameManager.ts | 2 +- navigator/src/epub/fxl/FXLCoordinator.ts | 2 +- .../src/epub/preferences/EpubDefaults.ts | 2 +- .../src/epub/preferences/EpubSettings.ts | 2 +- navigator/src/helpers/index.ts | 1 - navigator/src/protection/DevToolsDetector.ts | 2 +- navigator/src/webpub/WebPubFrameManager.ts | 2 +- .../src/webpub/preferences/WebPubDefaults.ts | 2 +- .../src/webpub/preferences/WebPubSettings.ts | 2 +- pnpm-lock.yaml | 6 +- 15 files changed, 61 insertions(+), 66 deletions(-) rename {navigator => navigator-html-injectables}/src/helpers/sML.ts (100%) diff --git a/navigator-html-injectables/package.json b/navigator-html-injectables/package.json index a2b073ae..49a8821f 100644 --- a/navigator-html-injectables/package.json +++ b/navigator-html-injectables/package.json @@ -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/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/index.ts b/navigator-html-injectables/src/index.ts index d14614d4..8e9db578 100644 --- a/navigator-html-injectables/src/index.ts +++ b/navigator-html-injectables/src/index.ts @@ -2,3 +2,4 @@ export * from './comms/index.ts'; export * from './modules/index.ts'; export * from './Loader.ts'; export * from './protection/index.ts'; +export * from './helpers/sML.ts'; diff --git a/navigator-html-injectables/src/modules/Decorator.ts b/navigator-html-injectables/src/modules/Decorator.ts index 520eebf9..61154969 100644 --- a/navigator-html-injectables/src/modules/Decorator.ts +++ b/navigator-html-injectables/src/modules/Decorator.ts @@ -7,6 +7,7 @@ import { Rect, getClientRectsNoOverlap, rectContainsPoint } from "../helpers/rec import { getProperty } from "../helpers/css.ts"; import { ReadiumWindow } from "../helpers/dom.ts"; import { isDarkColor, getContrastingTextColor } from "../helpers/color.ts"; +import { sML } from "../helpers/sML.ts"; const DEFAULT_HIGHLIGHT_COLOR = "#FFFF00"; // Yellow in HEX @@ -235,8 +236,16 @@ 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); + }); + }); } private experimentalLayout(item: DecorationItem) { @@ -283,33 +292,41 @@ class DecorationGroup { const xOffset = scrollingElement.scrollLeft; const yOffset = scrollingElement.scrollTop; + 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) => { element.style.position = "absolute"; // TODO change to switch if (item.decoration?.style?.width === DecorationWidth.Viewport) { - element.style.width = `${viewportWidth}px`; - element.style.height = `${rect.height}px`; + element.style.width = `${viewportWidth * iz}px`; + element.style.height = `${rect.height * iz}px`; let left = Math.floor(rect.left / viewportWidth) * viewportWidth; - element.style.left = `${left + xOffset}px`; - element.style.top = `${rect.top + yOffset}px`; + element.style.left = `${(left + xOffset) * iz}px`; + element.style.top = `${(rect.top + yOffset) * iz}px`; } else if (item.decoration?.style?.width === DecorationWidth.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`; + element.style.width = `${boundingRect.width * iz}px`; + element.style.height = `${rect.height * iz}px`; + element.style.left = `${(boundingRect.left + xOffset) * iz}px`; + element.style.top = `${(rect.top + yOffset) * iz}px`; } else if (item.decoration?.style?.width === DecorationWidth.Page) { - element.style.width = `${pageWidth}px`; - element.style.height = `${rect.height}px`; + element.style.width = `${pageWidth * iz}px`; + element.style.height = `${rect.height * iz}px`; let left = Math.floor(rect.left / pageWidth) * pageWidth; - element.style.left = `${left + xOffset}px`; - element.style.top = `${rect.top + yOffset}px`; + element.style.left = `${(left + xOffset) * iz}px`; + element.style.top = `${(rect.top + yOffset) * iz}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`; + element.style.width = `${rect.width * iz}px`; + element.style.height = `${rect.height * iz}px`; + element.style.left = `${(rect.left + xOffset) * iz}px`; + element.style.top = `${(rect.top + yOffset) * iz}px`; } } @@ -450,7 +467,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, @@ -472,13 +489,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(() => { @@ -536,40 +546,25 @@ export class Decorator extends Module { }); 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"); @@ -582,7 +577,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/package.json b/navigator/package.json index 6f23fb6c..caf1e8e9 100644 --- a/navigator/package.json +++ b/navigator/package.json @@ -60,7 +60,6 @@ "tslib": "^2.8.1", "typescript": "^5.9.3", "typescript-plugin-css-modules": "^5.2.0", - "user-agent-data-types": "^0.4.2", "vite": "^7.3.1" } } diff --git a/navigator/src/epub/frame/FrameManager.ts b/navigator/src/epub/frame/FrameManager.ts index 628e369a..0af7b110 100644 --- a/navigator/src/epub/frame/FrameManager.ts +++ b/navigator/src/epub/frame/FrameManager.ts @@ -1,7 +1,7 @@ import { Loader, ModuleName } from "@readium/navigator-html-injectables"; import { FrameComms } from "./FrameComms.ts"; import type { ReadiumWindow } from "../../../../navigator-html-injectables/types/src/helpers/dom"; -import { sML } from "../../helpers/index.ts"; +import { sML } from "../../../../navigator-html-injectables/src/helpers/sML.ts"; import type { IContentProtectionConfig, IKeyboardPeripheralsConfig } from "../../Navigator.ts"; diff --git a/navigator/src/epub/fxl/FXLCoordinator.ts b/navigator/src/epub/fxl/FXLCoordinator.ts index 9766d246..3ddaf187 100644 --- a/navigator/src/epub/fxl/FXLCoordinator.ts +++ b/navigator/src/epub/fxl/FXLCoordinator.ts @@ -1,4 +1,4 @@ -import { sML } from "../../helpers/sML.ts"; +import { sML } from "../../../../navigator-html-injectables/src/helpers/sML.ts"; export interface Point { X: number; diff --git a/navigator/src/epub/preferences/EpubDefaults.ts b/navigator/src/epub/preferences/EpubDefaults.ts index 5e8babc8..98221102 100644 --- a/navigator/src/epub/preferences/EpubDefaults.ts +++ b/navigator/src/epub/preferences/EpubDefaults.ts @@ -19,7 +19,7 @@ import { withFallback } from "../../preferences/guards.ts"; -import { sMLWithRequest } from "../../helpers/index.ts"; +import { sMLWithRequest } from "../../../../navigator-html-injectables/src/helpers/sML.ts"; export interface IEpubDefaults { backgroundColor?: string | null, diff --git a/navigator/src/epub/preferences/EpubSettings.ts b/navigator/src/epub/preferences/EpubSettings.ts index 12a33209..5b774a5f 100644 --- a/navigator/src/epub/preferences/EpubSettings.ts +++ b/navigator/src/epub/preferences/EpubSettings.ts @@ -3,7 +3,7 @@ import { ExperimentKey, TextAlignment } from "../../preferences/Types.ts"; import { EpubDefaults } from "./EpubDefaults.ts"; import { EpubPreferences } from "./EpubPreferences.ts"; -import { sMLWithRequest } from "../../helpers/index.ts"; +import { sMLWithRequest } from "../../../../navigator-html-injectables/src/helpers/sML.ts"; export interface IEpubSettings { backgroundColor?: string | null, diff --git a/navigator/src/helpers/index.ts b/navigator/src/helpers/index.ts index 5de79d53..583ee00e 100644 --- a/navigator/src/helpers/index.ts +++ b/navigator/src/helpers/index.ts @@ -1,3 +1,2 @@ export * from "./lineLength.ts"; -export * from './sML.ts'; export * from './scriptMode.ts'; diff --git a/navigator/src/protection/DevToolsDetector.ts b/navigator/src/protection/DevToolsDetector.ts index 1b3364e3..241f0071 100644 --- a/navigator/src/protection/DevToolsDetector.ts +++ b/navigator/src/protection/DevToolsDetector.ts @@ -1,4 +1,4 @@ -import { sML } from "../helpers/sML.ts"; +import { sML } from "../../../navigator-html-injectables/src/helpers/sML.ts"; import { WorkerConsole } from "./utils/WorkerConsole.ts"; import { log, table, clear } from "./utils/console.ts"; import { isBrave } from "./utils/platform.ts"; diff --git a/navigator/src/webpub/WebPubFrameManager.ts b/navigator/src/webpub/WebPubFrameManager.ts index fab1c8d1..393096bc 100644 --- a/navigator/src/webpub/WebPubFrameManager.ts +++ b/navigator/src/webpub/WebPubFrameManager.ts @@ -1,7 +1,7 @@ import { Loader, ModuleName } from "@readium/navigator-html-injectables"; import { FrameComms } from "../epub/frame/FrameComms.ts"; import type { ReadiumWindow } from "../../../navigator-html-injectables/types/src/helpers/dom"; -import { sML } from "../helpers/index.ts"; +import { sML } from "../../../navigator-html-injectables/src/helpers/sML.ts"; import { IContentProtectionConfig, IKeyboardPeripheralsConfig } from "../Navigator.ts"; export class WebPubFrameManager { diff --git a/navigator/src/webpub/preferences/WebPubDefaults.ts b/navigator/src/webpub/preferences/WebPubDefaults.ts index 2ddc2ada..8b0b486d 100644 --- a/navigator/src/webpub/preferences/WebPubDefaults.ts +++ b/navigator/src/webpub/preferences/WebPubDefaults.ts @@ -14,7 +14,7 @@ import { ensureExperiment } from "../../preferences/guards.ts"; -import { sMLWithRequest } from "../../helpers/index.ts"; +import { sMLWithRequest } from "../../../../navigator-html-injectables/src/helpers/sML.ts"; export interface IWebPubDefaults { fontFamily?: string | null, diff --git a/navigator/src/webpub/preferences/WebPubSettings.ts b/navigator/src/webpub/preferences/WebPubSettings.ts index c92164e6..874e3022 100644 --- a/navigator/src/webpub/preferences/WebPubSettings.ts +++ b/navigator/src/webpub/preferences/WebPubSettings.ts @@ -3,7 +3,7 @@ import { ExperimentKey, TextAlignment } from "../../preferences/Types.ts"; import { WebPubDefaults } from "./WebPubDefaults.ts"; import { WebPubPreferences } from "./WebPubPreferences.ts"; -import { sMLWithRequest } from "../../helpers/index.ts"; +import { sMLWithRequest } from "../../../../navigator-html-injectables/src/helpers/sML.ts"; export interface IWebPubSettings { fontFamily?: string | null, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5bcb567f..099a50dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,9 +50,6 @@ importers: typescript-plugin-css-modules: specifier: ^5.2.0 version: 5.2.0(typescript@5.9.3) - user-agent-data-types: - specifier: ^0.4.2 - version: 0.4.2 vite: specifier: ^7.3.1 version: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(less@4.5.1)(sass@1.97.2)(stylus@0.62.0) @@ -74,6 +71,9 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 + user-agent-data-types: + specifier: ^0.4.2 + version: 0.4.2 vite: specifier: ^7.3.1 version: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(less@4.5.1)(sass@1.97.2)(stylus@0.62.0) From 4819b1d51a964d11e53d44eb60f1a192a0449388 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Tue, 28 Apr 2026 09:55:48 +0200 Subject: [PATCH 03/58] Update import of sML TMP. will deal with this in new build. --- navigator/src/epub/frame/FrameManager.ts | 2 +- navigator/src/epub/fxl/FXLCoordinator.ts | 2 +- navigator/src/epub/preferences/EpubDefaults.ts | 2 +- navigator/src/epub/preferences/EpubSettings.ts | 2 +- navigator/src/protection/DevToolsDetector.ts | 2 +- navigator/src/webpub/WebPubFrameManager.ts | 2 +- navigator/src/webpub/preferences/WebPubDefaults.ts | 2 +- navigator/src/webpub/preferences/WebPubSettings.ts | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/navigator/src/epub/frame/FrameManager.ts b/navigator/src/epub/frame/FrameManager.ts index 0af7b110..f8424724 100644 --- a/navigator/src/epub/frame/FrameManager.ts +++ b/navigator/src/epub/frame/FrameManager.ts @@ -1,7 +1,7 @@ import { Loader, ModuleName } from "@readium/navigator-html-injectables"; import { FrameComms } from "./FrameComms.ts"; import type { ReadiumWindow } from "../../../../navigator-html-injectables/types/src/helpers/dom"; -import { sML } from "../../../../navigator-html-injectables/src/helpers/sML.ts"; +import { sML } from "@readium/navigator-html-injectables"; import type { IContentProtectionConfig, IKeyboardPeripheralsConfig } from "../../Navigator.ts"; diff --git a/navigator/src/epub/fxl/FXLCoordinator.ts b/navigator/src/epub/fxl/FXLCoordinator.ts index 3ddaf187..40508b40 100644 --- a/navigator/src/epub/fxl/FXLCoordinator.ts +++ b/navigator/src/epub/fxl/FXLCoordinator.ts @@ -1,4 +1,4 @@ -import { sML } from "../../../../navigator-html-injectables/src/helpers/sML.ts"; +import { sML } from "@readium/navigator-html-injectables"; export interface Point { X: number; diff --git a/navigator/src/epub/preferences/EpubDefaults.ts b/navigator/src/epub/preferences/EpubDefaults.ts index 98221102..434617bd 100644 --- a/navigator/src/epub/preferences/EpubDefaults.ts +++ b/navigator/src/epub/preferences/EpubDefaults.ts @@ -19,7 +19,7 @@ import { withFallback } from "../../preferences/guards.ts"; -import { sMLWithRequest } from "../../../../navigator-html-injectables/src/helpers/sML.ts"; +import { sMLWithRequest } from "@readium/navigator-html-injectables"; export interface IEpubDefaults { backgroundColor?: string | null, diff --git a/navigator/src/epub/preferences/EpubSettings.ts b/navigator/src/epub/preferences/EpubSettings.ts index 5b774a5f..5ea5286a 100644 --- a/navigator/src/epub/preferences/EpubSettings.ts +++ b/navigator/src/epub/preferences/EpubSettings.ts @@ -3,7 +3,7 @@ import { ExperimentKey, TextAlignment } from "../../preferences/Types.ts"; import { EpubDefaults } from "./EpubDefaults.ts"; import { EpubPreferences } from "./EpubPreferences.ts"; -import { sMLWithRequest } from "../../../../navigator-html-injectables/src/helpers/sML.ts"; +import { sMLWithRequest } from "@readium/navigator-html-injectables"; export interface IEpubSettings { backgroundColor?: string | null, diff --git a/navigator/src/protection/DevToolsDetector.ts b/navigator/src/protection/DevToolsDetector.ts index 241f0071..4da105bf 100644 --- a/navigator/src/protection/DevToolsDetector.ts +++ b/navigator/src/protection/DevToolsDetector.ts @@ -1,4 +1,4 @@ -import { sML } from "../../../navigator-html-injectables/src/helpers/sML.ts"; +import { sML } from "@readium/navigator-html-injectables"; import { WorkerConsole } from "./utils/WorkerConsole.ts"; import { log, table, clear } from "./utils/console.ts"; import { isBrave } from "./utils/platform.ts"; diff --git a/navigator/src/webpub/WebPubFrameManager.ts b/navigator/src/webpub/WebPubFrameManager.ts index 393096bc..953cfb98 100644 --- a/navigator/src/webpub/WebPubFrameManager.ts +++ b/navigator/src/webpub/WebPubFrameManager.ts @@ -1,7 +1,7 @@ import { Loader, ModuleName } from "@readium/navigator-html-injectables"; import { FrameComms } from "../epub/frame/FrameComms.ts"; import type { ReadiumWindow } from "../../../navigator-html-injectables/types/src/helpers/dom"; -import { sML } from "../../../navigator-html-injectables/src/helpers/sML.ts"; +import { sML } from "@readium/navigator-html-injectables"; import { IContentProtectionConfig, IKeyboardPeripheralsConfig } from "../Navigator.ts"; export class WebPubFrameManager { diff --git a/navigator/src/webpub/preferences/WebPubDefaults.ts b/navigator/src/webpub/preferences/WebPubDefaults.ts index 8b0b486d..7a0b5029 100644 --- a/navigator/src/webpub/preferences/WebPubDefaults.ts +++ b/navigator/src/webpub/preferences/WebPubDefaults.ts @@ -14,7 +14,7 @@ import { ensureExperiment } from "../../preferences/guards.ts"; -import { sMLWithRequest } from "../../../../navigator-html-injectables/src/helpers/sML.ts"; +import { sMLWithRequest } from "@readium/navigator-html-injectables"; export interface IWebPubDefaults { fontFamily?: string | null, diff --git a/navigator/src/webpub/preferences/WebPubSettings.ts b/navigator/src/webpub/preferences/WebPubSettings.ts index 874e3022..9f68ae8b 100644 --- a/navigator/src/webpub/preferences/WebPubSettings.ts +++ b/navigator/src/webpub/preferences/WebPubSettings.ts @@ -3,7 +3,7 @@ import { ExperimentKey, TextAlignment } from "../../preferences/Types.ts"; import { WebPubDefaults } from "./WebPubDefaults.ts"; import { WebPubPreferences } from "./WebPubPreferences.ts"; -import { sMLWithRequest } from "../../../../navigator-html-injectables/src/helpers/sML.ts"; +import { sMLWithRequest } from "@readium/navigator-html-injectables"; export interface IWebPubSettings { fontFamily?: string | null, From 5cba77ab2c5da7a746c00611271472836c22b004 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Tue, 28 Apr 2026 12:39:15 +0200 Subject: [PATCH 04/58] Add Decoration Style types --- .../src/modules/Decorator.ts | 102 +++++++++++++----- 1 file changed, 76 insertions(+), 26 deletions(-) diff --git a/navigator-html-injectables/src/modules/Decorator.ts b/navigator-html-injectables/src/modules/Decorator.ts index 61154969..3df0edaf 100644 --- a/navigator-html-injectables/src/modules/Decorator.ts +++ b/navigator-html-injectables/src/modules/Decorator.ts @@ -23,9 +23,16 @@ export enum DecorationLayout { Bounds = "bounds", // A single HTML element covering the smallest region containing all CSS border boxes. } -// TODO improve +export enum 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. +} + export interface DecorationStyle { - tint: string; // CSS color string + type?: DecorationStyleType; // Defaults to Highlight when omitted. + tint?: string; // CSS color string; optional, falls back to yellow. layout: DecorationLayout; // Determines the number of created HTML elements and their position relative to the matching DOM range. width: DecorationWidth; // Indicates how the width of each created HTML element expands in the viewport. isActive?: boolean; // Whether user activation (click/tap) events fire for this decoration. @@ -127,6 +134,10 @@ class DecorationGroup { this.notTextFlag?.set(id, true); } } + if (this.experimentalHighlights && decoration.style?.type === DecorationStyleType.Outline) { + // CSS Highlight API does not support `outline`; force DOM overlay path. + this.notTextFlag?.set(id, true); + } const item = { decoration, @@ -252,16 +263,35 @@ class DecorationGroup { 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; + const type = item.decoration?.style?.type ?? DecorationStyleType.Highlight; // 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}; - }`; + let css: string; + switch (type) { + case DecorationStyleType.Underline: + css = `::highlight(${this.id}) { + text-decoration: underline; + text-decoration-color: ${tint}; + text-decoration-thickness: 0.1em; + }`; + break; + case DecorationStyleType.TextColor: + css = `::highlight(${this.id}) { + color: ${tint}; + }`; + break; + case DecorationStyleType.Highlight: + default: { + const backgroundColor = getProperty(this.wnd, "--USER__backgroundColor") || + this.wnd.getComputedStyle(this.wnd.document.documentElement).getPropertyValue("background-color"); + css = `::highlight(${this.id}) { + color: ${getContrastingTextColor(tint, backgroundColor)}; + background-color: ${tint}; + }`; + } + } + stylesheet.innerHTML = css; } /** @@ -332,26 +362,46 @@ class DecorationGroup { const boundingRect = item.range.getBoundingClientRect(); - let template = this.wnd.document.createElement("template"); - // template.innerHTML = item.decoration.element.trim(); - // TODO more styles logic + const type = item.decoration?.style?.type ?? DecorationStyleType.Highlight; + const tint = item.decoration?.style?.tint ?? DEFAULT_HIGHLIGHT_COLOR; + + // TextColor requires CSS Highlight API; DOM overlay has no equivalent. + if (type === DecorationStyleType.TextColor) { + item.container = itemContainer; + item.clickableElements = []; + return; + } const isDarkMode = this.getCurrentDarkMode(); - template.innerHTML = ` -
-
- `.trim(); + const styleAttr = (() => { + switch (type) { + case DecorationStyleType.Underline: + return [ + `border-bottom: 0.1em solid ${tint} !important`, + "background-color: transparent !important", + "box-sizing: border-box !important", + ].join("; "); + case DecorationStyleType.Outline: + return [ + `outline: 2px solid ${tint} !important`, + "outline-offset: 1px !important", + "background-color: transparent !important", + "box-sizing: border-box !important", + ].join("; "); + case DecorationStyleType.Highlight: + default: + return [ + `background-color: ${tint} !important`, + `mix-blend-mode: ${isDarkMode ? "exclusion" : "multiply"} !important`, + "opacity: 1 !important", + "box-sizing: border-box !important", + ].join("; "); + } + })(); + + let template = this.wnd.document.createElement("template"); + template.innerHTML = `
`.trim(); const elementTemplate = template.content.firstElementChild!; if(item.decoration?.style?.layout === DecorationLayout.Bounds) { From 4ae3fc7f56ffe0084e29a9ec6fd804bc03c794cb Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Tue, 28 Apr 2026 13:48:03 +0200 Subject: [PATCH 05/58] Templating --- .../src/helpers/sanitize.ts | 77 +++++++ .../src/modules/Decorator.ts | 201 ++++++++++++------ navigator/docs/epub/Decorations.md | 119 +++++++++-- navigator/src/decorations/index.ts | 30 ++- 4 files changed, 335 insertions(+), 92 deletions(-) create mode 100644 navigator-html-injectables/src/helpers/sanitize.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/modules/Decorator.ts b/navigator-html-injectables/src/modules/Decorator.ts index 3df0edaf..943aa249 100644 --- a/navigator-html-injectables/src/modules/Decorator.ts +++ b/navigator-html-injectables/src/modules/Decorator.ts @@ -8,6 +8,7 @@ import { getProperty } from "../helpers/css.ts"; import { ReadiumWindow } from "../helpers/dom.ts"; import { isDarkColor, getContrastingTextColor } from "../helpers/color.ts"; import { sML } from "../helpers/sML.ts"; +import { sanitizeHTML } from "../helpers/sanitize.ts"; const DEFAULT_HIGHLIGHT_COLOR = "#FFFF00"; // Yellow in HEX @@ -28,16 +29,35 @@ export enum DecorationStyleType { Underline = "underline", // Underline drawn beneath the text. Outline = "outline", // Border drawn around the text boxes. TextColor = "textColor", // Changes the text color directly. + Template = "template", // Custom HTML template (HTMLDecorationTemplate). } -export interface DecorationStyle { - type?: DecorationStyleType; // Defaults to Highlight when omitted. - tint?: string; // CSS color string; optional, falls back to yellow. - layout: DecorationLayout; // Determines the number of created HTML elements and their position relative to the matching DOM range. - width: DecorationWidth; // Indicates how the width of each created HTML element expands in the viewport. - isActive?: boolean; // Whether user activation (click/tap) events fire for this decoration. +/** 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; } +/** + * 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: DecorationStyleType.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. @@ -134,9 +154,15 @@ class DecorationGroup { this.notTextFlag?.set(id, true); } } - if (this.experimentalHighlights && decoration.style?.type === DecorationStyleType.Outline) { - // CSS Highlight API does not support `outline`; force DOM overlay path. - this.notTextFlag?.set(id, true); + if (this.experimentalHighlights) { + if (decoration.style?.type === DecorationStyleType.Outline) { + // CSS Highlight API does not support `outline`; force DOM overlay path. + this.notTextFlag?.set(id, true); + } + if (decoration.style?.type === DecorationStyleType.Template) { + // Custom element templates render as DOM nodes, not CSS ranges. + this.notTextFlag?.set(id, true); + } } const item = { @@ -209,29 +235,40 @@ class DecorationGroup { for (const item of this.items) { if (!item.decoration.style?.isActive) continue; - let hit = false; - - // 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 in neither case is the decoration element the pointer event target. - const rects = item.range.getClientRects(); - for (const rect of rects) { - if (rectContainsPoint(rect as Rect, cssX, cssY, 0)) { - hit = true; - break; + 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 (hit) { - const r = item.range.getBoundingClientRect(); + if (hitRect) { this.comms.send("decoration_activated", { decorationId: item.decoration.id, group: this.name, rect: { - top: r.top * pixelRatio, - left: r.left * pixelRatio, - width: r.width * pixelRatio, - height: r.height * pixelRatio, + 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); @@ -263,8 +300,10 @@ class DecorationGroup { const [stylesheet, highlighter]: [HTMLStyleElement, any] = this.requireContainer(true) as [HTMLStyleElement, unknown]; highlighter.add(item.range); - const tint = item.decoration?.style?.tint ?? DEFAULT_HIGHLIGHT_COLOR; - const type = item.decoration?.style?.type ?? DecorationStyleType.Highlight; + // Template items are always routed to the DOM overlay; only BuiltinDecorationStyle reaches here. + const style = item.decoration.style as BuiltinDecorationStyle; + const tint = style.tint ?? DEFAULT_HIGHLIGHT_COLOR; + const type = style.type ?? DecorationStyleType.Highlight; // TODO add caching layer ("vdom") to this so we aren't completely replacing the CSS every time let css: string; @@ -362,47 +401,66 @@ class DecorationGroup { const boundingRect = item.range.getBoundingClientRect(); - const type = item.decoration?.style?.type ?? DecorationStyleType.Highlight; - const tint = item.decoration?.style?.tint ?? DEFAULT_HIGHLIGHT_COLOR; + const decoStyle = item.decoration.style; + let elementTemplate: Element; - // TextColor requires CSS Highlight API; DOM overlay has no equivalent. - if (type === DecorationStyleType.TextColor) { - item.container = itemContainer; - item.clickableElements = []; - return; - } - - const isDarkMode = this.getCurrentDarkMode(); - - const styleAttr = (() => { - switch (type) { - case DecorationStyleType.Underline: - return [ - `border-bottom: 0.1em solid ${tint} !important`, - "background-color: transparent !important", - "box-sizing: border-box !important", - ].join("; "); - case DecorationStyleType.Outline: - return [ - `outline: 2px solid ${tint} !important`, - "outline-offset: 1px !important", - "background-color: transparent !important", - "box-sizing: border-box !important", - ].join("; "); - case DecorationStyleType.Highlight: - default: - return [ - `background-color: ${tint} !important`, - `mix-blend-mode: ${isDarkMode ? "exclusion" : "multiply"} !important`, - "opacity: 1 !important", - "box-sizing: border-box !important", - ].join("; "); + 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 ?? DEFAULT_HIGHLIGHT_COLOR; + + // TextColor requires CSS Highlight API; DOM overlay has no equivalent. + if (type === DecorationStyleType.TextColor) { + item.container = itemContainer; + item.clickableElements = []; + return; } - })(); - let template = this.wnd.document.createElement("template"); - template.innerHTML = `
`.trim(); - const elementTemplate = template.content.firstElementChild!; + const isDarkMode = this.getCurrentDarkMode(); + const styleAttr = (() => { + switch (type) { + case DecorationStyleType.Underline: + return [ + `border-bottom: 0.1em solid ${tint} !important`, + "background-color: transparent !important", + "box-sizing: border-box !important", + ].join("; "); + case DecorationStyleType.Outline: + return [ + `outline: 2px solid ${tint} !important`, + "outline-offset: 1px !important", + "background-color: transparent !important", + "box-sizing: border-box !important", + ].join("; "); + case DecorationStyleType.Highlight: + default: + return [ + `background-color: ${tint} !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; @@ -500,6 +558,18 @@ class DecorationGroup { isDarkColor(this.wnd.getComputedStyle(this.wnd.document.documentElement).getPropertyValue("background-color")); } + 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; + } + /** * Removes the group container. */ @@ -507,6 +577,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; diff --git a/navigator/docs/epub/Decorations.md b/navigator/docs/epub/Decorations.md index 8045f10c..9f2f8ec0 100644 --- a/navigator/docs/epub/Decorations.md +++ b/navigator/docs/epub/Decorations.md @@ -21,23 +21,39 @@ interface Decoration { ### DecorationStyle -`DecorationStyle` controls the appearance: +`DecorationStyle` is a union of the built-in style types and `HTMLDecorationTemplate`: ```ts -interface DecorationStyle { - tint: string; // Any CSS color — "#ffff00", "rgba(255,200,0,0.4)", etc. - layout: DecorationLayout; // DecorationLayout.Boxes | DecorationLayout.Bounds - width: DecorationWidth; // DecorationWidth.Wrap | DecorationWidth.Viewport | DecorationWidth.Page | DecorationWidth.Bounds +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 } ``` +**`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. | + **`DecorationLayout`** | Value | Description | |---|---| -| `DecorationLayout.Boxes` | One element per CSS border box (i.e. per line of text). Best for highlights. | -| `DecorationLayout.Bounds` | A single element covering the bounding box of the whole range. Best for margin icons. | +| `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`** @@ -48,6 +64,25 @@ interface DecorationStyle { | `DecorationWidth.Page` | Fills one page in a paginated layout. | | `DecorationWidth.Bounds` | Fills the anchor page (useful in dual-page FXL). | +#### HTMLDecorationTemplate + +For fully custom decoration rendering, use `HTMLDecorationTemplate`. The `element` string is an HTML snippet that is sanitized before injection; the `stylesheet` is injected as a `