diff --git a/package-lock.json b/package-lock.json index 64f8bcbc..b8ad7346 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "applesauce-react": "^4.0.0", "applesauce-relay": "^4.0.0", "date-fns": "^4.1.0", + "fast-average-color": "^9.5.0", "nostr-tools": "^2.4.0", "prismjs": "^1.30.0", "react": "^18.2.0", @@ -6086,6 +6087,15 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fast-average-color": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/fast-average-color/-/fast-average-color-9.5.0.tgz", + "integrity": "sha512-nC6x2YIlJ9xxgkMFMd1BNoM1ctMjNoRKfRliPmiEWW3S6rLTHiQcy9g3pt/xiKv/D0NAAkhb9VyV+WJFvTqMGg==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", diff --git a/package.json b/package.json index 524c74bd..1b390f08 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "applesauce-react": "^4.0.0", "applesauce-relay": "^4.0.0", "date-fns": "^4.1.0", + "fast-average-color": "^9.5.0", "nostr-tools": "^2.4.0", "prismjs": "^1.30.0", "react": "^18.2.0", diff --git a/src/components/ReaderHeader.tsx b/src/components/ReaderHeader.tsx index c98aad09..618c5bfb 100644 --- a/src/components/ReaderHeader.tsx +++ b/src/components/ReaderHeader.tsx @@ -3,6 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faHighlighter, faClock, faNewspaper } from '@fortawesome/free-solid-svg-icons' import { format } from 'date-fns' import { useImageCache } from '../hooks/useImageCache' +import { useAdaptiveTextColor } from '../hooks/useAdaptiveTextColor' import { UserSettings } from '../services/settingsService' import { Highlight, HighlightLevel } from '../types/highlights' import { HighlightVisibility } from './HighlightsPanel' @@ -34,6 +35,7 @@ const ReaderHeader: React.FC = ({ highlightVisibility = { nostrverse: true, friends: true, mine: true } }) => { const cachedImage = useImageCache(image) + const { textColor, shadowColor } = useAdaptiveTextColor(cachedImage) const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null const isLongSummary = summary && summary.length > 150 @@ -83,7 +85,13 @@ const ReaderHeader: React.FC = ({ )} {formattedDate && ( -
+
{formattedDate}
)} @@ -125,7 +133,13 @@ const ReaderHeader: React.FC = ({ {title && (
{formattedDate && ( -
+
{formattedDate}
)} diff --git a/src/hooks/useAdaptiveTextColor.ts b/src/hooks/useAdaptiveTextColor.ts new file mode 100644 index 00000000..2fb8fd46 --- /dev/null +++ b/src/hooks/useAdaptiveTextColor.ts @@ -0,0 +1,88 @@ +import { useEffect, useState } from 'react' +import FastAverageColor from 'fast-average-color' + +interface AdaptiveTextColor { + textColor: string + shadowColor: string +} + +/** + * Hook to determine optimal text and shadow colors based on image background + * Samples the top-right corner of the image to ensure publication date is readable + * + * @param imageUrl - The URL of the image to analyze + * @returns Object containing textColor and shadowColor for optimal contrast + */ +export function useAdaptiveTextColor(imageUrl: string | undefined): AdaptiveTextColor { + const [colors, setColors] = useState({ + textColor: '#ffffff', + shadowColor: 'rgba(0, 0, 0, 0.5)' + }) + + useEffect(() => { + if (!imageUrl) { + // No image, use default white text + setColors({ + textColor: '#ffffff', + shadowColor: 'rgba(0, 0, 0, 0.5)' + }) + return + } + + const fac = new FastAverageColor() + const img = new Image() + img.crossOrigin = 'anonymous' + + img.onload = async () => { + try { + const width = img.naturalWidth + const height = img.naturalHeight + + // Sample top-right corner (last 25% width, first 25% height) + const color = await fac.getColor(img, { + left: Math.floor(width * 0.75), + top: 0, + width: Math.floor(width * 0.25), + height: Math.floor(height * 0.25) + }) + + // Use library's built-in isLight check for optimal contrast + if (color.isLight) { + setColors({ + textColor: '#000000', + shadowColor: 'rgba(255, 255, 255, 0.5)' + }) + } else { + setColors({ + textColor: '#ffffff', + shadowColor: 'rgba(0, 0, 0, 0.5)' + }) + } + } catch (error) { + // Fallback to default on error + console.error('Error analyzing image color:', error) + setColors({ + textColor: '#ffffff', + shadowColor: 'rgba(0, 0, 0, 0.5)' + }) + } + } + + img.onerror = () => { + // Fallback to default if image fails to load + setColors({ + textColor: '#ffffff', + shadowColor: 'rgba(0, 0, 0, 0.5)' + }) + } + + img.src = imageUrl + + return () => { + fac.destroy() + } + }, [imageUrl]) + + return colors +} +