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