"use client"; import Image, { getImageProps } from "next/image"; import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useDraggable, 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, useCallback, useRef, useState, type ChangeEvent, type CSSProperties, } 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, FuzzyCombobox, 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 { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { ColorPicker, ColorPickerEyeDropper, ColorPickerHue, ColorPickerSelection, } from "@/components/ui/color-picker"; import { Slider } from "@/components/ui/slider"; 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; characterScale: number; characterOffset: Position; fadeOffset: Position; }; type BlendLayer = { id: number; mode: OverviewBlendMode; amount: number; }; type CropArea = { x: number; y: number; width: number; height: number }; type Position = { x: number; y: number }; type PosterLayer = "character" | "fade"; const blendModeItems: Array<{ value: OverviewBlendMode; label: string }> = [ ...overviewBlendModes, ]; const DISCLAIMER = "ไม่สามารถเอาไปเปรียบเทียบกับการคำนวณของ TC ท่านอื่นๆได้\nเพราะแต่ละคนใช้สแตทในการคำนวณที่ไม่เหมือนกัน\nและตารางที่เห็นอาจมีการเปลี่ยนแปลงได้ในอนาคต"; const DEFAULT_FADE_OFFSET: Position = { x: 0, y: 2 }; function DraggablePosterLayer({ id, offset, scale = 1, interactive, className, style, children, }: { id: PosterLayer; offset: Position; scale?: number; interactive: boolean; className?: string; style?: CSSProperties; children: React.ReactNode; }) { const { attributes, listeners, setNodeRef, transform } = useDraggable({ id, disabled: !interactive, }); const temporaryTransform = transform ? ` translate3d(${transform.x}px, ${transform.y}px, 0)` : ""; const dragProps = interactive ? { ...attributes, ...listeners } : {}; return (
{children}
); } function OverviewPoster({ artworkUrl, character, role, topColors, bottomColors, elementColors, elementGradientAngle, blendLayers, nameCardCrop, nameCardSize, elementIconUrls, composition, interactive = false, activeLayer, onLayerMove, }: { artworkUrl: string | null; character: Character | null; role: string; topColors: [string, string]; bottomColors: [string, string]; elementColors: [string, string]; elementGradientAngle: number; blendLayers: BlendLayer[]; nameCardCrop: CropArea | null; nameCardSize: { width: number; height: number } | null; elementIconUrls: Record; composition: Composition; interactive?: boolean; activeLayer?: PosterLayer; onLayerMove?: (layer: PosterLayer, delta: Position) => void; }) { 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 posterRef = useRef(null); const posterSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), ); function handlePosterDragEnd({ active, delta }: DragEndEvent) { if (!interactive || !onLayerMove) return; const size = posterRef.current?.getBoundingClientRect().width; if (!size || (active.id !== "character" && active.id !== "fade")) return; onLayerMove(active.id, { x: (delta.x / size) * 100, y: (delta.y / size) * 100, }); } useEffect(() => { let cancelled = false; const fit = () => { if (cancelled || !titleRef.current) return; const title = titleRef.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 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); const handlePickerChange = useCallback( (color: unknown) => { if (!Array.isArray(color)) return; const hex = color .slice(0, 3) .map((channel) => Math.round(channel).toString(16).padStart(2, "0")) .join(""); onChange(`#${hex.toUpperCase()}`); }, [onChange], ); return ( {label}
} /> 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: typeof value === "number" ? value : value[0] ?? 0, }) } />
); } 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 [customRoleText, setCustomRoleText] = useState(""); 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(0); const [auraUpOffset, setAuraUpOffset] = useState(4); const [characterScale, setCharacterScale] = useState(100); const [characterOffset, setCharacterOffset] = useState({ x: 0, y: 0, }); const [fadeOffset, setFadeOffset] = useState(DEFAULT_FADE_OFFSET); const composition: Composition = { backgroundUrl, fadeColor, fadeTint, auraUpOffset, characterScale: characterScale / 100, characterOffset, fadeOffset, }; const displayRole = role === "Custom" ? customRoleText.trim() || "Custom" : role; 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); }); } function movePosterLayer(layer: PosterLayer, delta: Position) { const update = (current: Position): Position => ({ x: Number((current.x + delta.x).toFixed(2)), y: Number((current.y + delta.y).toFixed(2)), }); if (layer === "character") setCharacterOffset(update); else setFadeOffset(update); } function resetPosterPositions() { setCharacterOffset({ x: 0, y: 0 }); setFadeOffset(DEFAULT_FADE_OFFSET); } 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); setCharacterScale(100); setArtworkUrl(url); setArtworkError(null); return; } 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, displayRole); 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} searchNames={(item) => [item.name, ...(item.aliases ?? [])]} autoHighlight > ไม่พบตัวละคร {(item) => ( {item.name} )} ชื่อ ธาตุ และประเภทอาวุธจะมาจาก Catalog พื้นหลัง Namecard ใช้ Namecard ของตัวละครใต้ภาพโปร่งใส ภาพตัวละคร 1:1 selectArtwork(event)} /> ใช้ภาพเต็มโดยไม่ครอป และสามารถลากเพื่อจัดตำแหน่งในตัวอย่างได้ {artworkError ? {artworkError} : null} ซูมภาพตัวละคร {characterScale}% setCharacterScale( typeof value === "number" ? value : value[0] ?? 100, ) } /> อัปโหลดพื้นหลัง selectArtwork(event, "background")} /> {backgroundUrl ? ( ) : null} ครอปและซูมพื้นหลังเป็นสี่เหลี่ยมก่อนใช้งาน ธีมและเอฟเฟกต์ สีขอบบนและล่างจะตรวจจากภาพตัวละครโดยอัตโนมัติเมื่ออัปโหลด ความเข้มสี Fade {fadeTint}% setFadeTint(typeof value === "number" ? value : value[0] ?? 0) } /> เลื่อน Aura ด้านบนขึ้น {auraUpOffset}% setAuraUpOffset( typeof value === "number" ? value : value[0] ?? 0, ) } /> ตำแหน่งภาพ เลือกเลเยอร์ใต้ตัวอย่าง แล้วลากในตัวอย่างเพื่อเลื่อนตำแหน่ง Role { const selected = value[0] as OverviewRole | undefined; if (selected) setRole(selected); }} variant="outline" className="flex-wrap justify-start" > {overviewRoles.map((value) => ( {value} ))} {role === "Custom" ? ( ข้อความ Custom Role setCustomRoleText(event.target.value)} /> ) : null} Blend mode layer.id)} strategy={verticalListSortingStrategy} >
{blendLayers.map((layer) => ( updateBlendLayer(layer.id, patch)} onRemove={() => removeBlendLayer(layer.id)} /> ))}
เพิ่มได้หลายแบบ และลากที่ไอคอนเพื่อจัดลำดับการผสมสี
ตัวอย่าง ภาพดาวน์โหลดจะใช้เลย์เอาต์เดียวกับตัวอย่าง
ตำแหน่ง Fade แนวตั้ง {fadeOffset.y}% setFadeOffset((current) => ({ ...current, y: typeof value === "number" ? value : value[0] ?? 2, })) } /> ลากในตัวอย่างเพื่อเลื่อนตัวละคร และใช้แถบนี้เพื่อเลื่อน Fade
สีธีม สีขอบบนและล่างจะตรวจจากภาพตัวละครโดยอัตโนมัติเมื่ออัปโหลด และแก้ไขเองได้ มุมสีไล่ระดับธาตุ {elementGradientAngle}° setElementGradientAngle( typeof value === "number" ? value : value[0] ?? 0, ) } /> { if (!open) setPendingCrop(null); }} > ครอปภาพเป็น 1:1 เลื่อนตำแหน่งภาพให้ส่วนที่ต้องการอยู่ภายในกรอบสี่เหลี่ยม {pendingCrop ? (
ลากภาพหรือใช้ปุ่มลูกศรเพื่อเลือกพื้นที่ครอปสี่เหลี่ยม
ซูม
) : null}
ครอปพื้นหลัง Namecard ลากและซูม Namecard เพื่อเลือกพื้นที่ด้านหลังตัวละคร {character?.nameCardImageUrl ? (
ลากภาพหรือใช้ปุ่มลูกศรเพื่อเลือกพื้นหลังสี่เหลี่ยม ซูม
) : null}
); }