();
+
+const getCanvasContext = (): CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null => {
+ if (!canvas) {
+ // Try to use OffscreenCanvas if available
+ if (typeof OffscreenCanvas !== "undefined") {
+ canvas = new OffscreenCanvas(5, 5);
+ ctx = canvas.getContext("2d", {
+ willReadFrequently: true,
+ desynchronized: true
+ });
+ } else {
+ // Fall back to regular canvas
+ const htmlCanvas = document.createElement("canvas");
+ htmlCanvas.width = 5;
+ htmlCanvas.height = 5;
+ canvas = htmlCanvas;
+ ctx = htmlCanvas.getContext("2d", {
+ willReadFrequently: true,
+ desynchronized: true
+ });
}
}
- return { r: 0, g: 0, b: 0, a: 1 };
+ return ctx;
};
-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 isSpecialColorValue = (color: string): boolean => {
+ if (!color) return true;
+
+ const normalizedColor = color.trim().toLowerCase();
+
+ // Check for CSS variables
+ if (normalizedColor.startsWith("var(")) {
+ return true;
+ }
+
+ // Check for CSS color keywords
+ const cssKeywords = [
+ "transparent",
+ "currentcolor",
+ "inherit",
+ "initial",
+ "revert",
+ "unset",
+ "revert-layer"
+ ];
+
+ if (cssKeywords.includes(normalizedColor)) {
+ return true;
+ }
+
+ // Check for gradients
+ const gradientTypes = [
+ "linear-gradient",
+ "radial-gradient",
+ "conic-gradient",
+ "repeating-linear-gradient",
+ "repeating-radial-gradient",
+ "repeating-conic-gradient"
+ ];
+
+ return gradientTypes.some(grad => normalizedColor.includes(grad));
};
-export const isLightColor = (color: string): boolean => !isDarkColor(color);
-
-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);
+const warnAboutInvalidColor = (color: string, reason: string): void => {
+ console.warn(
+ `[Decorator] Could not parse color: "${color}". ${reason} Falling back to ${JSON.stringify(DEFAULT_COLOR)} to compute contrast. Please use a CSS color value that can be computed to RGB(A).`
+ );
};
-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];
-
- 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})`
- ];
+export const colorToRgba = (
+ color: string,
+ backgroundColor: string | null = null
+): { r: number; g: number; b: number; a: number } => {
+ // Check cache with background key if provided
+ const cacheKey = backgroundColor ? `${color}|${backgroundColor}` : color;
+ const cached = colorCache.get(cacheKey);
+ if (cached !== undefined) {
+ return cached ?? DEFAULT_COLOR;
}
- 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;
+ // Check for special color values
+ if (isSpecialColorValue(color)) {
+ warnAboutInvalidColor(color, "Unsupported color format or special value.");
+ colorCache.set(cacheKey, null);
+ return DEFAULT_COLOR;
}
- const correctedColorAdjusted = adjustColor(correctedColor, -delta);
- const newLum = getLuminance(correctedColorAdjusted);
- const newContrastRatio = (brightest + 0.05) / (newLum + 0.05);
-
- 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})`,
- ];
+ const context = getCanvasContext();
+ if (!context) {
+ warnAboutInvalidColor(color, "Could not get canvas context.");
+ colorCache.set(cacheKey, null);
+ return DEFAULT_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})`,
- ];
+ try {
+ // Clear and initialize canvas at the start of each cycle
+ if (currentPixelIndex === 0) {
+ context.clearRect(0, 0, 5, 5);
+ }
+
+ // Calculate which pixel to use for this operation
+ const x = currentPixelIndex % 5;
+ const y = Math.floor(currentPixelIndex / 5);
+
+ // Clear just this pixel to ensure clean state
+ context.clearRect(x, y, 1, 1);
+
+ // Fill background color if provided
+ if (backgroundColor) {
+ context.fillStyle = backgroundColor;
+ context.fillRect(x, y, 1, 1);
+ }
+
+ // Draw the color at the current pixel
+ context.fillStyle = color;
+ context.fillRect(x, y, 1, 1);
+
+ // Get the pixel data for this specific pixel
+ const imageData = context.getImageData(x, y, 1, 1);
+
+ // Move to next pixel for next call
+ currentPixelIndex = (currentPixelIndex + 1) % 25;
+
+ // Get the pixel data for the pixel we just sampled
+ const [r, g, b, a] = imageData.data;
+
+ // If the color is completely transparent, return default
+ if (a === 0) {
+ warnAboutInvalidColor(color, "Fully transparent color.");
+ colorCache.set(cacheKey, null);
+ return DEFAULT_COLOR;
+ }
+
+ const result = { r, g, b, a: a / 255 };
+ colorCache.set(cacheKey, result);
+ return result;
+ } catch (error) {
+ warnAboutInvalidColor(color, `Error: ${error instanceof Error ? error.message : String(error)}`);
+ colorCache.set(cacheKey, null);
+ return DEFAULT_COLOR;
+ }
+};
+
+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 getLuminance = (color: { r: number; g: number; b: number; a?: number }): number => {
+ // Convert sRGB to linear RGB and apply WCAG 2.2 formula
+ const r = toLinear(color.r);
+ const g = toLinear(color.g);
+ const b = toLinear(color.b);
+
+ // WCAG 2.2 relative luminance formula (returns 0-1)
+ // Note: Alpha is ignored for contrast calculations. WCAG 2.2 only defines contrast for opaque colors,
+ // and semi-transparent colors have a range of possible contrast ratios depending on background.
+ // For text readability decisions, we use the base color as the most conservative approach.
+ const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
+ return luminance;
+};
+
+export const checkContrast = (
+ color1: string | { r: number; g: number; b: number; a?: number },
+ color2: string | { r: number; g: number; b: number; a?: number }
+): number => {
+ const rgba1 = typeof color1 === "string" ? colorToRgba(color1) : color1;
+ const rgba2 = typeof color2 === "string" ? colorToRgba(color2) : color2;
+
+ const l1 = getLuminance(rgba1);
+ const l2 = getLuminance(rgba2);
+
+ const lighter = Math.max(l1, l2);
+ const darker = Math.min(l1, l2);
+ return (lighter + 0.05) / (darker + 0.05);
};
+
+export const isDarkColor = (color: string, blendedWith: string | null = null): boolean => {
+ const blended = colorToRgba(color, blendedWith);
+ const contrastWithWhite = checkContrast(blended, { r: 255, g: 255, b: 255, a: 1 });
+ const contrastWithBlack = checkContrast(blended, { r: 0, g: 0, b: 0, a: 1 });
+ return contrastWithWhite > contrastWithBlack;
+};
+
+export const isLightColor = (color: string, blendedWith: string | null = null): boolean => {
+ return !isDarkColor(color, blendedWith);
+};
+
+export const getContrastingTextColor = (color: string, blendedWith: string | null = null): "black" | "white" => {
+ return isDarkColor(color, blendedWith) ? "white" : "black";
+};
\ No newline at end of file
diff --git a/navigator-html-injectables/src/modules/Decorator.ts b/navigator-html-injectables/src/modules/Decorator.ts
index f804aef9..0697a61d 100644
--- a/navigator-html-injectables/src/modules/Decorator.ts
+++ b/navigator-html-injectables/src/modules/Decorator.ts
@@ -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.
@@ -180,11 +182,15 @@ 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;
+
// 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(tint, backgroundColor)};
+ background-color: ${tint};
}`;
}
@@ -251,14 +257,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 = `
{
+ group.requestLayout();
+ });
+ }
+
+ 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(() => {
@@ -442,6 +468,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.updateHighlightStyles();
+ }
+ });
+
+ this.backgroundObserver.observe(wnd.document.documentElement, {
+ attributes: true,
+ attributeFilter: ["style"],
+ attributeOldValue: true,
+ subtree: true
+ });
+
comms.log("Decorator Mounted");
return true;
}
@@ -452,6 +510,7 @@ export class Decorator extends Module {
comms.unregisterAll(Decorator.moduleName);
this.resizeObserver.disconnect();
+ this.backgroundObserver.disconnect();
this.cleanup();
comms.log("Decorator Unmounted");