Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 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
26f1597
Merge branch 'develop' into decorator-api
JayPanoz Jun 4, 2026
b34e5d3
Prepare packages
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
12 changes: 12 additions & 0 deletions navigator-html-injectables/CHANGELOG.MD
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.5.0] – 2026–06–04

### Added

- Implemented Decorator API ([#209](https://github.com/readium/ts-toolkit/pull/209))

### Changed

- Snappers and Decorator modules will avoid running callbacks on mutation when not necessary
- `BasicTextSelection` now returns a ready-to-use `Locator`
- Decorations appended into the DOM are now appended into the Shadow DOM

## [2.4.4] – 2026–06–04

### Added
Expand Down
3 changes: 2 additions & 1 deletion navigator-html-injectables/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@readium/navigator-html-injectables",
"version": "2.4.4",
"version": "2.5.0",
"type": "module",
"description": "An embeddable solution for connecting frames of HTML publications with a Readium Navigator",
"author": "readium",
Expand Down Expand Up @@ -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"
}
}
5 changes: 3 additions & 2 deletions navigator-html-injectables/src/comms/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ export type CommsEventKey =
"media_play" |
"media_pause" |
"content_protection" |
"keyboard_peripherals";
;
"keyboard_peripherals" |
"decoration_activated";

export type CommsCommandKey =
"_ping" |
Expand All @@ -39,6 +39,7 @@ export type CommsCommandKey =
// "exact_progress" |
"first_visible_locator" |
"decorate" |
"decoration_activatable" |
"protect" |
"unprotect" |
"unfocus" |
Expand Down
63 changes: 63 additions & 0 deletions navigator-html-injectables/src/helpers/color.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,4 +202,67 @@ export const isLightColor = (color: string, blendedWith: string | null = null):

export const getContrastingTextColor = (color: string, blendedWith: string | null = null): "black" | "white" => {
return isDarkColor(color, blendedWith) ? "white" : "black";
};

const rgbaToCss = (rgba: { r: number; g: number; b: number; a?: number }): string => {
const a = rgba.a !== undefined ? rgba.a : 1;
return `rgba(${Math.round(rgba.r)}, ${Math.round(rgba.g)}, ${Math.round(rgba.b)}, ${a})`;
};

const lightenColor = (rgba: { r: number; g: number; b: number; a?: number }, factor: number): { r: number; g: number; b: number; a: number } => {
return {
r: Math.min(255, rgba.r + (255 - rgba.r) * factor),
g: Math.min(255, rgba.g + (255 - rgba.g) * factor),
b: Math.min(255, rgba.b + (255 - rgba.b) * factor),
a: rgba.a ?? 1
};
};

const darkenColor = (rgba: { r: number; g: number; b: number; a?: number }, factor: number): { r: number; g: number; b: number; a: number } => {
return {
r: Math.max(0, rgba.r * (1 - factor)),
g: Math.max(0, rgba.g * (1 - factor)),
b: Math.max(0, rgba.b * (1 - factor)),
a: rgba.a ?? 1
};
};

export const adjustColorForContrast = (
baseColor: string,
backgroundColor: string | null = null,
targetContrast: number = 3
): string => {
const baseRgba = colorToRgba(baseColor);
const bgRgba = backgroundColor ? colorToRgba(backgroundColor) : { r: 255, g: 255, b: 255, a: 1 };

let currentContrast = checkContrast(baseRgba, bgRgba);

// If already meets target, return as-is
if (currentContrast >= targetContrast) {
return baseColor;
}

const bgLuminance = getLuminance(bgRgba);
const isBgDark = bgLuminance < 0.5;

let adjustedRgba = { ...baseRgba, a: baseRgba.a ?? 1 };
const maxIterations = 20;
const step = 0.1;

for (let i = 0; i < maxIterations; i++) {
if (isBgDark) {
// Dark background: lighten the base color
adjustedRgba = lightenColor(adjustedRgba, step);
} else {
// Light background: darken the base color
adjustedRgba = darkenColor(adjustedRgba, step);
}

currentContrast = checkContrast(adjustedRgba, bgRgba);
if (currentContrast >= targetContrast) {
break;
}
}

return rgbaToCss(adjustedRgba);
};
29 changes: 29 additions & 0 deletions navigator-html-injectables/src/helpers/css.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
import { ReadiumWindow } from "./dom.ts";

const COSMETIC_PROPERTIES = new Set([
"backgroundColor", "textColor",
"linkColor", "visitedColor",
"primaryColor", "secondaryColor",
"selectionBackgroundColor", "selectionTextColor",
"blendFilter", "darkenFilter", "invertFilter", "invertGaiji",
]);

const CSS_PROP_RE = /--(?:USER|RS)__([\w-]+)/g;

/**
* Returns true if the style attribute change between oldValue and newValue
* includes at least one ReadiumCSS property that affects document layout/geometry.
* Pure appearance changes (colors, filters) return false.
*/
export function styleChangeAffectsLayout(oldValue: string | null, newValue: string | null): boolean {
const old_ = oldValue ?? "";
const new_ = newValue ?? "";
const seen = new Set<string>();

for (const match of old_.matchAll(CSS_PROP_RE)) seen.add(match[1]);
for (const match of new_.matchAll(CSS_PROP_RE)) seen.add(match[1]);

for (const suffix of seen) {
if (!COSMETIC_PROPERTIES.has(suffix)) return true;
}
return false;
}

export function getProperties(wnd: ReadiumWindow) {
const cssProperties: { [key: string]: string } = {};

Expand Down
120 changes: 120 additions & 0 deletions navigator-html-injectables/src/helpers/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,126 @@ export function isVerticalLR(wnd: ReadiumWindow): boolean {
return writingMode === 'vertical-lr';
}

export function isVerticalWriting(wnd: ReadiumWindow): boolean {
const writingMode = wnd.getComputedStyle(wnd.document.documentElement).writingMode
|| wnd.getComputedStyle(wnd.document.body).writingMode;
return writingMode === 'vertical-rl' || writingMode === 'vertical-lr';
}

/**
* Axis-normalizing context for decoration layout.
*
* Translates CSS physical properties into logical inline/block terms so that
* callers can position elements identically regardless of writing mode:
*
* horizontal writing → inline = horizontal, block = vertical
* vertical writing → inline = vertical, block = horizontal
*/
export interface WritingContext {
isVertical: boolean;
isVertLR: boolean;
/** Viewport size along the inline axis (innerWidth / innerHeight). */
viewportInlineSize: number;
/** Viewport size along the block axis (innerHeight / innerWidth). */
viewportBlockSize: number;
/**
* Size of a single page along the inline axis.
* Horizontal: viewportWidth / columnCount.
* Vertical: viewportHeight (no columns).
*/
pageInlineSize: number;
/**
* Physical x offset to add to a client-space x coordinate to get document space.
* Horizontal: scrollLeft (non-negative).
* Vertical-rl: scrollWidth − viewportWidth + scrollLeft (scrollLeft is negative,
* so this normalises it to the viewport's left edge in document coordinates).
* Vertical-lr: scrollLeft (non-negative, same as horizontal).
*/
xDocOffset: number;
/** Physical y offset to add to a client-space y coordinate to get document space (scrollTop). */
yDocOffset: number;
/** Scroll offset along the inline axis, derived from xDocOffset / yDocOffset. */
inlineScrollOffset: number;
/** Scroll offset along the block axis, derived from xDocOffset / yDocOffset. */
blockScrollOffset: number;

/** Logical inline start of a rect (left / top). */
inlineStart(r: DOMRect | { top: number; left: number }): number;
/** Logical block start of a rect (top / left). */
blockStart(r: DOMRect | { top: number; left: number }): number;
/** Logical inline size of a rect (width / height). */
inlineSize(r: DOMRect | { width: number; height: number }): number;
/** Logical block size of a rect (height / width). */
blockSize(r: DOMRect | { width: number; height: number }): number;

/**
* Set absolute position on an element using logical coordinates.
* `iz` is the inverse zoom factor (1 in non-Blink, 1/zoom in Blink).
*/
applyPosition(el: HTMLElement, inlineStart: number, blockStart: number, inlineSize: number, blockSize: number, iz: number): void;

/** Convert logical coordinates back to a physical DOMRect. */
toRect(inlineStart: number, blockStart: number, inlineSize: number, blockSize: number): DOMRect;
}

export function makeWritingContext(wnd: ReadiumWindow): WritingContext {
const isVert = isVerticalWriting(wnd);
const isVLR = isVert && isVerticalLR(wnd);
const vw = wnd.innerWidth;
const vh = wnd.innerHeight;
const se = wnd.document.scrollingElement!;
const xOff = se.scrollLeft; // negative for vertical-rl, non-negative otherwise
const yOff = se.scrollTop;
const cols = parseInt(wnd.getComputedStyle(wnd.document.documentElement).getPropertyValue("column-count"));

// In vertical-rl, scrollLeft starts at 0 (document start = right side) and goes
// negative as the reader scrolls left. The viewport's left edge in document space
// is therefore (scrollWidth − viewportWidth + scrollLeft). For vertical-lr and
// horizontal modes, scrollLeft is non-negative and is the offset directly.
const xDocOffset = (isVert && !isVLR)
? se.scrollWidth - vw + xOff
: xOff;
const yDocOffset = yOff;

return {
isVertical: isVert,
isVertLR: isVLR,
viewportInlineSize: isVert ? vh : vw,
viewportBlockSize: isVert ? vw : vh,
pageInlineSize: isVert ? vh : vw / (cols || 1),
xDocOffset,
yDocOffset,
inlineScrollOffset: isVert ? yDocOffset : xDocOffset,
blockScrollOffset: isVert ? xDocOffset : yDocOffset,

inlineStart: (r) => isVert ? r.top : r.left,
blockStart: (r) => isVert ? r.left : r.top,
inlineSize: (r) => isVert ? r.height : r.width,
blockSize: (r) => isVert ? r.width : r.height,

applyPosition(el, inlineStart, blockStart, inlineSize, blockSize, iz) {
el.style.position = "absolute";
if (isVert) {
el.style.top = `${inlineStart * iz}px`;
el.style.left = `${blockStart * iz}px`;
el.style.height = `${inlineSize * iz}px`;
el.style.width = `${blockSize * iz}px`;
} else {
el.style.left = `${inlineStart * iz}px`;
el.style.top = `${blockStart * iz}px`;
el.style.width = `${inlineSize * iz}px`;
el.style.height = `${blockSize * iz}px`;
}
},

toRect(inlineStart, blockStart, inlineSize, blockSize) {
return isVert
? new DOMRect(blockStart, inlineStart, blockSize, inlineSize)
: new DOMRect(inlineStart, blockStart, inlineSize, blockSize);
},
};
}

export function getColumnCountPerScreen(wnd: ReadiumWindow): number {
return parseInt(
wnd.getComputedStyle(
Expand Down
15 changes: 10 additions & 5 deletions navigator-html-injectables/src/helpers/rect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ export interface Rect {

export function getClientRectsNoOverlap(
range: Range,
doNotMergeHorizontallyAlignedRects: boolean
doNotMergeHorizontallyAlignedRects: boolean,
doNotMergeVerticallyAlignedRects: boolean = false
) {
let clientRects = range.getClientRects();

Expand All @@ -36,7 +37,8 @@ export function getClientRectsNoOverlap(
const mergedRects = mergeTouchingRects(
originalRects,
tolerance,
doNotMergeHorizontallyAlignedRects
doNotMergeHorizontallyAlignedRects,
doNotMergeVerticallyAlignedRects
);
const noContainedRects = removeContainedRects(mergedRects, tolerance);
const newRects = replaceOverlapingRects(noContainedRects);
Expand All @@ -61,7 +63,8 @@ export function getClientRectsNoOverlap(
function mergeTouchingRects(
rects: Rect[],
tolerance: number,
doNotMergeHorizontallyAlignedRects: boolean
doNotMergeHorizontallyAlignedRects: boolean,
doNotMergeVerticallyAlignedRects: boolean = false
): Rect[] {
for (let i = 0; i < rects.length; i++) {
for (let j = i + 1; j < rects.length; j++) {
Expand All @@ -78,8 +81,9 @@ function mergeTouchingRects(
almostEqual(rect1.left, rect2.left, tolerance) &&
almostEqual(rect1.right, rect2.right, tolerance);
const horizontalAllowed = !doNotMergeHorizontallyAlignedRects;
const verticalAllowed = !doNotMergeVerticallyAlignedRects;
const aligned =
(rectsLineUpHorizontally && horizontalAllowed) ||
(rectsLineUpHorizontally && horizontalAllowed && verticalAllowed) ||
(rectsLineUpVertically && !rectsLineUpHorizontally);
const canMerge = aligned && rectsTouchOrOverlap(rect1, rect2, tolerance);
if (canMerge) {
Expand All @@ -94,7 +98,8 @@ function mergeTouchingRects(
return mergeTouchingRects(
newRects,
tolerance,
doNotMergeHorizontallyAlignedRects
doNotMergeHorizontallyAlignedRects,
doNotMergeVerticallyAlignedRects
);
}
}
Expand Down
Loading
Loading