diff --git a/navigator-html-injectables/src/helpers/color.ts b/navigator-html-injectables/src/helpers/color.ts index 37af7186..dbaa1d6a 100644 --- a/navigator-html-injectables/src/helpers/color.ts +++ b/navigator-html-injectables/src/helpers/color.ts @@ -1,114 +1,150 @@ +// Lazy canvas initialization +let canvas: HTMLCanvasElement | null = null; +let ctx: CanvasRenderingContext2D | null = null; + +// Default color for failed conversions +const DEFAULT_COLOR = { r: 255, g: 255, b: 255, a: 1 }; + +// Cache for computed color conversions +const colorCache = new Map(); + +const getCanvasContext = () => { + if (!canvas) { + canvas = document.createElement("canvas"); + ctx = canvas.getContext("2d"); + } + return ctx; +}; + export const colorToRgba = (color: string): { r: number; g: number; b: number; a: number; } => { - if (color.startsWith("rgb")) { - const rgb = color.match(/rgb\((\d+),\s(\d+),\s(\d+)(?:,\s(\d+))?\)/); - 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, + // Check cache first + const cached = colorCache.get(color); + if (cached !== undefined) { + if (cached === null) { + return DEFAULT_COLOR; // Return default white for previously failed colors + } + return cached; + } + + // Use Canvas API to convert any CSS color to “standardized” format + const context = getCanvasContext(); + let computedColor = color; + + if (context) { + context.fillStyle = color; + computedColor = context.fillStyle; + } + + // Parse the computed color value from canvas + if (computedColor.startsWith("rgb")) { + // Regex that handles both comma and space separators, slash for alpha + const rgba = computedColor.match(/rgba?\(([\d.]+%?)[,\s]+([\d.]+%?)[,\s]+([\d.]+%?)(?:[\/,]\s*([\d.]+%?))?\)/); + + if (rgba) { + const parseValue = (val: string): number => { + if (val.endsWith("%")) { + return Math.round(parseFloat(val) * 2.55); // Convert percentage to 0-255 + } + return parseFloat(val); + }; + + const parseAlpha = (val: string): number => { + if (val.endsWith("%")) { + return parseFloat(val) / 100; // Convert percentage to 0-1 + } + return parseFloat(val); }; + + const result = { + r: parseValue(rgba[1]), // 0-255 + g: parseValue(rgba[2]), // 0-255 + b: parseValue(rgba[3]), // 0-255 + a: rgba[4] ? parseAlpha(rgba[4]) : 1, // 0-1 + }; + + // Cache the result for future use + colorCache.set(color, result); + return result; } - } else if (color.startsWith("#")) { - const hex = color.slice(1); + } else if (computedColor.startsWith("#")) { + const hex = computedColor.slice(1); + let result; + 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, + result = { + 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, + result = { + 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 }; + } else { + // Invalid hex length, cache null and return default + colorCache.set(color, null); + return DEFAULT_COLOR; } + + // Cache the result for future use + colorCache.set(color, result); + return result; } - return { r: 0, g: 0, b: 0, a: 1 }; + + // If we couldn't parse the color, warn and return default + // Decorator-specific ATM + console.warn(`Decorator: could not parse color format: ${color}. Falling back to ${DEFAULT_COLOR} to check contrast. Please make sure your color value can be computed to HEX or RGB(A) format.`); + + // Cache null to avoid repeated warnings + entire conversion process + colorCache.set(color, null); + return DEFAULT_COLOR; }; -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.2 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.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 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 checkContrast = (color1: string, color2: string): number => { + const luminance1 = getLuminance(colorToRgba(color1)); + const luminance2 = getLuminance(colorToRgba(color2)); - 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; - } + // Ensure luminance1 is the lighter color + const l1 = Math.max(luminance1, luminance2); + const l2 = Math.min(luminance1, luminance2); - 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})`, - ]; - } + // WCAG 2.2 contrast ratio formula + return (l1 + 0.05) / (l2 + 0.05); +}; - 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 isDarkColor = (color: string): boolean => { + const contrastWithWhite = checkContrast(color, "#FFFFFF"); + const contrastWithBlack = checkContrast(color, "#000000"); + return contrastWithWhite > contrastWithBlack; }; + +export const isLightColor = (color: string): boolean => !isDarkColor(color); + +export const getContrastingTextColor = (backgroundColor: string): "black" | "white" => { + return isDarkColor(backgroundColor) ? "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..bac75332 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. @@ -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}; }`; } @@ -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 = `
{ + (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(() => { @@ -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; } @@ -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");