authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-12 19:11:47-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-12 19:11:47-03:00
log75d8b132cd0718d8cd703c97d02b143891d80022
treef8298d033bf99e0efd4d2265c4fdd1b0c50a12dd
parent03e135577cbf861c2948981601c070dab038ea11
signature Signed by PGP key B5690EEEBB952194

feat(web): tint image chips with their average color (#11468)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

1 files changed, 51 insertions(+), 2 deletions(-)

apps/web/src/components/contextChipParts.tsx+51-2
......@@ -1,6 +1,12 @@
11import type { PullRequestContextMetadata } from "@t3tools/contracts";
22import { CircleDashedIcon, FilmIcon, GitPullRequestIcon, ImageIcon } from "lucide-react";
3import type { ComponentProps, MouseEvent, ReactNode } from "react";
3import {
4 useState,
5 type ComponentProps,
6 type CSSProperties,
7 type MouseEvent,
8 type ReactNode,
9} from "react";
410
511import { cn } from "~/lib/utils";
612import { PierreEntryIcon } from "./chat/PierreEntryIcon";
......@@ -139,6 +145,34 @@ export function PullRequestChip(props: {
139145 );
140146}
141147
148/** Sample the loaded thumbnail once; transparent pixels should not darken its accent. */
149function averageImageColor(image: HTMLImageElement): string | undefined {
150 try {
151 const canvas = document.createElement("canvas");
152 canvas.width = canvas.height = 16;
153 const context = canvas.getContext("2d");
154 if (!context) return;
155 context.drawImage(image, 0, 0, 16, 16);
156 const { data } = context.getImageData(0, 0, 16, 16);
157 let red = 0;
158 let green = 0;
159 let blue = 0;
160 let alpha = 0;
161 for (let index = 0; index < data.length; index += 4) {
162 const weight = data[index + 3]!;
163 red += data[index]! * weight;
164 green += data[index + 1]! * weight;
165 blue += data[index + 2]! * weight;
166 alpha += weight;
167 }
168 if (alpha === 0) return;
169 return `rgb(${Math.round(red / alpha)} ${Math.round(green / alpha)} ${Math.round(blue / alpha)})`;
170 } catch {
171 // Cross-origin or unavailable pixels keep the default image tone and preview action.
172 return;
173 }
174}
175
142176export function ImageChipButton({
143177 name,
144178 previewUrl,
......@@ -146,6 +180,7 @@ export function ImageChipButton({
146180 labelClassName,
147181 size,
148182 suffix,
183 style,
149184 ...props
150185}: ComponentProps<"button"> & {
151186 name: string;
......@@ -155,6 +190,9 @@ export function ImageChipButton({
155190 size: string;
156191 suffix?: string | null;
157192}) {
193 const [sample, setSample] = useState<{ url: string; color: string | undefined }>();
194 const [corsFailedUrl, setCorsFailedUrl] = useState<string>();
195 const accent = sample?.url === previewUrl ? sample?.color : undefined;
158196 return (
159197 <Button
160198 variant="chip"
......@@ -165,10 +203,21 @@ export function ImageChipButton({
165203 "cursor-zoom-in",
166204 )}
167205 aria-label={`Image attachment, ${name}, ${size}`}
206 style={{ ...style, ...(accent ? { "--context-chip-accent": accent } : {}) } as CSSProperties}
168207 {...props}
169208 >
170209 {previewUrl ? (
171 <img src={previewUrl} alt="" className="size-3.5 shrink-0 rounded-sm object-cover" />
210 <img
211 key={previewUrl}
212 crossOrigin={corsFailedUrl === previewUrl ? undefined : "anonymous"}
213 src={previewUrl}
214 alt=""
215 className="size-3.5 shrink-0 rounded-sm object-cover"
216 onError={() => setCorsFailedUrl(previewUrl)}
217 onLoad={(event) =>
218 setSample({ url: previewUrl, color: averageImageColor(event.currentTarget) })
219 }
220 />
172221 ) : (
173222 <ImageIcon
174223 className={cn(