diff --git a/navigator-html-injectables/src/helpers/color.ts b/navigator-html-injectables/src/helpers/color.ts index 37af7186..d7a01363 100644 --- a/navigator-html-injectables/src/helpers/color.ts +++ b/navigator-html-injectables/src/helpers/color.ts @@ -1,114 +1,205 @@ -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, - }; - } - } 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, - }; - } 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, - }; +// Lazy canvas/offscreen canvas initialization for color conversion +let canvas: HTMLCanvasElement | OffscreenCanvas | null = null; +let ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null = null; + +// Track which pixel to use next for color sampling (0-24 for 5x5 grid) +let currentPixelIndex = 0; + +// 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 = (): 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");