Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
e29a045
Init Decorator API in visual
JayPanoz Apr 27, 2026
eb6910d
Fix CSS updates for non-highlight API
JayPanoz Apr 28, 2026
4819b1d
Update import of sML
JayPanoz Apr 28, 2026
5cba77a
Add Decoration Style types
JayPanoz Apr 28, 2026
4ae3fc7
Templating
JayPanoz Apr 28, 2026
9843e7d
Implement mask
JayPanoz Apr 28, 2026
3dd7340
Add support for vertical-writing
JayPanoz Apr 29, 2026
a53b04a
MoreProtect against locations
JayPanoz Apr 29, 2026
3caa25d
Pass Locator to TextSelection
JayPanoz Apr 29, 2026
ecd8e91
Update BasicTextSelection Locator
JayPanoz Apr 29, 2026
180efbd
Make enforceContrast optional
JayPanoz Apr 30, 2026
d9146db
Improve Mutation observing in Snapper
JayPanoz May 4, 2026
1d04dd3
Clean up Service noop
JayPanoz May 4, 2026
a7888f4
Update Readium CSS JSON usage
JayPanoz May 4, 2026
039050b
Fix path of Bounds/Width options
JayPanoz May 5, 2026
08e2755
Handle outline in page/viewport Width
JayPanoz May 5, 2026
46d68b2
Update Underline for layout bounds
JayPanoz May 5, 2026
efea862
Remove MaskBlock type
JayPanoz May 5, 2026
f15fcb1
Correct path for textColor type
JayPanoz May 5, 2026
f110777
Handle text color width
JayPanoz May 5, 2026
e001b3d
Handle vertical-writing mode
JayPanoz May 5, 2026
90e0f8a
Adress gaps in implementation
JayPanoz May 6, 2026
9ec98b6
Correct textColor Bounds and Page
JayPanoz May 6, 2026
d84d4d9
Respect tint for mask type
JayPanoz May 6, 2026
49f3931
Merge branch 'develop' into decorator-api
JayPanoz May 7, 2026
c1c2968
Merge branch 'develop' into decorator-api
JayPanoz May 11, 2026
89a0f0e
Merge branch 'develop' into decorator-api
JayPanoz May 21, 2026
7ddd373
Merge branch 'develop' into decorator-api
JayPanoz May 27, 2026
44a9a97
Extract Decorator
JayPanoz Jun 2, 2026
a134b0f
Update config
JayPanoz Jun 2, 2026
b82530e
Add doc
JayPanoz Jun 2, 2026
7b096be
Merge branch 'develop' into standalone-decorator
JayPanoz Jun 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions decorator/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
*.log
.DS_Store
node_modules
dist
types
coverage
104 changes: 104 additions & 0 deletions decorator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# @readium/decorator

Standalone decoration controller for Readium publications. Lets `@readium/speech` (or any host) drive highlight decorations on a plain page **without** a Navigator or iframe.

## Why this package exists

The `Decorator` module that renders highlights lives in `@readium/navigator-html-injectables`. Normally it is mounted inside an iframe and receives commands via `postMessage` (`Comms`). This package adds:

- **`DirectCommsChannel`** — a same-process, synchronous replacement for `postMessage`. Instead of crossing a frame boundary, host and module talk directly in memory.
- **`DecorationController`** — the diff algorithm and group state that live inside `EpubNavigator`, extracted so any host can drive decorations without a Navigator.

`Decorator` itself still lives in `navigator-html-injectables` (it shares DOM helpers with Snappers and Setup — moving it would create circular deps). This package re-exports it, so speech only needs one import source.

## Dependency graph

```
@readium/shared
@readium/navigator-html-injectables
@readium/decorator ← this package
@readium/speech @readium/navigator (and any other consumer)
```

## Usage

```ts
import { DirectCommsChannel, Decorator, DecorationController } from "@readium/decorator";

// 1. Create the in-process comms channel
const channel = new DirectCommsChannel();

// 2. Mount the Decorator module directly on the host window
const decorator = new Decorator();
decorator.mount(window, channel.frame);

// 3. Create the controller — it owns the diff state and sends commands through channel.host
const ctrl = new DecorationController(channel.host);

// 4. Apply decorations (call again with a new array to update)
ctrl.applyDecorations([
{
id: "tts-0",
locator: /* Locator pointing at the text to highlight */,
style: { type: "highlight", tint: "#FFFF00" },
}
], "tts");

// 5. Cleanup
decorator.unmount(window, channel.frame);
ctrl.destroy();
channel.frame.destroy();
```

## API

### `DirectCommsChannel`

Pairs a `DirectCommsFrame` (implements `IComms` — pass to `Decorator.mount`) with a `DirectCommsHost` (used by `DecorationController` to send commands). No postMessage, no async.

```ts
const channel = new DirectCommsChannel();
channel.frame // IComms — module side
channel.host // DirectCommsHost — controller side
```

### `DecorationController`

```ts
class DecorationController {
constructor(host: DirectCommsHost)

// Replace all decorations for a group. Diffs against previous state.
applyDecorations(decorations: Decoration[], group: string): void

// Register a listener for when a user taps/clicks a decoration marked isActive: true.
registerDecorationObserver(group: string, observer: DecorationObserver): void
unregisterDecorationObserver(observer: DecorationObserver): void

destroy(): void
}
```

### `Decorator` (re-exported from `@readium/navigator-html-injectables`)

```ts
class Decorator {
mount(wnd: Window, comms: IComms): boolean
unmount(wnd: Window, comms: IComms): boolean
}
```

### Types (re-exported from `@readium/navigator-html-injectables`)

| Name | Notes |
|------|-------|
| `Decoration` | `{ id, locator, style, extras? }` |
| `DecorationStyle` | `BuiltinDecorationStyle \| HTMLDecorationTemplate` |
| `BuiltinDecorationStyle` | `{ type?, tint?, layout?, width?, isActive?, enforceContrast? }` |
| `DecorationStyleType` | `"highlight" \| "underline" \| "outline" \| "textColor" \| "mask" \| "template"` |
| `DecorationLayout` | `"boxes" \| "bounds"` |
| `DecorationWidth` | `"wrap" \| "viewport" \| "bounds" \| "page"` |
| `IComms` | Interface implemented by both `Comms` (postMessage) and `DirectCommsFrame` |
47 changes: 47 additions & 0 deletions decorator/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"name": "@readium/decorator",
"version": "1.0.0",
"type": "module",
"description": "Standalone decoration controller for Readium publications",
"author": "readium",
"repository": {
"type": "git",
"url": "git+https://github.com/readium/ts-toolkit.git",
"directory": "decorator"
},
"license": "BSD-3-Clause",
"bugs": {
"url": "https://github.com/readium/ts-toolkit/issues"
},
"homepage": "https://github.com/readium/ts-toolkit",
"main": "./dist/index.umd.cjs",
"module": "./dist/index.js",
"types": "./types/src/index.d.ts",
"exports": {
".": {
"types": "./types/src/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.umd.cjs"
}
},
"files": [
"dist",
"src",
"types"
],
"engines": {
"node": ">=18"
},
"scripts": {
"clean": "rimraf types dist",
"build": "pnpm clean && tsc && vite build"
},
"devDependencies": {
"@readium/navigator-html-injectables": "workspace:*",
"@readium/shared": "workspace:*",
"rimraf": "^6.1.2",
"tslib": "^2.8.1",
"typescript": "^5.9.3",
"vite": "^7.3.1"
}
}
93 changes: 93 additions & 0 deletions decorator/src/comms/direct.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import type { IComms, CommsCallback } from "@readium/navigator-html-injectables";

type AckFn = (ok: boolean) => void;
type EventListener = (data: unknown) => void;

export class DirectCommsChannel {
readonly frame: DirectCommsFrame;
readonly host: DirectCommsHost;

constructor() {
this.frame = new DirectCommsFrame(this);
this.host = new DirectCommsHost(this);
}
}

export class DirectCommsFrame implements IComms {
private registrar = new Map<string, { module: string; cb: CommsCallback }[]>();

constructor(private readonly channel: DirectCommsChannel) {}

register(key: string | string[], module: string, callback: CommsCallback): void {
const keys = Array.isArray(key) ? key : [key];
keys.forEach(k => {
const listeners = this.registrar.get(k) ?? [];
const existing = listeners.find(l => l.module === module);
if (existing) throw new Error(`Duplicate callback for "${k}" in module "${module}"`);
listeners.push({ module, cb: callback });
this.registrar.set(k, listeners);
});
}

unregister(key: string | string[], module: string): void {
const keys = Array.isArray(key) ? key : [key];
keys.forEach(k => {
const ls = this.registrar.get(k);
if (!ls) return;
this.registrar.set(k, ls.filter(l => l.module !== module));
});
}

unregisterAll(module: string): void {
this.registrar.forEach((ls, k) => {
this.registrar.set(k, ls.filter(l => l.module !== module));
});
}

_dispatch(key: string, data: unknown, ack: AckFn): void {
const ls = this.registrar.get(key);
if (!ls?.length) { ack(false); return; }
ls.forEach(l => l.cb(data, ack));
}

send(key: string, data: unknown): void {
this.channel.host._receive(key, data);
}

log(...data: unknown[]): void {
this.channel.host._receive("log", data);
}

readonly ready = true;

destroy(): void {
this.registrar.clear();
}
}

export class DirectCommsHost {
private listeners = new Map<string, EventListener[]>();

constructor(private readonly channel: DirectCommsChannel) {}

send(key: string, data: unknown, callback?: AckFn): void {
this.channel.frame._dispatch(key, data, callback ?? (() => {}));
}

on(key: string, cb: EventListener): void {
const ls = this.listeners.get(key) ?? [];
ls.push(cb);
this.listeners.set(key, ls);
}

off(key: string, cb: EventListener): void {
const ls = this.listeners.get(key);
if (ls) this.listeners.set(key, ls.filter(l => l !== cb));
}

_receive(key: string, data: unknown): void {
this.listeners.get(key)?.forEach(cb => cb(data));
}

readonly ready = true;
}
109 changes: 109 additions & 0 deletions decorator/src/controller/DecorationController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import type { Decoration } from "@readium/navigator-html-injectables";
import type { DirectCommsHost } from "../comms/direct.ts";

export interface DecorationObserver {
onDecorationActivated(event: DecorationActivatedEvent): boolean;
}

export interface DecorationActivatedEvent {
decorationId: string;
group: string;
decoration: Decoration;
rect?: { top: number; left: number; width: number; height: number };
point?: { x: number; y: number };
}

export class DecorationController {
private _decorations = new Map<string, Decoration[]>();
private _activationState = new Map<string, boolean>();
private _observers = new Map<string, Set<DecorationObserver>>();

constructor(private readonly host: DirectCommsHost) {
host.on("decoration_activated", (raw) => {
const ev = raw as { decorationId: string; group: string; rect?: unknown; point?: unknown };
const decoration = this._decorations.get(ev.group)?.find(d => d.id === ev.decorationId);
if (!decoration) return;
this._observers.get(ev.group)?.forEach(obs =>
obs.onDecorationActivated({
decorationId: ev.decorationId,
group: ev.group,
decoration,
rect: ev.rect as DecorationActivatedEvent["rect"],
point: ev.point as DecorationActivatedEvent["point"],
})
);
});
}

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]));

for (const [id, prev] of prevById) {
if (!nextById.has(id)) {
this.host.send("decorate", { group, action: "remove", decoration: { id } });
} else if (!_decorationsEqual(prev, nextById.get(id)!)) {
this.host.send("decorate", { group, action: "update", decoration: nextById.get(id)! });
}
}
for (const [id, next] of nextById) {
if (!prevById.has(id)) {
this.host.send("decorate", { group, action: "add", decoration: next });
}
}

this._decorations.set(group, decorations);
const activatable = this._activationState.get(group);
if (activatable !== undefined) {
this.host.send("decoration_activatable", { group, activatable });
}
}

registerDecorationObserver(group: string, observer: DecorationObserver): void {
if (!this._observers.has(group)) this._observers.set(group, new Set());
this._observers.get(group)!.add(observer);
this._activationState.set(group, true);
this.host.send("decoration_activatable", { group, activatable: true });
}

unregisterDecorationObserver(observer: DecorationObserver): void {
this._observers.forEach((set, group) => {
if (!set.has(observer)) return;
set.delete(observer);
if (set.size === 0) {
this._activationState.delete(group);
this.host.send("decoration_activatable", { group, activatable: false });
}
});
}

destroy(): void {
this._decorations.clear();
this._activationState.clear();
this._observers.clear();
}
}

function _decorationsEqual(a: Decoration, b: Decoration): boolean {
if (a.locator.href !== b.locator.href) return false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const locA = typeof (a.locator.locations as any)?.serialize === "function"
// eslint-disable-next-line @typescript-eslint/no-explicit-any
? (a.locator.locations as any).serialize() : a.locator.locations;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const locB = typeof (b.locator.locations as any)?.serialize === "function"
// eslint-disable-next-line @typescript-eslint/no-explicit-any
? (b.locator.locations as any).serialize() : b.locator.locations;
if (JSON.stringify(locA) !== JSON.stringify(locB)) return false;
if (a.style.type !== b.style.type) return false;
if ((a.style.isActive ?? false) !== (b.style.isActive ?? false)) return false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sa = a.style as any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sb = b.style as any;
if (sa.tint !== sb.tint || sa.layout !== sb.layout || sa.width !== sb.width) return false;
if ((sa.enforceContrast ?? true) !== (sb.enforceContrast ?? true)) return false;
if (sa.element !== sb.element || sa.stylesheet !== sb.stylesheet) return false;
return JSON.stringify(a.extras ?? null) === JSON.stringify(b.extras ?? null);
}
18 changes: 18 additions & 0 deletions decorator/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export type { IComms } from "@readium/navigator-html-injectables";
export { Decorator } from "@readium/navigator-html-injectables";
export type {
Decoration,
DecoratorRequest,
DecorationActivatedEvent as DecorationActivatedWireEvent,
DecorationStyle,
BuiltinDecorationStyle,
HTMLDecorationTemplate,
} from "@readium/navigator-html-injectables";
export {
DecorationStyleType,
DecorationLayout,
DecorationWidth,
} from "@readium/navigator-html-injectables";

export * from "./comms/direct.ts";
export * from "./controller/DecorationController.ts";
Loading
Loading