Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
144 changes: 58 additions & 86 deletions navigator-html-injectables/src/helpers/color.ts
Original file line number Diff line number Diff line change
@@ -1,114 +1,86 @@
export const colorToRgba = (color: string): { r: number; g: number; b: number; a: number; } => {
// Handle colors by using Canvas API's color conversion
if (!color.startsWith("#") && !color.startsWith("rgb")) {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.fillStyle = color;
const computedColor = ctx.fillStyle;
color = computedColor;
}
}

if (color.startsWith("rgb")) {
const rgb = color.match(/rgb\((\d+),\s(\d+),\s(\d+)(?:,\s(\d+))?\)/);
const rgb = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/i);
if (rgb) {
return {
r: parseInt(rgb[1], 10),
g: parseInt(rgb[2], 10),
b: parseInt(rgb[3], 10),
a: rgb[4] ? parseInt(rgb[4], 10) / 255 : 1,
r: parseInt(rgb[1], 10), // 0-255
g: parseInt(rgb[2], 10), // 0-255
b: parseInt(rgb[3], 10), // 0-255
a: rgb[4] ? parseFloat(rgb[4]) : 1, // 0-1
};
}
} else if (color.startsWith("#")) {
const hex = color.slice(1);
if (hex.length === 3 || hex.length === 4) {
return {
r: parseInt(hex[0] + hex[0], 16) / 255,
g: parseInt(hex[1] + hex[1], 16) / 255,
b: parseInt(hex[2] + hex[2], 16) / 255,
a: hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1,
r: parseInt(hex[0] + hex[0], 16), // 0-255
g: parseInt(hex[1] + hex[1], 16), // 0-255
b: parseInt(hex[2] + hex[2], 16), // 0-255
a: hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1, // 0-1
};
} else if (hex.length === 6 || hex.length === 8) {
return {
r: parseInt(hex[0] + hex[1], 16) / 255,
g: parseInt(hex[2] + hex[3], 16) / 255,
b: parseInt(hex[4] + hex[5], 16) / 255,
a: hex.length === 8 ? parseInt(hex[6] + hex[7], 16) / 255 : 1,
r: parseInt(hex[0] + hex[1], 16), // 0-255
g: parseInt(hex[2] + hex[3], 16), // 0-255
b: parseInt(hex[4] + hex[5], 16), // 0-255
a: hex.length === 8 ? parseInt(hex[6] + hex[7], 16) / 255 : 1, // 0-1
};
}
}
return { r: 0, g: 0, b: 0, a: 1 };
return { r: 255, g: 255, b: 255, a: 1 }; // Default to white (255, 255, 255, 1)
};

export const getLuminance = (color: { r: number; g: number; b: number; a: number }): number => {
return 0.2126 * color.r * color.a + 0.7152 * color.g * color.a + 0.0722 * color.b * color.a;
}

export const isDarkColor = (color: string): boolean => {
const rgba = colorToRgba(color);
const luminance = getLuminance(rgba);
return luminance < 128;
const toLinear = (c: number): number => {
const normalized = c / 255;
return normalized <= 0.03928
? normalized / 12.92
: Math.pow((normalized + 0.055) / 1.055, 2.4);
};

export const isLightColor = (color: string): boolean => !isDarkColor(color);
export const getLuminance = (color: { r: number; g: number; b: number; a?: number }): number => {
// Convert sRGB to linear RGB and apply WCAG 2.0 formula
const r = toLinear(color.r);
const g = toLinear(color.g);
const b = toLinear(color.b);

export const checkContrast = (color1: string, color2: string): number => {
const rgba1 = colorToRgba(color1);
const rgba2 = colorToRgba(color2);
const lum1 = getLuminance(rgba1);
const lum2 = getLuminance(rgba2);
const brightest = Math.max(lum1, lum2);
const darkest = Math.min(lum1, lum2);
return (brightest + 0.05) / (darkest + 0.05);
// WCAG 2.0 relative luminance formula (returns 0-1)
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;

// Apply alpha if provided (0-1 range)
return color.a !== undefined ? luminance * color.a : luminance;
};

export const ensureContrast = (color1: string, color2: string, contrast: number = 4.5): string[] => {
const c1 = colorToRgba(color1);
const c2 = colorToRgba(color2);

const lum1 = getLuminance(c1);
const lum2 = getLuminance(c2);
const [darkest, brightest] = lum1 < lum2 ? [lum1, lum2] : [lum2, lum1];
export const checkContrast = (color1: string, color2: string): number => {
const luminance1 = getLuminance(colorToRgba(color1));
const luminance2 = getLuminance(colorToRgba(color2));

const contrastRatio = (brightest + 0.05) / (darkest + 0.05);
if (contrastRatio >= contrast) {
return [
`rgba(${c1.r}, ${c1.g}, ${c1.b}, ${c1.a})`,
`rgba(${c2.r}, ${c2.g}, ${c2.b}, ${c2.a})`
];
}
// Ensure luminance1 is the lighter color
const l1 = Math.max(luminance1, luminance2);
const l2 = Math.min(luminance1, luminance2);

const adjustColor = (color: { r: number; g: number; b: number; a: number }, delta: number) => ({
r: Math.max(0, Math.min(255, color.r + delta)),
g: Math.max(0, Math.min(255, color.g + delta)),
b: Math.max(0, Math.min(255, color.b + delta)),
a: color.a
});

const delta = ((contrast - contrastRatio) * 255) / (contrastRatio + 0.05);
let correctedColor: { r: number; g: number; b: number; a: number };
let otherColor: { r: number; g: number; b: number; a: number };
if (lum1 < lum2) {
correctedColor = c1;
otherColor = c2;
} else {
correctedColor = c2;
otherColor = c1;
}
// WCAG 2.0 contrast ratio formula
return (l1 + 0.05) / (l2 + 0.05);
};

const correctedColorAdjusted = adjustColor(correctedColor, -delta);
const newLum = getLuminance(correctedColorAdjusted);
const newContrastRatio = (brightest + 0.05) / (newLum + 0.05);
export const isDarkColor = (color: string): boolean => {
const contrastWithWhite = checkContrast(color, "#FFFFFF");
const contrastWithBlack = checkContrast(color, "#000000");
return contrastWithWhite > contrastWithBlack;
};

if (newContrastRatio < contrast) {
const updatedDelta = ((contrast - newContrastRatio) * 255) / (newContrastRatio + 0.05);
const otherColorAdjusted = adjustColor(otherColor, updatedDelta);
return [
lum1 < lum2
? `rgba(${correctedColorAdjusted.r}, ${correctedColorAdjusted.g}, ${correctedColorAdjusted.b}, ${correctedColorAdjusted.a})`
: `rgba(${otherColorAdjusted.r}, ${otherColorAdjusted.g}, ${otherColorAdjusted.b}, ${otherColorAdjusted.a})`,
lum1 < lum2
? `rgba(${otherColorAdjusted.r}, ${otherColorAdjusted.g}, ${otherColorAdjusted.b}, ${otherColorAdjusted.a})`
: `rgba(${correctedColorAdjusted.r}, ${correctedColorAdjusted.g}, ${correctedColorAdjusted.b}, ${correctedColorAdjusted.a})`,
];
}
export const isLightColor = (color: string): boolean => !isDarkColor(color);

return [
lum1 < lum2
? `rgba(${correctedColorAdjusted.r}, ${correctedColorAdjusted.g}, ${correctedColorAdjusted.b}, ${correctedColorAdjusted.a})`
: `rgba(${otherColor.r}, ${otherColor.g}, ${otherColor.b}, ${otherColor.a})`,
lum1 < lum2
? `rgba(${otherColor.r}, ${otherColor.g}, ${otherColor.b}, ${otherColor.a})`
: `rgba(${correctedColorAdjusted.r}, ${correctedColorAdjusted.g}, ${correctedColorAdjusted.b}, ${correctedColorAdjusted.a})`,
];
};
export const getContrastingTextColor = (backgroundColor: string): "black" | "white" => {
return isDarkColor(backgroundColor) ? "white" : "black";
};
72 changes: 65 additions & 7 deletions navigator-html-injectables/src/modules/Decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import { ModuleName } from "./ModuleLibrary";
import { Rect, getClientRectsNoOverlap } from "../helpers/rect";
import { getProperty } from "../helpers/css";
import { ReadiumWindow } from "../helpers/dom";
import { isDarkColor } from "../helpers/color";
import { isDarkColor, getContrastingTextColor } from "../helpers/color";

const DEFAULT_HIGHLIGHT_COLOR = "#FFFF00"; // Yellow in HEX

export enum Width {
Wrap = "wrap", // Smallest width fitting the CSS border box.
Expand Down Expand Up @@ -183,8 +185,8 @@ class DecorationGroup {
// TODO add caching layer ("vdom") to this so we aren't completely replacing the CSS every time
stylesheet.innerHTML = `
::highlight(${this.id}) {
color: black;
background-color: ${item.decoration?.style?.tint ?? "yellow"};
color: ${getContrastingTextColor(item.decoration?.style?.tint ?? DEFAULT_HIGHLIGHT_COLOR)};
background-color: ${item.decoration?.style?.tint ?? DEFAULT_HIGHLIGHT_COLOR};
}`;
}

Expand Down Expand Up @@ -251,14 +253,14 @@ class DecorationGroup {
// template.innerHTML = item.decoration.element.trim();
// TODO more styles logic

const isDarkMode = getProperty(this.wnd, "--USER__appearance") === "readium-night-on" ||
isDarkColor(getProperty(this.wnd, "--USER__backgroundColor"));
const isDarkMode = this.getCurrentDarkMode();

template.innerHTML = `
<div
class="r2-highlight-0"
data-readium="true"
class="readium-highlight"
style="${[
`background-color: ${item.decoration?.style?.tint ?? "yellow"} !important`,
`background-color: ${item.decoration?.style?.tint ?? DEFAULT_HIGHLIGHT_COLOR} !important`,
//"opacity: 0.3 !important",
`mix-blend-mode: ${isDarkMode ? "exclusion" : "multiply"} !important`,
"opacity: 1 !important",
Expand Down Expand Up @@ -359,6 +361,12 @@ class DecorationGroup {
return this.container;
}

getCurrentDarkMode(): boolean {
return getProperty(this.wnd, "--USER__appearance") === "readium-night-on" ||
isDarkColor(getProperty(this.wnd, "--USER__backgroundColor")) ||
isDarkColor(this.wnd.getComputedStyle(this.wnd.document.documentElement).getPropertyValue("background-color"));
}

/**
* Removes the group container.
*/
Expand All @@ -376,6 +384,7 @@ class DecorationGroup {
export class Decorator extends Module {
static readonly moduleName: ModuleName = "decorator";
private resizeObserver!: ResizeObserver;
private backgroundObserver!: MutationObserver;
private wnd!: ReadiumWindow;
/*private readonly lastSize = {
width: 0,
Expand All @@ -392,6 +401,22 @@ export class Decorator extends Module {
this.groups.clear();
}

private updateAllBlendModes() {
const highlights = this.wnd.document.querySelectorAll(".readium-highlight");
const isDarkMode = this.groups.values().next().value?.getCurrentDarkMode() ?? false;

highlights.forEach(highlight => {
(highlight as HTMLElement).style.setProperty("mix-blend-mode", isDarkMode ? "exclusion" : "multiply", "important");
});
}

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(() => {
Expand Down Expand Up @@ -442,6 +467,38 @@ export class Decorator extends Module {
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.updateAllBlendModes();
}
});

this.backgroundObserver.observe(wnd.document.documentElement, {
attributes: true,
attributeFilter: ["style"],
attributeOldValue: true,
subtree: true
});

comms.log("Decorator Mounted");
return true;
}
Expand All @@ -452,6 +509,7 @@ export class Decorator extends Module {

comms.unregisterAll(Decorator.moduleName);
this.resizeObserver.disconnect();
this.backgroundObserver.disconnect();
this.cleanup();

comms.log("Decorator Unmounted");
Expand Down