| ... | ... | @@ -1,6 +1,12 @@ |
| 1 | 1 | import type { PullRequestContextMetadata } from "@t3tools/contracts"; |
| 2 | 2 | import { CircleDashedIcon, FilmIcon, GitPullRequestIcon, ImageIcon } from "lucide-react"; |
| 3 | | import type { ComponentProps, MouseEvent, ReactNode } from "react"; |
| 3 | import { |
| 4 | useState, |
| 5 | type ComponentProps, |
| 6 | type CSSProperties, |
| 7 | type MouseEvent, |
| 8 | type ReactNode, |
| 9 | } from "react"; |
| 4 | 10 | |
| 5 | 11 | import { cn } from "~/lib/utils"; |
| 6 | 12 | import { PierreEntryIcon } from "./chat/PierreEntryIcon"; |
| ... | ... | @@ -139,6 +145,34 @@ export function PullRequestChip(props: { |
| 139 | 145 | ); |
| 140 | 146 | } |
| 141 | 147 | |
| 148 | /** Sample the loaded thumbnail once; transparent pixels should not darken its accent. */ |
| 149 | function 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 | |
| 142 | 176 | export function ImageChipButton({ |
| 143 | 177 | name, |
| 144 | 178 | previewUrl, |
| ... | ... | @@ -146,6 +180,7 @@ export function ImageChipButton({ |
| 146 | 180 | labelClassName, |
| 147 | 181 | size, |
| 148 | 182 | suffix, |
| 183 | style, |
| 149 | 184 | ...props |
| 150 | 185 | }: ComponentProps<"button"> & { |
| 151 | 186 | name: string; |
| ... | ... | @@ -155,6 +190,9 @@ export function ImageChipButton({ |
| 155 | 190 | size: string; |
| 156 | 191 | suffix?: string | null; |
| 157 | 192 | }) { |
| 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; |
| 158 | 196 | return ( |
| 159 | 197 | <Button |
| 160 | 198 | variant="chip" |
| ... | ... | @@ -165,10 +203,21 @@ export function ImageChipButton({ |
| 165 | 203 | "cursor-zoom-in", |
| 166 | 204 | )} |
| 167 | 205 | aria-label={`Image attachment, ${name}, ${size}`} |
| 206 | style={{ ...style, ...(accent ? { "--context-chip-accent": accent } : {}) } as CSSProperties} |
| 168 | 207 | {...props} |
| 169 | 208 | > |
| 170 | 209 | {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 | /> |
| 172 | 221 | ) : ( |
| 173 | 222 | <ImageIcon |
| 174 | 223 | className={cn( |