"use client"; import Image, { getImageProps } from "next/image"; import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent, } from "@dnd-kit/core"; import { arrayMove, sortableKeyboardCoordinates, SortableContext, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { CropIcon, DownloadIcon, GripVerticalIcon, ImageIcon, PlusIcon, RotateCcwIcon, RotateCwIcon, Trash2Icon, ZoomInIcon, ZoomOutIcon, } from "lucide-react"; import { useEffect, useRef, useState, type ChangeEvent, type Ref } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from "@/components/ui/card"; import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList, } from "@/components/ui/combobox"; import { Cropper, CropperCropArea, CropperDescription, CropperImage, } from "@/components/ui/cropper"; import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel, } from "@/components/ui/field"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Spinner } from "@/components/ui/spinner"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { elementIconFilter } from "@/lib/catalog/element"; import { rarityGradientClass } from "@/lib/catalog/rarity"; import { accentGradient, detectThemeColorsFromPixels, elementGlowColor, elementGradientColors, elementOrder, normalizeHex, overviewFileName, overviewRoles, overviewBlendModes, type OverviewBlendMode, weaponTypeIconPath, type OverviewRole, } from "@/lib/overview-image"; import { cn } from "@/lib/utils"; type Character = { key: string; name: string; aliases: string[]; element: string | null; rarity: number | null; weaponType: string | null; imageUrl: string; nameCardImageUrl: string | null; }; type PendingCrop = { url: string; target: "artwork" | "background"; }; type Composition = { backgroundUrl: string | null; fadeColor: string; fadeTint: number; auraUpOffset: number; }; type BlendLayer = { id: number; mode: OverviewBlendMode; amount: number; }; type CropArea = { x: number; y: number; width: number; height: number }; const blendModeItems: Array<{ value: OverviewBlendMode; label: string }> = [ ...overviewBlendModes, ]; const DISCLAIMER = "ไม่สามารถเอาไปเปรียบเทียบกับการคำนวณของ TC ท่านอื่นๆได้\nเพราะแต่ละคนใช้สแตทในการคำนวณที่ไม่เหมือนกัน\nและตารางที่เห็นอาจมีการเปลี่ยนแปลงได้ในอนาคต"; function OverviewPoster({ artworkUrl, character, role, topColors, bottomColors, elementColors, elementGradientAngle, blendLayers, nameCardCrop, nameCardSize, elementIconUrls, captureRef, composition, }: { artworkUrl: string | null; character: Character | null; role: OverviewRole; topColors: [string, string]; bottomColors: [string, string]; elementColors: [string, string]; elementGradientAngle: number; blendLayers: BlendLayer[]; nameCardCrop: CropArea | null; nameCardSize: { width: number; height: number } | null; elementIconUrls: Record; captureRef?: Ref; composition: Composition; }) { const weaponIcon = weaponTypeIconPath(character?.weaponType ?? null); const z = { character: 1, fade: 2, aura: 3, badge: 4, text: 5, } as const; const titleRef = useRef(null); const disclaimerRef = useRef(null); useEffect(() => { let cancelled = false; const fit = () => { if (cancelled || !titleRef.current || !disclaimerRef.current) return; const title = titleRef.current; const disclaimer = disclaimerRef.current; const width = title.parentElement!.getBoundingClientRect().width; title.style.fontSize = `${width / 10}px`; if (title.scrollWidth > width) { title.style.fontSize = `${((width / 10) * width) / title.scrollWidth}px`; } const textWidth = title.getBoundingClientRect().width; const context = document.createElement("canvas").getContext("2d"); if (!context) return; context.font = '100px "Genshin Overview"'; const longest = Math.max( ...DISCLAIMER.split("\n").map( (line) => context.measureText(line).width, ), ); disclaimer.style.width = `${textWidth}px`; disclaimer.style.fontSize = `${Math.min(width * 0.024, (textWidth * 100) / longest)}px`; }; const observer = new ResizeObserver(fit); if (titleRef.current?.parentElement) observer.observe(titleRef.current.parentElement); void document.fonts.ready.then(fit); return () => { cancelled = true; observer.disconnect(); }; }, [character?.name]); return (
{composition.backgroundUrl ? ( ) : character?.nameCardImageUrl ? ( nameCardCrop && nameCardSize ? ( ) : ( ) ) : null} {artworkUrl ? (
{[...blendLayers].reverse().map((layer) => ( ))}
) : (
)}
{weaponIcon ? ( ) : null} {role}
{character ? (

{character.name.toLocaleUpperCase("en")}

{elementOrder.map((element) => { const url = elementIconUrls[element]; const active = element === character.element; if (!url) return null; if (active) { const { props: maskImageProps } = getImageProps({ src: url, alt: "", width: 72, height: 72, }); return ( ); } return ( ); })}

{DISCLAIMER}

) : null}
); } function ColorField({ id, label, value, onChange, }: { id: string; label: string; value: string; onChange: (value: string) => void; }) { const invalid = !normalizeHex(value); return ( {label}
onChange(event.target.value.toUpperCase())} className="w-14 shrink-0 p-1" /> onChange(event.target.value)} />
{invalid ? กรุณาใส่สีรูปแบบ #RRGGBB : null}
); } function SortableBlendLayerRow({ layer, onUpdate, onRemove, }: { layer: BlendLayer; onUpdate: (patch: Partial>) => void; onRemove: () => void; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: layer.id }); const selectedMode = blendModeItems.find((mode) => mode.value === layer.mode); return (
{ if (value) onUpdate({ mode: value.value }); }} itemToStringLabel={(item) => item.label} itemToStringValue={(item) => item.value} filter={(item, query) => `${item.label} ${item.value}` .toLocaleLowerCase("en") .includes(query.trim().toLocaleLowerCase("en")) } > ไม่พบ Blend mode {(item) => ( {item.label} )} ความเข้ม {layer.amount}% onUpdate({ amount: Number(event.target.value) })} />
); } export function OverviewImageGenerator({ characters, elementIconUrls, }: { characters: Character[]; elementIconUrls: Record; }) { const captureRef = useRef(null); const [character, setCharacter] = useState(null); const [artworkUrl, setArtworkUrl] = useState(null); const [artworkError, setArtworkError] = useState(null); const [pendingCrop, setPendingCrop] = useState(null); const [cropArea, setCropArea] = useState(null); const [cropZoom, setCropZoom] = useState(1); const [nameCardCrop, setNameCardCrop] = useState(null); const [nameCardDraftCrop, setNameCardDraftCrop] = useState( null, ); const [nameCardSize, setNameCardSize] = useState<{ width: number; height: number; } | null>(null); const [nameCardCropOpen, setNameCardCropOpen] = useState(false); const [nameCardZoom, setNameCardZoom] = useState(1); const [role, setRole] = useState("Main DPS"); const [topColorStart, setTopColorStart] = useState("#E9A51A"); const [topColorEnd, setTopColorEnd] = useState("#FFD84A"); const [bottomColorStart, setBottomColorStart] = useState("#E9A51A"); const [bottomColorEnd, setBottomColorEnd] = useState("#FFD84A"); const [elementColorStart, setElementColorStart] = useState("#FFFFFF"); const [elementColorEnd, setElementColorEnd] = useState("#FFFFFF"); const [elementGradientAngle, setElementGradientAngle] = useState(101); const [blendLayers, setBlendLayers] = useState([ { id: 1, mode: "color-burn", amount: 100 }, ]); const nextBlendLayerId = useRef(2); const blendLayerSensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ); const [exporting, setExporting] = useState(false); const [backgroundUrl, setBackgroundUrl] = useState(null); const [fadeColor, setFadeColor] = useState("#0E0200"); const [fadeTint, setFadeTint] = useState(100); const [auraUpOffset, setAuraUpOffset] = useState(4); const composition: Composition = { backgroundUrl, fadeColor, fadeTint, auraUpOffset, }; useEffect( () => () => { if (backgroundUrl) URL.revokeObjectURL(backgroundUrl); }, [backgroundUrl], ); useEffect( () => () => { if (artworkUrl) URL.revokeObjectURL(artworkUrl); }, [artworkUrl], ); function detectArtworkTheme( source: CanvasImageSource, width: number, height: number, ) { const maxDimension = 64; const scale = Math.min(1, maxDimension / Math.max(width, height)); const canvas = document.createElement("canvas"); canvas.width = Math.max(1, Math.round(width * scale)); canvas.height = Math.max(1, Math.round(height * scale)); const context = canvas.getContext("2d"); if (!context) return; context.drawImage(source, 0, 0, width, height, 0, 0, canvas.width, canvas.height); const theme = detectThemeColorsFromPixels( context.getImageData(0, 0, canvas.width, canvas.height).data, canvas.width, canvas.height, ); setTopColorStart(theme.top[0]); setTopColorEnd(theme.top[1]); setBottomColorStart(theme.bottom[0]); setBottomColorEnd(theme.bottom[1]); } function addBlendLayer() { setBlendLayers((current) => [ ...current, { id: nextBlendLayerId.current++, mode: "normal", amount: 100, }, ]); } function updateBlendLayer(id: number, patch: Partial>) { setBlendLayers((current) => current.map((layer) => (layer.id === id ? { ...layer, ...patch } : layer)), ); } function removeBlendLayer(id: number) { setBlendLayers((current) => current.filter((layer) => layer.id !== id)); } function handleBlendLayerDragEnd({ active, over }: DragEndEvent) { if (!over || active.id === over.id) return; setBlendLayers((current) => { const oldIndex = current.findIndex( (layer) => String(layer.id) === String(active.id), ); const newIndex = current.findIndex( (layer) => String(layer.id) === String(over.id), ); if (oldIndex === -1 || newIndex === -1) return current; return arrayMove(current, oldIndex, newIndex); }); } useEffect(() => { const url = character?.nameCardImageUrl; if (!url) return; let cancelled = false; const image = new window.Image(); image.onload = () => { if (cancelled) return; const size = Math.min(image.naturalWidth, image.naturalHeight); setNameCardSize({ width: image.naturalWidth, height: image.naturalHeight, }); setNameCardCrop({ x: (image.naturalWidth - size) / 2, y: (image.naturalHeight - size) / 2, width: size, height: size, }); }; image.onerror = () => { if (!cancelled) toast.error("ไม่สามารถโหลดภาพ Namecard ได้"); }; image.src = url; return () => { cancelled = true; }; }, [character]); useEffect( () => () => { if (pendingCrop) URL.revokeObjectURL(pendingCrop.url); }, [pendingCrop], ); async function selectArtwork( event: ChangeEvent, target: "artwork" | "background" = "artwork", ) { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; const url = URL.createObjectURL(file); try { const image = await new Promise((resolve, reject) => { const image = new window.Image(); image.onload = () => resolve(image); image.onerror = reject; image.src = url; }); if (target === "artwork") { detectArtworkTheme(image, image.naturalWidth, image.naturalHeight); } setArtworkError(null); setCropArea(null); setCropZoom(1); setPendingCrop({ url, target }); } catch { URL.revokeObjectURL(url); setArtworkError("ไม่สามารถอ่านไฟล์ภาพนี้ได้"); } } async function applyCrop() { if (!pendingCrop || !cropArea) return; try { const image = await new Promise((resolve, reject) => { const candidate = new window.Image(); candidate.onload = () => resolve(candidate); candidate.onerror = reject; candidate.src = pendingCrop.url; }); const canvas = document.createElement("canvas"); canvas.width = Math.round(cropArea.width); canvas.height = Math.round(cropArea.height); const context = canvas.getContext("2d"); if (!context) throw new Error("Canvas is unavailable"); context.imageSmoothingEnabled = true; context.imageSmoothingQuality = "high"; context.drawImage( image, cropArea.x, cropArea.y, cropArea.width, cropArea.height, 0, 0, canvas.width, canvas.height, ); if (pendingCrop.target === "artwork") { detectArtworkTheme(canvas, canvas.width, canvas.height); } const blob = await new Promise((resolve, reject) => canvas.toBlob( (value) => value ? resolve(value) : reject(new Error("Crop failed")), "image/png", ), ); if (pendingCrop.target === "background") setBackgroundUrl(URL.createObjectURL(blob)); else setArtworkUrl(URL.createObjectURL(blob)); setPendingCrop(null); setArtworkError(null); } catch { setArtworkError("ไม่สามารถครอปไฟล์ภาพนี้ได้"); setPendingCrop(null); } } async function rotateArtwork(degrees: -90 | 90) { if (!pendingCrop) return; try { const image = await new Promise((resolve, reject) => { const candidate = new window.Image(); candidate.onload = () => resolve(candidate); candidate.onerror = reject; candidate.src = pendingCrop.url; }); const canvas = document.createElement("canvas"); canvas.width = image.naturalHeight; canvas.height = image.naturalWidth; const context = canvas.getContext("2d"); if (!context) throw new Error("Canvas is unavailable"); context.translate(canvas.width / 2, canvas.height / 2); context.rotate((degrees * Math.PI) / 180); context.drawImage( image, -image.naturalWidth / 2, -image.naturalHeight / 2, ); const blob = await new Promise((resolve, reject) => canvas.toBlob( (value) => value ? resolve(value) : reject(new Error("Rotation failed")), "image/png", ), ); setCropArea(null); setCropZoom(1); setPendingCrop({ url: URL.createObjectURL(blob), target: pendingCrop.target, }); } catch { toast.error("หมุนภาพไม่สำเร็จ กรุณาลองอีกครั้ง"); } } async function download() { const node = captureRef.current; if ( !node || !character || !artworkUrl || !normalizeHex(topColorStart) || !normalizeHex(topColorEnd) || !normalizeHex(bottomColorStart) || !normalizeHex(bottomColorEnd) || !normalizeHex(elementColorStart) || !normalizeHex(elementColorEnd) || !normalizeHex(fadeColor) ) return; setExporting(true); try { const { domToBlob, waitUntilLoad } = await import("modern-screenshot"); await Promise.all([document.fonts.ready, waitUntilLoad(node)]); const blob = await domToBlob(node, { backgroundColor: "#000000", type: "image/png", width: 1000, height: 1000, scale: 3, }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = overviewFileName(character.key, role); document.body.append(link); link.click(); link.remove(); window.setTimeout(() => URL.revokeObjectURL(url), 0); toast.success("ดาวน์โหลดภาพ Overview แล้ว"); } catch { toast.error("สร้างภาพไม่สำเร็จ กรุณาลองอีกครั้ง"); } finally { setExporting(false); } } const ready = Boolean( character && artworkUrl && normalizeHex(topColorStart) && normalizeHex(topColorEnd) && normalizeHex(bottomColorStart) && normalizeHex(bottomColorEnd) && normalizeHex(elementColorStart) && normalizeHex(elementColorEnd) && normalizeHex(fadeColor) && weaponTypeIconPath(character.weaponType), ); return (
ตั้งค่าภาพ ไฟล์ภาพจะใช้เฉพาะในเบราว์เซอร์นี้ ตัวละคร { if (value?.element !== character?.element) { const [start, end] = elementGradientColors(value?.element); setElementColorStart(start); setElementColorEnd(end); } setCharacter(value); setNameCardCrop(null); setNameCardSize(null); setNameCardCropOpen(false); }} itemToStringLabel={(item) => item.name} itemToStringValue={(item) => item.key} filter={(item, query) => [item.name, ...item.aliases] .join(" ") .toLocaleLowerCase("th") .includes(query.trim().toLocaleLowerCase("th")) } autoHighlight > ไม่พบตัวละคร {(item) => ( {item.name} )} ชื่อ ธาตุ และประเภทอาวุธจะมาจาก Catalog พื้นหลัง Namecard ใช้ Namecard ของตัวละครใต้ภาพโปร่งใส ภาพตัวละคร 1:1 selectArtwork(event)} /> ทุกภาพจะเปิดหน้าต่างให้ครอป ซูม และจัดตำแหน่งก่อนใช้งาน {artworkError ? {artworkError} : null} อัปโหลดพื้นหลัง selectArtwork(event, "background")} /> {backgroundUrl ? ( ) : null} ครอปและซูมพื้นหลังเป็นสี่เหลี่ยมก่อนใช้งาน ธีมและเอฟเฟกต์ สีขอบบนและล่างจะตรวจจากภาพตัวละครโดยอัตโนมัติเมื่ออัปโหลด ความเข้มสี Fade {fadeTint}% setFadeTint(Number(event.target.value))} /> เลื่อน Aura ด้านบนขึ้น {auraUpOffset}% setAuraUpOffset(Number(event.target.value)) } /> Role { const selected = value[0] as OverviewRole | undefined; if (selected) setRole(selected); }} variant="outline" className="flex-wrap justify-start" > {overviewRoles.map((value) => ( {value} ))} Blend mode layer.id)} strategy={verticalListSortingStrategy} >
{blendLayers.map((layer) => ( updateBlendLayer(layer.id, patch)} onRemove={() => removeBlendLayer(layer.id)} /> ))}
เพิ่มได้หลายแบบ และลากที่ไอคอนเพื่อจัดลำดับการผสมสี
ตัวอย่าง ภาพดาวน์โหลดจะใช้เลย์เอาต์เดียวกับตัวอย่าง
สีธีม สีขอบบนและล่างจะตรวจจากภาพตัวละครโดยอัตโนมัติเมื่ออัปโหลด และแก้ไขเองได้ มุมสีไล่ระดับธาตุ {elementGradientAngle}° setElementGradientAngle(Number(event.target.value)) } /> { if (!open) setPendingCrop(null); }} > ครอปภาพเป็น 1:1 เลื่อนตำแหน่งภาพให้ส่วนที่ต้องการอยู่ภายในกรอบสี่เหลี่ยม {pendingCrop ? (
ลากภาพหรือใช้ปุ่มลูกศรเพื่อเลือกพื้นที่ครอปสี่เหลี่ยม
ซูม
) : null}
ครอปพื้นหลัง Namecard ลากและซูม Namecard เพื่อเลือกพื้นที่ด้านหลังตัวละคร {character?.nameCardImageUrl ? (
ลากภาพหรือใช้ปุ่มลูกศรเพื่อเลือกพื้นหลังสี่เหลี่ยม ซูม
) : null}
); }