Files
eranos/src/components/ColorMomentContent.tsx
T

409 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState, useEffect, useId } from 'react';
import { cn } from '@/lib/utils';
import { useTheme } from '@/hooks/useTheme';
import { hexToHslString, hexToRgb, rgbToHsl, hslToRgb, getLuminance, getContrastRatio, parseHsl, formatHsl } from '@/lib/colorUtils';
import type { CoreThemeColors } from '@/themes';
import type { NostrEvent } from '@nostrify/nostrify';
type Layout = 'horizontal' | 'vertical' | 'grid' | 'star' | 'checkerboard' | 'diagonalStripes';
function getTag(tags: string[][], name: string): string | undefined {
return tags.find(([n]) => n === name)?.[1];
}
function getColors(tags: string[][]): string[] {
return tags
.filter(([n]) => n === 'c')
.map(([, v]) => v)
.filter((v) => /^#[0-9A-Fa-f]{6}$/.test(v));
}
/** Compute a best-fit grid: cols × rows for n items. */
function gridDimensions(n: number): { cols: number; rows: number } {
if (n <= 3) return { cols: n, rows: 1 };
if (n === 4) return { cols: 2, rows: 2 };
if (n === 5) return { cols: 3, rows: 2 };
return { cols: 3, rows: 2 };
}
/** Relative luminance of a hex color (0-1). */
function luminance(hex: string): number {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const toLinear = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
}
// ─── Layout renderers ──────────────────────────────────────
function HorizontalLayout({ colors }: { colors: string[] }) {
return (
<div className="flex flex-col w-full rounded-2xl overflow-hidden" style={{ height: 180 }}>
{colors.map((color, i) => (
<div key={i} className="flex-1" style={{ backgroundColor: color }} />
))}
</div>
);
}
function VerticalLayout({ colors }: { colors: string[] }) {
return (
<div className="flex w-full rounded-2xl overflow-hidden" style={{ height: 180 }}>
{colors.map((color, i) => (
<div key={i} className="flex-1" style={{ backgroundColor: color }} />
))}
</div>
);
}
function GridLayout({ colors }: { colors: string[] }) {
const { cols } = gridDimensions(colors.length);
return (
<div
className="grid w-full rounded-2xl overflow-hidden"
style={{
height: 180,
gridTemplateColumns: `repeat(${cols}, 1fr)`,
}}
>
{colors.map((color, i) => (
<div key={i} style={{ backgroundColor: color }} />
))}
</div>
);
}
/** Build a clip-path polygon for a pie slice with slight overlap to prevent gaps. */
function pieSliceClipPath(index: number, total: number): string {
const angle = 360 / total;
const overlap = 0.5;
const startAngle = index * angle - 90 - overlap;
const sweepAngle = angle + overlap * 2;
const scale = 1.5; // extend past edges so slices cover the full rect
const steps = 12;
const points: string[] = ['50% 50%'];
for (let i = 0; i <= steps; i++) {
const a = startAngle + (sweepAngle * i) / steps;
const rad = (a * Math.PI) / 180;
const x = 50 + 50 * scale * Math.cos(rad);
const y = 50 + 50 * scale * Math.sin(rad);
points.push(`${x}% ${y}%`);
}
return `polygon(${points.join(', ')})`;
}
function StarLayout({ colors }: { colors: string[] }) {
return (
<div className="relative w-full rounded-2xl overflow-hidden" style={{ height: 180 }}>
{/* Background fill to cover the center seam where all slices meet */}
<div className="absolute inset-0" style={{ backgroundColor: colors[0] }} />
{colors.map((color, i) => (
<div
key={i}
className="absolute inset-0"
style={{
backgroundColor: color,
clipPath: pieSliceClipPath(i, colors.length),
}}
/>
))}
</div>
);
}
function CheckerboardLayout({ colors }: { colors: string[] }) {
const n = colors.length;
const height = 180;
// Target ~6 rows so squares are large and legible
const rows = n * Math.max(2, 4 - n);
const cellSize = height / rows;
// Enough columns to cover any container width (overflow is clipped)
const cols = Math.ceil(600 / cellSize);
const cells: { color: string; key: string }[] = [];
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const colorIndex = (row + col) % n;
cells.push({ color: colors[colorIndex], key: `${row}-${col}` });
}
}
return (
<div
className="w-full rounded-2xl overflow-hidden"
style={{ height }}
>
<div
style={{
display: 'grid',
gridTemplateColumns: `repeat(${cols}, ${cellSize}px)`,
gridTemplateRows: `repeat(${rows}, ${cellSize}px)`,
}}
>
{cells.map(({ color, key }) => (
<div key={key} style={{ backgroundColor: color }} />
))}
</div>
</div>
);
}
function DiagonalStripesLayout({ colors }: { colors: string[] }) {
const n = colors.length;
const H = 180;
// Use a canvas wider than the visible area by H so that parallelogram stripes
// at a 45° diagonal fill the rectangle exactly. The viewBox is (W+H) × H but
// we clip it back to W × H via the wrapper div + overflow-hidden.
// Each stripe is a true parallelogram with vertical width W/n and a horizontal
// shift of H from top to bottom, giving a consistent 45° diagonal boundary.
const W = 400;
const totalW = W + H; // canvas wide enough so last stripe reaches bottom-right
const stripeW = totalW / n;
return (
<div className="w-full rounded-2xl overflow-hidden" style={{ height: H }}>
<svg
width="100%"
height="100%"
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
xmlns="http://www.w3.org/2000/svg"
shapeRendering="geometricPrecision"
>
{colors.map((color, i) => {
// Stripe i: parallelogram where the top edge is at x=[i*sw, (i+1)*sw]
// and the bottom edge shifts left by H (height), giving a 45° angle.
const tx0 = i * stripeW; // top-left x
const tx1 = (i + 1) * stripeW; // top-right x
const bx0 = i * stripeW - H; // bottom-left x (shifted left by H)
const bx1 = (i + 1) * stripeW - H; // bottom-right x
const points = [
`${tx0},0`,
`${tx1},0`,
`${bx1},${H}`,
`${bx0},${H}`,
].join(' ');
return <polygon key={i} points={points} fill={color} />;
})}
</svg>
</div>
);
}
// ─── Palette → theme mapping ─────────────────────────────
function hexLuminance(hex: string): number {
return getLuminance(...hexToRgb(hex));
}
function hexContrast(hex1: string, hex2: string): number {
return getContrastRatio(hexToRgb(hex1), hexToRgb(hex2));
}
function hexSaturation(hex: string): number {
return rgbToHsl(...hexToRgb(hex)).s;
}
/**
* Adjust the lightness of an HSL string until it achieves at least `targetRatio`
* contrast against `bgHsl`. Steps toward white or black depending on which
* direction gives better contrast. Returns the adjusted HSL string.
*/
function enforceContrast(hsl: string, bgHsl: string, targetRatio: number): string {
const bg = parseHsl(bgHsl);
const bgLum = getLuminance(...hslToRgb(bg.h, bg.s, bg.l));
const { h, s, l } = parseHsl(hsl);
// Decide direction: go lighter if bg is dark, darker if bg is light
const goLighter = bgLum < 0.18;
let current = l;
for (let i = 0; i < 50; i++) {
current = goLighter
? Math.min(100, current + 2)
: Math.max(0, current - 2);
const rgb = hslToRgb(h, s, current);
const lum = getLuminance(...rgb);
const lighter = Math.max(bgLum, lum);
const darker = Math.min(bgLum, lum);
if ((lighter + 0.05) / (darker + 0.05) >= targetRatio) break;
}
return formatHsl(h, s, current);
}
/**
* Map palette hex colors to CoreThemeColors with guaranteed readability:
* 1. background = darkest color
* 2. text = lightest color; if contrast < 4.5:1, synthesize white or black
* 3. primary = most saturated remaining color; if contrast < 3:1 against
* background, adjust its lightness until it passes
*/
function paletteToTheme(colors: string[]): CoreThemeColors {
if (colors.length === 0) {
return { background: '0 0% 10%', text: '0 0% 98%', primary: '258 70% 55%' };
}
const sorted = [...colors].sort((a, b) => hexLuminance(a) - hexLuminance(b));
const bgHex = sorted[0];
const bgHsl = hexToHslString(bgHex);
// Text: lightest palette color; override with white/black if contrast is too low
const textHex = sorted[sorted.length - 1];
let textHsl = hexToHslString(textHex);
if (hexContrast(textHex, bgHex) < 4.5) {
// Pick white or black — whichever contrasts better
const whiteContrast = hexContrast('#ffffff', bgHex);
const blackContrast = hexContrast('#000000', bgHex);
textHsl = whiteContrast >= blackContrast ? '0 0% 98%' : '222 20% 8%';
}
// Primary: most saturated of remaining colors; nudge lightness if needed
const rest = colors.filter((c) => c !== bgHex && c !== textHex);
const pool = rest.length > 0 ? rest : [textHex];
const primaryHex = pool.reduce((best, c) => hexSaturation(c) > hexSaturation(best) ? c : best, pool[0]);
let primaryHsl = hexToHslString(primaryHex);
if (hexContrast(primaryHex, bgHex) < 3) {
primaryHsl = enforceContrast(primaryHsl, bgHsl, 3);
}
return { background: bgHsl, text: textHsl, primary: primaryHsl };
}
// ─── Main component ──────────────────────────────────────
const LAYOUT_MAP: Record<Layout, React.FC<{ colors: string[] }>> = {
horizontal: HorizontalLayout,
vertical: VerticalLayout,
grid: GridLayout,
star: StarLayout,
checkerboard: CheckerboardLayout,
diagonalStripes: DiagonalStripesLayout,
};
/** Standalone blinking eye button for setting a color moment as the active theme. */
export function ColorMomentEyeButton({ event }: { event: NostrEvent }) {
const colors = useMemo(() => getColors(event.tags), [event.tags]);
const { applyCustomTheme } = useTheme();
const [isBlinking, setIsBlinking] = useState(false);
const uid = useId();
useEffect(() => {
if (isBlinking) {
const timer = setTimeout(() => setIsBlinking(false), 500);
return () => clearTimeout(timer);
}
}, [isBlinking]);
const handleSetTheme = (e: React.MouseEvent) => {
e.stopPropagation();
setIsBlinking(true);
applyCustomTheme(paletteToTheme(colors));
};
if (colors.length === 0) return null;
const outer = colors[0] ?? '#888';
const iris = colors[colors.length - 1] ?? '#888';
// Build the eye as a data-URI SVG so the browser renders it as an image
// (smooth anti-aliasing, no compositing layer on siblings)
const eyeSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="45" fill="${outer}"/>
<circle cx="50" cy="50" r="32" fill="white" opacity="0.9"/>
<circle cx="50" cy="50" r="20" fill="${iris}"/>
<circle cx="50" cy="50" r="10" fill="#1c1917"/>
<circle cx="43" cy="43" r="4" fill="white" opacity="0.8"/>
</svg>`;
const eyeUrl = `data:image/svg+xml,${encodeURIComponent(eyeSvg)}`;
return (
<button
onClick={handleSetTheme}
className="flex items-center gap-1.5 hover:opacity-80 active:scale-95 transition-all shrink-0"
title="Set as theme"
>
<span className="text-[11px] font-medium text-muted-foreground">Set as theme</span>
{/* Eye container — overflow-hidden clips the eyelid, doesn't touch siblings */}
<div className="relative size-9 rounded-full overflow-hidden" id={uid}>
<img src={eyeUrl} alt="" className="w-9 h-9" />
{isBlinking && (
<div className="absolute inset-0 animate-eyelid-blink">
<div className="absolute rounded-full bg-background w-full h-full bottom-0 left-0" />
</div>
)}
</div>
</button>
);
}
export function ColorMomentContent({ event }: { event: NostrEvent }) {
const colors = useMemo(() => getColors(event.tags), [event.tags]);
const layout = (getTag(event.tags, 'layout') ?? 'horizontal') as Layout;
const name = getTag(event.tags, 'name');
const emoji = event.content.trim() || undefined;
const LayoutComponent = LAYOUT_MAP[layout] ?? HorizontalLayout;
// Determine whether overlay text should be light or dark
const avgLum = useMemo(() => {
if (colors.length === 0) return 0.5;
return colors.reduce((sum, c) => sum + luminance(c), 0) / colors.length;
}, [colors]);
const overlayTextClass = avgLum > 0.5 ? 'text-black/80' : 'text-white/90';
if (colors.length === 0) return null;
return (
<div className="mt-2">
{/* Name above the palette */}
{name && (
<p className="text-[15px] font-medium mb-2">{name}</p>
)}
{/* Color palette with emoji overlay */}
<div className="relative isolate">
<LayoutComponent colors={colors} />
{/* Emoji centered over the palette */}
{emoji && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span
className={cn(
'text-4xl drop-shadow-lg select-none',
overlayTextClass,
)}
>
{emoji}
</span>
</div>
)}
</div>
{/* Color hex swatches */}
<div className="flex flex-wrap items-center gap-1.5 mt-2">
{colors.map((color, i) => (
<button
key={i}
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(color);
}}
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] font-mono text-muted-foreground hover:bg-secondary/60 transition-colors"
title={`Copy ${color}`}
>
<span
className="size-2.5 rounded-sm shrink-0 border border-border/40"
style={{ backgroundColor: color }}
/>
{color}
</button>
))}
</div>
</div>
);
}