Render webxdc embeds as a tilted, color-tinted cartridge
Replace the plain launch card with a Game Boy-style cartridge (using public/cartridge.png) whose label region centers the app icon. The whole cartridge scales as one image and reacts to the pointer with the existing useCardTilt 3D effect. The cartridge is tinted by the icon's dominant color: a new useDominantColor hook samples the icon in an off-screen canvas, picks the heaviest hue bucket, and exposes it as HSL. A mask-image layer masked to the cartridge silhouette blends the color over the grayscale PNG with mix-blend-mode: color, preserving the shading. Grayscale or CORS-blocked icons fall back to the original gray cartridge. The app name moves out from under the cartridge and into the description card in FileMetadataContent — rendered larger and bolder above the note's content — while WebxdcEmbed still renders its own name card when a parent isn't providing one (e.g. the kind 1 imeta path).
This commit is contained in:
Generated
+2
-27
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"version": "2.10.4",
|
||||
"version": "2.10.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ditto",
|
||||
"version": "2.10.4",
|
||||
"version": "2.10.5",
|
||||
"dependencies": {
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/core": "^8.1.0",
|
||||
@@ -5768,7 +5768,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5782,7 +5781,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5796,7 +5794,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5810,7 +5807,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5824,7 +5820,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5838,7 +5833,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5852,7 +5846,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5866,7 +5859,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5880,7 +5872,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5894,7 +5885,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5908,7 +5898,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5922,7 +5911,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5936,7 +5924,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5950,7 +5937,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5964,7 +5950,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5978,7 +5963,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5992,7 +5976,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6006,7 +5989,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6020,7 +6002,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6034,7 +6015,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6048,7 +6028,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6062,7 +6041,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6076,7 +6054,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6090,7 +6067,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6104,7 +6080,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 245 KiB |
@@ -11,6 +11,7 @@ import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Extract the first value of a tag by name. */
|
||||
function getTag(tags: string[][], name: string): string | undefined {
|
||||
@@ -25,10 +26,18 @@ function formatBytes(bytes: number): string {
|
||||
}
|
||||
|
||||
/** YouTube-style description card rendered below media. */
|
||||
function DescriptionCard({ text }: { text: string }) {
|
||||
function DescriptionCard({ title, text }: { title?: string; text?: string }) {
|
||||
if (!title && !text) return null;
|
||||
return (
|
||||
<div className="mt-2.5 rounded-xl bg-secondary/50 px-3.5 py-2.5">
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{text}</p>
|
||||
{title && (
|
||||
<p className="text-base font-semibold text-foreground break-words">{title}</p>
|
||||
)}
|
||||
{text && (
|
||||
<p className={cn('text-sm leading-relaxed text-muted-foreground break-words', title && 'mt-1')}>
|
||||
{text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -101,10 +110,10 @@ export function FileMetadataContent({ event, compact }: FileMetadataContentProps
|
||||
<WebxdcEmbed
|
||||
url={url}
|
||||
uuid={webxdcId}
|
||||
name={appName}
|
||||
icon={thumb}
|
||||
showNameCard={false}
|
||||
/>
|
||||
{description && <DescriptionCard text={description} />}
|
||||
<DescriptionCard title={appName} text={description} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+128
-28
@@ -1,11 +1,13 @@
|
||||
import { useState, useRef, useCallback, useEffect, forwardRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Blocks, Play, X, Gamepad2 } from 'lucide-react';
|
||||
import { Blocks, X, Gamepad2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Webxdc, type WebxdcHandle } from '@/components/Webxdc';
|
||||
import { GameControls } from '@/components/GameControls';
|
||||
import { useCenterColumn } from '@/contexts/LayoutContext';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useCardTilt } from '@/hooks/useCardTilt';
|
||||
import { useDominantColor } from '@/hooks/useDominantColor';
|
||||
import { useWebxdc } from '@/hooks/useWebxdc';
|
||||
import { deriveIframeSubdomain } from '@/lib/iframeSubdomain';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -19,6 +21,12 @@ export interface WebxdcEmbedProps {
|
||||
name?: string;
|
||||
/** App icon URL. */
|
||||
icon?: string;
|
||||
/**
|
||||
* If true, renders a description-style card below the cartridge with the
|
||||
* app name. Defaults to true. Set to false when a parent component is
|
||||
* rendering its own card (e.g. `FileMetadataContent`).
|
||||
*/
|
||||
showNameCard?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -51,7 +59,7 @@ function useElementRect(el: HTMLElement | null): Rect | null {
|
||||
* then opens a fullscreen panel (covering the center column on desktop, the
|
||||
* full screen on mobile) when the user clicks Play — matching the nsite UX.
|
||||
*/
|
||||
export function WebxdcEmbed({ url, uuid, name, icon, className }: WebxdcEmbedProps) {
|
||||
export function WebxdcEmbed({ url, uuid, name, icon, showNameCard = true, className }: WebxdcEmbedProps) {
|
||||
const [launched, setLaunched] = useState(false);
|
||||
const [showGamepad, setShowGamepad] = useState(false);
|
||||
const webxdcHandleRef = useRef<WebxdcHandle>(null);
|
||||
@@ -77,36 +85,22 @@ export function WebxdcEmbed({ url, uuid, name, icon, className }: WebxdcEmbedPro
|
||||
}, []);
|
||||
|
||||
if (!launched) {
|
||||
const appName = name ?? 'Webxdc App';
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-3 rounded-2xl border border-border bg-secondary/30 overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
className={cn('mt-3 flex flex-col items-center', className)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-8 px-6">
|
||||
{icon ? (
|
||||
<img
|
||||
src={icon}
|
||||
alt={name ?? 'Webxdc App'}
|
||||
className="size-14 rounded-2xl object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center size-14 rounded-2xl bg-primary/10">
|
||||
<Blocks className="size-7 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm font-medium">{name ?? 'Webxdc App'}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setLaunched(true)}
|
||||
className="rounded-full gap-2"
|
||||
>
|
||||
<Play className="size-4" />
|
||||
Launch App
|
||||
</Button>
|
||||
</div>
|
||||
<WebxdcCartridgeButton
|
||||
appName={appName}
|
||||
icon={icon}
|
||||
onLaunch={() => setLaunched(true)}
|
||||
/>
|
||||
{showNameCard && (
|
||||
<div className="mt-2.5 w-full max-w-sm rounded-xl bg-secondary/50 px-3.5 py-2.5">
|
||||
<p className="text-base font-semibold text-foreground break-words">{appName}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -190,6 +184,112 @@ export function WebxdcEmbed({ url, uuid, name, icon, className }: WebxdcEmbedPro
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive cartridge button with a 3D mouse-tilt effect. The tilt hook
|
||||
* needs to own the pointer events on a `<div>`, so the button sits inside
|
||||
* the tilted wrapper rather than the other way around.
|
||||
*/
|
||||
function WebxdcCartridgeButton({
|
||||
appName,
|
||||
icon,
|
||||
onLaunch,
|
||||
}: {
|
||||
appName: string;
|
||||
icon?: string;
|
||||
onLaunch: () => void;
|
||||
}) {
|
||||
// Matches the subtle feel used by encrypted letters (18° max, 1.03x scale).
|
||||
const tilt = useCardTilt(18, 1.03, 800);
|
||||
const dominant = useDominantColor(icon);
|
||||
|
||||
// Boost saturation + keep mid-lightness so the tint reads vividly when
|
||||
// blended with the cartridge's mid-gray shading via `mix-blend-mode: color`.
|
||||
const tintColor = dominant
|
||||
? `hsl(${dominant.h.toFixed(1)}, ${Math.min(100, Math.max(70, dominant.s * 100 + 20)).toFixed(1)}%, 50%)`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={tilt.ref}
|
||||
style={{
|
||||
...tilt.style,
|
||||
// Allow vertical page scrolling to still work on touch — tilt is a
|
||||
// bonus, not the primary interaction (tap to launch is).
|
||||
touchAction: 'pan-y',
|
||||
}}
|
||||
onPointerDown={tilt.onPointerDown}
|
||||
onPointerMove={tilt.onPointerMove}
|
||||
onPointerUp={tilt.onPointerUp}
|
||||
onPointerLeave={tilt.onPointerLeave}
|
||||
className="w-full max-w-sm"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLaunch}
|
||||
aria-label={`Launch ${appName}`}
|
||||
className={cn(
|
||||
'relative block w-full bg-transparent p-0 border-0',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded-xl',
|
||||
// Create an isolated stacking context so the tint's mix-blend-mode
|
||||
// only blends within this button, not with whatever is behind it.
|
||||
'isolate',
|
||||
)}
|
||||
>
|
||||
{/* Cartridge background image establishes aspect ratio; icon is absolutely positioned over the label */}
|
||||
<img
|
||||
src="/cartridge.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="w-full h-auto block select-none pointer-events-none drop-shadow-md"
|
||||
draggable={false}
|
||||
/>
|
||||
{/* Color tint layer — masked to cartridge silhouette, blended with the
|
||||
grayscale PNG beneath to colorize it while preserving shading. */}
|
||||
{tintColor && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
backgroundColor: tintColor,
|
||||
WebkitMaskImage: 'url(/cartridge.png)',
|
||||
maskImage: 'url(/cartridge.png)',
|
||||
WebkitMaskSize: '100% 100%',
|
||||
maskSize: '100% 100%',
|
||||
WebkitMaskRepeat: 'no-repeat',
|
||||
maskRepeat: 'no-repeat',
|
||||
mixBlendMode: 'color',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Label region — coordinates match the inset rectangle in cartridge.png (1024x1024) */}
|
||||
<div
|
||||
className="absolute flex items-center justify-center"
|
||||
style={{
|
||||
left: '21.48%',
|
||||
top: '29.66%',
|
||||
width: '57.32%',
|
||||
height: '45.99%',
|
||||
}}
|
||||
>
|
||||
{icon ? (
|
||||
<img
|
||||
src={icon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="w-[70%] aspect-square rounded-[12%] object-cover drop-shadow-md"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-[70%] aspect-square rounded-[12%] bg-primary/15 flex items-center justify-center drop-shadow-md">
|
||||
<Blocks className="w-1/2 h-1/2 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner component that renders the actual webxdc iframe.
|
||||
* Separated so the useWebxdc hook only runs when the app is launched.
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/** RGB triple returned by the hook, or null if extraction failed. */
|
||||
export interface DominantColor {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
/** HSL representation for easy tinting. */
|
||||
h: number;
|
||||
s: number;
|
||||
l: number;
|
||||
}
|
||||
|
||||
/** In-memory cache so repeat renders of the same icon don't re-sample. */
|
||||
const cache = new Map<string, DominantColor | null>();
|
||||
|
||||
/** Downscaled canvas edge for sampling — keeps cost tiny. */
|
||||
const SAMPLE_SIZE = 32;
|
||||
|
||||
/** Pixels with alpha below this are skipped. */
|
||||
const ALPHA_THRESHOLD = 128;
|
||||
|
||||
/** Pixels with very low saturation (near gray/white/black) are skipped. */
|
||||
const MIN_SATURATION = 0.15;
|
||||
|
||||
/** Pixels this close to pure white or black are skipped. */
|
||||
const L_EDGE = 0.08;
|
||||
|
||||
function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
|
||||
const rn = r / 255, gn = g / 255, bn = b / 255;
|
||||
const max = Math.max(rn, gn, bn);
|
||||
const min = Math.min(rn, gn, bn);
|
||||
const l = (max + min) / 2;
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case rn: h = (gn - bn) / d + (gn < bn ? 6 : 0); break;
|
||||
case gn: h = (bn - rn) / d + 2; break;
|
||||
case bn: h = (rn - gn) / d + 4; break;
|
||||
}
|
||||
h /= 6;
|
||||
}
|
||||
return { h: h * 360, s, l };
|
||||
}
|
||||
|
||||
function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
|
||||
const hn = h / 360;
|
||||
if (s === 0) {
|
||||
const v = Math.round(l * 255);
|
||||
return { r: v, g: v, b: v };
|
||||
}
|
||||
const hue2rgb = (p: number, q: number, t: number) => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
return {
|
||||
r: Math.round(hue2rgb(p, q, hn + 1 / 3) * 255),
|
||||
g: Math.round(hue2rgb(p, q, hn) * 255),
|
||||
b: Math.round(hue2rgb(p, q, hn - 1 / 3) * 255),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a dominant hue from an image by sampling pixels in a downscaled
|
||||
* canvas and averaging the chromatic ones (skipping transparent, near-gray,
|
||||
* near-white, and near-black pixels).
|
||||
*
|
||||
* Returns `null` if the image can't be loaded (CORS failure) or has no
|
||||
* discernible dominant color (e.g. pure-grayscale icon).
|
||||
*/
|
||||
export function useDominantColor(url: string | undefined): DominantColor | null {
|
||||
const [color, setColor] = useState<DominantColor | null>(() => (url && cache.has(url) ? cache.get(url)! : null));
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) { setColor(null); return; }
|
||||
if (cache.has(url)) { setColor(cache.get(url)!); return; }
|
||||
|
||||
let cancelled = false;
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
|
||||
img.onload = () => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = SAMPLE_SIZE;
|
||||
canvas.height = SAMPLE_SIZE;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) { cache.set(url, null); setColor(null); return; }
|
||||
|
||||
ctx.drawImage(img, 0, 0, SAMPLE_SIZE, SAMPLE_SIZE);
|
||||
const { data } = ctx.getImageData(0, 0, SAMPLE_SIZE, SAMPLE_SIZE);
|
||||
|
||||
// Hue histogram (18 buckets × 20°), weighted by saturation × chroma-ish.
|
||||
const BUCKETS = 18;
|
||||
const weights = new Float32Array(BUCKETS);
|
||||
const sSum = new Float32Array(BUCKETS);
|
||||
const lSum = new Float32Array(BUCKETS);
|
||||
const counts = new Uint32Array(BUCKETS);
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const a = data[i + 3];
|
||||
if (a < ALPHA_THRESHOLD) continue;
|
||||
const r = data[i], g = data[i + 1], b = data[i + 2];
|
||||
const { h, s, l } = rgbToHsl(r, g, b);
|
||||
if (s < MIN_SATURATION) continue;
|
||||
if (l < L_EDGE || l > 1 - L_EDGE) continue;
|
||||
const bucket = Math.min(BUCKETS - 1, Math.floor((h / 360) * BUCKETS));
|
||||
// Weight by saturation so vivid pixels dominate over muted ones.
|
||||
const w = s;
|
||||
weights[bucket] += w;
|
||||
sSum[bucket] += s * w;
|
||||
lSum[bucket] += l * w;
|
||||
counts[bucket] += 1;
|
||||
}
|
||||
|
||||
// Find the bucket with the highest total weight.
|
||||
let best = -1, bestWeight = 0;
|
||||
for (let i = 0; i < BUCKETS; i++) {
|
||||
if (weights[i] > bestWeight) { bestWeight = weights[i]; best = i; }
|
||||
}
|
||||
|
||||
if (best < 0 || counts[best] < 4) {
|
||||
cache.set(url, null);
|
||||
setColor(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bucket midpoint hue, saturation/lightness averaged within the bucket.
|
||||
const h = (best + 0.5) * (360 / BUCKETS);
|
||||
const s = sSum[best] / weights[best];
|
||||
const l = lSum[best] / weights[best];
|
||||
const rgb = hslToRgb(h, s, l);
|
||||
const result: DominantColor = { ...rgb, h, s, l };
|
||||
cache.set(url, result);
|
||||
setColor(result);
|
||||
} catch {
|
||||
// Canvas taint (CORS) or other failure — fall back to null.
|
||||
cache.set(url, null);
|
||||
setColor(null);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
if (cancelled) return;
|
||||
cache.set(url, null);
|
||||
setColor(null);
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [url]);
|
||||
|
||||
return color;
|
||||
}
|
||||
Reference in New Issue
Block a user