diff --git a/components/admin/overview-image-generator.tsx b/components/admin/overview-image-generator.tsx index 617e910..6a5300b 100644 --- a/components/admin/overview-image-generator.tsx +++ b/components/admin/overview-image-generator.tsx @@ -6,6 +6,7 @@ import { DndContext, KeyboardSensor, PointerSensor, + useDraggable, useSensor, useSensors, type DragEndEvent, @@ -30,7 +31,13 @@ import { ZoomInIcon, ZoomOutIcon, } from "lucide-react"; -import { useEffect, useRef, useState, type ChangeEvent, type Ref } from "react"; +import { + useEffect, + useRef, + useState, + type ChangeEvent, + type CSSProperties, +} from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -113,6 +120,8 @@ type Composition = { fadeColor: string; fadeTint: number; auraUpOffset: number; + characterOffset: Position; + fadeOffset: Position; }; type BlendLayer = { @@ -122,6 +131,8 @@ type BlendLayer = { }; 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, @@ -130,6 +141,45 @@ const blendModeItems: Array<{ value: OverviewBlendMode; label: string }> = [ const DISCLAIMER = "ไม่สามารถเอาไปเปรียบเทียบกับการคำนวณของ TC ท่านอื่นๆได้\nเพราะแต่ละคนใช้สแตทในการคำนวณที่ไม่เหมือนกัน\nและตารางที่เห็นอาจมีการเปลี่ยนแปลงได้ในอนาคต"; +function DraggablePosterLayer({ + id, + offset, + interactive, + className, + style, + children, +}: { + id: PosterLayer; + offset: Position; + 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, @@ -142,12 +192,15 @@ function OverviewPoster({ nameCardCrop, nameCardSize, elementIconUrls, - captureRef, composition, + disclaimerFontSize, + interactive = false, + activeLayer, + onLayerMove, }: { artworkUrl: string | null; character: Character | null; - role: OverviewRole; + role: string; topColors: [string, string]; bottomColors: [string, string]; elementColors: [string, string]; @@ -156,8 +209,11 @@ function OverviewPoster({ nameCardCrop: CropArea | null; nameCardSize: { width: number; height: number } | null; elementIconUrls: Record; - captureRef?: Ref; composition: Composition; + disclaimerFontSize: number | null; + interactive?: boolean; + activeLayer?: PosterLayer; + onLayerMove?: (layer: PosterLayer, delta: Position) => void; }) { const weaponIcon = weaponTypeIconPath(character?.weaponType ?? null); const z = { @@ -169,6 +225,20 @@ function OverviewPoster({ } as const; const titleRef = useRef(null); const disclaimerRef = 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; @@ -190,8 +260,12 @@ function OverviewPoster({ (line) => context.measureText(line).width, ), ); - disclaimer.style.width = `${textWidth}px`; - disclaimer.style.fontSize = `${Math.min(width * 0.024, (textWidth * 100) / longest)}px`; + disclaimer.style.width = "max-content"; + disclaimer.style.textAlign = "center"; + disclaimer.style.fontSize = + disclaimerFontSize === null + ? `${Math.min(width * 0.024, (textWidth * 100) / longest)}px` + : `${disclaimerFontSize}cqw`; }; const observer = new ResizeObserver(fit); if (titleRef.current?.parentElement) @@ -201,14 +275,15 @@ function OverviewPoster({ cancelled = true; observer.disconnect(); }; - }, [character?.name]); + }, [character?.name, disclaimerFontSize]); return ( -
+ +
{composition.backgroundUrl ? ( + ))} -
+ ) : (
)} -
-
+

{DISCLAIMER}

) : null} -
+ + ); } @@ -624,6 +717,7 @@ export function OverviewImageGenerator({ 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"); @@ -646,12 +740,26 @@ export function OverviewImageGenerator({ const [fadeColor, setFadeColor] = useState("#0E0200"); const [fadeTint, setFadeTint] = useState(100); const [auraUpOffset, setAuraUpOffset] = useState(4); + const [characterOffset, setCharacterOffset] = useState({ + x: 0, + y: 0, + }); + const [fadeOffset, setFadeOffset] = useState({ x: 0, y: 0 }); + const [activePosterLayer, setActivePosterLayer] = + useState("character"); + const [disclaimerFontSize, setDisclaimerFontSize] = useState( + null, + ); const composition: Composition = { backgroundUrl, fadeColor, fadeTint, auraUpOffset, + characterOffset, + fadeOffset, }; + const displayRole = + role === "Custom" ? customRoleText.trim() || "Custom" : role; useEffect( () => () => { if (backgroundUrl) URL.revokeObjectURL(backgroundUrl); @@ -724,6 +832,20 @@ export function OverviewImageGenerator({ }); } + 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({ x: 0, y: 0 }); + } + useEffect(() => { const url = character?.nameCardImageUrl; if (!url) return; @@ -906,7 +1028,7 @@ export function OverviewImageGenerator({ const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; - link.download = overviewFileName(character.key, role); + link.download = overviewFileName(character.key, displayRole); document.body.append(link); link.click(); link.remove(); @@ -1106,6 +1228,50 @@ export function OverviewImageGenerator({ } /> + + ตำแหน่งภาพ + + + เลือกเลเยอร์ใต้ตัวอย่าง แล้วลากในตัวอย่างเพื่อเลื่อนตำแหน่ง + + + + + ขนาด Disclaimer {disclaimerFontSize === null ? "ตามชื่ออัตโนมัติ" : `${disclaimerFontSize}cqw`} + +
+ + setDisclaimerFontSize(Number(event.target.value)) + } + /> + +
+ + ค่าเริ่มต้นจะปรับให้พอดีกับความกว้างชื่ออัตโนมัติ + +
Role + {role === "Custom" ? ( + + + ข้อความ Custom Role + + setCustomRoleText(event.target.value)} + /> + + ) : null} Blend mode + + เลือกเลเยอร์ที่จะเลื่อน + { + const selected = value[0] as PosterLayer | undefined; + if (selected) setActivePosterLayer(selected); + }} + variant="outline" + className="w-full" + > + + Char + + + Fade + + + + เลือกเลเยอร์แล้วลากตรงบริเวณตัวอย่างเพื่อเลื่อนตำแหน่ง + + @@ -1269,6 +1475,7 @@ export function OverviewImageGenerator({ diff --git a/lib/overview-image.ts b/lib/overview-image.ts index 77ee99b..aaa61e0 100644 --- a/lib/overview-image.ts +++ b/lib/overview-image.ts @@ -1,4 +1,4 @@ -export const overviewRoles = ["Main DPS", "Sub DPS", "Support"] as const; +export const overviewRoles = ["Main DPS", "Sub DPS", "Support", "Custom"] as const; export type OverviewRole = (typeof overviewRoles)[number]; @@ -215,7 +215,7 @@ export function weaponTypeIconPath(weaponType: string | null): string | null { return fileName ? `/icon/weapon-types/${fileName}` : null; } -export function overviewFileName(characterKey: string, role: OverviewRole): string { +export function overviewFileName(characterKey: string, role: string): string { const safeKey = characterKey.replace(/[^\p{L}\p{N}._-]+/gu, "-"); const safeRole = role.toLowerCase().replaceAll(" ", "-"); return `overview-${safeKey}-${safeRole}.png`;