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 ? (
+
))}
-
+
) : (
)}
-
-
+
) : 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({
@@ -1276,7 +1483,7 @@ export function OverviewImageGenerator({
composition={composition}
artworkUrl={artworkUrl}
character={character}
- role={role}
+ role={displayRole}
topColors={[topColorStart, topColorEnd]}
bottomColors={[bottomColorStart, bottomColorEnd]}
elementColors={[elementColorStart, elementColorEnd]}
@@ -1285,7 +1492,7 @@ export function OverviewImageGenerator({
nameCardCrop={nameCardCrop}
nameCardSize={nameCardSize}
elementIconUrls={elementIconUrls}
- captureRef={captureRef}
+ disclaimerFontSize={disclaimerFontSize}
/>
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`;